/** * 时间线构建 + 视频调整策略 * * 核心算法模块。纯函数 + 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 }