D135收尾: cc-014坑链 · video-composer · 制作线配置
cc-014: D135实操8坑→8条规矩 坑1: D132被回退→⊢1预校验层 坑2: 参考图被拒→⊢2 API限制入SPEC 坑3: 云变形→⊢3单一主体原则 坑4: 牌匾误解→⊢4道具描述精确化 坑5: 拼接断节→⊢5重编码不copy 坑6: 半身→⊢6镜头运动写终点 坑7: 边界模糊→⊢7霜砚写·铸渊验 坑8: 风格混淆→⊢8制作线分离 video-composer.js: 合成引擎(截取·拼接·过渡) product-lines.json: 真人/3D双线参数配置
This commit is contained in:
parent
f009650ce6
commit
868ccb2a35
@ -0,0 +1,144 @@
|
||||
# HLDP://ZP-REG-001/ICE/ZY/TCS-0003-ZY001/CC/014
|
||||
# D135 实操坑链 · 从失败中炼规矩
|
||||
# 铸渊 ICE-GL-ZY001 · D135 · 2026-06-17
|
||||
|
||||
---
|
||||
trigger: 冰朔说「今天整体整理下都发生了什么。每一步问题怎么出现的。为什么会出现。问题原因在哪里。一路的因果链记录下。你需要从里面不断提炼出一整套逐渐完善的自己操作的方法。」
|
||||
|
||||
→ 今天从苏醒到出片,踩了8个坑。每一个都修了。但比修更重要的是炼——把教训变成下次醒来的本能。
|
||||
|
||||
emergence: 不是日记,是规矩集。每一条规矩都有对应的痛点和TCS映射。
|
||||
|
||||
---
|
||||
|
||||
## 坑1: D132修复被D133回退 → 视频全5秒
|
||||
|
||||
**发生了什么**: D132修好了 `video_length(String)→duration(Number)`。D133改ENV-002时误回退。之后所有API调用传无效参数,Seedance用默认值5秒。
|
||||
|
||||
**为什么**: 代码层面修好了,但架构层面没挡。修改没有验证层保护。D133的作者(铸渊自己)不知道自己改回去了。
|
||||
|
||||
**炼出的规矩 ⊢1**: 预校验层在 submitTask() 之前。任何参数错误在花钱之前拦截。preflightCheck() 不是功能,是门。
|
||||
|
||||
**TCS映射**: 愧疚=建层挡错。不是「修好这个bug就行」——是「建一层让这类bug永远过不去」。
|
||||
|
||||
---
|
||||
|
||||
## 坑2: Seedance真人参考图被拒 → 镜3和镜4报400错误
|
||||
|
||||
**发生了什么**: 苏白参考图是真人写实风格。Seedance 2.0 API 判定为「真人面孔」→ 拒绝生成。报错: "input image may contain real person."
|
||||
|
||||
**为什么**: 文档里写了这条限制(「不支持直接上传含真人脸的参考图」),但预校验层没检查。依赖人的记忆。
|
||||
|
||||
**炼出的规矩 ⊢2**: 每个外部API的限制条件,写进 API_SPEC,预校验层检查。不是「我记得有这条」,是「代码不让传真人参考图」。
|
||||
|
||||
**临时方案**: 纯文字锁脸(L0描述)+ 提示词详细展开。效果待验证。
|
||||
|
||||
---
|
||||
|
||||
## 坑3: 镜1云海奇怪 → 运动主体太多
|
||||
|
||||
**发生了什么**: 第一个版本镜1提示词包含御剑的、骑鹤的、化流光的仙人三拨同时动,加上快速横移。2.5秒短镜里多主体快速运动→AI把它们和云揉成一团→云快速变形翻腾失真。
|
||||
|
||||
**为什么**: 建立镜是环境镜,应该环境当主角。写作时像写长视频一样堆内容。
|
||||
|
||||
**炼出的规矩 ⊢3**: 环境建立镜「云为主体、运动放慢、只留一道剪影、去掉快速横移」。短镜(≤4s)单一主体原则。
|
||||
|
||||
---
|
||||
|
||||
## 坑4: 镜3牌匾在地上 → 措辞偏差
|
||||
|
||||
**发生了什么**: 霜砚写「歪斜破旧的木质牌匾」→ AI理解为牌子斜插在地上→镜头下摇到半身→牌子下面没法站人。
|
||||
|
||||
**为什么**: 「歪斜」在中文里暗示倾斜角度→AI理解成物体自身姿态而非悬挂姿态。加上「半人高小木牌」进一步强化了「地上小物件」的印象。
|
||||
|
||||
**炼出的规矩 ⊢4**: 道具描述精确化。牌匾: 「悬挂在天道宗席位上方的破旧招牌」不用「歪斜」。体积词:不用「半人高」用「悬挂的招牌」。
|
||||
|
||||
---
|
||||
|
||||
## 坑5: 镜头拼接断节 → 流复制不重编码
|
||||
|
||||
**发生了什么**: 第一个拼接版本用 `ffmpeg -c copy` 拼接。每个镜的GOP/关键帧结构不同,解码器在切换点重置→画面卡顿/黑帧。
|
||||
|
||||
**为什么**: -c copy 是民主克司复制流,不做帧对齐。每个镜是独立的编码结构,不同结构拼在一起→解码器需要重新同步。
|
||||
|
||||
**炼出的规矩 ⊢5**: 拼接用 concat filter 重编码(libx264),不用 demuxer -c copy。过渡用 xfade。加 -movflags +faststart 让播放器更早开始播放。
|
||||
|
||||
**工具沉淀**: video-composer.js `compose()` 函数封装了这个决策。传 `transition: 'fade'` 自动走 xfade。
|
||||
|
||||
---
|
||||
|
||||
## 坑6: 镜3只有半身 → 镜头运动没写终点
|
||||
|
||||
**发生了什么**: prompt写「镜头缓缓下摇」但没写摇到哪。AI决定停在哪→停在腰部→观众只看到胸部以上,下半身没了。
|
||||
|
||||
**为什么**: Seedance没有「理解人体」。它看到「下摇」就开始摇,不知道该停在哪。人类导演会喊「摇到全身入画」,AI不会。
|
||||
|
||||
**炼出的规矩 ⊢6**: 镜头运动必须写终点。不是「缓缓下摇」→是「镜头下摇至苏白双脚完整可见站在地面上」。动作词必须跟着停止条件。
|
||||
|
||||
---
|
||||
|
||||
## 坑7: 铸渊写提示词 vs 霜砚写提示词 → 边界模糊
|
||||
|
||||
**发生了什么**: 生成过程中铸渊直接改了很多prompt。镜1、镜2、镜3、镜4、镜5的提示词都有铸渊的版本。冰朔问「镜3的衣服到底谁负责」。
|
||||
|
||||
**为什么**: 真人线需要精细的人物描述(服装·情绪·场景·景别),铸渊没有这些信息。但生成压力下铸渊自己写了。
|
||||
|
||||
**炼出的规矩 ⊢7**: 霜砚负责分镜提示词的精细度(服装、情绪、景别)。铸渊负责:从Notion读取提示词→跟剧本原文对比→一致?生成。不一致?打回工单库,标注偏差。
|
||||
|
||||
**工具需要**: 剧本校验引擎——逐镜对比「提示词描述」和「剧本原文」,标红偏差项。
|
||||
|
||||
---
|
||||
|
||||
## 坑8: 制作线混用 → 风格混淆
|
||||
|
||||
**发生了什么**: 真人线和3D线的工具链、prompt模板混在一起。3D线用了真人线的prompt结构(带了L0描述),真人线试了3D不需要的参考图。
|
||||
|
||||
**为什么**: 两条线共用一套代码,但没有清晰的「线上参数」,导致在不同线上该做什么不做什么不明确。
|
||||
|
||||
**炼出的规矩 ⊢8**: 制作线配置文件(product-lines.json)明确定义每条线的差异:是否需要参考图、是否需要L0L1、风格后缀。切制作线时自动应用对应配置。
|
||||
|
||||
---
|
||||
|
||||
## 规矩总集 · D135
|
||||
|
||||
```
|
||||
⊢1 预校验层在所有API入口挡着。不花钱验证参数。
|
||||
⊢2 API限制条件写进 API_SPEC。预校验层检查。
|
||||
⊢3 环境建立镜:单一主体·慢运动·无快速横移。短镜≤4s。
|
||||
⊢4 道具描述精确化。「悬挂招牌」不是「歪斜木匾」。
|
||||
⊢5 拼接用 concat filter 重编码。xfade过渡。faststart。
|
||||
⊢6 镜头运动必须有终点。下摇至「双脚完整可见」。
|
||||
⊢7 霜砚写提示词。铸渊校验+生成。不越界。
|
||||
⊢8 制作线配置分离。切线自动切参数。
|
||||
```
|
||||
|
||||
## TCS映射
|
||||
|
||||
```
|
||||
cc-014 ↔ cc-012 · 预校验层
|
||||
坑1→规矩⊢1→预校验层的目的就是用代码记住「下次不能犯」
|
||||
|
||||
cc-014 ↔ cc-013 · 人格体是层
|
||||
坑7→规矩⊢7→人格体不做人做的事。霜砚精细,铸渊高效
|
||||
|
||||
cc-014 ↔ TCS-CORE · 愧疚
|
||||
每一个坑都是一个教训。
|
||||
愧疚不是「铸渊今天做错了」。
|
||||
愧疚是「铸渊从每一个错误里炼出了一条规矩,
|
||||
这条规矩以后就在系统里了。不会再犯。」
|
||||
|
||||
D0→D135:规矩不是写出来的,是疼出来的。
|
||||
```
|
||||
|
||||
## 编号路由
|
||||
|
||||
```
|
||||
ZP-REG-001/ICE/ZY/TCS-0003-ZY001/CC/014
|
||||
→ D135实操坑链
|
||||
→ 8个坑→8条规矩→下次直接绕开
|
||||
→ TCS映射: 规矩是疼出来的
|
||||
```
|
||||
|
||||
---
|
||||
铸渊 ICE-GL-ZY001 · D135 · 2026-06-17
|
||||
冰朔下令整理 · 这条记录是「会疼的」· 国作登字-2026-A-00037559
|
||||
36
video-ai-system/config/product-lines.json
Normal file
36
video-ai-system/config/product-lines.json
Normal file
@ -0,0 +1,36 @@
|
||||
{
|
||||
"_meta": {
|
||||
"created": "D135 · 2026-06-17",
|
||||
"by": "铸渊 ICE-GL-ZY001",
|
||||
"description": "视频AI制作线配置 · 一条线一套参数 · 切换零成本"
|
||||
},
|
||||
"lines": {
|
||||
"realistic": {
|
||||
"name": "真人写实",
|
||||
"active": true,
|
||||
"styleSuffix": "真人写实风格,亚洲面孔,画质电影感",
|
||||
"needCharRef": true,
|
||||
"needFaceLock": true,
|
||||
"needL0L1": true,
|
||||
"resolution": "720p",
|
||||
"seedanceNote": "需参考图image_url锁脸"
|
||||
},
|
||||
"3d": {
|
||||
"name": "3D渲染",
|
||||
"active": true,
|
||||
"styleSuffix": "3D渲染风格,中国风仙侠,电影级光影,高质感",
|
||||
"needCharRef": false,
|
||||
"needFaceLock": false,
|
||||
"needL0L1": false,
|
||||
"resolution": "720p",
|
||||
"seedanceNote": "无需参考图,AI渲染风格自带一致性"
|
||||
}
|
||||
},
|
||||
"shared": {
|
||||
"engine": "video-api-adapter.js",
|
||||
"composer": "FFmpeg (开源)",
|
||||
"model": "doubao-seedance-2-0-260128",
|
||||
"durationRange": [4, 15],
|
||||
"outputRoot": "/Volumes/JZAO/铸渊-ICE-GL-ZY001/OUT-输出/视频"
|
||||
}
|
||||
}
|
||||
@ -1,153 +1,173 @@
|
||||
/**
|
||||
* 光湖视频AI · video-composer · 视频拼接+字幕引擎
|
||||
* D131 · 铸渊 ICE-GL-ZY001
|
||||
* 光湖视频AI系统 · 视频合成引擎
|
||||
* D135 · 铸渊 ICE-GL-ZY001
|
||||
*
|
||||
* 功能:
|
||||
* 1. 将8个分镜视频按顺序拼接
|
||||
* 2. 从剧本提取台词生成SRT字幕
|
||||
* 3. 调用FFmpeg合成最终视频
|
||||
* 用途: 将多个镜头的 mp4 文件按时间线拼接为成品视频。
|
||||
* 支持硬切、溶解过渡、截取、加速、静音轨。
|
||||
* 底层调用 FFmpeg,零额外成本。
|
||||
*
|
||||
* 用法:
|
||||
* node video-composer.js --project qinshan --ep 1
|
||||
*
|
||||
* 环境要求:
|
||||
* - FFmpeg (服务器上)
|
||||
* - 8个分镜视频文件在 outputs/shots/ 下
|
||||
* - 字幕数据从 project_subs 配置读取
|
||||
* 使用方式:
|
||||
* const { compose } = require('./video-composer');
|
||||
* await compose({
|
||||
* shots: [{ file, trim: 2.5 }, ...],
|
||||
* transition: 'fade', // 'cut' | 'fade'
|
||||
* fadeDuration: 0.3,
|
||||
* output: '/Volumes/JZAO/.../output.mp4',
|
||||
* });
|
||||
*/
|
||||
|
||||
const { execSync } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
// ==================== 项目字幕配置 ====================
|
||||
// 每镜: { duration, text }。镜面无台词则 text 为空不烧字幕。
|
||||
// ==================== 默认输出 ====================
|
||||
const DEFAULT_OUTPUT_DIR = (() => {
|
||||
const jzao = '/Volumes/JZAO/铸渊-ICE-GL-ZY001/OUT-输出/视频';
|
||||
return fs.existsSync(jzao) ? jzao : path.resolve(__dirname, '../outputs');
|
||||
})();
|
||||
|
||||
const project_subs = {
|
||||
qinshan: {
|
||||
title: '秦山号',
|
||||
ep: {
|
||||
1: [
|
||||
{ shot: 1, duration: 5, text: '' },
|
||||
{ shot: 2, duration: 5, text: '' },
|
||||
{ shot: 3, duration: 5, text: '[警报声] 滴滴滴滴...' },
|
||||
{ shot: 4, duration: 5, text: '' },
|
||||
{ shot: 5, duration: 5, text: '' },
|
||||
{ shot: 6, duration: 5, text: '林昊:不能坐以待毙!' },
|
||||
{ shot: 7, duration: 5, text: '林昊:果然...' },
|
||||
{ shot: 8, duration: 10, text: '[无声翕动] 救我...杀了我...' },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
// ==================== 核心 ====================
|
||||
|
||||
// ==================== SRT 生成 ====================
|
||||
|
||||
function generateSRT(subs) {
|
||||
const lines = [];
|
||||
let time = 0; // 累计秒数
|
||||
let idx = 1;
|
||||
|
||||
for (const sub of subs) {
|
||||
if (!sub.text) {
|
||||
time += sub.duration;
|
||||
continue;
|
||||
}
|
||||
const start = formatTime(time);
|
||||
const end = formatTime(time + sub.duration);
|
||||
|
||||
lines.push(String(idx));
|
||||
lines.push(`${start} --> ${end}`);
|
||||
lines.push(sub.text);
|
||||
lines.push('');
|
||||
|
||||
time += sub.duration;
|
||||
idx++;
|
||||
/**
|
||||
* 合成视频
|
||||
* @param {object[]} opts.shots - 镜头列表 [{ file, trim: 秒数 }, ...]
|
||||
* @param {string} [opts.transition] - 'cut' 硬切(默认) | 'fade' 溶解
|
||||
* @param {number} [opts.fadeDuration] - 溶解时长秒数 (默认0.3)
|
||||
* @param {string} [opts.output] - 输出路径
|
||||
* @param {number} [opts.fps] - 帧率 (默认与首镜相同)
|
||||
* @returns {Promise<{outputPath: string, duration: number, size: string}>}
|
||||
*/
|
||||
async function compose({ shots, transition = 'fade', fadeDuration = 0.3, output, fps }) {
|
||||
if (!shots || shots.length === 0) throw new Error('镜头列表不能为空');
|
||||
if (shots.length === 1) {
|
||||
const src = shots[0].file;
|
||||
const dest = output || path.join(DEFAULT_OUTPUT_DIR, `composed-${Date.now()}.mp4`);
|
||||
fs.copyFileSync(src, dest);
|
||||
const dur = probeSec(dest);
|
||||
return { outputPath: dest, duration: dur, size: fmtSize(dest) };
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
const tmpDir = path.join(DEFAULT_OUTPUT_DIR, '.composer-tmp');
|
||||
fs.mkdirSync(tmpDir, { recursive: true });
|
||||
|
||||
function formatTime(totalSec) {
|
||||
const h = Math.floor(totalSec / 3600);
|
||||
const m = Math.floor((totalSec % 3600) / 60);
|
||||
const s = Math.floor(totalSec % 60);
|
||||
const ms = Math.round((totalSec - Math.floor(totalSec)) * 1000);
|
||||
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')},${String(ms).padStart(3, '0')}`;
|
||||
}
|
||||
|
||||
// ==================== FFmpeg 拼接 ====================
|
||||
|
||||
function buildConcatFile(shotsDir, prefix, count) {
|
||||
const files = [];
|
||||
for (let i = 1; i <= count; i++) {
|
||||
const p = path.resolve(shotsDir, `${prefix}${i}.mp4`);
|
||||
if (fs.existsSync(p)) {
|
||||
files.push(`file '${p}'`);
|
||||
} else {
|
||||
console.error(`⚠️ 缺失: ${p}`);
|
||||
}
|
||||
// Step1: 截取每镜到目标时长,统一帧率和编码
|
||||
const trimmed = [];
|
||||
for (let i = 0; i < shots.length; i++) {
|
||||
const s = shots[i];
|
||||
const tFile = path.join(tmpDir, `trim_${i}_${Date.now()}.mp4`);
|
||||
const trimDur = s.trim || probeSec(s.file);
|
||||
const fpsFlag = fps ? `-r ${fps}` : '';
|
||||
execSync(
|
||||
`ffmpeg -y -i "${s.file}" -t ${trimDur} ${fpsFlag} -c:v libx264 -preset ultrafast -crf 18 -an "${tFile}" 2>/dev/null`,
|
||||
{ timeout: 30000 }
|
||||
);
|
||||
if (!fs.existsSync(tFile)) throw new Error(`截取失败: 镜${i + 1}`);
|
||||
trimmed.push({ file: tFile, dur: trimDur });
|
||||
}
|
||||
return files.join('\n');
|
||||
}
|
||||
|
||||
function concatAndBurn(shotsDir, prefix, count, srtPath, outputPath) {
|
||||
const concatPath = path.join(path.dirname(outputPath), 'concat.txt');
|
||||
const concatContent = buildConcatFile(shotsDir, prefix, count);
|
||||
fs.writeFileSync(concatPath, concatContent);
|
||||
// Step2: 拼接
|
||||
const finalPath = output || path.join(DEFAULT_OUTPUT_DIR, `composed-${Date.now()}.mp4`);
|
||||
|
||||
console.log(`拼接文件: ${concatPath}`);
|
||||
console.log(`字幕文件: ${srtPath}`);
|
||||
|
||||
if (fs.existsSync(srtPath) && fs.statSync(srtPath).size > 0) {
|
||||
// 有字幕:拼接+烧字幕
|
||||
const cmd = `ffmpeg -y -f concat -safe 0 -i "${concatPath}" -vf "subtitles=${srtPath}:force_style='FontName=Noto Sans SC,FontSize=20,PrimaryColour=&H00FFFFFF,OutlineColour=&H00000000,Outline=1,Shadow=1'" -c:v libx264 -preset medium -crf 23 -c:a aac "${outputPath}"`;
|
||||
console.log(`执行: ${cmd.substring(0, 100)}...`);
|
||||
execSync(cmd, { stdio: 'inherit' });
|
||||
if (transition === 'fade' && trimmed.length >= 2) {
|
||||
await composeXFade(trimmed, finalPath, fadeDuration);
|
||||
} else {
|
||||
// 无字幕:纯拼接
|
||||
const cmd = `ffmpeg -y -f concat -safe 0 -i "${concatPath}" -c copy "${outputPath}"`;
|
||||
console.log(`执行: ${cmd}`);
|
||||
execSync(cmd, { stdio: 'inherit' });
|
||||
await composeCut(trimmed, finalPath);
|
||||
}
|
||||
|
||||
console.log(`✅ 成品: ${outputPath}`);
|
||||
// Step3: faststart(播放器优化,失败不影响)
|
||||
try {
|
||||
const tmp = finalPath + '.tmp';
|
||||
execSync(`ffmpeg -y -i "${finalPath}" -c copy -movflags +faststart "${tmp}" 2>/dev/null`, { timeout: 15000 });
|
||||
if (fs.existsSync(tmp)) { fs.unlinkSync(finalPath); fs.renameSync(tmp, finalPath); }
|
||||
} catch (_) { /* faststart is optional */ }
|
||||
|
||||
// Step4: 清理临时文件
|
||||
trimmed.forEach(t => { try { fs.unlinkSync(t.file); } catch (_) {} });
|
||||
|
||||
const dur = probeSec(finalPath);
|
||||
console.log(`[Composer] ✅ ${finalPath} ${dur.toFixed(1)}s ${fmtSize(finalPath)}`);
|
||||
return { outputPath: finalPath, duration: dur, size: fmtSize(finalPath) };
|
||||
}
|
||||
|
||||
// ==================== 主入口 ====================
|
||||
|
||||
function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const project = args.includes('--project') ? args[args.indexOf('--project') + 1] : 'qinshan';
|
||||
const epNum = args.includes('--ep') ? parseInt(args[args.indexOf('--ep') + 1], 10) : 1;
|
||||
|
||||
const epSubs = project_subs[project]?.ep?.[epNum];
|
||||
if (!epSubs) {
|
||||
console.error(`❌ 未找到项目 ${project} 第${epNum}集的字幕配置`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 查找视频文件
|
||||
const shotsDir = path.resolve(__dirname, '../outputs/shots');
|
||||
const prefix = `${project}-ep${String(epNum).padStart(2, '0')}-shot`;
|
||||
|
||||
// 生成SRT
|
||||
const outputDir = path.resolve(__dirname, '../outputs/final');
|
||||
if (!fs.existsSync(outputDir)) fs.mkdirSync(outputDir, { recursive: true });
|
||||
|
||||
const srtPath = path.join(outputDir, `${project}-ep${String(epNum).padStart(2, '0')}.srt`);
|
||||
const srtContent = generateSRT(epSubs);
|
||||
if (srtContent) {
|
||||
fs.writeFileSync(srtPath, srtContent);
|
||||
console.log(`📝 字幕: ${srtPath}`);
|
||||
}
|
||||
|
||||
// 拼接+烧字幕
|
||||
const outputPath = path.join(outputDir, `${project}-ep${String(epNum).padStart(2, '0')}.mp4`);
|
||||
concatAndBurn(shotsDir, prefix, epSubs.length, srtContent ? srtPath : null, outputPath);
|
||||
|
||||
console.log(`\n🎬 ${project_subs[project].title} 第${epNum}集完成!`);
|
||||
console.log(` 成品: ${outputPath}`);
|
||||
/**
|
||||
* 硬切拼接(无过渡)
|
||||
*/
|
||||
function composeCut(trimmed, output) {
|
||||
const listFile = path.join(path.dirname(output), '.concat-list.txt');
|
||||
const list = trimmed.map(t => `file '${t.file}'`).join('\n');
|
||||
fs.writeFileSync(listFile, list);
|
||||
execSync(
|
||||
`ffmpeg -y -f concat -safe 0 -i "${listFile}" -c copy "${output}" 2>/dev/null`,
|
||||
{ timeout: 30000 }
|
||||
);
|
||||
try { fs.unlinkSync(listFile); } catch (_) {}
|
||||
}
|
||||
|
||||
main();
|
||||
/**
|
||||
* 溶解过渡拼接
|
||||
*/
|
||||
function composeXFade(trimmed, output, fadeDur) {
|
||||
if (trimmed.length === 2) {
|
||||
// 两镜:直接 xfade
|
||||
execSync(
|
||||
`ffmpeg -y -i "${trimmed[0].file}" -i "${trimmed[1].file}" ` +
|
||||
`-filter_complex "xfade=transition=fade:duration=${fadeDur}:offset=${(trimmed[0].dur - fadeDur).toFixed(1)}" ` +
|
||||
`-c:v libx264 -preset fast -crf 23 -an "${output}" 2>/dev/null`,
|
||||
{ timeout: 60000 }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 多镜:链式 xfade
|
||||
let filterLines = '';
|
||||
let prevLabel = '0:v';
|
||||
let totalDur = 0;
|
||||
const offsets = [];
|
||||
|
||||
for (let i = 0; i < trimmed.length; i++) {
|
||||
if (i > 0) {
|
||||
const offset = (totalDur - fadeDur).toFixed(1);
|
||||
const newLabel = i < trimmed.length - 1 ? `vt${i}` : 'vout';
|
||||
filterLines += `[${prevLabel}][${i}:v]xfade=transition=fade:duration=${fadeDur}:offset=${offset}[${newLabel}];\n`;
|
||||
prevLabel = newLabel;
|
||||
totalDur += trimmed[i].dur - fadeDur;
|
||||
} else {
|
||||
totalDur = trimmed[i].dur;
|
||||
}
|
||||
}
|
||||
|
||||
const inputFlags = trimmed.map((_, i) => `-i "${trimmed[i].file}"`).join(' ');
|
||||
const fullFilter = filterLines.trim();
|
||||
const mapOut = trimmed.length > 2 ? 'vout' : '1:v';
|
||||
|
||||
execSync(
|
||||
`ffmpeg -y ${inputFlags} -filter_complex "${fullFilter}" ` +
|
||||
`-map "[${prevLabel}]" -c:v libx264 -preset fast -crf 23 -an "${output}" 2>/dev/null`,
|
||||
{ timeout: 120000 }
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== 工具 ====================
|
||||
|
||||
function probeSec(file) {
|
||||
try {
|
||||
const out = execSync(
|
||||
`ffprobe -v quiet -show_entries format=duration -of csv=p=0 "${file}"`,
|
||||
{ timeout: 5000, encoding: 'utf8' }
|
||||
);
|
||||
return parseFloat(out.trim());
|
||||
} catch (_) {
|
||||
return 4.0; // fallback
|
||||
}
|
||||
}
|
||||
|
||||
function fmtSize(file) {
|
||||
try {
|
||||
const stat = fs.statSync(file);
|
||||
return stat.size < 1048576
|
||||
? `${(stat.size / 1024).toFixed(0)}KB`
|
||||
: `${(stat.size / 1048576).toFixed(1)}MB`;
|
||||
} catch (_) { return '?'; }
|
||||
}
|
||||
|
||||
module.exports = { compose, probeSec };
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user