D140: generate EP01 preview 002
Some checks failed
自动更新代码和重启 / update-and-restart (push) Has been cancelled
CI检查 + 自动部署 / check (push) Has been cancelled
CI检查 + 自动部署 / deploy (push) Has been cancelled

This commit is contained in:
冰朔 2026-06-23 15:59:46 +08:00
parent 18c7e01a16
commit 8cc582317e
14 changed files with 844 additions and 42 deletions

View File

@ -96,6 +96,9 @@ JZAO外置盘: 产物存放地,不是状态主控。
| 冰朔反馈 | ✅ 已记录 | `video-ai-system/feedback/D140-preview001-bingshuo-feedback.hdlp` |
| preview-002计划 | ✅ 已建立 | `video-ai-system/plans/EP01-15S-V2-PREVIEW-002.hdlp` |
| 多模型路由 | ✅ 已建立 | `video-ai-system/MODEL-ROUTER.hdlp` |
| 本地密钥入口 | ✅ 已固定 | `video-ai-system/LOCAL-SECRETS-PATH.hdlp` → `/Users/bingshuolingdianyuanhe/Documents/guanghulab-local-secrets/video-ai-system.env` |
| V2 15秒小样 | ✅ preview-002已出 | `/Volumes/JZAO/铸渊-ICE-GL-ZY001/OUT-输出/视频/zai-fu-fei-xiu-xian/ep01/EP01-15S-V2/final/EP01-15S-V2-preview-002.mp4` |
| V2 preview-002报告 | ✅ 已回写 | `video-ai-system/outputs/EP01-15S-V2-preview-002-report.md` |
| 旧导演编码 | ⚫ D136三镜遗留 | `video-ai-system/outputs/付费修仙-ep01-director-encoding.json` |
| 旧提示词 | ⚫ 真人线遗留 | `video-ai-system/data/ep01-prompts.json` |
| 旧3D产物 | ⚫ 废弃 | `/Volumes/JZAO/.../ep01-3D/` · 仅作反例 |
@ -109,7 +112,7 @@ JZAO外置盘: 产物存放地,不是状态主控。
```
EP01-15S-V2:
从剧本MD重新建立第1集开头15秒3D漫剧小样。
导演编码和3D提示词已重建。下一步生成/确认视觉锚点跑生成前体检再生成2~3镜并剪辑成可看15秒。
preview-001 和 preview-002 均已输出。preview-002 使用万相 T2V/I2V + 首帧锁定 + 本地音频/BGM/SFX + PNG字幕层输出约14.7秒。
```
---
@ -160,7 +163,8 @@ video-ai-system/PROTOCOL-ASSESSMENT.hdlp
11. 铸渊之眼拆帧质检 ✅
12. video-editor.js加字幕/声音/剪辑成15秒小样 ✅ `preview-001`
13. 回写 CURRENT / STATUS / audit report / registry ✅
14. 下一步: preview-002 重构资产固定、台词/BGM、转场叙事、story context、多模型路由
14. preview-002 重构资产固定、台词/BGM、转场叙事、story context、万相路由 ✅
15. 下一步: 人工观看 preview-002记录问题后决定是否重跑单镜或进入 EP01 后续镜头
```
---
@ -177,7 +181,9 @@ video-ai-system/PROTOCOL-ASSESSMENT.hdlp
⊢ 编码协议继续使用,但升级为“输入一致性底座”,不能再误认为“画面一致性万能解”。
⊢ 下一步不是继续抽卡,是先做 EP01-15S-V2 的导演编码、3D提示词和视觉锚点。
⊢ 下一步不是继续抽卡,是先人工观看 preview-002按镜头记录问题再决定是否只重跑问题镜头。
⊢ 密钥问题先查 `video-ai-system/LOCAL-SECRETS-PATH.hdlp`,不再靠记忆判断“有没有给过”。
---

View File

@ -0,0 +1,62 @@
# 视频AI系统 · 本地密钥固定入口
> HLDP://video-ai-system/LOCAL-SECRETS-PATH
> 类型: 本地运行约定 · 不存放真实密钥
> D140 · 2026-06-23
---
## 固定路径
视频AI系统的真实 API 密钥固定放在本机:
```
/Users/bingshuolingdianyuanhe/Documents/guanghulab-local-secrets/video-ai-system.env
```
项目目录里的本地副本:
```
video-ai-system/.env
```
两个文件都不进入代码仓库。
---
## 读取规则
代码加载顺序:
```
1. VIDEO_AI_SECRETS_FILE 指向的文件
2. /Users/bingshuolingdianyuanhe/Documents/guanghulab-local-secrets/video-ai-system.env
3. video-ai-system/.env
4. 当前进程环境变量
```
空值不覆盖已有非空值。
---
## 目前应包含
```
KLING_API_KEY=
JIMENG_API_KEY=
JIMENG_BASE_URL=https://ark.cn-beijing.volces.com/api/v3
JIMENG_MODEL=doubao-seedance-2-0-260128
ALIYUN_BAILIAN_API_KEY=
ALIYUN_BAILIAN_WORKSPACE_ID=
ALIYUN_BAILIAN_BASE_URL=https://dashscope.aliyuncs.com/api/v1
PYTHON_BIN=/Users/bingshuolingdianyuanhe/.workbuddy/binaries/python/envs/default/bin/python3
```
---
## 锁定
⊢ 不要再说“没给过密钥”。先检查这个文件。
⊢ 如果这里是空占位,才能说“本地固定密钥文件里该项为空”。
⊢ 代码仓库只存路径和读取规则,不存真实密钥。

View File

@ -0,0 +1,45 @@
const fs = require('fs');
const path = require('path');
const CANONICAL_SECRETS_FILE = '/Users/bingshuolingdianyuanhe/Documents/guanghulab-local-secrets/video-ai-system.env';
function loadEnvFile(file) {
if (!file || !fs.existsSync(file)) return false;
const content = fs.readFileSync(file, 'utf8');
for (const line of content.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eq = trimmed.indexOf('=');
if (eq <= 0) continue;
const key = trimmed.slice(0, eq).trim();
const value = trimmed.slice(eq + 1).trim();
if (!key || !value) continue;
if (!process.env[key]) process.env[key] = value;
}
return true;
}
function loadVideoAiEnv(localEnvPath = path.resolve(__dirname, '../.env')) {
const loaded = [];
const files = [
process.env.VIDEO_AI_SECRETS_FILE,
CANONICAL_SECRETS_FILE,
localEnvPath,
];
for (const file of files) {
if (loadEnvFile(file)) loaded.push(file);
}
return loaded;
}
module.exports = {
CANONICAL_SECRETS_FILE,
loadVideoAiEnv,
};

View File

@ -26,19 +26,10 @@ const fs = require('fs');
const path = require('path');
const https = require('https');
const http = require('http');
const { loadVideoAiEnv } = require('./env-loader');
// ==================== 环境变量 ====================
const envPath = path.resolve(__dirname, '../.env');
if (fs.existsSync(envPath)) {
const envContent = fs.readFileSync(envPath, 'utf-8');
envContent.split('\n').forEach(line => {
const trimmed = line.trim();
if (trimmed && !trimmed.startsWith('#')) {
const [key, ...vals] = trimmed.split('=');
if (key && vals.length) process.env[key.trim()] = vals.join('=').trim();
}
});
}
loadVideoAiEnv(path.resolve(__dirname, '../.env'));
const API_KEY = process.env.JIMENG_API_KEY || '';
const BASE_URL = process.env.JIMENG_BASE_URL || 'https://ark.cn-beijing.volces.com/api/v3';

View File

@ -14,8 +14,10 @@
const fs = require('fs');
const path = require('path');
const https = require('https');
const { loadVideoAiEnv } = require('./env-loader');
// ═══ 从环境变量读取 ═══
loadVideoAiEnv(path.resolve(__dirname, '../.env'));
const API_KEY = process.env.KLING_API_KEY || '';
const BASE_URL = 'api-beijing.klingai.com';
const POLL_INTERVAL = 2000;

View File

@ -25,19 +25,9 @@ const fs = require('fs');
const path = require('path');
const https = require('https');
const http = require('http');
const { loadVideoAiEnv } = require('./env-loader');
// 读取环境变量
const envPath = path.resolve(__dirname, '../.env');
if (fs.existsSync(envPath)) {
const envContent = fs.readFileSync(envPath, 'utf-8');
envContent.split('\n').forEach(line => {
const trimmed = line.trim();
if (trimmed && !trimmed.startsWith('#')) {
const [key, ...vals] = trimmed.split('=');
if (key && vals.length) process.env[key.trim()] = vals.join('=').trim();
}
});
}
loadVideoAiEnv(path.resolve(__dirname, '../.env'));
const API_KEY = process.env.JIMENG_API_KEY || '';
const BASE_URL = process.env.JIMENG_BASE_URL || 'https://ark.cn-beijing.volces.com/api/v3';

View File

@ -24,19 +24,9 @@ const fs = require('fs');
const path = require('path');
const https = require('https');
const http = require('http');
const { loadVideoAiEnv } = require('./env-loader');
// 读取环境变量
const envPath = path.resolve(__dirname, '../.env');
if (fs.existsSync(envPath)) {
const envContent = fs.readFileSync(envPath, 'utf-8');
envContent.split('\n').forEach(line => {
const trimmed = line.trim();
if (trimmed && !trimmed.startsWith('#')) {
const [key, ...vals] = trimmed.split('=');
if (key && vals.length) process.env[key.trim()] = vals.join('=').trim();
}
});
}
loadVideoAiEnv(path.resolve(__dirname, '../.env'));
const API_KEY = process.env.ALIYUN_BAILIAN_API_KEY || '';
const WORKSPACE_ID = process.env.ALIYUN_BAILIAN_WORKSPACE_ID || '';

View File

@ -65,6 +65,9 @@
| 冰朔反馈 | ✅ | `feedback/D140-preview001-bingshuo-feedback.hdlp` · 牌匾漂移/文字贴图/配音/转场/模型路由/story context |
| preview-002计划 | ✅ | `plans/EP01-15S-V2-PREVIEW-002.hdlp` · 重构为资产固定+音频剧本+叙事转场+模型路由 |
| 多模型路由 | ✅ | `MODEL-ROUTER.hdlp` · Seedance/Kling/Wan/Hunyuan/剪辑工具评估 |
| 本地密钥入口 | ✅ | `LOCAL-SECRETS-PATH.hdlp` · 固定读取 `/Users/bingshuolingdianyuanhe/Documents/guanghulab-local-secrets/video-ai-system.env` |
| V2 15秒小样 | ✅ | `EP01-15S-V2-preview-002.mp4` · 万相T2V/I2V + 本地音频/BGM/SFX + PNG字幕层 |
| V2 preview-002报告 | ✅ | `outputs/EP01-15S-V2-preview-002-report.md` |
| 旧导演编码 | ⚫ | `付费修仙-ep01-director-encoding.json` · D136三镜遗留仅作经验 |
| 旧提示词 | ⚫ | `ep01-prompts.json` 为真人线遗留,不再使用 |
| 旧3D产物 | ⚫ | 全部废弃,只保留为反例经验 |
@ -111,7 +114,8 @@
- [x] 体检通过后再调用视频API避免继续抽卡烧钱
- [x] 先生成2~3镜扩展到4镜剪辑出15秒连续可看小样
- [x] 记录 preview-001 冰朔反馈,建立 preview-002 重构计划
- [ ] preview-002 固定牌匾/广告牌资产重写音频剧本、转场语法、story context、多模型路由
- [x] preview-002 固定牌匾/广告牌资产重写音频剧本、转场语法、story context、万相路由
- [ ] 人工观看 preview-002记录下一轮单镜修复项
- [ ] 连续剧集CHAR保鲜(跨集一致性校验)
### 输出约定 · 永久有效
@ -138,6 +142,7 @@
- Codex恢复后补齐 EP01-15S-V2: 新增4镜/15秒 `ep01-director-encoding-3d-v2.json` 与 `ep01-prompts-3d-v2.json`体检脚本升级为识别V2文件当前无红黄问题。
- D140晚间执行第一阶段: 生成苏白/牌匾/广告牌/百宗会视觉锚点废弃竖屏S02、横牌S03、错字S04首帧重跑横版首帧与视频镜头剪辑出 `EP01-15S-V2-preview-001.mp4`15秒720p带字幕与临时旁白。
- D140次日冰朔反馈 preview-001: 天道宗牌匾跨镜变色变形;广告牌贴字模糊晃动;配音像念剧本而不是角色台词;转场像硬拼;需要多模型路由和 story context。已写入 `feedback/D140-preview001-bingshuo-feedback.hdlp` 与 `plans/EP01-15S-V2-PREVIEW-002.hdlp`。
- D140 preview-002执行: 固定本地密钥入口 `LOCAL-SECRETS-PATH.hdlp`;接入统一 env-loader使用万相 S01 T2V + S02/S03/S04 I2V 首帧锁定生成4镜本地生成配音/BGM/SFX因本机 FFmpeg 缺 subtitles/drawtext 滤镜,改用 Pillow 透明PNG字幕层 + overlay输出 `EP01-15S-V2-preview-002.mp4`14.741秒1280x72024fps。
### D136+ · 2026-06-21
- 冰朔检视3镜预览→发现3个连续性断裂

View File

@ -0,0 +1,33 @@
# EP01-15S-V2 Preview-002 Report
- createdAt: 2026-06-23T07:57:53.451Z
- output: /Volumes/JZAO/铸渊-ICE-GL-ZY001/OUT-输出/视频/zai-fu-fei-xiu-xian/ep01/EP01-15S-V2/final/EP01-15S-V2-preview-002.mp4
- duration: 14.741
- size: 6.3MB
- prompts: /Users/bingshuolingdianyuanhe/Documents/guanghulab/video-ai-system/outputs/ep01-preview-002-prompts.json
- results: /Users/bingshuolingdianyuanhe/Documents/guanghulab/video-ai-system/outputs/ep01-preview-002-results.json
## Routing
- EP01-15S-V2-S01-P002: wan-t2v · trim 4s · 百宗会大场景开场:云海、远山、御剑修仙者、热闹广场,镜头从高空下降,最后看见边缘角落的天道宗位置。
- EP01-15S-V2-S02-P002: wan-i2v · trim 3.1s · 牌匾特写:固定深棕破旧竖式悬挂牌匾,画面只慢推,不重新发明牌匾。
- EP01-15S-V2-S03-P002: wan-i2v · trim 4s · 苏白登场喊话:他站在同一竖式牌匾下方,白衣、双手叉腰、自信大笑,说招徒台词。
- EP01-15S-V2-S04-P002: wan-i2v · trim 3.9s · 招生广告牌反差:横式破木广告牌留出空白文字区,群众指指点点笑完散去,苏白和落叶留在角落。
## Audio
- Narration/dialogue generated as a dedicated voice track.
- BGM and SFX generated locally by FFmpeg; no paid audio service used.
## Notes
- Chinese readable in-frame sign text is still treated as post-production work.
- S02/S03/S04 use Wan image-to-video first frames to preserve asset continuity.
- Local secrets now have a fixed non-repo path documented in `video-ai-system/LOCAL-SECRETS-PATH.hdlp`.
- Subtitle burn-in uses Pillow transparent PNG overlays because this local FFmpeg build lacks `subtitles`, `ass`, and `drawtext` filters.
## Validation
- ffprobe: 14.741s, 1280x720, 24fps, H.264 + AAC stereo.
- Contact sheet: `/tmp/ep01-preview-002/contact.jpg`.
- Preview file: `/Volumes/JZAO/铸渊-ICE-GL-ZY001/OUT-输出/视频/zai-fu-fei-xiu-xian/ep01/EP01-15S-V2/final/EP01-15S-V2-preview-002.mp4`.

View File

@ -0,0 +1,109 @@
{
"_meta": {
"type": "EP01-15S-V2-preview-002-prompts",
"createdAt": "2026-06-23T07:57:42.334Z",
"storySoFar": "这是修仙世界百宗会招新。各大宗门人山人海,只有天道宗寒酸地缩在角落。苏白是天道宗年轻宗主,穷但自信,正在用离谱待遇招第一个弟子。",
"policy": "Wan T2V for S01; Wan I2V first-frame mode for locked assets S02-S04."
},
"shots": [
{
"id": "EP01-15S-V2-S01-P002",
"baseId": "EP01-15S-V2-S01",
"provider": "wan-t2v",
"duration": 4,
"trim": 4,
"output": "/Volumes/JZAO/铸渊-ICE-GL-ZY001/OUT-输出/视频/zai-fu-fei-xiu-xian/ep01/EP01-15S-V2/EP01-15S-V2-S01-preview-002-wan.mp4",
"currentBeat": "百宗会大场景开场:云海、远山、御剑修仙者、热闹广场,镜头从高空下降,最后看见边缘角落的天道宗位置。",
"prompt": "[Story so far]\n这是修仙世界百宗会招新。各大宗门人山人海只有天道宗寒酸地缩在角落。苏白是天道宗年轻宗主穷但自信正在用离谱待遇招第一个弟子。\n[Current beat]\nOnly shoot the opening establishing shot: a 3D Chinese cultivation recruitment fair, golden daylight, clouds and distant mountains, many cultivators flying on swords toward a crowded square.\nCamera descends from high clouds into the square, passes busy sect booths and long lines, then moves toward a neglected edge corner where Tiandao Sect will be located.\nKeep it cinematic 3D animation, Chinese xianxia, warm golden light, rich crowd, no modern objects.\n[Negative] 真人写实,现代服装,现代广告牌,科幻界面,欧美面孔,恐怖风,血腥,赛博朋克,文字乱码,横式天道宗牌匾,改变苏白服装,改变牌匾材质,空旷无人广场",
"subtitle": "百宗会招新这天,万修齐聚。",
"narration": "百宗会招新这天,修仙者从四面八方赶来。",
"transition": "cut",
"keyframes": {
"zoom": {
"from": 1,
"to": 1.08
},
"pan": {
"from": [
0,
0
],
"to": [
16,
-8
]
}
}
},
{
"id": "EP01-15S-V2-S02-P002",
"baseId": "EP01-15S-V2-S02",
"provider": "wan-i2v",
"duration": 4,
"trim": 3.1,
"output": "/Volumes/JZAO/铸渊-ICE-GL-ZY001/OUT-输出/视频/zai-fu-fei-xiu-xian/ep01/EP01-15S-V2/EP01-15S-V2-S02-preview-002-plaque.mp4",
"referenceImage": "/Volumes/JZAO/铸渊-ICE-GL-ZY001/OUT-输出/图片/zai-fu-fei-xiu-xian/ep01/anchors/shot-s02-plaque-firstframe-landscape.png",
"currentBeat": "牌匾特写:固定深棕破旧竖式悬挂牌匾,画面只慢推,不重新发明牌匾。",
"prompt": "Use the provided first frame as the exact visual reference.\nA cinematic 3D Chinese fantasy animation shot. Keep the same vertical dark brown old wooden hanging sign, same shape, same material, same hanging structure, same background composition.\nOnly add a subtle slow push-in and a very gentle natural sway. The background is a lively fantasy fair but softly blurred.\nDo not invent new readable text. Leave the sign surface stable for post-production text overlay.\nAvoid photorealism, modern objects, metal signs, neon signs, horizontal sign layout, or changing the prop design.",
"subtitle": "角落里,只有一块快掉漆的天道宗牌匾。",
"narration": "可最偏的角落里,只有一块快掉漆的天道宗牌匾。",
"transition": "cut",
"keyframes": {
"zoom": {
"from": 1,
"to": 1.06
}
}
},
{
"id": "EP01-15S-V2-S03-P002",
"baseId": "EP01-15S-V2-S03",
"provider": "wan-i2v",
"duration": 4,
"trim": 4,
"output": "/Volumes/JZAO/铸渊-ICE-GL-ZY001/OUT-输出/视频/zai-fu-fei-xiu-xian/ep01/EP01-15S-V2/EP01-15S-V2-S03-preview-002-subai.mp4",
"referenceImage": "/Volumes/JZAO/铸渊-ICE-GL-ZY001/OUT-输出/图片/zai-fu-fei-xiu-xian/ep01/anchors/shot-s03-subai-under-vertical-plaque-firstframe.png",
"currentBeat": "苏白登场喊话:他站在同一竖式牌匾下方,白衣、双手叉腰、自信大笑,说招徒台词。",
"prompt": "Use the provided first frame as the exact visual reference.\nA cinematic 3D Chinese fantasy animation shot. Keep the same young Asian male character in a white robe, same hairstyle, same confident pose, same vertical old wooden sign behind him.\nThe character stands with hands on waist and speaks confidently toward an off-screen crowd. Add only subtle upper-body movement, smiling expression, and a gentle camera push-in.\nDo not change clothing, age, hairstyle, sign position, sign color, or background composition. Dialogue will be added later as voice and subtitles.\nAvoid photorealism, modern objects, dark clothing, sad expression, disappearing sign, horizontal sign layout.",
"subtitle": "未来的天下第一宗!天道宗开门收徒啦!",
"dialogue": "未来的天下第一宗!天道宗开门收徒啦!",
"transition": "cut",
"keyframes": {
"zoom": {
"from": 1,
"to": 1.09
}
}
},
{
"id": "EP01-15S-V2-S04-P002",
"baseId": "EP01-15S-V2-S04",
"provider": "wan-i2v",
"duration": 4,
"trim": 3.9,
"output": "/Volumes/JZAO/铸渊-ICE-GL-ZY001/OUT-输出/视频/zai-fu-fei-xiu-xian/ep01/EP01-15S-V2/EP01-15S-V2-S04-preview-002-ad-crowd.mp4",
"referenceImage": "/Volumes/JZAO/铸渊-ICE-GL-ZY001/OUT-输出/图片/zai-fu-fei-xiu-xian/ep01/anchors/shot-s04-ad-board-crowd-firstframe-v2.png",
"currentBeat": "招生广告牌反差:横式破木广告牌留出空白文字区,群众指指点点笑完散去,苏白和落叶留在角落。",
"prompt": "Use the provided first frame as the exact visual reference.\nA cinematic 3D Chinese fantasy animation shot. Keep the same horizontal old wooden notice board in the foreground, with stable blank line areas for post-production text overlay.\nKeep the same young male character in a white robe near the board. Several bystanders gesture lightly from the sides and then drift away. A few leaves move in the wind.\nThe vertical wooden sign remains visible in the side background. Do not block the main character or the board.\nAvoid photorealism, modern posters, neon signs, readable generated text, crowd covering the board, changing character clothing.",
"subtitle": "包吃包住,每月三十两。围观者笑完,又散了。",
"narration": "众人看清牌子,笑了几声便散开,只剩风卷着落叶。",
"transition": "cut",
"keyframes": {
"zoom": {
"from": 1,
"to": 1.04
},
"pan": {
"from": [
0,
0
],
"to": [
-10,
0
]
}
}
}
]
}

View File

@ -0,0 +1,32 @@
{
"updatedAt": "2026-06-23T07:48:44.109Z",
"results": [
{
"shotId": "EP01-15S-V2-S01-P002",
"provider": "wan-t2v",
"file": "/Volumes/JZAO/铸渊-ICE-GL-ZY001/OUT-输出/视频/zai-fu-fei-xiu-xian/ep01/EP01-15S-V2/EP01-15S-V2-S01-preview-002-wan.mp4",
"status": "cached"
},
{
"shotId": "EP01-15S-V2-S02-P002",
"provider": "wan-i2v",
"file": "/Volumes/JZAO/铸渊-ICE-GL-ZY001/OUT-输出/视频/zai-fu-fei-xiu-xian/ep01/EP01-15S-V2/EP01-15S-V2-S02-preview-002-plaque.mp4",
"taskId": "b35d312c-c67d-4256-9e14-4f6885d5b292",
"status": "completed"
},
{
"shotId": "EP01-15S-V2-S03-P002",
"provider": "wan-i2v",
"file": "/Volumes/JZAO/铸渊-ICE-GL-ZY001/OUT-输出/视频/zai-fu-fei-xiu-xian/ep01/EP01-15S-V2/EP01-15S-V2-S03-preview-002-subai.mp4",
"taskId": "d949c35a-5c36-46ff-9e55-902623062e9b",
"status": "completed"
},
{
"shotId": "EP01-15S-V2-S04-P002",
"provider": "wan-i2v",
"file": "/Volumes/JZAO/铸渊-ICE-GL-ZY001/OUT-输出/视频/zai-fu-fei-xiu-xian/ep01/EP01-15S-V2/EP01-15S-V2-S04-preview-002-ad-crowd.mp4",
"taskId": "738f80ad-68e3-4301-a9b9-70da67a68217",
"status": "completed"
}
]
}

View File

@ -1,5 +1,5 @@
{
"generatedAt": "2026-06-23T07:26:24.628Z",
"generatedAt": "2026-06-23T07:59:32.357Z",
"activeLines": [
{
"key": "3d",
@ -212,6 +212,15 @@
"height": 720,
"codec": "h264"
},
{
"file": "/Volumes/JZAO/铸渊-ICE-GL-ZY001/OUT-输出/视频/zai-fu-fei-xiu-xian/ep01/EP01-15S-V2/EP01-15S-V2-S01-preview-002-wan.mp4",
"name": "EP01-15S-V2-S01-preview-002-wan.mp4",
"duration": 4.04,
"size": 6652037,
"width": 1280,
"height": 720,
"codec": "h264"
},
{
"file": "/Volumes/JZAO/铸渊-ICE-GL-ZY001/OUT-输出/视频/zai-fu-fei-xiu-xian/ep01/EP01-15S-V2/EP01-15S-V2-S02-plaque-r2-landscape.mp4",
"name": "EP01-15S-V2-S02-plaque-r2-landscape.mp4",
@ -230,6 +239,24 @@
"height": 1112,
"codec": "h264"
},
{
"file": "/Volumes/JZAO/铸渊-ICE-GL-ZY001/OUT-输出/视频/zai-fu-fei-xiu-xian/ep01/EP01-15S-V2/EP01-15S-V2-S02-preview-002-plaque.mp4",
"name": "EP01-15S-V2-S02-preview-002-plaque.mp4",
"duration": 4.04,
"size": 2046172,
"width": 1280,
"height": 720,
"codec": "h264"
},
{
"file": "/Volumes/JZAO/铸渊-ICE-GL-ZY001/OUT-输出/视频/zai-fu-fei-xiu-xian/ep01/EP01-15S-V2/EP01-15S-V2-S03-preview-002-subai.mp4",
"name": "EP01-15S-V2-S03-preview-002-subai.mp4",
"duration": 4.04,
"size": 2428059,
"width": 1280,
"height": 720,
"codec": "h264"
},
{
"file": "/Volumes/JZAO/铸渊-ICE-GL-ZY001/OUT-输出/视频/zai-fu-fei-xiu-xian/ep01/EP01-15S-V2/EP01-15S-V2-S03-subai-call-r2-landscape.mp4",
"name": "EP01-15S-V2-S03-subai-call-r2-landscape.mp4",
@ -257,6 +284,15 @@
"height": 720,
"codec": "h264"
},
{
"file": "/Volumes/JZAO/铸渊-ICE-GL-ZY001/OUT-输出/视频/zai-fu-fei-xiu-xian/ep01/EP01-15S-V2/EP01-15S-V2-S04-preview-002-ad-crowd.mp4",
"name": "EP01-15S-V2-S04-preview-002-ad-crowd.mp4",
"duration": 4.04,
"size": 2747976,
"width": 1280,
"height": 720,
"codec": "h264"
},
{
"file": "/Volumes/JZAO/铸渊-ICE-GL-ZY001/OUT-输出/视频/zai-fu-fei-xiu-xian/ep01/EP01-15S-V2/final/EP01-15S-V2-preview-001-nosub.mp4",
"name": "EP01-15S-V2-preview-001-nosub.mp4",
@ -275,6 +311,24 @@
"height": 720,
"codec": "h264"
},
{
"file": "/Volumes/JZAO/铸渊-ICE-GL-ZY001/OUT-输出/视频/zai-fu-fei-xiu-xian/ep01/EP01-15S-V2/final/EP01-15S-V2-preview-002.mp4",
"name": "EP01-15S-V2-preview-002.mp4",
"duration": 14.741,
"size": 6606005,
"width": 1280,
"height": 720,
"codec": "h264"
},
{
"file": "/Volumes/JZAO/铸渊-ICE-GL-ZY001/OUT-输出/视频/zai-fu-fei-xiu-xian/ep01/EP01-15S-V2/final/preview-002-work/EP01-15S-V2-preview-002-nosub.mp4",
"name": "EP01-15S-V2-preview-002-nosub.mp4",
"duration": 14.75,
"size": 6781049,
"width": 1280,
"height": 720,
"codec": "h264"
},
{
"file": "/Volumes/JZAO/铸渊-ICE-GL-ZY001/OUT-输出/视频/zai-fu-fei-xiu-xian/ep01/S01-镜1-v2.mp4",
"name": "S01-镜1-v2.mp4",

View File

@ -1,6 +1,6 @@
# 视频AI系统 · 无成本体检报告
生成时间: 2026-06-23T07:26:24.628Z
生成时间: 2026-06-23T07:59:32.357Z
当前active制作线: 3d(3D漫剧)
剧本MD: 存在
@ -8,7 +8,7 @@
导演编码: 旧3镜 / V2 4镜
3D提示词V2: 4镜
本地产物: 11个mp4
JZAO ep01产物: 38个mp4
JZAO ep01产物: 44个mp4
JZAO ep01-3D产物: 4个mp4
旧产物裁决: 旧JZAO视频产物全部废弃只作为踩坑样本和反例参考不进入D140 V2生产线。
注册表: 13条 / 失效路径0条

View File

@ -0,0 +1,483 @@
/**
* EP01-15S-V2 Preview-002 runner
*
* Flow:
* storyboard/director encoding -> story-context prompts -> shot generation
* -> voice/BGM/SFX/subtitles -> video-editor final compose.
*
* Cost guard:
* This script performs real video generation unless the output files already exist.
*/
const fs = require('fs');
const path = require('path');
const { execFileSync, execSync } = require('child_process');
const { generateWanVideo, generateWanImageToVideo } = require('../engines/wan-api-adapter');
const { edit } = require('../engines/video-editor');
const ROOT = path.resolve(__dirname, '..');
const OUT_ROOT = '/Volumes/JZAO/铸渊-ICE-GL-ZY001/OUT-输出/视频/zai-fu-fei-xiu-xian/ep01/EP01-15S-V2';
const FINAL_DIR = path.join(OUT_ROOT, 'final');
const ASSET_ROOT = '/Volumes/JZAO/铸渊-ICE-GL-ZY001/OUT-输出/图片/zai-fu-fei-xiu-xian/ep01/anchors';
const PROMPT_FILE = path.join(ROOT, 'outputs/ep01-preview-002-prompts.json');
const RESULT_FILE = path.join(ROOT, 'outputs/ep01-preview-002-results.json');
const REPORT_FILE = path.join(ROOT, 'outputs/EP01-15S-V2-preview-002-report.md');
const PYTHON_BIN = process.env.PYTHON_BIN || '/Users/bingshuolingdianyuanhe/.workbuddy/binaries/python/envs/default/bin/python3';
const storySoFar = [
'这是修仙世界百宗会招新。各大宗门人山人海,只有天道宗寒酸地缩在角落。',
'苏白是天道宗年轻宗主,穷但自信,正在用离谱待遇招第一个弟子。',
].join('');
const negativeGlobal = [
'真人写实',
'现代服装',
'现代广告牌',
'科幻界面',
'欧美面孔',
'恐怖风',
'血腥',
'赛博朋克',
'文字乱码',
'横式天道宗牌匾',
'改变苏白服装',
'改变牌匾材质',
'空旷无人广场',
].join('');
const shots = [
{
id: 'EP01-15S-V2-S01-P002',
baseId: 'EP01-15S-V2-S01',
provider: 'wan-t2v',
duration: 4,
trim: 4,
output: path.join(OUT_ROOT, 'EP01-15S-V2-S01-preview-002-wan.mp4'),
currentBeat: '百宗会大场景开场:云海、远山、御剑修仙者、热闹广场,镜头从高空下降,最后看见边缘角落的天道宗位置。',
prompt: [
'[Story so far]',
storySoFar,
'[Current beat]',
'Only shoot the opening establishing shot: a 3D Chinese cultivation recruitment fair, golden daylight, clouds and distant mountains, many cultivators flying on swords toward a crowded square.',
'Camera descends from high clouds into the square, passes busy sect booths and long lines, then moves toward a neglected edge corner where Tiandao Sect will be located.',
'Keep it cinematic 3D animation, Chinese xianxia, warm golden light, rich crowd, no modern objects.',
`[Negative] ${negativeGlobal}`,
].join('\n'),
subtitle: '百宗会招新这天,万修齐聚。',
narration: '百宗会招新这天,修仙者从四面八方赶来。',
transition: 'cut',
keyframes: { zoom: { from: 1.0, to: 1.08 }, pan: { from: [0, 0], to: [16, -8] } },
},
{
id: 'EP01-15S-V2-S02-P002',
baseId: 'EP01-15S-V2-S02',
provider: 'wan-i2v',
duration: 4,
trim: 3.1,
output: path.join(OUT_ROOT, 'EP01-15S-V2-S02-preview-002-plaque.mp4'),
referenceImage: path.join(ASSET_ROOT, 'shot-s02-plaque-firstframe-landscape.png'),
currentBeat: '牌匾特写:固定深棕破旧竖式悬挂牌匾,画面只慢推,不重新发明牌匾。',
prompt: [
'Use the provided first frame as the exact visual reference.',
'A cinematic 3D Chinese fantasy animation shot. Keep the same vertical dark brown old wooden hanging sign, same shape, same material, same hanging structure, same background composition.',
'Only add a subtle slow push-in and a very gentle natural sway. The background is a lively fantasy fair but softly blurred.',
'Do not invent new readable text. Leave the sign surface stable for post-production text overlay.',
'Avoid photorealism, modern objects, metal signs, neon signs, horizontal sign layout, or changing the prop design.',
].join('\n'),
subtitle: '角落里,只有一块快掉漆的天道宗牌匾。',
narration: '可最偏的角落里,只有一块快掉漆的天道宗牌匾。',
transition: 'cut',
keyframes: { zoom: { from: 1.0, to: 1.06 } },
},
{
id: 'EP01-15S-V2-S03-P002',
baseId: 'EP01-15S-V2-S03',
provider: 'wan-i2v',
duration: 4,
trim: 4,
output: path.join(OUT_ROOT, 'EP01-15S-V2-S03-preview-002-subai.mp4'),
referenceImage: path.join(ASSET_ROOT, 'shot-s03-subai-under-vertical-plaque-firstframe.png'),
currentBeat: '苏白登场喊话:他站在同一竖式牌匾下方,白衣、双手叉腰、自信大笑,说招徒台词。',
prompt: [
'Use the provided first frame as the exact visual reference.',
'A cinematic 3D Chinese fantasy animation shot. Keep the same young Asian male character in a white robe, same hairstyle, same confident pose, same vertical old wooden sign behind him.',
'The character stands with hands on waist and speaks confidently toward an off-screen crowd. Add only subtle upper-body movement, smiling expression, and a gentle camera push-in.',
'Do not change clothing, age, hairstyle, sign position, sign color, or background composition. Dialogue will be added later as voice and subtitles.',
'Avoid photorealism, modern objects, dark clothing, sad expression, disappearing sign, horizontal sign layout.',
].join('\n'),
subtitle: '未来的天下第一宗!天道宗开门收徒啦!',
dialogue: '未来的天下第一宗!天道宗开门收徒啦!',
transition: 'cut',
keyframes: { zoom: { from: 1.0, to: 1.09 } },
},
{
id: 'EP01-15S-V2-S04-P002',
baseId: 'EP01-15S-V2-S04',
provider: 'wan-i2v',
duration: 4,
trim: 3.9,
output: path.join(OUT_ROOT, 'EP01-15S-V2-S04-preview-002-ad-crowd.mp4'),
referenceImage: path.join(ASSET_ROOT, 'shot-s04-ad-board-crowd-firstframe-v2.png'),
currentBeat: '招生广告牌反差:横式破木广告牌留出空白文字区,群众指指点点笑完散去,苏白和落叶留在角落。',
prompt: [
'Use the provided first frame as the exact visual reference.',
'A cinematic 3D Chinese fantasy animation shot. Keep the same horizontal old wooden notice board in the foreground, with stable blank line areas for post-production text overlay.',
'Keep the same young male character in a white robe near the board. Several bystanders gesture lightly from the sides and then drift away. A few leaves move in the wind.',
'The vertical wooden sign remains visible in the side background. Do not block the main character or the board.',
'Avoid photorealism, modern posters, neon signs, readable generated text, crowd covering the board, changing character clothing.',
].join('\n'),
subtitle: '包吃包住,每月三十两。围观者笑完,又散了。',
narration: '众人看清牌子,笑了几声便散开,只剩风卷着落叶。',
transition: 'cut',
keyframes: { zoom: { from: 1.0, to: 1.04 }, pan: { from: [0, 0], to: [-10, 0] } },
},
];
async function main() {
fs.mkdirSync(OUT_ROOT, { recursive: true });
fs.mkdirSync(FINAL_DIR, { recursive: true });
writePrompts();
validateRefs();
await generateShots();
const final = await composeFinal();
writeReport(final);
console.log(`[Preview-002] done: ${final.outputPath}`);
}
function writePrompts() {
fs.writeFileSync(PROMPT_FILE, JSON.stringify({
_meta: {
type: 'EP01-15S-V2-preview-002-prompts',
createdAt: new Date().toISOString(),
storySoFar,
policy: 'Wan T2V for S01; Wan I2V first-frame mode for locked assets S02-S04.',
},
shots,
}, null, 2));
console.log(`[Preview-002] prompts: ${PROMPT_FILE}`);
}
function validateRefs() {
for (const shot of shots) {
if (shot.referenceImage && !fs.existsSync(shot.referenceImage)) {
throw new Error(`reference missing: ${shot.referenceImage}`);
}
}
}
async function generateShots() {
const results = [];
for (const shot of shots) {
console.log(`\n[Preview-002] ${shot.id} via ${shot.provider}`);
if (fs.existsSync(shot.output)) {
console.log(` cached: ${shot.output}`);
results.push({ shotId: shot.id, provider: shot.provider, file: shot.output, status: 'cached' });
continue;
}
try {
let result;
if (shot.provider === 'wan-t2v') {
result = await generateWanVideo({
prompt: shot.prompt,
duration: shot.duration,
resolution: '720P',
ratio: '16:9',
watermark: false,
promptExtend: true,
shotId: shot.id,
projectKey: 'zai-fu-fei-xiu-xian/ep01/EP01-15S-V2',
outputPath: shot.output,
});
} else if (shot.provider === 'wan-i2v') {
const firstFrame = prepareWanReferenceImage(shot);
result = await generateWanImageToVideo({
prompt: shot.prompt,
media: [{ type: 'first_frame', url: firstFrame }],
duration: shot.duration,
resolution: '720P',
watermark: false,
promptExtend: true,
shotId: shot.id,
projectKey: 'zai-fu-fei-xiu-xian/ep01/EP01-15S-V2',
outputPath: shot.output,
});
} else {
throw new Error(`unknown provider: ${shot.provider}`);
}
results.push({
shotId: shot.id,
provider: shot.provider,
file: result.videoPath,
taskId: result.taskId,
status: 'completed',
});
} catch (error) {
results.push({ shotId: shot.id, provider: shot.provider, file: null, status: 'failed', error: error.message });
fs.writeFileSync(RESULT_FILE, JSON.stringify({ updatedAt: new Date().toISOString(), results }, null, 2));
throw error;
}
fs.writeFileSync(RESULT_FILE, JSON.stringify({ updatedAt: new Date().toISOString(), results }, null, 2));
}
}
function prepareWanReferenceImage(shot) {
if (!shot.referenceImage) throw new Error(`missing reference image for ${shot.id}`);
const workDir = path.join(FINAL_DIR, 'preview-002-work', 'wan-refs');
fs.mkdirSync(workDir, { recursive: true });
const out = path.join(workDir, `${shot.id}-first-frame-1280x720.jpg`);
if (!fs.existsSync(out)) {
execFileSync('ffmpeg', [
'-y',
'-i', shot.referenceImage,
'-vf', 'scale=1280:720:force_original_aspect_ratio=decrease,pad=1280:720:(ow-iw)/2:(oh-ih)/2',
'-q:v', '3',
out,
], { stdio: 'ignore' });
}
return out;
}
async function composeFinal() {
const workDir = path.join(FINAL_DIR, 'preview-002-work');
fs.mkdirSync(workDir, { recursive: true });
const srtPath = path.join(workDir, 'EP01-15S-V2-preview-002.srt');
const voicePath = path.join(workDir, 'EP01-15S-V2-preview-002-voice.wav');
const bgmPath = path.join(workDir, 'EP01-15S-V2-preview-002-bgm.wav');
const sfxPath = path.join(workDir, 'EP01-15S-V2-preview-002-sfx.wav');
const outputPath = path.join(FINAL_DIR, 'EP01-15S-V2-preview-002.mp4');
const noSubPath = path.join(workDir, 'EP01-15S-V2-preview-002-nosub.mp4');
writeSrt(srtPath);
buildVoiceTrack(workDir, voicePath);
buildBgm(bgmPath);
buildSfx(sfxPath);
const edited = await edit({
timeline: shots.map(shot => ({
shot: shot.output,
trim: shot.trim,
transition: shot.transition,
keyframes: shot.keyframes,
})),
audio: {
voice: voicePath,
bgm: bgmPath,
sfx: [{ at: 4.0, file: sfxPath }, { at: 11.0, file: sfxPath }],
voiceVolume: 1.25,
bgmVolume: 0.23,
},
subtitle: null,
colorGrade: { contrast: 1.05, saturation: 1.04 },
resolution: { w: 1280, h: 720 },
fps: 24,
output: noSubPath,
});
burnPngSubtitles(noSubPath, outputPath, workDir);
return { outputPath, duration: probeDuration(outputPath), size: fmtSize(outputPath), noSubPath: edited.outputPath };
}
function writeSrt(srtPath) {
const blocks = [
[0.0, 4.0, shots[0].subtitle],
[4.0, 7.1, shots[1].subtitle],
[7.1, 11.1, shots[2].subtitle],
[11.1, 15.0, shots[3].subtitle],
];
const srt = blocks.map((b, i) => `${i + 1}\n${fmtTime(b[0])} --> ${fmtTime(b[1])}\n${b[2]}\n`).join('\n');
fs.writeFileSync(srtPath, srt);
}
function buildVoiceTrack(workDir, output) {
const items = [
{ at: 0.45, text: shots[0].narration, voice: 'Tingting', rate: 185 },
{ at: 4.45, text: shots[1].narration, voice: 'Tingting', rate: 182 },
{ at: 7.25, text: shots[2].dialogue, voice: 'Tingting', rate: 205 },
{ at: 11.45, text: shots[3].narration, voice: 'Tingting', rate: 186 },
];
const inputs = [];
const filters = [];
const labels = [];
items.forEach((item, i) => {
const aiff = path.join(workDir, `voice-${i}.aiff`);
execFileSync('say', ['-v', item.voice, '-r', String(item.rate), '-o', aiff, item.text], { stdio: 'ignore' });
inputs.push('-i', aiff);
const delay = Math.round(item.at * 1000);
filters.push(`[${i}:a]adelay=${delay}|${delay},volume=1.0[v${i}]`);
labels.push(`[v${i}]`);
});
execFileSync('ffmpeg', [
'-y',
...inputs,
'-filter_complex', `${filters.join(';')};${labels.join('')}amix=inputs=${labels.length}:duration=longest:normalize=0,atrim=0:15`,
'-ar', '48000',
'-ac', '2',
output,
], { stdio: 'ignore' });
}
function buildBgm(output) {
execFileSync('ffmpeg', [
'-y',
'-f', 'lavfi',
'-i', 'sine=frequency=196:sample_rate=48000:duration=15',
'-f', 'lavfi',
'-i', 'sine=frequency=392:sample_rate=48000:duration=15',
'-filter_complex',
'[0:a]volume=0.12,afade=t=in:st=0:d=1,afade=t=out:st=13.8:d=1.2[a0];[1:a]volume=0.05,adelay=7000|7000,afade=t=in:st=7:d=1.2[a1];[a0][a1]amix=inputs=2:duration=first,acompressor=threshold=-18dB:ratio=2',
'-ar', '48000',
'-ac', '2',
output,
], { stdio: 'ignore' });
}
function buildSfx(output) {
execFileSync('ffmpeg', [
'-y',
'-f', 'lavfi',
'-i', 'anoisesrc=color=pink:sample_rate=48000:duration=1.1',
'-filter_complex', 'volume=0.16,afade=t=in:st=0:d=0.05,afade=t=out:st=0.75:d=0.35',
'-ar', '48000',
'-ac', '2',
output,
], { stdio: 'ignore' });
}
function generateSubtitleImages(workDir) {
const subDir = path.join(workDir, 'subtitle-png');
fs.mkdirSync(subDir, { recursive: true });
const subtitles = [
{ start: 0.0, end: 4.0, text: shots[0].subtitle },
{ start: 4.0, end: 7.1, text: shots[1].subtitle },
{ start: 7.1, end: 11.1, text: shots[2].subtitle },
{ start: 11.1, end: 15.0, text: shots[3].subtitle },
];
const jsonPath = path.join(subDir, 'subtitles.json');
fs.writeFileSync(jsonPath, JSON.stringify({ width: 1280, height: 720, subtitles }, null, 2));
const scriptPath = path.join(subDir, 'render_subtitles.py');
fs.writeFileSync(scriptPath, `
import json
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
data = json.loads(Path(${JSON.stringify(jsonPath)}).read_text(encoding='utf-8'))
out_dir = Path(${JSON.stringify(subDir)})
font_candidates = [
'/System/Library/Fonts/STHeiti Medium.ttc',
'/System/Library/Fonts/Hiragino Sans GB.ttc',
'/System/Library/Fonts/Supplemental/Arial Unicode.ttf',
]
font_path = next((p for p in font_candidates if Path(p).exists()), None)
font = ImageFont.truetype(font_path, 42) if font_path else ImageFont.load_default()
for i, item in enumerate(data['subtitles']):
img = Image.new('RGBA', (data['width'], data['height']), (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
text = item['text']
bbox = draw.textbbox((0, 0), text, font=font, stroke_width=3)
tw = bbox[2] - bbox[0]
th = bbox[3] - bbox[1]
x = (data['width'] - tw) // 2
y = data['height'] - 96
pad_x, pad_y = 22, 12
draw.rounded_rectangle((x - pad_x, y - pad_y, x + tw + pad_x, y + th + pad_y), radius=12, fill=(0, 0, 0, 128))
draw.text((x, y), text, font=font, fill=(255, 255, 255, 255), stroke_width=3, stroke_fill=(0, 0, 0, 230))
img.save(out_dir / f'sub_{i}.png')
`, 'utf8');
execFileSync(PYTHON_BIN, [scriptPath], { stdio: 'ignore' });
return subtitles.map((item, i) => ({ ...item, file: path.join(subDir, `sub_${i}.png`) }));
}
function burnPngSubtitles(inputVideo, outputVideo, workDir) {
const subtitles = generateSubtitleImages(workDir);
const args = ['-y', '-i', inputVideo];
for (const sub of subtitles) args.push('-loop', '1', '-i', sub.file);
const overlays = [];
let current = '[0:v]';
subtitles.forEach((sub, i) => {
const next = i === subtitles.length - 1 ? '[vout]' : `[v${i}]`;
overlays.push(`${current}[${i + 1}:v]overlay=0:0:enable='between(t,${sub.start},${sub.end})'${next}`);
current = next;
});
args.push(
'-filter_complex', overlays.join(';'),
'-map', '[vout]',
'-map', '0:a?',
'-c:v', 'libx264',
'-preset', 'fast',
'-crf', '21',
'-c:a', 'copy',
'-t', '15',
'-shortest',
'-movflags', '+faststart',
outputVideo,
);
execFileSync('ffmpeg', args, { stdio: 'ignore' });
}
function probeDuration(file) {
const out = execFileSync('ffprobe', ['-v', 'quiet', '-show_entries', 'format=duration', '-of', 'csv=p=0', file], { encoding: 'utf8' });
return parseFloat(out.trim()) || 0;
}
function fmtSize(file) {
const s = fs.statSync(file).size;
return s < 1048576 ? `${(s / 1024).toFixed(0)}KB` : `${(s / 1048576).toFixed(1)}MB`;
}
function writeReport(final) {
const lines = [
'# EP01-15S-V2 Preview-002 Report',
'',
`- createdAt: ${new Date().toISOString()}`,
`- output: ${final.outputPath}`,
`- duration: ${final.duration}`,
`- size: ${final.size}`,
`- prompts: ${PROMPT_FILE}`,
`- results: ${RESULT_FILE}`,
'',
'## Routing',
'',
...shots.map(s => `- ${s.id}: ${s.provider} · trim ${s.trim}s · ${s.currentBeat}`),
'',
'## Audio',
'',
'- Narration/dialogue generated as a dedicated voice track.',
'- BGM and SFX generated locally by FFmpeg; no paid audio service used.',
'',
'## Notes',
'',
'- Chinese readable in-frame sign text is still treated as post-production work.',
'- S02/S03/S04 use Wan image-to-video first frames to preserve asset continuity.',
];
fs.writeFileSync(REPORT_FILE, lines.join('\n'));
}
function fmtTime(sec) {
const h = Math.floor(sec / 3600);
const m = Math.floor((sec % 3600) / 60);
const s = (sec % 60).toFixed(3);
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(6, '0')}`.replace('.', ',');
}
main().catch(error => {
console.error(`[Preview-002] failed: ${error.stack || error.message}`);
process.exit(1);
});