cang-ying/tools/run-ep01-preview-002.js
铸渊 ICE-GL-ZY001 a2e5214f03 LL-172-20260707 · cang-ying 仓初始化 · 苍耳+鉴影的干净之家
铸渊 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∞ 主权
2026-07-07 10:20:10 +08:00

484 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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);
});