Monorepo for Aesthetic.Computer
aesthetic.computer
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 };