auto-save 2026-05-17 20:47 (~4)

This commit is contained in:
2026-05-17 20:47:53 +08:00
parent 6f7bb910f7
commit db248221f7
4 changed files with 289 additions and 31 deletions

View File

@@ -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
}
]
}

View File

@@ -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 {

View File

@@ -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<string | null>(null)
const [copyOverrides, setCopyOverrides] = useState<Record<number, string>>({})
const [authorIntent, setAuthorIntent] = useState("")
const [scriptRewriteBusy, setScriptRewriteBusy] = useState<"all" | number | null>(null)
const productFileRef = useRef<HTMLInputElement | null>(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({
</div>
{rows.length ? (
<>
<div className="mb-2 grid gap-2 rounded-md border border-white/10 bg-black/24 p-2 xl:grid-cols-[minmax(0,1fr)_auto]">
<textarea
value={authorIntent}
onChange={(event) => setAuthorIntent(event.target.value)}
placeholder="作者想法:比如更像真实达人、前半段强调久坐低头、结尾弱化优惠、语气更轻松..."
className="min-h-[42px] resize-y rounded-md border border-white/10 bg-black/35 px-2.5 py-2 text-[11px] leading-snug text-white outline-none placeholder:text-white/25 focus:border-cyan-300/50"
/>
<div className="flex items-center justify-end gap-2">
<button
type="button"
onClick={() => void rewriteAllRows()}
disabled={scriptRewriteBusy !== null || !rows.length}
className="inline-flex h-9 items-center justify-center gap-1 rounded-md bg-white px-2.5 text-[11px] font-semibold text-black transition hover:bg-white/90 disabled:cursor-not-allowed disabled:opacity-40"
>
{scriptRewriteBusy === "all" ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Wand2 className="h-3.5 w-3.5" />}
</button>
<button
type="button"
onClick={() => setCopyOverrides({})}
disabled={scriptRewriteBusy !== null || !Object.keys(copyOverrides).length}
className="inline-flex h-9 items-center justify-center rounded-md border border-white/10 bg-white/[0.045] px-2.5 text-[11px] font-semibold text-white/60 transition hover:border-white/25 hover:text-white disabled:cursor-not-allowed disabled:opacity-35"
>
稿
</button>
</div>
</div>
<div className="max-h-[560px] space-y-2 overflow-y-auto pr-1">
{rows.map((row) => {
const refs = framesForRow(row)
const rowVideos = videosForRow(refs)
const busy = busyRow === row.index
const generating = videoBusyRow === row.index
const copyText = copyForRow(row)
return (
<article
key={row.index}
className="grid overflow-hidden rounded-md border border-white/10 bg-black/24 text-[11px] leading-snug text-white/64 xl:grid-cols-[70px_minmax(112px,0.68fr)_minmax(128px,0.78fr)_minmax(154px,0.92fr)_minmax(126px,0.74fr)_230px]"
className="grid overflow-hidden rounded-md border border-white/10 bg-black/24 text-[11px] leading-snug text-white/64 xl:grid-cols-[64px_minmax(96px,0.5fr)_minmax(132px,0.68fr)_minmax(176px,1fr)_minmax(128px,0.72fr)_230px]"
>
<StoryboardPlanCell label="分镜">
<div className="font-mono text-[11px] text-white/40">{row.start.toFixed(1)}-{row.end.toFixed(1)}s</div>
@@ -1475,11 +1574,24 @@ function AudioStoryboardPlanPanel({
</StoryboardPlanCell>
<StoryboardPlanCell label="原内容">
<p className="line-clamp-4" title={row.source}>{row.source}</p>
<p className="line-clamp-3 text-[10.5px] leading-snug" title={row.source}>{row.source}</p>
</StoryboardPlanCell>
<StoryboardPlanCell label="新口播文案">
<p className="line-clamp-4 text-white/82" title={row.skgCopy}>{row.skgCopy}</p>
<textarea
value={copyText}
onChange={(event) => patchRowCopy(row.index, event.target.value)}
className="min-h-[64px] w-full resize-y rounded border border-white/10 bg-black/32 px-2 py-1.5 text-[11px] leading-snug text-white/82 outline-none placeholder:text-white/25 focus:border-cyan-300/50"
/>
<button
type="button"
onClick={() => void rewriteSingleRow(row)}
disabled={scriptRewriteBusy !== null}
className="mt-1 inline-flex h-6 w-full items-center justify-center gap-1 rounded border border-white/10 bg-white/[0.045] px-2 text-[10.5px] font-semibold text-white/62 transition hover:border-cyan-300/35 hover:text-cyan-100 disabled:cursor-not-allowed disabled:opacity-35"
>
{scriptRewriteBusy === row.index ? <Loader2 className="h-3 w-3 animate-spin" /> : <Wand2 className="h-3 w-3" />}
</button>
</StoryboardPlanCell>
<StoryboardPlanCell label="画面规划 / 产品融入">
@@ -1539,6 +1651,7 @@ function AudioStoryboardPlanPanel({
)
})}
</div>
</>
) : (
<EmptyState text="音频解析完成后,这里会按逐句时间轴生成信息流复刻分镜工作台。先看结构,再按分镜定向抽参考帧和生成视频。" />
)}

View File

@@ -179,6 +179,35 @@ export async function generateProductAngleAsset(
return res.json()
}
export interface StoryboardScriptRewriteSegment {
index: number
start: number
end: number
role: string
source: string
current_text: string
}
export async function rewriteStoryboardScript(
jobId: string,
body: {
mode: "segment" | "all"
author_intent?: string
segments: StoryboardScriptRewriteSegment[]
},
): Promise<{ items: Array<{ index: number; text: string }> }> {
const res = await fetch(`${API_BASE}/jobs/${jobId}/script/rewrite`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
})
if (!res.ok) {
const txt = await res.text().catch(() => "")
throw new Error(`rewriteStoryboardScript ${res.status} ${txt.slice(0, 300)}`)
}
return res.json()
}
export interface ProductViewAnalysisItem {
index: number
view: string