Files
video-create/.claude/skills/video-from-script/scripts/lib/capcut-timeline.js

183 lines
6.7 KiB
JavaScript
Raw Normal View History

/**
* 时间线构建 + 视频调整策略
*
* 核心算法模块纯函数 + ffmpeg自包含可测试
*
* 铁律固化不可绕过
* 音频生成后不可调速TTS=1.15xCapCut无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 }