diff --git a/.memory/worklog.json b/.memory/worklog.json index 841c3c9..eca05ac 100644 --- a/.memory/worklog.json +++ b/.memory/worklog.json @@ -2441,6 +2441,19 @@ "message": "auto-save 2026-05-13 21:29 (~7)", "hash": "7b59ed9", "files_changed": 7 + }, + { + "ts": "2026-05-13T21:35:36+08:00", + "type": "commit", + "message": "auto-save 2026-05-13 21:35 (~6)", + "hash": "d36b5ca", + "files_changed": 6 + }, + { + "ts": "2026-05-13T13:39:31Z", + "type": "session-heartbeat", + "message": "Codex 会话活跃 · 最近命令:codex · 6 项未提交变更 · 最近提交:auto-save 2026-05-13 21:35 (~6)", + "files_changed": 6 } ] } diff --git a/api/main.py b/api/main.py index 0e3e3be..8adc7bf 100644 --- a/api/main.py +++ b/api/main.py @@ -127,6 +127,7 @@ class StoryboardScene(BaseModel): duration: float = 0 first_image: dict | None = None last_image: dict | None = None + product_images: list[dict] = Field(default_factory=list) # 4 图槽:dict 含 {kind, frame_idx, element_id?, cutout_id?, label} subject_image: dict | None = None scene_image: dict | None = None @@ -290,6 +291,12 @@ def storyboard_ref_path(job_id: str, ref: dict | None) -> Path | None: for p in candidates: if p.exists(): return p + if kind == "asset": + asset_id = (ref.get("element_id") or ref.get("cutout_id") or "").strip() + if not asset_id: + return None + p = job_dir(job_id) / "assets" / f"{asset_id}.jpg" + return p if p.exists() else None return None @@ -306,6 +313,8 @@ def storyboard_ref_url(job_id: str, ref: dict | None) -> str: if cutout_id and cutout_id != element_id: return f"/jobs/{job_id}/frames/{int(frame_idx)}/elements/{element_id}/cutouts/{cutout_id}.jpg" return f"/jobs/{job_id}/frames/{int(frame_idx)}/elements/{element_id}/cutout.jpg" + if kind == "asset" and ref.get("element_id"): + return f"/jobs/{job_id}/assets/{ref.get('element_id')}.jpg" return "" @@ -1651,6 +1660,7 @@ class GenerateStoryboardVideoReq(BaseModel): duration: float = 4 first_image: dict | None = None last_image: dict | None = None + product_images: list[dict] = Field(default_factory=list) subject_image: dict | None = None scene_image: dict | None = None product_image: dict | None = None @@ -1770,7 +1780,7 @@ def submit_video_create( payload: dict, source_ref: VideoSourceRef | None = None, last_img: Path | None = None, - product_img: Path | None = None, + product_imgs: list[Path] | None = None, ): if video_uses_ark(): content = [{"type": "text", "text": payload["prompt"]}] @@ -1797,14 +1807,15 @@ 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", - } - ) + for product_img in (product_imgs or [])[:6]: + if 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, @@ -1841,14 +1852,13 @@ def render_storyboard_video( size: str, source_ref: VideoSourceRef | None = None, last_ref_path: Path | None = None, - product_ref_path: Path | None = None, + product_ref_paths: list[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()}"} @@ -1859,10 +1869,12 @@ 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 + prepared_product_imgs: list[Path] = [] + for i, product_ref_path in enumerate((product_ref_paths or [])[:6], start=1): + if product_ref_path.exists(): + product_img = out_dir / f"product_reference_{i}.jpg" + prepare_video_reference(product_ref_path, product_img) + prepared_product_imgs.append(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} @@ -1870,14 +1882,14 @@ 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, prepared_product_img) + resp = submit_video_create(client, f"{base}{video_path(create_path)}", headers, ref_img, payload, source_ref, prepared_last_img, prepared_product_imgs) 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, prepared_product_img) + resp = submit_video_create(client, f"{base}{video_path(create_path)}", headers, ref_img, payload, None, prepared_last_img, prepared_product_imgs) 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, prepared_product_img) - if video_uses_ark() and prepared_product_img and resp.status_code in {400, 422}: + resp = submit_video_create(client, f"{base}{video_path(create_path)}", headers, ref_img, payload, None, None, prepared_product_imgs) + if video_uses_ark() and prepared_product_imgs 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: @@ -1942,7 +1954,8 @@ 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) + raw_product_refs = req.product_images[:6] if req.product_images else ([req.product_image] if req.product_image else []) + product_ref_paths = [p for p in (storyboard_ref_path(job_id, r) for r in raw_product_refs) if p] local_id = uuid.uuid4().hex[:12] model = resolve_video_model(req.model) @@ -1964,7 +1977,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, product_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_paths) return job @@ -1976,6 +1989,44 @@ def get_storyboard_video(job_id: str, video_id: str): return FileResponse(p, media_type="video/mp4") +@app.post("/jobs/{job_id}/assets") +async def upload_storyboard_asset(job_id: str, file: UploadFile = File(...)) -> dict: + if job_id not in JOBS: + raise HTTPException(404, "job not found") + asset_id = uuid.uuid4().hex[:12] + out_dir = job_dir(job_id) / "assets" + out_dir.mkdir(parents=True, exist_ok=True) + tmp = out_dir / f"{asset_id}.upload" + out = out_dir / f"{asset_id}.jpg" + try: + tmp.write_bytes(await file.read()) + img = Image.open(tmp).convert("RGB") + img.thumbnail((1600, 1600), Image.Resampling.LANCZOS) + img.save(out, "JPEG", quality=94) + except Exception as e: + raise HTTPException(400, f"product image upload failed: {e}") + finally: + try: + tmp.unlink() + except Exception: + pass + return { + "kind": "asset", + "frame_idx": -1, + "element_id": asset_id, + "cutout_id": asset_id, + "label": file.filename or "SKG 产品图", + } + + +@app.get("/jobs/{job_id}/assets/{asset_id}.jpg") +def get_storyboard_asset(job_id: str, asset_id: str): + p = job_dir(job_id) / "assets" / f"{asset_id}.jpg" + if not p.exists(): + raise HTTPException(404, "asset not found") + return FileResponse(p, media_type="image/jpeg") + + @app.delete("/jobs/{job_id}/storyboard-videos/{video_id}", response_model=Job) def delete_storyboard_video(job_id: str, video_id: str) -> Job: """删除 Video Gen 节点里的一个视频任务(成功/失败/排队都可删)。""" diff --git a/docs/source-analysis.html b/docs/source-analysis.html index 5566258..f2c26ea 100644 --- a/docs/source-analysis.html +++ b/docs/source-analysis.html @@ -830,6 +830,18 @@ api/main.py

