diff --git a/.memory/worklog.json b/.memory/worklog.json index a084558..53699bd 100644 --- a/.memory/worklog.json +++ b/.memory/worklog.json @@ -1,19 +1,5 @@ { "entries": [ - { - "files_changed": 1, - "hash": "dd3d7b2", - "message": "auto-save 2026-05-12 21:38 (~1)", - "ts": "2026-05-12T21:38:30+08:00", - "type": "commit" - }, - { - "files_changed": 1, - "hash": "d2d1e25", - "message": "auto-save 2026-05-12 21:44 (~1)", - "ts": "2026-05-12T21:44:22+08:00", - "type": "commit" - }, { "files_changed": 1, "hash": "f8cd466", @@ -3336,6 +3322,19 @@ "type": "session-heartbeat", "message": "Claude 会话活跃 · 最近命令:claude · 1 项未提交变更 · 最近提交:auto-save 2026-05-14 07:28 (~6)", "files_changed": 1 + }, + { + "ts": "2026-05-14T07:34:24+08:00", + "type": "commit", + "message": "auto-save 2026-05-14 07:34 (~1)", + "hash": "19813b5", + "files_changed": 1 + }, + { + "ts": "2026-05-13T23:38:52Z", + "type": "session-heartbeat", + "message": "Codex 会话活跃 · 最近命令:codex · 2 项未提交变更 · 最近提交:auto-save 2026-05-14 07:34 (~1)", + "files_changed": 2 } ] } diff --git a/api/main.py b/api/main.py index 535b247..c8fbc88 100644 --- a/api/main.py +++ b/api/main.py @@ -2540,6 +2540,10 @@ class GenerateStoryboardVideoReq(BaseModel): size: str = "720x1280" +class ProductFusionDescriptionReq(BaseModel): + shots: list[ProductFusionShot] = Field(default_factory=list) + + def video_seconds(duration: float) -> str: if video_uses_ark(): if duration <= 0: @@ -2997,6 +3001,59 @@ def create_product_fusion_guide(job_id: str, req: ProductFusionShot) -> dict: } +def fallback_product_fusion_descriptions() -> list[str]: + return [ + "人物双手拿起 SKG 颈部按摩仪,准备戴到脖子上,镜头轻微推近产品。", + "人物把 SKG 按摩仪贴合到肩颈位置,手部轻轻调整两侧机身角度。", + "人物坐在场景中轻按侧边控制区,产品保持在画框指定区域内清晰可见。", + "人物闭眼放松,肩颈从紧绷变舒展,产品佩戴位置稳定不漂移。", + "镜头靠近展示 SKG 产品材质、按键和内侧触点,手部不要遮挡产品主体。", + "使用后的放松状态收尾,人物自然抬头,产品仍保持白色 U 形外观和真实比例。", + ] + + +@app.post("/jobs/{job_id}/product-fusion/descriptions") +def generate_product_fusion_descriptions(job_id: str, req: ProductFusionDescriptionReq) -> dict: + if job_id not in JOBS: + raise HTTPException(404, "job not found") + fallback = fallback_product_fusion_descriptions() + shots = (req.shots or [])[:6] + if not LLM_API_KEY: + return {"descriptions": fallback, "mode": "fallback"} + shot_lines = [] + for i, shot in enumerate(shots, start=1): + product = (shot.product_image or {}).get("label") or "SKG 产品图" + person = (shot.person_image or {}).get("label") or "白底人物姿态图" + scene = (shot.scene_image or {}).get("label") or "场景图" + region = shot.product_region + region_text = f"x={region.x:.2f}, y={region.y:.2f}, w={region.w:.2f}, h={region.h:.2f}" if region else "未画区域" + shot_lines.append(f"{i}. 产品={product};人物={person};区域={region_text};场景={scene};已有描述={shot.action_text or '空'}") + prompt = ( + "你是 SKG 产品短视频分镜导演。请为 6 条产品融合镜头各写一条中文动作描述," + "每条 20-40 字,必须说明人物在做什么、产品如何佩戴/展示、动作如何自然连续。" + "产品是 SKG 白色 U 形颈部/肩颈按摩仪,不要写医疗治疗承诺,不要出现竞品。" + "输出 JSON:{\"descriptions\":[\"...\", \"...\"]}。\n\n" + + "\n".join(shot_lines) + ) + try: + resp = llm().chat.completions.create( + model=REWRITE_MODEL, + messages=[ + {"role": "system", "content": "只输出合法 JSON,不要解释。"}, + {"role": "user", "content": prompt}, + ], + temperature=0.5, + ) + text = resp.choices[0].message.content or "" + data = json.loads(text) + descriptions = [str(x).strip() for x in data.get("descriptions", []) if str(x).strip()] + if len(descriptions) < 6: + descriptions = (descriptions + fallback)[:6] + return {"descriptions": descriptions[:6], "mode": "llm"} + except Exception: + return {"descriptions": fallback, "mode": "fallback"} + + @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" diff --git a/web/components/lightbox.tsx b/web/components/lightbox.tsx index 4650dbe..bd5d5fb 100644 --- a/web/components/lightbox.tsx +++ b/web/components/lightbox.tsx @@ -5,7 +5,7 @@ import { X, ChevronLeft, ChevronRight, Check, Sparkles, Wand2, Loader2, Eye, Ref import { frameUrl, cleanedFrameUrl, apiAssetUrl, describeFrame, cleanupFrame, applyCleanedFrame, discardCleanedFrame, addElement, updateElement, deleteElement, - generateSceneAsset, generateSubjectAssets, resolveImageRefUrl, uploadStoryboardAsset, updateStoryboard, + generateSceneAsset, generateSubjectAssets, generateProductFusionDescriptions, resolveImageRefUrl, uploadStoryboardAsset, updateStoryboard, type AssetBackground, type AssetSize, type KeyFrame, type Job, type ImageRef, type ProductFusionShot, type SceneMode, type SceneStyle, type SubjectKind, } from "@/lib/api" import { ProductLibraryPicker } from "@/components/product-library-picker" @@ -347,7 +347,7 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o fusionFileInputRef.current?.click() } - const draftFusionDescriptions = () => { + const draftFusionDescriptions = async () => { const actions = [ "人物双手拿起 SKG 颈部按摩仪,准备戴到脖子上,镜头轻微推近产品。", "人物把 SKG 按摩仪贴合到肩颈位置,手部轻轻调整两侧机身角度。", @@ -356,9 +356,16 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o "镜头靠近展示 SKG 产品材质、按键和内侧触点,手部不要遮挡产品主体。", "使用后的放松状态收尾,人物自然抬头,产品仍保持白色 U 形外观和真实比例。", ] + let descriptions = actions + try { + const result = await generateProductFusionDescriptions(jobId, fusionShots) + descriptions = result.descriptions.length ? result.descriptions : actions + } catch (e) { + toast.error("AI 描述生成失败,已使用本地草稿") + } const next = fusionShots.map((shot, i) => ({ ...shot, - action_text: shot.action_text?.trim() || actions[i], + action_text: shot.action_text?.trim() || descriptions[i] || actions[i], })) setFusionShots(next) void persistFusionShots(next) diff --git a/web/lib/api.ts b/web/lib/api.ts index e78f13f..f363457 100644 --- a/web/lib/api.ts +++ b/web/lib/api.ts @@ -177,6 +177,22 @@ export async function createProductFusionGuide( return res.json() } +export async function generateProductFusionDescriptions( + jobId: string, + shots: ProductFusionShot[], +): Promise<{ descriptions: string[]; mode: "llm" | "fallback" }> { + const res = await fetch(`${API_BASE}/jobs/${jobId}/product-fusion/descriptions`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ shots }), + }) + if (!res.ok) { + const txt = await res.text().catch(() => "") + throw new Error(`generateProductFusionDescriptions ${res.status} ${txt.slice(0, 300)}`) + } + return res.json() +} + export interface KeyFrame { index: number timestamp: number