/** * CapCut 轨道操作 * * 所有 add* 函数 + 转场策略 + 账号配置读取。 * Agent 修改字幕风格、Ken Burns、转场、特效等只需关注此文件。 * * 音频策略(固化铁律): * - 音频由 TTS 1.15x 生成,导入 CapCut 时无 speed 字段(不可调速) * - 每个 item 的 segments[] 逐段添加,各段 start 按 startOffset 精确对齐 */ const path = require('path') const fs = require('fs') const { api, US, getConfig } = require('./capcut-api') const { splitTextIntoSentences, loadAccountConfig: loadAccountConfigFromUtils, getManifestDir } = 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 } // ============================================================================ // 将 segment 写入视频轨道时间线(slot) // 背景:add_videos 只负责把视频加入素材库,不自动上时间线。 // 此函数在 add_videos 成功后调用,将每个 segment_id 写入第一个 video track。 // ============================================================================ async function addSlots(draftUrl, items, timeline) { // 获取当前云端草稿的 draft_content,获取第一个 video track 的 id let draftData try { draftData = (await api('get_draft', { draft_url: draftUrl })).data || {} } catch (err) { console.log(' get_draft 不可用,切换本地写入模式') return addSlotsLocally(draftUrl, items, timeline) } const tracks = draftData.tracks || [] const videoTrack = tracks.find(t => t.type === 'video') if (!videoTrack) { console.log(' 未找到 video track,跳过 slot 写入') return } // 构造 slot 数据(复用 buildSlot) const slots = [] for (let i = 0; i < items.length; i++) { const segId = items[i].segmentId || items[i]._segmentId if (!segId) continue slots.push(buildSlot(segId, videoTrack.id, i, timeline[i])) } if (slots.length === 0) { console.log(' 无有效 slot 数据,跳过') return } // 通过 add_slots API 写入 try { await api('add_slots', { draft_url: draftUrl, slots: JSON.stringify(slots), }) console.log(` 已写入 ${slots.length} 个 slot 到视频轨道`) } catch (err) { console.log(` add_slots API 不可用: ${err.message},降级为本地写入`) await addSlotsLocally(draftUrl, items, timeline, videoTrack.id) } } // 直接写入本地 draft_content.json 的 slot // options.draftId: 可选,直接指定 draftId(优先使用),否则从 draftUrl 提取 async function addSlotsLocally(draftUrl, items, timeline, trackId, options = {}) { // 优先使用 options.draftId,否则从 draftUrl 提取 let draftId = options.draftId || null if (!draftId) { try { draftId = new URL(draftUrl).searchParams.get('draft_id') } catch { console.log(' 无法解析 draftUrl,跳过本地 slot 写入') return } } if (!draftId) { console.log(' 无法提取 draft_id,跳过本地 slot 写入') return } const jianyingPath = getConfig().jianyingDraftPath const draftPath = path.join(jianyingPath, draftId, 'draft_content.json') if (!fs.existsSync(draftPath)) { console.log(` 本地草稿不存在: ${draftPath},跳过 slot 写入`) return } let draft try { draft = JSON.parse(fs.readFileSync(draftPath, 'utf-8')) } catch { console.log(' draft_content.json 读取失败,跳过') return } // 找到第一个 video track const videoTrack = trackId ? draft.tracks.find(t => t.id === trackId) : draft.tracks.find(t => t.type === 'video') if (!videoTrack) { console.log(' 未找到 video track,跳过') return } const slots = [] for (let i = 0; i < items.length; i++) { const item = items[i] const tl = timeline[i] const segId = item.segmentId || item._segmentId if (!segId) { // 尝试从 materials.videos 匹配 const fname = item.video ? path.basename(item.video) : '' const matVideo = (draft.materials.videos || []).find(v => { const matFname = path.basename(v.path || '') return fname && matFname.includes(fname.replace('videos/', '')) }) if (matVideo) { items[i]._segmentId = matVideo.id slots.push(buildSlot(matVideo.id, videoTrack.id, i, tl)) } } else { slots.push(buildSlot(segId, videoTrack.id, i, tl)) } } if (slots.length > 0) { videoTrack.slots = slots draft.duration = timeline.length > 0 ? timeline[timeline.length - 1].end : 0 fs.writeFileSync(draftPath, JSON.stringify(draft, null, 2), 'utf-8') console.log(` 已本地写入 ${slots.length} 个 slot 到视频轨道`) // 触发剪映扫描 triggerDirScan(path.dirname(draftPath)) } } function buildSlot(segId, trackId, index, tl) { return { id: generateUUID(), material_id: segId, track_id: trackId, render_index: index, type: 'video', common_property: { start_time: tl.start, source_timerange: { start: 0, duration: tl.duration }, target_timerange: { start: tl.start, duration: tl.duration }, is_avatar: false, audio_fade: { fade_in_duration: 0, fade_out_duration: 0 }, volume: 1.0, }, } } function generateUUID() { return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => { const r = Math.random() * 16 | 0 return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16).toUpperCase() }) } const { execFile } = require('child_process') function triggerDirScan(dir) { const tmp = dir + '.slot_tmp' if (process.platform === 'darwin') { execFile('rsync', ['-a', dir + '/', tmp], (err) => { try { fs.rmSync(tmp, { recursive: true, force: true }) } catch {} }) } } // ============================================================================ // 添加 TTS 配音 // ============================================================================ async function addVoiceover(draftUrl, inputDir, items, timeline, audioUrls = {}) { // 优先使用 segments[] 逐段添加(精确对齐) // 无 segments 时降级为旧的整段方式 const segmentsFlat = [] for (let i = 0; i < items.length; i++) { const item = items[i] const tl = timeline[i] if (!item.audio) continue if (item.segments && item.segments.length > 0) { // 使用 segments 精确添加 for (const seg of item.segments) { if (!seg.audio || seg.error) continue const audioUrl = seg.audio.startsWith('http') ? seg.audio : (audioUrls[seg.audio] || path.resolve(inputDir, seg.audio)) const segDurUs = Math.round(seg.duration * US) const segStartUs = tl.start + Math.round(seg.startOffset * US) segmentsFlat.push({ audio_url: audioUrl, start: segStartUs, end: segStartUs + segDurUs, duration: segDurUs, volume: 1.0, }) } } else { // 降级:整段添加 const audioUrl = item.audio.startsWith('http') ? item.audio : (audioUrls[item.audio] || path.resolve(inputDir, item.audio)) const audioDurUs = item.audioDuration ? item.audioDuration * US : tl.duration segmentsFlat.push({ audio_url: audioUrl, start: tl.start, end: tl.start + audioDurUs, duration: audioDurUs, volume: 1.0, }) } } if (segmentsFlat.length === 0) { console.log(' 无 TTS 音频文件,跳过') return } // 批量添加音频(同一轨道),失败时逐个兜底 let addedCount = 0 try { await api('add_audios', { draft_url: draftUrl, audio_infos: JSON.stringify(segmentsFlat), }) addedCount = segmentsFlat.length } catch (err) { console.log(` 批量添加音频失败 (${err.message.slice(0, 60)}),逐个添加...`) for (const audioInfo of segmentsFlat) { try { await api('add_audios', { draft_url: draftUrl, audio_infos: JSON.stringify([audioInfo]), }) addedCount++ } catch (e2) { console.error(` 音频添加失败: ${e2.message.slice(0, 80)}`) } } } const ossCount = segmentsFlat.filter(a => a.audio_url.startsWith('http')).length console.log(` 已添加 ${addedCount}/${segmentsFlat.length} 段 TTS 配音 (${ossCount} 段 OSS)`) } // ============================================================================ // 添加 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 && item.segments && item.segments.length > 0) { // 精确字幕模式:使用 segments 实测时长,逐段添加字幕 for (const seg of item.segments) { if (seg.error || !seg.text) continue const segStartUs = tl.start + Math.round(seg.startOffset * US) const segDurUs = Math.round(seg.duration * US) const cap = { start: segStartUs, end: segStartUs + segDurUs, text: seg.text, } applyAnimationProps(cap, animStyle) captions.push(cap) } } else if (split) { // 降级:按字符比例分配(无 segments 时) 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 } const commonStyle = { 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, } // 批量添加字幕(同一轨道),失败时逐条兜底 let addedCount = 0 try { await api('add_captions', { draft_url: draftUrl, captions: JSON.stringify(captions), ...commonStyle, }) addedCount = captions.length } catch (err) { console.log(` 批量添加字幕失败 (${err.message.slice(0, 60)}),逐条添加...`) for (const cap of captions) { try { await api('add_captions', { draft_url: draftUrl, captions: JSON.stringify([cap]), ...commonStyle, }) addedCount++ } catch (e2) { console.error(` 字幕添加失败: ${e2.message.slice(0, 80)}`) } } } console.log(` 已添加 ${addedCount}/${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, addSlots, addSlotsLocally, addEffects, addFilter, }