diff --git a/.memory/worklog.json b/.memory/worklog.json index f25eedc..736aa75 100644 --- a/.memory/worklog.json +++ b/.memory/worklog.json @@ -1,32 +1,5 @@ { "entries": [ - { - "files_changed": 1, - "hash": "2b3e7bd", - "message": "auto-save 2026-05-15 12:18 (~1)", - "ts": "2026-05-15T12:19:08+08:00", - "type": "commit" - }, - { - "files_changed": 1, - "hash": "c14e065", - "message": "auto-save 2026-05-15 12:24 (~1)", - "ts": "2026-05-15T12:24:39+08:00", - "type": "commit" - }, - { - "files_changed": 1, - "message": "Codex 会话活跃 · 最近命令:codex · 1 项未提交变更 · 最近提交:auto-save 2026-05-15 12:24 (~1)", - "ts": "2026-05-15T04:24:45Z", - "type": "session-heartbeat" - }, - { - "files_changed": 1, - "hash": "15d8b4c", - "message": "auto-save 2026-05-15 12:29 (~1)", - "ts": "2026-05-15T12:30:08+08:00", - "type": "commit" - }, { "files_changed": 1, "message": "Codex 会话活跃 · 最近命令:codex · 1 项未提交变更 · 最近提交:auto-save 2026-05-15 12:29 (~1)", @@ -3263,6 +3236,32 @@ "type": "session-heartbeat", "message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 4 项未提交变更 · 最近提交:auto-save 2026-05-17 20:15 (~4)", "files_changed": 4 + }, + { + "ts": "2026-05-17T20:20:36+08:00", + "type": "commit", + "message": "auto-save 2026-05-17 20:20 (~4)", + "hash": "8990db4", + "files_changed": 4 + }, + { + "ts": "2026-05-17T12:28:29Z", + "type": "session-heartbeat", + "message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 2 项未提交变更 · 最近提交:auto-save 2026-05-17 20:20 (~4)", + "files_changed": 2 + }, + { + "ts": "2026-05-17T20:30:30+08:00", + "type": "commit", + "message": "fix: harden product view parsing", + "hash": "6f7bb91", + "files_changed": 1 + }, + { + "ts": "2026-05-17T12:38:29Z", + "type": "session-heartbeat", + "message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 1 项未提交变更 · 最近提交:fix: harden product view parsing", + "files_changed": 1 } ] } diff --git a/api/main.py b/api/main.py index 9d90511..5018bcb 100644 --- a/api/main.py +++ b/api/main.py @@ -2567,6 +2567,21 @@ class TranslateReq(BaseModel): target: Literal["en", "zh"] = "en" +class ScriptRewriteSegmentReq(BaseModel): + index: int + start: float = 0.0 + end: float = 0.0 + role: str = "" + source: str = "" + current_text: str = "" + + +class RewriteStoryboardScriptReq(BaseModel): + mode: Literal["segment", "all"] = "segment" + author_intent: str = "" + segments: list[ScriptRewriteSegmentReq] = Field(default_factory=list) + + @app.post("/translate") def translate_text(req: TranslateReq) -> dict: """单条文本翻译(给生图自定义提取元素 zh→en 用)""" @@ -2602,6 +2617,108 @@ def translate_text(req: TranslateReq) -> dict: raise HTTPException(500, f"translate failed: {e}") +def _fallback_script_rewrite_item(segment: ScriptRewriteSegmentReq, author_intent: str = "") -> dict: + text = (segment.current_text or "").strip() + source = (segment.source or "").strip() + intent = (author_intent or "").strip() + if text: + rewritten = text + elif source: + rewritten = f"这一段按原片节奏切到 SKG 肩颈按摩仪,把{source[:28]}转成肩颈放松场景。" + else: + rewritten = "这一段延续原片节奏,把画面和口播落到 SKG 肩颈放松体验。" + if intent and intent not in rewritten: + rewritten = f"{rewritten} {intent[:48]}" + return {"index": segment.index, "text": rewritten[:220]} + + +def _parse_script_rewrite_items(raw: str, requested: list[ScriptRewriteSegmentReq], author_intent: str = "") -> list[dict]: + text = (raw or "").strip() + text = re.sub(r"^```(?:json)?\s*", "", text, flags=re.I).strip() + text = re.sub(r"\s*```$", "", text).strip() + match = re.search(r"\{[\s\S]*\}", text) + json_text = match.group(0) if match else text + try: + data = json.loads(json_text) + except Exception: + return [_fallback_script_rewrite_item(segment, author_intent) for segment in requested] + raw_items = data.get("items") if isinstance(data, dict) else data + if not isinstance(raw_items, list): + raw_items = [] + by_index: dict[int, str] = {} + for item in raw_items: + if not isinstance(item, dict): + continue + try: + idx = int(item.get("index")) + except Exception: + continue + value = str(item.get("text") or item.get("rewritten_text") or "").strip() + if value: + by_index[idx] = re.sub(r"\s+", " ", value).strip()[:260] + return [ + {"index": segment.index, "text": by_index.get(segment.index) or _fallback_script_rewrite_item(segment, author_intent)["text"]} + for segment in requested + ] + + +def _rewrite_storyboard_script_sync(req: RewriteStoryboardScriptReq) -> list[dict]: + segments = [segment for segment in req.segments if (segment.source or segment.current_text).strip()] + if not segments: + return [] + author_intent = (req.author_intent or "").strip() + if not LLM_API_KEY: + return [_fallback_script_rewrite_item(segment, author_intent) for segment in segments] + payload = [ + { + "index": segment.index, + "time": f"{segment.start:.1f}-{segment.end:.1f}s", + "role": segment.role, + "source_reference": segment.source, + "current_voiceover": segment.current_text, + } + for segment in segments + ] + prompt = ( + "你是信息流广告脚本文案改写师。任务:基于原参考文案的节奏和信息结构,把每段改写成 SKG 挂脖肩颈按摩仪的新口播文案。\n" + "硬规则:\n" + "1. 输出中文短视频口播,不要英文,不要舞台说明,不要引号。\n" + "2. 不逐字翻译原文,不保留原品牌、价格、优惠码、平台话术;只参考节奏、钩子、痛点、转化结构。\n" + "3. 产品固定为套在脖子上的 U 形肩颈按摩仪,表达肩颈紧绷、久坐低头、热敷感、揉按感、佩戴放松和日常使用场景。\n" + "4. 避免医疗疗效、治疗、治愈、止痛等强功效承诺。\n" + "5. 每段尽量短,适配该段时间;保持自然创作者口吻。\n" + "6. mode=all 时,整片要前后连贯;mode=segment 时,只改给定段落但仍要贴合上下文风格。\n" + f"作者想法:{author_intent or '没有额外想法,按原片节奏改成自然卖点口播。'}\n" + f"改写模式:{req.mode}\n" + f"SKG 产品背景:{AUDIO_PRODUCT_BRIEF}\n\n" + "输入段落 JSON:\n" + + json.dumps(payload, ensure_ascii=False) + + '\n\n只输出严格 JSON:{"items":[{"index":0,"text":"改写后的中文口播"}]}' + ) + try: + resp = llm().chat.completions.create( + model=AUDIO_REWRITE_MODEL, + messages=[ + {"role": "system", "content": "只返回合法 JSON,不要 markdown,不要解释。"}, + {"role": "user", "content": prompt}, + ], + response_format={"type": "json_object"}, + temperature=0.68 if req.mode == "all" else 0.62, + max_tokens=max(900, min(5000, 180 * len(segments) + 500)), + ) + raw = (resp.choices[0].message.content or "").strip() + return _parse_script_rewrite_items(raw, segments, author_intent) + except Exception: + return [_fallback_script_rewrite_item(segment, author_intent) for segment in segments] + + +@app.post("/jobs/{job_id}/script/rewrite") +def rewrite_storyboard_script(job_id: str, req: RewriteStoryboardScriptReq) -> dict: + if job_id not in JOBS: + raise HTTPException(404, "job not found") + return {"items": _rewrite_storyboard_script_sync(req)} + + @app.get("/health") def health() -> dict: return { diff --git a/web/components/ad-recreation-board.tsx b/web/components/ad-recreation-board.tsx index 14a019f..45ae852 100644 --- a/web/components/ad-recreation-board.tsx +++ b/web/components/ad-recreation-board.tsx @@ -17,6 +17,7 @@ import { type KeyElement, type KeyFrame, type ProductViewAnalysisItem, + type StoryboardScriptRewriteSegment, type StoryboardScene, type SubjectKind, addElement, @@ -30,6 +31,7 @@ import { hasCutout, representativeCutoutUrl, resolveImageRefUrl, + rewriteStoryboardScript, sourceAudioUrl, updateStoryboard, uploadStoryboardAsset, @@ -1174,14 +1176,35 @@ function AudioStoryboardPlanPanel({ const [productUploading, setProductUploading] = useState(false) const [productAnalyzing, setProductAnalyzing] = useState(false) const [productAngleBusy, setProductAngleBusy] = useState(null) + const [copyOverrides, setCopyOverrides] = useState>({}) + const [authorIntent, setAuthorIntent] = useState("") + const [scriptRewriteBusy, setScriptRewriteBusy] = useState<"all" | number | null>(null) const productFileRef = useRef(null) const rows = useMemo(() => buildAudioStoryboardRows(job), [job]) const orderedFrames = useMemo(() => job ? [...job.frames].sort((a, b) => a.timestamp - b.timestamp) : [], [job]) useEffect(() => { setProductItems([]) + setCopyOverrides({}) + setAuthorIntent("") + setScriptRewriteBusy(null) }, [job?.id]) + const copyForRow = (row: AudioStoryboardRow) => copyOverrides[row.index] ?? row.skgCopy + + const patchRowCopy = (rowIndex: number, value: string) => { + setCopyOverrides((prev) => ({ ...prev, [rowIndex]: value })) + } + + const rewriteSegmentForRow = (row: AudioStoryboardRow): StoryboardScriptRewriteSegment => ({ + index: row.index, + start: row.start, + end: row.end, + role: row.role, + source: row.source, + current_text: copyForRow(row), + }) + const framesForRow = (row: AudioStoryboardRow) => orderedFrames.filter((frame) => frame.timestamp >= row.start - 0.2 && frame.timestamp <= row.end + 0.2).slice(0, 3) @@ -1330,6 +1353,53 @@ function AudioStoryboardPlanPanel({ await analyzeAndCompleteProductViews(productItems.map((item) => item.ref)) } + const applyScriptRewriteItems = (items: Array<{ index: number; text: string }>) => { + if (!items.length) return + setCopyOverrides((prev) => { + const next = { ...prev } + for (const item of items) { + if (item.text?.trim()) next[item.index] = item.text.trim() + } + return next + }) + } + + const rewriteSingleRow = async (row: AudioStoryboardRow) => { + if (!job) return + setScriptRewriteBusy(row.index) + try { + const result = await rewriteStoryboardScript(job.id, { + mode: "segment", + author_intent: authorIntent, + segments: [rewriteSegmentForRow(row)], + }) + applyScriptRewriteItems(result.items) + toast.success(`分镜 ${row.index + 1} 已改写`) + } catch (e) { + toast.error("单段改写失败:" + (e instanceof Error ? e.message : String(e))) + } finally { + setScriptRewriteBusy(null) + } + } + + const rewriteAllRows = async () => { + if (!job || !rows.length) return + setScriptRewriteBusy("all") + try { + const result = await rewriteStoryboardScript(job.id, { + mode: "all", + author_intent: authorIntent, + segments: rows.map(rewriteSegmentForRow), + }) + applyScriptRewriteItems(result.items) + toast.success("整片新口播文案已改写") + } catch (e) { + toast.error("整片改写失败:" + (e instanceof Error ? e.message : String(e))) + } finally { + setScriptRewriteBusy(null) + } + } + const generateMissingProductAngle = async (slot: typeof PRODUCT_VIEW_SLOTS[number]) => { if (!job || !productItems.length) return const source = productItems[0] @@ -1353,7 +1423,7 @@ function AudioStoryboardPlanPanel({ if (!job || !refs.length || !onGenerateVideo) return const frame = refs[0] const nextFrame = orderedFrames.find((item) => item.timestamp > frame.timestamp) ?? null - const scene = buildStoryboardSceneFromAudioRow(row, frame, nextFrame, productItems) + const scene = buildStoryboardSceneFromAudioRow({ ...row, skgCopy: copyForRow(row) }, frame, nextFrame, productItems) setVideoBusyRow(row.index) try { const updated = await updateStoryboard(job.id, frame.index, scene) @@ -1456,16 +1526,45 @@ function AudioStoryboardPlanPanel({ {rows.length ? ( + <> +
+