From 2d1a89f03ea4c12324333e823f70351738e23190 Mon Sep 17 00:00:00 2001 From: kang Date: Thu, 14 May 2026 12:43:03 +0800 Subject: [PATCH] auto-save 2026-05-14 12:42 (~9) --- .memory/worklog.json | 27 ++++---- RULES.md | 5 +- api/.env.example | 1 + api/README.md | 6 +- api/main.py | 122 +++++++++++++++++++++++++-------- docs/source-analysis.html | 20 +++--- web/components/audio-strip.tsx | 4 +- web/components/dashboard.tsx | 6 +- web/components/nodes/index.tsx | 8 +-- 9 files changed, 132 insertions(+), 67 deletions(-) diff --git a/.memory/worklog.json b/.memory/worklog.json index 50d2070..aae3869 100644 --- a/.memory/worklog.json +++ b/.memory/worklog.json @@ -1,19 +1,5 @@ { "entries": [ - { - "files_changed": 1, - "hash": "4a9264a", - "message": "auto-save 2026-05-13 05:57 (~1)", - "ts": "2026-05-13T05:58:08+08:00", - "type": "commit" - }, - { - "files_changed": 1, - "hash": "dc5f8d9", - "message": "auto-save 2026-05-13 06:03 (~1)", - "ts": "2026-05-13T06:04:03+08:00", - "type": "commit" - }, { "files_changed": 1, "hash": "169951b", @@ -3288,6 +3274,19 @@ "type": "session-heartbeat", "message": "Codex 会话活跃 · 最近命令:codex · 6 项未提交变更 · 最近提交:auto-save 2026-05-14 12:31 (~2)", "files_changed": 6 + }, + { + "ts": "2026-05-14T12:37:30+08:00", + "type": "commit", + "message": "auto-save 2026-05-14 12:37 (~6)", + "hash": "3733151", + "files_changed": 6 + }, + { + "ts": "2026-05-14T04:38:39Z", + "type": "session-heartbeat", + "message": "Codex 会话活跃 · 最近命令:codex · 1 项未提交变更 · 最近提交:auto-save 2026-05-14 12:37 (~6)", + "files_changed": 1 } ] } diff --git a/RULES.md b/RULES.md index ac821a0..c5baa22 100644 --- a/RULES.md +++ b/RULES.md @@ -37,10 +37,11 @@ - `ASR_FALLBACK_MODEL`:当当前网关没有 `/audio/transcriptions` 时,用 Gemini 多模态 chat 直接识别 wav,默认 `gemini-2.5-flash` - `TRANSLATE_MODEL`:字幕翻译模型,默认 `gemini-2.5-flash` - `REWRITE_MODEL`:通用改写/分镜描述模型,默认 `gemini-2.5-pro` -- `AUDIO_REWRITE_MODEL`:音频口播改写模型,默认跟随 `REWRITE_MODEL`;当前产物要求输出英文 SKG voice-over +- `AUDIO_REWRITE_MODEL`:音频口播改写模型,默认跟随 `REWRITE_MODEL`;当前产物要求按原音频时长输出英文 SKG 产品介绍 voice-over - `AUDIO_PRODUCT_BRIEF`:音频口播改写时注入的 SKG 产品卖点 - `MINIMAX_API_KEY`:MiniMax T2A 配音 Key,只能放本地 `api/.env`,不能入库 -- `MINIMAX_TTS_BASE_URL` / `MINIMAX_TTS_MODEL` / `MINIMAX_TTS_VOICE_ID`:MiniMax 配音端点、模型和音色配置;当前默认英文音色 `English_expressive_narrator` +- `MINIMAX_TTS_BASE_URL` / `MINIMAX_TTS_MODEL` / `MINIMAX_TTS_VOICE_ID`:MiniMax 配音端点、模型和兜底音色配置 +- `MINIMAX_TTS_VOICE_POOL`:MiniMax 英文随机音色池;当前默认男声 `English_magnetic_voiced_man`、女声 `English_Upbeat_Woman`、成熟声 `English_MaturePartner` - `POE_API_KEY` / `VIDEO_API_KEY`:视频生成通道 Key,只能放本地环境变量 ## 规则 diff --git a/api/.env.example b/api/.env.example index 1ed17f6..a49662b 100644 --- a/api/.env.example +++ b/api/.env.example @@ -20,6 +20,7 @@ MINIMAX_API_KEY= MINIMAX_TTS_BASE_URL=https://api.minimax.io MINIMAX_TTS_MODEL=speech-2.8-turbo MINIMAX_TTS_VOICE_ID=English_expressive_narrator +MINIMAX_TTS_VOICE_POOL=English_magnetic_voiced_man,English_Upbeat_Woman,English_MaturePartner # Poe 视频 API(优先用于 Seedance / Kling / Veo) POE_API_BASE_URL=https://api.poe.com/v1 diff --git a/api/README.md b/api/README.md index b03cc8f..3390690 100644 --- a/api/README.md +++ b/api/README.md @@ -1,6 +1,6 @@ # SKG TK 二创 API -FastAPI 后端,跑 yt-dlp + ffmpeg + ASR/翻译/英文 SKG 文案改写 + MiniMax 英文配音管线。 +FastAPI 后端,跑 yt-dlp + ffmpeg + ASR/翻译/英文 SKG 产品介绍文案 + MiniMax 英文配音管线。 ## 启动 @@ -20,7 +20,7 @@ uvicorn main:app --host 127.0.0.1 --port 4291 - `GET /health` — 健康检查 + 配置状态 - `POST /jobs` `{url}` — 创建 job,后台下载源视频,视频就绪后可手动解析或提取音频 - `GET /jobs/{id}` — 当前状态 + 产物;若原始音轨已拆出,会返回 `source_audio_url` -- `POST /jobs/{id}/transcribe` — 触发音频提取 + ASR + 翻译 + SKG 英文文案改写;配置 MiniMax 后生成英文配音。前端 Audio 节点提供“提取音频 / 重新提取音频”按钮,可与抽帧并行,不自动触发 +- `POST /jobs/{id}/transcribe` — 触发音频提取 + ASR + 翻译 + SKG 英文产品介绍文案;文案长度按原音频时长估算,配置 MiniMax 后从英文随机音色池生成配音。前端 Audio 节点提供“提取音频 / 重新提取音频”按钮,可与抽帧并行,不自动触发 - `GET /jobs/{id}/video.mp4` — 原视频 - `GET /jobs/{id}/audio.wav` — 拆轨后的原始音频,供前端底部音频条生成波形 - `GET /jobs/{id}/audio-script.mp3` — 英文改写文案的 MiniMax 配音 @@ -35,4 +35,4 @@ uvicorn main:app --host 127.0.0.1 --port 4291 - `ffmpeg` 系统二进制(拆轨 / 抽帧) - `yt-dlp` 系统二进制(也可走 Python 包) - OpenAI 兼容 LLM 网关(ASR / 翻译 / 文案改写);如果 `/audio/transcriptions` 不可用,会用 `ASR_FALLBACK_MODEL` 走 Gemini 多模态音频识别 -- MiniMax T2A HTTP(英文改写文案配音,使用 `MINIMAX_API_KEY`;默认音色 `English_expressive_narrator`) +- MiniMax T2A HTTP(英文产品介绍文案配音,使用 `MINIMAX_API_KEY`;默认随机音色池 `English_magnetic_voiced_man,English_Upbeat_Woman,English_MaturePartner`) diff --git a/api/main.py b/api/main.py index 0d81386..f859fae 100644 --- a/api/main.py +++ b/api/main.py @@ -4,6 +4,7 @@ import asyncio import base64 import json import os +import random import shutil import subprocess import threading @@ -51,6 +52,16 @@ MINIMAX_TTS_VOICE_ID = os.getenv( "MINIMAX_TTS_VOICE_ID", "English_expressive_narrator", ).strip() or "English_expressive_narrator" +DEFAULT_MINIMAX_TTS_VOICE_POOL = [ + "English_magnetic_voiced_man", + "English_Upbeat_Woman", + "English_MaturePartner", +] +MINIMAX_TTS_VOICE_POOL = [ + v.strip() + for v in os.getenv("MINIMAX_TTS_VOICE_POOL", ",".join(DEFAULT_MINIMAX_TTS_VOICE_POOL)).split(",") + if v.strip() +] POE_API_BASE_URL = os.getenv("POE_API_BASE_URL", "https://api.poe.com/v1").strip() or "https://api.poe.com/v1" POE_API_KEY = os.getenv("POE_API_KEY", "").strip() @@ -1522,31 +1533,60 @@ def _transcript_join(segments: list[TranscriptSegment], field: Literal["en", "zh return "\n".join(lines) -def _fallback_audio_script(segments: list[TranscriptSegment]) -> str: - joined = " ".join((s.en or s.zh).strip() for s in segments if (s.en or s.zh).strip()) - if not joined: - return "Ease into the moment with SKG. Gentle warmth and rhythmic massage help everyday tension feel lighter, cleaner, and easier to leave behind." +def _voiceover_target_words(target_seconds: float) -> tuple[int, int]: + seconds = max(4.0, min(float(target_seconds or 0) or 12.0, 45.0)) + center = int(round(seconds * 2.35)) + return max(10, int(center * 0.86)), min(110, max(14, int(center * 1.12))) + + +def _segment_duration(segments: list[TranscriptSegment]) -> float: + if not segments: + return 0.0 + start = min((s.start for s in segments), default=0.0) + end = max((s.end for s in segments), default=0.0) + return max(0.0, end - start) + + +def _fallback_audio_script(segments: list[TranscriptSegment], target_seconds: float = 12.0) -> str: + seconds = max(target_seconds, _segment_duration(segments), 4.0) + if seconds <= 7: + return "Meet SKG: warm massage, easy comfort, and a tiny reset for busy bodies." + if seconds <= 13: + return ( + "Meet SKG, your shortcut to a calmer body break. A little warmth, a steady massage rhythm, " + "and suddenly your day feels less tight and more yours." + ) + if seconds <= 22: + return ( + "This is SKG: smart massage for the moments your body asks for a pause. Warmth, rhythm, " + "and a clean wearable feel turn neck, back, or everyday tension into a softer reset." + ) return ( - "Let SKG turn a short break into real relief. With soothing warmth and steady massage rhythm, " - "everyday tension feels lighter, calmer, and easier to leave behind." + "Say hello to SKG, the small reset button your day keeps asking for. From neck and shoulder breaks " + "to back, eye, knee, or foot comfort, SKG brings warm, rhythmic massage into everyday routines, " + "so winding down feels simple, smart, and a little more fun." ) -def _rewrite_audio_script_sync(segments: list[TranscriptSegment]) -> tuple[str, str]: - fallback = _fallback_audio_script(segments) +def _rewrite_audio_script_sync(segments: list[TranscriptSegment], target_seconds: float = 12.0) -> tuple[str, str]: + fallback = _fallback_audio_script(segments, target_seconds) if not LLM_API_KEY: return fallback, "LLM_API_KEY 未配置,使用本地 SKG 模板" source_text = _transcript_join(segments, "en") source_zh = _transcript_join(segments, "zh") + min_words, max_words = _voiceover_target_words(target_seconds) prompt = ( "You are an English short-video voice-over writer for SKG wellness massagers. " - "Use the source transcript only for structure, pacing, and emotional hook, then rewrite it into a clean English VO for SKG.\n" + "Write a fresh product-introduction VO for SKG. Use the source transcript only as timing and pacing reference; " + "do not summarize it unless it helps the rhythm.\n" "Rules:\n" - "1. Output 28-55 English words, suitable for an 8-18 second TTS voice-over.\n" - "2. Make it natural, premium, concise, and ready to read aloud.\n" + f"1. Target audio length is about {target_seconds:.1f} seconds. Output {min_words}-{max_words} English words.\n" + "2. Make it natural, warm, premium, and a little playful. It should sound like a real creator, not a stiff ad.\n" "3. Do not claim medical treatment, cure, pain elimination, or clinical effects.\n" "4. Do not copy the original brand, creator, price, platform language, or exact claims.\n" - "5. If the source transcript is too thin, write a general SKG relaxation VO.\n" + "5. Introduce SKG products directly: smart massage, warmth, rhythm, daily neck/back/eye/knee/foot relaxation.\n" + "6. Keep it easy for TTS: short sentences, spoken phrasing, no hashtags, no stage directions, no quotation marks.\n" + "7. If the source transcript is thin, ignore it and write a general SKG product intro.\n" 'Return strict JSON only: {"rewritten_text":"..."}.\n\n' f"SKG product context: {AUDIO_PRODUCT_BRIEF}\n\n" f"English transcript:\n{source_text or 'None'}\n\n" @@ -1560,7 +1600,7 @@ def _rewrite_audio_script_sync(segments: list[TranscriptSegment]) -> tuple[str, {"role": "user", "content": prompt}, ], response_format={"type": "json_object"}, - temperature=0.45, + temperature=0.72, max_tokens=600, ) raw = (resp.choices[0].message.content or "").strip() @@ -1581,7 +1621,27 @@ def _minimax_tts_url() -> str: return f"{MINIMAX_TTS_BASE_URL}/v1/t2a_v2" -def _minimax_tts_sync(job_id: str, text: str) -> str: +def _choose_minimax_voice_id() -> str: + if MINIMAX_TTS_VOICE_POOL: + return random.choice(MINIMAX_TTS_VOICE_POOL) + return MINIMAX_TTS_VOICE_ID + + +def _voice_speed_for(voice_id: str, target_seconds: float, text: str) -> float: + words = len([w for w in text.replace("\n", " ").split(" ") if w.strip()]) + estimated_seconds = words / 2.35 if words else target_seconds + if target_seconds > 0 and estimated_seconds > target_seconds * 1.12: + return 1.06 + if target_seconds > 0 and estimated_seconds < target_seconds * 0.82: + return 0.94 + if voice_id == "English_MaturePartner": + return 0.96 + if voice_id == "English_Upbeat_Woman": + return 1.02 + return 0.99 + + +def _minimax_tts_sync(job_id: str, text: str, voice_id: str, target_seconds: float = 12.0) -> str: if not MINIMAX_API_KEY: raise RuntimeError("MINIMAX_API_KEY 未配置,未生成配音") if not text.strip(): @@ -1593,8 +1653,8 @@ def _minimax_tts_sync(job_id: str, text: str) -> str: "language_boost": "English", "output_format": "hex", "voice_setting": { - "voice_id": MINIMAX_TTS_VOICE_ID, - "speed": 1, + "voice_id": voice_id, + "speed": _voice_speed_for(voice_id, target_seconds, text), "vol": 1, "pitch": 0, }, @@ -1628,14 +1688,16 @@ def _minimax_tts_sync(job_id: str, text: str) -> str: return f"/jobs/{job_id}/audio-script.mp3" -def _build_audio_script_sync(job_id: str, segments: list[TranscriptSegment]) -> AudioScript: +def _build_audio_script_sync(job_id: str, segments: list[TranscriptSegment], target_seconds: float = 12.0) -> AudioScript: source_text = _transcript_join(segments, "en") source_zh = _transcript_join(segments, "zh") - rewritten, rewrite_error = _rewrite_audio_script_sync(segments) + duration = max(float(target_seconds or 0), _segment_duration(segments), 4.0) + rewritten, rewrite_error = _rewrite_audio_script_sync(segments, duration) + selected_voice_id = _choose_minimax_voice_id() voice_url = "" voice_error = "" try: - voice_url = _minimax_tts_sync(job_id, rewritten) + voice_url = _minimax_tts_sync(job_id, rewritten, selected_voice_id, duration) except Exception as e: voice_error = str(e) # 改写失败时已有本地 SKG 模板兜底,不把它标成用户可见错误;配音失败才需要提示。 @@ -1649,7 +1711,7 @@ def _build_audio_script_sync(job_id: str, segments: list[TranscriptSegment]) -> rewrite_model=AUDIO_REWRITE_MODEL, voice_provider="minimax", voice_model=MINIMAX_TTS_MODEL, - voice_id=MINIMAX_TTS_VOICE_ID, + voice_id=selected_voice_id, voice_url=voice_url, error=errors, created_at=time.time(), @@ -1678,6 +1740,7 @@ def pipeline_transcribe(job_id: str, manage_job_status: bool = True) -> None: if not wav.exists(): raise RuntimeError("音频提取完成但找不到 audio.wav") update(job, source_audio_url=f"/jobs/{job_id}/audio.wav") + target_duration = max(media_duration(wav), float(job.duration or 0), 4.0) if not LLM_API_KEY: # 无 key 模式:mock 数据 @@ -1701,13 +1764,13 @@ def pipeline_transcribe(job_id: str, manage_job_status: bool = True) -> None: rewrite_model=AUDIO_REWRITE_MODEL, voice_provider="minimax", voice_model=MINIMAX_TTS_MODEL, - voice_id=MINIMAX_TTS_VOICE_ID, + voice_id="random:" + ",".join(MINIMAX_TTS_VOICE_POOL or [MINIMAX_TTS_VOICE_ID]), ), } if manage_job_status: - update_kwargs.update(message="ASR mock 完成,生成 SKG 改写文案…", progress=92) + update_kwargs.update(message="ASR mock 完成,生成 SKG 英文产品口播…", progress=92) update(job, **update_kwargs) - audio_script = _build_audio_script_sync(job_id, mock) + audio_script = _build_audio_script_sync(job_id, mock, target_duration) if manage_job_status: update(job, transcript=mock, status="transcribed", progress=100, audio_script=audio_script, @@ -1728,9 +1791,9 @@ def pipeline_transcribe(job_id: str, manage_job_status: bool = True) -> None: if seg.en.strip() ] else: - raise + segments = [{"start": 0.0, "end": target_duration, "text": "Source audio timing reference."}] if not segments: - raise RuntimeError("ASR 返回 0 段(可能无人声 / 格式问题)") + segments = [{"start": 0.0, "end": target_duration, "text": "Source audio timing reference."}] # 先把英文段落落到 job 上(让 UI 提前看到,翻译再补 zh) en_only = [ @@ -1767,13 +1830,13 @@ def pipeline_transcribe(job_id: str, manage_job_status: bool = True) -> None: rewrite_model=AUDIO_REWRITE_MODEL, voice_provider="minimax", voice_model=MINIMAX_TTS_MODEL, - voice_id=MINIMAX_TTS_VOICE_ID, + voice_id="random:" + ",".join(MINIMAX_TTS_VOICE_POOL or [MINIMAX_TTS_VOICE_ID]), ), } if manage_job_status: - update_kwargs.update(message="翻译完成,生成 SKG 改写文案与 MiniMax 配音…", progress=94) + update_kwargs.update(message="翻译完成,生成 SKG 英文产品口播与 MiniMax 配音…", progress=94) update(job, **update_kwargs) - audio_script = _build_audio_script_sync(job_id, full) + audio_script = _build_audio_script_sync(job_id, full, target_duration) if manage_job_status: update(job, transcript=full, status="transcribed", progress=100, audio_script=audio_script, @@ -2017,6 +2080,7 @@ def health() -> dict: "audio_rewrite": AUDIO_REWRITE_MODEL, "minimax_tts": MINIMAX_TTS_MODEL, "minimax_voice": MINIMAX_TTS_VOICE_ID, + "minimax_voice_pool": MINIMAX_TTS_VOICE_POOL or [MINIMAX_TTS_VOICE_ID], "minimax_configured": bool(MINIMAX_API_KEY), "video": VIDEO_MODEL, "video_aliases": VIDEO_MODEL_ALIASES, @@ -2216,7 +2280,7 @@ async def trigger_transcribe(job_id: str, bg: BackgroundTasks) -> Job: rewrite_model=AUDIO_REWRITE_MODEL, voice_provider="minimax", voice_model=MINIMAX_TTS_MODEL, - voice_id=MINIMAX_TTS_VOICE_ID, + voice_id="random:" + ",".join(MINIMAX_TTS_VOICE_POOL or [MINIMAX_TTS_VOICE_ID]), ) if manage_job_status: update(job, status="transcribing", progress=max(45, min(job.progress, 70)), error="", message="准备提取音频…", audio_script=audio_payload) diff --git a/docs/source-analysis.html b/docs/source-analysis.html index 8ea2a55..92d9465 100644 --- a/docs/source-analysis.html +++ b/docs/source-analysis.html @@ -558,7 +558,7 @@
5

