Monorepo for Aesthetic.Computer
aesthetic.computer
1#!/usr/bin/env node
2
3// ac-pack: Package aesthetic.computer pieces for Teia Interactive OBJKTs
4// Usage: node ac-pack.mjs <piece-name> [options]
5
6import { promises as fs } from "fs";
7import fsSync from "fs";
8import path from "path";
9import { fileURLToPath } from "url";
10import { spawn } from "child_process";
11import { extractCodes, fetchAllCodes, generateCacheCode } from "./kidlisp-extractor.mjs";
12import { execSync } from "child_process";
13import os from "os";
14import { once } from "events";
15import readline from "readline";
16
17const __filename = fileURLToPath(import.meta.url);
18const __dirname = path.dirname(__filename);
19
20const DEFAULT_TIME_ZONE =
21 process.env.AC_PACK_TZ || process.env.TZ || "America/Los_Angeles";
22
23// Helper function for interactive prompts
24function askQuestion(query) {
25 const rl = readline.createInterface({
26 input: process.stdin,
27 output: process.stdout,
28 });
29
30 return new Promise(resolve => rl.question(query, answer => {
31 rl.close();
32 resolve(answer);
33 }));
34}
35
36function getTimestampParts(date = new Date(), timeZone = DEFAULT_TIME_ZONE) {
37 const formatter = new Intl.DateTimeFormat("en-CA", {
38 timeZone,
39 year: "numeric",
40 month: "2-digit",
41 day: "2-digit",
42 hour: "2-digit",
43 minute: "2-digit",
44 second: "2-digit",
45 hour12: false,
46 fractionalSecondDigits: 3,
47 });
48
49 const parts = formatter.formatToParts(date).reduce((acc, part) => {
50 if (part.type !== "literal") {
51 acc[part.type] = part.value;
52 }
53 return acc;
54 }, {});
55
56 if (!parts.fractionalSecond) {
57 parts.fractionalSecond = String(date.getMilliseconds()).padStart(3, "0");
58 }
59
60 return parts;
61}
62
63function formatTimestampForFile(date = new Date(), timeZone = DEFAULT_TIME_ZONE) {
64 const parts = getTimestampParts(date, timeZone);
65 return `${parts.year}.${parts.month}.${parts.day}.${parts.hour}.${parts.minute}.${parts.second}.${parts.fractionalSecond}`;
66}
67
68function formatDateTimeForDisplay(date = new Date(), timeZone = DEFAULT_TIME_ZONE) {
69 const formatter = new Intl.DateTimeFormat("en-US", {
70 timeZone,
71 year: "numeric",
72 month: "2-digit",
73 day: "2-digit",
74 hour: "2-digit",
75 minute: "2-digit",
76 second: "2-digit",
77 hour12: true,
78 timeZoneName: "short",
79 });
80
81 return formatter.format(date);
82}
83
84// Get git information for colophonic data
85function getGitInfo() {
86 try {
87 const gitDir = path.join(__dirname, "..");
88 const commit = execSync("git rev-parse HEAD", { cwd: gitDir, encoding: "utf8" }).trim();
89 const shortCommit = commit.substring(0, 7);
90 const branch = execSync("git rev-parse --abbrev-ref HEAD", { cwd: gitDir, encoding: "utf8" }).trim();
91 const commitDate = execSync("git show -s --format=%ci HEAD", { cwd: gitDir, encoding: "utf8" }).trim();
92 const isDirty = execSync("git status --porcelain", { cwd: gitDir, encoding: "utf8" }).trim().length > 0;
93
94 return {
95 commit,
96 shortCommit,
97 branch,
98 commitDate,
99 isDirty,
100 repoUrl: "https://github.com/digitpain/aesthetic.computer"
101 };
102 } catch (error) {
103 console.log("ℹ️ Could not retrieve git information:", error.message);
104 return {
105 commit: "unknown",
106 shortCommit: "unknown",
107 branch: "unknown",
108 commitDate: "unknown",
109 isDirty: false,
110 repoUrl: "https://github.com/digitpain/aesthetic.computer"
111 };
112 }
113}
114
115// Configuration
116const SYSTEM_DIR = path.join(__dirname, "..", "system");
117const PUBLIC_DIR = path.join(SYSTEM_DIR, "public");
118const AC_DIR = path.join(PUBLIC_DIR, "aesthetic.computer");
119const DISKS_DIR = path.join(AC_DIR, "disks");
120const TOKENS_DIR = path.join(__dirname, "output");
121const PACKAGED_SYSTEM_DIR_NAME = "ac";
122
123class AcPacker {
124 constructor(pieceName, options = {}) {
125 this.pieceName = pieceName;
126 this.timeZone = options.timeZone || DEFAULT_TIME_ZONE;
127 this.buildDate = new Date();
128 this.zipTimestamp = formatTimestampForFile(this.buildDate, this.timeZone); // Generate timestamp once for consistent naming
129 this.packagedSystemDirName = options.packagedSystemDirName || PACKAGED_SYSTEM_DIR_NAME;
130 this.packagedSystemBaseHref = `./${this.packagedSystemDirName}`;
131 // Sanitize piece name for directory creation (remove $ and other shell-problematic characters)
132 const sanitizedPieceName = pieceName.replace(/[$]/g, '');
133 const rawCoverDuration = options.coverDurationSeconds ?? options.gifLengthSeconds;
134 let coverDurationSeconds = Number(rawCoverDuration);
135 if (!Number.isFinite(coverDurationSeconds) || coverDurationSeconds <= 0) {
136 coverDurationSeconds = 3;
137 }
138 const coverFrameCount = Math.max(1, Math.round(coverDurationSeconds * 60));
139 this.options = {
140 ...options,
141 outputDir: path.join(options.targetDir || TOKENS_DIR, sanitizedPieceName),
142 targetDir: options.targetDir || TOKENS_DIR, // Directory where final artifacts should be placed
143 coverImage: options.coverImage || "cover.gif", // Default to GIF, fallback to SVG handled in generateCover
144 faviconImage: options.faviconImage || `${this.packagedSystemBaseHref}/favicon.png`, // Default favicon, updated to GIF if available
145 title: options.title || pieceName,
146 description: options.description || `Interactive ${pieceName} piece from aesthetic.computer`,
147 author: options.author || "@jeffrey",
148 verbose: options.verbose === true,
149 logInkColors: options.logInkColors === true,
150 coverDurationSeconds,
151 coverFrameCount,
152 timeZone: this.timeZone,
153 packagedSystemDirName: this.packagedSystemDirName,
154 tapePath: options.tapePath ? path.resolve(options.tapePath) : undefined,
155 };
156 this.bundledFiles = new Set();
157 this.tempDirs = [];
158 }
159
160 getPackagedDirPath(...segments) {
161 return path.join(this.options.outputDir, this.packagedSystemDirName, ...segments);
162 }
163
164 getPackagedRelativePath(...segments) {
165 return `./${path.posix.join(this.packagedSystemDirName, ...segments)}`;
166 }
167
168 logVerbose(...args) {
169 if (this.options.verbose) {
170 console.log(...args);
171 }
172 }
173
174 async getGifEncoderClass() {
175 if (!this.GIFEncoderClass) {
176 const gifModule = await import("gif-encoder-2");
177 const GIFEncoder = gifModule.default || gifModule.GIFEncoder || gifModule;
178 if (!GIFEncoder) {
179 throw new Error("Failed to load gif-encoder-2 module");
180 }
181 this.GIFEncoderClass = GIFEncoder;
182 }
183 return this.GIFEncoderClass;
184 }
185
186 async getPNGClass() {
187 if (!this.PNGClass) {
188 const pngModule = await import("pngjs");
189 const PNG = pngModule.PNG || (pngModule.default && pngModule.default.PNG) || pngModule.default;
190 if (!PNG || !PNG.sync || !PNG.sync.read || !PNG.sync.write) {
191 throw new Error("Failed to load pngjs PNG reader");
192 }
193 this.PNGClass = PNG;
194 }
195 return this.PNGClass;
196 }
197
198 scaleImageNearest(srcData, srcWidth, srcHeight, targetWidth, targetHeight) {
199 const output = Buffer.alloc(targetWidth * targetHeight * 4);
200
201 for (let y = 0; y < targetHeight; y++) {
202 const srcY = Math.min(srcHeight - 1, Math.floor(((y + 0.5) * srcHeight) / targetHeight));
203 for (let x = 0; x < targetWidth; x++) {
204 const srcX = Math.min(srcWidth - 1, Math.floor(((x + 0.5) * srcWidth) / targetWidth));
205 const srcIndex = (srcY * srcWidth + srcX) * 4;
206 const destIndex = (y * targetWidth + x) * 4;
207
208 output[destIndex] = srcData[srcIndex];
209 output[destIndex + 1] = srcData[srcIndex + 1];
210 output[destIndex + 2] = srcData[srcIndex + 2];
211 output[destIndex + 3] = srcData[srcIndex + 3];
212 }
213 }
214
215 return output;
216 }
217
218 async writePngFromRaw(rawData, width, height, outputPath) {
219 const PNG = await this.getPNGClass();
220 const png = new PNG({ width, height });
221 rawData.copy(png.data);
222 const buffer = PNG.sync.write(png);
223 await fs.writeFile(outputPath, buffer);
224 }
225
226 async pack() {
227 console.log(`📦 Packing ...`);
228
229 try {
230 await this.createOutputDir();
231 await this.prepareTapeSource();
232 const pieceData = await this.loadPiece();
233
234 // Store piece data for dependency analysis
235 this.pieceData = pieceData;
236
237 // Handle KidLisp dependencies if this is a KidLisp piece
238 let hasDependencies = false;
239 if (pieceData.isKidLispCode) {
240 hasDependencies = await this.bundleKidLispDependencies(pieceData);
241 }
242
243 await this.bundleSystemFiles();
244 await this.bundleLibFiles();
245 await this.bundleSystemsFiles();
246 await this.bundleDepFiles();
247 await this.bundleCommonDiskFiles();
248 await this.bundleFontDrawings(); // Add font drawings bundling
249 await this.bundleCurrentPiece();
250 await this.createDiskStubs();
251 await this.bundleWebfonts();
252 await this.bundleFontAssets();
253 await this.generateCover();
254 await this.createAssets(); // Moved after generateCover to check for GIF favicon first
255 await this.copyAssets();
256
257 // Generate index.html after all bundling is complete so we have accurate file count
258 await this.generateIndexHtml(pieceData, hasDependencies);
259 await this.convertMjsModulesToJs();
260
261 console.log("✅ Successfully generated assets for", this.pieceName);
262 console.log("📁 Files bundled:", this.bundledFiles.size);
263 console.log("📍 Location:", this.options.outputDir);
264
265 return { success: true, outputDir: this.options.outputDir };
266 } catch (error) {
267 console.error("❌ Packing failed:", error);
268 return { success: false, error };
269 } finally {
270 await this.cleanupTempDirs();
271 }
272 }
273
274 async cleanup() {
275 // Only clean up if we're using a different target directory than teia/output
276 if (this.options.targetDir !== TOKENS_DIR) {
277 try {
278 await fs.rm(this.options.outputDir, { recursive: true, force: true });
279 console.log(`🧹 Cleaned up temporary directory: ${this.options.outputDir}`);
280 } catch (error) {
281 console.warn(`⚠️ Failed to clean up temporary directory: ${error.message}`);
282 }
283 }
284 }
285
286 async cleanupTempDirs() {
287 while (this.tempDirs.length > 0) {
288 const dir = this.tempDirs.pop();
289 if (!dir) {
290 continue;
291 }
292 try {
293 await fs.rm(dir, { recursive: true, force: true });
294 this.logVerbose(`🧹 Removed temporary directory: ${dir}`);
295 } catch (error) {
296 console.warn(`⚠️ Failed to remove temporary directory ${dir}: ${error.message}`);
297 }
298 }
299 }
300
301 async createOutputDir() {
302 try {
303 await fs.rm(this.options.outputDir, { recursive: true, force: true });
304 console.log(`🧹 Cleared existing output directory: ${this.options.outputDir}`);
305 } catch (error) {
306 console.warn(`⚠️ Failed to clear output directory before pack: ${error.message}`);
307 }
308 await fs.mkdir(this.options.outputDir, { recursive: true });
309 console.log(`📁 Created output directory: ${this.options.outputDir}`);
310 }
311
312 async loadPiece() {
313 // First check if this is a KidLisp $code
314 if (this.pieceName.startsWith('$')) {
315 const codeId = this.pieceName.slice(1); // Remove $ prefix
316 console.log(`🔍 Detected KidLisp code: ${this.pieceName}`);
317
318 // Fetch the code and all its dependencies
319 const { fetchCode } = await import("./kidlisp-extractor.mjs");
320 const result = await fetchCode(codeId);
321
322 if (result) {
323 console.log(`📜 Loaded KidLisp code: ${this.pieceName}`);
324 return {
325 sourceCode: result.source,
326 language: "kidlisp",
327 metadata: {},
328 isKidLispCode: true,
329 codeId: codeId
330 };
331 } else {
332 throw new Error(`KidLisp code not found: ${this.pieceName}`);
333 }
334 }
335
336 // Try to load JavaScript piece first
337 const jsPath = path.join(DISKS_DIR, `${this.pieceName}.mjs`);
338 try {
339 const sourceCode = await fs.readFile(jsPath, "utf8");
340 console.log(`📜 Loaded javascript piece: ${this.pieceName}`);
341 return { sourceCode, language: "javascript", metadata: {} };
342 } catch (err) {
343 // Try Lisp piece if JavaScript fails
344 const lispPath = path.join(DISKS_DIR, `${this.pieceName}.lisp`);
345 try {
346 const sourceCode = await fs.readFile(lispPath, "utf8");
347 console.log(`📜 Loaded lisp piece: ${this.pieceName}`);
348 return { sourceCode, language: "lisp", metadata: {} };
349 } catch (lispErr) {
350 throw new Error(`Piece not found: ${this.pieceName} (tried .mjs, .lisp, and $code API)`);
351 }
352 }
353 }
354
355 async bundleKidLispDependencies(pieceData) {
356 console.log(`🔗 Fetching KidLisp dependencies for ${this.pieceName}...`);
357
358 // Create a codes map that includes the main code itself
359 const codesMap = await fetchAllCodes(pieceData.sourceCode);
360
361 // Always include the main piece code in the cache
362 const mainCodeId = pieceData.codeId; // "roz" from "$roz"
363 codesMap.set(mainCodeId, {
364 source: pieceData.sourceCode,
365 when: new Date().toISOString(),
366 hits: 1,
367 user: "teia-package"
368 });
369
370 console.log(`📚 Found ${codesMap.size} KidLisp codes (including main: ${mainCodeId})`);
371
372 // Always generate cache code if we have any codes (including main)
373 if (codesMap.size > 0) {
374 // Store the cache data for inline injection into HTML
375 this.kidlispCacheData = { codesMap, count: codesMap.size };
376 console.log(`💾 Prepared KidLisp cache for inline injection with ${codesMap.size} codes`);
377 return true; // Dependencies + main code bundled
378 } else {
379 console.log(`ℹ️ No KidLisp codes to bundle`);
380 return false; // No codes at all
381 }
382 }
383
384 async generateIndexHtml(pieceData, hasDependencies = false) {
385 // Set up PACK mode environment before generating metadata
386 global.window = global.window || {};
387 global.window.acPACK_MODE = true;
388 global.globalThis = global.globalThis || {};
389 global.globalThis.acPACK_MODE = true;
390
391 // Import and call metadata with OBJKT context
392 const { metadata } = await import("../system/public/aesthetic.computer/lib/parse.mjs");
393 const objktContext = { author: this.options.author };
394 const generatedMetadata = metadata("localhost", this.pieceName, {}, "https:", objktContext);
395
396 // Override metadata URLs to use relative paths for static packaging
397 if (generatedMetadata) {
398 generatedMetadata.icon = `./icon/256x256/${this.pieceName}.png`;
399 // Also override any preview/cover images to use our static cover
400 generatedMetadata.ogImage = this.options.coverImage;
401 generatedMetadata.twitterImage = this.options.coverImage;
402 // Use relative manifest path for standalone packages
403 generatedMetadata.manifest = "./manifest.json";
404 }
405
406 const gitInfo = getGitInfo();
407 console.log("🔧 Git info retrieved:", gitInfo);
408 const buildDate = this.buildDate ? new Date(this.buildDate) : new Date();
409 const packTimeUTC = buildDate.toISOString();
410 const packTimeLocal = formatDateTimeForDisplay(buildDate, this.timeZone);
411
412 // Get system information
413 const systemInfo = {
414 platform: process.platform,
415 arch: process.arch,
416 nodeVersion: process.version,
417 hostname: os.hostname(),
418 userInfo: os.userInfo().username
419 };
420
421 // Prepare colophonic information
422 const zipFilename = `${this.options.author}-${this.pieceName}-${this.zipTimestamp}.zip`;
423
424 const colophonData = {
425 piece: {
426 name: this.pieceName,
427 isKidLisp: pieceData.isKidLispCode,
428 sourceCode: pieceData.sourceCode || null,
429 hasDependencies,
430 codeLength: pieceData.sourceCode ? pieceData.sourceCode.length : 0
431 },
432 build: {
433 packTime: packTimeLocal,
434 packTimeLocal,
435 packTimeUTC,
436 timeZone: this.timeZone,
437 author: this.options.author,
438 gitCommit: gitInfo.shortCommit,
439 gitCommitFull: gitInfo.commit,
440 gitBranch: gitInfo.branch,
441 gitCommitDate: gitInfo.commitDate,
442 gitIsDirty: gitInfo.isDirty,
443 repoUrl: gitInfo.repoUrl,
444 systemInfo,
445 fileCount: this.bundledFiles.size,
446 zipFilename: zipFilename
447 },
448 metadata: {
449 ...generatedMetadata,
450 favicon: this.options.faviconImage || this.getPackagedRelativePath('favicon.png')
451 }
452 };
453
454 // Set colophon in global context for metadata generation
455 globalThis.acPACK_COLOPHON = colophonData;
456
457 // Regenerate metadata with complete colophon data (including zipFilename) for proper title
458 const finalMetadata = metadata("localhost", this.pieceName, colophonData, "https:", objktContext);
459
460 // Override metadata URLs to use relative paths for static packaging
461 if (finalMetadata) {
462 finalMetadata.icon = `./icon/256x256/${this.pieceName}.png`;
463 finalMetadata.ogImage = this.options.coverImage;
464 finalMetadata.twitterImage = this.options.coverImage;
465 finalMetadata.manifest = "./manifest.json";
466 }
467
468 // Update colophon with final metadata
469 colophonData.metadata = {
470 ...finalMetadata,
471 favicon: this.options.faviconImage || this.getPackagedRelativePath('favicon.png')
472 };
473
474 console.log("📋 Colophon data prepared:", JSON.stringify(colophonData, null, 2));
475
476 const indexHtml = `<!doctype html>
477<html>
478 <head>
479 <meta charset="utf-8" />
480 <base href="./" />
481 <title>${finalMetadata.title || this.options.title}</title>
482 <meta property="og:image" content="${this.options.coverImage}" />
483 <link rel="icon" href="${this.options.faviconImage || this.getPackagedRelativePath('favicon.png')}" type="${this.options.faviconImage && this.options.faviconImage.endsWith('.gif') ? 'image/gif' : 'image/png'}" />
484 <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
485 <meta name="description" content="${this.options.description}" />
486 <meta name="og:title" content="${finalMetadata.title || this.options.title}" />
487 <meta name="og:description" content="${this.options.description}" />
488 <meta name="twitter:card" content="summary_large_image" />
489 <meta name="twitter:title" content="${finalMetadata.title || this.options.title}" />
490 <meta name="twitter:site" content="${this.options.author}" />
491 <meta name="twitter:image" content="${this.options.coverImage}" />
492
493 <script type="text/javascript">
494// PACK mode configuration - simple starting piece override
495window.acPACK_MODE = true;
496window.acSTARTING_PIECE = "${this.pieceName}"; // Override default "prompt" piece
497
498// Suppress console errors for missing font files in PACK mode
499(function() {
500 const originalError = console.error;
501 console.error = function(...args) {
502 const message = args.join(' ');
503 // Skip MatrixChunky8 font loading 404 errors
504 if (message.includes('Failed to load resource') &&
505 (message.includes('MatrixChunky8') ||
506 message.includes('assets/type/') ||
507 message.includes('.json')) &&
508 message.includes('404')) {
509 return; // Silently ignore these font loading errors
510 }
511 originalError.apply(console, args);
512 };
513
514 // Capture resource errors at the window level before they hit the console
515 window.addEventListener('error', (event) => {
516 const target = event?.target;
517 const message = event?.message || '';
518 const resourceUrl = typeof target?.src === 'string' ? target.src : '';
519 if (
520 (resourceUrl && resourceUrl.includes('MatrixChunky8')) ||
521 message.includes('MatrixChunky8') ||
522 message.includes('assets/type/')
523 ) {
524 event.preventDefault();
525 event.stopImmediatePropagation();
526 return false;
527 }
528 }, true);
529
530 window.addEventListener('unhandledrejection', (event) => {
531 const reason = event?.reason;
532 if (typeof reason === 'string' && reason.includes('MatrixChunky8')) {
533 event.preventDefault();
534 } else if (reason && typeof reason.message === 'string' && reason.message.includes('MatrixChunky8')) {
535 event.preventDefault();
536 }
537 });
538})();
539
540// Colophonic information for provenance and debugging
541window.acPACK_COLOPHON = ${JSON.stringify(colophonData, null, 2)};
542
543// Extract Teia URL parameters
544const urlParams = new URLSearchParams(window.location.search);
545window.acPACK_VIEWER = urlParams.get('viewer') || null;
546window.acPACK_CREATOR = urlParams.get('creator') || null;
547
548// Add custom density parameter to URL if specified
549${this.options.density ? `
550if (!urlParams.has('density')) {
551 urlParams.set('density', '${this.options.density}');
552 // Update the URL without page reload to include density parameter when environment allows it
553 if (window.location && window.location.origin && window.location.origin !== 'null' && window.location.protocol !== 'about:') {
554 try {
555 const newUrl = window.location.pathname + '?' + urlParams.toString();
556 history.replaceState(null, '', newUrl);
557 } catch (error) {
558 console.warn('⚠️ Skipped history.replaceState in restricted environment:', error?.message || error);
559 }
560 }
561}` : '// No custom density specified'}
562
563// Force sandbox mode for Teia
564window.acSANDBOX_MODE = true;
565
566// Disable session for pack mode (no need for session in standalone packages)
567window.acDISABLE_SESSION = true;
568
569// Enable nogap mode by default for teia
570window.acNOGAP_MODE = true;
571
572// Set PACK mode on both window and globalThis for maximum compatibility
573window.acPACK_MODE = true;
574globalThis.acPACK_MODE = true;
575
576// Periodically ensure PACK mode stays enabled
577setInterval(() => {
578 if (!window.acPACK_MODE || !globalThis.acPACK_MODE) {
579 window.acPACK_MODE = true;
580 globalThis.acPACK_MODE = true;
581 }
582}, 100);
583 </script>
584 ${this.generateMatrixChunkyGlyphScript()}
585 ${this.generateKidLispCacheScript()}
586 <script src="${this.getPackagedRelativePath('boot.js')}" type="module" defer onerror="handleModuleLoadError()"></script>
587 <link rel="stylesheet" href="${this.getPackagedRelativePath('style.css')}" />
588 <style>
589 /* Keep transparent background for PACK mode */
590 body.nogap {
591 background-color: transparent !important;
592 }
593
594 /* Show console div for file:// protocol errors */
595 .file-protocol-error #console {
596 display: block !important;
597 background: linear-gradient(135deg, #1a1a2e, #16213e);
598 color: #ffffff;
599 font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
600 font-size: 14px;
601 line-height: 1.5;
602 padding: 30px;
603 box-sizing: border-box;
604 z-index: 9999;
605 border: 2px solid #4a9eff;
606 border-radius: 12px;
607 margin: 20px;
608 max-height: 90vh;
609 overflow-y: auto;
610 box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
611 }
612
613 .file-protocol-error #console h3 {
614 color: #ff6b6b;
615 margin-top: 0;
616 font-size: 20px;
617 text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5);
618 }
619
620 .file-protocol-error #console h4 {
621 color: #4ecdc4;
622 font-size: 18px;
623 margin-top: 30px;
624 margin-bottom: 15px;
625 border-bottom: 2px solid #4ecdc4;
626 padding-bottom: 8px;
627 }
628
629 .file-protocol-error #console code {
630 background: #2a2a4a;
631 color: #ffd93d;
632 padding: 3px 6px;
633 border-radius: 4px;
634 font-family: inherit;
635 }
636
637 .file-protocol-error #console ul {
638 background: rgba(255, 255, 255, 0.05);
639 padding: 15px 20px;
640 border-radius: 8px;
641 border-left: 4px solid #4a9eff;
642 }
643
644 .source-code-section {
645 margin-top: 30px;
646 padding: 20px;
647 background: rgba(255, 255, 255, 0.03);
648 border-radius: 12px;
649 border: 1px solid #4ecdc4;
650 }
651
652 .source-code-container {
653 position: relative;
654 margin: 15px 0;
655 }
656
657 .source-code {
658 background: #0f0f23;
659 color: #a6e22e;
660 padding: 20px;
661 border-radius: 8px;
662 font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
663 font-size: 13px;
664 line-height: 1.4;
665 overflow-x: auto;
666 white-space: pre-wrap;
667 word-wrap: break-word;
668 border: 1px solid #4a9eff;
669 user-select: text;
670 -webkit-user-select: text;
671 -moz-user-select: text;
672 -ms-user-select: text;
673 max-height: 300px;
674 overflow-y: auto;
675 }
676
677 .copy-button {
678 position: absolute;
679 top: 10px;
680 right: 10px;
681 background: #4a9eff;
682 color: white;
683 border: none;
684 padding: 8px 12px;
685 border-radius: 6px;
686 cursor: pointer;
687 font-size: 12px;
688 transition: all 0.2s ease;
689 }
690
691 .copy-button:hover {
692 background: #357abd;
693 transform: translateY(-1px);
694 }
695
696 .piece-info {
697 background: rgba(0, 0, 0, 0.3);
698 padding: 15px;
699 border-radius: 8px;
700 margin-top: 15px;
701 border-left: 4px solid #a6e22e;
702 }
703
704 .piece-info p {
705 margin: 5px 0;
706 color: #e0e0e0;
707 }
708
709 .piece-info strong {
710 color: #4ecdc4;
711 }
712 </style>
713 </head>
714 <body class="native-cursor nogap">
715 <div id="console" class="hidden">
716 <div class="boot-message">booting...</div>
717 <div class="error-message" style="display: none;">
718 <h3>🚫 Module Loading Error (CORS)</h3>
719 <p>This OBJKT package needs to be served from an HTTP server to work properly.</p>
720 <p><strong>Quick Solutions:</strong></p>
721 <ul>
722 <li><strong>Python:</strong> <code>python -m http.server 8000</code></li>
723 <li><strong>Node.js:</strong> <code>npx serve .</code></li>
724 <li><strong>PHP:</strong> <code>php -S localhost:8000</code></li>
725 </ul>
726 <p>Then open <code>http://localhost:8000</code> in your browser.</p>
727 <p><em>Reason: Browsers block ES modules when opened directly as files (file:// protocol) due to CORS security policy.</em></p>
728
729 ${colophonData.piece.sourceCode ? `
730 <div class="source-code-section">
731 <h4>📜 Source Code</h4>
732 <div class="source-code-container">
733 <pre class="source-code">${colophonData.piece.sourceCode}</pre>
734 <button class="copy-button" onclick="copySourceCode()">📋 Copy</button>
735 </div>
736 <div class="piece-info">
737 <p><strong>Piece:</strong> ${colophonData.piece.name}</p>
738 <p><strong>Author:</strong> ${colophonData.build.author}</p>
739 <p><strong>Created:</strong> ${colophonData.build.packTimeLocal || colophonData.build.packTime} (${colophonData.build.timeZone || "local"})</p>
740 <p><strong>Type:</strong> ${colophonData.piece.isKidLisp ? 'KidLisp' : 'JavaScript'} (${colophonData.piece.codeLength} characters)</p>
741 </div>
742 </div>
743 ` : ''}
744 </div>
745 </div>
746 <script>
747 if (window.self !== window.top) document.body.classList.add("embed");
748
749 // Auto-enable nogap for pack mode or URL parameter
750 const params = new URLSearchParams(location.search);
751 if (window.acNOGAP_MODE || params.has("nogap") || location.search.includes("nogap")) {
752 document.body.classList.add("nogap");
753 }
754
755 // Handle module loading errors (CORS issues with file:// protocol)
756 function handleModuleLoadError() {
757 // Skip CORS error overlay if running in Electron
758 if (window.navigator.userAgent.includes('Electron')) {
759 console.log('🔌 Running in Electron - suppressing CORS error overlay');
760 return;
761 }
762
763 console.error('❌ Failed to load boot.mjs - likely due to CORS policy with file:// protocol');
764 document.body.classList.add('file-protocol-error');
765 document.querySelector('#console .boot-message').style.display = 'none';
766 document.querySelector('#console .error-message').style.display = 'block';
767 document.getElementById('console').classList.remove('hidden');
768 }
769
770 // Also catch any other module loading errors
771 window.addEventListener('error', function(e) {
772 if (e.message && e.message.includes('Failed to resolve module specifier')) {
773 handleModuleLoadError();
774 }
775 });
776
777 // Timeout fallback - if nothing loads after 3 seconds, assume CORS error
778 // BUT skip this check if we're running in Electron
779 setTimeout(function() {
780 if (!window.acBOOTED && location.protocol === 'file:' && !window.navigator.userAgent.includes('Electron')) {
781 handleModuleLoadError();
782 }
783 }, 3000);
784 </script>
785 </body>
786</html>`;
787
788 await fs.writeFile(path.join(this.options.outputDir, "index.html"), indexHtml);
789 console.log("📄 Generated index.html");
790
791 // Generate a basic manifest.json for the packaged piece
792 // Check for 256x256 icon (preferred) or fallback to 128x128
793 const icon256Dir = path.join(this.options.outputDir, "icon", "256x256");
794 const icon128Dir = path.join(this.options.outputDir, "icon", "128x128");
795 const png256Icon = path.join(icon256Dir, `${this.pieceName}.png`);
796 const png128Icon = path.join(icon128Dir, `${this.pieceName}.png`);
797 const gif128Icon = path.join(icon128Dir, `${this.pieceName}.gif`);
798
799 let iconSrc, iconType, iconSize;
800 if (fsSync.existsSync(png256Icon)) {
801 iconSrc = `./icon/256x256/${this.pieceName}.png`;
802 iconType = "image/png";
803 iconSize = "256x256";
804 } else if (fsSync.existsSync(png128Icon)) {
805 iconSrc = `./icon/128x128/${this.pieceName}.png`;
806 iconType = "image/png";
807 iconSize = "128x128";
808 } else if (fsSync.existsSync(gif128Icon)) {
809 iconSrc = `./icon/128x128/${this.pieceName}.gif`;
810 iconType = "image/gif";
811 iconSize = "128x128";
812 } else {
813 // Fallback to 256x256 PNG (even if it doesn't exist)
814 iconSrc = `./icon/256x256/${this.pieceName}.png`;
815 iconType = "image/png";
816 iconSize = "256x256";
817 }
818
819 const manifest = {
820 name: finalMetadata.title || this.options.title,
821 short_name: this.pieceName,
822 start_url: "./",
823 display: "standalone",
824 background_color: "#000000",
825 theme_color: "#0084FF",
826 icons: [
827 {
828 src: `./icon/128x128/${this.pieceName}.png`,
829 sizes: "128x128",
830 type: "image/png"
831 },
832 {
833 src: `./icon/256x256/${this.pieceName}.png`,
834 sizes: "256x256",
835 type: "image/png"
836 },
837 {
838 src: `./icon/512x512/${this.pieceName}.png`,
839 sizes: "512x512",
840 type: "image/png"
841 }
842 ]
843 };
844
845 await fs.writeFile(path.join(this.options.outputDir, "manifest.json"), JSON.stringify(manifest, null, 2));
846 console.log("📄 Generated manifest.json");
847 }
848
849 generateKidLispCacheScript() {
850 // Return empty string if no cache data
851 if (!this.kidlispCacheData || !this.kidlispCacheData.codesMap) {
852 return '';
853 }
854
855 // Generate the inline cache script
856 const cacheCode = generateCacheCode(this.kidlispCacheData.codesMap);
857 return `<script type="text/javascript">
858${cacheCode}
859 </script>`;
860 }
861
862 generateMatrixChunkyGlyphScript() {
863 if (!this.matrixChunkyGlyphMap || Object.keys(this.matrixChunkyGlyphMap).length === 0) {
864 return '';
865 }
866
867 const serializedGlyphs = JSON.stringify(this.matrixChunkyGlyphMap);
868 return `<script type="text/javascript">
869window.acPACK_MATRIX_CHUNKY_GLYPHS = ${serializedGlyphs};
870</script>`;
871 }
872
873 async bundleSystemFiles() {
874 const acOutputDir = this.getPackagedDirPath();
875 await fs.mkdir(acOutputDir, { recursive: true });
876
877 const coreFiles = ["boot.mjs", "style.css", "bios.mjs", "lib/parse.mjs"];
878
879 for (const file of coreFiles) {
880 const srcPath = path.join(AC_DIR, file);
881 const destPath = path.join(acOutputDir, file);
882
883 try {
884 // Create directory for files in subdirectories
885 await fs.mkdir(path.dirname(destPath), { recursive: true });
886
887 let content = await fs.readFile(srcPath, "utf8");
888
889 // Patch style.css for better nogap support in pack mode
890 if (file === 'style.css') {
891 content = this.patchStyleCssForObjkt(content);
892 console.log(`🔧 Patched style.css for enhanced nogap support`);
893 }
894
895 // Patch bios.mjs for pack mode - fix webfont URLs
896 if (file === 'bios.mjs') {
897 content = await this.patchBiosJsForObjkt(content);
898 console.log(`🎨 Patched bios.mjs for pack mode`);
899 }
900
901 // Patch boot.mjs for pack mode - fix dependency URLs
902 if (file === 'boot.mjs') {
903 content = await this.patchBootJsForObjkt(content);
904 console.log(`🎨 Patched boot.mjs for pack mode`);
905 }
906
907 // Patch parse.mjs for pack mode - handle piece overrides
908 if (file === 'lib/parse.mjs') {
909 content = await this.patchParseJsForObjkt(content);
910 console.log(`🎨 Patched parse.mjs for pack mode`);
911 }
912
913 await fs.writeFile(destPath, content);
914 this.bundledFiles.add(file);
915 console.log(`📎 Bundled: ${file}`);
916 } catch (error) {
917 console.warn(`⚠️ Warning: Could not bundle ${file}:`, error.message);
918 }
919 }
920 }
921
922 async bundleLibFiles() {
923 const libDir = path.join(AC_DIR, "lib");
924 const libOutputDir = this.getPackagedDirPath("lib");
925 await fs.mkdir(libOutputDir, { recursive: true });
926
927 const libFiles = await fs.readdir(libDir);
928 const jsFiles = libFiles.filter(file => file.endsWith(".mjs"));
929
930 console.log(`📚 Found ${jsFiles.length} library files to bundle`);
931
932 for (const libFile of jsFiles) {
933 try {
934 const srcPath = path.join(libDir, libFile);
935 const destPath = path.join(libOutputDir, libFile);
936 let content = await fs.readFile(srcPath, "utf8");
937
938 // Patch type.mjs for pack mode - prevent API fallback calls
939 if (libFile === 'type.mjs') {
940 content = this.patchTypeJsForObjkt(content);
941 console.log(`🔧 Patched type.mjs for pack mode`);
942 }
943
944 // Patch kidlisp.mjs for pack mode - reduce verbose logging
945 if (libFile === 'kidlisp.mjs') {
946 content = this.patchKidLispJsForObjkt(content);
947 console.log(`🔧 Patched kidlisp.mjs for pack mode`);
948 }
949
950 // Patch headers.mjs for pack mode - fix import statements
951 if (libFile === 'headers.mjs') {
952 content = this.patchHeadersJsForObjkt(content);
953 console.log(`🔧 Patched headers.mjs for pack mode`);
954 }
955
956 // Patch disk.mjs for pack mode - prevent session connections
957 if (libFile === 'disk.mjs') {
958 content = await this.patchDiskJsForObjkt(content);
959 console.log(`🔧 Patched disk.mjs for pack mode`);
960 }
961
962 // Patch udp.mjs for pack mode - disable networking functionality
963 if (libFile === 'udp.mjs') {
964 content = this.patchUdpJsForObjkt(content);
965 console.log(`🔧 Patched udp.mjs for pack mode`);
966 }
967
968 await fs.writeFile(destPath, content);
969 this.bundledFiles.add(`lib/${libFile}`);
970 console.log(`📚 Bundled lib: ${libFile}`);
971 } catch (error) {
972 console.warn(`⚠️ Warning: Could not bundle lib/${libFile}:`, error.message);
973 }
974 }
975
976 // Handle subdirectories (like sound/, glazes/)
977 const allEntries = await fs.readdir(libDir, { withFileTypes: true });
978 const subdirs = allEntries.filter(entry => entry.isDirectory()).map(entry => entry.name);
979
980 for (const subdir of subdirs) {
981 const subdirPath = path.join(libDir, subdir);
982 const subdirOutputPath = path.join(libOutputDir, subdir);
983 await fs.mkdir(subdirOutputPath, { recursive: true });
984
985 const subdirFiles = await fs.readdir(subdirPath);
986 for (const file of subdirFiles) {
987 if (file.endsWith(".mjs") || file.endsWith(".js")) {
988 const srcPath = path.join(subdirPath, file);
989 const destPath = path.join(subdirOutputPath, file);
990 const content = await fs.readFile(srcPath, "utf8");
991 await fs.writeFile(destPath, content);
992 this.bundledFiles.add(`lib/${subdir}/${file}`);
993 console.log(`📚 Bundled lib/${subdir}: ${file}`);
994 }
995 }
996 }
997
998 // Create required stubs
999 const stubs = [
1000 { name: "uniforms.js", content: "// Uniform stub for PACK mode\nexport default {};" },
1001 { name: "vec4.mjs", content: "// Vec4 stub for PACK mode\nexport default {};" },
1002 { name: "idb.js", content: "// IndexedDB stub for PACK mode" },
1003 { name: "geckos.io-client.2.3.2.min.js", content: "// Geckos stub for PACK mode\nexport default null;\nmodule.exports = null;" }
1004 ];
1005
1006 for (const stub of stubs) {
1007 const stubPath = path.join(libOutputDir, stub.name);
1008 await fs.writeFile(stubPath, stub.content);
1009 console.log(`📝 Created additional stub: ${stub.name}`);
1010 }
1011 }
1012
1013 async bundleSystemsFiles() {
1014 const systemsDir = path.join(AC_DIR, "systems");
1015 const systemsOutputDir = this.getPackagedDirPath("systems");
1016 await fs.mkdir(systemsOutputDir, { recursive: true });
1017
1018 try {
1019 const systemFiles = await fs.readdir(systemsDir);
1020 const jsFiles = systemFiles.filter(file => file.endsWith(".mjs"));
1021
1022 console.log(`⚙️ Found ${jsFiles.length} system files to bundle`);
1023
1024 for (const systemFile of jsFiles) {
1025 const srcPath = path.join(systemsDir, systemFile);
1026 const destPath = path.join(systemsOutputDir, systemFile);
1027 const content = await fs.readFile(srcPath, "utf8");
1028 await fs.writeFile(destPath, content);
1029 this.bundledFiles.add(`system: ${systemFile}`);
1030 console.log(`⚙️ Bundled system: ${systemFile}`);
1031 }
1032 } catch (error) {
1033 console.warn("⚠️ Warning: Could not bundle system files:", error.message);
1034 }
1035 }
1036
1037 async bundleDepFiles() {
1038 const depDir = path.join(AC_DIR, "dep");
1039 const depOutputDir = this.getPackagedDirPath("dep");
1040
1041 try {
1042 await fs.mkdir(depOutputDir, { recursive: true });
1043 const depFiles = await this.getAllFilesRecursively(depDir);
1044
1045 // Analyze piece dependencies to determine what can be excluded
1046 let exclusionPatterns = [];
1047 if (this.pieceData) {
1048 const { DependencyAnalyzer } = await import('./dependency-analyzer.mjs');
1049 const analyzer = new DependencyAnalyzer();
1050
1051 const pieceCode = this.pieceData.sourceCode || '';
1052 const pieceSystem = this.pieceData.system || '';
1053 const analysis = analyzer.analyzePiece(pieceCode, pieceSystem);
1054
1055 console.log(`🔍 Dependency Analysis:`);
1056 console.log(` 💾 Potential savings: ${analysis.savings.toFixed(1)}MB`);
1057 console.log(` 🚫 Excluding: ${analysis.exclusions.join(', ')}`);
1058 console.log(` ✅ Required: ${analysis.required.join(', ')}`);
1059
1060 exclusionPatterns = analyzer.generateExclusionPatterns(analysis.exclusions);
1061 }
1062
1063 // Filter files based on exclusion patterns
1064 const filteredFiles = depFiles.filter(file => {
1065 const relativePath = path.relative(depDir, file);
1066
1067 // Check if file matches any exclusion pattern
1068 for (const pattern of exclusionPatterns) {
1069 const globPattern = pattern.replace(/\*\*/g, '.*').replace(/\*/g, '[^/]*');
1070 const regex = new RegExp(`^${globPattern}`, 'i');
1071 if (regex.test(relativePath)) {
1072 console.log(`🚫 Excluding: ${relativePath} (matched ${pattern})`);
1073 return false;
1074 }
1075 }
1076 return true;
1077 });
1078
1079 console.log(`📦 Bundling ${filteredFiles.length} of ${depFiles.length} dependency files (saved ${depFiles.length - filteredFiles.length} files)...`);
1080
1081 for (const file of filteredFiles) {
1082 const relativePath = path.relative(depDir, file);
1083 const destPath = path.join(depOutputDir, relativePath);
1084 const destDir = path.dirname(destPath);
1085
1086 await fs.mkdir(destDir, { recursive: true });
1087 const content = await fs.readFile(file, "utf8");
1088 await fs.writeFile(destPath, content);
1089 this.bundledFiles.add(`dep: ${relativePath}`);
1090 console.log(`📦 Bundled dep: ${relativePath}`);
1091 }
1092 } catch (error) {
1093 console.warn("⚠️ Warning: Could not bundle dependency files:", error.message);
1094 }
1095 }
1096
1097 async bundleCommonDiskFiles() {
1098 const commonDiskDir = path.join(DISKS_DIR, "common");
1099 const commonOutputDir = this.getPackagedDirPath("disks", "common");
1100
1101 try {
1102 await fs.mkdir(commonOutputDir, { recursive: true });
1103 const commonFiles = await fs.readdir(commonDiskDir);
1104 const jsFiles = commonFiles.filter(file => file.endsWith(".mjs"));
1105
1106 console.log(`📚 Found ${jsFiles.length} common disk files to bundle`);
1107
1108 for (const commonFile of jsFiles) {
1109 const srcPath = path.join(commonDiskDir, commonFile);
1110 const destPath = path.join(commonOutputDir, commonFile);
1111 const content = await fs.readFile(srcPath, "utf8");
1112 await fs.writeFile(destPath, content);
1113 this.bundledFiles.add(`common disk: ${commonFile}`);
1114 console.log(`📚 Bundled common disk: ${commonFile}`);
1115 }
1116 } catch (error) {
1117 console.warn("⚠️ Warning: Could not bundle common disk files:", error.message);
1118 }
1119 }
1120
1121 async bundleFontDrawings() {
1122 const drawingsDir = path.join(DISKS_DIR, "drawings");
1123 const drawingsOutputDir = this.getPackagedDirPath("disks", "drawings");
1124
1125 try {
1126 await fs.mkdir(drawingsOutputDir, { recursive: true });
1127
1128 // Bundle font_1 directory
1129 const font1Dir = path.join(drawingsDir, "font_1");
1130 const font1OutputDir = path.join(drawingsOutputDir, "font_1");
1131
1132 if (await this.directoryExists(font1Dir)) {
1133 await fs.mkdir(font1OutputDir, { recursive: true });
1134
1135 // Copy all subdirectories and files
1136 const font1Subdirs = await fs.readdir(font1Dir);
1137 let totalGlyphs = 0;
1138
1139 for (const subdir of font1Subdirs) {
1140 const srcSubdirPath = path.join(font1Dir, subdir);
1141 const destSubdirPath = path.join(font1OutputDir, subdir);
1142
1143 const stat = await fs.stat(srcSubdirPath);
1144 if (stat.isDirectory()) {
1145 await fs.mkdir(destSubdirPath, { recursive: true });
1146
1147 // Copy all .json files in this subdirectory
1148 const files = await fs.readdir(srcSubdirPath);
1149 const jsonFiles = files.filter(file => file.endsWith(".json"));
1150
1151 for (const jsonFile of jsonFiles) {
1152 const srcFilePath = path.join(srcSubdirPath, jsonFile);
1153 const destFilePath = path.join(destSubdirPath, jsonFile);
1154 const content = await fs.readFile(srcFilePath, "utf8");
1155 await fs.writeFile(destFilePath, content);
1156 totalGlyphs++;
1157 }
1158 }
1159 }
1160
1161 this.bundledFiles.add(`font_1 glyphs: ${totalGlyphs} files`);
1162 this.logVerbose(`🔤 Bundled font_1: ${totalGlyphs} glyph files`);
1163 } else {
1164 this.logVerbose("ℹ️ font_1 directory not found, skipping");
1165 }
1166
1167 } catch (error) {
1168 console.warn("⚠️ Warning: Could not bundle font drawings:", error.message);
1169 }
1170 }
1171
1172 async directoryExists(dir) {
1173 try {
1174 const stat = await fs.stat(dir);
1175 return stat.isDirectory();
1176 } catch {
1177 return false;
1178 }
1179 }
1180
1181 async bundleCurrentPiece() {
1182 const disksOutputDir = this.getPackagedDirPath("disks");
1183 await fs.mkdir(disksOutputDir, { recursive: true });
1184
1185 // Handle KidLisp $codes - create a stub piece that jumps to the cached code
1186 if (this.pieceName.startsWith('$')) {
1187 const stubContent = `// KidLisp $code stub for PACK mode
1188export function boot({ wipe, ink, help, backgroundFill }) {
1189 // Load the cached KidLisp code
1190 wipe("black");
1191 ink("white");
1192 help.choose("Load ${this.pieceName} from cache...").then(() => {
1193 help.system.nopaint.load("${this.pieceName}");
1194 });
1195}`;
1196
1197 const destPath = path.join(disksOutputDir, `${this.pieceName}.mjs`);
1198 await fs.writeFile(destPath, stubContent);
1199 this.bundledFiles.add(`KidLisp stub: ${this.pieceName}.mjs`);
1200 console.log(`🔗 Created KidLisp stub for: ${this.pieceName}`);
1201 return;
1202 }
1203
1204 // Copy the current piece to the disks directory (regular pieces)
1205 const extensions = [".mjs", ".lisp"];
1206 for (const ext of extensions) {
1207 const srcPath = path.join(DISKS_DIR, `${this.pieceName}${ext}`);
1208 const destPath = path.join(disksOutputDir, `${this.pieceName}${ext}`);
1209
1210 try {
1211 const content = await fs.readFile(srcPath, "utf8");
1212 await fs.writeFile(destPath, content);
1213 this.bundledFiles.add(`current piece: ${this.pieceName}${ext}`);
1214 console.log(`🎯 Bundled current piece: ${this.pieceName}${ext}`);
1215 break; // Stop after first successful copy
1216 } catch (error) {
1217 // Try next extension
1218 }
1219 }
1220 }
1221
1222 async createDiskStubs() {
1223 const disksOutputDir = this.getPackagedDirPath("disks");
1224
1225 // Create essential disk stubs that might be required
1226 const stubs = [
1227 { name: "chat.mjs", content: "// Chat stub for PACK mode\nexport function boot() {}\nexport function paint() {}\nexport function act() {}" }
1228 ];
1229
1230 for (const stub of stubs) {
1231 const stubPath = path.join(disksOutputDir, stub.name);
1232 await fs.writeFile(stubPath, stub.content);
1233 console.log(`📝 Created disk stub: ${stub.name}`);
1234 }
1235 }
1236
1237 async createAssets() {
1238 // Check if we already have a GIF favicon, if not create PNG
1239 const faviconGifPath = this.getPackagedDirPath("favicon.gif");
1240 const faviconPngPath = this.getPackagedDirPath("favicon.png");
1241
1242 try {
1243 await fs.access(faviconGifPath);
1244 console.log("🎯 Using existing GIF favicon, skipping PNG creation");
1245 } catch (error) {
1246 // No GIF favicon exists, create PNG fallback
1247 const minimalPng = Buffer.from([
1248 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D,
1249 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
1250 0x08, 0x06, 0x00, 0x00, 0x00, 0x1F, 0x15, 0xC4, 0x89, 0x00, 0x00, 0x00,
1251 0x0B, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9C, 0x63, 0x00, 0x01, 0x00, 0x00,
1252 0x05, 0x00, 0x01, 0x0D, 0x0A, 0x2D, 0xB4, 0x00, 0x00, 0x00, 0x00, 0x49,
1253 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82
1254 ]);
1255 await fs.writeFile(faviconPngPath, minimalPng);
1256 console.log("🖼️ Created fallback favicon.png");
1257 }
1258 }
1259
1260 async bundleWebfonts() {
1261 const webfontsDir = path.join(__dirname, "..", "system", "public", "type", "webfonts");
1262 const webfontsOutputDir = path.join(this.options.outputDir, "type", "webfonts");
1263
1264 try {
1265 await fs.mkdir(webfontsOutputDir, { recursive: true });
1266 const webfontFiles = await fs.readdir(webfontsDir);
1267
1268 this.logVerbose("🔤 Bundling webfonts...");
1269
1270 for (const fontFile of webfontFiles) {
1271 const srcPath = path.join(webfontsDir, fontFile);
1272 const destPath = path.join(webfontsOutputDir, fontFile);
1273
1274 if (fontFile.endsWith(".css") || fontFile.endsWith(".woff2") || fontFile.endsWith(".woff") || fontFile.endsWith(".ttf")) {
1275 const content = await fs.readFile(srcPath);
1276 await fs.writeFile(destPath, content);
1277 this.logVerbose(`🔤 Bundled webfont: ${fontFile}`);
1278 }
1279 }
1280 } catch (error) {
1281 console.warn("⚠️ Warning: Could not bundle webfonts:", error.message);
1282 }
1283 }
1284
1285 async bundleFontAssets() {
1286 const fontsDir = path.join(__dirname, "..", "system", "public", "assets", "type");
1287 const assetsOutputDir = path.join(this.options.outputDir, "assets", "type");
1288
1289 try {
1290 await fs.mkdir(assetsOutputDir, { recursive: true });
1291
1292 // Bundle MatrixChunky8 precomputed glyphs
1293 const matrixFontDir = path.join(fontsDir, "MatrixChunky8");
1294 const matrixOutputDir = path.join(assetsOutputDir, "MatrixChunky8");
1295
1296 // Check if MatrixChunky8 directory exists before trying to read it
1297 try {
1298 await fs.access(matrixFontDir);
1299 await fs.mkdir(matrixOutputDir, { recursive: true });
1300
1301 const glyphFiles = await fs.readdir(matrixFontDir);
1302 const jsonFiles = glyphFiles.filter(file => file.endsWith(".json"));
1303
1304 this.logVerbose(`🔤 Found ${jsonFiles.length} MatrixChunky8 glyph files to bundle`);
1305
1306 let bundledCount = 0;
1307 const inlineGlyphMap = {};
1308 for (const glyphFile of jsonFiles) {
1309 const srcPath = path.join(matrixFontDir, glyphFile);
1310
1311 // Convert 2-digit hex filename to 4-digit hex for consistency with code expectations
1312 const hexCode = glyphFile.replace('.json', '');
1313 const paddedHexCode = hexCode.padStart(4, '0').toUpperCase();
1314 const destFileName = `${paddedHexCode}.json`;
1315 const destPath = path.join(matrixOutputDir, destFileName);
1316
1317 const content = await fs.readFile(srcPath, "utf8");
1318 await fs.writeFile(destPath, content);
1319 try {
1320 inlineGlyphMap[paddedHexCode] = JSON.parse(content);
1321 } catch (error) {
1322 console.warn(`⚠️ Failed to parse glyph ${destFileName} for inline bundle:`, error.message);
1323 }
1324 bundledCount++;
1325 }
1326 this.matrixChunkyGlyphMap = inlineGlyphMap;
1327
1328 this.logVerbose(`✅ Successfully bundled ${bundledCount} MatrixChunky8 glyph files`);
1329
1330 // Also create a font manifest for easier debugging
1331 const fontManifest = {
1332 font: "MatrixChunky8",
1333 bundledGlyphs: jsonFiles.map(file => {
1334 const hexCode = file.replace('.json', '');
1335 const charCode = parseInt(hexCode, 16);
1336 return {
1337 hex: hexCode,
1338 decimal: charCode,
1339 char: String.fromCharCode(charCode),
1340 file: file
1341 };
1342 }),
1343 totalGlyphs: bundledCount,
1344 bundledAt: new Date().toISOString()
1345 };
1346
1347 const manifestPath = path.join(matrixOutputDir, "_manifest.json");
1348 await fs.writeFile(manifestPath, JSON.stringify(fontManifest, null, 2));
1349 this.logVerbose(`📋 Created font manifest: _manifest.json`);
1350
1351 } catch (matrixError) {
1352 this.logVerbose("ℹ️ MatrixChunky8 font directory not found, skipping font bundling");
1353 }
1354
1355 } catch (error) {
1356 console.error("❌ Error bundling font assets:", error.message);
1357 console.error(" Make sure the font directory exists at:", fontsDir);
1358 }
1359 }
1360
1361 async prepareTapeSource() {
1362 if (!this.options.tapePath || this.tapeInfo) {
1363 return;
1364 }
1365
1366 const tapePath = this.options.tapePath;
1367
1368 try {
1369 await fs.access(tapePath);
1370 } catch (error) {
1371 throw new Error(`Tape file not found: ${tapePath}`);
1372 }
1373
1374 console.log(`📼 Using supplied tape archive: ${tapePath}`);
1375
1376 const { default: JSZip } = await import("jszip");
1377 const zipBuffer = await fs.readFile(tapePath);
1378 const zip = await JSZip.loadAsync(zipBuffer);
1379
1380 let timingData = [];
1381 const timingEntry = zip.file("timing.json");
1382 if (timingEntry) {
1383 try {
1384 const timingContent = await timingEntry.async("string");
1385 timingData = JSON.parse(timingContent);
1386 } catch (error) {
1387 console.warn("⚠️ Failed to parse timing.json from tape:", error.message);
1388 }
1389 } else {
1390 console.warn("⚠️ No timing.json found in tape archive; falling back to filename ordering");
1391 }
1392
1393 let metadata = null;
1394 const metadataEntry = zip.file("metadata.json");
1395 if (metadataEntry) {
1396 try {
1397 const metadataContent = await metadataEntry.async("string");
1398 metadata = JSON.parse(metadataContent);
1399 } catch (error) {
1400 console.warn("⚠️ Failed to parse metadata.json from tape:", error.message);
1401 }
1402 }
1403
1404 let frameFilenames = [];
1405 if (Array.isArray(timingData) && timingData.length > 0) {
1406 frameFilenames = timingData
1407 .filter((entry) => entry && entry.filename)
1408 .map((entry) => entry.filename);
1409 }
1410
1411 if (frameFilenames.length === 0) {
1412 frameFilenames = Object.keys(zip.files).filter((name) => name.endsWith(".png"));
1413 frameFilenames.sort();
1414 }
1415
1416 if (frameFilenames.length === 0) {
1417 throw new Error("Tape archive did not contain any PNG frames");
1418 }
1419
1420 const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "ac-pack-tape-"));
1421 this.tempDirs.push(tempDir);
1422
1423 const framePaths = [];
1424 const frameDurations = [];
1425
1426 for (let index = 0; index < frameFilenames.length; index++) {
1427 const filename = frameFilenames[index];
1428 const entry = zip.file(filename);
1429 if (!entry) {
1430 console.warn(`⚠️ Tape frame missing in archive: ${filename}`);
1431 continue;
1432 }
1433
1434 const frameBuffer = await entry.async("nodebuffer");
1435 const outputPath = path.join(tempDir, path.basename(filename));
1436 await fs.writeFile(outputPath, frameBuffer);
1437 framePaths.push(outputPath);
1438
1439 if (timingData[index]?.duration !== undefined) {
1440 const durationValue = Number(timingData[index].duration);
1441 frameDurations.push(Number.isFinite(durationValue) ? durationValue : 0);
1442 }
1443 }
1444
1445 if (framePaths.length === 0) {
1446 throw new Error("No frame data could be extracted from tape");
1447 }
1448
1449 const audioEntries = zip.file(/soundtrack\.(wav|mp3|ogg|flac)$/i) || [];
1450 if (audioEntries.length > 0) {
1451 console.log("🎵 Tape includes soundtrack (not automatically bundled to keep package size manageable)");
1452 }
1453
1454 let totalDurationMs;
1455 if (frameDurations.length === framePaths.length) {
1456 totalDurationMs = frameDurations.reduce((sum, value) => sum + value, 0);
1457 } else {
1458 const fallbackFrameDuration = 1000 / 60;
1459 totalDurationMs = framePaths.length * fallbackFrameDuration;
1460 }
1461
1462 const totalDurationSeconds = totalDurationMs / 1000;
1463
1464 // Override cover duration metadata to reflect the tape recording
1465 this.options.coverDurationSeconds = totalDurationSeconds;
1466 this.options.coverFrameCount = framePaths.length;
1467
1468 this.tapeInfo = {
1469 tempDir,
1470 framePaths,
1471 frameDurations,
1472 metadata,
1473 totalDurationMs,
1474 frameCount: framePaths.length,
1475 tapeFilename: path.basename(tapePath),
1476 };
1477
1478 console.log(
1479 `📼 Extracted ${framePaths.length} frames from tape (${totalDurationSeconds.toFixed(2)}s)`
1480 );
1481 if (metadata?.scale) {
1482 console.log(
1483 `📐 Tape metadata: original ${metadata.originalSize?.width || "?"}x${metadata.originalSize?.height || "?"},` +
1484 ` scaled ${metadata.scaledSize?.width || "?"}x${metadata.scaledSize?.height || "?"}, scale ${metadata.scale}`
1485 );
1486 }
1487 }
1488
1489 async generateCoverFromTape() {
1490 if (!this.tapeInfo) {
1491 throw new Error("Tape information missing; cannot generate cover from tape");
1492 }
1493
1494 const { framePaths, frameDurations, metadata, totalDurationMs, tapeFilename } = this.tapeInfo;
1495
1496 if (!framePaths || framePaths.length === 0) {
1497 throw new Error("Tape extraction yielded no frames");
1498 }
1499
1500 const coverPath = path.join(this.options.outputDir, "cover.gif");
1501
1502 console.log("🎞️ Generating animated cover from supplied tape...");
1503 if (tapeFilename) {
1504 console.log(`� Tape source: ${tapeFilename}`);
1505 }
1506
1507 // Create symlinks with sequential numbering for ffmpeg's image2 demuxer
1508 const tempFramesDir = path.join(this.tapeInfo.tempDir, "frames-seq");
1509 await fs.mkdir(tempFramesDir, { recursive: true });
1510
1511 // Calculate start frame based on percentage
1512 const tapeStartPercent = this.options.tapeStartPercent || 0;
1513 const startFrameIndex = Math.floor((tapeStartPercent / 100) * framePaths.length);
1514 const selectedFrames = framePaths.slice(startFrameIndex);
1515
1516 if (selectedFrames.length === 0) {
1517 throw new Error(`No frames remaining after applying start percentage ${tapeStartPercent}%`);
1518 }
1519
1520 if (tapeStartPercent > 0) {
1521 console.log(`⏩ Starting from ${tapeStartPercent}% (frame ${startFrameIndex + 1}/${framePaths.length})`);
1522 console.log(`📊 Using ${selectedFrames.length} frames (${((selectedFrames.length / framePaths.length) * 100).toFixed(1)}% of tape)`);
1523 }
1524
1525 for (let i = 0; i < selectedFrames.length; i++) {
1526 const seqPath = path.join(tempFramesDir, `frame${String(i).padStart(6, '0')}.png`);
1527 await fs.symlink(selectedFrames[i], seqPath);
1528 }
1529
1530 // Use 50fps constant framerate for GIF
1531 const fps = 50;
1532
1533 // Use ffmpeg to create GIF from PNG sequence using image2 demuxer
1534 // Scale down to 512x512 and optimize palette for smaller file size
1535 // Reduce colors to 128 (from default 256) and use bayer dithering for better compression
1536 await new Promise((resolve, reject) => {
1537 const inputPattern = path.join(tempFramesDir, "frame%06d.png");
1538 const ffmpeg = spawn("ffmpeg", [
1539 "-framerate", String(fps),
1540 "-i", inputPattern,
1541 "-vf", `scale=512:512:flags=neighbor,split[s0][s1];[s0]palettegen=max_colors=128:stats_mode=diff[p];[s1][p]paletteuse=dither=bayer:bayer_scale=3`,
1542 "-loop", "0",
1543 "-y",
1544 coverPath
1545 ], { stdio: ["pipe", "pipe", "pipe"] });
1546
1547 let stderr = "";
1548 ffmpeg.stderr.on("data", (data) => {
1549 stderr += data.toString();
1550 });
1551
1552 ffmpeg.on("close", (code) => {
1553 if (code === 0) {
1554 resolve();
1555 } else {
1556 console.error("❌ FFmpeg stderr:", stderr);
1557 reject(new Error(`FFmpeg failed with code ${code}`));
1558 }
1559 });
1560
1561 ffmpeg.on("error", (err) => {
1562 reject(err);
1563 });
1564 });
1565
1566 const externalCoverFilename = `${this.options.author}-${this.pieceName}-${this.zipTimestamp}-cover.gif`;
1567 const externalCoverPath = path.join(this.options.targetDir, externalCoverFilename);
1568 await fs.copyFile(coverPath, externalCoverPath);
1569
1570 console.log("🎞️ Generated animated cover from tape: cover.gif");
1571 console.log(`�️ External cover created: ${externalCoverFilename}`);
1572
1573 // Generate Twitter/X-optimized version (15MB max)
1574 await this.generateTwitterCoverFromTape(tempFramesDir, selectedFrames.length, externalCoverPath);
1575
1576 // Generate objkt.com-optimized version (2MB max)
1577 await this.generateObjktCoverFromTape(tempFramesDir, selectedFrames.length, externalCoverPath);
1578
1579 this.options.coverImage = "cover.gif";
1580 this.bundledFiles.add(`tape cover frames: ${framePaths.length}`);
1581
1582 const totalSeconds = totalDurationMs / 1000;
1583 console.log(`⏱️ Tape duration: ${totalSeconds.toFixed(2)}s`);
1584 }
1585
1586 async generateTwitterCoverFromTape(tempFramesDir, frameCount, fullCoverPath) {
1587 // Generate a Twitter/X-optimized version (15MB max)
1588 // Twitter recommends 900x900 for square pixel art GIFs for optimal display quality
1589 // Limit to 15 seconds at 24fps (360 frames) to keep file size manageable
1590 const twitterCoverFilename = `${this.options.author}-${this.pieceName}-${this.zipTimestamp}-x.gif`;
1591 const twitterCoverPath = path.join(this.options.targetDir, twitterCoverFilename);
1592
1593 const targetFps = 24;
1594 const maxDurationSeconds = 11; // 11 seconds for 15MB target
1595 const maxFrames = Math.min(frameCount, targetFps * maxDurationSeconds);
1596
1597 console.log(`🐦 Generating Twitter/X-optimized version (800x800, ${maxFrames} frames @ ${targetFps}fps, 15MB max)...`);
1598
1599 // Create a temporary directory with only the frames we want for Twitter
1600 const twitterFramesDir = path.join(path.dirname(tempFramesDir), "frames-twitter");
1601 await fs.mkdir(twitterFramesDir, { recursive: true });
1602
1603 // Copy/symlink only the first maxFrames frames
1604 for (let i = 0; i < maxFrames; i++) {
1605 const sourceFrame = path.join(tempFramesDir, `frame${String(i).padStart(6, '0')}.png`);
1606 const targetFrame = path.join(twitterFramesDir, `frame${String(i).padStart(6, '0')}.png`);
1607 await fs.symlink(sourceFrame, targetFrame);
1608 }
1609
1610 const fps = 50;
1611 const inputPattern = path.join(twitterFramesDir, "frame%06d.png");
1612
1613 await new Promise((resolve, reject) => {
1614 const ffmpeg = spawn("ffmpeg", [
1615 "-framerate", String(fps),
1616 "-i", inputPattern,
1617 "-frames:v", String(maxFrames),
1618 // Twitter version: 800x800, 24fps, 16 colors with bayer dithering for best compression
1619 "-vf", `fps=${targetFps},scale=800:800:flags=neighbor,split[s0][s1];[s0]palettegen=max_colors=16:stats_mode=diff[p];[s1][p]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle`,
1620 "-loop", "0",
1621 "-y",
1622 twitterCoverPath
1623 ], { stdio: ["pipe", "pipe", "pipe"] });
1624
1625 let stderr = "";
1626 ffmpeg.stderr.on("data", (data) => {
1627 stderr += data.toString();
1628 });
1629
1630 ffmpeg.on("close", (code) => {
1631 if (code === 0) {
1632 resolve();
1633 } else {
1634 console.error("❌ Twitter cover FFmpeg stderr:", stderr);
1635 reject(new Error(`Twitter cover FFmpeg failed with code ${code}`));
1636 }
1637 });
1638
1639 ffmpeg.on("error", (err) => {
1640 reject(err);
1641 });
1642 });
1643
1644 // Check file size and log result
1645 const stats = await fs.stat(twitterCoverPath);
1646 const sizeMB = (stats.size / (1024 * 1024)).toFixed(2);
1647 console.log(`🐦 Twitter/X cover created: ${twitterCoverFilename} (${sizeMB} MB)`);
1648
1649 if (stats.size > 15 * 1024 * 1024) {
1650 console.warn(`⚠️ Warning: Twitter/X cover is ${sizeMB}MB (exceeds 15MB limit)`);
1651 }
1652 }
1653
1654 async generateObjktCoverFromTape(tempFramesDir, frameCount, externalCoverPath) {
1655 // Generate an objkt.com-optimized version (2MB max)
1656 // Use frame sampling to create an abbreviated version
1657 const objktCoverFilename = `${this.options.author}-${this.pieceName}-${this.zipTimestamp}-objkt.gif`;
1658 const objktCoverPath = path.join(this.options.targetDir, objktCoverFilename);
1659
1660 console.log(`📦 Generating objkt.com-optimized version (600x600, full color, slideshow-style)...`);
1661
1662 // Sample only 6 frames evenly distributed across the animation
1663 const targetFrames = 6;
1664 const sampleRate = Math.floor(frameCount / targetFrames);
1665 const sampledFrameCount = Math.min(targetFrames, frameCount);
1666
1667 // Create a temporary directory with only the sampled frames
1668 const objktFramesDir = path.join(path.dirname(tempFramesDir), "frames-objkt");
1669 await fs.mkdir(objktFramesDir, { recursive: true });
1670
1671 // Copy/symlink only the sampled frames, evenly distributed
1672 for (let i = 0; i < sampledFrameCount; i++) {
1673 const sourceFrameIndex = Math.floor((i * frameCount) / sampledFrameCount);
1674 const sourceFrame = path.join(tempFramesDir, `frame${String(sourceFrameIndex).padStart(6, '0')}.png`);
1675 const targetFrame = path.join(objktFramesDir, `frame${String(i).padStart(6, '0')}.png`);
1676 await fs.symlink(sourceFrame, targetFrame);
1677 }
1678
1679 const inputPattern = path.join(objktFramesDir, "frame%06d.png");
1680
1681 await new Promise((resolve, reject) => {
1682 const ffmpeg = spawn("ffmpeg", [
1683 "-framerate", "4", // Input at 4fps (0.25s per frame)
1684 "-i", inputPattern,
1685 // objkt version: 600x600, 256 colors (full palette) for slideshow quality
1686 "-vf", `scale=600:600:flags=neighbor,split[s0][s1];[s0]palettegen=max_colors=256:stats_mode=diff[p];[s1][p]paletteuse=dither=bayer:bayer_scale=2:diff_mode=rectangle`,
1687 "-loop", "0",
1688 "-y",
1689 objktCoverPath
1690 ], { stdio: ["pipe", "pipe", "pipe"] });
1691
1692 let stderr = "";
1693 ffmpeg.stderr.on("data", (data) => {
1694 stderr += data.toString();
1695 });
1696
1697 ffmpeg.on("close", (code) => {
1698 if (code === 0) {
1699 resolve();
1700 } else {
1701 console.error("❌ objkt cover FFmpeg stderr:", stderr);
1702 reject(new Error(`objkt cover FFmpeg failed with code ${code}`));
1703 }
1704 });
1705
1706 ffmpeg.on("error", (err) => {
1707 reject(err);
1708 });
1709 });
1710
1711 // Check file size and log result
1712 const stats = await fs.stat(objktCoverPath);
1713 const sizeMB = (stats.size / (1024 * 1024)).toFixed(2);
1714 console.log(`📦 objkt.com cover created: ${objktCoverFilename} (${sizeMB} MB, ${sampledFrameCount} sampled frames)`);
1715
1716 if (stats.size > 2 * 1024 * 1024) {
1717 console.warn(`⚠️ Warning: objkt cover is ${sizeMB}MB (exceeds 2MB limit)`);
1718 }
1719 }
1720
1721 async generateFallbackCoverFromTape() {
1722 if (!this.tapeInfo) {
1723 throw new Error("Tape information missing; cannot generate fallback cover");
1724 }
1725
1726 const { framePaths, totalDurationMs } = this.tapeInfo;
1727 if (!framePaths || framePaths.length === 0) {
1728 throw new Error("Tape extraction yielded no frames");
1729 }
1730
1731 console.log("🖼️ Generating fallback cover from tape middle frame...");
1732
1733 const GIFEncoder = await this.getGifEncoderClass();
1734 const PNG = await this.getPNGClass();
1735
1736 const coverPath = path.join(this.options.outputDir, "cover.gif");
1737 const middleIndex = Math.min(framePaths.length - 1, Math.floor(framePaths.length / 2));
1738 const buffer = await fs.readFile(framePaths[middleIndex]);
1739 const png = PNG.sync.read(buffer);
1740
1741 const encoder = new GIFEncoder(png.width, png.height, { highWaterMark: 1 << 24 });
1742 const writeStream = fsSync.createWriteStream(coverPath);
1743 encoder.createReadStream().pipe(writeStream);
1744 encoder.start();
1745 encoder.setRepeat(0);
1746 const averageDuration = totalDurationMs && Number.isFinite(totalDurationMs)
1747 ? totalDurationMs / Math.max(1, framePaths.length)
1748 : 1000 / 60;
1749 const fallbackDelay = Math.round(averageDuration) || Math.round(1000 / 60);
1750 encoder.setDelay(Math.max(16, fallbackDelay));
1751 encoder.setQuality(10);
1752 encoder.addFrame(png.data);
1753 encoder.finish();
1754
1755 await new Promise((resolve, reject) => {
1756 writeStream.on("finish", resolve);
1757 writeStream.on("error", reject);
1758 });
1759
1760 const externalCoverFilename = `${this.options.author}-${this.pieceName}-${this.zipTimestamp}-cover.gif`;
1761 const externalCoverPath = path.join(this.options.targetDir, externalCoverFilename);
1762 await fs.copyFile(coverPath, externalCoverPath);
1763
1764 this.options.coverImage = "cover.gif";
1765 this.bundledFiles.add("tape fallback cover");
1766 console.log("🖼️ Generated fallback single-frame cover from tape");
1767 }
1768
1769 async generateIconsFromTape(iconDirs) {
1770 if (!this.tapeInfo) {
1771 throw new Error("Tape information missing; cannot generate icons from tape");
1772 }
1773
1774 const { framePaths, frameDurations, totalDurationMs } = this.tapeInfo;
1775 if (!framePaths || framePaths.length === 0) {
1776 throw new Error("Tape extraction yielded no frames for icon generation");
1777 }
1778
1779 console.log("🖼️ Generating icons from supplied tape...");
1780
1781 const PNG = await this.getPNGClass();
1782
1783 const icon128GifPath = path.join(iconDirs.icon128Dir, `${this.pieceName}.gif`);
1784 const icon128PngPath = path.join(iconDirs.icon128Dir, `${this.pieceName}.png`);
1785 const icon256PngPath = path.join(iconDirs.icon256Dir, `${this.pieceName}.png`);
1786 const icon512PngPath = path.join(iconDirs.icon512Dir, `${this.pieceName}.png`);
1787
1788 // Create symlinks with sequential numbering for ffmpeg's image2 demuxer
1789 const tempIconFramesDir = path.join(this.tapeInfo.tempDir, "icon-frames-seq");
1790 await fs.mkdir(tempIconFramesDir, { recursive: true });
1791
1792 for (let i = 0; i < framePaths.length; i++) {
1793 const seqPath = path.join(tempIconFramesDir, `frame${String(i).padStart(6, '0')}.png`);
1794 await fs.symlink(framePaths[i], seqPath);
1795 }
1796
1797 // Use 50fps constant framerate for icon GIF
1798 const fps = 50;
1799
1800 // Generate 128x128 animated GIF using ffmpeg with image2 demuxer
1801 // Use reduced colors (64) for smaller icon file size
1802 const inputPattern = path.join(tempIconFramesDir, "frame%06d.png");
1803 const ffmpegArgs = [
1804 "-framerate", String(fps),
1805 "-i", inputPattern,
1806 "-vf", `scale=128:128:flags=neighbor,split[s0][s1];[s0]palettegen=max_colors=64:stats_mode=diff[p];[s1][p]paletteuse=dither=bayer:bayer_scale=2`,
1807 "-loop", "0",
1808 "-y",
1809 icon128GifPath,
1810 ];
1811
1812 await new Promise((resolve, reject) => {
1813 const ffmpeg = spawn("ffmpeg", ffmpegArgs, { stdio: "pipe" });
1814 let stderr = "";
1815 ffmpeg.stderr.on("data", (data) => { stderr += data.toString(); });
1816 ffmpeg.on("close", (code) => {
1817 if (code === 0) resolve();
1818 else reject(new Error(`FFmpeg failed with code ${code}: ${stderr}`));
1819 });
1820 ffmpeg.on("error", reject);
1821 });
1822
1823 this.bundledFiles.add(`tape icon animation: ${framePaths.length} frames @128x128`);
1824 this.options.faviconImage = `./icon/128x128/${this.pieceName}.gif`;
1825
1826 // Generate static PNG icons from final frame (most progressed state)
1827 const finalIndex = framePaths.length - 1;
1828 const finalBuffer = await fs.readFile(framePaths[finalIndex]);
1829 const finalPng = PNG.sync.read(finalBuffer);
1830
1831 const scaled128Raw = this.scaleImageNearest(finalPng.data, finalPng.width, finalPng.height, 128, 128);
1832 await this.writePngFromRaw(scaled128Raw, 128, 128, icon128PngPath);
1833 this.bundledFiles.add(`tape icon static: icon/128x128/${this.pieceName}.png`);
1834
1835 const scaled256Raw = this.scaleImageNearest(finalPng.data, finalPng.width, finalPng.height, 256, 256);
1836 await this.writePngFromRaw(scaled256Raw, 256, 256, icon256PngPath);
1837 this.bundledFiles.add(`tape icon static: icon/256x256/${this.pieceName}.png`);
1838
1839 const scaled512Raw = this.scaleImageNearest(finalPng.data, finalPng.width, finalPng.height, 512, 512);
1840 await this.writePngFromRaw(scaled512Raw, 512, 512, icon512PngPath);
1841 this.bundledFiles.add(`tape icon static: icon/512x512/${this.pieceName}.png`);
1842
1843 console.log("🖼️ Generated icon set from tape frames");
1844 }
1845
1846 async createTransparentFallbackIcons(iconDirs) {
1847 const icon128Path = path.join(iconDirs.icon128Dir, `${this.pieceName}.png`);
1848 const icon256Path = path.join(iconDirs.icon256Dir, `${this.pieceName}.png`);
1849 const icon512Path = path.join(iconDirs.icon512Dir, `${this.pieceName}.png`);
1850
1851 const transparent128Png = Buffer.from([
1852 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A,
1853 0x00, 0x00, 0x00, 0x0D,
1854 0x49, 0x48, 0x44, 0x52,
1855 0x00, 0x00, 0x00, 0x80,
1856 0x00, 0x00, 0x00, 0x80,
1857 0x08, 0x06, 0x00, 0x00, 0x00,
1858 0xC3, 0x3E, 0x61, 0xCB,
1859 0x00, 0x00, 0x00, 0x17,
1860 0x49, 0x44, 0x41, 0x54,
1861 0x78, 0x9C, 0xED, 0xC1, 0x01, 0x01, 0x00, 0x00, 0x00, 0x80, 0x90, 0xFE,
1862 0xAF, 0x6E, 0x48, 0x40, 0x00, 0x00, 0x00, 0x02, 0x10, 0x00, 0x01,
1863 0x8E, 0x0D, 0x71, 0xDA,
1864 0x00, 0x00, 0x00, 0x00,
1865 0x49, 0x45, 0x4E, 0x44,
1866 0xAE, 0x42, 0x60, 0x82
1867 ]);
1868
1869 const transparent256Png = Buffer.from([
1870 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A,
1871 0x00, 0x00, 0x00, 0x0D,
1872 0x49, 0x48, 0x44, 0x52,
1873 0x00, 0x00, 0x01, 0x00,
1874 0x00, 0x00, 0x01, 0x00,
1875 0x08, 0x06, 0x00, 0x00, 0x00,
1876 0x5C, 0x72, 0x9C, 0x91,
1877 0x00, 0x00, 0x00, 0x17,
1878 0x49, 0x44, 0x41, 0x54,
1879 0x78, 0x9C, 0xED, 0xC1, 0x01, 0x01, 0x00, 0x00, 0x00, 0x80, 0x90, 0xFE,
1880 0xAF, 0x6E, 0x48, 0x40, 0x00, 0x00, 0x00, 0x02, 0x10, 0x00, 0x01,
1881 0x8E, 0x0D, 0x71, 0xDA,
1882 0x00, 0x00, 0x00, 0x00,
1883 0x49, 0x45, 0x4E, 0x44,
1884 0xAE, 0x42, 0x60, 0x82
1885 ]);
1886
1887 const transparent512Png = Buffer.from([
1888 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A,
1889 0x00, 0x00, 0x00, 0x0D,
1890 0x49, 0x48, 0x44, 0x52,
1891 0x00, 0x00, 0x02, 0x00,
1892 0x00, 0x00, 0x02, 0x00,
1893 0x08, 0x06, 0x00, 0x00, 0x00,
1894 0xF4, 0x78, 0xD4, 0xFA,
1895 0x00, 0x00, 0x00, 0x17,
1896 0x49, 0x44, 0x41, 0x54,
1897 0x78, 0x9C, 0xED, 0xC1, 0x01, 0x01, 0x00, 0x00, 0x00, 0x80, 0x90, 0xFE,
1898 0xAF, 0x6E, 0x48, 0x40, 0x00, 0x00, 0x00, 0x02, 0x10, 0x00, 0x01,
1899 0x8E, 0x0D, 0x71, 0xDA,
1900 0x00, 0x00, 0x00, 0x00,
1901 0x49, 0x45, 0x4E, 0x44,
1902 0xAE, 0x42, 0x60, 0x82
1903 ]);
1904
1905 await fs.writeFile(icon128Path, transparent128Png);
1906 await fs.writeFile(icon256Path, transparent256Png);
1907 await fs.writeFile(icon512Path, transparent512Png);
1908 this.logVerbose(`🖼️ Created fallback stub icons: icon/128x128/${this.pieceName}.png, icon/256x256/${this.pieceName}.png and icon/512x512/${this.pieceName}.png`);
1909
1910 this.options.faviconImage = `./icon/128x128/${this.pieceName}.png`;
1911 }
1912
1913 async generateCover() {
1914 if (this.tapeInfo) {
1915 try {
1916 await this.generateCoverFromTape();
1917 return;
1918 } catch (error) {
1919 console.warn("⚠️ Tape-based cover generation failed, falling back to tape fallback cover:", error.message);
1920 try {
1921 await this.generateFallbackCoverFromTape();
1922 return;
1923 } catch (fallbackError) {
1924 console.error("❌ Both tape-based cover methods failed. Cannot use orchestrator when --tape is specified:", fallbackError.message);
1925 throw new Error("Tape-based cover generation failed; orchestrator is disabled when --tape is provided");
1926 }
1927 }
1928 }
1929
1930 // Try to generate an animated GIF cover using the piece
1931 try {
1932 const { RenderOrchestrator } = await import("../reference/tools/recording/orchestrator.mjs");
1933 const coverPath = path.join(this.options.outputDir, "cover.gif");
1934
1935 // Create a temporary output directory for GIF generation
1936 const tempOutputDir = path.join(this.options.outputDir, "temp-gif");
1937 await fs.mkdir(tempOutputDir, { recursive: true });
1938
1939 console.log("🎞️ Generating animated GIF cover...");
1940 console.log(`📁 Using piece: ${this.pieceName}`);
1941 console.log(`📁 Base outputDir: ${this.options.outputDir}`);
1942 console.log(`📁 Temp outputDir: ${tempOutputDir}`);
1943
1944 // Calculate render dimensions based on density
1945 // Base canvas resolution is 128x128 for crisp pixel art - this is the RECORDING resolution
1946 // The final GIF will be scaled up by the orchestrator based on density
1947 const currentDensity = this.options.density || 2; // Default density is 2
1948 const baseRecordingResolution = 128; // Always record at base resolution
1949 const coverSeconds = this.options.coverDurationSeconds || 3;
1950 const frameCount = this.options.coverFrameCount || Math.max(1, Math.round(coverSeconds * 60));
1951 const coverSecondsDisplay = Number.isInteger(coverSeconds) ? coverSeconds : coverSeconds.toFixed(2);
1952
1953 console.log(`🔍 Density: ${currentDensity}, Recording at: ${baseRecordingResolution}x${baseRecordingResolution}, GIF output will be scaled by orchestrator`);
1954 console.log(`⏱️ Cover duration: ${coverSecondsDisplay}s (${frameCount} frames @ 60fps)`);
1955
1956 // Use the RenderOrchestrator to generate GIF
1957 const orchestrator = new RenderOrchestrator(
1958 this.pieceName, // piece (supports KidLisp $code or .mjs files)
1959 frameCount, // render duration in frames
1960 tempOutputDir, // temporary directory for frame rendering
1961 baseRecordingResolution, // width - small recording resolution
1962 baseRecordingResolution, // height - small recording resolution
1963 {
1964 gifMode: true, // enable GIF mode
1965 density: currentDensity, // pass density parameter
1966 kidlispCache: this.kidlispCacheData, // pass KidLisp cache for dependencies
1967 extractIconFrame: false, // disable icon extraction - we handle icons separately
1968 iconOutputDir: this.options.outputDir, // output icon to main directory, not temp
1969 debugInkColors: this.options.logInkColors,
1970 }
1971 );
1972
1973 // Run the rendering
1974 await orchestrator.renderAll();
1975
1976 // Find the generated GIF file and move it to cover.gif
1977 // The GIF will be created in the same directory as tempOutputDir
1978 const searchDir = this.options.outputDir;
1979 const tempFiles = await fs.readdir(searchDir);
1980 const gifFile = tempFiles.find(file => file.endsWith('.gif') && file.includes(this.pieceName.replace('$', '')));
1981
1982 if (gifFile) {
1983 const sourcePath = path.join(searchDir, gifFile);
1984 await fs.copyFile(sourcePath, coverPath);
1985
1986 // Create external copy with zip naming pattern
1987 const externalCoverFilename = `${this.options.author}-${this.pieceName}-${this.zipTimestamp}-cover.gif`;
1988 const externalCoverPath = path.join(this.options.targetDir, externalCoverFilename);
1989 await fs.copyFile(sourcePath, externalCoverPath);
1990
1991 // Favicon will be set by the icon generation process
1992
1993 // Clean up temporary files
1994 await fs.rm(tempOutputDir, { recursive: true, force: true });
1995 await fs.unlink(sourcePath).catch(() => {}); // Ignore errors
1996
1997 console.log("🎞️ Generated animated cover: cover.gif");
1998 console.log(`🖼️ External cover created: ${externalCoverFilename}`);
1999 this.options.coverImage = "cover.gif";
2000 return;
2001 }
2002 } catch (error) {
2003 console.warn("⚠️ GIF generation failed, falling back to SVG:", error.message);
2004 console.warn(" Error details:", error.stack);
2005 }
2006
2007 // Fallback to SVG cover if GIF generation fails
2008 const coverPath = path.join(this.options.outputDir, "cover.svg");
2009 const coverSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
2010 <rect width="512" height="512" fill="#000"/>
2011 <text x="256" y="256" text-anchor="middle" dominant-baseline="middle" fill="#fff" font-family="monospace" font-size="20">${this.pieceName}</text>
2012</svg>`;
2013 await fs.writeFile(coverPath, coverSvg);
2014 console.log("🖼️ Generated fallback cover: cover.svg");
2015 this.options.coverImage = "cover.svg";
2016
2017 // Only use PNG favicon if GIF favicon wasn't already created
2018 if (!this.options.faviconImage.endsWith('.gif')) {
2019 this.options.faviconImage = this.getPackagedRelativePath('favicon.png');
2020 }
2021 }
2022
2023 async copyAssets() {
2024 // Copy cursor files
2025 try {
2026 const cursorsSourceDir = path.join(AC_DIR, "cursors");
2027 const cursorsOutputDir = this.getPackagedDirPath("cursors");
2028
2029 await fs.mkdir(cursorsOutputDir, { recursive: true });
2030
2031 // Copy all cursor files
2032 const cursorFiles = await fs.readdir(cursorsSourceDir);
2033
2034 for (const file of cursorFiles) {
2035 if (file.endsWith('.svg')) {
2036 const sourcePath = path.join(cursorsSourceDir, file);
2037 const outputPath = path.join(cursorsOutputDir, file);
2038 await fs.copyFile(sourcePath, outputPath);
2039 this.logVerbose(`📎 Bundled cursor: ${file}`);
2040 }
2041 }
2042 } catch (error) {
2043 console.log("ℹ️ Cursor directory error:", error.message);
2044 }
2045
2046 // Generate icons from tape frames or cover GIF
2047 const icon128Dir = path.join(this.options.outputDir, "icon", "128x128");
2048 const icon256Dir = path.join(this.options.outputDir, "icon", "256x256");
2049 const icon512Dir = path.join(this.options.outputDir, "icon", "512x512");
2050 await fs.mkdir(icon128Dir, { recursive: true });
2051 await fs.mkdir(icon256Dir, { recursive: true });
2052 await fs.mkdir(icon512Dir, { recursive: true });
2053
2054 const iconDirs = { icon128Dir, icon256Dir, icon512Dir };
2055
2056 if (this.tapeInfo) {
2057 try {
2058 await this.generateIconsFromTape(iconDirs);
2059 } catch (error) {
2060 console.warn("⚠️ Tape-based icon generation failed; creating transparent fallbacks:", error.message);
2061 try {
2062 await this.createTransparentFallbackIcons(iconDirs);
2063 } catch (fallbackError) {
2064 console.log("ℹ️ Error creating fallback icon stubs:", fallbackError.message);
2065 }
2066 }
2067 } else {
2068 try {
2069 const coverPath = path.join(this.options.outputDir, "cover.gif");
2070 if (!fsSync.existsSync(coverPath)) {
2071 throw new Error("No cover GIF available for stub icon generation");
2072 }
2073
2074 this.logVerbose(`🖼️ Generating stub icons for ${this.pieceName} using cover.gif...`);
2075
2076 await new Promise((resolve, reject) => {
2077 const ffmpeg = spawn("ffmpeg", [
2078 "-i", coverPath,
2079 "-vf", "scale=128:128:flags=neighbor",
2080 "-y",
2081 path.join(icon128Dir, `${this.pieceName}.gif`)
2082 ], { stdio: ["pipe", "pipe", "pipe"] });
2083
2084 let stderr = "";
2085 ffmpeg.stderr.on("data", (data) => {
2086 stderr += data.toString();
2087 });
2088
2089 ffmpeg.on("close", (code) => {
2090 if (code === 0) {
2091 this.logVerbose(`🪄 Generated 128x128 animated stub icon: icon/128x128/${this.pieceName}.gif`);
2092 this.options.faviconImage = `./icon/128x128/${this.pieceName}.gif`;
2093 resolve();
2094 } else {
2095 console.warn("⚠️ 128x128 animated stub icon generation failed with code:", code);
2096 console.warn("⚠️ FFmpeg stderr:", stderr);
2097 reject(new Error(`FFmpeg failed with code ${code}`));
2098 }
2099 });
2100
2101 ffmpeg.on("error", (err) => {
2102 console.warn("⚠️ FFmpeg error:", err.message);
2103 reject(err);
2104 });
2105 });
2106
2107 const staticIconConfigs = [
2108 { size: 128, output: path.join(icon128Dir, `${this.pieceName}.png`) },
2109 { size: 256, output: path.join(icon256Dir, `${this.pieceName}.png`) },
2110 { size: 512, output: path.join(icon512Dir, `${this.pieceName}.png`) }
2111 ];
2112
2113 for (const config of staticIconConfigs) {
2114 await new Promise((resolve, reject) => {
2115 const ffmpeg = spawn("ffmpeg", [
2116 "-i", coverPath,
2117 `-vf`, `scale=${config.size}:${config.size}:flags=neighbor,select=eq(n\\,90)`,
2118 "-vframes", "1",
2119 "-y",
2120 config.output
2121 ], { stdio: ["pipe", "pipe", "pipe"] });
2122
2123 let stderr = "";
2124 ffmpeg.stderr.on("data", (data) => {
2125 stderr += data.toString();
2126 });
2127
2128 ffmpeg.on("close", (code) => {
2129 if (code === 0) {
2130 this.logVerbose(`🪄 Generated ${config.size}x${config.size} static PNG icon: ${path.relative(this.options.outputDir, config.output)}`);
2131 resolve();
2132 } else {
2133 console.warn(`⚠️ ${config.size}x${config.size} static PNG icon generation failed with code:`, code);
2134 console.warn("⚠️ FFmpeg stderr:", stderr);
2135 reject(new Error(`FFmpeg failed with code ${code}`));
2136 }
2137 });
2138
2139 ffmpeg.on("error", (err) => {
2140 console.warn("⚠️ FFmpeg error:", err.message);
2141 reject(err);
2142 });
2143 });
2144 }
2145 } catch (error) {
2146 console.log("ℹ️ Stub icon generation failed, creating static PNG fallbacks:", error.message);
2147 try {
2148 await this.createTransparentFallbackIcons(iconDirs);
2149 } catch (fallbackError) {
2150 console.log("ℹ️ Error creating fallback icon stubs:", fallbackError.message);
2151 }
2152 }
2153 }
2154
2155 console.log("📎 Asset copying complete (minimal set)");
2156 }
2157
2158 patchStyleCssForObjkt(content) {
2159 // Enhance nogap mode for full viewport coverage
2160 console.log("🔧 Patching style.css for enhanced nogap support...");
2161
2162 // Replace precise.svg cursor with viewpoint.svg for PACK mode
2163 let patched = content.replace(
2164 /cursor:\s*url\(['"]?cursors\/precise\.svg['"]?\)\s*12\s*12,\s*auto;/g,
2165 "cursor: url('cursors/viewpoint.svg') 12 12, auto;"
2166 );
2167
2168 // Find the existing nogap rules and enhance them
2169 const nogapBodyPattern = /body\.nogap \{[^}]*\}/;
2170 const nogapComputerPattern = /body\.nogap #aesthetic-computer \{[^}]*\}/;
2171
2172 // Enhanced nogap body rules
2173 const enhancedNogapBody = `body.nogap {
2174 background-image: none;
2175 background-color: transparent !important;
2176 margin: 0 !important;
2177 padding: 0 !important;
2178 width: 100vw !important;
2179 height: 100vh !important;
2180 overflow: hidden !important;
2181}`;
2182
2183 // Enhanced nogap computer element rules
2184 const enhancedNogapComputer = `body.nogap #aesthetic-computer {
2185 border-radius: 0px;
2186 position: fixed !important;
2187 top: 0 !important;
2188 left: 0 !important;
2189 width: 100vw !important;
2190 height: 100vh !important;
2191 margin: 0 !important;
2192 padding: 0 !important;
2193}`; // Replace existing nogap rules or add them if they don't exist
2194 if (nogapBodyPattern.test(content)) {
2195 patched = patched.replace(nogapBodyPattern, enhancedNogapBody);
2196 } else {
2197 // Add the rule after the body rule
2198 patched = patched.replace(/body \{[^}]*\}/, match => match + '\n\n' + enhancedNogapBody);
2199 }
2200
2201 if (nogapComputerPattern.test(content)) {
2202 patched = patched.replace(nogapComputerPattern, enhancedNogapComputer);
2203 } else {
2204 // Add after the #aesthetic-computer rule
2205 patched = patched.replace(/#aesthetic-computer \{[^}]*\}/, match => match + '\n\n' + enhancedNogapComputer);
2206 }
2207
2208 return patched;
2209 }
2210
2211 patchTypeJsForObjkt(content) {
2212 console.log("🔧 Patching type.mjs for pack mode...");
2213
2214 let patched = content;
2215
2216 // Skip font_1 and microtype glyph loading in PACK mode
2217 // These drawing files are not bundled, so prevent 404 errors
2218 // CRITICAL: Must return 'this' to ensure typeface object is properly initialized
2219
2220 // Patch font_1 loading - insert PACK check at start of the block
2221 const font1Check = `// Skip font_1 loading in PACK mode - glyphs not bundled
2222 const { checkPackMode } = await import("./pack-mode.mjs");
2223 const isPackMode = checkPackMode();
2224 if (isPackMode) {
2225 return this; // CRITICAL: Return this for proper typeface initialization
2226 }
2227 `;
2228
2229 // Use regex to match the font_1 block with flexible whitespace
2230 patched = patched.replace(
2231 /(if\s*\(this\.name\s*===\s*"font_1"\)\s*\{)\s*(\/\/\s*1\.\s*Ignore)/,
2232 `$1\n ${font1Check}$2`
2233 );
2234
2235 // Patch microtype loading - insert PACK check at start of the block
2236 const microtypeCheck = `// Skip microtype loading in PACK mode - glyphs not bundled
2237 const { checkPackMode: checkPackMode2 } = await import("./pack-mode.mjs");
2238 const isPackMode2 = checkPackMode2();
2239 if (isPackMode2) {
2240 return this; // CRITICAL: Return this for proper typeface initialization
2241 }
2242 `;
2243
2244 // Use regex to match the microtype block with flexible whitespace
2245 patched = patched.replace(
2246 /(}\s*else\s*if\s*\(this\.name\s*===\s*"microtype"\)\s*\{)\s*(\/\/\s*Load\s*microtype)/,
2247 `$1\n ${microtypeCheck}$2`
2248 );
2249
2250 // Suppress fetch errors for missing MatrixChunky8 font files in PACK mode
2251 const fetchPattern = /const response = await fetch\(glyphPath\);[\s\S]*?if \(!response\.ok\) \{\s*throw new Error\(`HTTP \$\{response\.status\}: \$\{response\.statusText\}`\);\s*\}/;
2252 patched = patched.replace(fetchPattern,
2253 "const response = await fetch(glyphPath);\n if (!response.ok) {\n // Silently fail for missing font files in PACK mode to avoid console errors\n throw new Error(`HTTP ${response.status}: ${response.statusText}`);\n }"
2254 );
2255
2256 return patched;
2257 }
2258
2259 async patchBootJsForObjkt(content) {
2260 console.log('🎨 Patching boot.mjs for teia dependency URLs and TV mode...');
2261
2262 let patched = content;
2263 const packagedAuth0Path = this.getPackagedRelativePath('dep', 'auth0-spa-js.production.js');
2264
2265 // Force TV mode (non-interactive) when in PACK mode
2266 const tvParamPattern = /const tv = tvParam === true \|\| tvParam === "true";/;
2267 patched = patched.replace(tvParamPattern,
2268 `const tv = tvParam === true || tvParam === "true" || window.acPACK_MODE;`
2269 );
2270
2271 // Fix auth0 script URL to use relative path in pack mode
2272 const auth0Pattern = /script\.src = "\/aesthetic\.computer\/dep\/auth0-spa-js\.production\.js";/;
2273 patched = patched.replace(auth0Pattern,
2274 `// Check if we're in pack mode for relative path
2275 const isObjktMode = (typeof window !== 'undefined' && window.acPACK_MODE) ||
2276 (typeof globalThis !== 'undefined' && globalThis.acPACK_MODE);
2277
2278 if (isObjktMode) {
2279 script.src = "${packagedAuth0Path}";
2280 } else {
2281 script.src = "/aesthetic.computer/dep/auth0-spa-js.production.js";
2282 }`
2283 );
2284
2285 return patched;
2286 }
2287
2288 patchKidLispJsForObjkt(content) {
2289 // kidlisp.mjs now has built-in OBJKT support via getCachedCodeMultiLevel
2290 // No patching needed anymore
2291 return content;
2292 } async patchDiskJsForObjkt(content) {
2293 console.log('🔧 Patching disk.mjs for PACK mode...');
2294
2295 let patched = content;
2296
2297 // Add pack mode check to prevent session connections
2298 const socketPattern = /if \(\s*\/\/parsed\.search\?\.startsWith\("preview"\) \|\|\s*\/\/parsed\.search\?\.startsWith\("icon"\)\s*previewOrIconMode\s*\) \{/;
2299 patched = patched.replace(socketPattern,
2300 `if (
2301 //parsed.search?.startsWith("preview") ||
2302 //parsed.search?.startsWith("icon")
2303 previewOrIconMode ||
2304 (typeof window !== 'undefined' && window.acPACK_MODE) ||
2305 (typeof globalThis !== 'undefined' && globalThis.acPACK_MODE)
2306 ) {`
2307 );
2308
2309 return patched;
2310 }
2311
2312 patchUdpJsForObjkt(content) {
2313 console.log('🔧 Patching udp.mjs for PACK mode...');
2314
2315 // Replace the entire UDP module with a stub that provides the same API
2316 // but doesn't try to import geckos or establish network connections
2317 const teiaStub = `// UDP stub for PACK mode - networking disabled for offline use
2318
2319const logs = { udp: false }; // Disable UDP logging in PACK mode
2320
2321let connected = false;
2322
2323// Stub functions that match the original UDP API but don't do networking
2324function connect(port = 8889, url = undefined, send) {
2325 if (logs.udp) console.log("🩰 UDP disabled in PACK mode");
2326 connected = false; // Always stay disconnected in PACK mode
2327 return;
2328}
2329
2330function disconnect() {
2331 if (logs.udp) console.log("🩰 UDP disconnect (PACK mode)");
2332 connected = false;
2333}
2334
2335function send(data, options = {}) {
2336 if (logs.udp) console.log("🩰 UDP send disabled in PACK mode:", data);
2337 // No-op in PACK mode
2338}
2339
2340function isConnected() {
2341 return false; // Always disconnected in PACK mode
2342}
2343
2344// Create UDP object that matches the expected API structure
2345const UDP = { connect, disconnect, send, isConnected };
2346
2347// Export the same API as the original UDP module
2348export { connect, disconnect, send, isConnected, UDP };
2349export default { connect, disconnect, send, isConnected, UDP };
2350`;
2351
2352 return teiaStub;
2353 }
2354
2355 patchHeadersJsForObjkt(content) {
2356 console.log('🎨 Patching headers.mjs for teia import statements...');
2357
2358 let patched = content;
2359
2360 // Replace the import statement with stub functions that preserve functionality
2361 // but don't rely on external module loading
2362 const stubFunctions = `
2363// Inlined color-highlighting functions for PACK mode (to avoid import 404s)
2364function colorizeColorName(colorName) {
2365 // Simple colorization - just return the color name for now in PACK mode
2366 return colorName;
2367}
2368
2369function getColorTokenHighlight(token) {
2370 // Basic color highlighting for common KidLisp tokens
2371 const colorMap = {
2372 'purple': '#9575cd',
2373 'blue': '#42a5f5',
2374 'red': '#ef5350',
2375 'green': '#66bb6a',
2376 'yellow': '#ffee58',
2377 'orange': '#ff7043',
2378 'pink': '#ec407a',
2379 'cyan': '#26c6da',
2380 'ink': '#90a4ae',
2381 'line': '#78909c',
2382 'blur': '#607d8b'
2383 };
2384
2385 return colorMap[token] || null;
2386}`;
2387
2388 // Replace the import statement with the stub functions
2389 patched = patched.replace(
2390 /^import\s+\{[^}]+\}\s+from\s+"\.\/color-highlighting\.mjs";?\s*$/m,
2391 '// Import replaced with inline functions for PACK mode' + stubFunctions
2392 );
2393
2394 return patched;
2395 }
2396
2397 async patchBiosJsForObjkt(content) {
2398 console.log('🎨 Patching bios.mjs for teia webfont URLs...');
2399
2400 let patched = content;
2401 const packagedCursorPath = this.getPackagedRelativePath('cursors', 'viewpoint.svg');
2402 const packagedDiskLibPath = this.getPackagedRelativePath('lib', 'disk.mjs');
2403
2404 // Replace precise.svg cursor references with viewpoint.svg for PACK mode
2405 patched = patched.replace(
2406 /\/aesthetic\.computer\/cursors\/precise\.svg/g,
2407 packagedCursorPath
2408 );
2409
2410 // Replace the font URL logic to use relative paths in pack mode
2411 const fontUrlPattern = /\/\/ Use origin-aware font loading\s*let fontUrl;\s*try \{[\s\S]*?link\.href = fontUrl;/;
2412 patched = patched.replace(fontUrlPattern,
2413 `// Use origin-aware font loading with pack mode support
2414 let fontUrl;
2415 try {
2416 // Check if we're in pack mode
2417 const isObjktMode = (typeof window !== 'undefined' && window.acPACK_MODE) ||
2418 (typeof globalThis !== 'undefined' && globalThis.acPACK_MODE);
2419
2420 if (isObjktMode) {
2421 // In pack mode, use relative path to bundled webfonts
2422 fontUrl = "./type/webfonts/" + font;
2423 } else {
2424 // Check if we're in development environment
2425 const isDevelopment = location.hostname === 'localhost' && location.port;
2426 if (isDevelopment) {
2427 // In development, fonts are served from the root /type/webfonts/ path
2428 fontUrl = "/type/webfonts/" + font;
2429 } else {
2430 // In production or sandboxed iframe, use the standard path
2431 fontUrl = "/type/webfonts/" + font;
2432 }
2433 }
2434 } catch (err) {
2435 // Fallback to standard path if there's any error
2436 fontUrl = "/type/webfonts/" + font;
2437 }
2438
2439 link.href = fontUrl;`
2440 );
2441
2442 // Also patch the worker path to use relative paths in pack mode
2443 const workerPathPattern = /const fullPath =\s*"\/aesthetic\.computer\/lib\/disk\.mjs"\s*\+\s*window\.location\.search\s*\+\s*"#"\s*\+\s*Date\.now\(\);/;
2444 patched = patched.replace(workerPathPattern,
2445 `const fullPath = (typeof window !== 'undefined' && window.acPACK_MODE) ?
2446 "${packagedDiskLibPath}" + "#" + Date.now() :
2447 "/aesthetic.computer/lib/disk.mjs" + window.location.search + "#" + Date.now();`
2448 );
2449
2450 // Also patch the initial piece parsing to prioritize acSTARTING_PIECE in pack mode
2451 const pieceParsingPattern = /const parsed = parse\(sluggy \|\| window\.acSTARTING_PIECE\);/;
2452 patched = patched.replace(pieceParsingPattern,
2453 `const parsed = parse((typeof window !== 'undefined' && window.acPACK_MODE && window.acSTARTING_PIECE) ?
2454 window.acSTARTING_PIECE :
2455 (sluggy || window.acSTARTING_PIECE));`
2456 );
2457
2458 // Patch the worklet loading to skip in PACK mode to prevent AbortError
2459 const workletPattern = /\/\/ Sound Synthesis Processor\s*try \{\s*\(async \(\) => \{/;
2460 patched = patched.replace(workletPattern,
2461 `// Sound Synthesis Processor
2462 try {
2463 // Skip worklet loading in PACK mode to prevent AbortError
2464 const isObjktMode = (typeof window !== 'undefined' && window.acPACK_MODE) ||
2465 (typeof globalThis !== 'undefined' && globalThis.acPACK_MODE);
2466
2467 if (isObjktMode) {
2468 if (debug) console.log("🎭 Skipping audio worklet loading in PACK mode");
2469 return;
2470 }
2471
2472 (async () => {`
2473 );
2474
2475 // Patch worker detection to disable workers in sandboxed environments like OBJKT
2476 const workerDetectionPattern = /\/\/ Override: force disable workers only for specific problematic environments\s*if \(sandboxed && window\.origin === "null" && !window\.acPACK_MODE\) \{\s*\/\/ Only disable for truly sandboxed non-TEIA environments\s*workersEnabled = false;\s*\}/;
2477 patched = patched.replace(workerDetectionPattern,
2478 `// Override: force disable workers for OBJKT and other sandboxed environments
2479 if (sandboxed || window.origin === "null") {
2480 // Disable workers in any sandboxed environment, including OBJKT
2481 workersEnabled = false;
2482 if (debug) console.log("🚫 Workers disabled due to sandboxed/null origin environment");
2483 }`
2484 );
2485
2486 // Suppress console errors for missing MatrixChunky8 font files in PACK mode
2487 const consoleInitPattern = /\/\/ Boot\s*let bootTime/;
2488 patched = patched.replace(consoleInitPattern,
2489 `// Boot - suppress font loading errors in PACK mode
2490 if (typeof window !== 'undefined' && window.acPACK_MODE) {
2491 const originalError = console.error;
2492 console.error = function(...args) {
2493 const message = args.join(' ');
2494 // Skip MatrixChunky8 font loading errors
2495 if (message.includes('Failed to load resource') &&
2496 message.includes('MatrixChunky8') &&
2497 message.includes('404')) {
2498 return; // Silently ignore these errors
2499 }
2500 originalError.apply(console, args);
2501 };
2502 }
2503
2504 let bootTime`
2505 );
2506
2507 // Normalize any remaining relative references to the packaged system directory
2508 patched = patched.replace(/\.\/aesthetic\.computer\//g, `${this.packagedSystemBaseHref}/`);
2509
2510 return patched;
2511 }
2512
2513 async patchParseJsForObjkt(content) {
2514 console.log('🎨 Patching parse.mjs for pack mode piece override...');
2515
2516 let patched = content;
2517
2518 // Override slug function to return acSTARTING_PIECE in pack mode
2519 const slugFunctionPattern = /function slug\(url\) \{[\s\S]*?return cleanedUrl;\s*\}/;
2520 patched = patched.replace(slugFunctionPattern,
2521 `function slug(url) {
2522 // In pack mode, always prioritize acSTARTING_PIECE over URL parsing
2523 if ((typeof window !== 'undefined' && window.acPACK_MODE && window.acSTARTING_PIECE)) {
2524 return window.acSTARTING_PIECE;
2525 }
2526
2527 //console.log("🐛 slug() input:", url);
2528
2529 // Remove http protocol and host from current url before feeding it to parser.
2530 let cleanedUrl = url
2531 .replace(/^http(s?):\/\//i, "")
2532 .replace(window.location.hostname + ":" + window.location.port + "/", "")
2533 .replace(window.location.hostname + "/", "")
2534 .split("#")[0]; // Remove any hash.
2535
2536 //console.log("🐛 slug() after host removal:", cleanedUrl);
2537
2538 // Use safe parameter removal instead of .split("?")[0]
2539 cleanedUrl = getCleanPath(cleanedUrl);
2540
2541 //console.log("🐛 slug() after getCleanPath:", cleanedUrl);
2542
2543 // Decode URL-encoded characters first
2544 cleanedUrl = decodeURIComponent(cleanedUrl);
2545
2546 //console.log("🐛 slug() after decodeURIComponent:", cleanedUrl);
2547
2548 // Only apply kidlisp URL decoding if this actually looks like kidlisp code
2549 if (isKidlispSource(cleanedUrl)) {
2550 //console.log("🐛 slug() detected as KidLisp, decoding...");
2551 return decodeKidlispFromUrl(cleanedUrl);
2552 }
2553
2554 //console.log("🐛 slug() final result:", cleanedUrl);
2555 return cleanedUrl;
2556}`
2557 );
2558
2559 return patched;
2560 }
2561
2562 async getAllFilesRecursively(dir) {
2563 const files = [];
2564 const entries = await fs.readdir(dir, { withFileTypes: true });
2565
2566 for (const entry of entries) {
2567 const fullPath = path.join(dir, entry.name);
2568 if (entry.isDirectory()) {
2569 files.push(...await this.getAllFilesRecursively(fullPath));
2570 } else {
2571 files.push(fullPath);
2572 }
2573 }
2574
2575 return files;
2576 }
2577
2578 async convertMjsModulesToJs() {
2579 const allFiles = await this.getAllFilesRecursively(this.options.outputDir);
2580 const mjsFiles = allFiles.filter(filePath => filePath.endsWith(".mjs"));
2581
2582 if (mjsFiles.length === 0) {
2583 return;
2584 }
2585
2586 for (const filePath of mjsFiles) {
2587 const fileContent = await fs.readFile(filePath, "utf8");
2588 const updatedContent = this.updateModuleSpecifierExtensions(fileContent);
2589 const jsPath = filePath.slice(0, -4) + ".js";
2590 await fs.writeFile(jsPath, updatedContent);
2591 await fs.rm(filePath);
2592 }
2593
2594 const refreshedFiles = await this.getAllFilesRecursively(this.options.outputDir);
2595 const updateTargets = refreshedFiles.filter(filePath =>
2596 /(\.js|\.json|\.html|\.css|\.txt|\.svg)$/i.test(filePath)
2597 );
2598
2599 for (const filePath of updateTargets) {
2600 const fileContent = await fs.readFile(filePath, "utf8");
2601 const updatedContent = this.updateModuleSpecifierExtensions(fileContent);
2602 if (updatedContent !== fileContent) {
2603 await fs.writeFile(filePath, updatedContent);
2604 }
2605 }
2606
2607 console.log(`🔁 Converted ${mjsFiles.length} .mjs files to .js for platform compatibility`);
2608 }
2609
2610 updateModuleSpecifierExtensions(content) {
2611 return content.replace(/\.mjs\b/g, ".js");
2612 }
2613}
2614
2615// Main execution
2616async function main() {
2617 const pieceName = process.argv[2];
2618 const args = process.argv.slice(3);
2619
2620 if (!pieceName) {
2621 console.error("Usage: node ac-pack.mjs <piece-name> [options]");
2622 console.error("Options:");
2623 console.error(" --density <value> Set GIF output density scaling");
2624 console.error(" --gif-length <seconds> Set cover GIF length in seconds (default 3)");
2625 console.error(" --target-dir <path> Directory where ZIP and cover should be created");
2626 console.error(" --tape <zip> Use a pre-recorded tape ZIP instead of rendering live");
2627 console.error(" --tape-start <percent> Start cover GIF at this percentage (0-100, default 0)");
2628 console.error(" --analyze Show dependency analysis without building");
2629 console.error(" --verbose Enable detailed asset/glyph logging");
2630 console.error(" --quiet Suppress detailed asset/glyph logging (default)");
2631 console.error(" --log-ink Log resolved colors for ink() and flood() during renders");
2632 console.error("");
2633 console.error("Examples:");
2634 console.error(" node ac-pack.mjs '$bop' --density 8");
2635 console.error(" node ac-pack.mjs '$bop' --analyze");
2636 console.error(" node ac-pack.mjs '$bop' --target-dir /path/to/output");
2637 console.error(" node ac-pack.mjs '$4bb' --tape ./tape.zip");
2638 console.error(" node ac-pack.mjs '$4bb' --tape ./tape.zip --tape-start 50");
2639 console.error("");
2640 console.error("💡 Tip: Run without --tape to be asked if you want to record one first!");
2641 process.exit(1);
2642 }
2643
2644 // Parse command line arguments
2645 const options = {};
2646 let analyzeOnly = false;
2647 let autoShip = false;
2648 let hasTapeArg = false;
2649
2650 for (let i = 0; i < args.length; i++) {
2651 if (args[i] === '--density' && i + 1 < args.length) {
2652 const densityValue = parseFloat(args[i + 1]);
2653 if (!isNaN(densityValue) && densityValue > 0) {
2654 options.density = densityValue;
2655 console.log(`🔍 Custom density: ${densityValue}`);
2656 } else {
2657 console.error("❌ Invalid density value. Must be a positive number.");
2658 process.exit(1);
2659 }
2660 i++; // Skip the next argument since we consumed it
2661 } else if (args[i] === '--gif-length' && i + 1 < args.length) {
2662 const secondsValue = parseFloat(args[i + 1]);
2663 if (!isNaN(secondsValue) && secondsValue > 0) {
2664 options.coverDurationSeconds = secondsValue;
2665 console.log(`⏱️ Cover GIF length: ${secondsValue}s`);
2666 } else {
2667 console.error("❌ Invalid GIF length. Must be a positive number of seconds.");
2668 process.exit(1);
2669 }
2670 i++;
2671 } else if (args[i] === '--target-dir' && i + 1 < args.length) {
2672 options.targetDir = args[i + 1];
2673 console.log(`🎯 Target directory: ${options.targetDir}`);
2674 i++; // Skip the next argument since we consumed it
2675 } else if (args[i] === '--tape' && i + 1 < args.length) {
2676 options.tapePath = args[i + 1];
2677 hasTapeArg = true;
2678 console.log(`📼 Tape source provided: ${options.tapePath}`);
2679 i++;
2680 } else if (args[i] === '--tape-start' && i + 1 < args.length) {
2681 options.tapeStartPercent = parseFloat(args[i + 1]);
2682 if (isNaN(options.tapeStartPercent) || options.tapeStartPercent < 0 || options.tapeStartPercent > 100) {
2683 console.error("❌ Invalid tape start percentage. Must be between 0 and 100.");
2684 process.exit(1);
2685 }
2686 console.log(`⏩ Tape start position: ${options.tapeStartPercent}%`);
2687 i++;
2688 } else if (args[i] === '--analyze') {
2689 analyzeOnly = true;
2690 } else if (args[i] === '--auto-ship') {
2691 autoShip = true;
2692 } else if (args[i] === '--verbose') {
2693 options.verbose = true;
2694 } else if (args[i] === '--quiet') {
2695 options.verbose = false;
2696 } else if (args[i] === '--log-ink') {
2697 options.logInkColors = true;
2698 }
2699 }
2700
2701 // Interactive tape recording prompt (only if no --tape provided and not in analyze mode)
2702 if (!hasTapeArg && !analyzeOnly && process.stdin.isTTY) {
2703 console.log("");
2704 console.log("📼 Tape Recording Option");
2705 console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
2706 console.log("Using a pre-recorded tape gives you:");
2707 console.log(" • Full control over the animation recording");
2708 console.log(" • 4 optimized covers: main, Twitter/X, objkt.com, icons");
2709 console.log(" • Consistent output across all platforms");
2710 console.log("");
2711 console.log("To record a tape:");
2712 console.log(` 1. Visit: https://aesthetic.computer/${pieceName}`);
2713 console.log(" 2. Press 'r' to start recording");
2714 console.log(" 3. Let it animate, then press 'r' again to stop");
2715 console.log(" 4. Download the tape ZIP file");
2716 console.log("");
2717
2718 const answer = await askQuestion("Would you like to record a tape first? (y/N): ");
2719
2720 if (answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes') {
2721 console.log("");
2722 console.log("📝 Instructions:");
2723 console.log(` 1. Open: https://aesthetic.computer/${pieceName}`);
2724 console.log(" 2. Press 'r' to start recording");
2725 console.log(" 3. Wait for your animation to complete");
2726 console.log(" 4. Press 'r' again to stop");
2727 console.log(" 5. Download the tape ZIP");
2728 console.log(" 6. Move it to the teia/output/ directory");
2729 console.log("");
2730 console.log("When ready, run:");
2731 console.log(` node teia/ac-pack.mjs ${pieceName} --tape ./objkt/output/<tape-file>.zip --tape-start 50`);
2732 console.log("");
2733 console.log("💡 Tip: Use --tape-start to skip initial loading frames (0-100%)");
2734 console.log("");
2735 process.exit(0);
2736 }
2737
2738 console.log("▶️ Continuing with live rendering (orchestrator mode)...");
2739 console.log("");
2740 }
2741
2742 // If analyze-only mode, just show dependency analysis
2743 if (analyzeOnly) {
2744 console.log(`🔍 Analyzing dependencies for ${pieceName}...`);
2745
2746 try {
2747 const tempPacker = new AcPacker(pieceName, options);
2748 const pieceData = await tempPacker.loadPiece();
2749
2750 const { DependencyAnalyzer } = await import('./dependency-analyzer.mjs');
2751 const analyzer = new DependencyAnalyzer();
2752
2753 const pieceCode = pieceData.sourceCode || '';
2754 const pieceSystem = pieceData.system || '';
2755 const analysis = analyzer.analyzePiece(pieceCode, pieceSystem);
2756
2757 console.log('\n' + analyzer.generateReport(analysis));
2758 return;
2759 } catch (error) {
2760 console.error("❌ Analysis failed:", error.message);
2761 process.exit(1);
2762 }
2763 }
2764
2765 // Set default density to 4 for PACK mode if not specified
2766 if (!options.density) {
2767 options.density = 4;
2768 console.log('🔍 Using default density: 4');
2769 }
2770
2771 const packer = new AcPacker(pieceName, options);
2772 const result = await packer.pack();
2773
2774 if (!result.success) {
2775 console.error("Packing failed:", result.error);
2776 process.exit(1);
2777 }
2778
2779 // Interactive testing and zip creation
2780 console.log("");
2781 console.log("🎉 Package assets generated successfully!");
2782 console.log(`📁 Directory: ${packer.options.outputDir}`);
2783 console.log("");
2784
2785 // Auto-create zip with timestamp
2786 console.log("� Package ready for OBJKT deployment!");
2787 console.log(" • All assets bundled locally");
2788 console.log(" • Font loading fixed for offline use");
2789 console.log(" • Session connections disabled");
2790 console.log(" • PACK mode styling enabled");
2791 console.log("");
2792
2793 // Automatically create zip with timestamp
2794 console.log("📦 Creating zip file...");
2795 try {
2796 const zipResult = await createZipWithTimestamp(packer.options.outputDir, packer.pieceName, packer.zipTimestamp, packer.options.author, packer.options.targetDir);
2797 console.log("");
2798 console.log("🎉 Success! Your package is ready:");
2799 console.log(`📁 Directory: ${packer.options.outputDir}`);
2800 console.log(`📦 Zip file: ${zipResult.zipPath}`);
2801 console.log("");
2802
2803 // Auto-ship to Electron if requested
2804 if (autoShip) {
2805 console.log("🚀 Auto-shipping to Electron...");
2806 try {
2807 const { ElectronShipper } = await import('./ac-ship.mjs');
2808 const shipper = new ElectronShipper();
2809
2810 // Use the run method which is the main entry point
2811 await shipper.run(zipResult.zipPath, ['linux']); // Start with Linux only for testing
2812
2813 console.log("✅ Electron apps built successfully!");
2814 console.log("📱 Desktop apps ready for distribution");
2815 console.log("");
2816 console.log("🚀 Next steps:");
2817 console.log("1. Test your desktop app");
2818 console.log("2. Distribute to users");
2819 console.log("3. Also consider uploading to teia.art or objkt.com for web access");
2820 } catch (error) {
2821 console.log("❌ Auto-ship failed:", error.message);
2822 }
2823 console.log("");
2824 } else {
2825 console.log("🚀 Next steps:");
2826 console.log("1. Go to https://teia.art/mint");
2827 console.log(`2. Upload ${path.basename(zipResult.zipPath)}`);
2828 console.log("3. Preview and test your interactive OBJKT");
2829 console.log("4. Mint when ready!");
2830 console.log("");
2831 }
2832
2833 // Clean up build artifacts (keep only the zip)
2834 await packer.cleanup();
2835 } catch (error) {
2836 console.error("❌ Failed to create zip:", error.message);
2837 process.exit(1);
2838 }
2839}
2840
2841async function createZipWithTimestamp(outputDir, pieceName, timeStr, author = "@jeffrey", targetDir = null) {
2842 const archiver = (await import('archiver')).default;
2843
2844 // Use targetDir if provided, otherwise use parent of outputDir
2845 const zipPath = path.join(targetDir || path.dirname(outputDir), `${author}-${pieceName}-${timeStr}.zip`);
2846
2847 const output = fsSync.createWriteStream(zipPath);
2848 const archive = archiver('zip', { zlib: { level: 9 } });
2849
2850 return new Promise((resolve, reject) => {
2851 output.on('close', () => {
2852 const sizeBytes = archive.pointer();
2853 const sizeMB = (sizeBytes / (1024 * 1024)).toFixed(2);
2854 console.log(`📦 Created zip: ${zipPath} (${sizeBytes} bytes / ${sizeMB} MB)`);
2855 resolve({ zipPath, sizeBytes });
2856 });
2857
2858 archive.on('error', reject);
2859 archive.pipe(output);
2860 archive.directory(outputDir, false);
2861 archive.finalize();
2862 });
2863}
2864
2865if (import.meta.url === `file://${process.argv[1]}`) {
2866 main();
2867}
2868