This commit is contained in:
2026-04-29 22:47:59 +08:00
parent 803da39b85
commit 357b8e7bcf

View File

@@ -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 都有 videoUrlCapCut 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) {
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) {
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)
}
if (require.main === module) {
main().catch(err => {
console.error(`\n错误: ${err.message}`)
process.exit(1)
})
}
module.exports = { assemble }