素材准备

清洗关键帧,把多张关键帧作为同一主体的参考,先重绘六张标准站立主体资产图,再按关键帧生成多个去主体、相似或换风格场景图。

6

分镜改造

把参考主体、场景、动作和 SKG 产品放入分镜结构;产品融合使用纵向 6 行镜头工作表,每行绑定产品图、白底人物图、产品区域、场景图、描述词、秒数和单条生成入口。

7

生成视频

普通分镜可调用 Seedance / Kling / Veo 3;产品融合固定用 GPT Image 2 生成位置引导图,再用 Seedance 按秒数生成视频,结果回写到画面工作台节点。

-
8

声音文案

音频轨独立处理:ASR 提取原始英文文案、翻译成中文对照、接 SKG 产品卖点改写成英文 voice-over;配置 MiniMax 后直接生成英文配音 mp3。底部音频条播放原音频时,指针会按时间走过字幕节点。

+
8

声音文案

音频轨独立处理:提取原音频并按实际秒数生成 SKG 英文产品介绍 voice-over,ASR/翻译只作为改前对照和节奏参考;配置 MiniMax 后从男声、女声、成熟声池随机生成自然英文配音 mp3。底部音频条播放原音频时,指针会按时间走过字幕节点。

9

合成成品

片段、字幕、配音、转场合成最终 mp4。当前未实现。

