diff --git a/.claude/skills/video-from-script/scripts/capcut_assemble.js b/.claude/skills/video-from-script/scripts/capcut_assemble.js index 1f82d69..6974efb 100644 --- a/.claude/skills/video-from-script/scripts/capcut_assemble.js +++ b/.claude/skills/video-from-script/scripts/capcut_assemble.js @@ -16,6 +16,7 @@ const axios = require('axios') const path = require('path') const fs = require('fs') +const { execFile } = require('child_process') const { syncDraft, registerDraft, triggerDirectoryScan } = require('./sync-to-jianying') // ============================================================================ @@ -128,17 +129,25 @@ async function batchUploadToOSS(inputDir, files) { // ============================================================================ function buildTimeline(items, defaultDurationUs) { - // 音频为主轴,视频适配音频(短视频行业标准) - // 有视频时长时取 max(不截断音频),无视频时用音频时长 + // 音频为主轴,视频调速适配(≤2x 加速,>2x 截断) let offset = 0 return items.map(item => { const audioDur = (item.duration != null) ? item.duration * US : 0 const videoDur = (item.videoDuration != null) ? item.videoDuration * US : 0 - // 有视频:保证音频不被截断;无视频(图片模式):用音频时长 - const dur = videoDur > 0 - ? Math.max(audioDur, videoDur) - : (audioDur || defaultDurationUs) - const entry = { start: offset, end: offset + dur, duration: dur } + // 无 TTS:用视频时长或固定时长 + if (audioDur <= 0) { + const dur = videoDur || defaultDurationUs + const entry = { start: offset, end: offset + dur, duration: dur, speed: 1 } + offset += dur + return entry + } + // 有 TTS:音频时长为主轴 + const dur = audioDur + const ratio = videoDur > 0 ? videoDur / audioDur : 1 + // ≤2x: 加速到音频时长;>2x: 截断(视频只取前 audioDur 部分) + const speed = ratio <= 2 ? ratio : 1 + const needAdjust = videoDur > audioDur + 100000 // 视频比音频长 0.1s 以上才需要调整 + const entry = { start: offset, end: offset + dur, duration: dur, speed, needAdjust } offset += dur return entry }) @@ -236,7 +245,28 @@ async function assemble(args) { if (mode === 'images') { await addImages(draftUrl, items, imgUrls, timeline, width, height, animation) } else { - // 视频模式:确保所有 item 都有 videoUrl(CapCut API 需要公网 URL) + // 视频模式:调速 → 上传 OSS → 添加到草稿 + // Step 1: ffmpeg 调速(在上传前,避免传两份) + let adjustedCount = 0 + for (let i = 0; i < items.length; i++) { + const item = items[i] + const tl = timeline[i] + if (tl.needAdjust && item.video) { + const videoPath = path.resolve(inputDir, item.video) + const audioDur = tl.duration / US + const adjustedPath = await adjustVideoSpeed(videoPath, audioDur) + if (adjustedPath !== videoPath) { + item.video = path.relative(inputDir, adjustedPath) + item.videoDuration = audioDur + adjustedCount++ + } + } + } + if (adjustedCount > 0) { + console.log(` 视频调速: ${adjustedCount}/${items.length} 个`) + } + + // Step 2: 上传(已调速的)视频到 OSS const missingUrl = items.filter(it => it.video && !it.videoUrl) if (missingUrl.length > 0) { const { uploadFile } = require('./oss-upload') @@ -295,7 +325,11 @@ async function assemble(args) { // -- 添加特效 -- step++; console.log(`[${step}/${totalSteps}] 添加特效...`) if (effectsStr) { - await addEffects(draftUrl, effectsStr, totalDurationUs) + try { + await addEffects(draftUrl, effectsStr, totalDurationUs) + } catch (e) { + console.log(` 特效跳过: ${e.message}`) + } } else { console.log(' 跳过(未指定 --effects)') } @@ -303,7 +337,11 @@ async function assemble(args) { // -- 添加滤镜 -- step++; console.log(`[${step}/${totalSteps}] 添加滤镜...`) if (filterStr) { - await addFilter(draftUrl, filterStr, totalDurationUs) + try { + await addFilter(draftUrl, filterStr, totalDurationUs) + } catch (e) { + console.log(` 滤镜跳过: ${e.message}`) + } } else { console.log(' 跳过(未指定 --filter)') } @@ -376,6 +414,60 @@ async function addImages(draftUrl, items, imgUrls, timeline, width, height, anim // 添加视频(从 manifest 读取时长) // ============================================================================ +/** + * ffmpeg 调速:将视频调整为指定时长 + * ratio <= 2x: 加速;ratio > 2x: 截断 + * 返回调整后的文件路径(调整失败则返回原路径) + */ +async function adjustVideoSpeed(videoPath, targetDurationSec) { + if (!fs.existsSync(videoPath)) return videoPath + + return new Promise((resolve) => { + // 先获取视频时长 + execFile('ffprobe', [ + '-v', 'quiet', '-show_entries', 'format=duration', + '-of', 'csv=p=0', videoPath + ], (err, stdout) => { + if (err) { resolve(videoPath); return } + const videoDur = parseFloat(stdout.trim()) + if (!videoDur || videoDur <= 0 || videoDur <= targetDurationSec + 0.1) { + resolve(videoPath); return + } + + const ratio = videoDur / targetDurationSec + const outPath = videoPath.replace(/(\.\w+)$/, '_adj$1') + + if (ratio <= 2) { + // 加速:setpts=PTS/speed, atempo=speed (音频变速) + const speed = ratio.toFixed(3) + const atempo = Math.min(speed, 2.0) // atempo 单次上限 2.0 + execFile('ffmpeg', [ + '-y', '-i', videoPath, + '-filter_complex', `setpts=PTS/${speed}${videoPath.endsWith('.mp4') ? `,atempo=${atempo}` : ''}`, + '-an', // 去掉音频(视频模式不需要原始音轨) + outPath + ], { timeout: 30000 }, (err) => { + if (err) { console.log(` 调速失败,使用原始视频: ${err.message}`); resolve(videoPath); return } + console.log(` 调速: ${videoDur.toFixed(1)}s → ${targetDurationSec.toFixed(1)}s (${speed}x)`) + resolve(outPath) + }) + } else { + // 截断:取前 targetDuration 秒 + 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) + }) + } + }) + }) +} + async function addVideos(draftUrl, inputDir, items, timeline, width, height) { const videoInfos = items.map((item, i) => { const tl = timeline[i] @@ -722,9 +814,11 @@ async function main() { await assemble(args) } -main().catch(err => { - console.error(`\n错误: ${err.message}`) - process.exit(1) -}) +if (require.main === module) { + main().catch(err => { + console.error(`\n错误: ${err.message}`) + process.exit(1) + }) +} module.exports = { assemble }