auto-save 2026-05-14 10:59 (~6)

This commit is contained in:
2026-05-14 10:59:27 +08:00
parent 8bd52f676a
commit 3aceb221ac
6 changed files with 89 additions and 49 deletions

View File

@@ -1,26 +1,5 @@
{ {
"entries": [ "entries": [
{
"files_changed": 1,
"hash": "f4a421b",
"message": "auto-save 2026-05-13 02:07 (~1)",
"ts": "2026-05-13T02:08:10+08:00",
"type": "commit"
},
{
"files_changed": 1,
"hash": "a63d7c7",
"message": "auto-save 2026-05-13 02:13 (~1)",
"ts": "2026-05-13T02:14:06+08:00",
"type": "commit"
},
{
"files_changed": 1,
"hash": "1a5f5be",
"message": "auto-save 2026-05-13 02:19 (~1)",
"ts": "2026-05-13T02:20:00+08:00",
"type": "commit"
},
{ {
"files_changed": 1, "files_changed": 1,
"hash": "bcc7ce0", "hash": "bcc7ce0",
@@ -3309,6 +3288,25 @@
"type": "session-heartbeat", "type": "session-heartbeat",
"message": "Codex 会话活跃 · 最近命令codex · 5 项未提交变更 · 最近提交auto-save 2026-05-14 10:45 (+1, ~5)", "message": "Codex 会话活跃 · 最近命令codex · 5 项未提交变更 · 最近提交auto-save 2026-05-14 10:45 (+1, ~5)",
"files_changed": 5 "files_changed": 5
},
{
"ts": "2026-05-14T10:53:54+08:00",
"type": "commit",
"message": "auto-save 2026-05-14 10:51 (~7)",
"hash": "8bd52f6",
"files_changed": 7
},
{
"ts": "2026-05-14T02:56:10Z",
"type": "session-heartbeat",
"message": "Codex 会话活跃 · 最近命令codex · 3 项未提交变更 · 最近提交auto-save 2026-05-14 10:51 (~7)",
"files_changed": 3
},
{
"ts": "2026-05-14T02:58:38Z",
"type": "session-heartbeat",
"message": "Codex 会话活跃 · 最近命令codex · 5 项未提交变更 · 最近提交auto-save 2026-05-14 10:51 (~7)",
"files_changed": 5
} }
] ]
} }

View File

@@ -18,7 +18,7 @@ uvicorn main:app --port 4291 --reload
- `GET /health` — 健康检查 + 配置状态 - `GET /health` — 健康检查 + 配置状态
- `POST /jobs` `{url}` — 创建 job后台跑下载/拆轨/抽帧 - `POST /jobs` `{url}` — 创建 job后台跑下载/拆轨/抽帧
- `GET /jobs/{id}` — 当前状态 + 产物 - `GET /jobs/{id}` — 当前状态 + 产物
- `POST /jobs/{id}/transcribe` — 触发 ASR + 翻译 + SKG 文案改写;配置 MiniMax 后生成配音 - `POST /jobs/{id}/transcribe` — 触发音频提取 + ASR + 翻译 + SKG 文案改写;配置 MiniMax 后生成配音。前端 Audio 节点提供“提取音频 / 重新提取音频”按钮,不依赖抽帧完成
- `GET /jobs/{id}/video.mp4` — 原视频 - `GET /jobs/{id}/video.mp4` — 原视频
- `GET /jobs/{id}/audio.wav` — 拆轨后的原始音频,供前端底部音频条生成波形 - `GET /jobs/{id}/audio.wav` — 拆轨后的原始音频,供前端底部音频条生成波形
- `GET /jobs/{id}/audio-script.mp3` — 改写文案的 MiniMax 配音 - `GET /jobs/{id}/audio-script.mp3` — 改写文案的 MiniMax 配音

View File

