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 ? (
-

- ) : (
-
-
-
- )}
+
+
)
})}
)}
-
- {/* 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 (
+
+ )
+}
diff --git a/web/lib/api.ts b/web/lib/api.ts
index 4e48f77..d3ecb21 100644
--- a/web/lib/api.ts
+++ b/web/lib/api.ts
@@ -47,6 +47,15 @@ export interface KeyElement {
created_at?: number
}
+export interface StoryboardScene {
+ subject: string
+ product: string
+ scene: string
+ action: string
+ duration: number
+ reference_ids: string[]
+}
+
export interface KeyFrame {
index: number
timestamp: number
@@ -55,6 +64,7 @@ export interface KeyFrame {
cleaned_url?: string | null
cleaned_applied?: boolean
elements?: KeyElement[]
+ storyboard?: StoryboardScene | null
generated_images?: GeneratedImage[]
}
@@ -238,6 +248,23 @@ export function representativeCutoutUrl(
return null
}
+export async function updateStoryboard(
+ jobId: string,
+ frameIdx: number,
+ body: StoryboardScene,
+): Promise
{
+ const res = await fetch(`${API_BASE}/jobs/${jobId}/frames/${frameIdx}/storyboard`, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ })
+ if (!res.ok) {
+ const txt = await res.text().catch(() => "")
+ throw new Error(`updateStoryboard ${res.status} ${txt.slice(0, 300)}`)
+ }
+ return res.json()
+}
+
export async function deleteCutout(jobId: string, frameIdx: number, elementId: string, cutoutId: string): Promise {
const res = await fetch(`${API_BASE}/jobs/${jobId}/frames/${frameIdx}/elements/${elementId}/cutouts/${cutoutId}`, {
method: "DELETE",