Spaces:
Paused
Paused
| /** | |
| * ============================================ | |
| * 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, '&') | |
| .replace(/</g, '<') | |
| .replace(/>/g, '>') | |
| .replace(/"/g, '"') | |
| .replace(/'/g, '''); | |
| 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(); | |