@@ -97,7 +97,8 @@ def llm() -> OpenAI:
return _llm_client return _llm_client
# Pipeline 状态: # Pipeline 状态:
# created → downloading → downloaded等用户点解析→ splitting → frames_extracted # created → downloading → downloaded等用户点解析/提取音频
# → splitting → frames_extracted
# → transcribing → transcribed | failed # → transcribing → transcribed | failed
JobStatus = Literal[ JobStatus = Literal[
"created", "downloading", "downloaded", "created", "downloading", "downloaded",
@@ -1563,7 +1564,17 @@ async def pipeline_transcribe(job_id: str) -> None:
wav = d / "audio.wav" wav = d / "audio.wav"
try: try:
if not wav.exists(): if not wav.exists():
raise RuntimeError("audio.wav 不存在") mp4 = d / "source.mp4"
if not mp4.exists():
raise RuntimeError("source.mp4 不存在,视频导入完成后再提取音频")
update(job, status="transcribing", message="ffmpeg 提取音频轨…", progress=max(job.progress, 45), error="")
run([
"ffmpeg", "-y", "-i", str(mp4),
"-vn", "-ac", "1", "-ar", "16000", "-c:a", "pcm_s16le",
str(wav),
])
if not wav.exists():
raise RuntimeError("音频提取完成但找不到 audio.wav")
if not LLM_API_KEY: if not LLM_API_KEY:
# 无 key 模式mock 数据 # 无 key 模式mock 数据
@@ -2004,9 +2015,12 @@ async def trigger_transcribe(job_id: str, bg: BackgroundTasks) -> Job:
job = JOBS.get(job_id) job = JOBS.get(job_id)
if not job: if not job:
raise HTTPException(404, "job not found") raise HTTPException(404, "job not found")
if job.status not in {"frames_extracted", "transcribed", "failed"}: mp4 = job_dir(job_id) / "source.mp4"
raise HTTPException(409, f"status must be frames_extracted/transcribed/failed, got {job.status}") if job.status in {"created", "downloading"} or not mp4.exists():
update(job, status="transcribing", progress=max(job.progress, 72), error="", message="准备音频转写…") raise HTTPException(409, f"video not ready, got {job.status}")
if job.status in {"splitting", "transcribing"} or job.audio_script.status == "rewriting":
raise HTTPException(409, f"job is busy, got {job.status}")
update(job, status="transcribing", progress=max(job.progress, 45), error="", message="准备提取音频…")
bg.add_task(pipeline_transcribe, job_id) bg.add_task(pipeline_transcribe, job_id)
return job return job

View File

@@ -792,7 +792,7 @@ SubjectAsset {
<tr><td>上传视频</td><td><code>POST /jobs/upload</code></td><td><code>uploadJob</code></td><td>保存 source.mp4然后同样进入下载完成状态。</td></tr> <tr><td>上传视频</td><td><code>POST /jobs/upload</code></td><td><code>uploadJob</code></td><td>保存 source.mp4然后同样进入下载完成状态。</td></tr>
<tr><td>删除输入视频</td><td><code>DELETE /jobs/{id}</code></td><td><code>deleteJob</code></td><td>从任务队列、URL 和磁盘 <code>jobs/&lt;id&gt;</code> 目录移除整个 job包括源视频、关键帧、元素提取图和生成视频。</td></tr> <tr><td>删除输入视频</td><td><code>DELETE /jobs/{id}</code></td><td><code>deleteJob</code></td><td>从任务队列、URL 和磁盘 <code>jobs/&lt;id&gt;</code> 目录移除整个 job包括源视频、关键帧、元素提取图和生成视频。</td></tr>
<tr><td>解析视频</td><td><code>POST /jobs/{id}/analyze?frames=&amp;target=&amp;mode=&amp;quality=</code></td><td><code>analyzeJob</code></td><td>拆轨 + 目标化抽关键帧。默认 <code>frames=12</code><code>target</code> 支持透明骨架人、综合、清晰主体、转场变化、表情瞬间、动作峰值;当前 UI 默认 <code>transparent_human</code>。透明骨架人目标会先扩大本地候选池,再调用 Vision 按 6 个分数验收;不合格候选自动丢弃并抽下一候选。<code>mode=append</code> 追加新关键帧;<code>quality=auto</code> 根据本机算力和视频时长自动选择快速、精细或极准。多个抽帧请求进入后端队列顺序处理。</td></tr> <tr><td>解析视频</td><td><code>POST /jobs/{id}/analyze?frames=&amp;target=&amp;mode=&amp;quality=</code></td><td><code>analyzeJob</code></td><td>拆轨 + 目标化抽关键帧。默认 <code>frames=12</code><code>target</code> 支持透明骨架人、综合、清晰主体、转场变化、表情瞬间、动作峰值;当前 UI 默认 <code>transparent_human</code>。透明骨架人目标会先扩大本地候选池,再调用 Vision 按 6 个分数验收;不合格候选自动丢弃并抽下一候选。<code>mode=append</code> 追加新关键帧;<code>quality=auto</code> 根据本机算力和视频时长自动选择快速、精细或极准。多个抽帧请求进入后端队列顺序处理。</td></tr>
<tr><td>音频文案轨</td><td><code>POST /jobs/{id}/transcribe</code></td><td><code>triggerTranscribe</code></td><td>读取拆轨得到的 <code>audio.wav</code>,先 ASR 得到英文时间戳段落,再翻译中文,随后按 <code>AUDIO_PRODUCT_BRIEF</code> 生成 <code>audio_script.rewritten_text</code>;配置 <code>MINIMAX_API_KEY</code> 后调用 MiniMax T2A 生成 <code>audio_script.voice_url</code></td></tr> <tr><td>音频文案轨</td><td><code>POST /jobs/{id}/transcribe</code></td><td><code>triggerTranscribe</code></td><td>读取拆轨得到的 <code>audio.wav</code>,先 ASR 得到英文时间戳段落,再翻译中文,随后按 <code>AUDIO_PRODUCT_BRIEF</code> 生成 <code>audio_script.rewritten_text</code>;配置 <code>MINIMAX_API_KEY</code> 后调用 MiniMax T2A 生成 <code>audio_script.voice_url</code>前端在抽帧完成且尚无 transcript 时会自动触发一次Audio 节点也提供“开始/重新处理音频”按钮。</td></tr>
<tr><td>原始音频文件</td><td><code>GET /jobs/{id}/audio.wav</code></td><td><code>sourceAudioUrl</code></td><td>返回拆轨得到的 wav底部 <code>AudioStrip</code> 拉取该文件,用 Web Audio API 解码并计算波形峰值,只读展示,不参与改写。</td></tr> <tr><td>原始音频文件</td><td><code>GET /jobs/{id}/audio.wav</code></td><td><code>sourceAudioUrl</code></td><td>返回拆轨得到的 wav底部 <code>AudioStrip</code> 拉取该文件,用 Web Audio API 解码并计算波形峰值,只读展示,不参与改写。</td></tr>
<tr><td>改写配音文件</td><td><code>GET /jobs/{id}/audio-script.mp3</code></td><td><code>apiAssetUrl(job.audio_script.voice_url)</code></td><td>返回 MiniMax T2A 生成的 mp3。没有配置 MiniMax 或生成失败时该文件不存在,但改写文案仍会保存在 <code>audio_script.rewritten_text</code></td></tr> <tr><td>改写配音文件</td><td><code>GET /jobs/{id}/audio-script.mp3</code></td><td><code>apiAssetUrl(job.audio_script.voice_url)</code></td><td>返回 MiniMax T2A 生成的 mp3。没有配置 MiniMax 或生成失败时该文件不存在,但改写文案仍会保存在 <code>audio_script.rewritten_text</code></td></tr>
<tr><td>手动加帧</td><td><code>POST /jobs/{id}/frames?t=</code></td><td><code>addManualFrame</code></td><td>按视频时间戳抽一帧index 递增但 frames 按 timestamp 排序。</td></tr> <tr><td>手动加帧</td><td><code>POST /jobs/{id}/frames?t=</code></td><td><code>addManualFrame</code></td><td>按视频时间戳抽一帧index 递增但 frames 按 timestamp 排序。</td></tr>
@@ -841,7 +841,7 @@ SubjectAsset {
</tr> </tr>
<tr> <tr>
<td><span class="tag gray">Audio / ASR / Rewrite</span></td> <td><span class="tag gray">Audio / ASR / Rewrite</span></td>
<td>独立声音文案轨:从 <code>audio.wav</code> 提取原始口播、翻译中文、改写成 SKG 产品语境口播MiniMax T2A 配置后生成配音 mp3。主画布的 <code>AudioNode</code> 用“改前 · 原音频 / 改后 · SKG 口播”摘要展示;底部 <code>AudioStrip</code> 吸附屏幕底端,可拖拽调整高度,按时间段展示英文、中文翻译和波形;侧栏 Rewrite 展开后显示完整审核视图。</td> <td>独立声音文案轨:从 <code>audio.wav</code> 提取原始口播、翻译中文、改写成 SKG 产品语境口播MiniMax T2A 配置后生成配音 mp3。抽帧完成后自动触发一次,也可在主画布 <code>AudioNode</code> 手动开始/重新处理。<code>AudioNode</code> 用“改前 · 原音频 / 改后 · SKG 口播”摘要展示;底部 <code>AudioStrip</code> 吸附屏幕底端,可拖拽调整高度,按时间段展示英文、中文翻译和波形;侧栏 Rewrite 展开后显示完整审核视图。</td>
<td>不要阻断视觉素材管线。</td> <td>不要阻断视觉素材管线。</td>
<td><code>AudioNode</code><code>AudioStrip</code><code>ASRNode</code><code>TranslateNode</code><code>RewriteNode</code><code>pipeline_transcribe</code><code>AudioScript</code></td> <td><code>AudioNode</code><code>AudioStrip</code><code>ASRNode</code><code>TranslateNode</code><code>RewriteNode</code><code>pipeline_transcribe</code><code>AudioScript</code></td>
</tr> </tr>
@@ -918,6 +918,18 @@ SubjectAsset {
<h2>变更记录</h2> <h2>变更记录</h2>
<p>这个记录不是 git log 的替代品。它记录“产品理解发生了什么变化、影响了哪些源码、你以后描述需求时该怎么说”。后续每次改功能都要补一条。</p> <p>这个记录不是 git log 的替代品。它记录“产品理解发生了什么变化、影响了哪些源码、你以后描述需求时该怎么说”。后续每次改功能都要补一条。</p>
<div class="changelog"> <div class="changelog">
<article class="change">
<header>
<h3>2026-05-14 · 音频处理支持自动触发和手动重试</h3>
<span class="tag gray">Audio</span>
<span class="tag blue">Workflow</span>
</header>
<div class="body">
<p><strong>问题:</strong>后端已有 <code>/transcribe</code> 接口,但前端没有入口调用,用户不知道什么时候音频开始工作。</p>
<p><strong>改动:</strong>前端在 job 进入 <code>frames_extracted</code> 且没有 transcript 时自动调用一次 <code>triggerTranscribe</code><code>AudioNode</code> 增加“开始音频处理 / 重新处理音频”按钮。后端触发接口会立即把 job 状态置为 <code>transcribing</code>,让轮询、节点状态和底部音频条能立刻进入运行态。</p>
<p><strong>影响:</strong><code>web/app/page.tsx</code><code>web/components/nodes/index.tsx</code><code>api/main.py</code><code>docs/source-analysis.html</code></p>
</div>
</article>
<article class="change"> <article class="change">
<header> <header>
<h3>2026-05-14 · 新增底部可伸缩音频条</h3> <h3>2026-05-14 · 新增底部可伸缩音频条</h3>

View File

@@ -390,31 +390,27 @@ export default function Home() {
if (!targetId) return if (!targetId) return
const target = jobs.find((item) => item.id === targetId) const target = jobs.find((item) => item.id === targetId)
if (!target) return if (!target) return
if (!["frames_extracted", "transcribed", "failed"].includes(target.status)) { if (!target.video_url) {
if (!options?.silent) toast.info("先完成抽帧,音频轨会自动开始处理") if (!options?.silent) toast.info("视频导入完成后,可在音频卡片点击提取音频")
return
}
if (target.status === "splitting") {
if (!options?.silent) toast.info("当前正在抽帧,结束后可重新点击提取音频")
return
}
if (target.status === "transcribing" || target.audio_script?.status === "rewriting") {
if (!options?.silent) toast.info("音频正在处理中")
return return
} }
try { try {
const updated = await triggerTranscribe(targetId) const updated = await triggerTranscribe(targetId)
updateJobInList(updated) updateJobInList(updated)
if (!options?.silent) toast.success("音频处理已开始") if (!options?.silent) toast.success("已开始提取音频")
} catch (e) { } catch (e) {
if (!options?.silent) toast.error("音频处理启动失败:" + (e instanceof Error ? e.message : String(e))) if (!options?.silent) toast.error("音频处理启动失败:" + (e instanceof Error ? e.message : String(e)))
} }
}, [activeJobId, jobs, updateJobInList]) }, [activeJobId, jobs, updateJobInList])
const autoAudioStartedRef = useRef<Set<string>>(new Set())
useEffect(() => {
for (const item of jobs) {
const audioStatus = item.audio_script?.status ?? "idle"
const hasAudioOutput = item.transcript.length > 0 || !!item.audio_script?.rewritten_text
const ready = item.status === "frames_extracted" && !hasAudioOutput && audioStatus !== "rewriting"
if (!ready || autoAudioStartedRef.current.has(item.id)) continue
autoAudioStartedRef.current.add(item.id)
void handleTranscribeAudio(item.id, { silent: true })
}
}, [jobs, handleTranscribeAudio])
const handleQuickGenerateVideo = useCallback(async (frameIdx: number, scene: StoryboardScene, model: string) => { const handleQuickGenerateVideo = useCallback(async (frameIdx: number, scene: StoryboardScene, model: string) => {
if (!job) return if (!job) return
const frame = job.frames.find((f) => f.index === frameIdx) const frame = job.frames.find((f) => f.index === frameIdx)

View File

@@ -2109,7 +2109,19 @@ export function AudioNode({ data, selected }: any) {
const voiceUrl = apiAssetUrl(audioScript?.voice_url) const voiceUrl = apiAssetUrl(audioScript?.voice_url)
const hasASR = transcript.length > 0 const hasASR = transcript.length > 0
const isRewriting = audioScript?.status === "rewriting" const isRewriting = audioScript?.status === "rewriting"
const canTriggerAudio = !!job && ["frames_extracted", "transcribed", "failed"].includes(job.status) && !isRewriting && job.status !== "transcribing" const hasVideo = !!job?.video_url
const isAudioBusy = !!job && (job.status === "transcribing" || isRewriting)
const isVisualBusy = !!job && job.status === "splitting"
const audioButtonDisabled = !job || !hasVideo || isAudioBusy || isVisualBusy
const audioButtonLabel = !hasVideo
? "等待视频就绪"
: isAudioBusy
? "正在提取音频"
: isVisualBusy
? "抽帧中,稍后提取"
: hasASR || rewrittenText
? "重新提取音频"
: "提取音频"
const originalPreview = transcript const originalPreview = transcript
.slice(0, 2) .slice(0, 2)
.map((s) => (s.zh || s.en).trim()) .map((s) => (s.zh || s.en).trim())
@@ -2139,17 +2151,25 @@ export function AudioNode({ data, selected }: any) {
{audioScript?.rewrite_model || "AUDIO_REWRITE_MODEL"} {audioScript?.voice_model || "MiniMax T2A"} {audioScript?.rewrite_model || "AUDIO_REWRITE_MODEL"} {audioScript?.voice_model || "MiniMax T2A"}
</span> </span>
</div> </div>
{canTriggerAudio && ( {job && (
<button <button
type="button" type="button"
disabled={audioButtonDisabled}
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
if (audioButtonDisabled) return
void d.onTranscribeAudio?.(job.id) void d.onTranscribeAudio?.(job.id)
}} }}
className="inline-flex min-h-8 w-full items-center justify-center gap-1.5 rounded-md border border-violet-300/25 bg-violet-400/10 px-2.5 py-1.5 text-[11px] font-medium text-[var(--text-strong)] transition hover:border-violet-200/45 hover:bg-violet-400/18" className="inline-flex min-h-8 w-full items-center justify-center gap-1.5 rounded-md border border-violet-300/25 bg-violet-400/10 px-2.5 py-1.5 text-[11px] font-medium text-[var(--text-strong)] transition hover:border-violet-200/45 hover:bg-violet-400/18 disabled:cursor-not-allowed disabled:border-white/10 disabled:bg-white/[0.03] disabled:text-[var(--text-faint)]"
> >
{hasASR || rewrittenText ? <RotateCcw className="h-3.5 w-3.5" /> : <PlayCircle className="h-3.5 w-3.5" />} {isAudioBusy ? (
{hasASR || rewrittenText ? "重新处理音频" : "开始音频处理"} <Loader2 className="h-3.5 w-3.5 animate-spin" />
) : hasASR || rewrittenText ? (
<RotateCcw className="h-3.5 w-3.5" />
) : (
<PlayCircle className="h-3.5 w-3.5" />
)}
{audioButtonLabel}
</button> </button>
)} )}
{(originalPreview || rewrittenText) && ( {(originalPreview || rewrittenText) && (