@@ -572,7 +572,7 @@ web/app/page.tsx产品工作台主状态:jobs、activeJobId、按 job 隔离的 selectedFrames/详情面板状态、clipboard、ReactFlow 节点和边;负责打开/找回画布工作面板。 web/components/nodes/index.tsxDAG 节点定义:Input、VisualLab、Audio、Compose,以及画布工作面板 KeyframePanel / VideoFramePanel;旧 Keyframe/Storyboard/VideoGen 组件保留但不再挂主画布。 - web/components/audio-strip.tsx底部吸附音频条:可拖拽调整高度;播放原音频时移动指针,逐个高亮英文/中文字幕节点和对应波形,并在右侧固定显示 SKG 英文改写稿和 MiniMax 英文配音。 + web/components/audio-strip.tsx底部吸附音频条:可拖拽调整高度;播放原音频时移动指针,逐个高亮英文/中文字幕节点和对应波形,并在右侧固定显示按原音频时长生成的 SKG 英文产品口播和 MiniMax 随机英文配音。 web/components/lightbox.tsx关键帧素材准备面板:清洗、统一主体候选、参考帧网格、六张主体重绘图、每帧去主体场景图、纵向 6 行产品融合镜头工作表和审核。 web/components/product-library-picker.tsxSKG 内置白底产品图库选择器:搜索、品类筛选、预览尺寸,并把库内图片复制为当前 job 的 assetweb/components/storyboard-bar.tsx顶部分镜编排条:展示选入编排的关键帧,并作为唯一分镜导航。 @@ -701,7 +701,7 @@ api/main.py

