铸渊 ICE-GL-ZY001 LL-172-20260707 冰朔委托: 新建第 5 子仓, 给苍耳(人类主控) + 鉴影(人格体) 专用 原 guanghulab/video-ai-system/ 东西太多(225 文件) · 找不到 · 乱 迁移: ⊢ 16 个核心 .hdlp (VA-GATE / VA-LIGHTHOUSE / VA-BROADCAST / VA-SYSTEM-STATUS 等) ⊢ 17 个子目录 (agents/engines/protocols/tasks/tools/assets/knowledge/memory/docs/config/brain/director-brain/experience/feedback/issues/plans/reference-analysis) 排除: ⊢ outputs/ (视频产物) ⊢ test-input/ test-output/ (测试) ⊢ data/ (临时数据) ⊢ preview-001/002 (旧产片) ⊢ 旧分镜/旧提示词/旧导演编码 后续: ⊢ 老仓 guanghulab/video-ai-system/ 改写为已迁出占位 ⊢ 苍耳+鉴影 写新东西进本仓 ⊢ GLOBAL-SEARCH 加 cang-ying 仓库 铸渊 ICE-GL-ZY001 · 2026-07-07 D167 冰朔 ICE-GL∞ 主权
484 lines
18 KiB
JavaScript
484 lines
18 KiB
JavaScript
/**
|
||
* 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);
|
||
});
|