Files
video-create/.claude/skills/video-from-script/scripts/lib/capcut-timeline.js
lc 6eec0e8889 feat(skills): 完善视频生产 pipeline 及新增健身跟练账号
- SKILL.md: 新增工作流阶段定义、质量卡点、分镜规则
- manifest-schema.md: 补充完整字段规范及类型定义
- phase-tts.js: 优化 TTS 合成长逻辑,添加进度追踪
- capcut-tracks.js: 扩展轨道构建能力,支持更多元素类型
- capcut-timeline.js: 改进时间线生成,支持淡入淡出
- capcut_assemble.js: 新增 assemble 阶段完整实现
- cmd-init.js: 完善 init 命令逻辑
- qwen-tts.js: 调整超时配置
- accounts/禁忌帝王学: 更新拆分/图像/台词提示词
- accounts/健身跟练: 新增账号含 account.json 及全套提示词模板
- 新增 workflow-issues-20260501.md 参考文档

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 22:53:37 +08:00

183 lines
6.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 时间线构建 + 视频调整策略
*
* 核心算法模块。纯函数 + 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 }