Monorepo for Aesthetic.Computer aesthetic.computer
at main 1029 lines 40 kB view raw
1#!/usr/bin/env node 2 3/** 4 * Orchestrator Script 5 * Manages the stateless frame-by-frame rendering 6 * Supports both .mjs pieces and kidlisp $code pieces 7 */ 8 9import { execSync } from 'child_process'; 10import fs from 'fs'; 11import path from 'path'; 12import { fileURLToPath } from 'url'; 13import https from 'https'; 14import { timestamp } from '../../../system/public/aesthetic.computer/lib/num.mjs'; 15 16const __filename = fileURLToPath(import.meta.url); 17const __dirname = path.dirname(__filename); 18const ALLOWED_GIF_FPS = [100, 50, 25, 20, 10, 5, 4, 2, 1]; 19 20const FRAMES_PER_SECOND = 60; 21const DEFAULT_FRAME_COUNT = 300; 22 23class RenderOrchestrator { 24 constructor(piece, frames, outputDir, width = 2048, height = 2048, options = {}) { 25 this.piece = piece; 26 this.frames = frames; 27 this.outputDir = outputDir; 28 this.width = width; 29 this.height = height; 30 this.frameRendererPath = path.join(__dirname, 'frame-renderer.mjs'); 31 this.isKidlispPiece = piece.startsWith('$'); 32 this.gifMode = options.gifMode || false; 33 this.density = options.density || null; // Custom density parameter 34 this.kidlispCache = options.kidlispCache || null; // KidLisp dependency cache 35 this.extractIconFrame = options.extractIconFrame || false; // Extract midpoint frame as icon 36 this.iconOutputDir = options.iconOutputDir || null; // Custom directory for icon output 37 this.debugInkColors = options.debugInkColors || false; 38 this.gifCompress = options.gifCompress || false; // Ultra-compress GIF for smallest size 39 if (this.gifMode) { 40 if (options.gifFps && !ALLOWED_GIF_FPS.includes(options.gifFps)) { 41 console.warn(`⚠️ Requested GIF fps ${options.gifFps} is not a valid divisor of 100; falling back to nearest supported value.`); 42 } 43 if (options.gifFps && ALLOWED_GIF_FPS.includes(options.gifFps)) { 44 this.gifFps = options.gifFps; 45 } else { 46 this.gifFps = 50; 47 } 48 // For GIF mode, render at native GIF fps for smoothest motion 49 this.nativeFps = this.gifFps; 50 } else { 51 this.gifFps = null; 52 this.nativeFps = 60; // Standard fps for MP4 53 } 54 } 55 56 // Fetch KidLisp source code from the localhost API (similar to tape.mjs) 57 async fetchKidLispSource(code) { 58 const cleanCode = code.replace(/^\$/, ''); 59 60 // Try localhost first, then fallback to production 61 const urls = [ 62 `https://localhost:8888/.netlify/functions/store-kidlisp?code=${cleanCode}`, 63 `https://aesthetic.computer/.netlify/functions/store-kidlisp?code=${cleanCode}` 64 ]; 65 66 for (let i = 0; i < urls.length; i++) { 67 const url = urls[i]; 68 const isLocalhost = url.includes('localhost'); 69 const serverName = isLocalhost ? 'localhost:8888' : 'aesthetic.computer'; 70 71 try { 72 const source = await this.tryFetchFromUrl(url, isLocalhost); 73 if (i > 0) { 74 console.log(`🌐 Fetched from ${serverName} (localhost unavailable)`); 75 } 76 return source; 77 } catch (error) { 78 if (i === urls.length - 1) { 79 // Last attempt failed 80 throw new Error(`Could not fetch KidLisp piece '${code}' from any server`); 81 } 82 // Continue to next server 83 if (isLocalhost) { 84 console.log(`🔄 localhost:8888 unavailable, trying aesthetic.computer...`); 85 } 86 } 87 } 88 } 89 90 // Helper method to try fetching from a specific URL 91 async tryFetchFromUrl(url, isLocalhost) { 92 return new Promise((resolve, reject) => { 93 const options = isLocalhost ? { rejectUnauthorized: false, timeout: 5000 } : { timeout: 10000 }; // 5s for localhost, 10s for production 94 95 const req = https.get(url, options, (res) => { 96 let data = ''; 97 res.on('data', (chunk) => data += chunk); 98 res.on('end', () => { 99 try { 100 const response = JSON.parse(data); 101 if (response.error) { 102 reject(new Error(`KidLisp piece not found`)); 103 return; 104 } 105 if (!response.source) { 106 reject(new Error("Could not parse source code from response")); 107 return; 108 } 109 resolve(response.source); 110 } catch (error) { 111 reject(new Error("Could not parse JSON response")); 112 } 113 }); 114 }).on('error', (error) => { 115 const serverName = isLocalhost ? 'localhost:8888' : 'aesthetic.computer'; 116 reject(new Error(`Could not connect to ${serverName}`)); 117 }).on('timeout', () => { 118 req.destroy(); 119 const serverName = isLocalhost ? 'localhost:8888' : 'aesthetic.computer'; 120 reject(new Error(`Timeout connecting to ${serverName}`)); 121 }); 122 123 // Set timeout 124 req.setTimeout(isLocalhost ? 5000 : 10000); 125 }); 126 } 127 128 // Extract KidLisp dependencies from source code 129 extractDependencies(source) { 130 const dependencies = new Set(); 131 // Match patterns like ($code ...) where code starts with a letter/number 132 const matches = source.match(/\(\$[a-zA-Z0-9][a-zA-Z0-9]*\b/g); 133 if (matches) { 134 for (const match of matches) { 135 // Extract just the code part without the opening parenthesis and $ 136 const code = match.slice(2); // Remove "($" 137 dependencies.add(code); 138 } 139 } 140 return Array.from(dependencies); 141 } 142 143 // Fetch all dependencies and build a cache 144 async fetchDependencies(source) { 145 console.log(`🔍 Scanning for dependencies in KidLisp source...`); 146 const dependencies = this.extractDependencies(source); 147 148 if (dependencies.length === 0) { 149 console.log(`📝 No dependencies found`); 150 return null; 151 } 152 153 console.log(`📦 Found ${dependencies.length} dependencies: ${dependencies.join(', ')}`); 154 155 const codesMap = new Map(); 156 const errors = []; 157 158 for (const dep of dependencies) { 159 try { 160 console.log(`🔄 Fetching dependency: $${dep}`); 161 const depSource = await this.fetchKidLispSource(`$${dep}`); 162 codesMap.set(dep, { source: depSource }); 163 console.log(`✅ Cached dependency: $${dep} (${depSource.length} chars)`); 164 165 // Recursively fetch dependencies of dependencies 166 const subDeps = await this.fetchDependencies(depSource); 167 if (subDeps && subDeps.codesMap) { 168 for (const [subCode, subData] of subDeps.codesMap) { 169 if (!codesMap.has(subCode)) { 170 codesMap.set(subCode, subData); 171 console.log(`✅ Cached sub-dependency: $${subCode} (${subData.source.length} chars)`); 172 } 173 } 174 } 175 } catch (error) { 176 console.warn(`⚠️ Failed to fetch dependency $${dep}: ${error.message}`); 177 errors.push(`$${dep}: ${error.message}`); 178 } 179 } 180 181 if (codesMap.size === 0) { 182 console.warn(`❌ No dependencies could be fetched. Errors: ${errors.join(', ')}`); 183 return null; 184 } 185 186 console.log(`🎯 Built dependency cache with ${codesMap.size} codes`); 187 return { codesMap }; 188 } 189 190 // Create a temporary kidlisp piece file for rendering with dependencies 191 async createKidlispPieceFile(source) { 192 const pieceName = this.piece.replace('$', ''); 193 const pieceFilePath = path.resolve(this.outputDir, `${pieceName}-kidlisp.mjs`); 194 195 try { 196 // Generate cache code if we have cached dependencies 197 let cacheScript = ''; 198 if (this.kidlispCache && this.kidlispCache.codesMap) { 199 console.log(`🎯 Using provided KidLisp cache with ${this.kidlispCache.codesMap.size} codes`); 200 const { generateCacheCode } = await import('../../../objkt/kidlisp-extractor.mjs'); 201 cacheScript = generateCacheCode(this.kidlispCache.codesMap); 202 } else { 203 console.log(`⚠️ No KidLisp cache provided - dependencies may not render correctly`); 204 } 205 206 // Create a piece file that uses the kidlisp() function with cache setup 207 const pieceContent = ` 208// Auto-generated kidlisp piece for recording: ${this.piece} 209// Uses built-in kidlisp() function with dependency cache 210 211${cacheScript ? ` 212// Initialize KidLisp cache at module level (before any KidLisp instances are created) 213${cacheScript} 214` : '// No KidLisp cache available'} 215 216export async function boot({ screen }) { 217 console.log("🎨 Booting kidlisp piece: ${this.piece}"); 218} 219 220export async function paint(api) { 221 try { 222 // Use the built-in kidlisp() function with the actual source code 223 // Note: Width and height will be auto-detected by the kidlisp function 224 const source = \`${source.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$/g, '\\$')}\`; 225 api.kidlisp(0, 0, undefined, undefined, source); 226 } catch (error) { 227 console.error("❌ KidLisp execution error:", error); 228 // Show error visually 229 if (api.wipe && api.ink && api.write) { 230 api.wipe('red'); 231 api.ink('white'); 232 api.write(\`Error: \${error.message}\`, 10, 10); 233 } else { 234 console.error("❌ Cannot show error visually - missing API functions"); 235 } 236 } 237} 238`; 239 240 fs.writeFileSync(pieceFilePath, pieceContent); 241 console.log(`📝 Created temporary kidlisp piece: ${pieceFilePath}`); 242 return pieceFilePath; 243 } catch (error) { 244 console.error(`❌ Failed to resolve KidLisp dependencies: ${error.message}`); 245 // Fallback to simple version without dependencies 246 const pieceContent = ` 247// Auto-generated kidlisp piece for recording: ${this.piece} 248// Simple fallback without dependency resolution 249 250// No KidLisp cache available 251 252export async function boot({ screen }) { 253 console.log("🎨 Booting kidlisp piece: ${this.piece}"); 254} 255 256export async function paint(api) { 257 try { 258 const source = \`${source.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$/g, '\\$')}\`; 259 api.kidlisp(0, 0, undefined, undefined, source); 260 } catch (error) { 261 console.error("❌ KidLisp execution error:", error); 262 if (api.wipe && api.ink && api.write) { 263 api.wipe('red'); 264 api.ink('white'); 265 api.write(\`Error: \${error.message}\`, 10, 10); 266 } 267 } 268} 269`; 270 fs.writeFileSync(pieceFilePath, pieceContent); 271 console.log(`📝 Created fallback kidlisp piece: ${pieceFilePath}`); 272 return pieceFilePath; 273 } 274 } 275 276 async renderAll() { 277 console.log(`🎬 Starting stateless render: ${this.piece} for ${this.frames} frames`); 278 console.log(`📁 Output: ${this.outputDir}`); 279 280 let actualPiece = this.piece; 281 282 // Handle kidlisp pieces 283 if (this.isKidlispPiece) { 284 console.log(`🎨 Detected kidlisp piece: ${this.piece}`); 285 try { 286 const source = await this.fetchKidLispSource(this.piece); 287 console.log(`📄 Fetched kidlisp source (${source.length} chars)`); 288 289 // Fetch dependencies and build cache if not already provided 290 if (!this.kidlispCache) { 291 this.kidlispCache = await this.fetchDependencies(source); 292 } 293 294 actualPiece = await this.createKidlispPieceFile(source); 295 } catch (error) { 296 console.error(`❌ Failed to fetch kidlisp source: ${error.message}`); 297 return; 298 } 299 } 300 301 const startTime = Date.now(); 302 let frameCount = 0; 303 let complete = false; 304 let consecutiveFailures = 0; 305 const maxRetries = 3; 306 307 // Clear screen and hide cursor for progress bar 308 process.stdout.write('\x1b[2J\x1b[H\x1b[?25l'); 309 310 while (!complete && consecutiveFailures < maxRetries) { 311 try { 312 // Run one frame in fresh process 313 // Use single quotes to prevent shell variable expansion of $ceo, etc. 314 const densityArg = this.density ? ` ${this.density}` : ''; 315 const fpsArg = ` ${this.nativeFps}`; 316 const command = `node ${this.frameRendererPath} '${actualPiece}' ${this.frames} '${this.outputDir}' ${this.width} ${this.height}${densityArg}${fpsArg}`; 317 console.log(`🔧 Executing: ${command}`); 318 const env = { ...process.env }; 319 if (this.debugInkColors) { 320 env.AC_LOG_INK_COLORS = '1'; 321 env.AC_LOG_INK_LABEL = this.piece; 322 } 323 const result = execSync( 324 command, 325 { encoding: 'utf8', stdio: 'pipe', env } 326 ); 327 328 // Show frame renderer output in a controlled way 329 if (result && result.length > 0) { 330 // Only show important messages, filter out verbose output 331 const lines = result.split('\n'); 332 const importantLines = lines.filter(line => 333 line.includes('FRAME') || 334 line.includes('ERROR') || 335 line.includes('WARNING') || 336 line.includes('✅') || 337 line.includes('❌') 338 ); 339 if (importantLines.length > 0) { 340 console.log(importantLines.join('\n')); 341 } 342 } 343 344 frameCount++; 345 consecutiveFailures = 0; // Reset on success 346 347 // Check if complete by looking for state 348 const stateFile = path.join(this.outputDir, 'state.json'); 349 if (fs.existsSync(stateFile)) { 350 const state = JSON.parse(fs.readFileSync(stateFile, 'utf8')); 351 complete = state.frameIndex >= state.totalFrames; 352 353 // Display current frame as full-screen sixel 354 await this.displayLastFrameAsSixel(); 355 356 // Update static progress bar 357 this.updateProgressBar(state.frameIndex, state.totalFrames, startTime); 358 } 359 360 } catch (error) { 361 consecutiveFailures++; 362 console.error(`💥 Frame ${frameCount} failed (${consecutiveFailures}/${maxRetries}):`, error.message); 363 364 if (consecutiveFailures >= maxRetries) { 365 console.error(`🚫 Too many consecutive failures. Aborting render.`); 366 return; 367 } 368 } 369 } 370 371 if (complete) { 372 console.log(`🎯 Render complete! ${frameCount} frames rendered with zero memory leaks.`); 373 374 // Convert to requested format 375 if (this.gifMode) { 376 console.log(`�️ Target GIF frame rate: ${this.gifFps}fps (quantized to 10ms steps)`); 377 console.log(`�🎬 Converting frames to GIF...`); 378 await this.convertToGIF(); 379 } else { 380 console.log(`🎬 Converting frames to MP4...`); 381 await this.convertToMP4(); 382 } 383 384 // Cleanup temporary kidlisp piece file if it was created 385 if (this.isKidlispPiece && actualPiece !== this.piece) { 386 try { 387 fs.unlinkSync(actualPiece); 388 console.log(`🗑️ Cleaned up temporary kidlisp piece file`); 389 } catch (error) { 390 console.warn(`⚠️ Could not cleanup temporary file: ${error.message}`); 391 } 392 } 393 } 394 } 395 396 async convertToMP4() { 397 console.log(`🎬 Converting frames to MP4...`); 398 399 try { 400 // First concatenate all frame files into one RGB file 401 console.log(`📦 Concatenating frame files...`); 402 const allFramesFile = path.join(this.outputDir, 'all-frames.rgb'); 403 execSync(`cat ${this.outputDir}/frame-*.rgb > ${allFramesFile}`, { stdio: 'inherit' }); 404 405 // Generate proper filename using AC naming scheme 406 const pieceName = this.isKidlispPiece ? this.piece.replace('$', '') : path.basename(this.piece, '.mjs'); 407 const timestampStr = timestamp(); 408 const durationSeconds = Math.round(this.frames / this.nativeFps * 10) / 10; // Convert frames to seconds at native fps, round to 1 decimal place 409 const filename = `${pieceName}-${timestampStr}-${durationSeconds}s.mp4`; 410 // Save MP4 one level up from the artifacts directory for better organization 411 const outputMP4 = path.join(path.dirname(this.outputDir), filename); 412 413 // Get frame dimensions from state file for proper resolution 414 const stateFile = path.join(this.outputDir, 'state.json'); 415 let width = 2048, height = 2048; // defaults 416 if (fs.existsSync(stateFile)) { 417 const state = JSON.parse(fs.readFileSync(stateFile, 'utf8')); 418 width = state.width || 2048; 419 height = state.height || 2048; 420 } 421 422 const renderWidth = width; 423 const renderHeight = height; 424 425 let targetWidth = this.width || renderWidth; 426 let targetHeight = this.height || renderHeight; 427 428 if (this.density) { 429 targetWidth = Math.max(1, Math.round(targetWidth * this.density)); 430 targetHeight = Math.max(1, Math.round(targetHeight * this.density)); 431 } 432 433 let vfFilter = ''; 434 if (this.density) { 435 const baseWidth = Math.max(1, Math.round(targetWidth / this.density)); 436 const baseHeight = Math.max(1, Math.round(targetHeight / this.density)); 437 vfFilter = ` -vf "scale=${baseWidth}:${baseHeight}:flags=neighbor,scale=${targetWidth}:${targetHeight}:flags=neighbor"`; 438 console.log(`🔍 Density ${this.density}: Rendering at ${renderWidth}x${renderHeight} → chunky ${baseWidth}x${baseHeight} → final ${targetWidth}x${targetHeight} (${this.density}x pixel chunks)`); 439 } else if (targetWidth !== renderWidth || targetHeight !== renderHeight) { 440 vfFilter = ` -vf "scale=${targetWidth}:${targetHeight}:flags=lanczos"`; 441 console.log(`🔍 Scaling MP4: ${renderWidth}x${renderHeight}${targetWidth}x${targetHeight} with smooth interpolation`); 442 } else { 443 console.log(`🔍 MP4 at native resolution ${renderWidth}x${renderHeight}`); 444 } 445 446 execSync( 447 `ffmpeg -y -f rawvideo -pix_fmt rgb24 -s ${renderWidth}x${renderHeight} -r ${this.nativeFps} ` + 448 `-i ${allFramesFile} -c:v libx264 -pix_fmt yuv420p` + 449 `${vfFilter} -movflags +faststart -r ${this.nativeFps} -b:v 32M ${outputMP4}`, 450 { stdio: 'inherit' } 451 ); 452 453 console.log(`✅ MP4 created: ${filename}`); 454 455 } catch (error) { 456 console.error(`💥 MP4 conversion failed:`, error.message); 457 } 458 } 459 460 async convertToGIF() { 461 console.log(`🎞️ Converting frames to GIF with ffmpeg Floyd-Steinberg dithering...`); 462 463 try { 464 // Get frame dimensions from state file 465 const stateFile = path.join(this.outputDir, 'state.json'); 466 let width = 2048, height = 2048; // defaults 467 let sourceFps = this.nativeFps; // Use the actual rendering fps 468 if (fs.existsSync(stateFile)) { 469 const state = JSON.parse(fs.readFileSync(stateFile, 'utf8')); 470 width = state.width || 2048; 471 height = state.height || 2048; 472 sourceFps = state.fps || this.nativeFps; 473 } 474 475 const renderWidth = width; 476 const renderHeight = height; 477 478 let targetWidth = this.width || renderWidth; 479 let targetHeight = this.height || renderHeight; 480 481 if (this.density) { 482 targetWidth = Math.max(1, Math.round(targetWidth * this.density)); 483 targetHeight = Math.max(1, Math.round(targetHeight * this.density)); 484 } 485 486 // Get all RGB frame files 487 const rgbFiles = fs.readdirSync(this.outputDir) 488 .filter(file => file.endsWith('.rgb')) 489 .sort((a, b) => { 490 const aFrame = parseInt(a.match(/frame-(\d+)\.rgb/)[1]); 491 const bFrame = parseInt(b.match(/frame-(\d+)\.rgb/)[1]); 492 return aFrame - bFrame; 493 }); 494 495 if (rgbFiles.length === 0) { 496 throw new Error("No RGB frames found for conversion"); 497 } 498 499 console.log(`🎨 Converting ${rgbFiles.length} RGB frames to PNG for ffmpeg...`); 500 501 // Create temporary directory for PNG frames 502 const tempDir = path.join(this.outputDir, 'png_frames'); 503 if (!fs.existsSync(tempDir)) { 504 fs.mkdirSync(tempDir); 505 } 506 507 // Convert RGB files to PNG files using ffmpeg 508 for (let i = 0; i < rgbFiles.length; i++) { 509 const rgbFile = path.join(this.outputDir, rgbFiles[i]); 510 const frameNumber = String(i).padStart(6, '0'); 511 const pngFile = path.join(tempDir, `frame_${frameNumber}.png`); 512 513 // Use ffmpeg to convert RGB to PNG directly 514 const ffmpegCmd = `ffmpeg -f rawvideo -pix_fmt rgb24 -s ${width}x${height} -i "${rgbFile}" -y "${pngFile}" 2>/dev/null`; 515 execSync(ffmpegCmd); 516 517 // Progress indicator 518 if (i % 10 === 0) { 519 process.stdout.write(`\r🖼️ Converting frame ${i + 1}/${rgbFiles.length} to PNG`); 520 } 521 } 522 523 console.log(`\n🎬 Creating GIF with ffmpeg Floyd-Steinberg dithering...`); 524 525 // Generate proper filename 526 const pieceName = this.isKidlispPiece ? this.piece.replace('$', '') : path.basename(this.piece, '.mjs'); 527 const timestampStr = timestamp(); 528 const targetFps = this.gifFps || 50; 529 const durationSeconds = Math.round((rgbFiles.length / sourceFps) * 10) / 10; 530 const filename = `${pieceName}-${timestampStr}-${durationSeconds}s.gif`; 531 const outputGIF = path.join(path.dirname(this.outputDir), filename); 532 533 console.log(`🎬 Native rendering: ${rgbFiles.length} frames at ${sourceFps}fps → direct GIF conversion (no resampling)`); 534 535 // Create optimal palette with compression-focused settings 536 if (this.gifCompress) { 537 console.log(`🗜️ Generating ultra-compressed palette with smart optimization...`); 538 } else { 539 console.log(`🎨 Generating optimized palette for maximum compression...`); 540 } 541 // Use fewer colors for compress mode, but with better stats analysis 542 const maxColors = this.gifCompress ? 96 : 128; 543 const statsMode = this.gifCompress ? "full" : "diff"; 544 const paletteCmd = `ffmpeg -y -framerate ${sourceFps} -i "${tempDir}/frame_%06d.png" -vf "scale=-1:-1:flags=lanczos,palettegen=reserve_transparent=0:max_colors=${maxColors}:stats_mode=${statsMode}" "${tempDir}/palette.png" 2>/dev/null`; 545 execSync(paletteCmd); 546 547 // Create final GIF with optimized compression settings 548 if (this.gifCompress) { 549 console.log(`🗜️ Applying ULTRA compression (optimized palette + Floyd-Steinberg)...`); 550 } else { 551 console.log(`🗜️ Applying optimized compression and dithering...`); 552 } 553 554 let scaleFilter = `scale=${targetWidth}:${targetHeight}:flags=lanczos`; 555 556 if (this.density) { 557 // Calculate the render resolution based on density to create chunky pixels before scaling back up 558 const baseWidth = Math.max(1, Math.round(targetWidth / this.density)); 559 const baseHeight = Math.max(1, Math.round(targetHeight / this.density)); 560 scaleFilter = `scale=${baseWidth}:${baseHeight}:flags=neighbor,scale=${targetWidth}:${targetHeight}:flags=neighbor`; 561 console.log(`🔍 Density ${this.density}: Rendering at ${renderWidth}x${renderHeight} → chunky ${baseWidth}x${baseHeight} → final ${targetWidth}x${targetHeight} (${this.density}x pixel chunks)`); 562 } else if (targetWidth !== renderWidth || targetHeight !== renderHeight) { 563 console.log(`🔍 No density specified: Scaling ${renderWidth}x${renderHeight}${targetWidth}x${targetHeight} with smooth interpolation`); 564 } else { 565 console.log(`🔍 No scaling applied: keeping native resolution ${renderWidth}x${renderHeight}`); 566 } 567 568 // Floyd-Steinberg usually compresses better than no dithering or bayer 569 const ditheringOptions = this.gifCompress 570 ? "paletteuse=dither=floyd_steinberg" 571 : "paletteuse=dither=floyd_steinberg:bayer_scale=3"; 572 573 const gifCmd = `ffmpeg -y -framerate ${sourceFps} -i "${tempDir}/frame_%06d.png" -i "${tempDir}/palette.png" -lavfi "${scaleFilter}[x];[x][1:v]${ditheringOptions}" "${outputGIF}" 2>/dev/null`; 574 execSync(gifCmd); 575 576 // Extract icon frame if requested 577 if (this.extractIconFrame) { 578 const iconOutputDir = this.iconOutputDir || this.outputDir; 579 await this.extractMidpointFrameAsIcon(tempDir, iconOutputDir); 580 } 581 582 // Clean up temporary files 583 console.log(`🧹 Cleaning up temporary files...`); 584 execSync(`rm -rf "${tempDir}"`); 585 586 // Get final file size and log success 587 const stats = fs.statSync(outputGIF); 588 const fileSizeKB = Math.round(stats.size / 1024); 589 console.log(`✅ GIF created: ${filename}`); 590 console.log(`📊 File size: ${fileSizeKB}KB`); 591 if (this.gifCompress) { 592 console.log(`🗜️ Ultra-compressed GIF with minimal file size!`); 593 } else { 594 console.log(`🗜️ Optimized GIF with maximum compression!`); 595 } 596 597 } catch (error) { 598 console.error(`💥 GIF conversion failed:`, error.message); 599 throw error; 600 } 601 } 602 603 async extractMidpointFrameAsIcon(tempDir, outputDir) { 604 try { 605 console.log(`🎨 Extracting midpoint frame as icon...`); 606 607 // Find all PNG files in temp directory 608 const pngFiles = fs.readdirSync(tempDir) 609 .filter(file => file.endsWith('.png') && file.startsWith('frame_')) 610 .sort((a, b) => { 611 const aFrame = parseInt(a.match(/frame_(\d+)\.png/)[1]); 612 const bFrame = parseInt(b.match(/frame_(\d+)\.png/)[1]); 613 return aFrame - bFrame; 614 }); 615 616 if (pngFiles.length === 0) { 617 console.warn(`⚠️ No PNG frames found for icon extraction`); 618 return; 619 } 620 621 // Get midpoint frame 622 const midpointIndex = Math.floor(pngFiles.length / 2); 623 const midpointFrame = pngFiles[midpointIndex]; 624 const sourcePath = path.join(tempDir, midpointFrame); 625 626 // Create icon directories for different sizes 627 // Get piece name for icon filename 628 const pieceName = this.isKidlispPiece ? this.piece.replace('$', '') : path.basename(this.piece, '.mjs'); 629 630 // Create both 256x256 and 512x512 versions for better compatibility 631 const iconDir256 = path.join(outputDir, 'icon', '256x256'); 632 const iconDir512 = path.join(outputDir, 'icon', '512x512'); 633 634 if (!fs.existsSync(iconDir256)) { 635 fs.mkdirSync(iconDir256, { recursive: true }); 636 } 637 if (!fs.existsSync(iconDir512)) { 638 fs.mkdirSync(iconDir512, { recursive: true }); 639 } 640 641 // Create 256x256 PNG using piece name 642 const iconPath256 = path.join(iconDir256, `${pieceName}.png`); 643 const iconPath512 = path.join(iconDir512, `${pieceName}.png`); 644 645 try { 646 // Create 256x256 version 647 const ffmpegCmd256 = `ffmpeg -i "${sourcePath}" -vf "scale=256:256:flags=neighbor" -y "${iconPath256}" 2>/dev/null`; 648 execSync(ffmpegCmd256); 649 650 // Create 512x512 version 651 const ffmpegCmd512 = `ffmpeg -i "${sourcePath}" -vf "scale=512:512:flags=neighbor" -y "${iconPath512}" 2>/dev/null`; 652 execSync(ffmpegCmd512); 653 654 console.log(`✅ Icon extracted from frame ${midpointIndex + 1}/${pngFiles.length}: icon/256x256/${pieceName}.png and icon/512x512/${pieceName}.png created`); 655 } catch (error) { 656 // Fallback: copy original and create basic versions 657 fs.copyFileSync(sourcePath, iconPath256); 658 fs.copyFileSync(sourcePath, iconPath512); 659 console.log(`✅ Icon extracted from frame ${midpointIndex + 1}/${pngFiles.length}: icon/256x256/${pieceName}.png and icon/512x512/${pieceName}.png created (fallback)`); 660 } 661 662 } catch (error) { 663 console.warn(`⚠️ Could not extract icon frame: ${error.message}`); 664 } 665 } 666 667 updateProgressBar(currentFrame, totalFrames, startTime) { 668 const progress = currentFrame / totalFrames; 669 const barWidth = 20; 670 const filled = Math.floor(progress * barWidth); 671 const empty = barWidth - filled; 672 673 const bar = '█'.repeat(filled) + '░'.repeat(empty); 674 const percentage = (progress * 100).toFixed(1); 675 676 // Calculate elapsed time and estimated remaining time 677 const elapsed = Date.now() - startTime; 678 const estimatedTotal = elapsed / progress; 679 const remaining = estimatedTotal - elapsed; 680 681 const formatTime = (ms) => { 682 const seconds = Math.floor(ms / 1000); 683 const minutes = Math.floor(seconds / 60); 684 const hours = Math.floor(minutes / 60); 685 686 if (hours > 0) return `${hours}h ${minutes % 60}m ${seconds % 60}s`; 687 if (minutes > 0) return `${minutes}m ${seconds % 60}s`; 688 return `${seconds}s`; 689 }; 690 691 // Clear line and print progress 692 process.stdout.write('\r'); 693 process.stdout.write(`🎬 ${bar} ${percentage}% (${currentFrame}/${totalFrames} frames) `); 694 process.stdout.write(`⏱️ ${formatTime(elapsed)} elapsed`); 695 if (progress > 0.01) { // Only show ETA after 1% progress 696 process.stdout.write(` | ETA: ${formatTime(remaining)}`); 697 } 698 } 699 700 // Display last frame as sixel image inline in terminal 701 async displayLastFrameAsSixel() { 702 try { 703 // Find the last individual RGB frame file (exclude all-frames.rgb) 704 const rgbFiles = fs.readdirSync(this.outputDir) 705 .filter(f => f.endsWith('.rgb') && f.startsWith('frame-')) 706 .sort(); 707 708 if (rgbFiles.length === 0) { 709 return; // No frames yet, skip silently 710 } 711 712 const lastFrame = rgbFiles[rgbFiles.length - 1]; 713 const framePath = path.join(this.outputDir, lastFrame); 714 715 // Read the RGB data 716 const rgbData = fs.readFileSync(framePath); 717 718 // Get terminal dimensions 719 const termWidth = process.stdout.columns || 80; 720 const termHeight = process.stdout.rows || 24; 721 722 // Calculate optimal size to fill most of the terminal 723 // Use very aggressive scaling for large, visually prominent images 724 const maxWidth = Math.floor(termWidth * 2.5); // Even wider (sixel pixels are very narrow) 725 const maxHeight = Math.floor(termHeight * 4.0); // Much taller for visual impact 726 727 // Generate and display sixel at large terminal size 728 const sixelData = this.generateSixel(rgbData, this.width, this.height, maxWidth, maxHeight); 729 730 // Calculate actual sixel display dimensions (we want it large!) 731 const aspectRatio = this.height / this.width; 732 const displayWidth = maxWidth; // Use the full available width 733 const displayHeight = Math.floor(displayWidth * aspectRatio); 734 const sixelHeight = Math.ceil(displayHeight / 6); // Sixel is 6 pixels per line 735 736 // Enter alternative screen buffer for clean full-screen display 737 process.stdout.write('\x1b[?1049h'); // Enter alt screen buffer 738 process.stdout.write('\x1b[2J\x1b[H'); // Clear screen and move to top-left 739 740 // Calculate optical centering 741 const imageHeightInLines = Math.ceil(displayHeight / 6); // Sixel uses 6-pixel bands 742 const verticalPadding = Math.max(1, Math.floor((termHeight - imageHeightInLines) / 2)); 743 744 // Better horizontal centering calculation 745 // Sixel pixels are about 1/2 the width of a character cell in most terminals 746 const estimatedSixelWidthInChars = Math.floor(displayWidth / 12); // More accurate estimate 747 const horizontalPadding = Math.max(1, Math.floor((termWidth - estimatedSixelWidthInChars) / 2)); 748 749 // Position cursor for optically centered display 750 process.stdout.write(`\x1b[${verticalPadding};${horizontalPadding}H`); 751 752 // Display the sixel image 753 process.stdout.write(sixelData); 754 755 // Hold the frame for half a second 756 await new Promise(resolve => setTimeout(resolve, 500)); 757 758 // Return to main screen buffer 759 process.stdout.write('\x1b[?1049l'); // Exit alt screen buffer 760 761 } catch (error) { 762 console.error('🚨 Sixel display error:', error.message); 763 } 764 } 765 766 // Generate sixel data from RGB buffer (based on ultra-fast-sixel.mjs) 767 generateSixel(rgbBuffer, width, height, maxWidth = 128, maxHeight = 128) { 768 // Scale to fit within terminal dimensions while maintaining aspect ratio 769 const aspectRatio = height / width; 770 let targetWidth = Math.min(maxWidth, width); 771 let targetHeight = Math.floor(targetWidth * aspectRatio); 772 773 // If height is too large, scale based on height instead 774 if (targetHeight > maxHeight) { 775 targetHeight = maxHeight; 776 targetWidth = Math.floor(targetHeight / aspectRatio); 777 } 778 779 const scaledWidth = targetWidth; 780 const scaledHeight = targetHeight; 781 782 // Calculate scaling factors 783 const scaleX = width / scaledWidth; 784 const scaleY = height / scaledHeight; 785 786 // Create scaled buffer 787 const scaledBuffer = new Uint8Array(scaledWidth * scaledHeight * 3); 788 789 for (let y = 0; y < scaledHeight; y++) { 790 for (let x = 0; x < scaledWidth; x++) { 791 const srcX = Math.floor(x * scaleX); 792 const srcY = Math.floor(y * scaleY); 793 const srcIdx = (srcY * width + srcX) * 3; 794 const dstIdx = (y * scaledWidth + x) * 3; 795 796 scaledBuffer[dstIdx] = rgbBuffer[srcIdx]; 797 scaledBuffer[dstIdx + 1] = rgbBuffer[srcIdx + 1]; 798 scaledBuffer[dstIdx + 2] = rgbBuffer[srcIdx + 2]; 799 } 800 } 801 802 // Generate sixel without automatic positioning (let caller handle positioning) 803 let output = '\x1bPq'; 804 805 const colors = new Map(); 806 let colorIndex = 0; 807 808 // Process pixels in sixel bands (6 pixels high) 809 for (let band = 0; band < Math.ceil(scaledHeight / 6); band++) { 810 const bandData = new Map(); 811 812 // Collect colors for this band 813 for (let x = 0; x < scaledWidth; x++) { 814 for (let dy = 0; dy < 6; dy++) { 815 const y = band * 6 + dy; 816 if (y >= scaledHeight) break; 817 818 const idx = (y * scaledWidth + x) * 3; 819 const r = scaledBuffer[idx]; 820 const g = scaledBuffer[idx + 1]; 821 const b = scaledBuffer[idx + 2]; 822 823 const colorKey = `${r},${g},${b}`; 824 825 if (!colors.has(colorKey)) { 826 colors.set(colorKey, colorIndex); 827 // Define color in sixel format 828 output += `#${colorIndex};2;${Math.round(r * 100 / 255)};${Math.round(g * 100 / 255)};${Math.round(b * 100 / 255)}`; 829 colorIndex++; 830 } 831 832 const color = colors.get(colorKey); 833 834 if (!bandData.has(color)) { 835 bandData.set(color, new Array(scaledWidth).fill(0)); 836 } 837 838 // Set the bit for this pixel in the sixel band 839 bandData.get(color)[x] |= (1 << dy); 840 } 841 } 842 843 // Output band data 844 for (const [color, pixels] of bandData) { 845 output += `#${color}`; 846 for (let x = 0; x < scaledWidth; x++) { 847 output += String.fromCharCode(63 + pixels[x]); 848 } 849 output += '$'; 850 } 851 output += '-'; // New line 852 } 853 854 return output + '\x1b\\'; // End sixel sequence 855 } 856} 857 858function parseFrameCountArg(rawValue) { 859 const defaultResult = () => ({ 860 frames: DEFAULT_FRAME_COUNT, 861 seconds: DEFAULT_FRAME_COUNT / FRAMES_PER_SECOND, 862 }); 863 864 if (rawValue === undefined || rawValue === null) { 865 return defaultResult(); 866 } 867 868 if (typeof rawValue === 'string' && rawValue.startsWith('--')) { 869 return defaultResult(); 870 } 871 872 const normalized = String(rawValue).trim().toLowerCase(); 873 if (!normalized) { 874 return defaultResult(); 875 } 876 877 let value = normalized; 878 let unit = 'frames'; 879 880 if (value.endsWith('s')) { 881 unit = 'seconds'; 882 value = value.slice(0, -1); 883 } else if (value.endsWith('f')) { 884 value = value.slice(0, -1); 885 } 886 887 const numeric = Number(value); 888 if (!Number.isFinite(numeric)) { 889 throw new Error(`Invalid duration value: ${rawValue}`); 890 } 891 892 const frames = unit === 'seconds' 893 ? Math.max(1, Math.round(numeric * FRAMES_PER_SECOND)) 894 : Math.max(1, Math.round(numeric)); 895 896 return { 897 frames, 898 seconds: frames / FRAMES_PER_SECOND, 899 }; 900} 901 902// CLI usage 903if (import.meta.url === `file://${process.argv[1]}`) { 904 const rawArgs = process.argv.slice(2); 905 const positional = []; 906 const options = { 907 gifMode: false, 908 density: null, 909 gifFps: null, 910 }; 911 912 for (let i = 0; i < rawArgs.length; i++) { 913 const arg = rawArgs[i]; 914 if (arg === '--gif') { 915 options.gifMode = true; 916 continue; 917 } 918 if (arg === '--density') { 919 const value = rawArgs[i + 1]; 920 if (!value || value.startsWith('--')) { 921 console.error('❌ --density flag requires a numeric value'); 922 process.exit(1); 923 } 924 options.density = parseFloat(value); 925 if (Number.isNaN(options.density) || options.density <= 0) { 926 console.error(`❌ Invalid density value: ${value}`); 927 process.exit(1); 928 } 929 i += 1; 930 continue; 931 } 932 if (arg === '--gif-fps') { 933 const value = rawArgs[i + 1]; 934 if (!value || value.startsWith('--')) { 935 console.error('❌ --gif-fps flag requires a numeric value'); 936 process.exit(1); 937 } 938 const parsed = parseInt(value, 10); 939 if (Number.isNaN(parsed) || parsed <= 0) { 940 console.error(`❌ Invalid GIF fps value: ${value}`); 941 process.exit(1); 942 } 943 options.gifFps = parsed; 944 i += 1; 945 continue; 946 } 947 if (arg === '--gif-25' || arg === '--gif25') { 948 options.gifFps = 25; 949 continue; 950 } 951 if (arg === '--gif-50' || arg === '--gif50') { 952 options.gifFps = 50; 953 continue; 954 } 955 if (arg === '--gif-compress') { 956 options.gifCompress = true; 957 continue; 958 } 959 if (arg === '--sixel' || arg === 'sixel') { 960 continue; 961 } 962 positional.push(arg); 963 } 964 965 let piece = positional[0] || 'elcid-flyer'; 966 let durationArg = positional[1]; 967 968 let frameInfo; 969 try { 970 frameInfo = parseFrameCountArg(durationArg); 971 } catch (error) { 972 console.error(`${error.message}`); 973 process.exit(1); 974 } 975 976 const { frames, seconds: durationSecondsRaw } = frameInfo; 977 // Allows values such as "3s" (seconds) or "180f" (frames); defaults to 300 frames (~5s) 978 const width = parseInt(positional[2]) || 1024; 979 const height = parseInt(positional[3]) || 1024; 980 const durationSecondsLabel = Math.round(durationSecondsRaw * 10) / 10; 981 982 // Handle kidlisp pieces (starting with $) 983 if (piece.startsWith('$')) { 984 console.log(`🎨 KidLisp piece detected: ${piece}`); 985 // Keep piece as-is for kidlisp pieces 986 } else { 987 // Auto-resolve piece paths: if it's just a name, look in pieces/ folder 988 if (piece && !piece.includes('/') && !piece.endsWith('.mjs')) { 989 piece = path.join(__dirname, 'pieces', `${piece}.mjs`); 990 } else if (piece && !piece.includes('/') && piece.endsWith('.mjs')) { 991 piece = path.join(__dirname, 'pieces', piece); 992 } 993 // Otherwise use the piece path as-is (for absolute/relative paths) 994 } 995 996 // Create project-specific directory with piece name automatically 997 let outputDir; 998 // Extract piece name from path, handling kidlisp pieces specially 999 let pieceName; 1000 if (piece.startsWith('$')) { 1001 pieceName = piece.replace('$', ''); // Remove $ for directory name 1002 } else { 1003 pieceName = path.basename(piece, '.mjs'); 1004 } 1005 // Create proper output directory with timestamp matching MP4 naming pattern 1006 const timestampStr = timestamp(); // Use AC timestamp format: YYYY.MM.DD.HH.MM.SS.mmm 1007 const durationSeconds = durationSecondsLabel; // Rounded to tenths for directory naming 1008 outputDir = path.resolve(__dirname, `../output/${pieceName}-${timestampStr}-${durationSecondsLabel}s`); 1009 1010 // Ensure output directory exists 1011 if (!fs.existsSync(outputDir)) { 1012 fs.mkdirSync(outputDir, { recursive: true }); 1013 console.log(`📁 Created output directory: ${outputDir}`); 1014 } 1015 1016 let normalizedGifFps = options.gifFps; 1017 if (normalizedGifFps !== null && !ALLOWED_GIF_FPS.includes(normalizedGifFps)) { 1018 const nearest = ALLOWED_GIF_FPS.reduce((prev, curr) => { 1019 return Math.abs(curr - normalizedGifFps) < Math.abs(prev - normalizedGifFps) ? curr : prev; 1020 }, ALLOWED_GIF_FPS[0]); 1021 console.warn(`⚠️ GIF fps ${normalizedGifFps} unsupported. Using nearest supported value: ${nearest}`); 1022 normalizedGifFps = nearest; 1023 } 1024 1025 const orchestrator = new RenderOrchestrator(piece, frames, outputDir, width, height, { gifMode: options.gifMode, density: options.density, gifFps: normalizedGifFps, gifCompress: options.gifCompress }); 1026 orchestrator.renderAll().catch(console.error); 1027} 1028 1029export { RenderOrchestrator };