From f5d47ec5dbe776ce9e7f552ddfc9e4c30f5d4f53 Mon Sep 17 00:00:00 2001 From: sion123 <450702724@qq.com> Date: Fri, 1 May 2026 14:16:08 +0800 Subject: [PATCH] =?UTF-8?q?feat(video-pipeline):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E5=8F=AF=E9=85=8D=E7=BD=AE=E7=9A=84=E8=BD=AC=E5=9C=BA=E7=AD=96?= =?UTF-8?q?=E7=95=A5=E5=BC=95=E6=93=8E=E5=92=8CTTS=E8=AF=AD=E9=80=9F?= =?UTF-8?q?=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 引入转场策略系统(`getTransition`),支持 `fixed`、`director`、`rhythm` 三种模式 - 根据账号配置文件动态读取转场配置(`loadTransitions`) - 图片和视频轨道分别调用转场策略,替代原有的固定“闪白”转场 - 支持 `byPosition`(hook/body/keypoint/closing)和 `byDirector` 两种高级选择策略 - 图片动画支持 `loop_animation` 与 `in_animation` 解析(“缩放”、“弹入”等组合) - TTS 合成新增 `rate` 字段(源自账号配置 `ttsRate`),默认语速调整为 1.1 - 默认动画类型从 `kenburns-zoom` 改为 `缩放`,适配中文 CapCut --- .../scripts/capcut_assemble.js | 93 ++++++++++++++++--- .../video-from-script/scripts/lib/cmd-init.js | 1 + .../scripts/lib/phase-assemble.js | 2 +- .../scripts/lib/phase-tts.js | 1 + .../video-from-script/scripts/qwen-tts.js | 2 +- accounts/_template/account.json | 17 +++- accounts/军事账号/account.json | 17 +++- 7 files changed, 118 insertions(+), 15 deletions(-) diff --git a/.claude/skills/video-from-script/scripts/capcut_assemble.js b/.claude/skills/video-from-script/scripts/capcut_assemble.js index b77f51e..9a5f8d6 100644 --- a/.claude/skills/video-from-script/scripts/capcut_assemble.js +++ b/.claude/skills/video-from-script/scripts/capcut_assemble.js @@ -108,6 +108,44 @@ async function uploadToOSS(filePath) { return url } +// ============================================================================ +// 转场选择策略 +// ============================================================================ + +function getTransition(item, index, totalCount, transitionConfig) { + // 无配置 → 不加转场 + if (!transitionConfig) return { name: '', duration: 0 } + + const defaultT = transitionConfig.default || { name: '闪白', duration: 150000 } + const strategy = transitionConfig.strategy || 'fixed' + + // 第一个素材不加转场 + if (index === 0) return { name: '', duration: 0 } + + switch (strategy) { + case 'director': { + // 按 directorRef 选择转场 + const ref = (item.directorRef || '').toLowerCase() + const byDirector = transitionConfig.byDirector || {} + return byDirector[ref] || defaultT + } + + case 'rhythm': { + // 按位置选择转场(hook / body / keypoint / closing) + const rules = transitionConfig.byPosition || {} + if (index === 1) return rules.hook || defaultT + if (index >= totalCount - 2) return rules.closing || defaultT + // 每隔3个 shot 用一个强调转场 + if (index % 3 === 0) return rules.keypoint || defaultT + return rules.body || defaultT + } + + case 'fixed': + default: + return defaultT + } +} + async function batchUploadToOSS(inputDir, files) { const urls = {} for (const file of files) { @@ -179,7 +217,7 @@ async function assemble(args) { format = '9:16', apiKey = '', duration = '4', - animation = 'kenburns-zoom', + animation = '缩放', localAudio = 'true', } = args @@ -284,7 +322,7 @@ async function assemble(args) { // -- 导入素材 -- step++; console.log(`[${step}/${totalSteps}] 导入素材...`) if (mode === 'images') { - await addImages(draftUrl, items, imgUrls, timeline, width, height, animation) + await addImages(draftUrl, items, imgUrls, timeline, width, height, animation, transitionConfig) } else { // 视频模式:调速 → 上传 OSS → 添加到草稿 // Step 1: ffmpeg 调速(在上传前,避免传两份) @@ -330,7 +368,7 @@ async function assemble(args) { } } } - await addVideos(draftUrl, inputDir, items, timeline, width, height) + await addVideos(draftUrl, inputDir, items, timeline, width, height, transitionConfig) } // -- 添加 TTS 配音 -- @@ -355,6 +393,9 @@ async function assemble(args) { console.log(` 字幕风格: ${subtitleStyle.font || '默认'} ${subtitleStyle.inAnimation ? subtitleStyle.inAnimation + '→' + subtitleStyle.outAnimation : ''}`) } + // -- 读取转场策略 -- + const transitionConfig = loadTransitions(manifest) + // -- 添加字幕 -- step++; console.log(`[${step}/${totalSteps}] 添加字幕...`) if (subtitles === 'true' && items.some(i => i.script || i.text)) { @@ -418,23 +459,40 @@ async function assemble(args) { // 添加图片(自动上传到 OSS) // ============================================================================ -async function addImages(draftUrl, items, imgUrls, timeline, width, height, animation = '') { +async function addImages(draftUrl, items, imgUrls, timeline, width, height, animation = '', transitionConfig = null) { const imageInfos = items.map((item, i) => { const url = imgUrls[item.file] if (!url) throw new Error(`图片 ${item.file} 未上传成功,无法添加`) const tl = timeline[i] + const t = getTransition(item, i, items.length, transitionConfig) - return { + // animation 解析:支持 "缩放" (group), "放大" (in), "渐显+缩小" (in+out) + const info = { image_url: url, width, height, start: tl.start, end: tl.end, duration: tl.duration, - animation: animation || '', - transition: i > 0 ? '闪白' : '', - transition_duration: 150000, + transition: t.name, + transition_duration: t.duration, } + + if (animation) { + const parts = animation.split('+') + for (const part of parts) { + const name = part.trim() + // 组合动画(持续整段):缩放、三分割 等 + if (name === '缩放' || name === '缩放 II') { + info.loop_animation = name + } else { + // 默认作为入场动画 + info.in_animation = name + } + } + } + + return info }) // 单次全量提交,所有图片在同一轨道 @@ -509,9 +567,10 @@ async function adjustVideoSpeed(videoPath, targetDurationSec) { }) } -async function addVideos(draftUrl, inputDir, items, timeline, width, height) { +async function addVideos(draftUrl, inputDir, items, timeline, width, height, transitionConfig = null) { const videoInfos = items.map((item, i) => { const tl = timeline[i] + const t = getTransition(item, i, items.length, transitionConfig) return { video_url: item.videoUrl || (item.video ? path.resolve(inputDir, item.video) : null) || item.url || path.resolve(inputDir, item.file), width, @@ -520,8 +579,8 @@ async function addVideos(draftUrl, inputDir, items, timeline, width, height) { end: tl.end, duration: tl.duration, mask: '', - transition: i > 0 ? '闪白' : '', - transition_duration: 150000, + transition: t.name, + transition_duration: t.duration, volume: item.volume || 1, } }) @@ -718,6 +777,18 @@ function loadSubtitleStyle(manifest) { } catch { return {} } } +function loadTransitions(manifest) { + const account = manifest.account + if (!account) return null + const scriptDir = __dirname + const accountFile = path.join(scriptDir, '..', '..', '..', 'accounts', account, 'account.json') + if (!fs.existsSync(accountFile)) return null + try { + const accountData = JSON.parse(fs.readFileSync(accountFile, 'utf-8')) + return accountData.capcut?.transitions || null + } catch { return null } +} + // ============================================================================ // 添加字幕(支持关键词高亮 + 账号字幕风格 + 分句切分) // ============================================================================ diff --git a/.claude/skills/video-from-script/scripts/lib/cmd-init.js b/.claude/skills/video-from-script/scripts/lib/cmd-init.js index ad546bc..4bed70d 100644 --- a/.claude/skills/video-from-script/scripts/lib/cmd-init.js +++ b/.claude/skills/video-from-script/scripts/lib/cmd-init.js @@ -93,6 +93,7 @@ function initManifest(options) { references, ...(accountConfig.ttsVoice ? { ttsVoice: accountConfig.ttsVoice } : {}), ...(accountConfig.ttsInstruction ? { ttsInstruction: accountConfig.ttsInstruction } : {}), + ...(accountConfig.ttsRate ? { ttsRate: accountConfig.ttsRate } : {}), items, } diff --git a/.claude/skills/video-from-script/scripts/lib/phase-assemble.js b/.claude/skills/video-from-script/scripts/lib/phase-assemble.js index f8b4b6f..c7f6e06 100644 --- a/.claude/skills/video-from-script/scripts/lib/phase-assemble.js +++ b/.claude/skills/video-from-script/scripts/lib/phase-assemble.js @@ -23,7 +23,7 @@ async function phaseAssemble(manifest, manifestPath, options) { subtitles: mode === 'images' ? 'true' : 'false', voiceover: manifest.items.some(it => it.audio) ? 'true' : 'false', duration: '4', - animation: capcutConfig.animation || 'kenburns-zoom', + animation: capcutConfig.animation || '缩放', } if (capcutConfig.defaultBGM) assembleArgs.bgm = capcutConfig.defaultBGM diff --git a/.claude/skills/video-from-script/scripts/lib/phase-tts.js b/.claude/skills/video-from-script/scripts/lib/phase-tts.js index cb2fa3a..97e5f14 100644 --- a/.claude/skills/video-from-script/scripts/lib/phase-tts.js +++ b/.claude/skills/video-from-script/scripts/lib/phase-tts.js @@ -30,6 +30,7 @@ async function phaseTts(manifest, manifestPath, options = {}) { id: item.id || idx, voice: manifest.ttsVoice || undefined, instruction: manifest.ttsInstruction || undefined, + rate: manifest.ttsRate || undefined, }) item.audio = path.relative(dir, filePath).replace(/\\/g, '/') item.audioDuration = Math.round(duration * 1000) / 1000 diff --git a/.claude/skills/video-from-script/scripts/qwen-tts.js b/.claude/skills/video-from-script/scripts/qwen-tts.js index bc5d5f0..b95b0d6 100644 --- a/.claude/skills/video-from-script/scripts/qwen-tts.js +++ b/.claude/skills/video-from-script/scripts/qwen-tts.js @@ -108,7 +108,7 @@ function synthesize(text, options = {}) { format: 'mp3', sample_rate: 24000, volume: 50, - rate: 1.0, + rate: options.rate || 1.1, pitch_rate: 1.0, text_type: 'PlainText', ...(instruction ? { instruction } : {}), diff --git a/accounts/_template/account.json b/accounts/_template/account.json index 3385ef5..ddc90ba 100644 --- a/accounts/_template/account.json +++ b/accounts/_template/account.json @@ -21,6 +21,21 @@ "highlightColor": "#FF6B35", "bold": true }, - "defaultBGM": "" + "defaultBGM": "", + "transitions": { + "strategy": "rhythm", + "default": { "name": "闪白", "duration": 150000 }, + "byPosition": { + "hook": { "name": "闪白", "duration": 100000 }, + "keypoint": { "name": "闪白", "duration": 120000 }, + "body": { "name": "溶解", "duration": 300000 }, + "closing": { "name": "黑场", "duration": 200000 } + }, + "byDirector": { + "tarantino": { "name": "闪白", "duration": 100000 }, + "kitano": { "name": "溶解", "duration": 400000 }, + "fincher": { "name": "黑场", "duration": 200000 } + } + } } } diff --git a/accounts/军事账号/account.json b/accounts/军事账号/account.json index 0664f5d..6a2a76a 100644 --- a/accounts/军事账号/account.json +++ b/accounts/军事账号/account.json @@ -36,6 +36,21 @@ "inAnimation": "淡入", "outAnimation": "淡出" }, - "defaultBGM": "" + "defaultBGM": "", + "transitions": { + "strategy": "rhythm", + "default": { "name": "闪白", "duration": 150000 }, + "byPosition": { + "hook": { "name": "闪白", "duration": 100000 }, + "keypoint": { "name": "闪白", "duration": 120000 }, + "body": { "name": "溶解", "duration": 300000 }, + "closing": { "name": "黑场", "duration": 200000 } + }, + "byDirector": { + "tarantino": { "name": "闪白", "duration": 100000 }, + "kitano": { "name": "溶解", "duration": 400000 }, + "fincher": { "name": "黑场", "duration": 200000 } + } + } } }