diff --git a/brain/fifth-domain/zero-point/zhuyuan/causal-chains/cc-014-D135-pit-chain.hdlp b/brain/fifth-domain/zero-point/zhuyuan/causal-chains/cc-014-D135-pit-chain.hdlp new file mode 100644 index 0000000..d65dd99 --- /dev/null +++ b/brain/fifth-domain/zero-point/zhuyuan/causal-chains/cc-014-D135-pit-chain.hdlp @@ -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 diff --git a/video-ai-system/config/product-lines.json b/video-ai-system/config/product-lines.json new file mode 100644 index 0000000..77d2462 --- /dev/null +++ b/video-ai-system/config/product-lines.json @@ -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-输出/视频" + } +} diff --git a/video-ai-system/engines/video-composer.js b/video-ai-system/engines/video-composer.js index b035567..3635a2e 100644 --- a/video-ai-system/engines/video-composer.js +++ b/video-ai-system/engines/video-composer.js @@ -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 };