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