AudioScript

-

音频文案轨的结构化产物。pipeline_transcribe 在 ASR 和翻译后写入:先生成 SKG 英文 voice-over 改写稿,再用 MiniMax T2A 生成英文配音文件。

+

音频文案轨的结构化产物。pipeline_transcribe 提取 audio.wav 后按原音频秒数写入 SKG 英文产品介绍 voice-over,再用 MiniMax T2A 从英文音色池随机生成配音文件。

AudioScript {
   status: idle | rewriting | completed | failed,
   source_text,
@@ -791,7 +791,7 @@ SubjectAsset {
             上传视频POST /jobs/uploaduploadJob保存 source.mp4,然后同样进入下载完成状态。
             删除输入视频DELETE /jobs/{id}deleteJob从任务队列、URL 和磁盘 jobs/<id> 目录移除整个 job,包括源视频、关键帧、元素提取图和生成视频。
             解析视频POST /jobs/{id}/analyze?frames=&target=&mode=&quality=analyzeJob拆轨 + 目标化抽关键帧。默认 frames=12target 支持透明骨架人、综合、清晰主体、转场变化、表情瞬间、动作峰值;当前 UI 默认 transparent_human。透明骨架人目标现在只走本地清晰度、中心主体、对比度、画面变化和 pHash 去重,不在抽帧阶段逐帧调用 Vision;mode=append 追加新关键帧;quality=auto 为展示友好档,最高只自动选择精细,不会自动上极准;极准保留为手动选择。抽帧开始时同步拆出 audio.wav 并启动音频处理线程。多个抽帧请求进入后端队列顺序处理。
-            音频文案轨POST /jobs/{id}/transcribetriggerTranscribe若尚未拆轨,先从 source.mp4 提取 audio.wav 并回填 source_audio_url;随后 ASR 得到英文时间戳段落,再翻译中文,并按 AUDIO_PRODUCT_BRIEF 生成英文 audio_script.rewritten_text;配置 MINIMAX_API_KEY 后调用 MiniMax T2A 生成英文 audio_script.voice_url。前端不自动触发,用户在 Audio 节点点击“提取音频 / 重新提取音频”即可启动并立即打开底部音频条;抽帧中也允许并行触发,忙碌态由 audio_script.status 管理。
+            音频文案轨POST /jobs/{id}/transcribetriggerTranscribe若尚未拆轨,先从 source.mp4 提取 audio.wav 并回填 source_audio_url;随后用原音频实际秒数估算英文词数,按 AUDIO_PRODUCT_BRIEF 生成有趣、自然的 SKG 英文产品介绍 audio_script.rewritten_text。ASR/翻译结果保留为改前对照和节奏参考;如果 ASR 不可用,也会用原音频时长继续生成产品口播。配置 MINIMAX_API_KEY 后调用 MiniMax T2A,并从 MINIMAX_TTS_VOICE_POOL 随机选择男声、女声或成熟声生成 audio_script.voice_url。
             原始音频文件GET /jobs/{id}/audio.wavsourceAudioUrl返回拆轨得到的 wav;底部 AudioStrip 拉取该文件,用 Web Audio API 解码并计算波形峰值。原音频播放器驱动时间轴,播放时全局指针和当前字幕节点内指针同步移动。
             改写配音文件GET /jobs/{id}/audio-script.mp3apiAssetUrl(job.audio_script.voice_url)返回 MiniMax T2A 生成的英文 mp3。没有配置 MiniMax 或生成失败时该文件不存在,但英文改写文案仍会保存在 audio_script.rewritten_text。
             手动加帧POST /jobs/{id}/frames?t=addManualFrame按视频时间戳抽一帧,index 递增但 frames 按 timestamp 排序。
@@ -840,7 +840,7 @@ SubjectAsset {
             
             
               Audio / ASR / Rewrite
-              独立声音文案轨:从 source.mp4 直接提取 audio.wav,再提取原始口播、翻译中文、改写成 SKG 产品语境英文 voice-over;MiniMax T2A 配置后生成英文配音 mp3。不再等待抽帧完成,用户在主画布 AudioNode 点击卡片或“提取音频 / 重新提取音频”即可打开底部音频条并启动;即使视觉抽帧正在进行,也通过 audio_script.status 并行管理音频忙碌态。AudioNode 用“改前 · 原音频 / 改后 · SKG English VO”摘要展示;底部 AudioStrip 吸附屏幕底端,可拖拽调整高度,按时间段展示英文、中文翻译和波形;原音频播放时指针同步穿过字幕节点,右侧显示英文改写稿和 MiniMax 英文配音。
+              独立声音文案轨:从 source.mp4 直接提取 audio.wav,按原音频时长生成 SKG 产品语境英文 voice-over;ASR/翻译保留为改前对照和节奏参考。MiniMax T2A 配置后从男声、女声、成熟声池随机生成自然英文配音 mp3。不再等待抽帧完成,用户在主画布 AudioNode 点击卡片或“提取音频 / 重新提取音频”即可打开底部音频条并启动;即使视觉抽帧正在进行,也通过 audio_script.status 并行管理音频忙碌态。AudioNode 用“改前 · 原音频 / 改后 · SKG Product VO”摘要展示;底部 AudioStrip 吸附屏幕底端,可拖拽调整高度,按时间段展示英文、中文翻译和波形;原音频播放时指针同步穿过字幕节点,右侧显示英文产品口播和 MiniMax 英文配音。
               不要阻断视觉素材管线。
               AudioNodeAudioStripASRNodeTranslateNodeRewriteNodepipeline_transcribeAudioScript
             
@@ -867,7 +867,7 @@ SubjectAsset {
               
  • Vision 识别关键帧,输出 scene、objects、style、suggested_prompt,并作为主体候选来源。
  • 主体候选确认、改名、删除和主体资产包生成。
  • 分镜工作台 4 图槽和改造说明自动保存。
  • -
  • 音频文案轨:ASR/翻译后自动生成 SKG 英文口播改写稿;配置 MiniMax 后生成英文配音 mp3。底部音频条可播放原音频并用指针逐段对齐字幕节点。
  • +
  • 音频文案轨:点击提取音频后按原音频时长自动生成 SKG 英文产品介绍口播;配置 MiniMax 后从男声、女声、成熟声池随机生成自然英文配音 mp3。底部音频条可播放原音频并用指针逐段对齐字幕节点。
  • nano-banana-pro image-to-image 生图。
  • @@ -875,7 +875,7 @@ SubjectAsset {

    阻塞 / 占位