变更记录

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

+
+
+

2026-05-13 · 产品参考支持本地上传和拖拽

+ StoryboardWorkbench + API +
+
+

问题:SKG 产品参考不能只依赖从关键帧/元素里复制,用户需要手动上传同一产品的多角度图。

+

改动:SKG 产品参考 区支持点击上传和拖拽上传图片,一次可传多张,最多保留 6 张。后端新增 POST /jobs/{job_id}/assets 保存产品图资产,并返回 kind: "asset"ImageRef;生成视频时这些资产会作为产品参考传入 Ark。

+

影响:web/components/storyboard-workbench.tsxweb/lib/api.tsapi/main.py

+
+

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

diff --git a/web/app/page.tsx b/web/app/page.tsx index 8305f48..19f63bb 100644 --- a/web/app/page.tsx +++ b/web/app/page.tsx @@ -246,6 +246,7 @@ export default function Home() { : null const firstRef = scene.first_image ?? keyframeRef const lastRef = scene.last_image ?? defaultLastRef + const productRefs = (scene.product_images?.length ? scene.product_images : scene.product_image ? [scene.product_image] : []) const duration = scene.duration && scene.duration > 0 ? scene.duration : 5 const sourceScene = frame.description?.scene ? `参考画面识别:${frame.description.scene}` : "" const sourceStyle = frame.description?.style ? `参考风格:${frame.description.style}` : "" @@ -272,7 +273,7 @@ export default function Home() { `连续动作和镜头:${actionDirection}`, `首帧:${labelOf(firstRef, "当前分镜关键帧")}`, `尾帧:${labelOf(lastRef, "未指定,按首帧小幅自然运动收尾")}`, - `SKG 产品参考:${labelOf(scene.product_image, "SKG 产品视觉主角")}`, + `SKG 产品参考:${productRefs.length ? productRefs.map((ref, i) => `${i + 1}. ${labelOf(ref, "SKG 产品角度")}`).join(";") : "SKG 产品视觉主角"}`, `动作参考:${labelOf(scene.action_image, "自然拿取、佩戴、展示或靠近产品的动作")}`, sourceScene, sourceStyle, @@ -290,9 +291,10 @@ export default function Home() { duration, first_image: firstRef, last_image: lastRef, + product_images: productRefs, subject_image: firstRef, scene_image: null, - product_image: scene.product_image ?? null, + product_image: productRefs[0] ?? 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 2dacb83..d22f53f 100644 --- a/web/components/storyboard-workbench.tsx +++ b/web/components/storyboard-workbench.tsx @@ -3,7 +3,7 @@ import { useEffect, useState, useRef } from "react" import { X, Loader2, Check, Wand2, GripHorizontal } from "lucide-react" import { type Job, type StoryboardScene, type ImageRef, - updateStoryboard, resolveImageRefUrl, + updateStoryboard, resolveImageRefUrl, uploadStoryboardAsset, } from "@/lib/api" import { toast } from "sonner" @@ -41,6 +41,7 @@ export function StoryboardWorkbench({ job, selectedFrames, open, onClose, onJobU const [generating, setGenerating] = useState(false) const saveTimer = useRef | null>(null) const loadedFormKey = useRef("") + const productFileInput = useRef(null) // Esc 关闭 useEffect(() => { @@ -139,6 +140,31 @@ export function StoryboardWorkbench({ job, selectedFrames, open, onClose, onJobU } const currentModelLabel = VIDEO_MODELS.find((m) => m.value === videoModel)?.label ?? "Seedance" + const productRefs = form.product_images?.length ? form.product_images : form.product_image ? [form.product_image] : [] + const setProductRefs = (refs: ImageRef[]) => { + const next = refs.slice(0, 6) + queueSave({ ...form, product_image: next[0] ?? null, product_images: next }) + } + const addProductFiles = async (files: FileList | File[]) => { + if (!job) return + const room = 6 - productRefs.length + if (room <= 0) { + toast.error("最多添加 6 张产品参考") + return + } + const imageFiles = Array.from(files).filter((file) => file.type.startsWith("image/")).slice(0, room) + if (imageFiles.length === 0) { + toast.error("请上传图片文件") + return + } + try { + const uploaded = await Promise.all(imageFiles.map((file) => uploadStoryboardAsset(job.id, file))) + setProductRefs([...productRefs, ...uploaded]) + toast.success(`已上传 ${uploaded.length} 张产品参考`) + } catch (e) { + toast.error("产品图上传失败:" + (e instanceof Error ? e.message : String(e))) + } + } return (
{/* 首尾帧:图片直接参与视频生成 */} -
+
{([ { 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 : key === "last_image" ? defaultLastRef : null const ref = form[key] ?? fallback @@ -255,9 +280,100 @@ export function StoryboardWorkbench({ job, selectedFrames, open, onClose, onJobU })}
- 现在按首尾帧出片:首帧默认当前分镜,尾帧默认下一张已选分镜;「SKG 产品」用于锁定产品外观。需要指定素材时,在任意关键帧、产品图或生成图点 📋,再粘贴到对应槽位。 + 现在按首尾帧出片:首帧默认当前分镜,尾帧默认下一张已选分镜;产品参考组用于锁定 SKG 外观。
+
+
+
+ SKG 产品参考 + {productRefs.length}/6 +
+ + + { + const files = e.target.files + if (files) void addProductFiles(files) + e.currentTarget.value = "" + }} + /> +
+
{ + e.preventDefault() + e.dataTransfer.dropEffect = productRefs.length < 6 ? "copy" : "none" + }} + onDrop={(e) => { + e.preventDefault() + if (e.dataTransfer.files?.length) void addProductFiles(e.dataTransfer.files) + }} + > + {productRefs.length === 0 ? ( +
+ 可添加同一 SKG 产品的不同角度,最多 6 张 +
+ ) : ( +
+ {productRefs.map((ref, i) => { + const url = resolveImageRefUrl(job.id, ref) + return ( +
+ {url && {`产品参考} +
#{i + 1}
+ +
+ ) + })} +
+ )} +
+
+ {/* 改造 brief:明确“借鉴参考 → 变成 SKG 产品视频”,避免直接复刻 */}
diff --git a/web/lib/api.ts b/web/lib/api.ts index 897b0ed..26c1513 100644 --- a/web/lib/api.ts +++ b/web/lib/api.ts @@ -48,7 +48,7 @@ export interface KeyElement { } export interface ImageRef { - kind: "keyframe" | "cutout" + kind: "keyframe" | "cutout" | "asset" frame_idx: number element_id?: string | null cutout_id?: string | null @@ -92,6 +92,9 @@ export function resolveImageRefUrl(jobId: string, ref: ImageRef): string { if (ref.kind === "keyframe") { return effectiveFrameUrl(jobId, { index: ref.frame_idx, cleaned_applied: false }) } + if (ref.kind === "asset" && ref.element_id) { + return `${API_BASE}/jobs/${jobId}/assets/${ref.element_id}.jpg` + } if (ref.element_id && ref.cutout_id) { if (ref.cutout_id === ref.element_id) { // legacy v1 @@ -102,6 +105,17 @@ export function resolveImageRefUrl(jobId: string, ref: ImageRef): string { return "" } +export async function uploadStoryboardAsset(jobId: string, file: File): Promise { + const fd = new FormData() + fd.append("file", file) + const res = await fetch(`${API_BASE}/jobs/${jobId}/assets`, { method: "POST", body: fd }) + if (!res.ok) { + const txt = await res.text().catch(() => "") + throw new Error(`uploadStoryboardAsset ${res.status} ${txt.slice(0, 300)}`) + } + return res.json() +} + export interface KeyFrame { index: number timestamp: number