From d36b5ca317531358ce27d73f237ae526b9caf330 Mon Sep 17 00:00:00 2001 From: kang Date: Wed, 13 May 2026 21:35:36 +0800 Subject: [PATCH] auto-save 2026-05-13 21:35 (~6) --- .memory/worklog.json | 7 +++++++ api/main.py | 27 +++++++++++++++++++++---- docs/source-analysis.html | 12 +++++++++++ web/app/page.tsx | 2 +- web/components/storyboard-workbench.tsx | 22 ++++++++++++++------ web/lib/api.ts | 2 ++ 6 files changed, 61 insertions(+), 11 deletions(-) diff --git a/.memory/worklog.json b/.memory/worklog.json index c128a88..841c3c9 100644 --- a/.memory/worklog.json +++ b/.memory/worklog.json @@ -2434,6 +2434,13 @@ "type": "session-heartbeat", "message": "Codex 会话活跃 · 最近命令:codex · 7 项未提交变更 · 最近提交:auto-save 2026-05-13 21:24 (~6)", "files_changed": 7 + }, + { + "ts": "2026-05-13T21:30:04+08:00", + "type": "commit", + "message": "auto-save 2026-05-13 21:29 (~7)", + "hash": "7b59ed9", + "files_changed": 7 } ] } diff --git a/api/main.py b/api/main.py index a763853..0e3e3be 100644 --- a/api/main.py +++ b/api/main.py @@ -1770,6 +1770,7 @@ def submit_video_create( payload: dict, source_ref: VideoSourceRef | None = None, last_img: Path | None = None, + product_img: Path | None = None, ): if video_uses_ark(): content = [{"type": "text", "text": payload["prompt"]}] @@ -1796,6 +1797,14 @@ def submit_video_create( "role": "last_frame", } ) + if product_img and product_img.exists(): + content.append( + { + "type": "image_url", + "image_url": {"url": ark_reference_data_url(product_img)}, + "role": "reference_image", + } + ) data = { "model": payload["model"], "content": content, @@ -1832,12 +1841,14 @@ def render_storyboard_video( size: str, source_ref: VideoSourceRef | None = None, last_ref_path: Path | None = None, + product_ref_path: Path | None = None, ) -> None: import httpx out_dir = job_dir(job_id) / "storyboard_videos" / local_id ref_img = out_dir / "reference.jpg" last_img = out_dir / "last_reference.jpg" + product_img = out_dir / "product_reference.jpg" out_mp4 = out_dir / "video.mp4" base = video_api_base() headers = {"Authorization": f"Bearer {video_api_key()}"} @@ -1848,6 +1859,10 @@ def render_storyboard_video( if last_ref_path and last_ref_path.exists(): prepare_video_reference(last_ref_path, last_img) prepared_last_img = last_img + prepared_product_img: Path | None = None + if product_ref_path and product_ref_path.exists(): + prepare_video_reference(product_ref_path, product_img) + prepared_product_img = product_img update_generated_video(job_id, local_id, status="in_progress", progress=5) with httpx.Client(timeout=120) as client: payload = {"model": model, "prompt": prompt, "size": size} @@ -1855,13 +1870,16 @@ def render_storyboard_video( create = None create_errors: list[str] = [] for create_path in VIDEO_CREATE_PATHS: - resp = submit_video_create(client, f"{base}{video_path(create_path)}", headers, ref_img, payload, source_ref, prepared_last_img) + resp = submit_video_create(client, f"{base}{video_path(create_path)}", headers, ref_img, payload, source_ref, prepared_last_img, prepared_product_img) if video_uses_ark() and source_ref and resp.status_code in {400, 422}: create_errors.append(f"{video_path(create_path)} + reference_video -> HTTP {resp.status_code}: {resp.text[:160]}") - resp = submit_video_create(client, f"{base}{video_path(create_path)}", headers, ref_img, payload, None, prepared_last_img) + resp = submit_video_create(client, f"{base}{video_path(create_path)}", headers, ref_img, payload, None, prepared_last_img, prepared_product_img) if video_uses_ark() and prepared_last_img and resp.status_code in {400, 422}: create_errors.append(f"{video_path(create_path)} + last_frame -> HTTP {resp.status_code}: {resp.text[:160]}") - resp = submit_video_create(client, f"{base}{video_path(create_path)}", headers, ref_img, payload, None, None) + resp = submit_video_create(client, f"{base}{video_path(create_path)}", headers, ref_img, payload, None, None, prepared_product_img) + if video_uses_ark() and prepared_product_img and resp.status_code in {400, 422}: + create_errors.append(f"{video_path(create_path)} + product_reference -> HTTP {resp.status_code}: {resp.text[:160]}") + resp = submit_video_create(client, f"{base}{video_path(create_path)}", headers, ref_img, payload, None, prepared_last_img, None) if resp.status_code < 400: create = resp break @@ -1924,6 +1942,7 @@ def generate_storyboard_video(job_id: str, idx: int, req: GenerateStoryboardVide raise HTTPException(404, "reference image missing") poster = storyboard_ref_url(job_id, ref) or f"/jobs/{job_id}/frames/{idx}.jpg" last_ref_path = storyboard_ref_path(job_id, req.last_image) + product_ref_path = storyboard_ref_path(job_id, req.product_image) local_id = uuid.uuid4().hex[:12] model = resolve_video_model(req.model) @@ -1945,7 +1964,7 @@ def generate_storyboard_video(job_id: str, idx: int, req: GenerateStoryboardVide source_ref = req.source_ref if source_ref and source_ref.kind == "source_video" and not source_ref.url: source_ref = None - bg.add_task(render_storyboard_video, job_id, local_id, "", ref_path, prompt, model, seconds, req.size, source_ref, last_ref_path) + bg.add_task(render_storyboard_video, job_id, local_id, "", ref_path, prompt, model, seconds, req.size, source_ref, last_ref_path, product_ref_path) return job diff --git a/docs/source-analysis.html b/docs/source-analysis.html index a7527ad..5566258 100644 --- a/docs/source-analysis.html +++ b/docs/source-analysis.html @@ -830,6 +830,18 @@ api/main.py

变更记录

这个记录不是 git log 的替代品。它记录“产品理解发生了什么变化、影响了哪些源码、你以后描述需求时该怎么说”。后续每次改功能都要补一条。

+
+
+

2026-05-13 · 首尾帧编排增加 SKG 产品槽

+ StoryboardWorkbench + API +
+
+

问题:首尾帧可以控制视频起止,但还需要单独指定 SKG 产品图,避免模型只模仿原视频动作而没有稳定产品外观。

+

改动:分镜编排区新增 SKG 产品 槽,和首帧、尾帧并列;生成视频时把该槽作为 product_image 提交。Ark 请求会附加一张 reference_image 产品参考图;如果接口不接受额外参考图,后端自动回退到首尾帧生成。

+

影响:web/components/storyboard-workbench.tsxweb/app/page.tsxapi/main.py

+
+

2026-05-13 · 分镜编排改为首尾帧生成

diff --git a/web/app/page.tsx b/web/app/page.tsx index 7484ac8..8305f48 100644 --- a/web/app/page.tsx +++ b/web/app/page.tsx @@ -292,7 +292,7 @@ export default function Home() { last_image: lastRef, subject_image: firstRef, scene_image: null, - product_image: null, + product_image: scene.product_image ?? null, action_image: null, source_ref: sourceUrl ? { kind: "source_video", url: sourceUrl } : null, model, diff --git a/web/components/storyboard-workbench.tsx b/web/components/storyboard-workbench.tsx index c672cc2..2dacb83 100644 --- a/web/components/storyboard-workbench.tsx +++ b/web/components/storyboard-workbench.tsx @@ -40,6 +40,7 @@ export function StoryboardWorkbench({ job, selectedFrames, open, onClose, onJobU const [videoModel, setVideoModel] = useState<(typeof VIDEO_MODELS)[number]["value"]>("seedance") const [generating, setGenerating] = useState(false) const saveTimer = useRef | null>(null) + const loadedFormKey = useRef("") // Esc 关闭 useEffect(() => { @@ -68,12 +69,20 @@ export function StoryboardWorkbench({ job, selectedFrames, open, onClose, onJobU else setFocusedIdx(null) }, [open, job?.id, selectedFrames, focusedIdx, job?.frames]) - // 切换 focused 加载表单数据 + // 切换 focused 时加载表单数据。不要在每次 job 轮询/保存回包时重灌, + // 否则用户刚粘贴到尾帧,外部旧 job 刷新会把本地表单闪回去。 useEffect(() => { - if (!job || focusedIdx === null) { setForm(emptyScene()); return } + if (!job || focusedIdx === null) { + loadedFormKey.current = "" + setForm(emptyScene()) + return + } + const key = `${job.id}:${focusedIdx}` + if (loadedFormKey.current === key) return const f = job.frames.find((x) => x.index === focusedIdx) setForm(f?.storyboard ? { ...emptyScene(), ...f.storyboard } : emptyScene()) - }, [focusedIdx, job]) + loadedFormKey.current = key + }, [focusedIdx, job?.id]) if (!mounted || !open || !job) return null @@ -184,12 +193,13 @@ export function StoryboardWorkbench({ job, selectedFrames, open, onClose, onJobU
{/* 首尾帧:图片直接参与视频生成 */} -
+
{([ { key: "first_image" as const, label: "首帧", placeholder: "默认当前分镜关键帧" }, { key: "last_image" as const, label: "尾帧", placeholder: defaultLastRef ? "默认下一张已选分镜" : "粘贴一张结束画面" }, + { key: "product_image" as const, label: "SKG 产品", placeholder: "粘贴产品图 / 包装 / 使用状态" }, ]).map(({ key, label, placeholder }) => { - const fallback = key === "first_image" ? defaultFirstRef : defaultLastRef + const fallback = key === "first_image" ? defaultFirstRef : key === "last_image" ? defaultLastRef : null const ref = form[key] ?? fallback const url = ref ? resolveImageRefUrl(job.id, ref) : "" return ( @@ -245,7 +255,7 @@ export function StoryboardWorkbench({ job, selectedFrames, open, onClose, onJobU })}
- 现在按首尾帧出片:首帧默认当前分镜,尾帧默认下一张已选分镜。需要指定结尾时,在任意关键帧或生成图点 📋,再粘贴到「尾帧」。 + 现在按首尾帧出片:首帧默认当前分镜,尾帧默认下一张已选分镜;「SKG 产品」用于锁定产品外观。需要指定素材时,在任意关键帧、产品图或生成图点 📋,再粘贴到对应槽位。
{/* 改造 brief:明确“借鉴参考 → 变成 SKG 产品视频”,避免直接复刻 */} diff --git a/web/lib/api.ts b/web/lib/api.ts index ff55e2e..897b0ed 100644 --- a/web/lib/api.ts +++ b/web/lib/api.ts @@ -59,6 +59,7 @@ export interface StoryboardScene { duration: number first_image?: ImageRef | null last_image?: ImageRef | null + product_images?: ImageRef[] subject_image?: ImageRef | null scene_image?: ImageRef | null product_image?: ImageRef | null @@ -382,6 +383,7 @@ export async function generateStoryboardVideo( duration?: number first_image?: ImageRef | null last_image?: ImageRef | null + product_images?: ImageRef[] subject_image?: ImageRef | null scene_image?: ImageRef | null product_image?: ImageRef | null