/** * ============================================ * 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 ``; }).join(''); return ` ${stops} `; } /** * 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, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); return ` ${escaped} `; } /** * Create a solid-color rectangle PNG buffer using Sharp. */ async function makeRect(w, h, color) { const 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();