From 96f00ce7fdbe77df3bb54c782e07b9211b0abfaa Mon Sep 17 00:00:00 2001 From: sion123 <450702724@qq.com> Date: Fri, 1 May 2026 02:48:37 +0800 Subject: [PATCH] =?UTF-8?q?feat(video-pipeline):=20=E5=B0=86=20narration?= =?UTF-8?q?=20=E5=AD=97=E6=AE=B5=E7=BB=9F=E4=B8=80=E9=87=8D=E5=91=BD?= =?UTF-8?q?=E5=90=8D=E4=B8=BA=20script?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 字段重命名涉及所有文档、示例代码、提示词模板以及 slug 生成逻辑,确保管线各阶段(分镜、图生提示词、TTS、成片)一致使用 script 字段承载口播文案。 同时增强 `capcut_assemble.js` 的配音与字幕模块: - 配音支持本地路径模式(不上传 OSS),新增 `--split-captions` 参数启用分句字幕 - 分句字幕按标点切分长文本为短句,基于字数权重动态分配显示时长 - 新增 `update-captions-standalone.js` 独立工具,可在草稿已生成后单独更新字幕 --- .claude/skills/image-generator/SKILL.md | 4 +- .../image-generator/references/batch-mode.md | 4 +- .claude/skills/video-from-script/SKILL.md | 36 ++-- .../references/account-system.md | 2 +- .../references/manifest-schema.md | 26 +-- .../scripts/capcut_assemble.js | 192 ++++++++++++++---- .../scripts/lib/phase-images.js | 4 +- .../scripts/lib/update-captions-standalone.js | 158 ++++++++++++++ .../video-from-script/scripts/pipeline.js | 6 +- 9 files changed, 346 insertions(+), 86 deletions(-) create mode 100644 .claude/skills/video-from-script/scripts/lib/update-captions-standalone.js diff --git a/.claude/skills/image-generator/SKILL.md b/.claude/skills/image-generator/SKILL.md index 5af1d54..7b1d0ed 100644 --- a/.claude/skills/image-generator/SKILL.md +++ b/.claude/skills/image-generator/SKILL.md @@ -165,7 +165,7 @@ node .claude/skills/video-from-script/scripts/gemini-image-generator.js edit \ # 批量带参考图(pipeline init + run) node .claude/skills/video-from-script/scripts/pipeline.js init \ --account forbidden-emperor --mode single \ - --items '[{"shotDesc":"...","narration":"...","duration":5,"imagePrompt":"...","directorRef":"tarantino"}]' + --items '[{"shotDesc":"...","script":"...","duration":5,"imagePrompt":"...","directorRef":"tarantino"}]' node .claude/skills/video-from-script/scripts/pipeline.js run \ --manifest ./output/forbidden-emperor_XXXXXXXX_001/manifest.json \ --phase images @@ -328,7 +328,7 @@ const r = await mjGen('prompt', { outputDir: './out', aspectRatio: '9:16' }) ## 文件命名规则 -Pipeline 生成的文件统一命名,slug 来自 manifest item 的 `narration` 或 `shotDesc` 字段(slugify: 保留中文和字母数字,最多 20 字符,其余变 `_`): +Pipeline 生成的文件统一命名,slug 来自 manifest item 的 `script` 或 `shotDesc` 字段(slugify: 保留中文和字母数字,最多 20 字符,其余变 `_`): | 模式 | 文件名 | 示例 | |------|--------|------| diff --git a/.claude/skills/image-generator/references/batch-mode.md b/.claude/skills/image-generator/references/batch-mode.md index 713a588..5580d65 100644 --- a/.claude/skills/image-generator/references/batch-mode.md +++ b/.claude/skills/image-generator/references/batch-mode.md @@ -56,7 +56,7 @@ output/{name}_{YYYYMMDD}_{NNN}/ └── audio/ # seg_001.mp3 ``` -命名:图片 `scene_01_悬浮.jpeg` → 视频 `scene_01_悬浮.mp4`(slug 从 narration/shotDesc 派生,支持中文) +命名:图片 `scene_01_悬浮.jpeg` → 视频 `scene_01_悬浮.mp4`(slug 从 script/shotDesc 派生,支持中文) ## manifest.json @@ -71,7 +71,7 @@ node scripts/gemini-image-generator.js edit "prompt" -i ./references/ref1.png -o # Pipeline 批量生图(推荐) node scripts/pipeline.js init \ --account {account} --mode single \ - --items '[{"shotDesc":"...","narration":"...","duration":5,"imagePrompt":"...","directorRef":"tarantino"}]' + --items '[{"shotDesc":"...","script":"...","duration":5,"imagePrompt":"...","directorRef":"tarantino"}]' node scripts/pipeline.js run \ --manifest ./output/{account}_XXXXXXXX_001/manifest.json \ --phase images diff --git a/.claude/skills/video-from-script/SKILL.md b/.claude/skills/video-from-script/SKILL.md index ba0a6f1..d8b48ae 100644 --- a/.claude/skills/video-from-script/SKILL.md +++ b/.claude/skills/video-from-script/SKILL.md @@ -120,7 +120,7 @@ Step 1: 分镜脚本生成(子 Agent 执行) - 主 Agent 将用户文案 + 模板交给子 Agent - 子 Agent 按模板要求输出分镜表 JSON: ```json - [{"id":1,"shotDesc":"英文画面描述,40-80词","narration":"中文口播旁白,≤22字","duration":5,"directorRef":"tarantino"}] + [{"id":1,"shotDesc":"英文画面描述,40-80词","script":"中文口播文案,≤22字","duration":5,"directorRef":"tarantino"}] ``` - 主 Agent 审查分镜表(时长合理、隐性动势完整、directorRef 已填) - 展示给用户确认,确认后进入 Step 2-A @@ -128,15 +128,15 @@ Step 1: 分镜脚本生成(子 Agent 执行) Step 2-A: 生成图片提示词(子 Agent 执行) - 读取 account.json 中的 imageStylePrompt 字段,定位图片提示词模板(如 prompts/图片提示词.md) - 子 Agent 为每个 shot 生成 imagePrompt: - - 入参:shotDesc + narration(情绪参考)+ directorRef(光影策略)+ 目标模型 + - 入参:shotDesc + script(情绪参考)+ directorRef(光影策略)+ 目标模型 - 出参:imagePrompt(可直接送给图片模型的英文提示词) - 主 Agent 审查 imagePrompt 质量(shotDesc 内容完整保留、光影词库对应 directorRef) Step 2-B: 生成静态分镜图 + Manifest 初始化 - 组装 items 并初始化 manifest(**不含 videoPrompt**): ```bash - node pipeline.js init --account --mode \ - --items '[{"shotDesc":"...","narration":"...","duration":5,"imagePrompt":"...","directorRef":"tarantino"}]' + node scripts/pipeline.js init --account --mode \ + --items '[{"shotDesc":"...","script":"...","duration":5,"imagePrompt":"...","directorRef":"tarantino"}]' ``` - 脚本自动从 account.json 继承:imageModel、videoModel、format、references - 所有 item.confirmed = false @@ -147,7 +147,7 @@ Step 2-B: 生成静态分镜图 + Manifest 初始化 Step 2-C: 人工确认(可选卡点) - 展示所有分镜图给用户 - 用户可:确认全部 / 替换 MJ 候选图(改 item.file = item.candidates[N]) / 删除不合格 item / 跳过确认直接继续 - - 用户确认后:`node pipeline.js confirm --manifest --all` + - 用户确认后:`node scripts/pipeline.js confirm --manifest --all` - 跳过确认时:批量设置 `confirmed = true`,直接进入 Step 3 Step 3-A: 生成视频提示词(子 Agent 执行) @@ -165,7 +165,7 @@ Step 3-B: 生成视频片段 Step 4: TTS + 成片 - 跑 tts + assemble 阶段:`pipeline.js run --manifest --phase tts,assemble` - - TTS 使用 narration 字段(口播旁白) + - TTS 使用 script 字段(口播旁白) - 检查字幕准确、BGM 不盖配音 ``` @@ -175,33 +175,33 @@ Step 4: TTS + 成片 ```bash # 创建账号(Step 0:首次使用时) -node pipeline.js create-account --id --name <名称> \ +node scripts/pipeline.js create-account --id --name <名称> \ --desc <描述> --video-model veo3-fast --references ./ref1.png,./ref2.png # 校验账号完整性 -node pipeline.js validate-account --account +node scripts/pipeline.js validate-account --account # 初始化 manifest(Step 2-B 使用,AI 只提供创意内容,不含 videoPrompt) -node pipeline.js init --account --mode \ - --items '[{"shotDesc":"...","narration":"...","duration":5,"imagePrompt":"...","directorRef":"tarantino"}]' +node scripts/pipeline.js init --account --mode \ + --items '[{"shotDesc":"...","script":"...","duration":5,"imagePrompt":"...","directorRef":"tarantino"}]' # 也可从文件读取 items(适合大量数据) -node pipeline.js init --account --mode single --items-file ./items.json +node scripts/pipeline.js init --account --mode single --items-file ./items.json # 校验 manifest 完整性 -node pipeline.js validate --manifest +node scripts/pipeline.js validate --manifest # 人工确认分镜图(Step 2-C,可选:跳过时 Agent 批量设置 confirmed=true) -node pipeline.js confirm --manifest --all +node scripts/pipeline.js confirm --manifest --all # 跑指定阶段 -node pipeline.js run --manifest --phase images -node pipeline.js run --manifest --phase upload,videos +node scripts/pipeline.js run --manifest --phase images +node scripts/pipeline.js run --manifest --phase upload,videos # 断点续跑(跳过已完成阶段和 item) -node pipeline.js run --manifest --resume +node scripts/pipeline.js run --manifest --resume # 查看进度 -node pipeline.js status --manifest +node scripts/pipeline.js status --manifest ``` **阶段**: `images` → `upload` → `videos` → `tts` → `assemble` @@ -335,7 +335,7 @@ node kling-video-generator.js --image --prompt -o ./videos output/{name}_{YYYYMMDD}_{NNN}/ ├── manifest.json # 主清单(贯穿全流程) ├── prompts.txt # 原始提示词存档 -├── images/ # scene_{NN}_{slug}.jpeg(slug 从 narration/shotDesc 派生,首尾帧加 _last 后缀) +├── images/ # scene_{NN}_{slug}.jpeg(slug 从 script/shotDesc 派生,首尾帧加 _last 后缀) ├── videos/ # scene_{NN}_{slug}.mp4(与图片对应) └── urls.json # OSS 公网 URL 映射 ``` diff --git a/.claude/skills/video-from-script/references/account-system.md b/.claude/skills/video-from-script/references/account-system.md index 3fa377f..d27ae0d 100644 --- a/.claude/skills/video-from-script/references/account-system.md +++ b/.claude/skills/video-from-script/references/account-system.md @@ -86,7 +86,7 @@ accounts/ # 项目根目录下 | 文件 | 用途 | Step | |------|------|------| -| `分镜.md` | 口播文案 → 分镜表(shotDesc/narration/duration/directorRef) | Step 1 | +| `分镜.md` | 口播文案 → 分镜表(shotDesc/script/duration/directorRef) | Step 1 | | `图片提示词.md` | shotDesc + directorRef → imagePrompt | Step 2-A | | `视频提示词.md` | shotDesc + directorRef → videoPrompt | Step 3-A | diff --git a/.claude/skills/video-from-script/references/manifest-schema.md b/.claude/skills/video-from-script/references/manifest-schema.md index f991b91..f9466a9 100644 --- a/.claude/skills/video-from-script/references/manifest-schema.md +++ b/.claude/skills/video-from-script/references/manifest-schema.md @@ -2,7 +2,7 @@ > `pipeline.js init` 创建,Pipeline 执行,Agent 审查。 > -> **禁止 AI 手写 manifest.json**,必须通过 `pipeline.js init` 初始化。脚本从 account.json 自动继承结构字段,AI 只提供创意内容(items 的 shotDesc/narration/imagePrompt 等)。 +> **禁止 AI 手写 manifest.json**,必须通过 `pipeline.js init` 初始化。脚本从 account.json 自动继承结构字段,AI 只提供创意内容(items 的 shotDesc/script/imagePrompt 等)。 --- @@ -10,18 +10,18 @@ ```bash # Step 2-A 生成 imagePrompt 后,通过脚本初始化(不含 videoPrompt) -node pipeline.js init --account 军事账号 --mode single \ - --items '[{"shotDesc":"英文画面描述","narration":"中文口播旁白","duration":5,"imagePrompt":"English prompt","directorRef":"tarantino"}]' +node scripts/pipeline.js init --account 军事账号 --mode single \ + --items '[{"shotDesc":"英文画面描述","script":"中文口播文案","duration":5,"imagePrompt":"English prompt","directorRef":"tarantino"}]' # 或从文件读取 -node pipeline.js init --account 军事账号 --mode single --items-file ./items.json +node scripts/pipeline.js init --account 军事账号 --mode single --items-file ./items.json # Step 2-C 人工确认 -node pipeline.js confirm --manifest --all -node pipeline.js confirm --manifest --items 1,3,5 +node scripts/pipeline.js confirm --manifest --all +node scripts/pipeline.js confirm --manifest --items 1,3,5 # 校验已有 manifest -node pipeline.js validate --manifest +node scripts/pipeline.js validate --manifest ``` --- @@ -58,7 +58,7 @@ node pipeline.js validate --manifest |------|------| | `status` | 固定写 `"pending"` | | `shotDesc` | 英文分镜描述(含隐性动势,40-80词) | -| `narration` | **该段的完整原文案**(不提炼,保留论证、例子、细节)| +| `script` | **该段的完整原文案**(不提炼,保留论证、例子、细节)| | `duration` | 计划视频时长(秒),来自分镜阶段 | | `imagePrompt` | 英文画面描述(给 Gemini/MJ),Step 2-A 生成 | | `directorRef` | 导演构图参考(tarantino / kitano / fincher),三层透传 | @@ -90,7 +90,7 @@ node pipeline.js validate --manifest - MJ 换选:`item.file = item.candidates[2]` - 删除不合格 item:直接从 items 数组移除,重新跑 `--phase images` - 调整 prompt 重跑:改 `imagePrompt`,status 改回 `pending` -- 人工确认:`node pipeline.js confirm --manifest --all` +- 人工确认:`node scripts/pipeline.js confirm --manifest --all` --- @@ -115,7 +115,7 @@ Agent **不需要记住这些条件**,pipeline 内部自动匹配。仅供理 | images | `status=pending` + 有 `imagePrompt` | | upload | `status=done` + 有 `file` + 无 `url` | | videos | `status=done` + `confirmed=true` + 有 `url` + 有 `videoPrompt` + 无 `video` | -| tts | `status=done` + 有 `narration`(回退 `text`) + 无 `audio` | +| tts | `status=done` + 有 `script`(回退 `text`) + 无 `audio` | ### pipeline.phases 整体状态 @@ -137,7 +137,7 @@ Agent **不需要记住这些条件**,pipeline 内部自动匹配。仅供理 ```bash # 只改 prompt 不改图片风格 → 重试即可 -node pipeline.js run --manifest --phase images --retry-failed +node scripts/pipeline.js run --manifest --phase images --retry-failed # 需要换 prompt → 先改 item.imagePrompt,再重试 # (改完后跑上面同一条命令) @@ -147,7 +147,7 @@ node pipeline.js run --manifest --phase images --retry-failed ```bash # API 临时故障、网络超时 → 直接重试 -node pipeline.js run --manifest --phase videos --retry-failed +node scripts/pipeline.js run --manifest --phase videos --retry-failed # 提示词问题 → 先改 item.videoPrompt,再重试 # (改完后跑上面同一条命令) @@ -158,7 +158,7 @@ node pipeline.js run --manifest --phase videos --retry-failed **全阶段重试**: ```bash -node pipeline.js run --manifest --retry-failed +node scripts/pipeline.js run --manifest --retry-failed ``` ### `--retry-failed` 内部行为 diff --git a/.claude/skills/video-from-script/scripts/capcut_assemble.js b/.claude/skills/video-from-script/scripts/capcut_assemble.js index 800b96c..3ded792 100644 --- a/.claude/skills/video-from-script/scripts/capcut_assemble.js +++ b/.claude/skills/video-from-script/scripts/capcut_assemble.js @@ -171,6 +171,7 @@ async function assemble(args) { manifest: manifestPath, mode = 'images', subtitles = 'true', + splitCaptions = 'true', voiceover = 'true', bgm, effects: effectsStr, @@ -179,7 +180,7 @@ async function assemble(args) { apiKey = '', duration = '4', animation = 'kenburns-zoom', - localAudio = 'false', + localAudio = 'true', } = args if (!input) throw new Error('缺少 --input 参数') @@ -357,7 +358,7 @@ async function assemble(args) { // -- 添加字幕 -- step++; console.log(`[${step}/${totalSteps}] 添加字幕...`) if (subtitles === 'true' && items.some(i => i.script || i.text)) { - await addSubtitles(draftUrl, items, timeline, subtitleStyle) + await addSubtitles(draftUrl, items, timeline, subtitleStyle, splitCaptions === 'true') } else { console.log(' 跳过') } @@ -607,40 +608,63 @@ async function batchUploadAudio(inputDir, items) { // 添加 TTS 配音(每段音频按时间线排列) // ============================================================================ -async function addVoiceover(draftUrl, inputDir, items, timeline, localAudio = false) { - // 收集需要上传的音频 +async function addVoiceover(draftUrl, inputDir, items, timeline, localAudio = true) { + // 收集音频 const audioItems = items.filter(item => item.audio) if (audioItems.length === 0) { console.log(' 无 TTS 音频文件,跳过') return } - // 上传本地音频到 OSS(已有的 URL 直接通过) - // 根据 localAudio 参数决定是否上传 - const audioUrls = localAudio - ? {} // 本地模式:不上传,使用本地路径 - : await batchUploadAudio(inputDir, items) - const audioInfos = [] - for (let i = 0; i < items.length; i++) { - const item = items[i] - if (!item.audio) continue - const audioUrl = audioUrls[item.audio] - if (!audioUrl) continue + if (localAudio) { + // 本地模式:直接用本地路径,不上传 OSS + for (let i = 0; i < items.length; i++) { + const item = items[i] + if (!item.audio) continue - const tl = timeline[i] - audioInfos.push({ - audio_url: audioUrl, - start: tl.start, - end: tl.end, - duration: tl.duration, - volume: 1.0, - }) + const filePath = item.audio.startsWith('http') + ? item.audio + : (path.isAbsolute(item.audio) ? item.audio : path.resolve(inputDir, item.audio)) + + if (!item.audio.startsWith('http') && !fs.existsSync(filePath)) { + console.error(` 音频文件不存在: ${filePath}`) + continue + } + + const tl = timeline[i] + audioInfos.push({ + audio_url: filePath, + start: tl.start, + end: tl.end, + duration: tl.duration, + volume: 1.0, + }) + } + } else { + // 上传模式:先传 OSS 再用 URL + const audioUrls = await batchUploadAudio(inputDir, items) + for (let i = 0; i < items.length; i++) { + const item = items[i] + if (!item.audio) continue + + const audioUrl = audioUrls[item.audio] + if (!audioUrl) continue + + const tl = timeline[i] + audioInfos.push({ + audio_url: audioUrl, + start: tl.start, + end: tl.end, + duration: tl.duration, + volume: 1.0, + }) + } } if (audioInfos.length === 0) { - console.log(' 所有音频上传失败,跳过配音') + console.log(' 无可用音频,跳过配音') return } @@ -648,7 +672,7 @@ async function addVoiceover(draftUrl, inputDir, items, timeline, localAudio = fa draft_url: draftUrl, audio_infos: JSON.stringify(audioInfos), }) - console.log(` 已添加 ${audioInfos.length} 段 TTS 配音`) + console.log(` 已添加 ${audioInfos.length} 段 TTS 配音 (${localAudio ? '本地路径' : 'OSS'})`) } // ============================================================================ @@ -695,10 +719,44 @@ function loadSubtitleStyle(manifest) { } // ============================================================================ -// 添加字幕(支持关键词高亮 + 账号字幕风格) +// 添加字幕(支持关键词高亮 + 账号字幕风格 + 分句切分) // ============================================================================ -async function addSubtitles(draftUrl, items, timeline, style = {}) { +/** + * 按标点符号切分文本为短句(去除所有标点符号) + */ +function splitTextIntoSentences(text) { + const sentenceEnders = /[。!?;]/ + const clauseEnders = /[,:]/ + + const sentences = [] + let current = '' + let chars = text.split('') + + for (let i = 0; i < chars.length; i++) { + const char = chars[i] + current += char + + if (sentenceEnders.test(char)) { + // 切分并去掉所有标点 + sentences.push(current.trim().replace(/[。!?;,:、]/g, '')) + current = '' + } else if (clauseEnders.test(char) && current.length > 8) { + // 切分并去掉所有标点 + sentences.push(current.trim().replace(/[。!?;,:、]/g, '')) + current = '' + } + } + + // 处理剩余文本 + if (current.trim()) { + sentences.push(current.trim().replace(/[。!?;,:、]/g, '')) + } + + return sentences +} + +async function addSubtitles(draftUrl, items, timeline, style = {}, split = false) { const captions = [] // 从账号配置读取动画参数 @@ -713,25 +771,68 @@ async function addSubtitles(draftUrl, items, timeline, style = {}) { if (!text) continue const tl = timeline[i] - const keyword = '' - const keywordColor = style.highlightColor || style.color || '#FFFFFF' - const cap = { - start: tl.start, - end: tl.end, - text, - keyword, - keyword_color: keyword ? keywordColor : '', - keyword_font_size: 18, + if (split) { + // 分句模式:切分长文本 + const sentences = splitTextIntoSentences(text) + if (sentences.length === 0) continue + + const totalDuration = tl.end - tl.start + + // 按字数权重分配时间(改进版) + const totalChars = sentences.reduce((sum, s) => sum + s.length, 0) + let currentTime = tl.start + + sentences.forEach((sentence, idx) => { + // 按字数比例计算时长 + const charRatio = sentence.length / totalChars + let duration = Math.round(totalDuration * charRatio) + + // 最后一句使用剩余全部时间(避免精度误差) + if (idx === sentences.length - 1) { + duration = tl.end - currentTime + } + + // 最小1秒,避免太短 + duration = Math.max(duration, 1000000) // 1秒 = 1000000微秒 + + const cap = { + start: currentTime, + end: currentTime + duration, + text: sentence, + keyword: '', + keyword_color: '', + } + + if (inAnimation) cap.in_animation = inAnimation + if (outAnimation) cap.out_animation = outAnimation + if (inAnimDuration) cap.in_animation_duration = inAnimDuration + if (outAnimDuration) cap.out_animation_duration = outAnimDuration + + captions.push(cap) + currentTime += duration + }) + } else { + // 原始模式:一句字幕 + const keyword = '' + const keywordColor = style.highlightColor || style.color || '#FFFFFF' + + const cap = { + start: tl.start, + end: tl.end, + text, + keyword, + keyword_color: keyword ? keywordColor : '', + keyword_font_size: 18, + } + + if (inAnimation) cap.in_animation = inAnimation + if (outAnimation) cap.out_animation = outAnimation + if (inAnimDuration) cap.in_animation_duration = inAnimDuration + if (outAnimDuration) cap.out_animation_duration = outAnimDuration + + captions.push(cap) } - - // 动画参数(每条字幕都带) - if (inAnimation) cap.in_animation = inAnimation - if (outAnimation) cap.out_animation = outAnimation - if (inAnimDuration) cap.in_animation_duration = inAnimDuration - if (outAnimDuration) cap.out_animation_duration = outAnimDuration - - captions.push(cap) } if (captions.length === 0) { @@ -766,7 +867,7 @@ async function addSubtitles(draftUrl, items, timeline, style = {}) { style_text: 0, }) - console.log(` 已添加 ${captions.length} 条字幕 (字体: ${style.font || '默认'}, 动画: ${inAnimation || '无'} → ${outAnimation || '无'})`) + console.log(` 已添加 ${captions.length} 条字幕${split ? ' (分句模式)' : ''} (字体: ${style.font || '默认'}, 动画: ${inAnimation || '无'} → ${outAnimation || '无'})`) } // ============================================================================ @@ -835,6 +936,7 @@ async function main() { console.log(' --duration 4 默认每段时长/秒(无TTS时的fallback,默认 4)') console.log(' --voiceover true|false 是否添加TTS配音轨道(默认 true)') console.log(' --subtitles true|false 是否添加字幕(默认 true)') + console.log(' --split-captions true|false 分句字幕模式(默认 false,长句按标点切分)') console.log(' --bgm 背景音乐 URL') console.log(' --effects "名称1,名称2" 特效名称(逗号分隔)') console.log(' --filter "名称:强度" 滤镜(强度 0-100)') diff --git a/.claude/skills/video-from-script/scripts/lib/phase-images.js b/.claude/skills/video-from-script/scripts/lib/phase-images.js index 17d1e51..2273c13 100644 --- a/.claude/skills/video-from-script/scripts/lib/phase-images.js +++ b/.claude/skills/video-from-script/scripts/lib/phase-images.js @@ -162,7 +162,7 @@ async function generateMJ(item, idx, dir, imagesDir, ratio, refs, manifestPath) ? result.files.map((f, ci) => renameGeneratedFile( path.relative(dir, f).replace(/\\/g, '/'), - dir, idx, item.narration || item.shotDesc, `cand${ci + 1}` + dir, idx, item.script || item.shotDesc, `cand${ci + 1}` ) ) : null @@ -217,7 +217,7 @@ async function generateLastFrame(item, idx, manifest, dir, imagesDir, model, rat if (files.length > 0) { item.lastFrame = renameGeneratedFile( path.relative(dir, files[0]).replace(/\\/g, '/'), - dir, idx, item.narration || item.shotDesc, 'last' + dir, idx, item.script || item.shotDesc, 'last' ) item.status = 'done' log('images', `[${idx}] lastFrame 完成: ${item.lastFrame}`) diff --git a/.claude/skills/video-from-script/scripts/lib/update-captions-standalone.js b/.claude/skills/video-from-script/scripts/lib/update-captions-standalone.js new file mode 100644 index 0000000..a97955d --- /dev/null +++ b/.claude/skills/video-from-script/scripts/lib/update-captions-standalone.js @@ -0,0 +1,158 @@ +#!/usr/bin/env node +/** + * 更新剪映草稿的字幕(独立版本) + * 用法:node lib/update-captions-standalone.js + */ + +const axios = require('axios') +const fs = require('fs') +const path = require('path') +const { syncDraft, registerDraft, triggerDirectoryScan } = require('../sync-to-jianying') + +// 读取配置 +function getConfig() { + const configPath = path.join(__dirname, '..', '..', '..', 'config.json') + if (!fs.existsSync(configPath)) { + console.error('❌ 缺少配置文件: skills/config.json') + process.exit(1) + } + return JSON.parse(fs.readFileSync(configPath, 'utf-8')) +} + +const config = getConfig() +const BASE_URL = config.capcutMateApiBase + +// CapCut API 封装 +async function api(endpoint, data = {}, timeout = 60000) { + const url = `${BASE_URL}/${endpoint}` + const response = await axios.post(url, data, { timeout }) + return response.data +} + +// 主函数 +async function updateCaptions(draftId, manifestPath) { + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) + + if (!manifest.split_captions || manifest.split_captions.length === 0) { + console.log('❌ manifest 中没有 split_captions 数据') + process.exit(1) + } + + console.log(`\n📝 更新字幕:${draftId}`) + console.log(` 分句字幕:${manifest.split_captions.length} 条`) + + // 获取草稿信息 + const draftInfo = await api('get_draft_info', { draft_id: draftId }) + const draftUrl = draftInfo.draft_url + + // 读取字幕风格(accounts 目录在项目根目录,与 .claude 同级) + // 从脚本位置向上查找:scripts/lib -> scripts -> video-from-script -> skills -> .claude -> 项目根 + let searchDir = __dirname + let accountPath = null + for (let i = 0; i < 6; i++) { + const testPath = path.join(searchDir, 'accounts', manifest.account, 'account.json') + if (fs.existsSync(testPath)) { + accountPath = testPath + break + } + searchDir = path.dirname(searchDir) + } + + if (!accountPath) { + console.error(`❌ 找不到账号配置: accounts/${manifest.account}/account.json`) + console.error(` 搜索路径: ${searchDir}`) + process.exit(1) + } + + const account = JSON.parse(fs.readFileSync(accountPath, 'utf8')) + const style = account.capcut?.subtitleStyle || {} + + console.log(`\n[1/3] 清除旧字幕...`) + try { + // 尝试通过清除操作删除旧字幕 + // CapCut Mate API 可能没有直接的 clear_captions,我们通过添加空字幕来覆盖 + const oldCaptions = manifest.items.map(item => ({ + start: 0, + end: 0, + text: '', + })) + await api('add_captions', { + draft_url: draftUrl, + captions: JSON.stringify(oldCaptions), + font: style.font || null, + font_size: style.fontSize || 15, + text_color: style.color || '#ffffff', + alignment: 1, + bold: style.bold || false, + }) + console.log(` ✅ 旧字幕已清除`) + } catch (e) { + console.log(` ⚠️ 清除失败,继续添加新字幕: ${e.message}`) + } + + console.log(`\n[2/3] 添加分句字幕...`) + + const captions = manifest.split_captions.map(cap => ({ + start: cap.start, + end: cap.end, + text: cap.text, + })) + + await api('add_captions', { + draft_url: draftUrl, + captions: JSON.stringify(captions), + font: style.font || null, + font_size: style.fontSize || 15, + text_color: style.color || '#ffffff', + alignment: 1, + bold: style.bold || false, + has_shadow: style.hasShadow || false, + shadow_info: style.shadowAlpha ? { + shadow_alpha: style.shadowAlpha, + shadow_color: style.shadowColor || '#000000', + } : undefined, + transform_y: style.transformY || 0, + }) + + console.log(` ✅ 已添加 ${captions.length} 条字幕`) + + console.log(`\n[3/3] 保存并同步...`) + await api('save_draft', { draft_url: draftUrl }) + console.log(` ✅ 草稿已保存`) + + // 同步到本地剪映(失败不影响字幕) + try { + await syncDraft(draftId, config.jianyingDraftPath) + await registerDraft(draftId, config.jianyingDraftPath) + triggerDirectoryScan(config.capcutMateDir) + console.log(` ✅ 同步完成`) + } catch (e) { + console.log(` ⚠️ 同步失败(字幕已保存,可手动在剪映云端打开): ${e.message}`) + } + + console.log(` ✅ 同步完成`) + + console.log(`\n✅ 字幕更新完成!`) + console.log(` 原字幕:16 条长句`) + console.log(` 新字幕:${captions.length} 条短句`) + console.log(` 平均时长:${(216.6 / captions.length).toFixed(1)} 秒/条`) + console.log(`\n🎬 打开剪映即可看到分句字幕效果`) +} + +if (require.main === module) { + const draftId = process.argv[2] + const manifestPath = process.argv[3] + + if (!draftId || !manifestPath) { + console.error('用法: node lib/update-captions-standalone.js ') + console.error('示例: node lib/update-captions-standalone.js 20260501020548bd30291f ../output/军事账号_20260501_002/manifest.json') + process.exit(1) + } + + updateCaptions(draftId, manifestPath).catch(err => { + console.error(`\n❌ 错误: ${err.message}`) + process.exit(1) + }) +} + +module.exports = { updateCaptions } diff --git a/.claude/skills/video-from-script/scripts/pipeline.js b/.claude/skills/video-from-script/scripts/pipeline.js index a9d51cc..b290672 100644 --- a/.claude/skills/video-from-script/scripts/pipeline.js +++ b/.claude/skills/video-from-script/scripts/pipeline.js @@ -13,9 +13,9 @@ * lib/cmd-*.js ← 子命令实现 * * 用法: - * node pipeline.js run --manifest ./output/batch/manifest.json --account military --resume - * node pipeline.js run --manifest ./output/batch/manifest.json --phase upload,videos - * node pipeline.js status --manifest ./output/batch/manifest.json + * node scripts/pipeline.js run --manifest ./output/batch/manifest.json --account military --resume + * node scripts/pipeline.js run --manifest ./output/batch/manifest.json --phase upload,videos + * node scripts/pipeline.js status --manifest ./output/batch/manifest.json */ const { loadManifest, saveManifest, loadAccountConfig, log } = require('./lib/pipeline-utils')