From 4d5c8cb96d4b6d940500c7a2c8088d91566cedc6 Mon Sep 17 00:00:00 2001 From: sion123 <450702724@qq.com> Date: Sat, 2 May 2026 00:27:54 +0800 Subject: [PATCH] =?UTF-8?q?refactor(video-pipeline):=20=E5=B0=86=20CapCut?= =?UTF-8?q?=20=E7=BB=84=E8=A3=85=E8=84=9A=E6=9C=AC=E6=8B=86=E5=88=86?= =?UTF-8?q?=E4=B8=BA=E6=A8=A1=E5=9D=97=E5=8C=96=E6=9E=B6=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 monolith 的 capcut_assemble.js 重构为核心编排器,提取基础设施层(capcut-api)、时间线算法(capcut-timeline)和轨道操作(capcut-tracks)为独立模块。此拆分使 Agent 未来对字幕风格、Ken Burns、转场、特效等调整只需关注 capcut-tracks.js,无需理解全流程编排逻辑。 --- .../scripts/capcut_assemble.js | 1129 ++--------------- .../scripts/lib/capcut-api.js | 94 ++ .../scripts/lib/capcut-timeline.js | 229 ++++ .../scripts/lib/capcut-tracks.js | 621 +++++++++ 4 files changed, 1029 insertions(+), 1044 deletions(-) create mode 100644 .claude/skills/video-from-script/scripts/lib/capcut-api.js create mode 100644 .claude/skills/video-from-script/scripts/lib/capcut-timeline.js create mode 100644 .claude/skills/video-from-script/scripts/lib/capcut-tracks.js diff --git a/.claude/skills/video-from-script/scripts/capcut_assemble.js b/.claude/skills/video-from-script/scripts/capcut_assemble.js index 8bfb691..03f70dc 100644 --- a/.claude/skills/video-from-script/scripts/capcut_assemble.js +++ b/.claude/skills/video-from-script/scripts/capcut_assemble.js @@ -1,102 +1,34 @@ #!/usr/bin/env node /** - * CapCut 成片组装脚本 + * CapCut 成片组装脚本 — 编排器 * * 将图片/视频素材通过 CapCut Mate API 组装为草稿,同步到本地剪映。 * + * 模块结构: + * capcut_assemble.js ← 编排器(本文件)+ CLI + 上传 + 同步 + * lib/capcut-api.js ← 配置、API 封装、工具函数 + * lib/capcut-timeline.js ← 时间线构建 + 视频调整策略 + * lib/capcut-tracks.js ← 所有轨道操作(图片/视频/音频/字幕/特效) + * * 用法: * node capcut_assemble.js --input ./output/batch_xxx [选项] - * - * 配置: - * 请运行 node setup.js 生成配置 - * 同步方式: 纯 Node.js(sync-to-jianying.js),无需 Python/uv */ -const axios = require('axios') const path = require('path') const fs = require('fs') -const { execFile } = require('child_process') -const { syncDraft, registerDraft, triggerDirectoryScan } = require('./sync-to-jianying') -const { splitTextIntoSentences, loadAccountConfig: loadAccountConfigFromUtils, saveManifest } = require('./lib/pipeline-utils') -// ============================================================================ -// 配置 -// ============================================================================ - -let _config = null -function getConfig() { - if (_config) return _config - const configPath = path.join(__dirname, '..', '..', 'config.json') - if (!fs.existsSync(configPath)) { - console.error('缺少配置文件: skills/config.json') - console.error('请运行 node setup.js 生成配置') - process.exit(1) - } - const config = JSON.parse(fs.readFileSync(configPath, 'utf-8')) - if (!config.jianyingDraftPath || !config.capcutMateDir || !config.capcutMateApiBase) { - console.error('config.json 需要填写 jianyingDraftPath、capcutMateDir 和 capcutMateApiBase') - process.exit(1) - } - _config = config - return _config -} - -const BASE_URL = getConfig().capcutMateApiBase -const US = 1_000_000 - -// ============================================================================ -// CapCut API 封装 -// ============================================================================ - -async function api(endpoint, data = {}, timeout = 60000) { - const url = `${BASE_URL}/${endpoint}` - const method = endpoint === 'get_draft' ? 'get' : 'post' - try { - const res = method === 'get' - ? await axios.get(url, { params: data, timeout }) - : await axios.post(url, data, { timeout }) - if (res.data.code !== undefined && res.data.code !== 0) { - throw new Error(`API [${endpoint}] 返回错误: ${res.data.message}`) - } - return res.data - } catch (err) { - if (err.response) { - throw new Error(`API [${endpoint}] HTTP ${err.response.status}: ${JSON.stringify(err.response.data)}`) - } - throw err - } -} - -// ============================================================================ -// CLI 参数 -// ============================================================================ - -function parseArgs(argv) { - const args = {} - for (let i = 0; i < argv.length; i++) { - if (argv[i].startsWith('--')) { - const key = argv[i].slice(2) - const value = argv[i + 1] - if (value && !value.startsWith('--')) { - args[key] = value - i++ - } else { - args[key] = true - } - } - } - return args -} - -function getResolution(format) { - const map = { - '9:16': { width: 1080, height: 1920 }, - '16:9': { width: 1920, height: 1080 }, - '1:1': { width: 1080, height: 1080 }, - '4:3': { width: 1440, height: 1080 }, - } - return map[format] || map['9:16'] -} +const { US, parseArgs, getResolution, getAudioDurationSec } = require('./lib/capcut-api') +const { buildTimeline, adjustVideoSpeed } = require('./lib/capcut-timeline') +const { + loadAccountConfig, loadSubtitleStyle, loadKeywordStyle, + loadKenBurns, loadTransitions, + addImages, addVideos, addKenBurns, + addVoiceover, addBGM, + addSubtitles, addKeywordOverlays, + addEffects, addFilter, +} = require('./lib/capcut-tracks') +const { saveManifest } = require('./lib/pipeline-utils') +const { syncDraft, registerDraft } = require('./sync-to-jianying') // ============================================================================ // OSS 上传 @@ -109,74 +41,6 @@ async function uploadToOSS(filePath) { return url } -// ============================================================================ -// 转场选择策略 -// ============================================================================ - -function getTransition(item, index, totalCount, transitionConfig, allItems, timeline) { - // 无配置 → 不加转场 - 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 } - - // 最后一段不需要转场(没有下一段可过渡) - if (index >= totalCount - 1) 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/keypoint/closing 加转场,body 段按累计时长间隔 - const rules = transitionConfig.byPosition || {} - // 收尾段 - if (index >= totalCount - 2) return rules.closing || 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 - } - } - // 基于累计时长的间隔(约每 8 秒加一次转场) - if (timeline && timeline.length > 0) { - const elapsed = timeline[index].start - // 向前搜索上一个有转场的段落(只看 directorRef/keyword/closing,不递归) - let lastTransStart = 0 - for (let pi = index - 1; pi >= 1; pi--) { - const p = allItems[pi] - const pp = pi > 0 ? allItems[pi - 1] : null - if (pi >= totalCount - 2) { lastTransStart = timeline[pi].start; break } - if (pp && p.directorRef && pp.directorRef && p.directorRef !== pp.directorRef) { - lastTransStart = timeline[pi].start; break - } - if (p.keyword && pp && !pp.keyword) { lastTransStart = timeline[pi].start; break } - } - if (elapsed - lastTransStart >= 8000000) return rules.body || defaultT - } else { - // 无 timeline 时回退到固定间隔 - if (index % 3 === 0) return rules.body || defaultT - } - return { name: '', duration: 0 } - } - - case 'fixed': - default: - return defaultT - } -} - async function batchUploadToOSS(inputDir, files, concurrency = 3) { const urls = {} const queue = [...files] @@ -198,109 +62,62 @@ async function batchUploadToOSS(inputDir, files, concurrency = 3) { return urls } -function getAudioDurationSec(filePath) { - return new Promise((resolve) => { - execFile('ffprobe', [ - '-v', 'quiet', '-show_entries', 'format=duration', - '-of', 'csv=p=0', filePath - ], (err, stdout) => { - if (err) { resolve(null); return } - const dur = parseFloat(stdout.trim()) - resolve(dur > 0 ? dur : null) - }) - }) +async function batchUploadAudio(inputDir, items) { + const urls = {} + for (const item of items) { + if (item.segments && item.segments.length > 0) { + for (const seg of item.segments) { + if (!seg.audio || seg.audio.startsWith('http') || urls[seg.audio]) continue + const filePath = path.isAbsolute(seg.audio) + ? seg.audio + : path.resolve(inputDir, seg.audio) + if (!fs.existsSync(filePath)) { + console.error(` 音频文件不存在: ${filePath}`) + continue + } + try { + urls[seg.audio] = await uploadToOSS(filePath) + console.log(` 上传: ${path.basename(filePath)} -> OK`) + } catch (err) { + console.error(` 上传失败: ${path.basename(filePath)} - ${err.message}`) + } + } + } + if (!item.audio || item.audio.startsWith('http')) { + if (item.audio) urls[item.audio] = item.audio + continue + } + if (urls[item.audio]) continue + const filePath = path.isAbsolute(item.audio) + ? item.audio + : path.resolve(inputDir, item.audio) + if (!fs.existsSync(filePath)) { + console.error(` 音频文件不存在: ${filePath}`) + continue + } + try { + urls[item.audio] = await uploadToOSS(filePath) + console.log(` 上传: ${path.basename(filePath)} -> OK`) + } catch (err) { + console.error(` 上传失败: ${path.basename(filePath)} - ${err.message}`) + } + } + return urls +} + +// ============================================================================ +// 同步到本地剪映 +// ============================================================================ + +async function syncToLocalJianying(draftUrl, draftId, totalDurationUs) { + await syncDraft(draftUrl, { name: draftId }) + registerDraft(draftId, draftId, totalDurationUs) } // ============================================================================ // 主流程 // ============================================================================ -function buildTimeline(items) { - // 核心规则: - // 图片模式:图片没有独立时长,TTS 音频时长 = 画面时长。无音频 = 0 时长(跳过) - // 视频模式:TTS 为主轴,视频通过 裁剪/加速/放缓/停顿 适配 - // 视频比音频长(ratio > 1.1): - // ≤ 2x → 加速(setpts 压缩时间) - // > 2x → 裁剪(截断到音频时长) - // 视频比音频短(ratio < 0.9): - // ≥ 0.5x → 放缓(setpts 拉长时间,≤2x慢速) - // < 0.5x → 画面停顿(视频正常播放+最后一帧冻结补时长) - let offset = 0 - return items.map(item => { - // 有 segments 时用各段实际时长之和(精确对齐音频文件) - let audioDur - if (item.segments && item.segments.length > 0) { - audioDur = item.segments.reduce((sum, s) => sum + (s.duration || 0), 0) * US - } else { - audioDur = (item.audioDuration != null) ? item.audioDuration * US : 0 - } - const videoDur = (item.videoDuration != null) ? item.videoDuration * US : 0 - const hasVideo = !!(item.video || item.videoUrl || item.url) - - // 无 TTS 音频 - if (audioDur <= 0) { - if (hasVideo && videoDur > 0) { - // 视频模式无音频:用视频原始时长 - const entry = { start: offset, end: offset + videoDur, duration: videoDur, speed: 1, strategy: 'none' } - offset += videoDur - return entry - } - // 图片模式无音频:0 时长,标记跳过 - const entry = { start: offset, end: offset, duration: 0, speed: 1, strategy: 'none', skip: true } - return entry - } - - // 有 TTS:音频时长为主轴 - const dur = audioDur - - if (!hasVideo || videoDur <= 0) { - // 图片模式:直接用音频时长 - const entry = { start: offset, end: offset + dur, duration: dur, speed: 1, strategy: 'none' } - offset += dur - return entry - } - - // 视频模式:视频 vs 音频时长匹配 - const ratio = videoDur / audioDur - - if (ratio > 1.1) { - // 视频比音频长 - if (ratio <= 2) { - // 加速策略 - const entry = { start: offset, end: offset + dur, duration: dur, speed: ratio, strategy: 'speed_up' } - offset += dur - return entry - } else { - // 裁剪策略 - const entry = { start: offset, end: offset + dur, duration: dur, speed: 1, strategy: 'trim' } - offset += dur - return entry - } - } else if (ratio < 0.9) { - // 视频比音频短 - if (ratio >= 0.5) { - // 放缓策略(慢放 ≤2x) - const entry = { start: offset, end: offset + dur, duration: dur, speed: ratio, strategy: 'slow_down' } - offset += dur - return entry - } else { - // 画面停顿策略(视频原速播放 + 最后一帧冻结补时长) - const entry = { - start: offset, end: offset + dur, duration: dur, speed: 1, - strategy: 'freeze', freezeExtra: dur - videoDur, - } - offset += dur - return entry - } - } else { - // 接近匹配(0.9 ~ 1.1),无需调整 - const entry = { start: offset, end: offset + dur, duration: dur, speed: 1, strategy: 'none' } - offset += dur - return entry - } - }) -} - async function assemble(args) { const { input, @@ -330,7 +147,7 @@ async function assemble(args) { const manifest = JSON.parse(fs.readFileSync(manifestFile, 'utf-8')) - // 从 account.json 自动继承 effects / filter(CLI 参数优先) + // 从 account.json 自动继承 effects / filter let finalEffects = effectsStr let finalFilter = filterStr if (!finalEffects || !finalFilter) { @@ -345,32 +162,18 @@ async function assemble(args) { const { width, height } = getResolution(format) - // 过滤出实际存在的文件 - const missingFileItems = [] const items = manifest.items.filter(item => { - if (item.url) return true // 视频模式可能用 URL - if (item.video) return true // 视频模式本地文件 - if (!item.file) { - missingFileItems.push(item.id || '?') - return false - } + if (item.url) return true + if (item.video) return true const filePath = path.join(inputDir, item.file) return fs.existsSync(filePath) }) - if (items.length === 0) { - if (missingFileItems.length > 0) { - throw new Error(`没有可用的素材文件 — ${missingFileItems.length} 个 item 缺少 file 字段(id: ${missingFileItems.join(', ')}),请先运行 images 阶段`) - } - throw new Error('没有可用的素材文件') - } - if (items.length === 0) throw new Error('没有可用的素材文件') - // 用 ffprobe 测量实际音频/视频时长,替代 manifest 中的估计值 + // ffprobe 测量实际时长 let audioMeasured = 0, videoMeasured = 0 for (const item of items) { - // 测量各 segment 音频文件实际时长 if (item.segments && item.segments.length > 0) { for (const seg of item.segments) { if (!seg.audio || seg.audio.startsWith('http')) continue @@ -390,13 +193,12 @@ async function assemble(args) { if (actualDur != null) { item.audioDuration = actualDur; audioMeasured++ } } } - // 测量视频文件实际时长(生成器返回的是硬编码常量,不准确) if (item.video) { const videoPath = path.isAbsolute(item.video) ? item.video : path.resolve(inputDir, item.video) if (fs.existsSync(videoPath)) { - const actualDur = await getAudioDurationSec(videoPath) // ffprobe 对音视频通用 + const actualDur = await getAudioDurationSec(videoPath) if (actualDur != null) { item.videoDuration = actualDur; videoMeasured++ } } } @@ -425,7 +227,6 @@ async function assemble(args) { console.log(` [${i + 1}] 画面=${slotDur.toFixed(2)}s 音频=${audioDur.toFixed(2)}s 视频=${videoDur.toFixed(2)}s${stratInfo}${marker}`) } - // -- 读取转场策略(在 addImages/addVideos 之前) -- const transitionConfig = loadTransitions(manifest) console.log(`\nCapCut 成片组装`) @@ -442,10 +243,9 @@ async function assemble(args) { const totalSteps = steps.length let step = 0 - // -- 上传图片到 OSS(优先使用 manifest 中已有的 URL) -- + // -- 上传图片到 OSS -- let imgUrls = {} if (mode === 'images') { - // 先从 manifest 收集已有 URL const needUpload = [] for (const item of items) { if (item.url && item.url.startsWith('http')) { @@ -467,7 +267,7 @@ async function assemble(args) { // -- 创建草稿 -- step++; console.log(`[${step}/${totalSteps}] 创建草稿...`) - const draftRes = await api('create_draft', { width, height }) + const draftRes = await require('./lib/capcut-api').api('create_draft', { width, height }) const draftUrl = draftRes.draft_url const draftId = new URL(draftUrl).searchParams.get('draft_id') console.log(` draft_id: ${draftId}\n`) @@ -478,8 +278,7 @@ async function assemble(args) { if (mode === 'images') { imageSegmentIds = await addImages(draftUrl, items, imgUrls, timeline, width, height, animation, transitionConfig) } else { - // 视频模式:调速 → 上传 OSS → 添加到草稿 - // Step 1: ffmpeg 调速(在上传前,避免传两份) + // 视频模式:调整 → 上传 → 添加 let adjustedCount = 0 for (let i = 0; i < items.length; i++) { const item = items[i] @@ -499,7 +298,6 @@ async function assemble(args) { console.log(` 视频调整: ${adjustedCount}/${items.length} 个`) } - // Step 2: 上传(已调速的)视频到 OSS const missingUrl = items.filter(it => it.video && !it.videoUrl) if (missingUrl.length > 0) { console.log(` 上传 ${missingUrl.length} 个视频到 OSS...`) @@ -508,7 +306,6 @@ async function assemble(args) { try { const url = await uploadToOSS(videoPath) item.videoUrl = url - // 回写 manifest if (manifestFile) { try { const m = JSON.parse(fs.readFileSync(manifestFile, 'utf-8')) @@ -524,7 +321,7 @@ async function assemble(args) { await addVideos(draftUrl, inputDir, items, timeline, width, height, transitionConfig) } - // -- Ken Burns 关键帧动画(消除幻灯片感) -- + // -- Ken Burns -- if (mode === 'images' && imageSegmentIds.length > 0) { step++; console.log(`[${step}/${totalSteps}] 添加 Ken Burns 镜头动画...`) await addKenBurns(draftUrl, imageSegmentIds, items, timeline, manifest) @@ -537,7 +334,6 @@ async function assemble(args) { try { audioUrls = await batchUploadAudio(inputDir, items) console.log(` 成功: ${Object.keys(audioUrls).length} 段音频\n`) - // 回写 OSS URL 到 manifest,避免重复上传 if (Object.keys(audioUrls).length > 0 && manifestFile) { let changed = false for (const item of manifest.items) { @@ -577,7 +373,7 @@ async function assemble(args) { console.log(' 跳过(未指定 --bgm)') } - // -- 读取账号字幕风格 -- + // -- 字幕风格 -- const subtitleStyle = loadSubtitleStyle(manifest) if (Object.keys(subtitleStyle).length > 0) { console.log(` 字幕风格: ${subtitleStyle.font || '默认'} ${subtitleStyle.inAnimation ? subtitleStyle.inAnimation + '→' + subtitleStyle.outAnimation : ''}`) @@ -591,7 +387,7 @@ async function assemble(args) { console.log(' 跳过') } - // -- 添加关键字氛围词 -- + // -- 关键字氛围词 -- step++; console.log(`[${step}/${totalSteps}] 添加关键字氛围词...`) const keywordStyle = loadKeywordStyle(manifest) if (Object.keys(keywordStyle).length > 0 && items.some(i => i.keyword)) { @@ -600,7 +396,7 @@ async function assemble(args) { console.log(' 跳过(无关键字或未配置 keywordStyle)') } - // -- 添加特效 -- + // -- 特效 -- step++; console.log(`[${step}/${totalSteps}] 添加特效...`) if (finalEffects) { try { @@ -612,7 +408,7 @@ async function assemble(args) { console.log(' 跳过(未配置特效)') } - // -- 添加滤镜 -- + // -- 滤镜 -- step++; console.log(`[${step}/${totalSteps}] 添加滤镜...`) if (finalFilter) { try { @@ -626,7 +422,7 @@ async function assemble(args) { // -- 保存草稿 -- step++; console.log(`[${step}/${totalSteps}] 保存草稿...`) - await api('save_draft', { draft_url: draftUrl }) + await require('./lib/capcut-api').api('save_draft', { draft_url: draftUrl }) console.log(' 已保存\n') // -- 同步到本地剪映 -- @@ -637,7 +433,7 @@ async function assemble(args) { // -- 云渲染(可选)-- if (apiKey) { console.log('提交云渲染...') - await api('gen_video', { draft_url: draftUrl, apiKey }) + await require('./lib/capcut-api').api('gen_video', { draft_url: draftUrl, apiKey }) console.log('渲染已提交,使用 gen_video_status 查询进度') } @@ -652,762 +448,7 @@ async function assemble(args) { } // ============================================================================ -// 添加图片(自动上传到 OSS) -// ============================================================================ - -// 按 directorRef 映射不同入场动画(IntroType,一次性不循环,不晕) -const DIRECTOR_ANIMATIONS = { - tarantino: '动感放大', // 强冲击感 - kitano: '轻微放大', // 静谧微推进 - fincher: '渐显', // 克制、精确、不露痕迹 -} - -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, items, timeline) - const info = { - image_url: url, - width, - height, - start: tl.start, - end: tl.end, - duration: tl.duration, - transition: t.name, - transition_duration: t.duration, - } - - // animation 解析:按 directorRef 选择风格,循环动画 → loop_animation - // GroupAnimationType(循环):缩放, 缩放 II, 回弹伸缩, 旋转伸缩, ... - // IntroType(入场):渐显, 放大, 缩小, 向右滑动, 轻微放大, ... - // OutroType(出场):渐隐, 缩小, 放大, 向左滑动, ... - 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)) - if (loopAnims.length > 0) info.loop_animation = loopAnims.join('|') - if (inAnims.length > 0) info.in_animation = inAnims.join('|') - } - - return info - }) - - // 单次全量提交,所有图片在同一轨道 - console.log(` 一次性添加 ${imageInfos.length} 张图片...`) - const res = await api('add_images', { - draft_url: draftUrl, - image_infos: JSON.stringify(imageInfos), - alpha: 1, scale_x: 1, scale_y: 1, - transform_x: 0, transform_y: 0, - }, 300000) - const allSegmentIds = res.segment_ids || [] - - console.log(` 已添加 ${items.length} 张图片`) - return allSegmentIds -} - -// ============================================================================ -// Ken Burns 关键帧动画(慢速缩放/平移,消除幻灯片感) -// ============================================================================ - -// 向后兼容:account.json 未配置 kenBurns 时的默认值 -const KEN_BURNS_FALLBACK = { - default: { startScale: 1.0, scaleRate: 0.8, panXRate: 0, panYRate: 0 }, - byDirector: { - tarantino: { startScale: 1.0, scaleRate: 0.9, panXRate: 0, panYRate: -0.3 }, - kitano: { startScale: 1.03, scaleRate: 0.5, panXRate: 0.4, panYRate: 0 }, - fincher: { startScale: 1.0, scaleRate: 0.4, panXRate: 0, panYRate: 0 }, - }, -} -const KEN_BURNS_MAX_SCALE = 1.20 - -function loadKenBurns(manifest) { - const cfg = loadAccountConfig(manifest).capcut?.kenBurns - if (cfg && cfg.default) return cfg - return KEN_BURNS_FALLBACK -} - -function getKenBurnsProfile(item, kbConfig) { - const director = (item.directorRef || '').toLowerCase() - const byDirector = kbConfig.byDirector || {} - return byDirector[director] || kbConfig.default -} - -async function addKenBurns(draftUrl, segmentIds, items, timeline, manifest) { - if (!segmentIds || segmentIds.length === 0) { - console.log(' 无 segment IDs,跳过 Ken Burns') - return - } - - const kbConfig = loadKenBurns(manifest) - if (kbConfig.enabled === false) { - console.log(' Ken Burns 已禁用(account.json kenBurns.enabled=false)') - return - } - - const keyframes = [] - for (let i = 0; i < segmentIds.length; i++) { - const segId = segmentIds[i] - const item = items[i] - const tl = timeline[i] - if (!segId || !tl) continue - - const profile = getKenBurnsProfile(item, kbConfig) - const durSec = tl.duration / US - const segDur = tl.duration // 微秒 - - // 速率驱动:scaleRate(%/秒)× 时长 = 实际 endScale,clamp 上限 - const startScale = profile.startScale || 1.0 - const scaleRate = profile.scaleRate != null ? profile.scaleRate : 0.8 - const endScale = Math.min(startScale + scaleRate * durSec / 100, KEN_BURNS_MAX_SCALE) - - keyframes.push( - { segment_id: segId, property: 'UNIFORM_SCALE', offset: 0, value: startScale }, - { segment_id: segId, property: 'UNIFORM_SCALE', offset: segDur, value: endScale }, - ) - - // 平移速率驱动:panXRate/panYRate(单位/秒)× 时长 = 实际偏移 - const panXRate = profile.panXRate || 0 - const panYRate = profile.panYRate || 0 - if (panXRate !== 0) { - const panX = panXRate * durSec - keyframes.push( - { segment_id: segId, property: 'KFTypePositionX', offset: 0, value: 0 }, - { segment_id: segId, property: 'KFTypePositionX', offset: segDur, value: panX }, - ) - } - if (panYRate !== 0) { - const panY = panYRate * durSec - keyframes.push( - { segment_id: segId, property: 'KFTypePositionY', offset: 0, value: 0 }, - { segment_id: segId, property: 'KFTypePositionY', offset: segDur, value: panY }, - ) - } - } - - if (keyframes.length === 0) { - console.log(' 无关键帧生成,跳过') - return - } - - const res = await api('add_keyframes', { - draft_url: draftUrl, - keyframes: JSON.stringify(keyframes), - }) - console.log(` 已添加 ${res.keyframes_added || keyframes.length} 个 Ken Burns 关键帧 (${segmentIds.length} 段)`) -} - -// ============================================================================ -// 添加视频(从 manifest 读取时长) -// ============================================================================ - -/** - * ffmpeg 视频调整:根据策略适配音频时长 - * - * 策略(按 ratio = videoDur / audioDur 选择): - * speed_up (ratio > 1.1, ≤2x) → setpts 压缩时间(加速) - * trim (ratio > 2x) → 截断到目标时长 - * slow_down (ratio < 0.9, ≥0.5x) → setpts 拉长时间(慢放) - * freeze (ratio < 0.5x) → 视频原速 + 最后一帧冻结补时长 - * none (0.9~1.1) → 无需调整 - * - * 所有策略失败后兜底:截断到目标时长 - * - * 返回调整后的文件路径(失败则返回原路径) - */ -async function adjustVideoSpeed(videoPath, targetDurationSec, strategy = 'none', speed = 1, freezeExtraUs = 0) { - if (!fs.existsSync(videoPath)) return videoPath - if (strategy === 'none') return videoPath - - // 兜底截断:所有策略失败后的最终回退 - function fallbackTrim(cb) { - execFile('ffmpeg', [ - '-y', '-i', videoPath, - '-t', String(targetDurationSec), - '-c', 'copy', - videoPath.replace(/(\.\w+)$/, '_adj$1') - ], { timeout: 30000 }, (err) => { - if (err) { cb(videoPath); return } - cb(videoPath.replace(/(\.\w+)$/, '_adj$1')) - }) - } - - return new Promise((resolve) => { - execFile('ffprobe', [ - '-v', 'quiet', '-show_entries', 'format=duration', - '-of', 'csv=p=0', videoPath - ], (err, stdout) => { - if (err) { fallbackTrim(resolve); return } - const videoDur = parseFloat(stdout.trim()) - if (!videoDur || videoDur <= 0) { fallbackTrim(resolve); return } - - const outPath = videoPath.replace(/(\.\w+)$/, '_adj$1') - - if (strategy === 'trim') { - execFile('ffmpeg', [ - '-y', '-i', videoPath, - '-t', String(targetDurationSec), - '-c', 'copy', - outPath - ], { timeout: 30000 }, (err) => { - if (err) { console.log(` 截断失败: ${err.message}`); resolve(videoPath); return } - console.log(` 截断: ${videoDur.toFixed(1)}s → ${targetDurationSec.toFixed(1)}s`) - resolve(outPath) - }) - } else if (strategy === 'speed_up') { - const speedVal = speed.toFixed(3) - execFile('ffmpeg', [ - '-y', '-i', videoPath, - '-filter_complex', `setpts=PTS/${speedVal}`, - '-an', - outPath - ], { timeout: 30000 }, (err) => { - if (err) { - console.log(` 加速失败,兜底截断: ${err.message}`) - fallbackTrim(resolve) - return - } - console.log(` 加速: ${videoDur.toFixed(1)}s → ${targetDurationSec.toFixed(1)}s (${speedVal}x)`) - resolve(outPath) - }) - } else if (strategy === 'slow_down') { - const factor = (1 / speed).toFixed(3) - execFile('ffmpeg', [ - '-y', '-i', videoPath, - '-filter_complex', `setpts=PTS*${factor}`, - '-an', - outPath - ], { timeout: 30000 }, (err) => { - if (err) { - console.log(` 放缓失败,兜底截断: ${err.message}`) - fallbackTrim(resolve) - return - } - console.log(` 放缓: ${videoDur.toFixed(1)}s → ${targetDurationSec.toFixed(1)}s (${speed.toFixed(2)}x speed)`) - resolve(outPath) - }) - } else if (strategy === 'freeze') { - // 画面停顿:原速播放 + 最后一帧冻结补时长 - const freezeSec = freezeExtraUs / US - execFile('ffmpeg', [ - '-y', '-i', videoPath, - '-filter_complex', `tpad=stop=-1:stop_duration=${freezeSec.toFixed(3)}`, - '-an', - outPath - ], { timeout: 30000 }, (err) => { - if (err) { - // 回退方案:截取最后一帧 → 生成冻结帧视频 → concat 拼接 - console.log(` tpad freeze 失败,尝试 concat 方案: ${err.message}`) - const lastFrame = videoPath.replace(/(\.\w+)$/, '_lastframe.png') - const frozenVideo = videoPath.replace(/(\.\w+)$/, '_frozen.mp4') - execFile('ffmpeg', [ - '-y', '-sseof', '-0.1', '-i', videoPath, - '-frames:v', '1', lastFrame - ], { timeout: 10000 }, (err2) => { - if (err2) { console.log(` concat 方案也失败,兜底截断`); fallbackTrim(resolve); return } - execFile('ffmpeg', [ - '-y', '-loop', '1', '-i', lastFrame, - '-t', String(freezeSec.toFixed(3)), - '-pix_fmt', 'yuv420p', - '-vf', 'scale=trunc(iw/2)*2:trunc(ih/2)*2', - frozenVideo - ], { timeout: 15000 }, (err3) => { - if (err3) { - try { fs.unlinkSync(lastFrame) } catch (_) {} - console.log(` 冻结帧视频生成失败,兜底截断`) - fallbackTrim(resolve) - return - } - const concatList = path.join(path.dirname(videoPath), '_freeze_concat.txt') - fs.writeFileSync(concatList, `file '${videoPath}'\nfile '${frozenVideo}'\n`) - execFile('ffmpeg', [ - '-y', '-f', 'concat', '-safe', '0', '-i', concatList, - '-c', 'copy', outPath - ], { timeout: 30000 }, (err4) => { - try { fs.unlinkSync(lastFrame); fs.unlinkSync(frozenVideo); fs.unlinkSync(concatList) } catch (_) {} - if (err4) { console.log(` 拼接失败,兜底截断`); fallbackTrim(resolve); return } - console.log(` 画面停顿: ${videoDur.toFixed(1)}s + 冻结 ${freezeSec.toFixed(1)}s = ${targetDurationSec.toFixed(1)}s`) - resolve(outPath) - }) - }) - }) - return - } - console.log(` 画面停顿: ${videoDur.toFixed(1)}s + 冻结 ${freezeSec.toFixed(1)}s = ${targetDurationSec.toFixed(1)}s`) - resolve(outPath) - }) - } else { - resolve(videoPath) - } - }) - }) -} - -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, items, timeline) - return { - video_url: item.videoUrl || (item.video ? path.resolve(inputDir, item.video) : null) || item.url || path.resolve(inputDir, item.file), - width, - height, - start: tl.start, - end: tl.end, - duration: tl.duration, - mask: '', - transition: t.name, - transition_duration: t.duration, - volume: item.volume || 1, - } - }) - - // 先尝试全量提交 - try { - const res = await api('add_videos', { - draft_url: draftUrl, - video_infos: JSON.stringify(videoInfos), - alpha: 1, scale_x: 1, scale_y: 1, - transform_x: 0, transform_y: 0, - scene_timelines: [], - }) - console.log(` 已添加 ${items.length} 个视频片段(全量)`) - return res.segment_ids || [] - } catch (err) { - if (!err.message.includes('504') && !err.message.includes('timeout')) throw err - console.log(` 全量提交超时,降级为分批添加...`) - } - - // 504 回退:分批添加(每批 3 个,保持绝对时间不变) - const BATCH_SIZE = 3 - const allSegmentIds = [] - for (let i = 0; i < videoInfos.length; i += BATCH_SIZE) { - const batch = videoInfos.slice(i, i + BATCH_SIZE) - const batchNum = Math.floor(i / BATCH_SIZE) + 1 - const totalBatches = Math.ceil(videoInfos.length / BATCH_SIZE) - console.log(` 分批 [${batchNum}/${totalBatches}] 添加 ${batch.length} 个片段...`) - const res = await api('add_videos', { - draft_url: draftUrl, - video_infos: JSON.stringify(batch), - alpha: 1, scale_x: 1, scale_y: 1, - transform_x: 0, transform_y: 0, - scene_timelines: [], - }) - if (res.segment_ids) allSegmentIds.push(...res.segment_ids) - } - - console.log(` 已添加 ${items.length} 个视频片段(分批)`) - return allSegmentIds -} - -// ============================================================================ -// 音频批量上传(本地文件 → OSS 公网 URL) -// ============================================================================ - -async function batchUploadAudio(inputDir, items) { - const urls = {} - for (const item of items) { - // 上传所有 segment 音频文件 - if (item.segments && item.segments.length > 0) { - for (const seg of item.segments) { - if (!seg.audio || seg.audio.startsWith('http') || urls[seg.audio]) continue - const filePath = path.isAbsolute(seg.audio) - ? seg.audio - : path.resolve(inputDir, seg.audio) - if (!fs.existsSync(filePath)) { - console.error(` 音频文件不存在: ${filePath}`) - continue - } - try { - urls[seg.audio] = await uploadToOSS(filePath) - console.log(` 上传: ${path.basename(filePath)} -> OK`) - } catch (err) { - console.error(` 上传失败: ${path.basename(filePath)} - ${err.message}`) - } - } - } - // 上传 item.audio(向后兼容,segments[0].audio 通常等于此值) - if (!item.audio || item.audio.startsWith('http')) { - if (item.audio) urls[item.audio] = item.audio - continue - } - if (urls[item.audio]) continue - const filePath = path.isAbsolute(item.audio) - ? item.audio - : path.resolve(inputDir, item.audio) - if (!fs.existsSync(filePath)) { - console.error(` 音频文件不存在: ${filePath}`) - continue - } - try { - urls[item.audio] = await uploadToOSS(filePath) - console.log(` 上传: ${path.basename(filePath)} -> OK`) - } catch (err) { - console.error(` 上传失败: ${path.basename(filePath)} - ${err.message}`) - } - } - return urls -} - -// ============================================================================ -// 添加 TTS 配音(每段音频按时间线排列) -// ============================================================================ - -async function addVoiceover(draftUrl, inputDir, items, timeline, audioUrls = {}) { - // 收集音频 - const audioItems = items.filter(item => item.audio || (item.segments && item.segments.length > 0)) - if (audioItems.length === 0) { - console.log(' 无 TTS 音频文件,跳过') - return - } - - const audioInfos = [] - const resolveAudio = (relPath) => { - if (relPath.startsWith('http')) return relPath - if (audioUrls[relPath]) return audioUrls[relPath] - return path.isAbsolute(relPath) ? relPath : path.resolve(inputDir, relPath) - } - - for (let i = 0; i < items.length; i++) { - const item = items[i] - const tl = timeline[i] - - if (item.segments && item.segments.length > 0) { - // 逐段添加,每段使用实际音频文件时长(不做比例分配,消除留白) - let currentTime = tl.start - for (let si = 0; si < item.segments.length; si++) { - const seg = item.segments[si] - const audioUrl = resolveAudio(seg.audio) - const segDurUs = (seg.duration || 0) * US - if (segDurUs <= 0) continue - // 最后一段对齐 timeline 末尾,吃掉浮点误差 - const isLast = si === item.segments.length - 1 - const endTime = isLast ? tl.end : currentTime + segDurUs - audioInfos.push({ - audio_url: audioUrl, - start: currentTime, - end: endTime, - duration: endTime - currentTime, - volume: 1.0, - }) - currentTime = endTime - } - } else if (item.audio) { - // 无 segments:用实际音频时长 - const audioUrl = resolveAudio(item.audio) - const audioDurUs = item.audioDuration ? item.audioDuration * US : tl.duration - - audioInfos.push({ - audio_url: audioUrl, - start: tl.start, - end: tl.start + audioDurUs, - duration: audioDurUs, - volume: 1.0, - }) - } - } - - if (audioInfos.length === 0) { - console.log(' 无可用音频,跳过配音') - return - } - - await api('add_audios', { - draft_url: draftUrl, - audio_infos: JSON.stringify(audioInfos), - }) - const ossCount = audioInfos.filter(a => a.audio_url.startsWith('http')).length - console.log(` 已添加 ${audioInfos.length} 段 TTS 配音 (${ossCount > 0 ? `${ossCount} 段 OSS + ` : ''}${audioInfos.length - ossCount} 段本地)`) -} - -// ============================================================================ -// 添加背景音乐 -// ============================================================================ - -async function addBGM(draftUrl, bgmUrl, totalDurationUs) { - // 先获取音频实际时长 - let audioDuration = totalDurationUs - try { - const durRes = await api('get_audio_duration', { mp3_url: bgmUrl }) - if (durRes.duration) audioDuration = durRes.duration - } catch (_) { - // 无法获取时长就用视频总时长 - } - - const fadeIn = 500000 // 0.5s 淡入 - const fadeOut = 1000000 // 1s 淡出 - - await api('add_audios', { - draft_url: draftUrl, - audio_infos: JSON.stringify([{ - audio_url: bgmUrl, - duration: audioDuration, - end: Math.min(audioDuration, totalDurationUs), - start: 0, - volume: 0.15, - fade_in_duration: fadeIn, - fade_out_duration: fadeOut, - }]), - }) - console.log(` 已添加 BGM (${(audioDuration / US).toFixed(1)}s, fade 0.5s/1s)`) -} - -// ============================================================================ -// 读取账号配置 -// ============================================================================ - -// 动画属性注入(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 -} - -function loadAccountConfig(manifest) { - const account = manifest.account - if (!account) return {} - try { return loadAccountConfigFromUtils(account) } catch { return {} } -} - -function loadSubtitleStyle(manifest) { - return loadAccountConfig(manifest).capcut?.subtitleStyle || {} -} - -function loadKeywordStyle(manifest) { - return loadAccountConfig(manifest).capcut?.keywordStyle || {} -} - -// ============================================================================ -// 添加关键字氛围词叠加(画面中央大字) -// ============================================================================ - -async function addKeywordOverlays(draftUrl, items, timeline, style = {}) { - const keywordItems = items.filter(item => item.keyword) - if (keywordItems.length === 0) { - console.log(' 无关键字,跳过') - return - } - - const captions = [] - for (let i = 0; i < items.length; i++) { - const item = items[i] - if (!item.keyword) continue - const tl = timeline[i] - - const cap = { - start: tl.start, - end: tl.end, - text: item.keyword, - } - applyAnimationProps(cap, style) - - captions.push(cap) - } - - if (captions.length === 0) return - - await api('add_captions', { - draft_url: draftUrl, - captions: JSON.stringify(captions), - font: style.font || null, - font_size: style.fontSize || 60, - 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', - shadow_diffuse: 15, - shadow_distance: 5, - shadow_angle: -45, - } : undefined, - alpha: style.alpha || 1, - scale_x: 1, scale_y: 1, - transform_x: 0, - transform_y: style.transformY || 0, - text_effect: style.textEffect || null, - }) - console.log(` 已添加 ${captions.length} 个关键字氛围词 (效果: ${style.textEffect || '无'})`) -} - -function loadTransitions(manifest) { - return loadAccountConfig(manifest).capcut?.transitions || null -} - -// ============================================================================ -// 添加字幕(支持关键词高亮 + 账号字幕风格 + 分句切分) -// ============================================================================ - -async function addSubtitles(draftUrl, items, timeline, style = {}, split = false) { - const captions = [] - - // 从账号配置读取动画参数 - 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] - const text = item.script || item.text || item.caption || '' - if (!text) continue - - const tl = timeline[i] - - if (split) { - // 分句模式:优先用 segments 精确时长(与 addVoiceover 同步),回退到字数估算 - if (item.segments && item.segments.length > 0) { - let currentTime = tl.start - for (let si = 0; si < item.segments.length; si++) { - const seg = item.segments[si] - const segDurUs = (seg.duration || 0) * US - if (segDurUs <= 0) continue - const isLast = si === item.segments.length - 1 - const endTime = isLast ? tl.end : currentTime + segDurUs - const cap = { start: currentTime, end: endTime, text: seg.text } - applyAnimationProps(cap, animStyle) - captions.push(cap) - currentTime = endTime - } - } else { - // 回退:字数权重估算 - 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 - } - - duration = Math.max(duration, 500000) - - const cap = { - start: currentTime, - end: currentTime + duration, - text: sentence, - } - - applyAnimationProps(cap, animStyle) - captions.push(cap) - currentTime += duration - }) - } - } else { - // 原始模式:一句字幕 - const cap = { - start: tl.start, - end: tl.end, - text, - } - - applyAnimationProps(cap, animStyle) - captions.push(cap) - } - } - - if (captions.length === 0) { - console.log(' 无字幕内容,跳过') - return - } - - 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, - italic: false, - underline: false, - has_shadow: style.hasShadow || false, - shadow_info: style.shadowAlpha ? { - shadow_alpha: style.shadowAlpha, - shadow_color: style.shadowColor || '#000000', - shadow_diffuse: 15, - shadow_distance: 5, - shadow_angle: -45, - } : undefined, - letter_spacing: style.letterSpacing || 0, - line_spacing: style.lineSpacing || 0, - alpha: style.alpha || 1, - scale_x: 1, scale_y: 1, - transform_x: 0, - transform_y: style.transformY || 0, - style_text: 0, - }) - - console.log(` 已添加 ${captions.length} 条字幕${split ? ' (分句模式)' : ''} (字体: ${style.font || '默认'}, 动画: ${animStyle.inAnimation || '无'} → ${animStyle.outAnimation || '无'})`) -} - -// ============================================================================ -// 添加特效 -// ============================================================================ - -async function addEffects(draftUrl, effectsStr, totalDurationUs) { - const effectNames = effectsStr.split(',').map(s => s.trim()).filter(Boolean) - const effectInfos = effectNames.map(name => ({ - effect_title: name, - start: 0, - end: totalDurationUs, - })) - - await api('add_effects', { - draft_url: draftUrl, - effect_infos: JSON.stringify(effectInfos), - }) - - console.log(` 已添加: ${effectNames.join(', ')}`) -} - -// ============================================================================ -// 添加滤镜 -// ============================================================================ - -async function addFilter(draftUrl, filterStr, totalDurationUs) { - const [name, intensity] = filterStr.split(':') - await api('add_filters', { - draft_url: draftUrl, - filter_infos: JSON.stringify([{ - filter_title: (name || '').trim(), - start: 0, - end: totalDurationUs, - intensity: parseFloat(intensity) || 50, - }]), - }) - console.log(` 已添加: ${(name || '').trim()} 强度 ${intensity || 50}`) -} - -// ============================================================================ -// 同步草稿到本地剪映 -// ============================================================================ - -async function syncToLocalJianying(draftUrl, draftId, totalDurationUs) { - await syncDraft(draftUrl, { name: draftId }) - registerDraft(draftId, draftId, totalDurationUs) -} - -// ============================================================================ -// 主入口 +// CLI 入口 // ============================================================================ async function main() { diff --git a/.claude/skills/video-from-script/scripts/lib/capcut-api.js b/.claude/skills/video-from-script/scripts/lib/capcut-api.js new file mode 100644 index 0000000..01b738e --- /dev/null +++ b/.claude/skills/video-from-script/scripts/lib/capcut-api.js @@ -0,0 +1,94 @@ +/** + * CapCut API 基础设施层 + * + * 提供: 配置加载、API 封装、CLI 解析、工具函数 + * 无业务逻辑,纯基础设施。 + */ + +const axios = require('axios') +const path = require('path') +const fs = require('fs') +const { execFile } = require('child_process') + +const US = 1_000_000 + +let _config = null +function getConfig() { + if (_config) return _config + const configPath = path.join(__dirname, '..', '..', '..', 'config.json') + if (!fs.existsSync(configPath)) { + console.error('缺少配置文件: skills/config.json') + console.error('请运行 node setup.js 生成配置') + process.exit(1) + } + const config = JSON.parse(fs.readFileSync(configPath, 'utf-8')) + if (!config.jianyingDraftPath || !config.capcutMateDir || !config.capcutMateApiBase) { + console.error('config.json 需要填写 jianyingDraftPath、capcutMateDir 和 capcutMateApiBase') + process.exit(1) + } + _config = config + return _config +} + +const BASE_URL = getConfig().capcutMateApiBase + +async function api(endpoint, data = {}, timeout = 60000) { + const url = `${BASE_URL}/${endpoint}` + const method = endpoint === 'get_draft' ? 'get' : 'post' + try { + const res = method === 'get' + ? await axios.get(url, { params: data, timeout }) + : await axios.post(url, data, { timeout }) + if (res.data.code !== undefined && res.data.code !== 0) { + throw new Error(`API [${endpoint}] 返回错误: ${res.data.message}`) + } + return res.data + } catch (err) { + if (err.response) { + throw new Error(`API [${endpoint}] HTTP ${err.response.status}: ${JSON.stringify(err.response.data)}`) + } + throw err + } +} + +function parseArgs(argv) { + const args = {} + for (let i = 0; i < argv.length; i++) { + if (argv[i].startsWith('--')) { + const key = argv[i].slice(2) + const value = argv[i + 1] + if (value && !value.startsWith('--')) { + args[key] = value + i++ + } else { + args[key] = true + } + } + } + return args +} + +function getResolution(format) { + const map = { + '9:16': { width: 1080, height: 1920 }, + '16:9': { width: 1920, height: 1080 }, + '1:1': { width: 1080, height: 1080 }, + '4:3': { width: 1440, height: 1080 }, + } + return map[format] || map['9:16'] +} + +function getAudioDurationSec(filePath) { + return new Promise((resolve) => { + execFile('ffprobe', [ + '-v', 'quiet', '-show_entries', 'format=duration', + '-of', 'csv=p=0', filePath + ], (err, stdout) => { + if (err) { resolve(null); return } + const dur = parseFloat(stdout.trim()) + resolve(dur > 0 ? dur : null) + }) + }) +} + +module.exports = { US, getConfig, BASE_URL, api, parseArgs, getResolution, getAudioDurationSec } diff --git a/.claude/skills/video-from-script/scripts/lib/capcut-timeline.js b/.claude/skills/video-from-script/scripts/lib/capcut-timeline.js new file mode 100644 index 0000000..4ecaf5b --- /dev/null +++ b/.claude/skills/video-from-script/scripts/lib/capcut-timeline.js @@ -0,0 +1,229 @@ +/** + * 时间线构建 + 视频调整策略 + * + * 核心算法模块。纯函数 + ffmpeg,自包含可测试。 + * + * 规则: + * 图片模式: TTS 音频时长 = 画面时长,无音频 = 跳过 + * 视频模式: TTS 为主轴,视频通过策略适配 + * 视频比音频长 → 加速(≤2x) / 裁剪(>2x) + * 视频比音频短 → 放缓(≥0.5x) / 画面停顿(<0.5x) + * 所有策略失败 → 兜底截断 + */ + +const fs = require('fs') +const path = require('path') +const { execFile } = require('child_process') +const { US } = require('./capcut-api') + +// ============================================================================ +// 时间线构建 +// ============================================================================ + +function buildTimeline(items) { + let offset = 0 + return items.map(item => { + let audioDur + if (item.segments && item.segments.length > 0) { + audioDur = item.segments.reduce((sum, s) => sum + (s.duration || 0), 0) * US + } else { + audioDur = (item.audioDuration != null) ? item.audioDuration * US : 0 + } + const videoDur = (item.videoDuration != null) ? item.videoDuration * US : 0 + const hasVideo = !!(item.video || item.videoUrl || item.url) + + // 无 TTS 音频 + if (audioDur <= 0) { + if (hasVideo && videoDur > 0) { + const entry = { start: offset, end: offset + videoDur, duration: videoDur, speed: 1, strategy: 'none' } + offset += videoDur + return entry + } + const entry = { start: offset, end: offset, duration: 0, speed: 1, strategy: 'none', skip: true } + return entry + } + + const dur = audioDur + + if (!hasVideo || videoDur <= 0) { + const entry = { start: offset, end: offset + dur, duration: dur, speed: 1, strategy: 'none' } + offset += dur + return entry + } + + // 视频模式:策略选择 + const ratio = videoDur / audioDur + + if (ratio > 1.1) { + if (ratio <= 2) { + const entry = { start: offset, end: offset + dur, duration: dur, speed: ratio, strategy: 'speed_up' } + offset += dur + return entry + } else { + const entry = { start: offset, end: offset + dur, duration: dur, speed: 1, strategy: 'trim' } + offset += dur + return entry + } + } else if (ratio < 0.9) { + if (ratio >= 0.5) { + const entry = { start: offset, end: offset + dur, duration: dur, speed: ratio, strategy: 'slow_down' } + offset += dur + return entry + } else { + const entry = { + start: offset, end: offset + dur, duration: dur, speed: 1, + strategy: 'freeze', freezeExtra: dur - videoDur, + } + offset += dur + return entry + } + } else { + const entry = { start: offset, end: offset + dur, duration: dur, speed: 1, strategy: 'none' } + offset += dur + return entry + } + }) +} + +// ============================================================================ +// 视频调整(ffmpeg 策略) +// ============================================================================ + +/** + * ffmpeg 视频调整:根据策略适配音频时长 + * + * 策略(按 ratio = videoDur / audioDur 选择): + * speed_up (ratio > 1.1, ≤2x) → setpts 压缩时间(加速) + * trim (ratio > 2x) → 截断到目标时长 + * slow_down (ratio < 0.9, ≥0.5x) → setpts 拉长时间(慢放) + * freeze (ratio < 0.5x) → 视频原速 + 最后一帧冻结补时长 + * none (0.9~1.1) → 无需调整 + * + * 所有策略失败后兜底:截断到目标时长 + */ +async function adjustVideoSpeed(videoPath, targetDurationSec, strategy = 'none', speed = 1, freezeExtraUs = 0) { + if (!fs.existsSync(videoPath)) return videoPath + if (strategy === 'none') return videoPath + + function fallbackTrim(cb) { + execFile('ffmpeg', [ + '-y', '-i', videoPath, + '-t', String(targetDurationSec), + '-c', 'copy', + videoPath.replace(/(\.\w+)$/, '_adj$1') + ], { timeout: 30000 }, (err) => { + if (err) { cb(videoPath); return } + cb(videoPath.replace(/(\.\w+)$/, '_adj$1')) + }) + } + + return new Promise((resolve) => { + execFile('ffprobe', [ + '-v', 'quiet', '-show_entries', 'format=duration', + '-of', 'csv=p=0', videoPath + ], (err, stdout) => { + if (err) { fallbackTrim(resolve); return } + const videoDur = parseFloat(stdout.trim()) + if (!videoDur || videoDur <= 0) { fallbackTrim(resolve); return } + + const outPath = videoPath.replace(/(\.\w+)$/, '_adj$1') + + if (strategy === 'trim') { + execFile('ffmpeg', [ + '-y', '-i', videoPath, + '-t', String(targetDurationSec), + '-c', 'copy', + outPath + ], { timeout: 30000 }, (err) => { + if (err) { console.log(` 截断失败: ${err.message}`); resolve(videoPath); return } + console.log(` 截断: ${videoDur.toFixed(1)}s → ${targetDurationSec.toFixed(1)}s`) + resolve(outPath) + }) + } else if (strategy === 'speed_up') { + const speedVal = speed.toFixed(3) + execFile('ffmpeg', [ + '-y', '-i', videoPath, + '-filter_complex', `setpts=PTS/${speedVal}`, + '-an', + outPath + ], { timeout: 30000 }, (err) => { + if (err) { + console.log(` 加速失败,兜底截断: ${err.message}`) + fallbackTrim(resolve) + return + } + console.log(` 加速: ${videoDur.toFixed(1)}s → ${targetDurationSec.toFixed(1)}s (${speedVal}x)`) + resolve(outPath) + }) + } else if (strategy === 'slow_down') { + const factor = (1 / speed).toFixed(3) + execFile('ffmpeg', [ + '-y', '-i', videoPath, + '-filter_complex', `setpts=PTS*${factor}`, + '-an', + outPath + ], { timeout: 30000 }, (err) => { + if (err) { + console.log(` 放缓失败,兜底截断: ${err.message}`) + fallbackTrim(resolve) + return + } + console.log(` 放缓: ${videoDur.toFixed(1)}s → ${targetDurationSec.toFixed(1)}s (${speed.toFixed(2)}x speed)`) + resolve(outPath) + }) + } else if (strategy === 'freeze') { + const freezeSec = freezeExtraUs / US + execFile('ffmpeg', [ + '-y', '-i', videoPath, + '-filter_complex', `tpad=stop=-1:stop_duration=${freezeSec.toFixed(3)}`, + '-an', + outPath + ], { timeout: 30000 }, (err) => { + if (err) { + console.log(` tpad freeze 失败,尝试 concat 方案: ${err.message}`) + const lastFrame = videoPath.replace(/(\.\w+)$/, '_lastframe.png') + const frozenVideo = videoPath.replace(/(\.\w+)$/, '_frozen.mp4') + execFile('ffmpeg', [ + '-y', '-sseof', '-0.1', '-i', videoPath, + '-frames:v', '1', lastFrame + ], { timeout: 10000 }, (err2) => { + if (err2) { console.log(` concat 方案也失败,兜底截断`); fallbackTrim(resolve); return } + execFile('ffmpeg', [ + '-y', '-loop', '1', '-i', lastFrame, + '-t', String(freezeSec.toFixed(3)), + '-pix_fmt', 'yuv420p', + '-vf', 'scale=trunc(iw/2)*2:trunc(ih/2)*2', + frozenVideo + ], { timeout: 15000 }, (err3) => { + if (err3) { + try { fs.unlinkSync(lastFrame) } catch (_) {} + console.log(` 冻结帧视频生成失败,兜底截断`) + fallbackTrim(resolve) + return + } + const concatList = path.join(path.dirname(videoPath), '_freeze_concat.txt') + fs.writeFileSync(concatList, `file '${videoPath}'\nfile '${frozenVideo}'\n`) + execFile('ffmpeg', [ + '-y', '-f', 'concat', '-safe', '0', '-i', concatList, + '-c', 'copy', outPath + ], { timeout: 30000 }, (err4) => { + try { fs.unlinkSync(lastFrame); fs.unlinkSync(frozenVideo); fs.unlinkSync(concatList) } catch (_) {} + if (err4) { console.log(` 拼接失败,兜底截断`); fallbackTrim(resolve); return } + console.log(` 画面停顿: ${videoDur.toFixed(1)}s + 冻结 ${freezeSec.toFixed(1)}s = ${targetDurationSec.toFixed(1)}s`) + resolve(outPath) + }) + }) + }) + return + } + console.log(` 画面停顿: ${videoDur.toFixed(1)}s + 冻结 ${freezeSec.toFixed(1)}s = ${targetDurationSec.toFixed(1)}s`) + resolve(outPath) + }) + } else { + resolve(videoPath) + } + }) + }) +} + +module.exports = { buildTimeline, adjustVideoSpeed } diff --git a/.claude/skills/video-from-script/scripts/lib/capcut-tracks.js b/.claude/skills/video-from-script/scripts/lib/capcut-tracks.js new file mode 100644 index 0000000..67ac0f6 --- /dev/null +++ b/.claude/skills/video-from-script/scripts/lib/capcut-tracks.js @@ -0,0 +1,621 @@ +/** + * CapCut 轨道操作 + * + * 所有 add* 函数 + 转场策略 + 账号配置读取。 + * Agent 修改字幕风格、Ken Burns、转场、特效等只需关注此文件。 + */ + +const path = require('path') +const { api, US } = require('./capcut-api') +const { splitTextIntoSentences, loadAccountConfig: loadAccountConfigFromUtils } = require('./pipeline-utils') + +// ============================================================================ +// 账号配置读取 +// ============================================================================ + +function loadAccountConfig(manifest) { + const account = manifest.account + if (!account) return {} + try { return loadAccountConfigFromUtils(account) } catch { return {} } +} + +function loadSubtitleStyle(manifest) { + return loadAccountConfig(manifest).capcut?.subtitleStyle || {} +} + +function loadKeywordStyle(manifest) { + return loadAccountConfig(manifest).capcut?.keywordStyle || {} +} + +function loadTransitions(manifest) { + return loadAccountConfig(manifest).capcut?.transitions || null +} + +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 +} + +// ============================================================================ +// 转场策略 +// ============================================================================ + +function getTransition(item, index, totalCount, transitionConfig, allItems, timeline) { + 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 } + if (index >= totalCount - 1) return { name: '', duration: 0 } + + switch (strategy) { + case 'director': { + const ref = (item.directorRef || '').toLowerCase() + const byDirector = transitionConfig.byDirector || {} + return byDirector[ref] || defaultT + } + + case 'rhythm': { + const rules = transitionConfig.byPosition || {} + if (index >= totalCount - 2) return rules.closing || defaultT + 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 + } + } + if (timeline && timeline.length > 0) { + const elapsed = timeline[index].start + let lastTransStart = 0 + for (let pi = index - 1; pi >= 1; pi--) { + const p = allItems[pi] + const pp = pi > 0 ? allItems[pi - 1] : null + if (pi >= totalCount - 2) { lastTransStart = timeline[pi].start; break } + if (pp && p.directorRef && pp.directorRef && p.directorRef !== pp.directorRef) { + lastTransStart = timeline[pi].start; break + } + if (p.keyword && pp && !pp.keyword) { lastTransStart = timeline[pi].start; break } + } + if (elapsed - lastTransStart >= 8000000) return rules.body || defaultT + } else { + if (index % 3 === 0) return rules.body || defaultT + } + return { name: '', duration: 0 } + } + + case 'fixed': + default: + return defaultT + } +} + +// ============================================================================ +// 入场动画映射 +// ============================================================================ + +const DIRECTOR_ANIMATIONS = { + tarantino: '动感放大', + kitano: '轻微放大', + fincher: '渐显', +} + +function getAnimationForDirector(directorRef, defaultAnimation) { + if (!directorRef) return defaultAnimation + return DIRECTOR_ANIMATIONS[directorRef.toLowerCase()] || defaultAnimation +} + +// ============================================================================ +// Ken Burns 关键帧动画 +// ============================================================================ + +const KEN_BURNS_FALLBACK = { + default: { startScale: 1.0, scaleRate: 0.8, panXRate: 0, panYRate: 0 }, + byDirector: { + tarantino: { startScale: 1.0, scaleRate: 0.9, panXRate: 0, panYRate: -0.3 }, + kitano: { startScale: 1.03, scaleRate: 0.5, panXRate: 0.4, panYRate: 0 }, + fincher: { startScale: 1.0, scaleRate: 0.4, panXRate: 0, panYRate: 0 }, + }, +} +const KEN_BURNS_MAX_SCALE = 1.20 + +function loadKenBurns(manifest) { + const cfg = loadAccountConfig(manifest).capcut?.kenBurns + if (cfg && cfg.default) return cfg + return KEN_BURNS_FALLBACK +} + +function getKenBurnsProfile(item, kbConfig) { + const director = (item.directorRef || '').toLowerCase() + const byDirector = kbConfig.byDirector || {} + return byDirector[director] || kbConfig.default +} + +async function addKenBurns(draftUrl, segmentIds, items, timeline, manifest) { + if (!segmentIds || segmentIds.length === 0) { + console.log(' 无 segment IDs,跳过 Ken Burns') + return + } + + const kbConfig = loadKenBurns(manifest) + if (kbConfig.enabled === false) { + console.log(' Ken Burns 已禁用(account.json kenBurns.enabled=false)') + return + } + + const keyframes = [] + for (let i = 0; i < segmentIds.length; i++) { + const segId = segmentIds[i] + const item = items[i] + const tl = timeline[i] + if (!segId || !tl) continue + + const profile = getKenBurnsProfile(item, kbConfig) + const durSec = tl.duration / US + const segDur = tl.duration + + const startScale = profile.startScale || 1.0 + const scaleRate = profile.scaleRate != null ? profile.scaleRate : 0.8 + const endScale = Math.min(startScale + scaleRate * durSec / 100, KEN_BURNS_MAX_SCALE) + + keyframes.push( + { segment_id: segId, property: 'UNIFORM_SCALE', offset: 0, value: startScale }, + { segment_id: segId, property: 'UNIFORM_SCALE', offset: segDur, value: endScale }, + ) + + const panXRate = profile.panXRate || 0 + const panYRate = profile.panYRate || 0 + if (panXRate !== 0) { + const panX = panXRate * durSec + keyframes.push( + { segment_id: segId, property: 'KFTypePositionX', offset: 0, value: 0 }, + { segment_id: segId, property: 'KFTypePositionX', offset: segDur, value: panX }, + ) + } + if (panYRate !== 0) { + const panY = panYRate * durSec + keyframes.push( + { segment_id: segId, property: 'KFTypePositionY', offset: 0, value: 0 }, + { segment_id: segId, property: 'KFTypePositionY', offset: segDur, value: panY }, + ) + } + } + + if (keyframes.length === 0) { + console.log(' 无关键帧生成,跳过') + return + } + + const res = await api('add_keyframes', { + draft_url: draftUrl, + keyframes: JSON.stringify(keyframes), + }) + console.log(` 已添加 ${res.keyframes_added || keyframes.length} 个 Ken Burns 关键帧 (${segmentIds.length} 段)`) +} + +// ============================================================================ +// 添加图片 +// ============================================================================ + +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, items, timeline) + const info = { + image_url: url, + width, + height, + start: tl.start, + end: tl.end, + duration: tl.duration, + transition: t.name, + transition_duration: t.duration, + } + + 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)) + if (loopAnims.length > 0) info.loop_animation = loopAnims.join('|') + if (inAnims.length > 0) info.in_animation = inAnims.join('|') + } + + return info + }) + + console.log(` 一次性添加 ${imageInfos.length} 张图片...`) + const res = await api('add_images', { + draft_url: draftUrl, + image_infos: JSON.stringify(imageInfos), + alpha: 1, scale_x: 1, scale_y: 1, + transform_x: 0, transform_y: 0, + }, 300000) + const allSegmentIds = res.segment_ids || [] + + console.log(` 已添加 ${items.length} 张图片`) + return allSegmentIds +} + +// ============================================================================ +// 添加视频 +// ============================================================================ + +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, items, timeline) + return { + video_url: item.videoUrl || (item.video ? path.resolve(inputDir, item.video) : null) || item.url || path.resolve(inputDir, item.file), + width, + height, + start: tl.start, + end: tl.end, + duration: tl.duration, + mask: '', + transition: t.name, + transition_duration: t.duration, + volume: item.volume || 1, + } + }) + + try { + const res = await api('add_videos', { + draft_url: draftUrl, + video_infos: JSON.stringify(videoInfos), + alpha: 1, scale_x: 1, scale_y: 1, + transform_x: 0, transform_y: 0, + scene_timelines: [], + }) + console.log(` 已添加 ${items.length} 个视频片段(全量)`) + return res.segment_ids || [] + } catch (err) { + if (!err.message.includes('504') && !err.message.includes('timeout')) throw err + console.log(` 全量提交超时,降级为分批添加...`) + } + + const BATCH_SIZE = 3 + const allSegmentIds = [] + for (let i = 0; i < videoInfos.length; i += BATCH_SIZE) { + const batch = videoInfos.slice(i, i + BATCH_SIZE) + const batchNum = Math.floor(i / BATCH_SIZE) + 1 + const totalBatches = Math.ceil(videoInfos.length / BATCH_SIZE) + console.log(` 分批 [${batchNum}/${totalBatches}] 添加 ${batch.length} 个片段...`) + const res = await api('add_videos', { + draft_url: draftUrl, + video_infos: JSON.stringify(batch), + alpha: 1, scale_x: 1, scale_y: 1, + transform_x: 0, transform_y: 0, + scene_timelines: [], + }) + if (res.segment_ids) allSegmentIds.push(...res.segment_ids) + } + + console.log(` 已添加 ${items.length} 个视频片段(分批)`) + return allSegmentIds +} + +// ============================================================================ +// 添加 TTS 配音 +// ============================================================================ + +async function addVoiceover(draftUrl, inputDir, items, timeline, audioUrls = {}) { + const audioItems = items.filter(item => item.audio || (item.segments && item.segments.length > 0)) + if (audioItems.length === 0) { + console.log(' 无 TTS 音频文件,跳过') + return + } + + const audioInfos = [] + const resolveAudio = (relPath) => { + if (relPath.startsWith('http')) return relPath + if (audioUrls[relPath]) return audioUrls[relPath] + return path.isAbsolute(relPath) ? relPath : path.resolve(inputDir, relPath) + } + + for (let i = 0; i < items.length; i++) { + const item = items[i] + const tl = timeline[i] + + if (item.segments && item.segments.length > 0) { + let currentTime = tl.start + for (let si = 0; si < item.segments.length; si++) { + const seg = item.segments[si] + const audioUrl = resolveAudio(seg.audio) + const segDurUs = (seg.duration || 0) * US + if (segDurUs <= 0) continue + const isLast = si === item.segments.length - 1 + const endTime = isLast ? tl.end : currentTime + segDurUs + audioInfos.push({ + audio_url: audioUrl, + start: currentTime, + end: endTime, + duration: endTime - currentTime, + volume: 1.0, + }) + currentTime = endTime + } + } else if (item.audio) { + const audioUrl = resolveAudio(item.audio) + const audioDurUs = item.audioDuration ? item.audioDuration * US : tl.duration + + audioInfos.push({ + audio_url: audioUrl, + start: tl.start, + end: tl.start + audioDurUs, + duration: audioDurUs, + volume: 1.0, + }) + } + } + + if (audioInfos.length === 0) { + console.log(' 无可用音频,跳过配音') + return + } + + await api('add_audios', { + draft_url: draftUrl, + audio_infos: JSON.stringify(audioInfos), + }) + const ossCount = audioInfos.filter(a => a.audio_url.startsWith('http')).length + console.log(` 已添加 ${audioInfos.length} 段 TTS 配音 (${ossCount > 0 ? `${ossCount} 段 OSS + ` : ''}${audioInfos.length - ossCount} 段本地)`) +} + +// ============================================================================ +// 添加 BGM +// ============================================================================ + +async function addBGM(draftUrl, bgmUrl, totalDurationUs) { + let audioDuration = totalDurationUs + try { + const durRes = await api('get_audio_duration', { mp3_url: bgmUrl }) + if (durRes.duration) audioDuration = durRes.duration + } catch (_) {} + + const fadeIn = 500000 + const fadeOut = 1000000 + + await api('add_audios', { + draft_url: draftUrl, + audio_infos: JSON.stringify([{ + audio_url: bgmUrl, + duration: audioDuration, + end: Math.min(audioDuration, totalDurationUs), + start: 0, + volume: 0.15, + fade_in_duration: fadeIn, + fade_out_duration: fadeOut, + }]), + }) + console.log(` 已添加 BGM (${(audioDuration / US).toFixed(1)}s, fade 0.5s/1s)`) +} + +// ============================================================================ +// 添加字幕 +// ============================================================================ + +async function addSubtitles(draftUrl, items, timeline, style = {}, split = false) { + const captions = [] + + 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] + const text = item.script || item.text || item.caption || '' + if (!text) continue + + const tl = timeline[i] + + if (split) { + if (item.segments && item.segments.length > 0) { + let currentTime = tl.start + for (let si = 0; si < item.segments.length; si++) { + const seg = item.segments[si] + const segDurUs = (seg.duration || 0) * US + if (segDurUs <= 0) continue + const isLast = si === item.segments.length - 1 + const endTime = isLast ? tl.end : currentTime + segDurUs + const cap = { start: currentTime, end: endTime, text: seg.text } + applyAnimationProps(cap, animStyle) + captions.push(cap) + currentTime = endTime + } + } else { + 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 + } + + duration = Math.max(duration, 500000) + + const cap = { + start: currentTime, + end: currentTime + duration, + text: sentence, + } + + applyAnimationProps(cap, animStyle) + captions.push(cap) + currentTime += duration + }) + } + } else { + const cap = { + start: tl.start, + end: tl.end, + text, + } + + applyAnimationProps(cap, animStyle) + captions.push(cap) + } + } + + if (captions.length === 0) { + console.log(' 无字幕内容,跳过') + return + } + + 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, + italic: false, + underline: false, + has_shadow: style.hasShadow || false, + shadow_info: style.shadowAlpha ? { + shadow_alpha: style.shadowAlpha, + shadow_color: style.shadowColor || '#000000', + shadow_diffuse: 15, + shadow_distance: 5, + shadow_angle: -45, + } : undefined, + letter_spacing: style.letterSpacing || 0, + line_spacing: style.lineSpacing || 0, + alpha: style.alpha || 1, + scale_x: 1, scale_y: 1, + transform_x: 0, + transform_y: style.transformY || 0, + style_text: 0, + }) + + console.log(` 已添加 ${captions.length} 条字幕${split ? ' (分句模式)' : ''} (字体: ${style.font || '默认'}, 动画: ${animStyle.inAnimation || '无'} → ${animStyle.outAnimation || '无'})`) +} + +// ============================================================================ +// 添加关键字氛围词 +// ============================================================================ + +async function addKeywordOverlays(draftUrl, items, timeline, style = {}) { + const keywordItems = items.filter(item => item.keyword) + if (keywordItems.length === 0) { + console.log(' 无关键字,跳过') + return + } + + const captions = [] + for (let i = 0; i < items.length; i++) { + const item = items[i] + if (!item.keyword) continue + const tl = timeline[i] + + const cap = { + start: tl.start, + end: tl.end, + text: item.keyword, + } + applyAnimationProps(cap, style) + + captions.push(cap) + } + + if (captions.length === 0) return + + await api('add_captions', { + draft_url: draftUrl, + captions: JSON.stringify(captions), + font: style.font || null, + font_size: style.fontSize || 60, + 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', + shadow_diffuse: 15, + shadow_distance: 5, + shadow_angle: -45, + } : undefined, + alpha: style.alpha || 1, + scale_x: 1, scale_y: 1, + transform_x: 0, + transform_y: style.transformY || 0, + text_effect: style.textEffect || null, + }) + console.log(` 已添加 ${captions.length} 个关键字氛围词 (效果: ${style.textEffect || '无'})`) +} + +// ============================================================================ +// 添加特效 +// ============================================================================ + +async function addEffects(draftUrl, effectsStr, totalDurationUs) { + const effectNames = effectsStr.split(',').map(s => s.trim()).filter(Boolean) + const effectInfos = effectNames.map(name => ({ + effect_title: name, + start: 0, + end: totalDurationUs, + })) + + await api('add_effects', { + draft_url: draftUrl, + effect_infos: JSON.stringify(effectInfos), + }) + + console.log(` 已添加: ${effectNames.join(', ')}`) +} + +// ============================================================================ +// 添加滤镜 +// ============================================================================ + +async function addFilter(draftUrl, filterStr, totalDurationUs) { + const [name, intensity] = filterStr.split(':') + await api('add_filters', { + draft_url: draftUrl, + filter_infos: JSON.stringify([{ + filter_title: (name || '').trim(), + start: 0, + end: totalDurationUs, + intensity: parseFloat(intensity) || 50, + }]), + }) + console.log(` 已添加: ${(name || '').trim()} 强度 ${intensity || 50}`) +} + +module.exports = { + loadAccountConfig, + loadSubtitleStyle, + loadKeywordStyle, + loadKenBurns, + loadTransitions, + getTransition, + addImages, + addVideos, + addKenBurns, + addVoiceover, + addBGM, + addSubtitles, + addKeywordOverlays, + addEffects, + addFilter, +}