feat: improve subject conversion composer

This commit is contained in:
2026-05-20 16:52:31 +08:00
parent 5ac48749df
commit b9c5511128
7 changed files with 273 additions and 52 deletions

View File

@@ -291,8 +291,10 @@ export default function Home() {
updateJobInList(updated)
setActiveJobId((prev) => prev ?? updated.id)
toast.success(`已加帧 @ ${t.toFixed(1)}s · 共 ${updated.frames.length}`)
return updated
} catch (e) {
toast.error("加帧失败:" + (e instanceof Error ? e.message : String(e)))
return undefined
}
}, [updateJobInList])

View File

@@ -1,6 +1,6 @@
"use client"
import { type MouseEvent as ReactMouseEvent, type ReactNode, type RefObject, useEffect, useMemo, useRef, useState } from "react"
import { type DragEvent as ReactDragEvent, type MouseEvent as ReactMouseEvent, type ReactNode, type RefObject, useEffect, useMemo, useRef, useState } from "react"
import { createPortal } from "react-dom"
import {
AlertTriangle, BookOpen, Check, ChevronDown, Circle, Film, FileText, Image as ImageIcon, Info, Link2, Loader2,
@@ -66,6 +66,7 @@ import {
subjectTemplateImageUrl,
updateElement,
updateStoryboard,
uploadReferenceFrame,
uploadStoryboardAsset,
translateText,
videoUrl,
@@ -2688,7 +2689,7 @@ function AudioIntakePanel({
selectedFrames: Set<number>
onToggleFrame: (idx: number) => void
onJobUpdate: (job: Job) => void
onAddFrame?: (jobId: string, t: number) => Promise<void> | void
onAddFrame?: (jobId: string, t: number) => Promise<Job | void> | Job | void
onDeleteFrame?: (jobId: string, idx: number) => Promise<void> | void
runtimeModels?: RuntimeModels
}) {
@@ -2839,17 +2840,21 @@ function AudioIntakePanel({
}
const addFilmstripFrame = async (time: number) => {
if (!job || !onAddFrame) return
if (!job || !onAddFrame) return null
const next = clampNumber(time, 0, timelineDuration)
const duplicate = frames.find((frame) => Math.abs(frame.timestamp - next) < 0.45)
if (duplicate) {
toast.warning(`附近已有关键帧:${duplicate.timestamp.toFixed(1)}s`)
return
return duplicate
}
setFilmstripBusyTime(next)
try {
await onAddFrame(job.id, next)
const known = new Set(frames.map((frame) => frame.index))
const updated = await onAddFrame(job.id, next)
toast.success(`已加入关键帧:${next.toFixed(1)}s`)
const updatedJob = updated && typeof updated === "object" && "frames" in updated ? updated : null
const added = updatedJob?.frames.find((frame) => !known.has(frame.index) && Math.abs(frame.timestamp - next) < 0.45) ?? null
return added
} finally {
setFilmstripBusyTime(null)
setFilmstripDragTime(null)
@@ -2999,7 +3004,7 @@ function AudioIntakePanel({
onJobUpdate={onJobUpdate}
runtimeModels={runtimeModels}
filmstripDragging={filmstripDragTime !== null}
onDropFilmstripFrame={(time) => void addFilmstripFrame(time)}
onDropFilmstripFrame={(time) => addFilmstripFrame(time)}
/>
</div>
</div>
@@ -3305,10 +3310,12 @@ function SourceSubjectPipeline({
onJobUpdate: (job: Job) => void
runtimeModels?: RuntimeModels
filmstripDragging?: boolean
onDropFilmstripFrame?: (time: number) => void
onDropFilmstripFrame?: (time: number) => Promise<KeyFrame | null> | KeyFrame | null | void
}) {
const [referenceDropActive, setReferenceDropActive] = useState(false)
const [agentDropActive, setAgentDropActive] = useState(false)
const [referenceFrameDragging, setReferenceFrameDragging] = useState(false)
const [agentReferenceUploadBusy, setAgentReferenceUploadBusy] = useState(false)
const [reconstructionDirections, setReconstructionDirections] = useState<Record<SubjectReconstructionMode, string>>(() => ({ ...DEFAULT_RECONSTRUCTION_DIRECTIONS }))
const [subjectModelBundle, setSubjectModelBundle] = useState<SubjectModelBundle>(() => job.subject_agent?.model_bundle ?? "gpt")
const [agentReferenceFrameIndices, setAgentReferenceFrameIndices] = useState<number[]>(() => job.subject_agent?.source_frame_indices ?? [])
@@ -3636,15 +3643,30 @@ function SourceSubjectPipeline({
}
}
const mergeAgentReferenceIndices = (current: number[], incoming: number[]) => {
let replaced = false
const next = [...current]
for (const index of incoming) {
const numericIndex = Number(index)
if (!Number.isFinite(numericIndex) || next.includes(numericIndex)) continue
next.push(numericIndex)
while (next.length > RECONSTRUCTION_FRAME_LIMIT) {
next.shift()
replaced = true
}
}
return { next, replaced }
}
const addAgentReferenceFrame = (frame: KeyFrame) => {
setAgentReferenceFrameIndices((current) => {
if (current.includes(frame.index)) {
toast.info("这张参考帧已经在转换层里。")
return current
}
const next = current.length >= RECONSTRUCTION_FRAME_LIMIT ? [...current.slice(1), frame.index] : [...current, frame.index]
if (current.length >= RECONSTRUCTION_FRAME_LIMIT) {
toast.warning(`最多保留 ${RECONSTRUCTION_FRAME_LIMIT} 张参考,已替换为最近拖入的组合。`)
const { next, replaced } = mergeAgentReferenceIndices(current, [frame.index])
if (replaced) {
toast.warning(`最多保留 ${RECONSTRUCTION_FRAME_LIMIT} 张参考,已替换为最近拖入的组合。`)
} else {
toast.info(`已加入转换层:${frame.timestamp.toFixed(1)}s。`)
}
@@ -3652,10 +3674,109 @@ function SourceSubjectPipeline({
})
}
const addAgentReferenceIndices = (indices: number[], notice = "已加入转换层") => {
if (!indices.length) return
setAgentReferenceFrameIndices((current) => {
const { next, replaced } = mergeAgentReferenceIndices(current, indices)
if (next.length === current.length && next.every((item, idx) => item === current[idx])) {
toast.info("这些参考图已经在转换层里。")
return current
}
if (replaced) {
toast.warning(`最多保留 ${RECONSTRUCTION_FRAME_LIMIT} 张参考图,已保留最近加入的组合。`)
} else {
toast.success(`${notice}${indices.length} 张。`)
}
return next
})
}
const removeAgentReferenceFrame = (frameIndex: number) => {
setAgentReferenceFrameIndices((current) => current.filter((index) => index !== frameIndex))
}
const transferHasAgentReference = (transfer: DataTransfer) => {
const types = Array.from(transfer.types || [])
return (
types.includes(SOURCE_KEYFRAME_DRAG_TYPE) ||
types.includes(FILMSTRIP_DRAG_TYPE) ||
types.includes("Files")
)
}
const handleAgentReferenceDragEnter = (event: ReactDragEvent<HTMLElement>) => {
if (!transferHasAgentReference(event.dataTransfer)) return
event.preventDefault()
setAgentDropActive(true)
}
const handleAgentReferenceDragOver = (event: ReactDragEvent<HTMLElement>) => {
if (!transferHasAgentReference(event.dataTransfer)) return
event.preventDefault()
event.dataTransfer.dropEffect = "copy"
setAgentDropActive(true)
}
const handleAgentReferenceDragLeave = (event: ReactDragEvent<HTMLElement>) => {
const next = event.relatedTarget as Node | null
if (next && event.currentTarget.contains(next)) return
setAgentDropActive(false)
}
const uploadAgentReferenceFiles = async (files: File[]) => {
const imageFiles = files.filter((file) => {
const name = file.name.toLowerCase()
return file.type.startsWith("image/") || /\.(jpe?g|png|webp|bmp)$/i.test(name)
}).slice(0, RECONSTRUCTION_FRAME_LIMIT)
if (!imageFiles.length) {
toast.warning("只支持拖入图片文件。")
return
}
setAgentReferenceUploadBusy(true)
try {
let workingJob = job
const known = new Set(job.frames.map((frame) => frame.index))
const addedIndices: number[] = []
for (const file of imageFiles) {
const updated = await uploadReferenceFrame(workingJob.id, file)
workingJob = updated
onJobUpdate(updated)
const added = updated.frames.filter((frame) => !known.has(frame.index))
added.forEach((frame) => {
known.add(frame.index)
addedIndices.push(frame.index)
})
}
addAgentReferenceIndices(addedIndices, "已上传并加入转换层")
} catch (e) {
toast.error("参考图上传失败:" + (e instanceof Error ? e.message : String(e)))
} finally {
setAgentReferenceUploadBusy(false)
}
}
const handleAgentReferenceDrop = async (event: ReactDragEvent<HTMLElement>) => {
if (!transferHasAgentReference(event.dataTransfer)) return
event.preventDefault()
setAgentDropActive(false)
const files = Array.from(event.dataTransfer.files || [])
if (files.length) {
await uploadAgentReferenceFiles(files)
return
}
const frameIndex = Number(event.dataTransfer.getData(SOURCE_KEYFRAME_DRAG_TYPE))
if (Number.isFinite(frameIndex)) {
const frame = frames.find((item) => item.index === frameIndex)
if (frame) addAgentReferenceFrame(frame)
return
}
const filmstripTime = Number(event.dataTransfer.getData(FILMSTRIP_DRAG_TYPE))
if (Number.isFinite(filmstripTime) && onDropFilmstripFrame) {
const addedFrame = await onDropFilmstripFrame(filmstripTime)
if (addedFrame) addAgentReferenceFrame(addedFrame)
}
}
const runSubjectAgentAnalyze = async () => {
if (!agentReferenceFrameIndices.length) {
toast.warning("先从左侧拖入 1-3 张参考帧,再开始分析。")
@@ -3748,7 +3869,7 @@ function SourceSubjectPipeline({
return (
<>
<div className="grid gap-2 xl:grid-cols-[150px_minmax(210px,0.75fr)_minmax(0,1.25fr)] 2xl:grid-cols-[170px_minmax(240px,0.8fr)_minmax(0,1.3fr)]">
<div className="grid gap-2 xl:grid-cols-[150px_minmax(260px,0.9fr)_minmax(0,1.1fr)] 2xl:grid-cols-[170px_minmax(285px,0.95fr)_minmax(0,1.05fr)]">
<div className="min-w-0">
<div className="mb-2 flex items-center justify-between gap-2">
<SectionTitle icon={<ImageIcon className="h-4 w-4" />} title="参考帧池" />
@@ -3805,7 +3926,15 @@ function SourceSubjectPipeline({
return (
<div
key={frame.index}
className="relative"
draggable
onDragStart={(event) => {
event.dataTransfer.setData(SOURCE_KEYFRAME_DRAG_TYPE, String(frame.index))
event.dataTransfer.effectAllowed = "copy"
setReferenceFrameDragging(true)
}}
onDragEnd={() => setReferenceFrameDragging(false)}
className="relative cursor-grab active:cursor-grabbing"
title="拖到转换层作为生图参考"
>
<MediaAssetTile
src={effectiveFrameUrl(job.id, frame)}
@@ -3856,7 +3985,7 @@ function SourceSubjectPipeline({
{agentReferenceFrames.length ? `${agentReferenceFrames.length}/${RECONSTRUCTION_FRAME_LIMIT}` : "待选图"}
</span>
</div>
<div className="min-h-[410px] rounded-md border border-white/10 bg-black/24 p-2 2xl:min-h-[500px]">
<div className="flex min-h-[410px] flex-col rounded-md border border-white/10 bg-black/24 p-2 2xl:min-h-[500px]">
<div className="mb-2 grid grid-cols-2 gap-1.5">
{SUBJECT_MODEL_BUNDLE_OPTIONS.map((option) => (
<button
@@ -3876,10 +4005,20 @@ function SourceSubjectPipeline({
))}
</div>
<div className="rounded-md border border-white/10 bg-black/22 p-2">
<div
className={`rounded-md border p-2 transition ${
agentDropActive || referenceFrameDragging || filmstripDragging || agentReferenceUploadBusy
? "border-cyan-200/65 bg-cyan-300/[0.08] ring-1 ring-cyan-200/25"
: "border-white/10 bg-black/22"
}`}
onDragEnter={handleAgentReferenceDragEnter}
onDragOver={handleAgentReferenceDragOver}
onDragLeave={handleAgentReferenceDragLeave}
onDrop={(event) => void handleAgentReferenceDrop(event)}
>
<div className="mb-1.5 flex items-center justify-between gap-2">
<span className="text-[10px] font-semibold text-white/72"></span>
<span className="text-[9px] text-white/34"> {RECONSTRUCTION_FRAME_LIMIT} </span>
<span className="text-[10px] font-semibold text-white/72"></span>
<span className="text-[9px] text-white/34">{agentReferenceFrames.length}/{RECONSTRUCTION_FRAME_LIMIT}</span>
</div>
{agentReferenceFrames.length ? (
<div className="grid grid-cols-3 gap-1.5">
@@ -3902,19 +4041,26 @@ function SourceSubjectPipeline({
))}
</div>
) : (
<div className="flex h-24 items-center justify-center rounded border border-dashed border-white/12 px-2 text-center text-[10px] leading-snug text-white/32">
+
<div className="flex h-[116px] flex-col items-center justify-center rounded border border-dashed border-white/15 px-3 text-center text-[10px] leading-snug text-white/34">
{agentReferenceUploadBusy ? <Loader2 className="mb-1.5 h-4 w-4 animate-spin text-cyan-100/80" /> : <Upload className="mb-1.5 h-4 w-4 text-cyan-100/55" />}
<span className="font-semibold text-white/50"></span>
<span className="mt-0.5 text-white/28"> +</span>
</div>
)}
<button
type="button"
onClick={() => void runSubjectAgentAnalyze()}
disabled={!agentReferenceFrames.length || !!subjectAgentBusy}
className="skg-secondary-action mt-2 inline-flex h-7 w-full items-center justify-center gap-1.5 px-2 text-[10px] font-semibold transition disabled:cursor-not-allowed disabled:opacity-40"
>
{subjectAgentBusy === "analyze" ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Sparkles className="h-3.5 w-3.5" />}
</button>
<div className="mt-2 flex items-center gap-1.5">
<button
type="button"
onClick={() => void runSubjectAgentAnalyze()}
disabled={!agentReferenceFrames.length || !!subjectAgentBusy || agentReferenceUploadBusy}
className="skg-secondary-action inline-flex h-7 flex-1 items-center justify-center gap-1.5 px-2 text-[10px] font-semibold transition disabled:cursor-not-allowed disabled:opacity-40"
>
{subjectAgentBusy === "analyze" || agentReferenceUploadBusy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Sparkles className="h-3.5 w-3.5" />}
</button>
<span className="shrink-0 rounded border border-white/10 bg-black/24 px-1.5 py-1 text-[9px] text-white/35">
</span>
</div>
</div>
{agentAnalysis ? (
@@ -3945,14 +4091,17 @@ function SourceSubjectPipeline({
</div>
) : null}
<div className="mt-2 rounded-md border border-white/10 bg-black/22 p-2">
<div className="mt-2 flex min-h-0 flex-1 flex-col rounded-md border border-white/10 bg-black/22 p-2">
<div className="mb-1.5 flex items-center justify-between gap-2">
<span className="text-[10px] font-semibold text-white/72"></span>
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-white/72">
<MessageSquare className="h-3.5 w-3.5 text-cyan-100/55" />
</span>
<span className="text-[9px] text-white/34">
{reconstructionModeConfig(effectiveAgentMode).label} · {effectiveAgentQuantity}
</span>
</div>
<div className="max-h-28 space-y-1.5 overflow-auto rounded border border-white/8 bg-black/20 p-1.5">
<div className="min-h-[86px] flex-1 space-y-1.5 overflow-auto rounded border border-white/8 bg-black/20 p-1.5">
{agentMessages.length ? agentMessages.slice(-5).map((message, index) => (
<div
key={`${message.created_at}-${index}`}
@@ -3965,8 +4114,8 @@ function SourceSubjectPipeline({
{message.content}
</div>
)) : (
<div className="flex h-14 items-center justify-center text-center text-[10px] leading-snug text-white/30">
<div className="flex h-full min-h-[74px] items-center justify-center px-2 text-center text-[10px] leading-snug text-white/30">
</div>
)}
</div>
@@ -3982,21 +4131,23 @@ function SourceSubjectPipeline({
</button>
))}
</div>
<textarea
value={agentInput}
onChange={(event) => setAgentInput(event.target.value)}
placeholder="例如保留透明骨架和蓝色头带但人物更大服装统一生成6张。"
className="mt-2 h-20 w-full resize-none rounded-md border border-white/10 bg-black/35 px-2 py-1.5 text-[10.5px] leading-snug text-white outline-none transition placeholder:text-white/24 focus:border-cyan-200/55"
/>
<button
type="button"
onClick={() => void sendSubjectAgentRequirement()}
disabled={!!subjectAgentBusy || (!agentInput.trim() && !agentRequirement.trim())}
className="skg-primary-action mt-2 inline-flex h-8 w-full items-center justify-center gap-1.5 px-2 text-[10.5px] font-semibold transition disabled:cursor-not-allowed disabled:opacity-40"
>
{subjectAgentBusy === "message" ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Send className="h-3.5 w-3.5" />}
</button>
<div className="mt-2 rounded-md border border-white/10 bg-black/35 p-1.5">
<textarea
value={agentInput}
onChange={(event) => setAgentInput(event.target.value)}
placeholder="例如保留透明骨架和蓝色头带但人物更大服装统一生成6张。"
className="h-[72px] w-full resize-none rounded border border-transparent bg-transparent px-1 py-1 text-[10.5px] leading-snug text-white outline-none transition placeholder:text-white/24 focus:border-cyan-200/45"
/>
<button
type="button"
onClick={() => void sendSubjectAgentRequirement()}
disabled={!!subjectAgentBusy || (!agentInput.trim() && !agentRequirement.trim())}
className="skg-primary-action mt-1 inline-flex h-8 w-full items-center justify-center gap-1.5 px-2 text-[10.5px] font-semibold transition disabled:cursor-not-allowed disabled:opacity-40"
>
{subjectAgentBusy === "message" ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Send className="h-3.5 w-3.5" />}
</button>
</div>
</div>
{effectivePrompt ? (

View File

@@ -57,7 +57,7 @@ export interface NodeData {
onFramePanelDockChange?: (dock: CanvasPanelDock) => void
onCloseExpandedFrame: () => void
onAddManualFrame: (t: number) => void
onAddManualFrameForJob?: (jobId: string, t: number) => Promise<void> | void
onAddManualFrameForJob?: (jobId: string, t: number) => Promise<Job | void> | Job | void
onOpenVideoPanel?: (jobId: string) => void
onCloseVideoPanel?: () => void
onVideoPanelScaleChange?: (scale: number) => void

View File

@@ -1123,6 +1123,17 @@ export async function addManualFrame(id: string, t: number): Promise<Job> {
return res.json()
}
export async function uploadReferenceFrame(jobId: string, file: File): Promise<Job> {
const fd = new FormData()
fd.append("file", file)
const res = await fetch(`${API_BASE}/jobs/${jobId}/frames/upload`, { method: "POST", body: fd })
if (!res.ok) {
const txt = await res.text().catch(() => "")
throw new Error(`uploadReferenceFrame ${res.status} ${txt.slice(0, 300)}`)
}
return res.json()
}
export async function describeFrame(jobId: string, frameIdx: number): Promise<Job> {
const res = await fetch(`${API_BASE}/jobs/${jobId}/frames/${frameIdx}/describe`, { method: "POST" })
if (!res.ok) {