diff --git a/.memory/worklog.json b/.memory/worklog.json index 2a69737..29cc292 100644 --- a/.memory/worklog.json +++ b/.memory/worklog.json @@ -1702,6 +1702,19 @@ "message": "auto-save 2026-05-13 14:38 (~4)", "hash": "9421836", "files_changed": 4 + }, + { + "ts": "2026-05-13T14:44:00+08:00", + "type": "commit", + "message": "auto-save 2026-05-13 14:43 (~6)", + "hash": "59f6c16", + "files_changed": 6 + }, + { + "ts": "2026-05-13T06:47:39Z", + "type": "session-heartbeat", + "message": "Claude 会话活跃 · 最近命令:claude · 2 项未提交变更 · 最近提交:auto-save 2026-05-13 14:43 (~6)", + "files_changed": 2 } ] } diff --git a/api/main.py b/api/main.py index e32c6d8..d3a1c4b 100644 --- a/api/main.py +++ b/api/main.py @@ -63,6 +63,16 @@ class GeneratedImage(BaseModel): created_at: float = 0.0 +class StoryboardScene(BaseModel): + """分镜头编排:每个 selected 分镜对应一个 scene 描述""" + subject: str = "" # 主体(如:戴头带的骨架人) + product: str = "" # 产品(如:Goli 营养软糖) + scene: str = "" # 场景(如:药店柜台) + action: str = "" # 在干什么(如:递给顾客一瓶软糖) + duration: float = 0 # 视频片段时长(秒) + reference_ids: list[str] = [] # 参考图:选用该分镜里已提取的 element ids 作 reference + + class KeyElement(BaseModel): """关键帧里识别 / 用户提取的元素 · 多次提取累积多张图,让用户挑选满意的""" id: str # uuid hex 8 @@ -87,6 +97,7 @@ class KeyFrame(BaseModel): cleaned_url: str | None = None # 清洗后干净版(待应用)→ /jobs/{id}/frames/{idx}/cleaned.jpg cleaned_applied: bool = False # 是否已用清洗版替换原图(替换后 cleaned_url=null) elements: list[KeyElement] = [] # 提取的元素清单(持久化) + storyboard: StoryboardScene | None = None # 分镜头编排字段 generated_images: list[GeneratedImage] = [] @@ -1389,6 +1400,40 @@ def delete_cutout(job_id: str, idx: int, element_id: str, cutout_id: str) -> Job return job +class UpdateStoryboardReq(BaseModel): + subject: str = "" + product: str = "" + scene: str = "" + action: str = "" + duration: float = 0 + reference_ids: list[str] = [] + + +@app.put("/jobs/{job_id}/frames/{idx}/storyboard", response_model=Job) +def update_storyboard(job_id: str, idx: int, req: UpdateStoryboardReq) -> Job: + """更新分镜的编排字段(subject / product / scene / action / duration / reference_ids)""" + job = JOBS.get(job_id) + if not job: + raise HTTPException(404, "job not found") + frame = next((f for f in job.frames if f.index == idx), None) + if not frame: + raise HTTPException(404, "frame not found") + new_frames = [] + for f in job.frames: + if f.index == idx: + f.storyboard = StoryboardScene( + subject=req.subject.strip(), + product=req.product.strip(), + scene=req.scene.strip(), + action=req.action.strip(), + duration=max(0.0, float(req.duration)), + reference_ids=list(req.reference_ids), + ) + new_frames.append(f) + update(job, frames=new_frames, message=f"分镜 {idx + 1} 编排已更新") + return job + + @app.get("/jobs/{job_id}/frames/{idx}/elements/{element_id}/cutouts/{cutout_id}.jpg") def get_cutout_versioned(job_id: str, idx: int, element_id: str, cutout_id: str): p = job_dir(job_id) / "elements" / f"{idx:03d}_{element_id}_{cutout_id}.jpg" diff --git a/web/app/page.tsx b/web/app/page.tsx index 7321c1d..014db0f 100644 --- a/web/app/page.tsx +++ b/web/app/page.tsx @@ -327,6 +327,7 @@ export default function Home() { selectedFrames={selectedFrames} focusedFrame={storyboardFrame} onFocusFrame={setStoryboardFrame} + onJobUpdate={setJob as any} />
- focusedFrame: number | null // 当前 focus 的分镜(imagegen 节点 / bar 缩略图点击触发) + focusedFrame: number | null onFocusFrame: (idx: number | null) => void + onJobUpdate?: (j: Job) => void } -export function StoryboardBar({ job, selectedFrames, focusedFrame, onFocusFrame }: Props) { +const emptyScene = (): StoryboardScene => ({ + subject: "", product: "", scene: "", action: "", duration: 0, reference_ids: [], +}) + +export function StoryboardBar({ job, selectedFrames, focusedFrame, onFocusFrame, onJobUpdate }: Props) { const [collapsed, setCollapsed] = useState(false) const [mounted, setMounted] = useState(false) useEffect(() => setMounted(true), []) - // hover preview state — portal 渲染到 body 避免被父级 overflow-x-auto clip const [hover, setHover] = useState<{ frame: KeyFrame; seq: number; rect: DOMRect } | null>(null) const btnRefs = useRef>({}) + // 表单 state:每次切到新 focus frame 加载该帧的 storyboard + const [form, setForm] = useState(emptyScene()) + const [saving, setSaving] = useState(false) + const [savedTick, setSavedTick] = useState(0) + const saveTimer = useRef | null>(null) + + useEffect(() => { + if (!job || focusedFrame === null) return + const f = job.frames.find((x) => x.index === focusedFrame) + setForm(f?.storyboard ? { ...emptyScene(), ...f.storyboard } : emptyScene()) + }, [focusedFrame, job?.id]) + + // 自动保存:表单变化 600ms 后调 API + const queueSave = (next: StoryboardScene) => { + setForm(next) + if (!job || focusedFrame === null) return + if (saveTimer.current) clearTimeout(saveTimer.current) + saveTimer.current = setTimeout(async () => { + setSaving(true) + try { + const updated = await updateStoryboard(job.id, focusedFrame, next) + onJobUpdate?.(updated) + setSavedTick((t) => t + 1) + } catch (e) { + toast.error("保存失败:" + (e instanceof Error ? e.message : String(e))) + } finally { + setSaving(false) + } + }, 600) + } + if (!job) return null const frames = job.frames @@ -163,64 +199,101 @@ export function StoryboardBar({ job, selectedFrames, focusedFrame, onFocusFrame
- {/* 右:元素 + Phase 2 操作 */} -
-
-
- - 该分镜元素 - · {focusCutCount}/{focusElements.length} 已裁切 + {/* 右:编排表单 */} +
+ {/* 保存状态 */} +
+
+ + 分镜编排参数
- {focusElements.length === 0 ? ( -
- 暂无元素 · 到关键帧节点画框提取 + + {saving ? (<>保存中…) + : savedTick > 0 ? (<>已自动保存) + : "字段变更自动保存"} + +
+ + {/* 5 个字段 — 2×2 grid + 跨列的 action */} +
+ queueSave({ ...form, subject: v })} + /> + queueSave({ ...form, product: v })} + /> + queueSave({ ...form, scene: v })} + /> + queueSave({ ...form, duration: v })} + /> +
+ queueSave({ ...form, action: v })} + rows={2} + /> + + {/* 参考图区 — 多选该分镜已提取元素 */} +
+
+ 参考图(多选) + + 选用 {form.reference_ids.length} / 可选 {focusElements.filter((e) => hasCutout(e)).length} + +
+ {focusElements.filter((e) => hasCutout(e)).length === 0 ? ( +
+ 该分镜暂无可选参考图 · 到关键帧节点画框「AI 提取」后会出现
) : ( -
- {focusElements.map((e) => { +
+ {focusElements.filter((e) => hasCutout(e)).map((e) => { const src = representativeCutoutUrl(job.id, focusFrame.index, e) + const checked = form.reference_ids.includes(e.id) return ( -
-
- {src ? ( - {e.name_zh} - ) : ( -
- -
- )} +
+ ) })}
)}
- - {/* Phase 2 操作占位 */} -
-
- - 编排操作 · Phase 2 待实施 -
-
- - - -
-
@@ -263,3 +336,50 @@ export function StoryboardBar({ job, selectedFrames, focusedFrame, onFocusFrame
) } + +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 ( +