zelin-bot / src /director.js
Z User
v5.8.5: Gemma 4, MC Wiki, MC Player, anti-hallucination, CPU optimizations
ee826ee
/**
* ============================================
* director.js β€” Zelin's FFmpeg Video Editor
* ============================================
* Post-production editing of Minecraft recordings using
* FFmpeg's filter_complex in a single pass.
*
* Architecture:
* - Takes raw recording path from youtube-life.js
* - Takes script markers from script-writer.js
* - Uses FFmpeg to: cut dead time, add transitions,
* normalize audio, add voiceover, render final video
* - Generates thumbnail using Sharp (programmatic)
*/
import fs from 'fs';
import path from 'path';
import { exec, execFile } from 'child_process';
import { fileURLToPath } from 'url';
import sharp from 'sharp';
import { readConfig, generateId, sleep } from './utils.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// ── Config ─────────────────────────────────────────────────────────────────────
const config = readConfig();
const VIDEO = config.video || {};
const OBS = config.obs || {};
const OUTPUT_DIR = VIDEO.outputDir || './videos';
const RECORDING_DIR = OBS.recordingPath || './recordings';
const FFMPEG_PATH = VIDEO.ffmpegPath || 'ffmpeg';
const EDGE_TTS_VOICE = VIDEO.edgeTTSVoice || 'es-ES-ElviraNeural';
const THUMB_FONT = VIDEO.thumbnailFont || 'Noto Sans SC';
const MAX_LENGTH = VIDEO.maxVideoLength || 600;
const TARGET_BITRATE = VIDEO.targetBitrate || '8M';
// ── FFmpeg availability check ──────────────────────────────────────────────────
let _ffmpegAvailable = null;
async function checkFFmpeg() {
if (_ffmpegAvailable !== null) return _ffmpegAvailable;
return new Promise(resolve => {
exec('which ffmpeg', err => {
if (err) {
console.warn('[Director] FFmpeg not found β€” video editing will be disabled');
_ffmpegAvailable = false;
} else {
console.log('[Director] FFmpeg detected');
_ffmpegAvailable = true;
}
resolve(_ffmpegAvailable);
});
});
}
// ── Directory helpers ──────────────────────────────────────────────────────────
function ensureDir(dirPath) {
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
}
function outputFileName(prefix, ext) {
ensureDir(OUTPUT_DIR);
return path.join(OUTPUT_DIR, `${prefix}_${generateId()}.${ext}`);
}
// ── Color schemes for thumbnails ───────────────────────────────────────────────
const COLOR_SCHEMES = {
cave: {
gradient: ['#1a0a2e', '#16213e', '#0f3460'],
accent: '#e94560',
border: '#533483',
text: '#ffffff',
subtitle: '#c4c4c4',
},
overworld: {
gradient: ['#56ab2f', '#a8e063', '#87ceeb'],
accent: '#2d5016',
border: '#3e7a1b',
text: '#ffffff',
subtitle: '#e8f5e9',
},
nether: {
gradient: ['#8b0000', '#c0392b', '#e74c3c'],
accent: '#ff6600',
border: '#6b1a1a',
text: '#ffffff',
subtitle: '#ffd9c0',
},
end: {
gradient: ['#0d0221', '#1a0533', '#2d1b69'],
accent: '#a855f7',
border: '#4c1d95',
text: '#e0d4ff',
subtitle: '#b8a9e8',
},
village: {
gradient: ['#8b6914', '#c4a35a', '#f5e6ca'],
accent: '#5d3a1a',
border: '#6b4226',
text: '#3e2723',
subtitle: '#5d4037',
},
};
// ═══════════════════════════════════════════════════════════════════════════════
// EXPORTED FUNCTIONS
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Main entry point β€” full edit pipeline.
* Takes raw recording path + script with markers, produces final video + thumbnail.
*
* @param {string} recordingPath - Path to raw recording file
* @param {object} script - Script object with markers array
* script.markers: [{ start, end, label, type }, ...]
* script.title: string
* script.subtitle: string
* script.colorScheme: 'cave'|'overworld'|'nether'|'end'|'village'
* @returns {{ outputPath: string, thumbnailPath: string, duration: number }}
*/
export async function editVideo(recordingPath, script) {
console.log('[Director] Starting video edit pipeline...');
if (!(await checkFFmpeg())) {
console.error('[Director] Cannot edit video β€” FFmpeg not available');
return { outputPath: null, thumbnailPath: null, duration: 0 };
}
// Validate input file
if (!fs.existsSync(recordingPath)) {
console.error(`[Director] Recording not found: ${recordingPath}`);
return { outputPath: null, thumbnailPath: null, duration: 0 };
}
let markers = script?.markers || [];
// Convert string markers (from script-writer.js) to {start, end, label, type} objects
// script-writer returns markers as strings like 'intro', 'action:mining', 'climax', etc.
// director needs objects with start/end timestamps for FFmpeg trimming
if (markers.length > 0 && typeof markers[0] === 'string') {
const totalDuration = script?.targetDuration || script?.estimatedDuration || 120;
markers = _convertStringMarkers(markers, totalDuration);
}
/**
* Convert string markers from script-writer.js into {start, end, label, type} objects.
* script-writer returns markers as strings like 'intro', 'action:mining', 'climax'.
* director needs objects with start/end timestamps for FFmpeg trimming.
*/
function _convertStringMarkers(stringMarkers, totalDuration) {
const count = stringMarkers.length;
const segmentDuration = totalDuration / Math.max(count, 1);
return stringMarkers.map((label, i) => ({
start : Math.round(i * segmentDuration),
end : Math.round((i + 1) * segmentDuration),
label : label,
type : label.split(':')[0], // 'action:mining' β†’ 'action'
}));
}
if (markers.length === 0) {
console.warn('[Director] No markers provided β€” using full recording');
const duration = await getVideoDuration(recordingPath);
markers.push({ start: 0, end: Math.min(duration, MAX_LENGTH), label: 'full', type: 'highlight' });
}
// Clamp markers to max video length
const totalMarkerTime = markers.reduce((sum, m) => sum + (m.end - m.start), 0);
if (totalMarkerTime > MAX_LENGTH) {
console.warn(`[Director] Total marker time (${totalMarkerTime}s) exceeds max (${MAX_LENGTH}s) β€” trimming`);
trimMarkers(markers, MAX_LENGTH);
}
try {
// Step 1: Generate intro/outro card images
const colorScheme = script?.colorScheme || 'overworld';
const introPath = await generateCard('intro', script?.title || 'Zelin', '', colorScheme);
const outroPath = await generateCard('outro', 'Gracias por ver!', 'Like & Subscribe', colorScheme);
// Step 2: Build and run FFmpeg filter_complex
const outputPath = outputFileName('zelin_edit', 'mp4');
await runFFmpegEdit(recordingPath, markers, introPath, outroPath, outputPath);
// Step 3: Generate thumbnail
const thumbnailPath = await generateDirectorThumbnail(
script?.title || 'Minecraft con Zelin',
script?.subtitle || '',
colorScheme
);
// Step 4: Get final duration
const duration = await getVideoDuration(outputPath);
console.log(`[Director] Edit complete β†’ ${outputPath} (${duration.toFixed(1)}s)`);
return { outputPath, thumbnailPath, duration };
} catch (err) {
console.error(`[Director] Edit pipeline failed: ${err.message}`);
return { outputPath: null, thumbnailPath: null, duration: 0 };
}
}
/**
* Generate a YouTube thumbnail using Sharp (programmatic, NOT AI).
*
* @param {string} title - Main title text
* @param {string} subtitle - Subtitle text
* @param {string} colorScheme - One of: cave, overworld, nether, end, village
* @returns {string} Path to generated PNG thumbnail
*/
export async function generateDirectorThumbnail(title, subtitle, colorScheme = 'overworld') {
console.log(`[Director] Generating thumbnail (${colorScheme})...`);
ensureDir(OUTPUT_DIR);
const scheme = COLOR_SCHEMES[colorScheme] || COLOR_SCHEMES.overworld;
const W = 1280;
const H = 720;
// Build gradient background using three color stops via SVG β†’ Sharp
const gradientSvg = buildGradientSvg(W, H, scheme.gradient);
const bgBuffer = await sharp(Buffer.from(gradientSvg)).png().toBuffer();
// Compose the thumbnail
const overlays = [];
// ── Pixel-art style border ───────────────────────────────────────────────
const borderWidth = 8;
const blockSize = 16;
// Top border
for (let x = 0; x < W; x += blockSize) {
overlays.push({
input: await makeRect(blockSize - 2, borderWidth, scheme.border),
top: 12,
left: x + 1,
});
}
// Bottom border
for (let x = 0; x < W; x += blockSize) {
overlays.push({
input: await makeRect(blockSize - 2, borderWidth, scheme.border),
top: H - 12 - borderWidth,
left: x + 1,
});
}
// Left border
for (let y = 0; y < H; y += blockSize) {
overlays.push({
input: await makeRect(borderWidth, blockSize - 2, scheme.border),
top: y + 1,
left: 12,
});
}
// Right border
for (let y = 0; y < H; y += blockSize) {
overlays.push({
input: await makeRect(borderWidth, blockSize - 2, scheme.border),
top: y + 1,
left: W - 12 - borderWidth,
});
}
// ── Accent line below title area ─────────────────────────────────────────
overlays.push({
input: await makeRect(W - 120, 4, scheme.accent),
top: 380,
left: 60,
});
// ── Title text ───────────────────────────────────────────────────────────
const titleTextSvg = buildTextSvg(title, {
width: W,
fontSize: 64,
fontWeight: 'bold',
fillColor: scheme.text,
strokeColor: 'rgba(0,0,0,0.6)',
y: 260,
fontFamily: THUMB_FONT,
});
overlays.push({
input: await sharp(Buffer.from(titleTextSvg)).png().toBuffer(),
top: 0,
left: 0,
});
// ── Subtitle text ────────────────────────────────────────────────────────
if (subtitle) {
const subtitleTextSvg = buildTextSvg(subtitle, {
width: W,
fontSize: 36,
fontWeight: 'normal',
fillColor: scheme.subtitle,
strokeColor: 'rgba(0,0,0,0.4)',
y: 340,
fontFamily: THUMB_FONT,
});
overlays.push({
input: await sharp(Buffer.from(subtitleTextSvg)).png().toBuffer(),
top: 0,
left: 0,
});
}
// ── Decorative corner blocks (pixel-art style) ───────────────────────────
const cornerSize = 32;
const cornerColor = scheme.accent;
const corners = [
{ top: 20, left: 20 },
{ top: 20, left: W - 20 - cornerSize },
{ top: H - 20 - cornerSize, left: 20 },
{ top: H - 20 - cornerSize, left: W - 20 - cornerSize },
];
for (const c of corners) {
overlays.push({
input: await makeRect(cornerSize, cornerSize, cornerColor),
top: c.top,
left: c.left,
});
}
const thumbPath = path.join(OUTPUT_DIR, `thumb_${generateId()}.png`);
await sharp(bgBuffer)
.composite(overlays)
.png()
.toFile(thumbPath);
console.log(`[Director] Thumbnail saved β†’ ${thumbPath}`);
return thumbPath;
}
/**
* Cut highlights from a recording based on markers.
* Produces a single concatenated clip of just the highlights.
*
* @param {string} recordingPath - Path to raw recording
* @param {Array<{start: number, end: number}>} markers - Timestamp markers
* @returns {string} Path to cut clip
*/
export async function cutHighlights(recordingPath, markers) {
console.log(`[Director] Cutting ${markers.length} highlight segments...`);
if (!(await checkFFmpeg())) {
console.error('[Director] Cannot cut highlights β€” FFmpeg not available');
return null;
}
if (!fs.existsSync(recordingPath)) {
console.error(`[Director] Recording not found: ${recordingPath}`);
return null;
}
if (markers.length === 0) {
console.warn('[Director] No markers to cut');
return null;
}
try {
const cutPath = outputFileName('highlights', 'mp4');
const filterComplex = buildHighlightFilter(markers);
await runFFmpegCommand([
'-i', recordingPath,
'-filter_complex', filterComplex.filter,
'-map', filterComplex.videoMap,
'-map', filterComplex.audioMap,
'-c:v', 'libx264',
'-preset', 'medium',
'-b:v', TARGET_BITRATE,
'-c:a', 'aac',
'-b:a', '192k',
'-y',
cutPath,
]);
console.log(`[Director] Highlights cut β†’ ${cutPath}`);
return cutPath;
} catch (err) {
console.error(`[Director] cutHighlights failed: ${err.message}`);
return null;
}
}
/**
* Add voiceover to a video using edge-tts.
*
* @param {string} videoPath - Path to the video file
* @param {string} audioPath - Path to voiceover audio (optional, will generate if missing)
* @param {object} script - Script object with voiceover text
* script.voiceover: string (text to speak)
* @returns {string} Path to output video with voiceover
*/
export async function addVoiceover(videoPath, audioPath, script) {
console.log('[Director] Adding voiceover...');
if (!(await checkFFmpeg())) {
console.error('[Director] Cannot add voiceover β€” FFmpeg not available');
return null;
}
if (!fs.existsSync(videoPath)) {
console.error(`[Director] Video not found: ${videoPath}`);
return null;
}
try {
// Generate voiceover audio if not provided
let voiceoverPath = audioPath;
if (!voiceoverPath || !fs.existsSync(voiceoverPath)) {
const voiceoverText = script?.voiceover || script?.title || '';
if (!voiceoverText) {
console.warn('[Director] No voiceover text provided β€” skipping');
return videoPath;
}
voiceoverPath = await generateVoiceoverAudio(voiceoverText);
if (!voiceoverPath) {
console.warn('[Director] Voiceover generation failed β€” returning original');
return videoPath;
}
}
const outputPath = outputFileName('voiced', 'mp4');
// Mix voiceover audio with original audio (voiceover louder, original as background)
await runFFmpegCommand([
'-i', videoPath,
'-i', voiceoverPath,
'-filter_complex',
'[0:a]volume=0.3[bg];[1:a]volume=1.2[vo];[bg][vo]amix=inputs=2:duration=first:dropout_transition=3[outa]',
'-map', '0:v',
'-map', '[outa]',
'-c:v', 'copy',
'-c:a', 'aac',
'-b:a', '192k',
'-y',
outputPath,
]);
console.log(`[Director] Voiceover added β†’ ${outputPath}`);
return outputPath;
} catch (err) {
console.error(`[Director] addVoiceover failed: ${err.message}`);
return null;
}
}
/**
* Get the duration of a video file in seconds.
*
* @param {string} filePath - Path to video file
* @returns {number} Duration in seconds
*/
export async function getVideoDuration(filePath) {
if (!fs.existsSync(filePath)) {
console.warn(`[Director] File not found for duration check: ${filePath}`);
return 0;
}
return new Promise(resolve => {
// Use execFile to avoid shell injection from filePath
execFile('ffprobe', ['-v', 'error', '-show_entries', 'format=duration', '-of', 'default=noprint_wrappers=1:nokey=1', filePath], (err, stdout) => {
if (err) {
// Fallback: try to get duration from ffmpeg
execFile(FFMPEG_PATH, ['-i', filePath], (err2, stdout2, stderr) => {
if (err2 || !stderr) { resolve(0); return; }
const match = stderr.match(/Duration:\s*(\d+):(\d+):(\d+\.\d+)/);
if (match) {
const h = parseInt(match[1], 10);
const m = parseInt(match[2], 10);
const s = parseFloat(match[3]);
resolve(h * 3600 + m * 60 + s);
} else {
resolve(0);
}
});
return;
}
const dur = parseFloat(stdout.trim());
resolve(isNaN(dur) ? 0 : dur);
});
});
}
// ═══════════════════════════════════════════════════════════════════════════════
// INTERNAL HELPERS
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Run FFmpeg with the full edit pipeline: intro + highlights + outro.
*/
async function runFFmpegEdit(recordingPath, markers, introPath, outroPath, outputPath) {
console.log(`[Director] Building FFmpeg filter_complex for ${markers.length} segments...`);
const introDuration = 2;
const outroDuration = 3;
// Build inputs: recording + intro image + outro image
const inputs = [
'-i', recordingPath, // [0] - main recording
'-loop', '1', '-t', String(introDuration), '-i', introPath, // [1] - intro
'-loop', '1', '-t', String(outroDuration), '-i', outroPath, // [2] - outro
];
const filterParts = [];
let videoChain = '';
let audioChain = '';
// ── Trim video segments from recording ───────────────────────────────────
for (let i = 0; i < markers.length; i++) {
const m = markers[i];
const start = Math.max(0, m.start || 0);
const end = m.end || (start + 30);
filterParts.push(`[0:v]trim=start=${start}:end=${end},setpts=PTS-STARTPTS[v${i}]`);
filterParts.push(`[0:a]atrim=start=${start}:end=${end},asetpts=PTS-STARTPTS[a${i}]`);
}
// ── Scale intro/outro to 1080p ──────────────────────────────────────────
filterParts.push(`[1:v]scale=1920:1080:force_original_aspect_ratio=decrease,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,format=yuv420p[introv]`);
filterParts.push(`anullsrc=r=44100:cl=stereo[introa]`); // silent audio for intro (source filter, no input)
filterParts.push(`[2:v]scale=1920:1080:force_original_aspect_ratio=decrease,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,format=yuv420p[outrov]`);
filterParts.push(`anullsrc=r=44100:cl=stereo[outroa]`); // silent audio for outro (source filter, no input)
// ── Build chain: intro β†’ segments with crossfade β†’ outro ─────────────────
// Scale all segments to 1080p
for (let i = 0; i < markers.length; i++) {
filterParts.push(`[v${i}]scale=1920:1080:force_original_aspect_ratio=decrease,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,fps=30,format=yuv420p[v${i}s]`);
}
// Calculate segment durations for xfade offsets
const segDurations = markers.map(m => (m.end || (m.start + 30)) - (m.start || 0));
const minSegDur = Math.min(...segDurations);
const xfadeDur = Math.max(0.1, Math.min(0.5, minSegDur * 0.4));
// Chain: intro β†’ v0s β†’ v1s β†’ ... β†’ outro (video)
let currentV = 'introv';
let currentA = 'introa';
let cumulativeOffset = introDuration;
for (let i = 0; i < markers.length; i++) {
const outV = i === markers.length - 1 && markers.length === 1 ? 'mergedv' : `xv${i}`;
const outA = i === markers.length - 1 && markers.length === 1 ? 'mergeda' : `xa${i}`;
const offset = cumulativeOffset - xfadeDur;
filterParts.push(`[${currentV}][v${i}s]xfade=transition=fade:duration=${xfadeDur}:offset=${offset}[${outV}]`);
filterParts.push(`[${currentA}][a${i}]acrossfade=d=${xfadeDur}[${outA}]`);
cumulativeOffset += segDurations[i] - xfadeDur;
currentV = outV;
currentA = outA;
}
// Append outro with crossfade
const outroOffset = cumulativeOffset - xfadeDur;
filterParts.push(`[${currentV}][outrov]xfade=transition=fade:duration=${xfadeDur}:offset=${outroOffset}[finalv]`);
filterParts.push(`[${currentA}][outroa]acrossfade=d=${xfadeDur}[finala]`);
// ── Final processing: normalize audio, set format ────────────────────────
filterParts.push(`[finalv]format=yuv420p[finalv2]`);
filterParts.push(`[finala]loudnorm=I=-16:TP=-1.5:LRA=11[finala2]`);
const filterComplex = filterParts.join(';\n');
const args = [
...inputs,
'-filter_complex', filterComplex,
'-map', '[finalv2]',
'-map', '[finala2]',
'-c:v', 'libx264',
'-preset', 'medium',
'-b:v', TARGET_BITRATE,
'-c:a', 'aac',
'-b:a', '192k',
'-movflags', '+faststart',
'-y',
outputPath,
];
console.log('[Director] Running FFmpeg (single pass)...');
await runFFmpegCommand(args);
console.log('[Director] FFmpeg render complete');
}
/**
* Build highlight-only filter (no intro/outro).
*/
function buildHighlightFilter(markers) {
const filterParts = [];
const segDurs = markers.map(m => (m.end || (m.start + 30)) - (m.start || 0));
const xfadeDur = Math.max(0.1, Math.min(0.5, Math.min(...segDurs) * 0.4));
// Trim and scale each segment
for (let i = 0; i < markers.length; i++) {
const m = markers[i];
const start = Math.max(0, m.start || 0);
const end = m.end || (start + 30);
filterParts.push(`[0:v]trim=start=${start}:end=${end},setpts=PTS-STARTPTS[v${i}]`);
filterParts.push(`[0:a]atrim=start=${start}:end=${end},asetpts=PTS-STARTPTS[a${i}]`);
filterParts.push(`[v${i}]scale=1920:1080:force_original_aspect_ratio=decrease,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,fps=30,format=yuv420p[v${i}s]`);
}
// Crossfade segments together
if (markers.length === 1) {
filterParts.push(`[v0s]format=yuv420p[outv]`);
filterParts.push(`[a0]loudnorm=I=-16:TP=-1.5:LRA=11[outa]`);
} else {
let currentV = 'v0s';
let currentA = 'a0';
const segDurations = markers.map(m => (m.end || (m.start + 30)) - (m.start || 0));
let cumulativeOffset = segDurations[0];
for (let i = 1; i < markers.length; i++) {
const isLast = i === markers.length - 1;
const outV = isLast ? 'outv' : `xv${i}`;
const outA = isLast ? 'outa' : `xa${i}`;
const offset = cumulativeOffset - xfadeDur;
filterParts.push(`[${currentV}][v${i}s]xfade=transition=fade:duration=${xfadeDur}:offset=${offset}[${outV}]`);
filterParts.push(`[${currentA}][a${i}]acrossfade=d=${xfadeDur}[${outA}]`);
cumulativeOffset += segDurations[i] - xfadeDur;
currentV = outV;
currentA = outA;
}
filterParts.push(`[outv]format=yuv420p[outv]`);
filterParts.push(`[outa]loudnorm=I=-16:TP=-1.5:LRA=11[outa]`);
}
return {
filter: filterParts.join(';'),
videoMap: '[outv]',
audioMap: '[outa]',
};
}
/**
* Trim markers so total time does not exceed maxLength.
* Removes segments from the end proportionally.
*/
function trimMarkers(markers, maxLength) {
let total = markers.reduce((sum, m) => sum + (m.end - m.start), 0);
while (total > maxLength && markers.length > 1) {
const last = markers[markers.length - 1];
const excess = total - maxLength;
if (last.end - last.start <= excess) {
// Remove entire last marker
total -= (last.end - last.start);
markers.pop();
} else {
// Trim last marker
last.end -= excess;
total = maxLength;
}
}
}
/**
* Run an FFmpeg command via child_process.
* Returns a promise that resolves on success, rejects on failure.
*/
function runFFmpegCommand(args) {
return new Promise((resolve, reject) => {
const fullArgs = [FFMPEG_PATH, ...args];
console.log(`[Director] Exec: ${fullArgs.slice(0, 8).join(' ')}...`);
const proc = execFile(FFMPEG_PATH, args, {
maxBuffer: 100 * 1024 * 1024, // 100MB buffer for long output
timeout: 600_000, // 10 minute timeout
}, (err, stdout, stderr) => {
if (err) {
const msg = stderr?.split('\n').slice(-5).join('\n') || err.message;
console.error(`[Director] FFmpeg error:\n${msg}`);
reject(new Error(`FFmpeg failed: ${msg}`));
return;
}
resolve(stdout);
});
// Log progress from stderr
if (proc.stderr) {
let lastProgress = '';
proc.stderr.on('data', data => {
const lines = data.toString().split('\n');
for (const line of lines) {
if (line.includes('time=') && line !== lastProgress) {
lastProgress = line;
const timeMatch = line.match(/time=(\d+:\d+:\d+\.\d+)/);
if (timeMatch) {
// Only log every few seconds to avoid spam
}
}
}
});
}
});
}
/**
* Generate a voiceover audio file using edge-tts CLI.
*
* @param {string} text - Text to speak
* @returns {string|null} Path to generated MP3, or null on failure
*/
async function generateVoiceoverAudio(text) {
ensureDir(OUTPUT_DIR);
const audioPath = path.join(OUTPUT_DIR, `vo_${generateId()}.mp3`);
return new Promise(resolve => {
const args = [
'--voice', EDGE_TTS_VOICE,
'--text', text,
'--write-media', audioPath,
];
console.log('[Director] Generating voiceover with edge-tts...');
execFile('edge-tts', args, { timeout: 120_000 }, (err, stdout, stderr) => {
if (err) {
console.error(`[Director] edge-tts failed: ${err.message}`);
resolve(null);
return;
}
if (!fs.existsSync(audioPath)) {
console.error('[Director] edge-tts produced no output file');
resolve(null);
return;
}
console.log(`[Director] Voiceover generated β†’ ${audioPath}`);
resolve(audioPath);
});
});
}
/**
* Generate an intro or outro card image using Sharp.
*
* @param {'intro'|'outro'} type - Card type
* @param {string} title - Title text
* @param {string} subtitle - Subtitle text
* @param {string} colorScheme - Color scheme name
* @returns {string} Path to generated PNG
*/
async function generateCard(type, title, subtitle, colorScheme) {
ensureDir(OUTPUT_DIR);
const scheme = COLOR_SCHEMES[colorScheme] || COLOR_SCHEMES.overworld;
const W = 1920;
const H = 1080;
// Background gradient
const gradientSvg = buildGradientSvg(W, H, scheme.gradient);
const bgBuffer = await sharp(Buffer.from(gradientSvg)).png().toBuffer();
const overlays = [];
// Title text
if (title) {
const titleSvg = buildTextSvg(title, {
width: W,
fontSize: type === 'intro' ? 80 : 60,
fontWeight: 'bold',
fillColor: scheme.text,
strokeColor: 'rgba(0,0,0,0.5)',
y: H / 2 - (type === 'intro' ? 60 : 40),
fontFamily: THUMB_FONT,
});
overlays.push({
input: await sharp(Buffer.from(titleSvg)).png().toBuffer(),
top: 0,
left: 0,
});
}
// Subtitle text
if (subtitle) {
const subSvg = buildTextSvg(subtitle, {
width: W,
fontSize: 40,
fontWeight: 'normal',
fillColor: scheme.subtitle,
strokeColor: 'rgba(0,0,0,0.3)',
y: H / 2 + 30,
fontFamily: THUMB_FONT,
});
overlays.push({
input: await sharp(Buffer.from(subSvg)).png().toBuffer(),
top: 0,
left: 0,
});
}
// Accent line
overlays.push({
input: await makeRect(W - 240, 6, scheme.accent),
top: H / 2 - 10,
left: 120,
});
const cardPath = path.join(OUTPUT_DIR, `${type}_${generateId()}.png`);
await sharp(bgBuffer)
.composite(overlays)
.png()
.toFile(cardPath);
console.log(`[Director] ${type} card β†’ ${cardPath}`);
return cardPath;
}
// ═══════════════════════════════════════════════════════════════════════════════
// SVG / SHARP HELPERS
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Build a linear gradient SVG.
*/
function buildGradientSvg(width, height, colors) {
const stops = colors.map((c, i) => {
const offset = (i / (colors.length - 1)) * 100;
return `<stop offset="${offset}%" stop-color="${c}" />`;
}).join('');
return `<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
${stops}
</linearGradient>
</defs>
<rect width="${width}" height="${height}" fill="url(#bg)" />
</svg>`;
}
/**
* Build an SVG with centered text, suitable for compositing with Sharp.
*/
function buildTextSvg(text, opts = {}) {
const {
width = 1280,
fontSize = 48,
fontWeight = 'bold',
fillColor = '#ffffff',
strokeColor = 'rgba(0,0,0,0.5)',
strokeWidth = 3,
y = 300,
fontFamily = 'Noto Sans SC',
} = opts;
// Escape XML special chars
const escaped = text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
return `<svg width="${width}" height="${fontSize * 3}" xmlns="http://www.w3.org/2000/svg">
<style>
@import url('local://fonts');
.title { font: ${fontWeight} ${fontSize}px "${fontFamily}", sans-serif; }
</style>
<text x="50%" y="${fontSize + 10}" text-anchor="middle" dominant-baseline="auto"
fill="${fillColor}" stroke="${strokeColor}" stroke-width="${strokeWidth}"
paint-order="stroke" class="title">${escaped}</text>
</svg>`;
}
/**
* Create a solid-color rectangle PNG buffer using Sharp.
*/
async function makeRect(w, h, color) {
const svg = `<svg width="${w}" height="${h}" xmlns="http://www.w3.org/2000/svg">
<rect width="${w}" height="${h}" fill="${color}" />
</svg>`;
return sharp(Buffer.from(svg)).png().toBuffer();
}
// ── Initial log ────────────────────────────────────────────────────────────────
console.log(`[Director] Module loaded β€” output: ${OUTPUT_DIR}, max: ${MAX_LENGTH}s, bitrate: ${TARGET_BITRATE}`);
// Pre-check FFmpeg availability on load (non-blocking)
checkFFmpeg();