diff --git a/.memory/worklog.json b/.memory/worklog.json index 34aee59..1ec0b94 100644 --- a/.memory/worklog.json +++ b/.memory/worklog.json @@ -1848,6 +1848,13 @@ "type": "session-heartbeat", "message": "Claude 会话活跃 · 最近命令:claude · 1 项未提交变更 · 最近提交:auto-save 2026-05-13 15:55 (~1)", "files_changed": 1 + }, + { + "ts": "2026-05-13T16:01:31+08:00", + "type": "commit", + "message": "auto-save 2026-05-13 16:01 (~1)", + "hash": "f30f6c4", + "files_changed": 1 } ] } diff --git a/web/app/page.tsx b/web/app/page.tsx index dcd5383..4c121ba 100644 --- a/web/app/page.tsx +++ b/web/app/page.tsx @@ -15,6 +15,7 @@ import { import { ThemeToggle } from "@/components/theme-toggle" import { Dashboard, type DashboardHandle } from "@/components/dashboard" import { StoryboardBar } from "@/components/storyboard-bar" +import { StoryboardWorkbench } from "@/components/storyboard-workbench" import { addManualFrame, analyzeJob, createJob, getJob, uploadJob, deleteFrame, deleteGeneratedImage, pushStoryboardImage, type Job } from "@/lib/api" import { VideoLightbox } from "@/components/video-lightbox" @@ -66,6 +67,7 @@ export default function Home() { const [expandedFrame, setExpandedFrame] = useState(null) const [videoLightboxOpen, setVideoLightboxOpen] = useState(false) const [storyboardFrame, setStoryboardFrame] = useState(null) + const [workbenchOpen, setWorkbenchOpen] = useState(false) const dashboardRef = useRef(null) // 把 setJob(prev=>...) 翻译成 setJobs 里更新当前 active @@ -335,6 +337,7 @@ export default function Home() { focusedFrame={storyboardFrame} onFocusFrame={setStoryboardFrame} onJobUpdate={setJob as any} + onOpenWorkbench={() => setWorkbenchOpen(true)} />
+ {/* 分镜头编排工作台 — 全屏覆盖 DAG */} + setWorkbenchOpen(false)} + onJobUpdate={setJob as any} + /> + ) diff --git a/web/components/storyboard-bar.tsx b/web/components/storyboard-bar.tsx index 6cc8841..befea9e 100644 --- a/web/components/storyboard-bar.tsx +++ b/web/components/storyboard-bar.tsx @@ -11,9 +11,10 @@ interface Props { focusedFrame: number | null onFocusFrame: (idx: number | null) => void onJobUpdate?: (j: Job) => void + onOpenWorkbench?: () => void } -export function StoryboardBar({ job, selectedFrames, focusedFrame, onFocusFrame, onJobUpdate }: Props) { +export function StoryboardBar({ job, selectedFrames, focusedFrame, onFocusFrame, onJobUpdate, onOpenWorkbench }: Props) { const [collapsed, setCollapsed] = useState(false) const [mounted, setMounted] = useState(false) useEffect(() => setMounted(true), []) @@ -75,14 +76,27 @@ export function StoryboardBar({ job, selectedFrames, focusedFrame, onFocusFrame, )}
- +
+ {onOpenWorkbench && ( + + )} + +
{/* thumbnails row */} diff --git a/web/components/storyboard-workbench.tsx b/web/components/storyboard-workbench.tsx new file mode 100644 index 0000000..8c05c2f --- /dev/null +++ b/web/components/storyboard-workbench.tsx @@ -0,0 +1,360 @@ +"use client" +import { useEffect, useState, useRef, type ReactNode } from "react" +import { createPortal } from "react-dom" +import { X, LayoutGrid, Loader2, Check, Sparkle, Wand2 } from "lucide-react" +import { + type Job, type StoryboardScene, + effectiveFrameUrl, cutoutUrl, updateStoryboard, +} from "@/lib/api" +import { toast } from "sonner" + +interface Props { + job: Job | null + selectedFrames: Set + open: boolean + onClose: () => void + onJobUpdate?: (j: Job) => void +} + +const emptyScene = (): StoryboardScene => ({ + subject: "", product: "", scene: "", action: "", duration: 0, reference_ids: [], +}) + +export function StoryboardWorkbench({ job, selectedFrames, open, onClose, onJobUpdate }: Props) { + const [mounted, setMounted] = useState(false) + useEffect(() => setMounted(true), []) + + const [focusedIdx, setFocusedIdx] = useState(null) + const [form, setForm] = useState(emptyScene()) + const [saving, setSaving] = useState(false) + const [savedTick, setSavedTick] = useState(0) + const saveTimer = useRef | null>(null) + + // Esc 关闭 + useEffect(() => { + if (!open) return + const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose() } + window.addEventListener("keydown", onKey) + return () => window.removeEventListener("keydown", onKey) + }, [open, onClose]) + + // 默认选第一个分镜 + useEffect(() => { + if (!open || !job) return + if (focusedIdx !== null && job.frames.find((f) => f.index === focusedIdx)) return + const frames = job.frames + .filter((f) => selectedFrames.has(f.index)) + .sort((a, b) => a.timestamp - b.timestamp) + if (frames.length > 0) setFocusedIdx(frames[0].index) + else setFocusedIdx(null) + }, [open, job?.id, selectedFrames, focusedIdx, job?.frames]) + + // 切换 focused 加载表单数据 + useEffect(() => { + if (!job || focusedIdx === null) { setForm(emptyScene()); return } + const f = job.frames.find((x) => x.index === focusedIdx) + setForm(f?.storyboard ? { ...emptyScene(), ...f.storyboard } : emptyScene()) + }, [focusedIdx, job]) + + if (!mounted || !open || !job) return null + + const frames = job.frames + .filter((f) => selectedFrames.has(f.index)) + .sort((a, b) => a.timestamp - b.timestamp) + const focusFrame = focusedIdx !== null ? job.frames.find((f) => f.index === focusedIdx) ?? null : null + const focusSeq = focusFrame ? frames.findIndex((f) => f.index === focusFrame.index) + 1 : 0 + + const queueSave = (next: StoryboardScene) => { + setForm(next) + if (!job || focusedIdx === null) return + if (saveTimer.current) clearTimeout(saveTimer.current) + saveTimer.current = setTimeout(async () => { + setSaving(true) + try { + const updated = await updateStoryboard(job.id, focusedIdx, next) + onJobUpdate?.(updated) + setSavedTick((t) => t + 1) + } catch (e) { + toast.error("保存失败:" + (e instanceof Error ? e.message : String(e))) + } finally { + setSaving(false) + } + }, 600) + } + + // 参考图候选 = 全部已推送图 + const pushedImages = job.storyboard_images ?? [] + const refOptions = pushedImages + .map((p) => { + const url = p.kind === "keyframe" + ? effectiveFrameUrl(job.id, { index: p.frame_idx, cleaned_applied: false }) + : (p.element_id && p.cutout_id + ? (p.cutout_id === p.element_id + ? cutoutUrl(job.id, p.frame_idx, p.element_id) + : cutoutUrl(job.id, p.frame_idx, p.element_id, p.cutout_id)) + : "") + return { ref_id: p.ref_id, url, label: p.label || "", frame_idx: p.frame_idx, kind: p.kind } + }) + .filter((r) => r.url) + + const aspect = job.height > 0 ? `${job.width}/${job.height}` : "9/16" + + return createPortal( +
+ {/* Header */} +
+
+ + 分镜头编排工作台 + + {frames.length} 分镜 · {pushedImages.length} 素材 + +
+
+ + {saving ? (<> 保存中) + : savedTick > 0 ? (<> 已自动保存) + : "字段变更自动保存"} + + +
+
+ +
+ {/* 左侧分镜列表 */} + + + {/* 右侧详情 */} +
+ {!focusFrame ? ( +
从左侧选一个分镜开始编排
+ ) : ( +
+ {/* 顶栏 */} +
+
+ 分镜 {focusSeq} {focusFrame.timestamp.toFixed(2)}s +
+
+ + {/* 左大图 + 右字段 */} +
+
+ +
+ {focusFrame.cleaned_applied ? "✨ 已清洗版" : "原图"} +
+
+ +
+
+ queueSave({ ...form, subject: v })} /> + queueSave({ ...form, product: v })} /> + queueSave({ ...form, scene: v })} /> + queueSave({ ...form, duration: v })} /> +
+ queueSave({ ...form, action: v })} + rows={4} + /> +
+
+ + {/* 参考图选择 */} +
+
+
+ + 选用参考图 + + · 选用 {form.reference_ids.length} / 可选 {refOptions.length} + +
+
+ {refOptions.length === 0 ? ( +
+ 暂无可选参考图 · 到关键帧节点 / 元素提取图 / 分镜头编排节点等处点 ⬆ 上推到这里 +
+ ) : ( +
+ {refOptions.map((r) => { + const checked = form.reference_ids.includes(r.ref_id) + return ( + + ) + })} +
+ )} +
+ + {/* 生成按钮(Phase 2 占位) */} +
+ +
+ 下一阶段:基于上述字段 + 参考图,调视频生成模型(Seedance / Kling / Veo3)生成该分镜的视频片段 +
+
+
+ )} +
+
+ + {/* 底部快捷 */} +
+ ESC 返回 DAG · 字段变更自动保存 +
+
, + document.body, + ) +} + +function FieldText({ label, value, onChange, placeholder }: { label: string; value: string; onChange: (v: string) => void; placeholder?: string }) { + return ( + + ) +} + +function FieldNum({ label, value, onChange, placeholder }: { label: string; value: number; onChange: (v: number) => void; placeholder?: string }) { + return ( + + ) +} + +function FieldTextarea({ label, value, onChange, placeholder, rows = 2 }: { label: string; value: string; onChange: (v: string) => void; placeholder?: string; rows?: number }) { + return ( +