diff --git a/.claude/skills/video-from-script/scripts/capcut_assemble.js b/.claude/skills/video-from-script/scripts/capcut_assemble.js index bbfa06c..8de01d5 100644 --- a/.claude/skills/video-from-script/scripts/capcut_assemble.js +++ b/.claude/skills/video-from-script/scripts/capcut_assemble.js @@ -17,7 +17,7 @@ const path = require('path') const fs = require('fs') const { execFile } = require('child_process') const { syncDraft, registerDraft, triggerDirectoryScan } = require('./sync-to-jianying') -const { splitTextIntoSentences } = require('./lib/pipeline-utils') +const { splitTextIntoSentences, loadAccountConfig: loadAccountConfigFromUtils, saveManifest } = require('./lib/pipeline-utils') // ============================================================================ // 配置 @@ -113,15 +113,18 @@ async function uploadToOSS(filePath) { // 转场选择策略 // ============================================================================ -function getTransition(item, index, totalCount, transitionConfig) { +function getTransition(item, index, totalCount, transitionConfig, allItems) { // 无配置 → 不加转场 if (!transitionConfig) return { name: '', duration: 0 } const defaultT = transitionConfig.default || { name: '闪白', duration: 150000 } const strategy = transitionConfig.strategy || 'fixed' - // 前两段不加转场(避免开头黑屏/闪烁) - if (index <= 1) return { name: '', duration: 0 } + // 第一段不加转场(避免开头黑屏/闪烁) + if (index === 0) return { name: '', duration: 0 } + + // 最后一段不需要转场(没有下一段可过渡) + if (index >= totalCount - 1) return { name: '', duration: 0 } switch (strategy) { case 'director': { @@ -132,10 +135,22 @@ function getTransition(item, index, totalCount, transitionConfig) { } case 'rhythm': { - // 按位置选择转场:只在 keypoint / closing 加转场,其余不加 + // 按位置选择转场:hook/keypoint/closing 加转场,body 段隔 3 段加一次 const rules = transitionConfig.byPosition || {} + // 收尾段 if (index >= totalCount - 2) return rules.closing || defaultT - if (index % 4 === 0) return rules.keypoint || defaultT + // 内容结构变化检测:directorRef 变化处、keyword 标记处加转场 + if (allItems && index > 0) { + const prev = allItems[index - 1] + if (item.directorRef && prev.directorRef && item.directorRef !== prev.directorRef) { + return rules.keypoint || defaultT + } + if (item.keyword && !prev.keyword) { + return rules.keypoint || defaultT + } + } + // 固定间隔兜底(每 3 段) + if (index % 3 === 0) return rules.body || defaultT return { name: '', duration: 0 } } @@ -375,8 +390,8 @@ async function assemble(args) { if (manifestFile) { try { const m = JSON.parse(fs.readFileSync(manifestFile, 'utf-8')) - const mi = m.items.find(i => i.id === item.id || i.script === item.script || i.text === item.text) - if (mi) { mi.videoUrl = url; fs.writeFileSync(manifestFile, JSON.stringify(m, null, 2)) } + const mi = m.items.find(i => i.id === item.id) + if (mi) { mi.videoUrl = url; saveManifest(manifestFile, m) } } catch (_) {} } } catch (err) { @@ -411,7 +426,7 @@ async function assemble(args) { } } } - if (changed) fs.writeFileSync(manifestFile, JSON.stringify(manifest, null, 2)) + if (changed) saveManifest(manifestFile, manifest) } } catch (err) { console.log(` OSS 上传失败,将尝试本地路径: ${err.message}\n`) @@ -512,12 +527,24 @@ async function assemble(args) { // 添加图片(自动上传到 OSS) // ============================================================================ +// 按 directorRef 映射不同动画风格 +const DIRECTOR_ANIMATIONS = { + tarantino: '缩放', // 快速冲击感缩放 + kitano: '回弹伸缩', // 缓慢静谧的弹性平移 + fincher: '缩放 II', // 精确微距推进 +} + +function getAnimationForDirector(directorRef, defaultAnimation) { + if (!directorRef) return defaultAnimation + return DIRECTOR_ANIMATIONS[directorRef.toLowerCase()] || defaultAnimation +} + 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) + const t = getTransition(item, i, items.length, transitionConfig, items) // animation 解析:支持 "缩放" (group), "放大" (in), "渐显+缩小" (in+out) const info = { @@ -531,12 +558,13 @@ async function addImages(draftUrl, items, imgUrls, timeline, width, height, anim transition_duration: t.duration, } - // animation 解析:循环动画 → loop_animation,其余 → in_animation + // animation 解析:按 directorRef 选择风格,循环动画 → loop_animation // GroupAnimationType(循环):缩放, 缩放 II, 回弹伸缩, 旋转伸缩, ... // IntroType(入场):渐显, 放大, 缩小, 向右滑动, 轻微放大, ... // OutroType(出场):渐隐, 缩小, 放大, 向左滑动, ... - if (animation) { - const parts = animation.split('+').map(p => p.trim()).filter(Boolean) + const itemAnimation = getAnimationForDirector(item.directorRef, animation) + if (itemAnimation) { + const parts = itemAnimation.split('+').map(p => p.trim()).filter(Boolean) const loopNames = ['缩放', '缩放 II', '回弹伸缩', '旋转伸缩'] const loopAnims = parts.filter(p => loopNames.includes(p)) const inAnims = parts.filter(p => !loopNames.includes(p)) @@ -594,8 +622,8 @@ async function adjustVideoSpeed(videoPath, targetDurationSec) { const atempo = Math.min(speed, 2.0) // atempo 单次上限 2.0 execFile('ffmpeg', [ '-y', '-i', videoPath, - '-filter_complex', `setpts=PTS/${speed}${videoPath.endsWith('.mp4') ? `,atempo=${atempo}` : ''}`, - '-an', // 去掉音频(视频模式不需要原始音轨) + '-filter_complex', `setpts=PTS/${speed}`, + '-an', outPath ], { timeout: 30000 }, (err) => { if (err) { console.log(` 调速失败,使用原始视频: ${err.message}`); resolve(videoPath); return } @@ -750,27 +778,17 @@ async function addVoiceover(draftUrl, inputDir, items, timeline, audioUrls = {}) if (segments) { // 多段音频:按 segment 逐段添加,使用精确时长 - const totalSegDur = segments.reduce((sum, s) => sum + s.duration * US, 0) - const tlDuration = tl.end - tl.start - let currentTime = tl.start - - for (let j = 0; j < segments.length; j++) { - const seg = segments[j] - const segDurUs = Math.round(seg.duration * US) - let duration = Math.round(tlDuration * (segDurUs / totalSegDur)) - if (j === segments.length - 1) duration = tl.end - currentTime - duration = Math.max(duration, 100000) - - const audioUrl = resolveAudio(seg.audio) + const slots = distributeSegments(tl, segments) + for (const slot of slots) { + const audioUrl = resolveAudio(slot.audio) audioInfos.push({ audio_url: audioUrl, - start: currentTime, - end: currentTime + duration, - duration, + start: slot.start, + end: slot.end, + duration: slot.duration, volume: 1.0, }) - currentTime += duration } } else if (item.audio) { // 单段音频 @@ -830,12 +848,35 @@ async function addBGM(draftUrl, bgmUrl, totalDurationUs) { // 读取账号配置 // ============================================================================ +// 动画属性注入(DRY helper) +function applyAnimationProps(cap, style = {}) { + if (style.inAnimation) cap.in_animation = style.inAnimation + if (style.outAnimation) cap.out_animation = style.outAnimation + if (style.inAnimDuration) cap.in_animation_duration = style.inAnimDuration + if (style.outAnimDuration) cap.out_animation_duration = style.outAnimDuration +} + +// segments 按比例分配到时间线(DRY helper) +function distributeSegments(tl, segments) { + const totalSegDur = segments.reduce((sum, s) => sum + (s.duration || 0) * US, 0) + if (totalSegDur <= 0) return [] + const tlDuration = tl.end - tl.start + let currentTime = tl.start + return segments.map((seg, idx) => { + const segDurUs = Math.round((seg.duration || 0) * US) + let duration = Math.round(tlDuration * (segDurUs / totalSegDur)) + if (idx === segments.length - 1) duration = tl.end - currentTime + duration = Math.max(duration, 100000) + const entry = { start: currentTime, end: currentTime + duration, duration, text: seg.text, audio: seg.audio } + currentTime += duration + return entry + }) +} + function loadAccountConfig(manifest) { const account = manifest.account if (!account) return {} - const accountFile = path.join(__dirname, '..', '..', '..', '..', 'accounts', account, 'account.json') - if (!fs.existsSync(accountFile)) return {} - try { return JSON.parse(fs.readFileSync(accountFile, 'utf-8')) } catch { return {} } + try { return loadAccountConfigFromUtils(account) } catch { return {} } } function loadSubtitleStyle(manifest) { @@ -868,10 +909,7 @@ async function addKeywordOverlays(draftUrl, items, timeline, style = {}) { end: tl.end, text: item.keyword, } - if (style.inAnimation) cap.in_animation = style.inAnimation - if (style.outAnimation) cap.out_animation = style.outAnimation - if (style.inAnimDuration) cap.in_animation_duration = style.inAnimDuration - if (style.outAnimDuration) cap.out_animation_duration = style.outAnimDuration + applyAnimationProps(cap, style) captions.push(cap) } @@ -915,10 +953,12 @@ async function addSubtitles(draftUrl, items, timeline, style = {}, split = false const captions = [] // 从账号配置读取动画参数 - const inAnimation = style.inAnimation || '' - const outAnimation = style.outAnimation || '' - const inAnimDuration = style.inAnimationDuration || null - const outAnimDuration = style.outAnimationDuration || null + const animStyle = { + inAnimation: style.inAnimation || '', + outAnimation: style.outAnimation || '', + inAnimDuration: style.inAnimationDuration || null, + outAnimDuration: style.outAnimationDuration || null, + } for (let i = 0; i < items.length; i++) { const item = items[i] @@ -933,33 +973,13 @@ async function addSubtitles(draftUrl, items, timeline, style = {}, split = false if (segments) { // 精确模式:用 segments 的实际音频时长 - const totalSegDur = segments.reduce((sum, s) => sum + s.duration * US, 0) - const tlDuration = tl.end - tl.start - let currentTime = tl.start - - segments.forEach((seg, idx) => { - const segDurUs = Math.round(seg.duration * US) - // 按实际时长占比映射到时间线(处理 ffprobe 重新测量的差异) - let duration = Math.round(tlDuration * (segDurUs / totalSegDur)) - if (idx === segments.length - 1) { - duration = tl.end - currentTime - } - duration = Math.max(duration, 1000000) - - const cap = { - start: currentTime, - end: currentTime + duration, - text: seg.text, - } - - 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 + const slots = distributeSegments(tl, segments) + for (const slot of slots) { + const cap = { start: slot.start, end: slot.end, text: slot.text } + applyAnimationProps(cap, animStyle) captions.push(cap) - currentTime += duration - }) + } } else { // 回退:字数权重估算 const sentences = splitTextIntoSentences(text) @@ -985,11 +1005,7 @@ async function addSubtitles(draftUrl, items, timeline, style = {}, split = false text: sentence, } - 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 - + applyAnimationProps(cap, animStyle) captions.push(cap) currentTime += duration }) @@ -1002,11 +1018,7 @@ async function addSubtitles(draftUrl, items, timeline, style = {}, split = false text, } - 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 - + applyAnimationProps(cap, animStyle) captions.push(cap) } } @@ -1043,7 +1055,7 @@ async function addSubtitles(draftUrl, items, timeline, style = {}, split = false style_text: 0, }) - console.log(` 已添加 ${captions.length} 条字幕${split ? ' (分句模式)' : ''} (字体: ${style.font || '默认'}, 动画: ${inAnimation || '无'} → ${outAnimation || '无'})`) + console.log(` 已添加 ${captions.length} 条字幕${split ? ' (分句模式)' : ''} (字体: ${style.font || '默认'}, 动画: ${animStyle.inAnimation || '无'} → ${animStyle.outAnimation || '无'})`) } // ============================================================================