/** * 时间线构建 + 视频调整策略 * * 核心算法模块。纯函数 + ffmpeg,自包含可测试。 * * 铁律(固化,不可绕过): * 音频:生成后不可调速(TTS=1.15x,CapCut无speed字段) * 视频:始终配合音频时长(只允许加速/截断,不允许慢放/冻结) * * 时间线规则: * 图片模式: TTS 音频时长 = 画面时长,无音频 = 跳过 * 视频模式: TTS 为主轴,视频通过策略适配 * 视频比音频长 → 加速(≤2x) / 裁剪(>2x) * 视频比音频短 → 禁止!应在分镜阶段拆分 shot,不允许慢放/冻结补齐 */ const fs = require('fs') const path = require('path') const { execFile } = require('child_process') const { US } = require('./capcut-api') // ============================================================================ // 时间线构建 // ============================================================================ /** * 构建时间线条目 * * @param {Array} items - manifest items * @returns {Array} timeline entries * * 策略选择(固化,按 ratio = videoDur / audioDur): * ≥ 1.1, ≤ 2 → speed_up (视频加速追上音频,最优) * > 2 → trim (视频截断至音频时长) * 0.9 ~ 1.1 → none (接近匹配,无需调整) * < 0.9 → 禁止!音频时长超过视频,分镜阶段未正确拆分 shot * * 铁律:不允许 slow_down / freeze,不允许音频调速 */ function buildTimeline(items) { let offset = 0 return items.map(item => { const audioDur = Math.round((item.audioDuration != null) ? item.audioDuration * US : 0) const videoDur = Math.round((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) { // 0.9 ~ 1.1:无需调整 const entry = { start: offset, end: offset + dur, duration: dur, speed: 1, strategy: 'none' } offset += dur return entry } else { // ratio < 0.9:音频时长超过视频! // 铁律禁止:不允许慢放/冻结/拼接补齐。此情况应在分镜阶段拆分 shot。 // 强制截断并打印错误标记,由主 Agent 上报给用户/打回分镜重做。 const entry = { start: offset, end: offset + dur, duration: dur, speed: 1, strategy: 'FORBIDDEN_audio_gt_video', ratio: parseFloat(ratio.toFixed(3)), videoDur: parseFloat((videoDur / US).toFixed(2)), audioDur: parseFloat((audioDur / US).toFixed(2)), error: '音频时长(' + (audioDur / US).toFixed(2) + 's) > 视频时长(' + (videoDur / US).toFixed(2) + 's),分镜阶段 shot 未正确拆分,请打回重新切割', } offset += dur return entry } }) } // ============================================================================ // 视频调整(ffmpeg 策略) // ============================================================================ /** * ffmpeg 视频调整:根据策略适配音频时长 * * 允许策略(按 ratio = videoDur / audioDur 选择): * speed_up (ratio > 1.1, ≤2x) → setpts 压缩时间(加速),最优 * trim (ratio > 2x) → 截断到目标时长,次选 * none (0.9~1.1) → 无需调整 * * 禁止策略(已删除): * slow_down (ratio < 0.9) → ❌ 音频不可调速! * freeze (ratio < 0.5) → ❌ 不允许冻结帧补齐! * * 所有策略失败后兜底:截断到目标时长 */ async function adjustVideoSpeed(videoPath, targetDurationSec, strategy = 'none', speed = 1) { 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 { // 未知策略,兜底截断 fallbackTrim(resolve) } }) }) } module.exports = { buildTimeline, adjustVideoSpeed }