diff --git a/.memory/worklog.json b/.memory/worklog.json index 4eb7b72..4b59256 100644 --- a/.memory/worklog.json +++ b/.memory/worklog.json @@ -1,36 +1,5 @@ { "entries": [ - { - "files_changed": 1, - "message": "Codex 会话活跃 · 最近命令:codex · 1 项未提交变更 · 最近提交:auto-save 2026-05-15 09:50 (~1)", - "ts": "2026-05-15T01:53:35Z", - "type": "session-heartbeat" - }, - { - "files_changed": 1, - "hash": "8575a45", - "message": "auto-save 2026-05-15 09:56 (~1)", - "ts": "2026-05-15T09:56:33+08:00", - "type": "commit" - }, - { - "files_changed": 1, - "message": "Codex 会话活跃 · 最近命令:codex · 1 项未提交变更 · 最近提交:auto-save 2026-05-15 09:56 (~1)", - "ts": "2026-05-15T02:01:07Z", - "type": "session-heartbeat" - }, - { - "files_changed": 1, - "message": "Codex 会话结束 · 持续 0 秒 · 最近命令:codex · 1 项未提交变更 · 最近提交:auto-save 2026-05-15 09:56 (~1)", - "ts": "2026-05-15T02:01:19Z", - "type": "session-end" - }, - { - "files_changed": 1, - "message": "Codex 会话结束 · 持续 0 秒 · 最近命令:codex · 1 项未提交变更 · 最近提交:auto-save 2026-05-15 09:56 (~1)", - "ts": "2026-05-15T02:01:19Z", - "type": "session-end" - }, { "files_changed": 1, "hash": "b610cd8", @@ -3272,6 +3241,39 @@ "type": "session-heartbeat", "message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 3 项未提交变更 · 最近提交:feat: add audio storyboard planning table", "files_changed": 3 + }, + { + "ts": "2026-05-17T16:00:25+08:00", + "type": "commit", + "message": "auto-save 2026-05-17 16:00 (~3)", + "hash": "300355d", + "files_changed": 3 + }, + { + "ts": "2026-05-17T16:03:36+08:00", + "type": "commit", + "message": "chore: align feed recreation worksheet naming", + "hash": "9400db6", + "files_changed": 2 + }, + { + "ts": "2026-05-17T08:08:26Z", + "type": "session-heartbeat", + "message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 1 项未提交变更 · 最近提交:chore: align feed recreation worksheet naming", + "files_changed": 1 + }, + { + "ts": "2026-05-17T16:15:48+08:00", + "type": "commit", + "message": "feat: add product refs and video candidate slots", + "hash": "c690979", + "files_changed": 2 + }, + { + "ts": "2026-05-17T08:18:26Z", + "type": "session-heartbeat", + "message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 1 项未提交变更 · 最近提交:feat: add product refs and video candidate slots", + "files_changed": 1 } ] } diff --git a/api/main.py b/api/main.py index 48fbf43..690bc14 100644 --- a/api/main.py +++ b/api/main.py @@ -4196,6 +4196,12 @@ class CopyCharacterLibraryAssetReq(BaseModel): character_id: str +class GenerateProductAngleAssetReq(BaseModel): + source_ref: dict + target_view: str + note: str = "" + + @app.get("/product-library/skg", response_model=list[ProductLibraryItem]) def list_skg_product_library() -> list[ProductLibraryItem]: """内置 SKG 白底产品图库。来源是本地筛选后的产品图 manifest。""" @@ -4254,6 +4260,41 @@ async def upload_storyboard_asset(job_id: str, file: UploadFile = File(...)) -> } +@app.post("/jobs/{job_id}/assets/product-angle") +def generate_product_angle_asset(job_id: str, req: GenerateProductAngleAssetReq) -> dict: + if job_id not in JOBS: + raise HTTPException(404, "job not found") + source_path = storyboard_ref_path(job_id, req.source_ref) + if not source_path or not source_path.exists(): + raise HTTPException(404, "source product image not found") + target_view = (req.target_view or "目标视角").strip() + note = (req.note or "").strip() + prompt = ( + "Use the reference image as the same SKG neck-and-shoulder wearable massage product. " + f"Generate a clean product-only white-background reference image in this missing view: {target_view}. " + "Preserve the exact product identity: white U-shaped shoulder/neck device, asymmetric left and right details, side buttons, inner metal massage contacts, opening width, material, thickness, curvature, and scale. " + "Do not mirror both sides into identical shapes; keep visible left/right asymmetry and believable shoulder-neck wearable proportions. " + "The product should be complete, centered, isolated on pure white, large enough to inspect, with no hands, people, packaging, text, UI, watermark, extra accessories, or scene background. " + "If the target view is not fully visible in the source, infer the missing surfaces conservatively from the same product design without inventing a new model. " + + (f"Additional operator note: {note}. " if note else "") + ) + models = [IMAGE_MODEL, "gemini-3.1-flash-image-preview", "gemini-2.5-flash-image"] + try: + img_bytes, _mode = _image_edit_call(source_path, prompt, models=models, fallback_text=False, max_attempts=3, max_side=1280) + except RuntimeError as e: + raise HTTPException(500, f"product angle generation failed: {e}") + asset_id = f"product_angle_{uuid.uuid4().hex[:10]}" + out_path = job_dir(job_id) / "assets" / f"{asset_id}.jpg" + _normalize_asset_image(img_bytes, out_path, source_path, "1024", "white", square=True, fill_subject=True) + return { + "kind": "asset", + "frame_idx": -1, + "element_id": asset_id, + "cutout_id": asset_id, + "label": f"AI 补角度 · {target_view}", + } + + @app.post("/jobs/{job_id}/assets/product-library") def copy_product_library_asset(job_id: str, req: CopyProductLibraryAssetReq) -> dict: if job_id not in JOBS: diff --git a/web/components/ad-recreation-board.tsx b/web/components/ad-recreation-board.tsx index 5449fa1..32e8ee3 100644 --- a/web/components/ad-recreation-board.tsx +++ b/web/components/ad-recreation-board.tsx @@ -21,6 +21,7 @@ import { apiAssetUrl, cutoutElement, effectiveFrameUrl, + generateProductAngleAsset, generateSubjectAssets, generatedImageUrl, hasCutout, @@ -82,6 +83,23 @@ type AudioStoryboardRow = { productIntegration: string } +type ProductRefItem = { + id: string + ref: ImageRef + view: string + note: string + source: "upload" | "ai" +} + +const PRODUCT_VIEW_SLOTS = [ + { value: "front", label: "正面", hint: "整体 U 形轮廓、开口宽度、主外观" }, + { value: "left_45", label: "左 45", hint: "左侧弧度、按钮/结构差异" }, + { value: "right_45", label: "右 45", hint: "右侧弧度、另一侧非对称细节" }, + { value: "side_thickness", label: "侧面厚度", hint: "机身厚度、后颈包裹体积" }, + { value: "inner_contacts", label: "内侧触点", hint: "按摩触点、贴颈面、佩戴比例" }, + { value: "back_bottom", label: "背面/底部", hint: "底面、背部闭合结构、补缺" }, +] as const + const controlClass = "h-10 rounded-md border border-white/10 bg-black/55 px-3 text-[12px] text-white outline-none transition focus:border-cyan-300/60 disabled:cursor-not-allowed disabled:opacity-40" @@ -290,9 +308,37 @@ function buildAudioStoryboardRows(job: Job | null): AudioStoryboardRow[] { }) } -function buildStoryboardSceneFromAudioRow(row: AudioStoryboardRow, frame: KeyFrame, nextFrame?: KeyFrame | null, productRefs: ImageRef[] = []): StoryboardScene { - const productGuidance = productRefs.length - ? "产品白底图已上传:生成时必须同时参考正面、左侧、右侧、厚度和内侧触点/佩戴比例,保留左右非对称细节,不要把两边做成镜像对称;肩颈产品大小必须贴近真实佩戴比例,不能缩成耳机,也不能放大成护颈枕。" +function productRefKey(ref: ImageRef, index: number) { + return `${ref.kind}:${ref.frame_idx}:${ref.element_id ?? ""}:${ref.cutout_id ?? ""}:${index}` +} + +function productViewLabel(view: string) { + return PRODUCT_VIEW_SLOTS.find((slot) => slot.value === view)?.label ?? view +} + +function createProductRefItem(ref: ImageRef, index: number, source: ProductRefItem["source"] = "upload", view?: string): ProductRefItem { + const slot = PRODUCT_VIEW_SLOTS[index] ?? PRODUCT_VIEW_SLOTS[PRODUCT_VIEW_SLOTS.length - 1] + return { + id: productRefKey(ref, index), + ref, + view: view ?? slot.value, + note: slot.hint, + source, + } +} + +function productReferenceNotes(items: ProductRefItem[]) { + if (!items.length) return "" + return items + .map((item, index) => `${index + 1}. ${productViewLabel(item.view)}:${item.note || "无补充备注"}`) + .join(";") +} + +function buildStoryboardSceneFromAudioRow(row: AudioStoryboardRow, frame: KeyFrame, nextFrame?: KeyFrame | null, productItems: ProductRefItem[] = []): StoryboardScene { + const productRefs = productItems.map((item) => item.ref) + const notes = productReferenceNotes(productItems) + const productGuidance = productItems.length + ? `产品白底图已上传:生成时必须同时参考各视角备注。视角备注:${notes}。保留左右非对称细节,不要把两边做成镜像对称;肩颈产品大小必须贴近真实佩戴比例,不能缩成耳机,也不能放大成护颈枕。` : "未上传产品白底图时使用默认 SKG 产品图;生成前建议补 5 张白底图锁定左右差异、厚度和佩戴比例。" return { duration: Number(Math.max(3.2, Math.min(6.5, row.end - row.start || 4.5)).toFixed(1)), diff --git a/web/lib/api.ts b/web/lib/api.ts index 4c88896..3d93382 100644 --- a/web/lib/api.ts +++ b/web/lib/api.ts @@ -147,6 +147,22 @@ export async function uploadStoryboardAsset(jobId: string, file: File): Promise< return res.json() } +export async function generateProductAngleAsset( + jobId: string, + body: { source_ref: ImageRef; target_view: string; note?: string }, +): Promise { + const res = await fetch(`${API_BASE}/jobs/${jobId}/assets/product-angle`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }) + if (!res.ok) { + const txt = await res.text().catch(() => "") + throw new Error(`generateProductAngleAsset ${res.status} ${txt.slice(0, 300)}`) + } + return res.json() +} + export async function listProductLibrary(): Promise { const res = await fetch(`${API_BASE}/product-library/skg`) if (!res.ok) {