PageSourceSearch

https://notepat.com/aesthetic.computer/bios.mjs

js notepat.com collected 2026-10-04 02:18:56 UTC 972,430 bytes, 24,441 lines download raw bytes

1import { installIOSPushBridge } from "./lib/ios-push.mjs";
2import { NOPAINT_SESSION_SEED_KEY, noPaintHistoryTarget } from "./lib/nopaint-navigation.mjs";
3import { visitMediaAction } from "./lib/visit-model.mjs";
4import { startAccountActivity } from "./lib/account-activity.mjs";
5
6// 💻 BIOS
7
8// 📦 All Imports
9import * as Loop from "./lib/loop.mjs";
10import * as perf from "./lib/perf.mjs";
11import { Pen } from "./lib/pen.mjs";
12import { Box } from "./lib/geo.mjs";
13import { Keyboard } from "./lib/keyboard.mjs";
14import { Gamepad } from "./lib/gamepad.mjs";
15import { createOutputWaveform } from "./lib/output-waveform.mjs";
16import { startCapturingMotion, stopCapturingMotion } from "./lib/motion.mjs";
17import { speak, speakAPI } from "./lib/speech.mjs";
18import * as UI from "./lib/ui.mjs";
19import * as Glaze from "./lib/glaze.mjs";
20import { apiObject, extension } from "./lib/helpers.mjs";
21import { choose } from "./lib/help.mjs";
22import { parse, slug } from "./lib/parse.mjs";
23import { isKidlispSource, encodeKidlispForUrl, getSyntaxHighlightingColors, isKidlispConsoleEnabled, postKidlispConsoleImage, getCachedCode } from "./lib/kidlisp.mjs";
24import * as Store from "./lib/store.mjs";
25import * as MIDI from "./lib/midi.mjs";
26import * as USB from "./lib/usb.mjs";
27import { 
28  renderVHSProgressBar,
29  calculateVHSEffects,
30  getLeaderPixelColor,
31  blendColorWithVHS
32} from "./disks/common/tape-player.mjs";
33import * as Platform from "./lib/platform.mjs";
34import { headers } from "./lib/headers.mjs";
35import { logs, log } from "./lib/logs.mjs";
36import { checkPackMode } from "./lib/pack-mode.mjs";
37import { captureFrame, formatTimestamp } from "./lib/frame-capture.mjs";
38import {
39  captureMediaCreated,
40  captureProductAction,
41} from "./lib/product-analytics.mjs";
42import { soundWhitelist } from "./lib/sound/sound-whitelist.mjs";
43import { timestamp, radians } from "./lib/num.mjs";
44import * as graph from "./lib/graph.mjs";
45import * as WebGPU from "./lib/webgpu.mjs";
46import { initGPU, switchBackend } from "./lib/gpu/index.mjs"; // 🎨 New backend system (auto-registers backends)
47import { createWebGLBlitter } from "./lib/webgl-blit.mjs";
48import * as CaptureSession from "./lib/capture-session.mjs";
49import * as SoundOnFilm from "./lib/sound-on-film.mjs";
50
51window.addEventListener("ac:handle-created", () => captureProductAction("handle_created"));
52
53// import * as TwoD from "./lib/2d.mjs"; // 🆕 2D GPU Renderer.
54const TwoD = undefined;
55const {
56  MetaBrowser = false,
57  iOS = false,
58  Android = false,
59  TikTok = false,
60  Safari = false,
61  Aesthetic = false,
62  AestheticExtension = false,
63  AestheticIOSApp = false,
64} = Platform;
65const chooseCompactVideoMime =
66  CaptureSession.chooseCompactVideoMime || (() => null);
67const getCameraCaptureSize =
68  CaptureSession.getCameraCaptureSize ||
69  ((requestedWidth, requestedHeight) => {
70    const even = (value) =>
71      Math.max(2, Math.round((Number(value) || 2) / 2) * 2);
72    return { width: even(requestedWidth), height: even(requestedHeight) };
73  });
74const measureCaptureAVSync =
75  CaptureSession.measureCaptureAVSync || (() => null);
76const shouldResumeCaptureRecorder =
77  CaptureSession.shouldResumeCaptureRecorder ||
78  ((state, freshSession = false) => state === "paused" && !freshSession);
79const attachSoundtrackToFrames =
80  SoundOnFilm.attachSoundtrackToFrames || ((frames) => frames);
81const frameIndexForSoundtrackProgress =
82  SoundOnFilm.frameIndexForSoundtrackProgress ||
83  ((frames, progress) => {
84    if (!Array.isArray(frames) || frames.length === 0) return -1;
85    return Math.max(
86      0,
87      Math.min(frames.length - 1, Math.floor(progress * frames.length)),
88    );
89  });
90const normalizeTapeAudio =
91  SoundOnFilm.normalizeTapeAudio ||
92  (() => ({ gain: 1, rms: 0, peak: 0, normalized: false }));
93let hadVectorOverlay = false; // so the vector overlay clears once when a piece drops it
94
95// 🪟 Replay a piece's vector-overlay display-list onto the native UI canvas — crisp
96// anti-aliased rounded rects, text and lines above the pixel buffer. Cleared fresh
97// each frame (the piece re-sends every paint). Coords are logical; the ctx is
98// already dpi-scaled by the caller.
99function drawVectorOverlay(ctx, ops) {
100  if (!ctx || !ops) return;
101  const rgba = (c) => `rgba(${c?.[0] || 0},${c?.[1] || 0},${c?.[2] || 0},${(c?.[3] ?? 255) / 255})`;
102  const round = (x, y, w, h, r) => {
103    const rr = Math.min(r, w / 2, h / 2);
104    ctx.beginPath();
105    ctx.moveTo(x + rr, y);
106    ctx.arcTo(x + w, y, x + w, y + h, rr);
107    ctx.arcTo(x + w, y + h, x, y + h, rr);
108    ctx.arcTo(x, y + h, x, y, rr);
109    ctx.arcTo(x, y, x + w, y, rr);
110    ctx.closePath();
111  };
112  for (const op of ops) {
113    const k = op[0];
114    if (k === "rr") {
115      round(op[1], op[2], op[3], op[4], op[5]);
116      ctx.fillStyle = rgba(op[6]);
117      ctx.fill();
118    } else if (k === "stroke") {
119      round(op[1], op[2], op[3], op[4], op[5]);
120      ctx.strokeStyle = rgba(op[6]);
121      ctx.lineWidth = op[7] || 1;
122      ctx.stroke();
123    } else if (k === "text") {
124      ctx.fillStyle = rgba(op[5]);
125      ctx.font = `700 ${op[4] || 12}px ui-monospace, "SF Mono", monospace`;
126      ctx.textAlign = op[6] || "center";
127      ctx.textBaseline = "middle";
128      ctx.fillText(op[1], op[2], op[3]);
129    } else if (k === "line") {
130      ctx.strokeStyle = rgba(op[5]);
131      ctx.lineWidth = op[6] || 1;
132      ctx.lineCap = "round";
133      ctx.beginPath();
134      ctx.moveTo(op[1], op[2]);
135      ctx.lineTo(op[3], op[4]);
136      ctx.stroke();
137    }
138  }
139}
140
141const { assign, keys } = Object;
142const { round, floor, min, max } = Math;
143const { isFinite } = Number;
144
145// 🔒 Sandbox Detection - Detect if we're running in a restrictive iframe sandbox
146// (like objkt.com's default mode which lacks allow-same-origin)
147const isOpaqueOrigin = (() => {
148  try {
149    // In sandboxed iframes without allow-same-origin, origin is 'null' string
150    if (window.origin === 'null') return true;
151    // Also check if we can access localStorage (blocked in opaque origins)
152    localStorage.getItem('__sandbox_test__');
153    return false;
154  } catch (e) {
155    return true;
156  }
157})();
158
159// Export to globalThis so pieces can detect sandbox mode
160globalThis.acIsSandboxed = isOpaqueOrigin;
161
162// Default to WebGL composite unless explicitly disabled.
163// Packed bundles (keeps, pack) set acUseWebGLComposite = false before this runs.
164// In opaque origins, keep it enabled for top-level contexts (e.g. file:// packs),
165// but disable in iframe sandboxes where it can black-screen.
166if (globalThis.acUseWebGLComposite === undefined) {
167  if (globalThis.acPACK_MODE) {
168    globalThis.acUseWebGLComposite = false;
169  } else {
170    let inIframe = false;
171    try {
172      inIframe = window.self !== window.top;
173    } catch {
174      inIframe = true;
175    }
176    globalThis.acUseWebGLComposite = !isOpaqueOrigin || !inIframe;
177  }
178}
179
180// Log once if we detect sandbox restrictions
181if (isOpaqueOrigin) {
182  console.warn('⚠️ Running in sandboxed iframe (opaque origin) - some features disabled, performance may be reduced');
183}
184
185// 🎹 DAW Sync (for Max for Live integration)
186// The global window.acDaw* functions are defined in index.mjs HTML before modules load.
187// This allows M4L's executejavascript to call them immediately.
188// bios.mjs just connects the send function via window.acDawConnect().
189let _dawBpm = 60;
190let _dawSampleRate = null; // 🎹 Store DAW sample rate for AudioContext creation
191
192// When set, BIOS will not update the URL on piece loads.
193// Used to keep a stable URL while a merry pipeline is running.
194let frozenUrlPath = null;
195
196// Called from boot() to connect the send function
197// NOTE: sendFunc is captured by VALUE at call time, but `send` may be reassigned
198// later (e.g. noWorker fallback). Use window.acSEND as the live reference.
199function _dawConnectSend(sendFunc, updateMetronome) {
200  if (window.acDawConnect) {
201    const wrappedSend = (msg) => {
202      if (msg.type === "daw:tempo" && updateMetronome) {
203        updateMetronome(msg.content.bpm);
204      }
205      if (msg.type === "daw:samplerate" && msg.content?.samplerate) {
206        _dawSampleRate = msg.content.samplerate;
207      }
208      // Use window.acSEND (always current) with sendFunc as fallback
209      const currentSend = window.acSEND || sendFunc;
210      currentSend(msg);
211    };
212    window.acDawConnect(wrappedSend);
213  }
214}
215
216// 📼 Tape Playback Classes (for multi-tape transitions)
217
218class Tape {
219  constructor(id) {
220    this.id = id;
221    this.code = null;
222    this.metadata = null;
223    
224    // Frame data
225    this.frames = []; // Array of ImageBitmap objects
226    this.timingData = []; // [{ frame, filename, duration, timestamp }, ...]
227    this.frameIndex = 0;
228    this.lastFrameTime = 0;
229    
230    // Audio data
231    this.audioBuffer = null; // Web Audio AudioBuffer
232    this.audioSource = null; // AudioBufferSourceNode
233    this.audioGainNode = null; // GainNode for volume control
234    this.audioStartTime = 0;
235    this.audioStartedAtFrame = 0;
236    
237    // Playback state (persistent across scroll)
238    this.isLoading = false;
239    this.isPlaying = false;
240    this.isPaused = false;
241    this.wasPlayingBeforeScroll = false; // Remember state when scrolling away
242    this.savedProgress = 0; // Save progress when pausing/scrolling away
243    this.loadProgress = 0;
244    this.loadPhase = ""; // download, unpack, frames, audio
245    
246    // Rendering state (managed by TapeManager)
247    this.yOffset = 0; // Vertical offset in pixels (for scrolling)
248    this.alpha = 0; // Opacity 0-1
249    this.volume = 0; // Audio volume 0-1
250  }
251
252  // Start playback (audio only - frames rendered by TapeManager)
253  play(audioContext) {
254    if (this.isPlaying || !this.audioBuffer) return;
255    
256    this.isPlaying = true;
257    this.isPaused = false;
258    this.wasPlayingBeforeScroll = true; // Remember we were playing
259    this.lastFrameTime = performance.now();
260    
261    // Create audio source
262    this.audioSource = audioContext.createBufferSource();
263    this.audioSource.buffer = this.audioBuffer;
264    
265    // Create gain node for volume control (for crossfade) - only if it doesn't exist
266    if (!this.audioGainNode) {
267      this.audioGainNode = audioContext.createGain();
268      this.audioGainNode.gain.value = this.volume;
269      this.audioGainNode.connect(audioContext.destination);
270    } else {
271      this.audioGainNode.gain.value = this.volume;
272    }
273    
274    this.audioSource.connect(this.audioGainNode);
275    
276    // Start audio from saved progress (for resume after scroll)
277    const startOffset = (this.frameIndex / this.frames.length) * this.audioBuffer.duration;
278    this.audioStartTime = audioContext.currentTime - startOffset;
279    this.audioStartedAtFrame = this.frameIndex;
280    this.audioSource.start(0, startOffset);
281    
282    // Loop audio when it ends
283    this.audioSource.onended = () => {
284      if (this.isPlaying && !this.isPaused) {
285        this.frameIndex = 0; // Reset to beginning
286        this.play(audioContext); // Restart
287      }
288    };
289  }
290
291  pause() {
292    this.isPlaying = false;
293    this.isPaused = true;
294    this.wasPlayingBeforeScroll = false; // Manually paused
295    this.savedProgress = this.getProgress();
296    this.audioSource?.stop();
297  }
298
299  stop() {
300    this.isPlaying = false;
301    this.isPaused = false;
302    this.audioSource?.stop();
303    this.audioSource = null;
304    // Don't reset frameIndex - keep position for resume
305    this.savedProgress = this.getProgress();
306  }
307
308  // Pause when scrolling away (but remember we were playing)
309  pauseForScroll() {
310    if (this.isPlaying) {
311      this.wasPlayingBeforeScroll = true;
312      this.savedProgress = this.getProgress();
313      this.audioSource?.stop();
314      this.isPlaying = false;
315    }
316  }
317
318  // Resume when scrolling back
319  resumeFromScroll(audioContext) {
320    if (this.wasPlayingBeforeScroll && !this.isPlaying) {
321      // Restore saved position
322      if (this.savedProgress > 0) {
323        this.setProgress(this.savedProgress);
324      }
325      this.play(audioContext);
326    }
327  }
328
329  // Seek to specific frame (for scrubbing)
330  seekToFrame(frameIndex, audioContext) {
331    if (frameIndex < 0 || frameIndex >= this.frames.length) return;
332    
333    this.frameIndex = frameIndex;
334    
335    // If playing, restart audio from new position
336    if (this.isPlaying && this.audioBuffer && audioContext) {
337      this.audioSource?.stop();
338      
339      const progress = frameIndex / this.frames.length;
340      const audioOffset = progress * this.audioBuffer.duration;
341      
342      this.audioSource = audioContext.createBufferSource();
343      this.audioSource.buffer = this.audioBuffer;
344      this.audioSource.connect(this.audioGainNode);
345      this.audioStartTime = audioContext.currentTime - audioOffset;
346      this.audioStartedAtFrame = frameIndex;
347      this.audioSource.start(0, audioOffset);
348    }
349  }
350
351  // Set playback progress (0-1)
352  setProgress(progress) {
353    const clampedProgress = Math.max(0, Math.min(1, progress));
354    const targetFrame = Math.floor(clampedProgress * this.frames.length);
355    this.frameIndex = Math.min(targetFrame, this.frames.length - 1);
356    this.savedProgress = clampedProgress;
357  }
358
359  // Get current progress (0-1)
360  getProgress() {
361    if (this.frames.length === 0) return 0;
362    return this.frameIndex / this.frames.length;
363  }
364
365  // Set volume (for crossfade)
366  setVolume(volume) {
367    this.volume = Math.max(0, Math.min(1, volume));
368    if (this.audioGainNode) {
369      this.audioGainNode.gain.value = this.volume;
370    }
371  }
372
373  // Update to next frame (called by TapeManager). Two fixes vs naive
374  // advance:
375  //
376  // 1. Catch up multiple frames per call. If the renderer hiccups and
377  //    we get a `now` that's two or more frame durations past
378  //    lastFrameTime, advance through every frame whose duration has
379  //    fully elapsed. Without this loop the tape silently runs slower
380  //    than real time on every dropped render frame.
381  //
382  // 2. Accumulate scheduled time (`lastFrameTime += frameDuration`),
383  //    don't clobber with `now`. Setting lastFrameTime = now throws
384  //    away the carryover after each advance, so playback drifts
385  //    later by (now - scheduled) on every hiccup. The fix keeps the
386  //    schedule frame-perfect even when individual updateFrame calls
387  //    arrive late.
388  //
389  // 3. Don't gate the whole advance on timingData[frameIndex] existing
390  //    — if a frame is missing its timing entry, fall back to 33.33ms
391  //    so the loop never silently freezes.
392  //
393  // Safety cap on the catch-up loop (worst case: a tab was throttled
394  // for many seconds and we'd otherwise spin advancing thousands of
395  // frames in one call). 256 frames is ~8.5s at 30fps — past that we
396  // jump straight to the wall-clock target.
397  updateFrame(now) {
398    if (!this.isPlaying || this.frames.length === 0) return;
399    if (!this.lastFrameTime) {
400      this.lastFrameTime = now;
401      return;
402    }
403    let safety = 256;
404    while (safety-- > 0) {
405      const entry = this.timingData[this.frameIndex];
406      const frameDuration = entry?.duration || 33.33;
407      if (now - this.lastFrameTime < frameDuration) break;
408      this.lastFrameTime += frameDuration;
409      this.frameIndex = (this.frameIndex + 1) % this.frames.length;
410    }
411    // Bound lastFrameTime to a recent window — if we hit the safety
412    // cap above, we were so far behind that perfect carryover would
413    // chase its tail forever. Snap to the last frame boundary just
414    // before `now` so subsequent calls advance from a clean state.
415    if (now - this.lastFrameTime > 1000) {
416      this.lastFrameTime = now;
417    }
418  }
419
420  cleanup() {
421    this.audioSource?.stop();
422    this.audioSource = null;
423    this.audioGainNode = null;
424    this.frames = [];
425    this.timingData = [];
426  }
427}
428
429class TapeManager {
430  constructor(canvas, audioContext, maxTapes = 6) {
431    this.canvas = canvas;
432    this.ctx = canvas.getContext("2d");
433    this.audioContext = audioContext;
434    this.tapes = new Map(); // Map<id, Tape>
435    this.maxTapes = maxTapes;
436    this.activeId = null;
437    this.preloadQueue = []; // Array of tape IDs to load
438    this.isPreloading = false;
439    this.activeProgress = 0; // Current progress of active tape (exposed to disk)
440  }
441
442  // Create or get tape
443  getTape(id) {
444    if (!this.tapes.has(id)) {
445      this.tapes.set(id, new Tape(id));
446    }
447    return this.tapes.get(id);
448  }
449
450  // Set active tape and start playback
451  setActive(id) {
452    // Pause previous active (but keep state)
453    if (this.activeId && this.tapes.has(this.activeId)) {
454      const prevTape = this.tapes.get(this.activeId);
455      prevTape.pauseForScroll(); // Pause but remember we were playing
456      prevTape.volume = 0;
457    }
458    
459    this.activeId = id;
460    const activeTape = this.tapes.get(id);
461    
462    if (activeTape && !activeTape.isLoading) {
463      // Resume playback if it was playing before we scrolled away
464      activeTape.resumeFromScroll(this.audioContext);
465      activeTape.volume = 1;
466      activeTape.yOffset = 0;
467      activeTape.alpha = 1;
468    }
469  }
470
471  // Render frame for single tape (compositing on main canvas)
472  renderFrame(now, send) {
473    if (!this.canvas || !this.ctx) return;
474    
475    // Update all playing tapes
476    for (const tape of this.tapes.values()) {
477      if (tape.isPlaying) {
478        tape.updateFrame(now);
479      }
480    }
481    
482    // Update active progress and send to disk (for tv.mjs progress bar)
483    if (this.activeId && send) {
484      const activeTape = this.tapes.get(this.activeId);
485      if (activeTape) {
486        this.activeProgress = activeTape.getProgress();
487        
488        // Send progress update to disk every frame
489        send({
490          type: "tape:playback-progress",
491          content: {
492            tapeId: this.activeId,
493            progress: this.activeProgress,
494            frameIndex: activeTape.frameIndex,
495            totalFrames: activeTape.frames.length,
496            isPlaying: activeTape.isPlaying,
497            isPaused: activeTape.isPaused
498          }
499        });
500      }
501    }
502    
503    // Composite all visible tapes onto canvas
504    this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
505    
506    for (const tape of this.tapes.values()) {
507      if (tape.alpha > 0 && tape.frames.length > 0) {
508        const frame = tape.frames[tape.frameIndex];
509        if (frame) {
510          this.ctx.globalAlpha = tape.alpha;
511          
512          // Calculate aspect-ratio-preserving dimensions (object-fit: contain)
513          const canvasAspect = this.canvas.width / this.canvas.height;
514          const frameAspect = frame.width / frame.height;
515          
516          let drawWidth, drawHeight, drawX, drawY;
517          
518          if (frameAspect > canvasAspect) {
519            // Frame is wider - fit to width
520            drawWidth = this.canvas.width;
521            drawHeight = this.canvas.width / frameAspect;
522            drawX = 0;
523            drawY = (this.canvas.height - drawHeight) / 2 + tape.yOffset;
524          } else {
525            // Frame is taller - fit to height
526            drawHeight = this.canvas.height;
527            drawWidth = this.canvas.height * frameAspect;
528            drawX = (this.canvas.width - drawWidth) / 2;
529            drawY = tape.yOffset;
530          }
531          
532          this.ctx.drawImage(
533            frame,
534            drawX,
535            drawY,
536            drawWidth,
537            drawHeight
538          );
539        }
540      }
541    }
542    
543    this.ctx.globalAlpha = 1;
544  }
545
546  // Update audio crossfade based on tape positions
547  updateAudioCrossfade(tapePositions) {
548    // tapePositions = [{ id, yOffset, alpha }, ...]
549    const centerY = this.canvas.height / 2;
550    const fadeDistance = this.canvas.height;
551    
552    for (const pos of tapePositions) {
553      const tape = this.tapes.get(pos.id);
554      if (!tape) continue;
555      
556      // Calculate volume based on distance from center
557      const distanceFromCenter = Math.abs(pos.yOffset + this.canvas.height / 2 - centerY);
558      const volume = Math.max(0, 1 - (distanceFromCenter / fadeDistance));
559      
560      tape.yOffset = pos.yOffset;
561      tape.alpha = pos.alpha;
562      tape.setVolume(volume);
563    }
564  }
565
566  // Evict furthest tape from cache
567  evictFurthestTape(currentIndex, tapeIndices) {
568    if (this.tapes.size <= this.maxTapes) return;
569    
570    let furthestId = null;
571    let furthestDistance = -1;
572    
573    for (const [id, tape] of this.tapes) {
574      const tapeIndex = tapeIndices.findIndex(t => t.id === id);
575      if (tapeIndex === -1) continue;
576      
577      const distance = Math.abs(tapeIndex - currentIndex);
578      if (distance > furthestDistance) {
579        furthestDistance = distance;
580        furthestId = id;
581      }
582    }
583    
584    if (furthestId) {
585      const tape = this.tapes.get(furthestId);
586      tape?.cleanup();
587      this.tapes.delete(furthestId);
588      console.log(`📼 Evicted tape ${furthestId} (distance: ${furthestDistance})`);
589    }
590  }
591
592  cleanup() {
593    for (const tape of this.tapes.values()) {
594      tape.cleanup();
595    }
596    this.tapes.clear();
597    this.activeId = null;
598  }
599}
600
601let pendingUrlRewrite = null;
602let rewriteFocusListenerAttached = false;
603
604// 📦 Opt-in auto-reload for the update badge. `localStorage` is only where the
605// choice is kept between visits — a blocked store (private windows, sandboxed
606// frames) must not make the checkbox untoggleable — so the live answer lives
607// here, and the badge's checkbox is drawn from a copy of it in the worker.
608const UPDATE_AUTO_RELOAD_KEY = "aesthetic:update-auto-reload";
609
610let updateAutoReload = (() => {
611  try {
612    return localStorage.getItem(UPDATE_AUTO_RELOAD_KEY) === "true";
613  } catch (err) {
614    return false;
615  }
616})();
617
618function setUpdateAutoReload(enabled) {
619  updateAutoReload = enabled;
620  try {
621    localStorage.setItem(UPDATE_AUTO_RELOAD_KEY, String(enabled));
622  } catch (err) {
623    /* Storage is blocked; the in-memory answer still stands for this visit. */
624  }
625}
626
627// Presentation flags belong to the view, not the piece address. Use this for
628// every history write so a later browser reload boots the same embedded view.
629function preserveViewParams(path) {
630  try {
631    const session = noPaintHistoryTarget(path, window.location.href);
632    if (session.seed) {
633      try { sessionStorage.setItem(NOPAINT_SESSION_SEED_KEY, session.seed); } catch {}
634      path = session.path;
635    }
636    const next = new URL(path, window.location.href);
637    for (const [name, value] of Object.entries(preservedParams || {})) {
638      if (value !== undefined && value !== null) next.searchParams.set(name, value);
639    }
640    return next.pathname + next.search + next.hash;
641  } catch (err) {
642    return path;
643  }
644}
645
646function performHistoryRewrite(path, historical) {
647  if (checkPackMode()) return;
648  window.acSignup?.remember(window.location.href);
649  path = preserveViewParams(path);
650  if (historical) {
651    console.log("Rewriting to:", path);
652    history.pushState("", document.title, path);
653  } else {
654    history.replaceState("", document.title, path);
655  }
656}
657
658function resolveBackgroundFillSpec(colorLike) {
659  if (colorLike === undefined || colorLike === null) return null;
660
661  let resolved;
662  try {
663    resolved = graph.findColor(colorLike);
664  } catch (err) {
665    return null;
666  }
667
668  if (!Array.isArray(resolved) || resolved.length === 0) {
669    return null;
670  }
671
672  const first = resolved[0];
673  if (typeof first === "string" && first.startsWith("fade:")) {
674    const fadeInfo = graph.parseFadeColor(resolved);
675    if (fadeInfo) {
676      return {
677        type: "fade",
678        fadeInfo,
679      };
680    }
681    return null;
682  }
683
684  const clampChannel = (value, fallback = 0) => {
685    if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
686    return Math.max(0, Math.min(255, Math.round(value)));
687  };
688
689  const r = clampChannel(resolved[0], 0);
690  const g = clampChannel(resolved[1], r);
691  const b = clampChannel(resolved[2], r);
692  const a = clampChannel(resolved[3], 255);
693
694  return {
695    type: "solid",
696    rgba: [r, g, b, a],
697  };
698}
699
700function computeFadePositionForPixel(x, y, width, height, fadeInfo) {
701  if (!fadeInfo || !width || !height) return 0;
702
703  const maxX = Math.max(width - 1, 0);
704  const maxY = Math.max(height - 1, 0);
705  const direction = fadeInfo.direction;
706
707  if (typeof direction === "number" && Number.isFinite(direction)) {
708    return graph.calculateAngleFadePosition(
709      x,
710      y,
711      0,
712      0,
713      maxX,
714      maxY,
715      direction,
716    );
717  }
718
719  const numeric = direction !== undefined ? parseFloat(direction) : NaN;
720  if (!Number.isNaN(numeric) && Number.isFinite(numeric)) {
721    return graph.calculateAngleFadePosition(
722      x,
723      y,
724      0,
725      0,
726      maxX,
727      maxY,
728      numeric,
729    );
730  }
731
732  const safeDiv = (value, denom) => (denom <= 0 ? 0 : value / denom);
733
734  switch (direction) {
735    case "horizontal-reverse":
736      return safeDiv(maxX - x, maxX);
737    case "vertical":
738      return safeDiv(y, maxY);
739    case "vertical-reverse":
740      return safeDiv(maxY - y, maxY);
741    case "diagonal": {
742      const dx = safeDiv(x, maxX);
743      const dy = safeDiv(y, maxY);
744      return (dx + dy) / 2;
745    }
746    case "diagonal-reverse": {
747      const dx = safeDiv(maxX - x, maxX);
748      const dy = safeDiv(maxY - y, maxY);
749      return (dx + dy) / 2;
750    }
751    case "horizontal":
752    default:
753      return safeDiv(x, maxX);
754  }
755}
756
757function fillFadeExpansionsOnCanvas(ctx, width, height, oldWidth, oldHeight, fadeInfo) {
758  const clampChannel = (value) => {
759    if (typeof value !== "number" || !Number.isFinite(value)) return 0;
760    return Math.max(0, Math.min(255, Math.round(value)));
761  };
762
763  if (width > oldWidth) {
764    const rightWidth = width - oldWidth;
765    const imageData = ctx.createImageData(rightWidth, height);
766    const data = imageData.data;
767
768    for (let y = 0; y < height; y += 1) {
769      for (let x = 0; x < rightWidth; x += 1) {
770        const globalX = oldWidth + x;
771        const globalY = y;
772        const t = computeFadePositionForPixel(globalX, globalY, width, height, fadeInfo);
773        const [r = 0, g = 0, b = 0, a = 255] = graph.getLocalFadeColor(t, globalX, globalY, fadeInfo);
774        const i = (y * rightWidth + x) * 4;
775        data[i] = clampChannel(r);
776        data[i + 1] = clampChannel(g);
777        data[i + 2] = clampChannel(b);
778        data[i + 3] = clampChannel(a || 255);
779      }
780    }
781
782    ctx.putImageData(imageData, oldWidth, 0);
783  }
784
785  if (height > oldHeight) {
786    const bottomHeight = height - oldHeight;
787    const bottomWidth = width > oldWidth ? oldWidth : width;
788
789    if (bottomWidth > 0 && bottomHeight > 0) {
790      const imageData = ctx.createImageData(bottomWidth, bottomHeight);
791      const data = imageData.data;
792
793      for (let y = 0; y < bottomHeight; y += 1) {
794        for (let x = 0; x < bottomWidth; x += 1) {
795          const globalX = x;
796          const globalY = oldHeight + y;
797          const t = computeFadePositionForPixel(globalX, globalY, width, height, fadeInfo);
798          const [r = 0, g = 0, b = 0, a = 255] = graph.getLocalFadeColor(t, globalX, globalY, fadeInfo);
799          const i = (y * bottomWidth + x) * 4;
800          data[i] = clampChannel(r);
801          data[i + 1] = clampChannel(g);
802          data[i + 2] = clampChannel(b);
803          data[i + 3] = clampChannel(a || 255);
804        }
805      }
806
807      ctx.putImageData(imageData, 0, oldHeight);
808    }
809  }
810}
811
812// 🎬 GIF Encoder Configuration
813// Set to true to use gifenc by default, false for gif.js
814const DEFAULT_USE_GIFENC = true;
815
816// 🚫 Cache Control Flags
817// Set these to true to disable caching for dynamic content updates
818window.acDISABLE_HUD_LABEL_CACHE = true; // Disable HUD label caching for dynamic coloring
819window.acDISABLE_QR_OVERLAY_CACHE = true; // Disable QR overlay caching for dynamic updates
820
821// Guard: When WS + HTTP module imports race (importWithRetry), both module
822// instances execute top-level code. Use a window-scoped queue so ALL instances
823// share the same sends array and consumed flag — prevents session:started from
824// being pushed to one instance's array but consumed from another (empty) one.
825if (!window._acDiskSendQ) {
826  window._acDiskSendQ = { sends: [], consumed: false };
827}
828const _diskQ = window._acDiskSendQ;
829
830// Store original URL parameters for refresh functionality
831let preservedParams = {};
832
833if (!window.acDISK_SEND) {
834  window.acDISK_SEND = function (message) {
835    !_diskQ.consumed ? _diskQ.sends.push(message) : window.acSEND(message);
836  };
837}
838
839// 🔊 Master volume control (for desktop app sliders, etc.)
840// NOTE: These must be at module scope so window.AC.setMasterVolume works before boot()
841let masterVolume = 1;
842let masterClockRate = 1;
843let speakerProcessorNode = null; // Will be set by boot()
844let backgroundMusicEl = null; // Will be set by boot()
845let backgroundMusicBaseVolume = 1;
846let streamAudio = {}; // Will be populated by boot()
847let audioStarting = false;
848
849function clampVolume(value) {
850  const numeric = Number(value);
851  if (!Number.isFinite(numeric)) return 1;
852  return Math.max(0, Math.min(1, numeric));
853}
854
855function applyMasterVolume(nextValue) {
856  masterVolume = clampVolume(nextValue);
857
858  if (speakerProcessorNode?.port) {
859    speakerProcessorNode.port.postMessage({ type: "volume", value: masterVolume });
860  }
861
862  if (backgroundMusicEl) {
863    backgroundMusicEl.volume = backgroundMusicBaseVolume * masterVolume;
864  }
865
866  for (const id in streamAudio) {
867    const stream = streamAudio[id];
868    if (stream?.audio) {
869      const base = Number.isFinite(stream.baseVolume) ? stream.baseVolume : 1;
870      stream.audio.volume = base * masterVolume;
871    }
872  }
873
874  return masterVolume;
875}
876
877window.AC = window.AC || {};
878// Guard module-scoped closures against duplicate-instance overwrite (same race as acDISK_SEND)
879if (!window.AC._biosGuarded) {
880  window.AC._biosGuarded = true;
881  window.AC.setMasterVolume = (value) => applyMasterVolume(value);
882  window.AC.getMasterVolume = () => masterVolume;
883  window.AC.setClockRate = (rate, reset = false) => {
884    if (!Number.isFinite(rate) || rate < 0.25 || rate > 2) return masterClockRate;
885    masterClockRate = rate;
886    window.acDISK_SEND({ type: "clock:rate", content: { rate, reset } });
887    return rate;
888  };
889  window.AC.getClockRate = () => masterClockRate;
890  window.acSetMasterVolume = window.AC.setMasterVolume;
891  window.acGetMasterVolume = window.AC.getMasterVolume;
892}
893
894// 🔍 CDP Debug State - Expose piece state for browser automation
895// Pieces can call `_send({ type: "debug:state", content: {...} })` to update this
896window.acPieceState = null;
897window.acGetState = () => ({
898  piece: window.acSTARTING_PIECE,
899  user: window.acUSER,
900  debug: window.acDEBUG,
901  pieceState: window.acPieceState,
902  paused: window.__acLoopPaused || false,
903  timestamp: Date.now(),
904});
905
906function consumeDiskSends(send) {
907  if (_diskQ.consumed) return;
908  _diskQ.sends.forEach((message) => send(message));
909  _diskQ.sends.length = 0;
910  _diskQ.consumed = true;
911}
912
913// 🔌 USB
914USB.initialize();
915
916// 💾 Boot the system and load a disk.
917async function boot(parsed, bpm = 60, resolution, debug) {
918  const bootStartTime = performance.now();
919  perf.markBootStart();
920  headers(); // Print console headers with auto-detected theme.
921
922  const workerBundleParam = new URLSearchParams(window.location.search).get("workerbundle");
923  const workerBundleDisabled =
924    workerBundleParam === "0" || workerBundleParam === "false";
925  const localWorkerHost =
926    window.location.hostname === "localhost" ||
927    window.location.hostname === "127.0.0.1" ||
928    window.location.hostname === "local.aesthetic.computer" ||
929    window.location.hostname.endsWith(".local");
930  const workerBundleRequested =
931    !window.acPACK_MODE &&
932    !workerBundleDisabled &&
933    (!localWorkerHost ||
934      workerBundleParam === "1" ||
935      workerBundleParam === "true");
936  const workerBundleState = (window.acWORKER_BUNDLE = {
937    requested: workerBundleRequested,
938    active: false,
939    ready: false,
940    filename: null,
941    fallback: null,
942  });
943  // The shell may have named the bundle and started its download already
944  // (window.acWORKER_BUNDLE_HINT): then there is no manifest round trip, and
945  // the script is in the cache by the time the worker asks for it. Waiting on
946  // the warm fetch keeps the worker from starting a second download of the
947  // same bytes; a failed warm-up just hands the path over and lets the
948  // worker's own retry loop deal with it.
949  const workerHint = window.acWORKER_BUNDLE_HINT;
950  const workerHintValid =
951    !!workerHint && /^disk\.worker\.[a-f0-9]{12}\.mjs$/.test(workerHint.filename || "");
952  const workerBundlePathPromise = !workerBundleRequested
953    ? Promise.resolve(null)
954    : workerHintValid
955    ? Promise.race([workerHint.warm, new Promise((r) => setTimeout(r, 8000))]).then(() => {
956        workerBundleState.filename = workerHint.filename;
957        return `/aesthetic.computer/lib/${workerHint.filename}`;
958      })
959    : fetch("/aesthetic.computer/lib/disk-worker-manifest.json", {
960        cache: "no-cache",
961      })
962        .then(async (response) => {
963          if (!response.ok) throw new Error(`manifest HTTP ${response.status}`);
964          const manifest = await response.json();
965          if (!/^disk\.worker\.[a-f0-9]{12}\.mjs$/.test(manifest?.filename || "")) {
966            throw new Error("invalid worker bundle manifest");
967          }
968          workerBundleState.filename = manifest.filename;
969          return `/aesthetic.computer/lib/${manifest.filename}`;
970        })
971        .catch((error) => {
972          workerBundleState.fallback = `manifest: ${error.message}`;
973          return null;
974        });
975
976  // Expose Loop control to window for boot.mjs
977  // Track pause state for kidlisp console snapshots
978  window.acPAUSE = () => {
979    window.__acLoopPaused = true;
980    Loop.pause();
981  };
982  window.acRESUME = () => {
983    window.__acLoopPaused = false;
984    // Reset snap timer so first snap happens 5s after resume
985    window._lastKidlispSnapTime = performance.now();
986    Loop.resume();
987  };
988  // Clear KidLisp bake layers (exposed for soft-stop)
989  window.acCLEAR_BAKE_LAYERS = () => {
990    if (window.__acGlobalKidLispInstance?.clearBakedLayers) {
991      window.__acGlobalKidLispInstance.clearBakedLayers();
992    }
993  };
994  
995  // Force immediate reframe (used by kidlisp.com after panel resize ends)
996  window.acREFRAME = () => {
997    needsReframe = true;
998  };
999
1000  // Notify parent of boot progress and update the boot log overlay
1001  if (window.acBOOT_LOG) {
1002    window.acBOOT_LOG("initializing graphics");
1003  } else if (window.parent) {
1004    window.parent.postMessage({ 
1005      type: "boot-log", 
1006      message: "initializing graphics" 
1007    }, "*");
1008  }
1009
1010  // Store original URL parameters for refresh functionality from the resolution object
1011  preservedParams = {};
1012  if (resolution.gap === 0) preservedParams.nogap = "true"; // gap: 0 means nogap was true
1013  if (resolution.nolabel === true) preservedParams.nolabel = "true";
1014  if (resolution.autoreload === true) {
1015    preservedParams.autoreload = "true";
1016    // Turned on for this view without touching storage: the flag describes
1017    // the embedding, not the person, and a preview card that borrowed the
1018    // browser's real preference would leave it changed behind it.
1019    updateAutoReload = true;
1020  }
1021  if (resolution.shellhtml === true) preservedParams.shellhtml = "true";
1022  // The frame-rate cap is the embedding's too: a preview card that reloads
1023  // without it runs at display rate, which is the one thing it was told not to do.
1024  if (resolution.maxfps) preservedParams.maxfps = String(resolution.maxfps);
1025  if (resolution.tv === true) preservedParams.tv = "true";
1026  if (resolution.device === true) preservedParams.device = "true";
1027  if (resolution.solo === true) preservedParams.solo = "true";
1028  if (resolution.highlight) preservedParams.highlight = resolution.highlight === true ? "true" : resolution.highlight;
1029  
1030  // Preserve URL-provided view options, never auth callbacks or piece arguments.
1031  const currentParams = new URLSearchParams(location.search);
1032  for (const name of ["density", "zoom", "duration", "daw", "width", "height", "desktop", "noauth", "preview", "icon"]) {
1033    if (currentParams.has(name)) preservedParams[name] = currentParams.get(name);
1034  }
1035
1036  if (debug) {
1037    if (window.isSecureContext) {
1038      // console.log("🔒 Secure");
1039    } else {
1040      console.warn("🔓 Insecure");
1041    }
1042  }
1043
1044  window.acCONTENT_EVENTS = [];
1045
1046  let HANDLE; // Populated with the user's handle from `disk`.
1047
1048  let pen,
1049    keyboard,
1050    gamepad,
1051    keyboardFocusLock = false,
1052    keyboardSoftLock = false;
1053  let handData; // Hand-tracking.
1054
1055  let frameCount = 0n;
1056  let now = 0;
1057
1058  let diskSupervisor;
1059  let currentPiece = null; // Gets set to a path after `loaded`.
1060  const accountActivity = startAccountActivity();
1061  let currentPieceHasKeyboard = false;
1062  let keyboardMaxChars = 256; // Default; pieces can override via keyboard:set-max-chars
1063
1064  // Media Recorder
1065  let mediaRecorder;
1066  let recordedFrames = [];
1067  let recordedPieceChanges = []; // Track piece changes during recording
1068  const mediaRecorderChunks = [];
1069  let mediaRecorderDuration = 0,
1070    mediaRecorderStartTime,
1071    mediaRecorderFrameCount = 0; // Frame counter for performance optimization
1072  let recorderGeneration = 0;
1073  let compactVideoRecording = false;
1074  let compactRecorderStream = null;
1075  let compactZoomCompositor = null; // rAF loop drawing camera → zoom canvas while recording.
1076  // Live camera preview zoom/mirror, written by the camera pipeline and read
1077  // by the recorder's zoom compositor so screen and tape ramp together.
1078  const cameraFeedState = { zoom: 1, mirrored: false };
1079  let recordedVideoBlob = null;
1080  let recordedVideoMime = "";
1081  let recordedVideoSource = "";
1082  let compactPlaybackUrl = null;
1083  let needs$creenshot = false; // Flag when a capture is requested.
1084  
1085  // Raw audio capture for tape playback
1086  let rawAudioProcessor = null;
1087  let rawAudioData = [];
1088  let rawAudioSampleRate = 44100;
1089  let rawAudioConnected = false;
1090  let rawAudioStartInterval = null;
1091  let rawAudioStartTimeout = null;
1092  let captureSession = null;
1093
1094  function clearRawAudioCapture(clearData = true) {
1095    if (rawAudioStartInterval) clearInterval(rawAudioStartInterval);
1096    if (rawAudioStartTimeout) clearTimeout(rawAudioStartTimeout);
1097    rawAudioStartInterval = null;
1098    rawAudioStartTimeout = null;
1099    if (rawAudioProcessor) {
1100      try { sfxStreamGain?.disconnect(rawAudioProcessor); } catch {}
1101      try { micStreamGain?.disconnect(rawAudioProcessor); } catch {}
1102      try { rawAudioProcessor.disconnect(); } catch {}
1103    }
1104    rawAudioProcessor = null;
1105    rawAudioConnected = false;
1106    if (clearData) rawAudioData = [];
1107  }
1108
1109  function clearCompactRecorderStream() {
1110    compactZoomCompositor?.stop?.();
1111    compactZoomCompositor = null;
1112    compactRecorderStream?.getTracks?.().forEach((track) => {
1113      try { track.stop(); } catch {}
1114    });
1115    compactRecorderStream = null;
1116    compactVideoRecording = false;
1117  }
1118
1119  function reportCaptureAVSync() {
1120    if (!captureSession || captureSession.reported) return;
1121    const result = measureCaptureAVSync(
1122      captureSession.firstVideoMs,
1123      captureSession.firstAudioMs,
1124    );
1125    if (!result) return;
1126    captureSession.reported = true;
1127    send({
1128      type: "recorder:av-sync",
1129      content: { sessionId: captureSession.id, ...result },
1130    });
1131  }
1132  
1133  // Dynamic FPS detection for display-rate independent recording
1134  let detectedDisplayFPS = 60; // Default fallback
1135  let lastFrameTime = 0;
1136  let frameTimes = [];
1137  const FPS_SAMPLE_SIZE = 30; // Number of frames to sample for FPS detection
1138
1139  // Duration Progress Bar (for timed pieces from query parameter)
1140  let durationProgressStartTime = undefined;
1141  let durationProgressDuration = resolution.duration; // Duration in seconds from query parameter
1142  let durationProgressCompleted = false;
1143
1144  // 🎮 Game Boy Emulator (main thread)
1145  let gameboyEmulator = null;
1146  let currentGameboyPixels = null;
1147  let currentGameboyROM = null;
1148
1149  // Events
1150  let whens = {};
1151
1152  // Register core signal handlers
1153  whens["recorder:cut"] = async function() {
1154    let rollingEndedSent = false;
1155    const cutGeneration = recorderGeneration;
1156    
1157    if (!mediaRecorder) {
1158      console.warn(`No mediaRecorder available during cut - sending rolling:ended anyway`);
1159      send({ type: "recorder:rolling:ended" });
1160      return;
1161    }
1162    
1163    if (debug && logs.recorder) console.log("✂️ Recorder: Cut");
1164    
1165    try {
1166      // Safety check to prevent NaN duration
1167      if (mediaRecorderStartTime !== undefined) {
1168        mediaRecorderDuration += performance.now() - mediaRecorderStartTime;
1169      } else {
1170        console.warn("Warning: mediaRecorderStartTime is undefined during cut, cannot calculate duration");
1171        // Set a minimal duration to prevent NaN
1172        if (mediaRecorderDuration === undefined || isNaN(mediaRecorderDuration)) {
1173          mediaRecorderDuration = 100; // Fallback to 100ms
1174        }
1175      }
1176
1177      // Camera clips can stay compressed from capture through review and
1178      // upload. MediaRecorder guarantees the final dataavailable event before
1179      // stop, so wait for stop instead of building a partial Blob after pause.
1180      if (compactVideoRecording) {
1181        const recorder = mediaRecorder;
1182        const mime = recorder.mimeType || recordedVideoMime || "video/mp4";
1183
1184        await new Promise((resolve, reject) => {
1185          recorder.onstop = null;
1186          recorder.addEventListener("stop", resolve, { once: true });
1187          recorder.addEventListener(
1188            "error",
1189            (event) => reject(event.error || new Error("Video recorder failed")),
1190            { once: true },
1191          );
1192          recorder.stop();
1193        });
1194
1195        recordedVideoBlob = new Blob(mediaRecorderChunks, { type: mime });
1196        recordedVideoMime = mime;
1197        send({
1198          type: "recorder:compact",
1199          content: {
1200            phase: "cut",
1201            mime,
1202            bytes: recordedVideoBlob.size,
1203            duration: mediaRecorderDuration / 1000,
1204            source: recordedVideoSource,
1205          },
1206        });
1207        clearCompactRecorderStream();
1208        mediaRecorder = undefined;
1209        mediaRecorderStartTime = undefined;
1210        mediaRecorderChunks.length = 0;
1211
1212        send({ type: "recorder:rolling:ended" });
1213        rollingEndedSent = true;
1214
1215        // 🎞️ Present now — the video piece is still loading, but the tape
1216        // can already be playing in the underlay beneath the old piece's
1217        // frozen frame. The piece's own present() lands on the idempotent
1218        // branch and leaves playback running.
1219        receivedChange({
1220          data: { type: "recorder:present", content: {} },
1221        }).catch((err) => console.warn("📼 Auto-present after cut failed:", err));
1222
1223        try {
1224          await receivedChange({
1225            data: {
1226              type: "store:persist",
1227              content: {
1228                key: "tape",
1229                method: "local:db",
1230                data: {
1231                  kind: "video",
1232                  blob: recordedVideoBlob,
1233                  mime: recordedVideoMime,
1234                  source: recordedVideoSource,
1235                  duration: mediaRecorderDuration,
1236                  timestamp: Date.now(),
1237                },
1238              },
1239            },
1240          });
1241          if (cutGeneration !== recorderGeneration) await Store.del("tape");
1242        } catch (storageError) {
1243          console.error("Error storing compact video tape:", storageError);
1244        }
1245        return;
1246      }
1247
1248      // mediaRecorder?.stop();
1249      mediaRecorder?.pause(); // Single clips for now.
1250      hdTapeStop(); // 🖼️📼 Finalize the hd recording (blob lands in onstop).
1251
1252      // Store the tape data to IndexedDB for persistence across page refreshes
1253      const blob = new Blob(mediaRecorderChunks, {
1254        type: mediaRecorder.mimeType,
1255      });
1256      
1257      // Convert raw audio data to serializable format for storage
1258      let rawAudioArrays = null;
1259      if (rawAudioData.length > 0 && audioContext) {
1260        try {
1261          const normalization = normalizeTapeAudio([
1262            ...rawAudioData.map((chunk) => chunk.left),
1263            ...rawAudioData.map((chunk) => chunk.right),
1264          ]);
1265          console.log(
1266            `🎚️ Tape audio normalization: ${normalization.gain.toFixed(2)}x ` +
1267              `(RMS ${normalization.rms.toFixed(4)}, peak ${normalization.peak.toFixed(4)})`,
1268          );
1269          const totalSamples = rawAudioData.length * 4096; // 4096 samples per chunk
1270          const leftChannelData = new Float32Array(totalSamples);
1271          const rightChannelData = new Float32Array(totalSamples);
1272          
1273          let sampleIndex = 0;
1274          for (let i = 0; i < rawAudioData.length; i++) {
1275            const chunk = rawAudioData[i];
1276            for (let j = 0; j < chunk.left.length; j++) {
1277              if (sampleIndex < totalSamples) {
1278                leftChannelData[sampleIndex] = chunk.left[j];
1279                rightChannelData[sampleIndex] = chunk.right[j];
1280                sampleIndex++;
1281              }
1282            }
1283          }
1284          
1285          rawAudioArrays = {
1286            left: leftChannelData,
1287            right: rightChannelData,
1288            sampleRate: rawAudioSampleRate,
1289            totalSamples: totalSamples
1290          };
1291          attachSoundtrackToFrames(recordedFrames, totalSamples);
1292
1293          console.log(`🎞️ Sound-on-film attached: ${recordedFrames.length} frames, ${totalSamples} audio samples`);
1294        } catch (error) {
1295          console.error("Error creating raw audio arrays:", error);
1296        }
1297      }
1298      clearRawAudioCapture(false);
1299
1300      // The live frames and soundtrack are ready now. Hand cap off to video
1301      // immediately; IndexedDB persistence can finish without holding the UI.
1302      send({ type: "recorder:rolling:ended" });
1303      rollingEndedSent = true;
1304
1305      try {
1306        // Don't store frames in IndexedDB to avoid memory issues with long recordings
1307        // Frames are kept in memory (recordedFrames) for the current session only
1308        await receivedChange({
1309          data: {
1310            type: "store:persist",
1311            content: {
1312              key: "tape",
1313              method: "local:db",
1314              data: {
1315                blob,
1316                duration: mediaRecorderDuration,
1317                // frames: recordedFrames, // Excluded to prevent out-of-memory errors
1318                timestamp: Date.now(),
1319                rawAudio: rawAudioArrays, // Add raw audio arrays for playback
1320              },
1321            },
1322          },
1323        });
1324        if (cutGeneration !== recorderGeneration) await Store.del("tape");
1325
1326        if (debug && logs.recorder) console.log("📼 Stored tape to IndexedDB (without frame data)");
1327      } catch (storageError) {
1328        console.error("Error storing tape to IndexedDB:", storageError);
1329        // Continue despite storage error
1330      }
1331
1332    } catch (error) {
1333      console.error("Error in cut operation:", error);
1334      // Still send the ended signal even if something fails
1335      if (!rollingEndedSent) send({ type: "recorder:rolling:ended" });
1336    }
1337  };
1338
1339  // Video storage
1340  const videos = [];
1341
1342  // Media preloading tracker (for cancellations).
1343  const mediaPathsLoading = {};
1344
1345  // Rendering
1346
1347  // Wrap everything in an #aesthetic-computer div.
1348  const wrapper = document.createElement("div");
1349  wrapper.id = "aesthetic-computer";
1350
1351  // 🖥️ Our main display surface. (Software Renderer)
1352  const canvas = document.createElement("canvas");
1353  canvas.dataset.acVisitCanvas = "";
1354  const ctx = canvas.getContext("2d", { willReadFrequently: true });
1355
1356  // 🖥️🔌 WebGPU 2D Renderer Canvas
1357  const webgpuCanvas = document.createElement("canvas");
1358  webgpuCanvas.dataset.type = "webgpu";
1359  webgpuCanvas.style.position = "absolute";
1360  webgpuCanvas.style.top = "0";
1361  webgpuCanvas.style.left = "0";
1362  webgpuCanvas.style.zIndex = "1"; // Place above main canvas
1363  webgpuCanvas.style.pointerEvents = "none"; // Let events pass through to main canvas
1364  webgpuCanvas.style.display = "none"; // Hidden by default until WebGPU is enabled
1365
1366  // 🖥️🎨 WebGL Composite Canvas (blitted pixel buffer)
1367  const webglCompositeCanvas = document.createElement("canvas");
1368  webglCompositeCanvas.dataset.type = "webgl-composite";
1369  webglCompositeCanvas.style.position = "absolute";
1370  webglCompositeCanvas.style.top = "0";
1371  webglCompositeCanvas.style.left = "0";
1372  webglCompositeCanvas.style.zIndex = "0";
1373  webglCompositeCanvas.style.pointerEvents = "none";
1374  webglCompositeCanvas.style.display = "none";
1375
1376  // � DOM-based Stats Overlay (always on top of everything)
1377  const statsOverlay = document.createElement("div");
1378  statsOverlay.id = "ac-stats-overlay";
1379  statsOverlay.style.cssText = `
1380    position: absolute;
1381    top: 4px;
1382    left: 4px;
1383    z-index: 99999;
1384    pointer-events: none;
1385    font-family: monospace;
1386    font-size: 11px;
1387    line-height: 1.3;
1388    padding: 6px 8px;
1389    background: rgba(0, 0, 0, 0.75);
1390    color: #fff;
1391    border-radius: 4px;
1392    display: none;
1393    white-space: pre;
1394  `;
1395  let statsOverlayEnabled = false;
1396  let statsOverlayData = { backend: "CPU", lines: 0 };
1397  let statsFps = 0;
1398  let statsFrameCount = 0;
1399  let statsLastTime = performance.now();
1400
1401  // Enable reframe debug logging (off by default, set window.acReframeDebug = true to enable)
1402  if (typeof window !== "undefined" && window.acReframeDebug === undefined) {
1403    window.acReframeDebug = false;
1404  }
1405
1406  let webglBlitter = null;
1407  let captureCompositeCanvas = null;
1408  let captureCompositeCtx = null;
1409
1410  function updateStatsOverlay() {
1411    if (!statsOverlayEnabled && !window.acReportFpsToParent) return;
1412    const now = performance.now();
1413    statsFrameCount++;
1414    if (now - statsLastTime >= 1000) {
1415      statsFps = statsFrameCount;
1416      statsFrameCount = 0;
1417      statsLastTime = now;
1418      // Report FPS to parent window if requested
1419      if (window.acReportFpsToParent && window.parent !== window) {
1420        window.parent.postMessage({ type: 'ac:fps-report', fps: statsFps }, '*');
1421      }
1422    }
1423    if (!statsOverlayEnabled) return;
1424    const lines = [
1425      `${statsOverlayData.backend || "GPU"}`,
1426      `FPS: ${statsFps}`,
1427    ];
1428    if (statsOverlayData.lines) lines.push(`Lines: ${statsOverlayData.lines}`);
1429    if (statsOverlayData.extra) lines.push(statsOverlayData.extra);
1430    statsOverlay.textContent = lines.join("\n");
1431  }
1432
1433  function shouldUseWebGLComposite(content) {
1434    if (!content) return false;
1435    if (content.webgpuEnabled) return false;
1436    return content.webglCompositeEnabled === true || globalThis.acUseWebGLComposite === true;
1437  }
1438
1439  function ensureCaptureCompositeCanvas(width, height) {
1440    if (!captureCompositeCanvas) {
1441      captureCompositeCanvas = document.createElement("canvas");
1442      captureCompositeCtx = captureCompositeCanvas.getContext("2d");
1443    }
1444    if (captureCompositeCanvas.width !== width) captureCompositeCanvas.width = width;
1445    if (captureCompositeCanvas.height !== height) captureCompositeCanvas.height = height;
1446    return captureCompositeCtx;
1447  }
1448
1449  // �🖥️🔌 Secondary main display surface. (3D GPU Renderer)
1450  // TODO: Eventually deprecate the software renderer in favor of this?
1451  //       (Or even reuse the same tag if pieces swap.)
1452  //       TODO: Would it be possible for pieces to use both... and why?
1453  //             (Probably Not)
1454  TwoD?.initialize(wrapper);
1455
1456  // An extra canvas reference for passing through or buffering video recording streams.
1457  let streamCanCtx;
1458  let paintToStreamCanvas = false;
1459  let startTapePlayback,
1460    stopTapePlayback,
1461    pauseTapePlayback,
1462    resumeTapePlayback,
1463    seekTapePlayback;
1464
1465  let audioContextResuming = false; // Flag to track when AudioContext resume is in progress
1466  let audioContextResumeTimestamps = {}; // Store resume timestamps separately
1467  let lastAudioResumeAttemptAt = 0; // Debounce repeated resume() calls from duplicate touch/pointer gestures
1468  const AUDIO_RESUME_DEBOUNCE_MS = 250;
1469  const AUDIO_RESUME_STUCK_MS = 1500;
1470  let dawAudioResumed = false; // Track if we've already auto-resumed AudioContext for DAW mode
1471  let tapeSyncPosition = null; // Position to sync tape audio to (0-1), set by video piece
1472  let currentTapePosition = 0; // Current tape playback position (0-1), updated by main loop
1473  
1474  // Make resume timestamps accessible globally
1475  window.audioContextResumeTimestamps = audioContextResumeTimestamps;
1476
1477  let shareFile, shareFileCallback; // A temporary storage container for a pre-prepped
1478  // file download to use on a user interaction.
1479
1480  // A layer for modal messages such as "audio engine is off".
1481  const modal = document.createElement("div");
1482  modal.id = "modal";
1483
1484  // A ui canvas for rendering a native resolution ui on top of everything.
1485  const uiCanvas = document.createElement("canvas");
1486  const uiCtx = uiCanvas.getContext("2d");
1487  uiCanvas.dataset.type = "ui";
1488
1489  const debugCanvas = document.createElement("canvas");
1490  const debugCtx = debugCanvas.getContext("2d");
1491  debugCanvas.dataset.type = "debug";
1492
1493  // A buffer for nicer resolution switches, nice when moving from
1494  // low resolution back to high resolution. Could eventually be used
1495  // for transition effects.
1496  const freezeFrameCan = document.createElement("canvas");
1497  const ffCtx = freezeFrameCan.getContext("2d");
1498  freezeFrameCan.dataset.type = "freeze";
1499  // Add smooth fade transition for seamless resize
1500  freezeFrameCan.style.transition = "opacity 50ms ease-out";
1501  freezeFrameCan.style.willChange = "opacity";
1502  freezeFrameCan.style.position = "absolute";
1503  freezeFrameCan.style.zIndex = "10";
1503 // Above all other canvases during reframe
1504  freezeFrameCan.style.pointerEvents = "none";
1505  // During a live window resize the old framebuffer must keep its aspect
1506  // ratio. The wrapper may change shape before the worker returns new pixels;
1507  // object-fit prevents that continuity frame from rubber-sheet stretching.
1508  freezeFrameCan.style.objectFit = "contain";
1509
1510  // A buffer for corner label overlays.
1511  const overlayCan = document.createElement("canvas");
1512  const octx = overlayCan.getContext("2d");
1513  overlayCan.style.position = "absolute";
1514  overlayCan.style.top = "0";
1515  overlayCan.style.left = "0";
1516  overlayCan.style.zIndex = "2";
1517  overlayCan.style.pointerEvents = "none";
1518  overlayCan.style.display = "none";
1519
1520  // 🎬 Recording UI Canvas - NOT included in tape captures
1521  // This is for piece UIs like cap.mjs that need to show controls during recording
1522  // but should not have those controls recorded in the tape itself
1523  const recordingUICan = document.createElement("canvas");
1524  const recordingUICtx = recordingUICan.getContext("2d");
1525  recordingUICan.style.position = "absolute";
1526  recordingUICan.style.top = "0";
1527  recordingUICan.style.left = "0";
1528  recordingUICan.style.zIndex = "3"; // Above overlay canvas
1529  recordingUICan.style.pointerEvents = "none";
1530  recordingUICan.style.display = "none";
1531  recordingUICan.dataset.type = "recording-ui"; // Mark for identification
1532
1533  // 🖼️ HD layer — native-resolution Canvas2D frames streamed from the piece
1534  // worker as ImageBitmaps (see `hd()` in lib/disk.mjs). Sits above the
1535  // pixel canvas but below glaze (z2), corner labels (z2), and ui (z6).
1536  // Created lazily on the first bitmap so pieces that never use it pay nothing.
1537  let hdCan = null;
1538  let hdCtx = null;
1539
1540  // 🖼️📼 HD tape — while a tape records over an hd piece, every incoming
1541  // bitmap is mirrored into a 2d canvas whose captureStream feeds a second,
1542  // live MediaRecorder (hardware encode → constant memory, no raw hi-res
1543  // frames buffered). On export the finished blob replaces the low-res frame
1544  // re-render wholesale. The blob is session-memory only, like recordedFrames.
1545  let hdTapeRecorder = null;
1546  let hdTapeChunks = [];
1547  let hdTapeBlob = null;
1548  let hdTapeAborted = false; // hd layer torn down mid-tape → classic export
1549  let hdStreamCanvas = null;
1550  let hdStreamCtx = null;
1551
1552  function hdTapeReset() {
1553    hdTapeAborted = false;
1554    hdTapeBlob = null;
1555    hdTapeChunks = [];
1556    try {
1557      if (hdTapeRecorder && hdTapeRecorder.state !== "inactive") hdTapeRecorder.stop();
1558    } catch (err) { /* already stopped */ }
1559    hdTapeRecorder = null;
1560    hdStreamCanvas = hdStreamCtx = null;
1561  }
1562
1563  function hdTapeAbort() {
1564    hdTapeAborted = true;
1565    try {
1566      if (hdTapeRecorder && hdTapeRecorder.state !== "inactive") hdTapeRecorder.stop();
1567    } catch (err) { /* already stopped */ }
1568    hdTapeRecorder = null;
1569    hdStreamCanvas = hdStreamCtx = null;
1570    hdTapeBlob = null;
1571  }
1572
1573  function hdTapeStop() {
1574    try {
1575      if (hdTapeRecorder && hdTapeRecorder.state !== "inactive") hdTapeRecorder.stop();
1576    } catch (err) { /* already stopped */ }
1577  }
1578
1579  function hdTapeStart(w, h) {
1580    hdStreamCanvas = document.createElement("canvas");
1581    hdStreamCanvas.width = w;
1582    hdStreamCanvas.height = h;
1583    hdStreamCtx = hdStreamCanvas.getContext("2d");
1584    try {
1585      const stream = hdStreamCanvas.captureStream(60);
1586      // Ride the same live audio the tape's audio recorder captures, so the
1587      // hd recording is a complete, synced video on its own.
1588      audioStreamDest?.stream?.getAudioTracks().forEach((t) => stream.addTrack(t));
1589      const mime = [
1590        "video/mp4;codecs=avc1.640028,mp4a.40.2",
1591        "video/mp4",
1592        "video/webm;codecs=vp9,opus",
1593        "video/webm",
1594      ].find((m) => MediaRecorder.isTypeSupported(m));
1595      const bps = Math.min(24e6, Math.max(8e6, Math.round(w * h * 60 * 0.12)));
1596      hdTapeChunks = [];
1597      hdTapeRecorder = new MediaRecorder(stream, {
1598        mimeType: mime,
1599        videoBitsPerSecond: bps,
1600      });
1601      hdTapeRecorder.ondataavailable = (e) => {
1602        if (e.data?.size) hdTapeChunks.push(e.data);
1603      };
1604      hdTapeRecorder.onstop = () => {
1605        if (!hdTapeAborted && hdTapeChunks.length) {
1606          hdTapeBlob = new Blob(hdTapeChunks, { type: hdTapeRecorder?.mimeType || mime });
1607          console.log(`🖼️📼 HD tape encoded: ${(hdTapeBlob.size / 1048576).toFixed(1)}MB ${hdTapeBlob.type} ${w}x${h}`);
1608        }
1609        hdTapeChunks = [];
1610      };
1611      hdTapeRecorder.start(1000); // Gather in 1s chunks.
1612      console.log(`🖼️📼 HD tape recorder armed: ${w}x${h} @ ${(bps / 1e6).toFixed(0)}Mbps ${mime}`);
1613    } catch (err) {
1614      console.warn("🖼️📼 HD tape recorder failed to start:", err);
1615      hdTapeAbort();
1616    }
1617  }
1618
1619  function updateHDLayer(hd) {
1620    if (!hd?.bitmap) return;
1621    if (!hdCan) {
1622      hdCan = document.createElement("canvas");
1623      hdCan.dataset.type = "hd";
1624      hdCan.style.position = "absolute";
1625      hdCan.style.top = "0";
1626      hdCan.style.left = "0";
1627      hdCan.style.zIndex = "1";
1628      hdCan.style.pointerEvents = "none";
1629      hdCtx = hdCan.getContext("bitmaprenderer");
1630      wrapper.append(hdCan);
1631    }
1632    // 🖼️📼 Mirror the bitmap for the tape encoder BEFORE the display transfer
1633    // consumes it (drawImage doesn't). Arms lazily on the tape's first hd
1634    // frame, so the recording spans first-hd-frame → cut.
1635    const taping =
1636      mediaRecorder?.state === "recording" && mediaRecorderStartTime !== undefined;
1637    if (taping && !hdTapeAborted) {
1638      if (!hdStreamCanvas) hdTapeStart(hd.bitmap.width, hd.bitmap.height);
1639      // Fixed encode size for the tape's lifetime — resizing a captured
1640      // track mid-recording corrupts most encoders, so later reframes scale.
1641      hdStreamCtx?.drawImage(hd.bitmap, 0, 0, hdStreamCanvas.width, hdStreamCanvas.height);
1642    }
1643    hdCtx.transferFromImageBitmap(hd.bitmap); // Sets intrinsic size to match.
1644    // Track the main canvas's CSS box so the layer overlays it exactly
1645    // ("100%" in nogap mode, projected pixel sizes otherwise).
1646    if (hdCan.style.width !== canvas.style.width)
1647      hdCan.style.width = canvas.style.width;
1648    if (hdCan.style.height !== canvas.style.height)
1649      hdCan.style.height = canvas.style.height;
1650    hdCan.style.display = "block";
1651  }
1652
1653  // Reusable canvas for dirtyBox updates
1654  let dirtyBoxCanvas = document.createElement("canvas");
1655  let dirtyBoxCtx = dirtyBoxCanvas.getContext("2d");
1656
1657  // Track overlay canvas dimensions to avoid unnecessary resizing
1658  let overlayCanvasWidth = 0;
1659  let overlayCanvasHeight = 0;
1660
1661  let imageData;
1662  let fixedWidth, fixedHeight;
1663  let projectedWidth, projectedHeight;
1664  let canvasRect;
1665
1666  // A post-process / effects layer.
1667  let glaze = { on: false };
1668  let currentGlaze;
1669
1670  const glazeComposite = document.createElement("canvas");
1671  const glazeCompositeCtx = glazeComposite.getContext("2d");
1672
1673  let needsReframe = false;
1674  let needsReappearance = false;
1675  let reframeJustCompleted = false; // Track when we just finished reframing
1676  let dimensionMismatchCount = 0; // Track freeze loop iterations during reframe
1677  let freezeFrame = false,
1678    freezeFrameFrozen = false,
1679    freezeFrameGlaze = false,
1680    glazeReady = true; // Track when glaze shaders have finished loading after resize
1681  let awaitingReframePixels = false; // Keep freeze frame until valid pixels arrive
1682  let reframeDrawn = false;
1683  let reframeDrawnAt = 0;
1684  let lastReframeTime = 0; // Track when the last reframe was requested for debouncing
1685  let freezeFrameRemovalTimeout = null; // Track pending freeze frame removal to prevent stale timeouts
1686
1687  function markReframePixelsReceived(source, width, height) {
1688    if (!awaitingReframePixels) return;
1689    if (width && height) {
1690      if (width !== canvas.width || height !== canvas.height) {
1691        if (window.acReframeDebug) {
1692          console.log("⚠️ REFRAME: pixel size mismatch", {
1693            source,
1694            width,
1695            height,
1696            canvasWidth: canvas.width,
1697            canvasHeight: canvas.height,
1698          });
1699        }
1700        return;
1701      }
1702    }
1703    awaitingReframePixels = false;
1704    if (window.acReframeDebug) {
1705      console.log("✅ REFRAME: pixels received", { source, width, height });
1706    }
1707  }
1708
1709  const screen = apiObject("pixels", "width", "height");
1710  let subdivisions = 1; // Gets set in frame.
1711
1712  // Track UI scaling state to avoid redundant operations
1713  let currentUiScale = 0;
1714  let uiContextScaled = false;
1715
1716  const REFRAME_DELAY = 0; // Instant reframe (was 80ms)
1717  let curReframeDelay = REFRAME_DELAY;
1718  let lastGap = undefined;
1719  // Use URL parameter, or acPACK_DENSITY (for bundles), else default to 2 —
1720  // except the Electron desktop app, which defaults to density 1 (chunkier
1721  // pixels; density 2 reads too fine on the desktop).
1722  let density = resolution.density !== undefined
1723    ? resolution.density
1724    : (window.acPACK_DENSITY !== undefined
1725        ? window.acPACK_DENSITY
1726        : (window.acElectron ? 1 : 2));
1727
1728  // acFORCE_NOGAP pins gap=0 for packed M4L bundles loaded via data: URI
1729  // where there's no `?nogap` query param to read — equivalent behavior.
1730  const startGap =
1731    location.host.indexOf("botce") > -1 || AestheticExtension || window.acFORCE_NOGAP === true ? 0 : 8;
1732
1733  // Runs one on boot & every time display resizes to adjust the framebuffer.
1734  function frame(width, height, gap) {
1735    // Packed M4L bundles pin gap=0 on every reframe so the nogap CSS class
1736    // sticks — without this, a later frame() call with an explicit non-zero
1737    // gap would remove nogap even though the device never wants margin.
1738    if (window.acFORCE_NOGAP === true) gap = 0;
1739    // Notify parent on first frame setup
1740    if (!imageData) {
1741      if (window.acBOOT_LOG) {
1742        window.acBOOT_LOG("setting up display");
1743      } else if (window.parent) {
1744        window.parent.postMessage({ 
1745          type: "boot-log", 
1746          message: "setting up display" 
1747        }, "*");
1748      }
1749    }
1750
1751    if (gap === undefined) gap = lastGap ?? startGap;
1752    lastGap = gap;
1753
1754    gap === 0
1755      ? document.body.classList.add("nogap")
1756      : document.body.classList.remove("nogap");
1757
1758    // Cache the current canvas if needed (only if not already frozen - resize handler handles the capture now)
1759    if (
1760      freezeFrame &&
1761      !freezeFrameFrozen && // Skip if already captured in resize handler
1762      imageData &&
1763      imageData.data &&
1764      imageData.data.buffer &&
1765      imageData.data.buffer.byteLength > 0 &&
1766      !underlayFrame // Don't show freeze frame during tape playback
1767    ) {
1768      if (debug && logs.frame) {
1769        console.log(
1770          "🥶 Freezing:",
1771          freezeFrame,
1772          imageData.width,
1773          imageData.height,
1774        );
1775      }
1776
1777      // Size freeze frame canvas to match imageData dimensions (the actual pixel content)
1778      freezeFrameCan.width = imageData.width;
1779      freezeFrameCan.height = imageData.height;
1780      
1781      // Position freeze frame to cover entire wrapper area
1782      // Using 100% ensures it covers regardless of canvas scaling/positioning
1783      freezeFrameCan.style.width = "100%";
1784      freezeFrameCan.style.height = "100%";
1785      freezeFrameCan.style.left = "0";
1786      freezeFrameCan.style.top = "0";
1787      freezeFrameCan.style.imageRendering = "pixelated";
1788
1789      // Capture the current frame content
1790      if (freezeFrameGlaze) {
1791        // Try Glaze.freeze, fallback to standard capture if it fails
1792        if (!Glaze.freeze(ffCtx)) {
1793          // Glaze capture failed, use fallback
1794          if (imageData && imageData.data && imageData.data.buffer && imageData.data.buffer.byteLength > 0) {
1795            try {
1796              ffCtx.putImageData(imageData, 0, 0);
1797            } catch (e) {
1798              const webglCompositeIsActive = webglBlitter?.isReady() && webglCompositeCanvas.style.display !== "none";
1799              const freezeSource = webglCompositeIsActive ? webglCompositeCanvas : canvas;
1800              if (freezeSource) ffCtx.drawImage(freezeSource, 0, 0);
1801            }
1802          }
1803        }
1804        freezeFrameGlaze = false;
1805      } else {
1806        // Best approach: use imageData directly if available (avoids WebGL readback issues)
1807        if (imageData && imageData.data && imageData.data.buffer && imageData.data.buffer.byteLength > 0) {
1808          try {
1809            ffCtx.putImageData(imageData, 0, 0);
1810          } catch (e) {
1811            // Fallback to canvas copy if putImageData fails
1812            const webglCompositeIsActive = webglBlitter?.isReady() && webglCompositeCanvas.style.display !== "none";
1813            const freezeSource = webglCompositeIsActive ? webglCompositeCanvas : canvas;
1814            ffCtx.drawImage(freezeSource, 0, 0);
1815          }
1816        } else {
1817          // Fallback: capture from appropriate canvas
1818          const webglCompositeIsActive = webglBlitter?.isReady() && webglCompositeCanvas.style.display !== "none";
1819          const freezeSource = webglCompositeIsActive ? webglCompositeCanvas : canvas;
1820          ffCtx.drawImage(freezeSource, 0, 0);
1821        }
1822      }
1823
1824      if (!wrapper.contains(freezeFrameCan)) {
1825        wrapper.append(freezeFrameCan);
1826      }
1827      // Disable transition to instantly reset opacity (prevents partial
1828      // transparency if a CSS fade-out was in progress)
1829      freezeFrameCan.style.transition = "none";
1830      freezeFrameCan.style.opacity = "1";
1831
1832      // Force browser to paint the freeze frame before we continue
1833      // This ensures it's visible before we clear the canvases
1834      freezeFrameCan.offsetHeight;
1835      // Re-enable transition for future fade-out
1836      freezeFrameCan.style.transition = "opacity 50ms ease-out";
1837
1838      freezeFrameFrozen = true;
1839    }
1840
1841    // Find the width and height of our default screen and native projection.
1842    width = width || fixedWidth;
1843    height = height || fixedHeight;
1844
1845    const gapSize = gap * window.devicePixelRatio;
1846
1847    subdivisions = 1;
1848
1849    if (width === undefined && height === undefined) {
1850      // Automatically set and frame a reasonable resolution.
1851      // Or pull from density.
1852      let ratio = density || window.devicePixelRatio;
1853      if (!density && window.devicePixelRatio === 1) ratio = 3; // Always force a screen density of 3 on non-retina displays.
1854      subdivisions = ratio;
1855
1856      width =
1857        floor(window.innerWidth / subdivisions) - floor(gapSize / subdivisions);
1858      height =
1859        floor(window.innerHeight / subdivisions) -
1860        floor(gapSize / subdivisions);
1861
1862      if (TikTok) height -= gap * 3;
1863
1864      projectedWidth = round(width * density);
1865      projectedHeight = round(height * density);
1866    } else {
1867      // Or do it manually if both width and height are defined.
1868      fixedWidth = width;
1869      fixedHeight = height;
1870
1871      const scale = min(window.innerWidth / width, window.innerHeight / height);
1872
1873      projectedWidth = round(width * scale - gapSize);
1874      projectedHeight = round(height * scale - gapSize);
1875    }
1876
1877    if (debug && logs.frame)
1878      console.info(
1879        "🖼 Frame:",
1880        width,
1881        height,
1882        "🖥 Window:",
1883        window.innerWidth,
1884        window.innerHeight,
1885      );
1886
1887    // Send a message about this new width and height to any hosting frames.
1888    // parent.postMessage({ width: projectedWidth, height: projectedHeight }, "*");
1889
1890    // TODO: Changing this width and height here will clear the canvas... which is no good...
1891
1892    // ffCtx.drawImage(canvas, 0, 0);
1893
1894    // TODO: How can I copy the pixels from canvas before changing it's width
1895    //       and height, and then copy them back after changing it here?
1896    // ctx.drawImage(canvas, 0, 0);
1897
1898    // Create a temporary canvas
1899    const tempCanvas = document.createElement("canvas");
1900    const tempCtx = tempCanvas.getContext("2d");
1901
1902    // Copy existing canvas contents from clean imageData (not from canvas which may have overlays)
1903    tempCanvas.width = canvas.width;
1904    tempCanvas.height = canvas.height;
1905    if (
1906      imageData &&
1907      imageData.width === canvas.width &&
1908      imageData.height === canvas.height &&
1909      imageData.data &&
1910      imageData.data.buffer &&
1911      imageData.data.buffer.byteLength > 0
1912    ) {
1913      // Use clean imageData to avoid copying overlays (only if data is not detached)
1914      tempCtx.putImageData(imageData, 0, 0);
1915    } else {
1916      // Fallback to copying from canvas if imageData doesn't match or data is detached
1917      tempCtx.drawImage(canvas, 0, 0);
1918    }
1919
1920    // Hide rendering canvases before resize to prevent black flash
1921    // (resizing a canvas clears it, and the freeze frame will provide visual continuity)
1922    // Note: Keep overlay canvas visible - it contains UI elements that should stay visible during resize
1923    const wasWebglVisible = webglCompositeCanvas.style.display !== "none";
1924    const wasWebgpuVisible = webgpuCanvas.style.display !== "none";
1925    if (wasWebglVisible) webglCompositeCanvas.style.display = "none";
1926    if (wasWebgpuVisible) webgpuCanvas.style.display = "none";
1927
1928    // Resize the original canvas
1929    canvas.width = width;
1930    canvas.height = height;
1931
1932    // Resize WebGPU canvas to match
1933    webgpuCanvas.width = width;
1934    webgpuCanvas.height = height;
1935
1936    // Resize WebGL composite and overlay canvases to match
1937    webglCompositeCanvas.width = width;
1938    webglCompositeCanvas.height = height;
1939    overlayCan.width = width;
1940    overlayCan.height = height;
1941    recordingUICan.width = width;
1942    recordingUICan.height = height;
1943
1944    // Restore the clean pixels onto the resized canvas
1945    // Skip during tape playback — canvas was just cleared to transparent by the
1946    // resize, and copying stale content from the previous piece causes a black
1947    // flash over the underlay video until the next wipe(0,0,0,0) paint.
1948    if (!underlayFrame) {
1949      ctx.drawImage(tempCanvas, 0, 0);
1950    }
1951
1952    // 🎨 REFRAME BACKGROUND FIX: Fill any new extended areas with background color
1953    // But skip during very early initialization to prevent purple flash
1954    const now = performance.now();
1955    const isVeryEarlyLoad = !globalThis.pageLoadTime || (now - globalThis.pageLoadTime) < 100;
1956    
1957    // Initialize page load time on first run
1958    if (!globalThis.pageLoadTime) {
1959      globalThis.pageLoadTime = now;
1960    }
1961    
1962    if ((width > tempCanvas.width || height > tempCanvas.height) && !isVeryEarlyLoad) {
1963      const skipReframeFill =
1964        !!underlayFrame ||
1965        (currentPiece && (
1966          currentPiece.includes("/disks/replay") ||
1967          currentPiece.includes("/disks/video")
1968        ));
1969
1970      if (skipReframeFill) {
1971        if (width > tempCanvas.width) {
1972          ctx.clearRect(tempCanvas.width, 0, width - tempCanvas.width, height);
1973        }
1974        if (height > tempCanvas.height) {
1975          ctx.clearRect(0, tempCanvas.height, width, height - tempCanvas.height);
1976        }
1977      } else {
1978        const coerceToRGB = (color) => {
1979          if (!color) return null;
1980          try {
1981            if (typeof globalThis.graph?.color?.coerce === "function") {
1982              const rgb = globalThis.graph.color.coerce(color);
1983              if (Array.isArray(rgb) && rgb.length >= 3) {
1984                return [rgb[0], rgb[1], rgb[2]];
1985              }
1986            }
1987          } catch (_) {
1988            // graph.color.coerce isn't always available (e.g. non-KidLisp pieces)
1989          }
1990
1991          try {
1992            coerceToRGB.ctx = coerceToRGB.ctx || document.createElement("canvas").getContext("2d", { willReadFrequently: true });
1993            const parseCtx = coerceToRGB.ctx;
1994            parseCtx.fillStyle = "#000";
1995            parseCtx.clearRect(0, 0, 1, 1);
1996            parseCtx.fillStyle = color;
1997            parseCtx.fillRect(0, 0, 1, 1);
1998            const data = parseCtx.getImageData(0, 0, 1, 1).data;
1999            return [data[0], data[1], data[2]];
2000          } catch (_) {
2001            return null;
2002          }
2003        };
2004
2005        const sampleExistingPixel = () => {
2006          if (!tempCanvas.width || !tempCanvas.height) {
2007            return null;
2008          }
2009
2010          const samplePoints = [
2011            [0, 0],
2012            [Math.max(tempCanvas.width - 1, 0), 0],
2013            [0, Math.max(tempCanvas.height - 1, 0)],
2014            [Math.max(tempCanvas.width - 1, 0), Math.max(tempCanvas.height - 1, 0)],
2015            [Math.floor(tempCanvas.width / 2), Math.floor(tempCanvas.height / 2)],
2016          ];
2017
2018          for (const [sx, sy] of samplePoints) {
2019            try {
2020              const data = tempCtx.getImageData(sx, sy, 1, 1).data;
2021              if (data[3] !== 0) {
2022                return [data[0], data[1], data[2]];
2023              }
2024            } catch (_) {
2025              // Canvas may be tainted; abort sampling altogether
2026              break;
2027            }
2028          }
2029
2030          return null;
2031        };
2032
2033        let persistentColor = null;
2034        try {
2035          persistentColor = typeof globalThis.getPersistentFirstLineColor === "function"
2036            ? globalThis.getPersistentFirstLineColor()
2037            : null;
2038        } catch (_) {
2039          persistentColor = null;
2040        }
2041
2042        let fillSpec = resolveBackgroundFillSpec(persistentColor);
2043        let fallbackColor = null;
2044
2045        if (!fillSpec && typeof globalThis.globalKidLispInstance?.getBackgroundFillColor === "function") {
2046          fallbackColor = globalThis.globalKidLispInstance.getBackgroundFillColor();
2047          fillSpec = resolveBackgroundFillSpec(fallbackColor);
2048        }
2049
2050        let appliedFade = false;
2051        let fillRGB = null;
2052
2053        if (fillSpec?.type === "fade") {
2054          fillFadeExpansionsOnCanvas(
2055            ctx,
2056            width,
2057            height,
2058            tempCanvas.width,
2059            tempCanvas.height,
2060            fillSpec.fadeInfo,
2061          );
2062          appliedFade = true;
2063        } else if (fillSpec?.type === "solid") {
2064          fillRGB = fillSpec.rgba.slice(0, 3);
2065        }
2066
2067        if (!fillSpec) {
2068          fillRGB = coerceToRGB(persistentColor);
2069          if (!fillRGB && fallbackColor !== null) {
2070            fillRGB = coerceToRGB(fallbackColor);
2071          }
2072        }
2073
2074        if (!appliedFade) {
2075          if (!fillRGB && imageData && imageData.data && imageData.data.length >= 4) {
2076            const data = imageData.data;
2077            for (let i = 0; i < data.length; i += 4) {
2078              if (data[i + 3] !== 0) {
2079                fillRGB = [data[i], data[i + 1], data[i + 2]];
2080                break;
2081              }
2082            }
2083          }
2084
2085          if (!fillRGB) {
2086            fillRGB = sampleExistingPixel();
2087          }
2088
2089          if (!fillRGB) {
2090            // Fallback to neutral black if everything else fails
2091            fillRGB = [0, 0, 0];
2092          }
2093
2094          const [r, g, b] = fillRGB;
2095          ctx.fillStyle = `rgb(${r}, ${g}, ${b})`;
2096
2097          if (width > tempCanvas.width) {
2098            ctx.fillRect(tempCanvas.width, 0, width - tempCanvas.width, height);
2099          }
2100
2101          if (height > tempCanvas.height) {
2102            ctx.fillRect(0, tempCanvas.height, width, height - tempCanvas.height);
2103          }
2104        }
2105      }
2106    }
2107
2108    tempCanvas.width = glazeComposite.width;
2109    tempCanvas.height = glazeComposite.height;
2110
2111    tempCtx.drawImage(glazeComposite, 0, 0);
2112
2113    glazeComposite.width = canvas.width;
2114    glazeComposite.height = canvas.height;
2115    glazeCompositeCtx.drawImage(tempCanvas, 0, 0);
2116
2117    uiCanvas.width = projectedWidth * window.devicePixelRatio;
2118    uiCanvas.height = projectedHeight * window.devicePixelRatio;
2119
2120    // Reset UI scaling state when canvas size changes
2121    uiContextScaled = false;
2122    currentUiScale = 0;
2123
2124    // Horizontal and vertical offsetting of the wrapper.
2125    // In nogap mode, fill the entire viewport to avoid subpixel gaps
2126    if (gap === 0) {
2127      wrapper.style.top = "0";
2128      wrapper.style.left = "0";
2129      wrapper.style.width = "100%";
2130      wrapper.style.height = "100%";
2131      
2132      // Canvases also fill 100% in nogap mode to avoid subpixel rounding gaps
2133      canvas.style.width = "100%";
2134      canvas.style.height = "100%";
2135      webglCompositeCanvas.style.width = "100%";
2136      webglCompositeCanvas.style.height = "100%";
2137      webgpuCanvas.style.width = "100%";
2138      webgpuCanvas.style.height = "100%";
2139      overlayCan.style.width = "100%";
2140      overlayCan.style.height = "100%";
2141      recordingUICan.style.width = "100%";
2142      recordingUICan.style.height = "100%";
2143      uiCanvas.style.width = "100%";
2144      uiCanvas.style.height = "100%";
2145    } else if (TikTok) {
2146      wrapper.style.top = `${8 * 1.5}px`;
2147      wrapper.style.left = (window.innerWidth - projectedWidth) / 2 + "px";
2148      wrapper.style.width = projectedWidth + "px";
2149      wrapper.style.height = projectedHeight + "px";
2150      
2151      canvas.style.width = projectedWidth + "px";
2152      canvas.style.height = projectedHeight + "px";
2153      webglCompositeCanvas.style.width = projectedWidth + "px";
2154      webglCompositeCanvas.style.height = projectedHeight + "px";
2155      webgpuCanvas.style.width = projectedWidth + "px";
2156      webgpuCanvas.style.height = projectedHeight + "px";
2157      overlayCan.style.width = projectedWidth + "px";
2158      overlayCan.style.height = projectedHeight + "px";
2159      recordingUICan.style.width = projectedWidth + "px";
2160      recordingUICan.style.height = projectedHeight + "px";
2161      uiCanvas.style.width = projectedWidth + "px";
2162      uiCanvas.style.height = projectedHeight + "px";
2163    } else {
2164      wrapper.style.top = (window.innerHeight - projectedHeight) / 2 + "px";
2165      wrapper.style.left = (window.innerWidth - projectedWidth) / 2 + "px";
2166      wrapper.style.width = projectedWidth + "px";
2167      wrapper.style.height = projectedHeight + "px";
2168      
2169      canvas.style.width = projectedWidth + "px";
2170      canvas.style.height = projectedHeight + "px";
2171      webglCompositeCanvas.style.width = projectedWidth + "px";
2172      webglCompositeCanvas.style.height = projectedHeight + "px";
2173      webgpuCanvas.style.width = projectedWidth + "px";
2174      webgpuCanvas.style.height = projectedHeight + "px";
2175      overlayCan.style.width = projectedWidth + "px";
2176      overlayCan.style.height = projectedHeight + "px";
2177      recordingUICan.style.width = projectedWidth + "px";
2178      recordingUICan.style.height = projectedHeight + "px";
2179      uiCanvas.style.width = projectedWidth + "px";
2180      uiCanvas.style.height = projectedHeight + "px";
2181    }
2182
2183    if (debug) {
2184      debugCanvas.width = projectedWidth;
2185      debugCanvas.height = projectedHeight;
2186      debugCanvas.style.width = projectedWidth + "px";
2187      debugCanvas.style.height = projectedHeight + "px";
2188    }
2189
2190    // During tape playback, don't restore stale content — the canvas was just
2191    // cleared to transparent by the resize, and we need screen.pixels to be
2192    // the correct new size so the render-abort mismatch check passes.
2193    if (underlayFrame) {
2194      imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
2195      console.log(`🎬 REFRAME: tape playback reset imageData → ${imageData.width}×${imageData.height}, alpha[0]=${imageData.data[3]}`);
2196    } else if (
2197      imageData &&
2198      imageData.data &&
2199      imageData.data.buffer &&
2200      imageData.data.buffer.byteLength > 0
2201    ) {
2202      ctx.putImageData(imageData, 0, 0);
2203    } else {
2204      imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
2205      // This will have zero alpha.
2206    }
2207
2208    // Store clean pixel data for worker communication (before any overlays are drawn)
2209    assign(screen, { pixels: imageData.data, width, height });
2210    if (underlayFrame) {
2211      console.log(`🎬 REFRAME: screen.pixels set → length=${screen.pixels.length} (${width}×${height}×4=${width*height*4})`);
2212    }
2213
2214    TwoD?.frame(width, height, wrapper); // Reframe the 2D GPU layer.
2215
2216    // Add the canvas, modal, and uiCanvas when we first boot up.
2217    if (!wrapper.contains(canvas)) {
2218      perf.markBoot("canvas-setup-start");
2219      wrapper.append(canvas);
2220      wrapper.append(webglCompositeCanvas);
2221      wrapper.append(modal);
2222
2223      const bumper = document.createElement("div");
2224      bumper.id = "bumper";
2225      modal.append(bumper);
2226
2227      wrapper.append(overlayCan);
2228      wrapper.append(recordingUICan); // Recording UI canvas (NOT captured in tape)
2229      wrapper.append(uiCanvas);
2230      if (debug) wrapper.append(debugCanvas);
2231      wrapper.append(webgpuCanvas); // Add WebGPU canvas (initially hidden)
2232      wrapper.append(statsOverlay); // Add stats overlay (highest z-index)
2233      document.body.append(wrapper);
2234      perf.markBoot("dom-appended");
2235
2236      // Initialize WebGPU 2D renderer with dedicated canvas (skip in PACK mode)
2237      if (!window.acPACK_MODE) {
2238        initWebGPU(webgpuCanvas).catch(err => {
2239          console.warn("⚠️ WebGPU initialization failed:", err);
2240        });
2241      }
2242
2243      if (!webglBlitter) {
2244        webglBlitter = createWebGLBlitter(webglCompositeCanvas);
2245        const ok = webglBlitter.init();
2246        if (!ok) webglBlitter = null;
2247      }
2248
2249      const fonts = [
2250        "berkeley-mono-variable.css",
2251        "ywft-processing-regular.css",
2252        "ywft-processing-light.css",
2253        "ywft-processing-bold.css",
2254      ];
2255
2256      // Load fonts post-boot.
2257      fonts.forEach((font) => {
2258        const link = document.createElement("link");
2259        link.rel = "stylesheet";
2260        link.crossOrigin = "anonymous";
2261        
2262        // Use origin-aware font loading
2263        let fontUrl;
2264        try {
2265          // Check if we're in OBJKT/PACK mode (sandboxed with bundled fonts)
2266          if (window.acPACK_MODE) {
2267            // In OBJKT mode, use relative paths to bundled fonts
2268            fontUrl = "./type/webfonts/" + font;
2269          } else if (window.acSPIDER) {
2270            // In SPIDER mode, leech fonts from aesthetic.computer
2271            fontUrl = "https://aesthetic.computer/type/webfonts/" + font;
2272          } else {
2273            // Check if we're in development environment
2274            const isDevelopment = location.hostname === 'localhost' && location.port;
2275            if (isDevelopment) {
2276              // In development, fonts are served from the root /type/webfonts/ path
2277              fontUrl = "/type/webfonts/" + font;
2278            } else {
2279              // In production or sandboxed iframe, use the standard path
2280              fontUrl = "/type/webfonts/" + font;
2281            }
2282          }
2283        } catch (err) {
2284          // Fallback to standard path if there's any error
2285          fontUrl = "/type/webfonts/" + font;
2286        }
2287        
2288        link.href = fontUrl;
2289        document.body.append(link);
2290      });
2291
2292      if ("fonts" in document) {
2293        (async () => {
2294          try {
2295            // Wait for stylesheet to load first
2296            await new Promise((resolve) => {
2297              const checkFonts = () => {
2298                // Check if at least one font face is available
2299                const fontFaces = Array.from(document.fonts);
2300                if (fontFaces.length > 0) {
2301                  resolve();
2302                } else {
2303                  setTimeout(checkFonts, 50);
2304                }
2305              };
2306              checkFonts();
2307            });
2308
2309            // Now load the fonts explicitly
2310            await Promise.all([
2311              document.fonts.load("1em YWFTProcessing-Light"),
2312              document.fonts.load("1em YWFTProcessing-Regular"),
2313              document.fonts.load("bold 1em YWFTProcessing-Regular"),
2314              document.fonts.load("normal 1em YWFTProcessing-Light"),
2315            ]);
2316          } catch (error) {
2317            console.warn("⚠️ Font loading during boot failed:", error);
2318            // Fallback to fonts.ready
2319            await document.fonts.ready;
2320          }
2321        })();
2322        // document.fonts.load("1em Berkeley Mono Variable").then(() => {
2323        //   setTimeout(() => {
2324        //     document.getElementById("console")?.classList.remove("hidden");
2325        //   }, 250);
2326        // });
2327      }
2328
2329      let timeout;
2330      let lastWidth = window.innerWidth;
2331      let lastHeight = window.innerHeight;
2332
2333      window.addEventListener("resize", (e) => {
2334        if (
2335          lastWidth === window.innerWidth &&
2336          lastHeight === window.innerHeight
2337        ) {
2338          return;
2339        }
2340
2341        lastWidth = window.innerWidth;
2342        lastHeight = window.innerHeight;
2343
2344        // Check to see if we are in "native-cursor" mode and hide
2345        // #aesthetic.computer for the resize if we aren't.
2346        if (document.body.classList.contains("native-cursor") === false) {
2347          // wrapper.classList.add("hidden");
2348        }
2349
2350        window.clearTimeout(timeout); // Small timer to save on performance.
2351        timeout = setTimeout(() => {
2352          // Cancel any pending freeze frame removal from a previous resize cycle
2353          if (freezeFrameRemovalTimeout !== null) {
2354            clearTimeout(freezeFrameRemovalTimeout);
2355            freezeFrameRemovalTimeout = null;
2356          }
2357          // Capture freeze frame before reframing to prevent black flicker
2358          // Skip during tape playback — the freeze frame would cover the video
2359          // underlay with stale opaque content, causing a black screen.
2360          if (!underlayFrame && imageData && imageData.data && imageData.data.buffer && imageData.data.buffer.byteLength > 0) {
2361            freezeFrame = true;
2362            freezeFrameGlaze = glaze.on;
2363            
2364            // Capture freeze frame content BEFORE resizing (resizing clears canvas)
2365            // Only resize if dimensions are different to avoid unnecessary clears
2366            if (freezeFrameCan.width !== imageData.width || freezeFrameCan.height !== imageData.height) {
2367              freezeFrameCan.width = imageData.width;
2368              freezeFrameCan.height = imageData.height;
2369            }
2370            
2371            // Capture current frame to freeze frame canvas
2372            if (freezeFrameGlaze) {
2373              // Try Glaze.freeze, fallback to standard capture if it fails
2374              if (!Glaze.freeze(ffCtx)) {
2375                // Glaze capture failed, use fallback
2376                try {
2377                  ffCtx.putImageData(imageData, 0, 0);
2378                } catch (e) {
2379                  const webglCompositeIsActive = webglBlitter?.isReady() && webglCompositeCanvas.style.display !== "none";
2380                  const freezeSource = webglCompositeIsActive ? webglCompositeCanvas : canvas;
2381                  if (freezeSource) ffCtx.drawImage(freezeSource, 0, 0);
2382                }
2383              }
2384            } else {
2385              try {
2386                ffCtx.putImageData(imageData, 0, 0);
2387              } catch (e) {
2388                // Fallback to canvas copy
2389                const webglCompositeIsActive = webglBlitter?.isReady() && webglCompositeCanvas.style.display !== "none";
2390                const freezeSource = webglCompositeIsActive ? webglCompositeCanvas : canvas;
2391                if (freezeSource) ffCtx.drawImage(freezeSource, 0, 0);
2392              }
2393            }
2394            
2395            // Position freeze frame
2396            freezeFrameCan.style.width = "100%";
2397            freezeFrameCan.style.height = "100%";
2398            freezeFrameCan.style.left = "0";
2399            freezeFrameCan.style.top = "0";
2400            freezeFrameCan.style.imageRendering = "pixelated";
2401            
2402            // Ensure freeze frame is visible
2403            if (!wrapper.contains(freezeFrameCan)) {
2404              wrapper.append(freezeFrameCan);
2405            }
2406            // Disable transition to instantly reset opacity (prevents partial
2407            // transparency if a CSS fade-out was in progress from a previous cycle)
2408            freezeFrameCan.style.transition = "none";
2409            freezeFrameCan.style.opacity = "1";
2410
2411            // Force paint before continuing
2412            freezeFrameCan.offsetHeight;
2413            // Re-enable transition for future fade-out
2414            freezeFrameCan.style.transition = "opacity 50ms ease-out";
2415            freezeFrameFrozen = true;
2416          }
2417          awaitingReframePixels = true;
2418          reframeDrawn = false;
2419          reframeDrawnAt = 0;
2420          lastReframeTime = performance.now();
2421          if (window.acReframeDebug) {
2422            console.log("🧊 REFRAME: awaiting pixels", {
2423              width: imageData?.width,
2424              height: imageData?.height,
2425            });
2426          }
2427          needsReframe = true; // This makes zooming work / not work.
2428          curReframeDelay = REFRAME_DELAY;
2429        }, curReframeDelay); // Is this needed?
2430      });
2431
2432      // Prevent canvas touchstart events from triggering magnifying glass on
2433      // iOS Safari, unless a link is pressed.
2434      wrapper.addEventListener(
2435        "touchstart",
2436        function (event) {
2437          if (
2438            (document.hasFocus() &&
2439              !ticketWrapper &&
2440              event.target.tagName !== "A" &&
2441              event.target.tagName !== "IMG") ||
2442            event.touches.length > 2 // Prevent undo pop-up in Mobile Safari.
2443          ) {
2444            event.preventDefault();
2445          }
2446        },
2447        false,
2448      );
2449    }
2450
2451    canvasRect = canvas.getBoundingClientRect();
2452
2453    // Glaze.clear();
2454
2455    // A native resolution canvas for drawing cursors, system UI, and effects.
2456    if (glaze.on) {
2457      glazeReady = false; // Mark glaze as not ready during shader reload
2458      currentGlaze = Glaze.on(
2459        canvas.width,
2460        canvas.height,
2461        canvasRect,
2462        projectedWidth,
2463        projectedHeight,
2464        wrapper,
2465        glaze.type,
2466        () => {
2467          glazeReady = true; // Glaze shaders loaded, safe to show
2468          // Don't unfreeze here — let the render loop handle it once
2469          // Glaze.update() has been called with valid pixels.
2470          send({ type: "needs-paint" }); // Render a frame through glaze
2471        },
2472      );
2473      // If Glaze.on() returned early (same dimensions/type, nothing to do),
2474      // the loaded() callback was never called, leaving glazeReady stuck at
2475      // false. This would permanently block freeze frame removal and leave
2476      // the glaze canvas hidden. Restore the flag so the reframe can complete.
2477      if (!glazeReady) {
2478        glazeReady = true;
2479        // Don't unfreeze here — render loop will unfreeze once pixels arrive.
2480      }
2481    } else {
2482      Glaze.off();
2483      glaze.on = false;
2484      canvas.style.removeProperty("opacity");
2485    }
2486
2487    needsReframe = false;
2488    reframeJustCompleted = true; // Mark that we just completed a reframe
2489    needsReappearance = true; // Only for `native-cursor` mode.
2490    // Send reframed BEFORE needs-paint so worker updates dimensions before painting
2491    if (underlayFrame && window.acReframeDebug) {
2492      console.log('📤 REFRAME: Sending reframe message to worker. New dimensions:', width, 'x', height);
2493    }
2494    awaitingReframePixels = true;
2495    reframeDrawn = false;
2496    reframeDrawnAt = 0;
2497    send({
2498      type: "reframed",
2499      content: {
2500        innerWidth: window.innerWidth,
2501        innerHeight: window.innerHeight,
2502        subdivisions,
2503        pixelRatio: window.devicePixelRatio, // For the worker's `hd()` layer.
2504        width,  // Include the new screen dimensions so the worker can update immediately
2505        height,
2506      },
2507    });
2508    send({ type: "needs-paint" });
2509  }
2510
2511  // Used by `disk` to set the metatags by default when a piece loads. It can
2512  // be overridden using `meta` inside of `boot` for any given piece.
2513  // TODO: Some meta tags in practice use image_url & icon_url it seems.
2514  //       (Like in `hell_-world` or `freaky-flowers`) 23.10.25.20.32
2515  function setMetatags(meta) {
2516    if (meta?.title) {
2517      // Don't override title in OBJKT mode - let the pack-time title remain
2518      if (!checkPackMode()) {
2519        document.title = meta.title;
2520      }
2521      const ogTitle = document.querySelector('meta[name="og:title"]');
2522      if (ogTitle) ogTitle.content = meta.title;
2523      const twitterTitle = document.querySelector('meta[name="twitter:title"]');
2524      if (twitterTitle) twitterTitle.content = meta.title;
2525    }
2526    if (meta?.desc) {
2527      const ogDesc = document.querySelector('meta[name="og:description"]');
2528      if (ogDesc) ogDesc.content = meta.desc;
2529      const desc = document.querySelector('meta[name="description"]');
2530      if (desc) desc.content = meta.desc;
2531    }
2532    if (meta?.img?.og) {
2533      const ogImg = document.querySelector('meta[name="og:image"]');
2534      if (ogImg) ogImg.content = meta.img.og;
2535    }
2536    if (meta?.img?.twitter) {
2537      const twitterImg = document.querySelector('meta[name="twitter:image"]');
2538      if (twitterImg) twitterImg.content = meta.img.twitter;
2539    }
2540
2541    const icon = document.querySelector('link[rel="icon"]');
2542
2543    if (icon) {
2544      if (meta?.icon_url) {
2545        if (icon.href !== meta.icon_url) icon.href = meta.icon_url;
2546      } else if (meta?.img?.icon) {
2547        if (icon.href !== meta.img.icon) icon.href = meta.img.icon;
2548      }
2549    }
2550
2551    if (meta?.url) {
2552      // This might need to be conditional / opt-in?
2553      // document.querySelector('meta[name="twitter:player"').content = meta.url;
2554    }
2555  }
2556
2557  // *** External Library Dependency Injection ***
2558
2559  // FFMPEG.WASM
2560  async function loadFFmpeg() {
2561    return new Promise((resolve, reject) => {
2562      const script = document.createElement("script");
2563      script.src = "/aesthetic.computer/dep/ffmpeg/ffmpeg.min.js";
2564
2565      script.onerror = function (err) {
2566        reject(err, s);
2567      };
2568
2569      script.onload = function handleScriptLoaded() {
2570        if (debug && logs.deps) console.log("📼 FFmpeg has loaded.", FFmpeg);
2571        resolve(FFmpeg);
2572      };
2573      document.head.appendChild(script);
2574    });
2575    // return await import(`/aesthetic.computer/dep/@ffmpeg/ffmpeg-core.js`);
2576  }
2577
2578  async function loadJSZip() {
2579    return new Promise((resolve, reject) => {
2580      const script = document.createElement("script");
2581      script.src = "/aesthetic.computer/dep/jszip.min.js";
2582
2583      script.onerror = function () {
2584        reject(new Error("Could not load the painting recorder"));
2585      };
2586
2587      script.onload = function handleScriptLoaded() {
2588        if (debug && logs.deps)
2589          console.log("🤐 JSZip has loaded.", window.JSZip);
2590        resolve(window.JSZip);
2591      };
2592
2593      document.head.appendChild(script);
2594    });
2595  }
2596
2597  async function loadStripe() {
2598    return new Promise((resolve, reject) => {
2599      const script = document.createElement("script");
2600      script.src = "https://js.stripe.com/v3/";
2601      script.crossOrigin = "anonymous";
2602
2603      script.onerror = function (err) {
2604        reject(err, s);
2605      };
2606
2607      script.onload = function handleScriptLoaded() {
2608        if (debug && logs.deps)
2609          console.log("🦓 Stripe has loaded.", window.Stripe);
2610        resolve(window.Stripe);
2611      };
2612
2613      document.head.appendChild(script);
2614    });
2615  }
2616
2617  // THREE.JS (With a thin wrapper called ThreeD).
2618  let ThreeD;
2619  let ThreeDBakeQueue = [];
2620  async function loadThreeD() {
2621    ThreeD = await import(`./lib/3d.mjs`);
2622    ThreeD.initialize(
2623      wrapper,
2624      Loop.mainLoop,
2625      receivedDownload,
2626      receivedUpload,
2627      send,
2628    );
2629  }
2630
2631  // WebGPU 2D Renderer
2632  let webGPUInitialized = false;
2633  let activeGPUBackend = null; // 🎨 Current renderer backend
2634  
2635  async function initWebGPU(canvas) {
2636    if (webGPUInitialized) return;
2637    
2638    // Try to initialize with fallback chain
2639    const preferredBackend = localStorage.getItem("ac-gpu-backend") || "webgpu";
2640    activeGPUBackend = await initGPU(canvas, preferredBackend);
2641    
2642    if (activeGPUBackend) {
2643      webGPUInitialized = true;
2644      log.gpu.success?.(`GPU renderer ready: ${activeGPUBackend.getName()}`);
2645      // Expose for debugging/artery access
2646      window.GPU = activeGPUBackend;
2647      window.switchGPUBackend = async (name) => {
2648        const newBackend = await switchBackend(canvas, name);
2649        if (newBackend) {
2650          activeGPUBackend = newBackend;
2651          window.GPU = newBackend;
2652          log.gpu.success?.(`Switched to: ${name}`);
2653          return true;
2654        }
2655        return false;
2656      };
2657    } else {
2658      log.gpu.warn?.("No GPU backend available");
2659    }
2660  }
2661
2662  // Web3
2663  async function loadWeb3() {
2664    return new Promise((resolve, reject) => {
2665      const script = document.createElement("script");
2666      script.src = "/aesthetic.computer/dep/web3/web3.min.js";
2667
2668      script.onerror = (err) => reject(err, s);
2669
2670      script.onload = function handleScriptLoaded() {
2671        if (debug) console.log("🕸️3️⃣ Ready...");
2672        resolve(Web3);
2673      };
2674
2675      document.head.appendChild(script);
2676    });
2677  }
2678
2679  // Tezos Wallet (Beacon SDK)
2680  let tezosWallet = null;
2681  
2682  // 🔷 Persistent wallet state (survives piece transitions)
2683  const walletState = {
2684    connected: false,
2685    address: null,
2686    balance: null,
2687    network: "ghostnet",
2688    domain: null,
2689  };
2690  
2691  // Broadcast wallet state to disk.mjs
2692  function broadcastWalletState() {
2693    send({ type: "wallet:state", content: { ...walletState } });
2694  }
2695  
2696  // 🔷 Tezos Wallet Connection (Beacon SDK only)
2697  // All wallets (Temple, Kukai, etc.) connect through Beacon's universal wallet picker.
2698
2699  const TEZOS_RPC = {
2700    mainnet: "https://mainnet.api.tez.ie",
2701    ghostnet: "https://ghostnet.ecadinfra.com",
2702  };
2703
2704  // Beacon SDK singleton — NOTE: v4.0.12 is stable; v4.6.3+ has IndexedDB issues
2705  let beaconClient = null;
2706  let beaconNetwork = null;
2707  let beaconSDK = null; // cached import
2708
2709  async function loadBeacon(network = "ghostnet") {
2710    if (!beaconSDK) {
2711      beaconSDK = await import("https://esm.sh/v135/@airgap/[email protected]/es2022/beacon-sdk.bundle.mjs");
2712    }
2713    const { DAppClient, NetworkType } = beaconSDK;
2714    const networkType = network === "mainnet" ? NetworkType.MAINNET : NetworkType.GHOSTNET;
2715
2716    if (!beaconClient || beaconNetwork !== network) {
2717      if (beaconClient) {
2718        try { await beaconClient.destroy(); } catch {}
2719      }
2720      beaconClient = new DAppClient({
2721        name: "Aesthetic Computer",
2722        preferredNetwork: networkType,
2723      });
2724      beaconNetwork = network;
2725    }
2726    return beaconClient;
2727  }
2728
2729  // Build the tezosWallet wrapper from a connected Beacon client + address
2730  function buildBeaconWallet(client, address, publicKey, network) {
2731    const rpcUrl = TEZOS_RPC[network] || TEZOS_RPC.ghostnet;
2732    return {
2733      _client: client,
2734      _rpcUrl: rpcUrl,
2735      _network: network,
2736      _publicKey: publicKey,
2737      pkh: () => address,
2738      sign: async (payload) => {
2739        const result = await client.requestSignPayload({
2740          signingType: "raw",
2741          payload,
2742        });
2743        return result.signature;
2744      },
2745      sendOperations: async (operations) => {
2746        const result = await client.requestOperation({
2747          operationDetails: operations,
2748        });
2749        return result.transactionHash;
2750      },
2751    };
2752  }
2753
2754  // Restore wallet session via Beacon's own persistence
2755  async function restoreWalletSession() {
2756    try {
2757      const savedSession = localStorage.getItem("ac:tezos:session");
2758      if (!savedSession) {
2759        log.wallet.verbose("No saved wallet session");
2760        return false;
2761      }
2762
2763      const session = JSON.parse(savedSession);
2764      log.wallet.debug("Restoring wallet session:", session.address?.slice(0, 8) + "...");
2765
2766      const network = session.network || "ghostnet";
2767      const client = await loadBeacon(network);
2768      const activeAccount = await client.getActiveAccount();
2769
2770      if (activeAccount?.address) {
2771        const address = activeAccount.address;
2772        const publicKey = activeAccount.publicKey;
2773
2774        walletState.connected = true;
2775        walletState.address = address;
2776        walletState.network = network;
2777        walletState.walletType = "beacon";
2778
2779        tezosWallet = buildBeaconWallet(client, address, publicKey, network);
2780        log.wallet.success("Beacon wallet restored:", address.slice(0, 8) + "...");
2781
2782        // Fetch fresh balance and domain in background
2783        Promise.all([
2784          fetchTezosBalanceForBios(address, network),
2785          fetchTezosDomain(address, network)
2786        ]).then(([balance, domain]) => {
2787          walletState.balance = balance;
2788          walletState.domain = domain;
2789          broadcastWalletState();
2790        });
2791
2792        broadcastWalletState();
2793        return true;
2794      } else {
2795        log.wallet.debug("Beacon has no active account, clearing stale session");
2796        clearWalletSession();
2797        return false;
2798      }
2799    } catch (err) {
2800      log.wallet.debug("restoreWalletSession error:", err.message);
2801      return false;
2802    }
2803  }
2804
2805  // Save wallet session to localStorage
2806  function saveWalletSession(address, network, walletType) {
2807    localStorage.setItem("ac:tezos:session", JSON.stringify({
2808      address,
2809      network,
2810      walletType,
2811      timestamp: Date.now(),
2812    }));
2813  }
2814
2815  // Clear wallet session
2816  function clearWalletSession() {
2817    localStorage.removeItem("ac:tezos:session");
2818  }
2819
2820  // Connect wallet — Beacon handles wallet selection (Temple, Kukai, etc.)
2821  async function connectTezosWallet(network = "ghostnet") {
2822    console.log("🔷 Connecting wallet via Beacon SDK...");
2823
2824    try {
2825      const { PermissionScope } = beaconSDK || await import("https://esm.sh/v135/@airgap/[email protected]/es2022/beacon-sdk.bundle.mjs");
2826      const client = await loadBeacon(network);
2827
2828      const permissions = await client.requestPermissions({
2829        scopes: [PermissionScope.SIGN, PermissionScope.OPERATION_REQUEST],
2830      });
2831
2832      const address = permissions.address;
2833      const publicKey = permissions.publicKey;
2834
2835      console.log("🔷 Wallet connected:", address);
2836
2837      walletState.connected = true;
2838      walletState.address = address;
2839      walletState.network = network;
2840      walletState.walletType = "beacon";
2841
2842      tezosWallet = buildBeaconWallet(client, address, publicKey, network);
2843      saveWalletSession(address, network, "beacon");
2844
2845      // Fetch balance and domain in background
2846      Promise.all([
2847        fetchTezosBalanceForBios(address, network),
2848        fetchTezosDomain(address, network)
2849      ]).then(([balance, domain]) => {
2850        walletState.balance = balance;
2851        walletState.domain = domain;
2852        broadcastWalletState();
2853      }).catch(err => {
2854        console.warn("🔷 Failed to fetch balance/domain:", err);
2855      });
2856
2857      broadcastWalletState();
2858      return address;
2859    } catch (err) {
2860      console.log("🔷 Wallet connection error:", err?.message || err);
2861      throw new Error(err?.message || "Wallet connection cancelled");
2862    }
2863  }
2864  
2865  // Sign a message with the connected wallet
2866  // NOTE: Temple wallet doesn't support message signing via postMessage API
2867  // This function is kept for future use with wallets that support it
2868  async function signTezosMessage(message) {
2869    if (!walletState.connected || !tezosWallet) {
2870      console.log("🔷 Wallet not connected for signing, attempting auto-restore...");
2871      const restored = await restoreWalletSession();
2872      if (!restored || !walletState.connected || !tezosWallet) {
2873        throw new Error("Wallet not connected");
2874      }
2875    }
2876    
2877    // Convert message to hex payload (Micheline format)
2878    const encoder = new TextEncoder();
2879    const bytes = encoder.encode(message);
2880    const hexPayload = "05" + "01" + bytes.length.toString(16).padStart(8, "0") + 
2881      Array.from(bytes).map(b => b.toString(16).padStart(2, "0")).join("");
2882    
2883    console.log("🔷 Signing message:", message);
2884    console.log("🔷 Hex payload:", hexPayload);
2885    
2886    try {
2887      const signature = await tezosWallet.sign(hexPayload);
2888      console.log("🔷 Signature:", signature);
2889      return signature;
2890    } catch (err) {
2891      console.log("🔷 Sign error:", err?.message || err);
2892      throw new Error(err?.message || "Signing cancelled");
2893    }
2894  }
2895  
2896  async function disconnectTezosWallet() {
2897    console.log("🔷 Disconnecting Tezos wallet");
2898    
2899    // If Beacon client exists, clear its permissions
2900    if (beaconClient) {
2901      try {
2902        await beaconClient.clearActiveAccount();
2903      } catch (e) {}
2904    }
2905    clearWalletSession();
2906    
2907    walletState.connected = false;
2908    walletState.address = null;
2909    walletState.balance = null;
2910    walletState.domain = null;
2911    walletState.walletType = null;
2912    
2913    broadcastWalletState();
2914  }
2915  
2916  async function getTezosAddress() {
2917    return walletState.address;
2918  }
2919  
2920  // Fetch Tezos balance from RPC (for bios.mjs wallet API)
2921  async function fetchTezosBalanceForBios(address, network = "ghostnet") {
2922    try {
2923      const rpcUrl = network === "mainnet" 
2924        ? "https://mainnet.api.tez.ie"
2925        : "https://ghostnet.ecadinfra.com";
2926      const res = await fetch(`${rpcUrl}/chains/main/blocks/head/context/contracts/${address}/balance`);
2927      if (res.ok) {
2928        const balanceMutez = await res.json();
2929        return parseInt(balanceMutez) / 1_000_000; // Convert mutez to tez
2930      }
2931    } catch (e) {
2932      console.warn("Failed to fetch Tezos balance:", e);
2933    }
2934    return null;
2935  }
2936  
2937  // Update user's Tezos address in MongoDB profile (called after wallet connection)
2938  // NOTE: Currently disabled - getToken() is not available in bios.mjs context
2939  // TODO: Implement if server-side persistence is needed via proper auth flow
2940  /*
2941  async function updateUserTezosAddress(address, network) {
2942    try {
2943      const token = await getToken();
2944      if (!token) {
2945        console.log("⚠️  Not logged in - skipping Tezos address save");
2946        return;
2947      }
2948
2949      const response = await fetch("/api/update-tezos-address", {
2950        method: "POST",
2951        headers: {
2952          "Content-Type": "application/json",
2953          "Authorization": `Bearer ${token}`,
2954        },
2955        body: JSON.stringify({ address, network }),
2956      });
2957
2958      if (response.ok) {
2959        console.log(`✅ Saved Tezos address to profile: ${address}`);
2960      } else {
2961        console.warn("Failed to save Tezos address:", await response.text());
2962      }
2963    } catch (err) {
2964      console.warn("Error saving Tezos address:", err);
2965    }
2966  }
2967  */
2968
2969  // Resolve .tez domain for an address using TzKT API (more reliable than Tezos Domains GraphQL)
2970  // NOTE: Always use mainnet API since .tez domains are only registered on mainnet
2971  async function fetchTezosDomain(address, _network = "ghostnet") {
2972    try {
2973      // Always use mainnet TzKT API - .tez domains only exist on mainnet
2974      const apiBase = "https://api.tzkt.io";
2975      
2976      const controller = new AbortController();
2977      const timeout = setTimeout(() => controller.abort(), 5000);
2978      
2979      // Look for domains where this address is set AND has reverse=true (primary domain)
2980      const url = `${apiBase}/v1/domains?address=${address}&reverse=true&select=name`;
2981      const res = await fetch(url, { signal: controller.signal });
2982      
2983      clearTimeout(timeout);
2984      
2985      if (res.ok) {
2986        const data = await res.json();
2987        // Returns array of domain strings (not objects) when using select=name
2988        if (data && data.length > 0 && data[0]) {
2989          const domain = data[0]; // Direct string, not data[0].name
2990          return domain;
2991        }
2992      }
2993    } catch (e) {
2994      // Silent - domain lookup is optional
2995    }
2996    return null;
2997  }
2998  
2999  async function callTezosContract(contractAddress, method, args, amount = 0) {
3000    // Auto-restore wallet session if not connected (session may have been
3001    // lost between prepare and sign steps in the keep flow)
3002    if (!walletState.connected || !tezosWallet) {
3003      console.log("🔷 Wallet not connected, attempting auto-restore...");
3004      const restored = await restoreWalletSession();
3005      if (!restored || !walletState.connected || !tezosWallet) {
3006        throw new Error("Wallet not connected - use 'wallet' command first");
3007      }
3008      console.log("🔷 Wallet auto-restored:", walletState.address?.slice(0, 8) + "...");
3009    }
3010    
3011    console.log("🔷 Calling contract:", contractAddress, method, "amount:", amount);
3012    
3013    // Build the operation in Temple/Beacon format
3014    // Temple expects: to, amount (number), mutez: true, parameter (singular)
3015    const operation = {
3016      kind: "transaction",
3017      to: contractAddress,
3018      amount: Math.floor(amount * 1_000_000), // Convert XTZ to mutez (as number)
3019      mutez: true,
3020      parameter: {
3021        entrypoint: method,
3022        value: args
3023      }
3024    };
3025    
3026    console.log("🔷 Operation:", JSON.stringify(operation, null, 2).slice(0, 500));
3027    
3028    // Use wallet's sendOperations method (works for both Temple and Beacon)
3029    const opHash = await tezosWallet.sendOperations([operation]);
3030    
3031    console.log("🔷 Transaction submitted:", opHash);
3032    return opHash;
3033  }
3034
3035  // UDP / Geckos Networking
3036  let UDP;
3037  async function loadUDP() {
3038    // 🎒 PACK mode: Return no-op UDP to disable networking in offline bundles
3039    if (window.acPACK_MODE) {
3040      return {
3041        connect: () => {},
3042        send: () => {},
3043        disconnect: () => {},
3044      };
3045    }
3046    if (!UDP) {
3047      // if (debug) console.log("🌐 Loading UDP networking library...");
3048      const udpModule = await import('./lib/udp.mjs');
3049      UDP = udpModule.UDP;
3050      // if (debug) console.log("🌐 UDP Ready...");
3051    }
3052    return UDP;
3053  }
3054
3055  // 2. 🔈 Audio
3056  const sound = {
3057    bpm: new Float32Array(1),
3058  };
3059
3060  const sfx = {}; // Buffers of sound effects that have been loaded.
3061  const sfxPlaying = {}; // Sound sources that are currently playing.
3062  const sfxProgress = {}; // Latest AudioWorklet read-head report by sound id.
3063  const sfxLoaded = {}; // Sound sources that have been buffered and loaded.
3064  const sfxCompletionCallbacks = {}; // Completion callbacks for sound effects.
3065  // NOTE: streamAudio is now at module scope for master volume control
3066
3067  // const sfX // ...
3068
3069  const sfxCancel = [];
3070  speakAPI.sfx = sfx;
3071  // TODO: Some of these need to be kept (like system ones) and others need to
3072  // be destroyed after pieces change.
3073
3074  let updateMetronome,
3075    beatSkip,
3076    activateSound,
3077    activatedSoundCallback,
3078    triggerSound,
3079    updateBubble,
3080    updateFart,
3081    updateOrganic,
3082    updateSound,
3083    killSound,
3084    killAllSound,
3085    clearSoundSampleCache,
3086    requestSpeakerWaveforms,
3087    requestSpeakerAmplitudes,
3088    requestSpeakerFrequencies,
3089    sendRoomMessage,
3090    attachMicrophone,
3091    detachMicrophone,
3092    audioContext,
3093    audioStreamDest,
3094    sfxStreamGain,
3095    micStreamGain,
3096    micGainNode,
3097    speakerGain;
3098
3099  // NOTE: speakerProcessorNode, masterVolume, backgroundMusicBaseVolume, streamAudio,
3100  // clampVolume, and applyMasterVolume are now at module scope for window.AC.setMasterVolume
3101
3102  let requestMicrophoneAmplitude,
3103    requestMicrophoneWaveform,
3104    requestMicrophonePitch,
3105    requestMicrophoneRecordingStart,
3106    requestMicrophoneRecordingStop,
3107    requestMicrophoneRecordingBuffer;
3108
3109  // 🔬 Sound Telemetry for latency testing (exposed via window.__bios_sound_telemetry)
3110  const soundTelemetry = {
3111    triggerCount: 0,           // Total sounds triggered
3112    lastTriggerTime: 0,        // performance.now() of last trigger
3113    recentTriggers: [],        // Last 50 trigger timestamps with sound IDs
3114    maxRecentTriggers: 50,
3115  };
3116  if (typeof window !== 'undefined') {
3117    window.__bios_sound_telemetry = soundTelemetry;
3118    // console.log('🔬 BIOS: Exposed __bios_sound_telemetry on window');
3119  }
3120
3121  // 🎸 M4L Console Forwarding (for Ableton integration debugging)
3122  // When running in jweb~, forward console.log/error/warn to Max via max.outlet
3123  if (typeof window !== 'undefined' && window.max?.outlet) {
3124    const originalLog = console.log;
3125    const originalError = console.error;
3126    const originalWarn = console.warn;
3127    
3128    console.log = (...args) => {
3129      originalLog(...args);
3130      try { window.max.outlet("log", args.map(a => String(a)).join(" ")); } catch {}
3131    };
3132    
3133    console.error = (...args) => {
3134      originalError(...args);
3135      try { window.max.outlet("error", args.map(a => String(a)).join(" ")); } catch {}
3136    };
3137    
3138    console.warn = (...args) => {
3139      originalWarn(...args);
3140      try { window.max.outlet("warn", args.map(a => String(a)).join(" ")); } catch {}
3141    };
3142    
3143    // Catch uncaught errors - DISABLED for now due to noise from jweb~ CORS issues
3144    // window.addEventListener?.("error", (e) => {
3145    //   try { window.max.outlet("error", `Uncaught: ${e.message} at ${e.filename}:${e.lineno}`); } catch {}
3146    // });
3147    
3148    // 🎸 Stub for M4L sample receiver (pedal.mjs will override this)
3149    // This prevents "Script error" spam before pedal's setupMaxBridge runs
3150    window.acSample = window.acSample || function() {};
3151    window.acPedalPeak = window.acPedalPeak || function() {};
3152    
3153    console.log("🎸 BIOS: M4L console forwarding enabled");
3154  }
3155
3156  // TODO: Eventually this would be replaced with a more dynamic system.
3157
3158  const backgroundTrackURLs = [
3159    "0 - analog multiplication",
3160    "1 - castlecowards",
3161    "2 - epanodos clinamen",
3162    "3 - for not being able",
3163    "4 - pantoum chain rhyme",
3164    "5 - they sit so nicely",
3165    "6 - vociferatings witchbefooled",
3166    "7 - an accuracy which it seems as impossible to attain",
3167    "8 - bivariate beamforming",
3168    "9 - and the three of them began to make",
3169    "10 - or perhaps destroyed",
3170    "11 - sunsmidnought",
3171    "12 - improvements design",
3172    "13 - consideration",
3173    "14 - magellanic clouds",
3174    "15 - syncopation demotic",
3175    "16 - textual criticism ambiguity",
3176  ];
3177
3178  let backgroundMusicEl;
3179  backgroundMusicEl = document.createElement("audio");
3180  backgroundMusicEl.id = "background-music";
3181  backgroundMusicEl.crossOrigin = "anonymous";
3182  wrapper.appendChild(backgroundMusicEl);
3183
3184  let analyserCtx, analyserSrc, analyser, frequencyData;
3185  window.AC ||= {};
3186  window.AC.readOutputWaveform = createOutputWaveform(() => [
3187    speakerGain,
3188    ...(!backgroundMusicEl?.paused && !backgroundMusicEl?.muted ? [analyser] : []),
3189    ...Object.values(streamAudio).filter(entry =>
3189 !entry.audio.paused && !entry.audio.muted).map(entry => entry.analyser),
3190  ]);
3191  let currentBackgroundTrack;
3192
3193  function playBackgroundMusic(n, volume) {
3194    if (currentBackgroundTrack !== n && !isNaN(n)) {
3195      let origin;
3196
3197      if (window.production === true) {
3198        origin = "https://assets.aesthetic.computer/bgm/";
3199      } else {
3200        origin = "/assets/bgm/";
3201      }
3202
3203      const ext = Safari ? "m4a" : "ogg";
3204      backgroundMusicEl.src = origin + backgroundTrackURLs[n] + "." + ext;
3205      backgroundMusicBaseVolume = clampVolume(volume);
3206      backgroundMusicEl.volume = backgroundMusicBaseVolume * masterVolume;
3207      if (audioContext) {
3208        backgroundMusicEl.play();
3209      }
3210      currentBackgroundTrack = n;
3211    }
3212  }
3213
3214  function stopBackgroundMusic() {
3215    currentBackgroundTrack = null;
3216    backgroundMusicEl.src = "";
3217  }
3218
3219  function resumeAudioContext(reason = "unspecified", { force = false, onResumed } = {}) {
3220    if (!audioContext) return;
3221    if (audioContext.state === "running") {
3222      onResumed?.();
3223      return;
3224    }
3225
3226    const now = performance.now();
3227    if (!force && now - lastAudioResumeAttemptAt < AUDIO_RESUME_DEBOUNCE_MS) return;
3228    if (audioContextResuming) return;
3229
3230    audioContextResuming = true;
3231    lastAudioResumeAttemptAt = now;
3232    const resumeStartTime = now;
3233    let finished = false;
3234
3235    const release = () => {
3236      if (finished) return;
3237      finished = true;
3238      clearTimeout(stuckTimer);
3239      audioContextResuming = false;
3240    };
3241
3242    const stuckTimer = setTimeout(() => {
3243      if (finished) return;
3244      console.warn(
3245        `🎵 AudioContext resume timeout after ${AUDIO_RESUME_STUCK_MS}ms (${reason})`,
3246      );
3247      release();
3248    }, AUDIO_RESUME_STUCK_MS);
3249
3250    audioContext
3251      .resume()
3252      .then(() => {
3253        const resumeEndTime = performance.now();
3254        window.audioContextResumeTimestamps.requestedAt = resumeStartTime;
3255        window.audioContextResumeTimestamps.completedAt = resumeEndTime;
3256        onResumed?.();
3257      })
3258      .catch((error) => {
3259        console.warn(
3260          `🎵 AudioContext resume failed (${reason}):`,
3261          error?.message || error,
3262        );
3263      })
3264      .finally(() => {
3265        release();
3266      });
3267  }
3268
3269  function startSound() {
3270    if (audioStarting) return;
3271    if (audioContext && audioContext.state !== "closed" && !window.__acForceStartSound) {
3272      return;
3273    }
3274
3275    audioStarting = true;
3276
3277    if (navigator.audioSession) navigator.audioSession.type = "playback";
3278
3279    // Notify parent of boot progress
3280    if (window.acBOOT_LOG) {
3281      window.acBOOT_LOG("starting audio system");
3282    } else if (window.parent) {
3283      window.parent.postMessage({ 
3284        type: "boot-log", 
3285        message: "starting audio system" 
3286      }, "*");
3287    }
3288
3289    // 🎵 AUDIO INITIALIZATION LOGGING - Critical for tracking audio timing setup
3290    const audioStartTimestamp = performance.now();
3291    // console.log(`🎵 AUDIO_INIT_START: ${audioStartTimestamp.toFixed(3)}ms`);
3292
3293    // Main audio feed
3294    // 🎹 In DAW mode, use Ableton's sample rate; otherwise try high sample rate on Chrome desktop
3295    let targetSampleRate;
3296    const requestedSampleRate =
3297      typeof window !== "undefined" && Number.isFinite(window.__acSampleRate)
3298        ? window.__acSampleRate
3299        : null;
3300    if (_dawSampleRate) {
3301      // DAW mode: match Ableton's sample rate for proper sync
3302      targetSampleRate = _dawSampleRate;
3303      console.log("🎹 DAW mode: Using Ableton sample rate:", targetSampleRate);
3304    } else if (requestedSampleRate) {
3305      targetSampleRate = requestedSampleRate;
3306    } else {
3307      // Force 48kHz for consistent audio across all platforms
3308      // (Dynamic reinit to change sample rates was causing audio breakage)
3309      targetSampleRate = 48000;
3310    }
3311    
3312    let latencyHint =
3313      (typeof window !== "undefined" && window.__acLatencyHint) ||
3314      "interactive"; // "interactive" = lowest latency, "balanced" = default, "playback" = prioritize battery
3315
3316    if (latencyHint === "low" || latencyHint === "min") {
3317      latencyHint = 0.005;
3318    }
3319
3320    const requestedTargetSampleRate = targetSampleRate;
3321    let sampleRateFallback = false;
3322    try {
3323      audioContext = new AudioContext({
3324        latencyHint,
3325        sampleRate: targetSampleRate,
3326      });
3327    } catch (error) {
3328      if (targetSampleRate === 48000) throw error;
3329      sampleRateFallback = true;
3330      targetSampleRate = 48000;
3331      window.__acSampleRate = 48000;
3332      console.warn(
3333        `🎧 AudioContext rejected ${requestedTargetSampleRate}Hz; falling back to 48000Hz`,
3334        error,
3335      );
3336      audioContext = new AudioContext({ latencyHint, sampleRate: 48000 });
3337    }
3338
3339    const baseLatency = Number(audioContext.baseLatency) || 0;
3340    const outputLatency = Number(audioContext.outputLatency) || 0;
3341    acDISK_SEND({
3342      type: "audio:latency-info",
3343      content: {
3344        base: baseLatency * 1000,
3345        output: outputLatency * 1000,
3346        quantum: (128 / audioContext.sampleRate) * 1000,
3347        total: (baseLatency + outputLatency) * 1000,
3348        sampleRate: audioContext.sampleRate,
3349        requestedSampleRate: requestedTargetSampleRate,
3350        sampleRateFallback,
3351        latencyHint,
3352      },
3353    });
3354
3355    // Resume immediately to capture the user gesture window.
3356    // On iOS Safari, AudioContext may start "suspended" even during a gesture —
3357    // calling resume() synchronously here ensures we don't lose the gesture.
3358    resumeAudioContext("startSound-initial", { force: true });
3359
3360    // 🎵 AUDIO CONTEXT LOGGING - Track timing characteristics
3361    // console.log(`🎵 AUDIO_CONTEXT_CREATED: sampleRate=${audioContext.sampleRate}, state=${audioContext.state}, baseLatency=${audioContext.baseLatency?.toFixed(6) || 'N/A'}s, outputLatency=${audioContext.outputLatency?.toFixed(6) || 'N/A'}s`);
3362    // console.log(`🎵 AUDIO_TIMING_INIT: currentTime=${audioContext.currentTime.toFixed(6)}s, creation_delay=${(performance.now() - audioStartTimestamp).toFixed(3)}ms`);
3363
3364    acDISK_SEND({
3365      type: "audio:sample-rate",
3366      content: audioContext.sampleRate,
3367    });
3368
3369    // Notify pieces of initial AudioContext state
3370    if (!window.acPACK_MODE) {
3371      // console.log("🎵 BIOS sending initial AudioContext state:", { state: audioContext.state, hasAudio: true });
3372    }
3373    acDISK_SEND({ 
3374      type: "tape:audio-context-state", 
3375      content: { 
3376        state: audioContext.state,
3377        hasAudio: true
3378      } 
3379    });
3380
3381
3382
3383    // Process any queued sounds now that audioContext is available
3384    processPendingSfx();
3385
3386    // Main audio feed
3387    // audioContext = new AudioContext({
3388    //   latencyHint: "interactive",
3389    //   sampleRate: Math.min(
3390    //     192000,
3391    //     new (window.AudioContext || window.webkitAudioContext)().sampleRate,
3392    //   ),
3393    // });
3394
3395    // BGM Analyser
3396    if (analyserSrc) {
3397      try {
3398        analyserSrc.disconnect();
3399      } catch (e) {
3400        console.warn("🎵 Audio init: failed to disconnect analyser source", e);
3401      }
3402    }
3403
3404    analyserCtx = new AudioContext();
3405    analyserSrc = analyserCtx.createMediaElementSource(backgroundMusicEl);
3406    analyser = analyserCtx.createAnalyser();
3407    analyser.fftSize = 256; // See also: https://developer.mozilla.org/en-US/docs/Web/API/AnalyserNode/frequencyBinCount
3408
3409    analyserSrc.connect(analyser);
3410    analyser.connect(analyserCtx.destination);
3411    frequencyData = new Uint8Array(analyser.frequencyBinCount);
3412
3413    // console.log("Sound started.");
3414
3415    speakAPI.audioContext = audioContext; // Make audioContext global, for
3416    //                                       `speech` and perhaps other
3417    //                                       modules. 23.08.17.12.31
3418
3419    audioStreamDest = audioContext.createMediaStreamDestination();
3420    sfxStreamGain = audioContext.createGain();
3421    sfxStreamGain.gain.value = 1;
3422    sfxStreamGain.connect(audioStreamDest);
3423
3424    speakerGain = audioContext.createGain();
3425    speakerGain.gain.value = 1;
3426    speakerGain.connect(audioContext.destination);
3427
3428    if (audioContext.state === "running") {
3429      // audioContext.suspend();
3430    }
3431
3432    // TODO: Check to see if there is support for AudioWorklet or not...
3433    //       and and use ScriptProcessorNode as a fallback. 2022.01.13.21.00
3434
3435    // Microphone Input Processor
3436    // (Gets attached via a message from the running disk.)
3437    attachMicrophone = async (data) => {
3438      if (navigator.audioSession)
3439        navigator.audioSession.type = "play-and-record"; // play-and-record";
3440
3441      let micStream;
3442      let reason;
3443      try {
3444        // Allow pieces to configure audio constraints via data parameter
3445        const audioConstraints = {
3446          echoCancellation: data?.echoCancellation ?? false,
3447          noiseSuppression: data?.noiseSuppression ?? false,
3448          autoGainControl: data?.autoGainControl ?? false,
3449          volume: data?.volume ?? 1,
3450          latency: data?.latency ?? 0,
3451        };
3452        
3453        // Request higher sample rate if on Chrome desktop for lower latency
3454        const isChrome = /Chrome/.test(navigator.userAgent) && !/Edge/.test(navigator.userAgent);
3455        const isDesktop = !/Mobile|Android|iPhone|iPad/.test(navigator.userAgent);
3456        if (isChrome && isDesktop) {
3457          // Try multiple sample rates in order of preference
3458          audioConstraints.sampleRate = { 
3459            ideal: 192000,
3460            min: 48000 // Fall back to 48kHz if higher not supported
3461          };
3462          audioConstraints.sampleSize = { ideal: 24 }; // Request 24-bit if available
3463        }
3464        
3465        micStream = await navigator.mediaDevices.getUserMedia({
3466          audio: audioConstraints,
3467        });
3468      } catch (err) {
3469        if (debug) console.warn("🎙 Microphone disabled:", err);
3470        reason = err.message;
3471      }
3472
3473      // return;
3474
3475      if (!micStream) {
3476        send({ type: "microphone-connect:failure", content: { reason } });
3477        return;
3478      }
3479
3480      const micNode = new MediaStreamAudioSourceNode(audioContext, {
3481        mediaStream: micStream,
3482      });
3483      
3484      // Send latency info to disk for display (when monitoring)
3485      if (data?.monitor) {
3486        const tracks = micStream.getAudioTracks();
3487        const micLatency = tracks[0]?.getSettings()?.latency || 0;
3488        const baseLatency = audioContext.baseLatency || 0;
3489        const outputLatency = audioContext.outputLatency || 0;
3490        const processingLatency = 128 / audioContext.sampleRate; // One quantum
3491        
3492        const inputMs = Math.round(micLatency * 1000);
3493        const processingMs = Math.round(processingLatency * 1000 * 10) / 10; // One decimal
3494        const outputMs = Math.round(baseLatency * 1000);
3495        const totalMs = inputMs + processingMs + outputMs;
3496        
3497        send({ 
3498          type: "audio:latency-info", 
3499          content: {
3500            input: inputMs,
3501            processing: processingMs,
3502            output: outputMs,
3503            total: Math.round(totalMs)
3504          }
3505        });
3506      }
3507
3508      // TODO: Why can't there be separate audioWorklet modules?
3509      await audioContext.audioWorklet.addModule(
3510        "/aesthetic.computer/lib/microphone.mjs",
3511      );
3512
3513      const micProcessorNode = new AudioWorkletNode(
3514        audioContext,
3515        "microphone-processor",
3516        {
3517          outputChannelCount: [2],
3518          processorOptions: { 
3519            debug,
3520            enablePitch: data?.enablePitch ?? true // Allow disabling pitch detection for lower CPU usage
3521          },
3522        },
3523      );
3524
3525      // Receive messages from the microphone processor thread.
3526      micProcessorNode.port.onmessage = (e) => {
3527        const msg = e.data;
3528
3529        if (msg.type === "amplitude") {
3530          send({ type: "microphone-amplitude", content: msg.content });
3531        }
3532
3533        if (msg.type === "waveform") {
3534          // console.log("Mic waveform:", msg.content);
3535          send({ type: "microphone-waveform", content: msg.content });
3536        }
3537
3538        if (msg.type === "pitch") {
3539          send({ type: "microphone-pitch", content: msg.content });
3540        }
3541
3542        if (msg.type === "recording-buffer") {
3543          send({ type: "microphone-recording-buffer", content: msg.content });
3544        }
3545
3546        if (msg.type === "recording:complete") {
3547          // console.log("Recording complete:", msg.content);
3548          // Turn this into a sample with a playback ID here and send
3549          // the sample ID back.
3550          const id = "microphone-recording" + "_" + performance.now();
3551
3552          //if (debug)
3553          //  console.log("🔈 Buffer length:", msg.content.recording?.length);
3554
3555          // Create an empty mono AudioBuffer (1 channel)
3556          const buffer = audioContext.createBuffer(
3557            1,
3558            msg.content.recording.length,
3559            audioContext.sampleRate,
3560          );
3561          const channel = buffer.getChannelData(0); // Ref to the first channel.
3562          channel.set(msg.content.recording);
3563          // Copy your Float32Array data into the buffer's channel
3564
3565          // console.log("Recording:", msg.content.recording);
3566
3567          sfx[id] = buffer; // Set the sfx id so the sfx system
3568          //                   can play back the sample.
3569
3570          send({
3571            type: "microphone-recording:complete",
3572            content: { id, data: msg.content.recording },
3573          });
3574        }
3575      };
3576
3577      requestMicrophoneRecordingStart = () => {
3578        micProcessorNode.port.postMessage({ type: "record:start" });
3579      };
3580
3581      requestMicrophoneRecordingStop = () => {
3582        micProcessorNode.port.postMessage({ type: "record:stop" });
3583      };
3584
3585      // Request data / send message to the mic processor thread.
3586      requestMicrophoneAmplitude = () => {
3587        micProcessorNode.port.postMessage({ type: "get-amplitude" });
3588      };
3589
3590      requestMicrophoneWaveform = () => {
3591        micProcessorNode.port.postMessage({ type: "get-waveform" });
3592      };
3593
3594      requestMicrophonePitch = () => {
3595        micProcessorNode.port.postMessage({ type: "get-pitch" });
3596      };
3597
3598      requestMicrophoneRecordingBuffer = () => {
3599        micProcessorNode.port.postMessage({ type: "get-recording-buffer" });
3600      };
3601
3602      micStreamGain = audioContext.createGain();
3603      micGainNode = audioContext.createGain();
3604
3605      // Connect mic to the mediaStream.
3606
3607      // TODO: How to automatically dip the mic gain node?
3608
3609      // micNode.connect(micProcessorNode);
3610      micNode.connect(micGainNode);
3611      micGainNode.connect(micProcessorNode);
3612
3613      micProcessorNode.connect(micStreamGain);
3614      micStreamGain.connect(audioStreamDest);
3615      // speakerGain.gain.value = 0.35;
3616      // micStreamGain.gain.value = 1.5;
3617
3618      micGainNode.gain.value = 1;
3619      micStreamGain.gain.value = 1;
3620      sfxStreamGain.gain.value = 1;
3621
3622      // Connect to the speaker if we are monitoring audio.
3623      if (data?.monitor === true)
3624        micProcessorNode.connect(audioContext.destination);
3625
3626      // Setup microphone detachment function.
3627      detachMicrophone = () => {
3628        if (navigator.audioSession) navigator.audioSession.type = "playback";
3629        micProcessorNode.disconnect();
3630        micNode.disconnect();
3631        micStream.getTracks().forEach((t) => t.stop());
3632        // speakerGain.gain.value = 1;
3633        sfxStreamGain.gain.value = 1;
3634        if (debug) console.log("🎙💀 Microphone:", "Detached");
3635        send({ type: "microphone-disconnect" });
3636      };
3637
3638      // Send a message back to `disk` saying the microphone is connected.
3639      send({ type: "microphone-connect:success" });
3640    };
3641
3642    // Sound Synthesis Processor
3643    try {
3644      // Check if we're in PACK mode
3645      const isPackMode = (typeof window !== 'undefined' && window.acPACK_MODE) ||
3646                        (typeof globalThis !== 'undefined' && globalThis.acPACK_MODE);
3647      
3648      (async () => {
3649        try {
3650          let workletUrl;
3651          let workletSource = null;
3652          let workletObjectUrl = null;
3653          
3654          if (isPackMode) {
3655            // In PACK mode, load the bundled worklet from VFS.
3656            // Prefer blob: URLs for CSP compatibility in sandboxed NFT iframes.
3657            if (window.VFS && window.VFS['lib/speaker-bundled.mjs']) {
3658              workletSource = window.VFS['lib/speaker-bundled.mjs'].content;
3659              const workletBlob = new Blob([workletSource], {
3660                type: "application/javascript",
3661              });
3662              workletObjectUrl = URL.createObjectURL(workletBlob);
3663              workletUrl = workletObjectUrl;
3664              if (debug) console.log("🎭 Using bundled speaker worklet from VFS");
3665            } else {
3666              console.warn("🎭 PACK mode: speaker-bundled.mjs not found in VFS, audio synth will not work");
3667              return;
3668            }
3669          } else {
3670            // Normal mode - load from server
3671            const baseUrl = "/aesthetic.computer/lib/speaker.mjs";
3672            const cacheBuster = /*debug ?*/ `?time=${new Date().getTime()}`; // : "";
3673            workletUrl = baseUrl + cacheBuster;
3674          }
3675          
3676          // 🎵 WORKLET LOADING LOGGING
3677          const workletLoadStart = performance.now();
3678          // console.log(`🎵 WORKLET_LOAD_START: Loading ${workletUrl}`);
3679          
3680          try {
3681            await audioContext.audioWorklet.addModule(workletUrl);
3682          } catch (addModuleError) {
3683            const canFallbackToData =
3684              isPackMode &&
3685              typeof window !== "undefined" &&
3686              typeof workletSource === "string";
3687
3688            if (!canFallbackToData) {
3689              throw addModuleError;
3690            }
3691
3692            if (workletObjectUrl) {
3693              URL.revokeObjectURL(workletObjectUrl);
3694              workletObjectUrl = null;
3695            }
3696
3697            // file:// origins can reject blob: worklet URLs; only fallback there.
3698            workletUrl =
3699              "data:application/javascript;charset=utf-8," +
3700              encodeURIComponent(workletSource);
3701            await audioContext.audioWorklet.addModule(workletUrl);
3702          } finally {
3703            if (workletObjectUrl) {
3704              URL.revokeObjectURL(workletObjectUrl);
3705              workletObjectUrl = null;
3706            }
3707          }
3708        
3709        const workletLoadTime = performance.now() - workletLoadStart;
3710        // console.log(`🎵 WORKLET_LOADED: Took ${workletLoadTime.toFixed(3)}ms`);
3711
3712        const speakerProcessor = new AudioWorkletNode(
3713          audioContext,
3714          "speaker-processor",
3715          {
3716            outputChannelCount: [2],
3717            processorOptions: { bpm: sound.bpm, debug: true },
3718          },
3719        );
3720
3721        speakerProcessorNode = speakerProcessor;
3722
3723        // 🎵 WORKLET INITIALIZATION LOGGING
3724        // console.log(`🎵 WORKLET_CREATED: bpm=${sound.bpm}, audioTime=${audioContext.currentTime.toFixed(6)}s, totalSetupTime=${(performance.now() - audioStartTimestamp).toFixed(3)}ms`);
3725
3726        beatSkip = function () {
3727          speakerProcessor.port.postMessage({ type: "beat:skip" });
3728        };
3729
3730        updateMetronome = function (newBPM) {
3731          speakerProcessor.port.postMessage({ type: "new-bpm", data: newBPM });
3732        };
3733
3734        triggerSound = function (sound) {
3735          // 🔬 Telemetry: Record this sound trigger for latency testing
3736          const triggerTime = performance.now();
3737          soundTelemetry.triggerCount++;
3738          soundTelemetry.lastTriggerTime = triggerTime;
3739          soundTelemetry.recentTriggers.push({
3740            id: sound.id,
3741            type: sound.type,
3742            timestamp: triggerTime,
3743          });
3744          if (soundTelemetry.recentTriggers.length > soundTelemetry.maxRecentTriggers) {
3745            soundTelemetry.recentTriggers.shift();
3746          }
3747
3748          if (sound.probe) {
3749            sound.probe = {
3750              ...sound.probe,
3751              biosAt: triggerTime,
3752              biosEpoch: Date.now(),
3753              sentAt: performance.now(),
3754              payloadBytes: JSON.stringify({
3755                type: sound.type, tone: sound.tone, beats: sound.beats,
3756                attack: sound.attack, decay: sound.decay, volume: sound.volume,
3757                pan: sound.pan, probe: sound.probe,
3758              }).length,
3759            };
3760          }
3761          speakerProcessor.port.postMessage({ type: "sound", data: sound });
3762
3763          return {
3764            progress: () => {
3765              speakerProcessor.port.postMessage({
3766                type: "get-progress",
3767                content: sound.id,
3768              });
3769              return sfxProgress[sound.id]?.progress;
3770            },
3771            kill: (fade) => {
3772              killSound(sound.id, fade);
3773            },
3774            update: (properties) => {
3775              // console.log(
3776              //   "Update needed for sound:",
3777              //   sound,
3778              //   "With:",
3779              //   properties,
3780              // );
3781              updateSound?.({ id: sound.id, properties });
3782            },
3783          };
3784        };
3785
3786        // Set flag to indicate worklet is ready
3787        window.audioWorkletReady = true;
3788
3789        // 📼 If a tape is mid-presentation its soundtrack has been silent —
3790        // audio decode happens before the worklet exists (cold tape loads).
3791        // Start it now from the current playhead.
3792        if (sfx["tape:audio"] && typeof seekTapePlayback === "function") {
3793          Object.keys(sfxPlaying).forEach((id) => {
3794            if (id.startsWith("tape:audio_")) {
3795              sfxPlaying[id]?.kill();
3796              delete sfxPlaying[id];
3797            }
3798          });
3799          startTapeAudioLoop(currentTapePosition || 0);
3800          console.log(
3801            "🎵 📼 Tape audio started on worklet-ready at",
3802            (currentTapePosition || 0).toFixed(3),
3803          );
3804          acDISK_SEND({
3805            type: "tape:audio-context-state",
3806            content: {
3807              state: audioContext.state,
3808        hasAudio: true,
3809        audioRate: sfx["tape:audio"]?.sampleRate || 0,
3810        ctxRate: audioContext?.sampleRate || 0,
3811        latencyMs: Math.round(((audioContext?.baseLatency || 0) + (audioContext?.outputLatency || 0)) * 1000),
3812            },
3813          });
3814        }
3815
3816        // Apply speaker performance mode hint if provided
3817        const speakerPerfMode =
3818          typeof window !== "undefined" ? window.__acSpeakerPerformanceMode : null;
3819        if (speakerPerfMode) {
3820          speakerProcessor.port.postMessage({
3821            type: "performance:mode",
3822            content: { mode: speakerPerfMode },
3823          });
3824        }
3825
3826        // 🔬 Speaker telemetry (queue/running sizes) for stability tests
3827        if (typeof window !== 'undefined') {
3828          window.__speaker_telemetry = window.__speaker_telemetry || {
3829            queueLength: 0,
3830            runningCount: 0,
3831            performanceMode: 'auto',
3832            lastUpdate: 0,
3833          };
3834        }
3835
3836        updateBubble = function (bubble) {
3837          speakerProcessor.port.postMessage({ type: "bubble", data: bubble });
3838        };
3839
3840        updateFart = function (fart) {
3841          speakerProcessor.port.postMessage({ type: "fart", data: fart });
3842        };
3843
3844        updateOrganic = function (o) {
3845          speakerProcessor.port.postMessage({ type: "organic", data: o });
3846        };
3847
3848        killSound = function (id, fade) {
3849          // console.log("📢 Kill:", id, "Fade:", fade);
3850          delete sfxPlaying[id];
3851          delete sfxCompletionCallbacks[id]; // Clean up completion callback
3852          speakerProcessor.port.postMessage({
3853            type: "kill",
3854            data: { id, fade },
3855          });
3856        };
3857
3858        updateSound = function (data) {
3859          speakerProcessor.port.postMessage({ type: "update", data });
3860        };
3861
3862        killAllSound = function () {
3863          speakerProcessor.port.postMessage({ type: "kill:all" });
3864        };
3865
3866        clearSoundSampleCache = function () {
3867          speakerProcessor.port.postMessage({ type: "cache:clear" });
3868          for (const k in sfxLoaded) delete sfxLoaded[k];
3869        };
3870
3871        // 🏠 Room/Reverb control
3872        sendRoomMessage = function (type, data) {
3873          speakerProcessor.port.postMessage({ type, data });
3874        };
3875
3876        // Request data / send message to the mic processor thread.
3877        requestSpeakerWaveforms = function () {
3878          speakerProcessor.port.postMessage({ type: "get-waveforms" });
3879        };
3880
3881        requestSpeakerAmplitudes = function () {
3882          speakerProcessor.port.postMessage({ type: "get-amplitudes" });
3883        };
3884
3885        requestSpeakerFrequencies = function () {
3886          speakerProcessor.port.postMessage({ type: "get-frequencies" });
3887        };
3888
3889        speakerProcessor.port.onmessage = ({ data: msg }) => {
3890          if (msg.type === "waveforms") {
3891            send({ type: "waveforms", content: msg.content });
3892            return;
3893          }
3894
3895          if (msg.type === "amplitudes") {
3896            send({ type: "amplitudes", content: msg.content });
3897            return;
3898          }
3899
3900          if (msg.type === "frequencies") {
3901            send({ type: "frequencies", content: msg.content });
3902            return;
3903          }
3904
3905          if (msg.type === "metronome") {
3906            diskSupervisor?.requestBeat?.(msg.content); // Update metronome.
3907            return;
3908          }
3909
3910          if (msg.type === "progress") {
3911            sfxProgress[msg.content.id] = msg.content;
3912            // Send sound progress to the disk.
3913            // console.log("Received progress for:", msg);
3914            send({
3915              type: "sfx:progress:report",
3916              content: msg.content,
3917            });
3918            return;
3919          }
3920
3921          if (msg.type === "killed") {
3922            delete sfxProgress[msg.content.id];
3923            // Call the completion callback if it exists
3924            const completionCallback = sfxCompletionCallbacks[msg.content.id];
3925            if (completionCallback) {
3926              completionCallback();
3927              delete sfxCompletionCallbacks[msg.content.id];
3928            }
3929
3930            send({ type: "sfx:killed", content: msg.content });
3931            return;
3932          }
3933
3934          if (msg.type === "pops") {
3935            // 🍿 Discontinuity (pop/click) reports from the worklet —
3936            // accumulated on window so harnesses can correlate pops with
3937            // gestures per pane and prove anti-pop fixes numerically.
3938            if (typeof window !== "undefined") {
3939              const t = (window.__popTelemetry = window.__popTelemetry || {
3940                total: 0,
3941                max: 0,
3942                lastAt: 0,
3943                events: [],
3944              });
3945              t.total += msg.content.count || 0;
3946              if ((msg.content.max || 0) > t.max) t.max = msg.content.max;
3947              t.events = t.events || [];
3948              // Keep the tail bounded — this is a debugging window, not a log.
3949              for (const ev of msg.content.events || []) t.events.push(ev);
3950              if (t.events.length > 64) t.events.splice(0, t.events.length - 64);
3951              t.lastAt = Date.now();
3952            }
3953            return;
3954          }
3955
3956          if (msg.type === "telemetry") {
3957            if (typeof window !== 'undefined') {
3958              window.__speaker_telemetry = window.__speaker_telemetry || {};
3959              window.__speaker_telemetry.queueLength = msg.content.queueLength || 0;
3960              window.__speaker_telemetry.runningCount = msg.content.runningCount || 0;
3961              window.__speaker_telemetry.performanceMode = msg.content.performanceMode || 'auto';
3962              window.__speaker_telemetry.lastUpdate = Date.now();
3963            }
3964            return;
3965          }
3966
3967          if (msg.type === "audio:probe") {
3968            const receivedAt = performance.now();
3969            const probe = msg.content || {};
3970            send({
3971              type: "audio:probe",
3972              content: {
3973                ...probe,
3974                workletRoundTrip: receivedAt - (probe.sentAt || receivedAt),
3975                pieceToBios: probe.pieceEpoch
3976                  ? (probe.biosEpoch || Date.now()) - probe.pieceEpoch
3977                  : (probe.biosAt || receivedAt) - (probe.pieceAt || probe.biosAt || receivedAt),
3978                context: {
3979                  sampleRate: audioContext?.sampleRate || probe.sampleRate,
3980                  base: (Number(audioContext?.baseLatency) || 0) * 1000,
3981                  output: (Number(audioContext?.outputLatency) || 0) * 1000,
3982                  quantum: audioContext?.sampleRate ? 128 / audioContext.sampleRate * 1000 : 0,
3983                },
3984              },
3985            });
3986            return;
3987          }
3988          
3989          // 🎛️ VST Bridge Mode - forward audio samples to native plugin
3990          if (msg.type === "vst:samples") {
3991            // Call the VST sample callback if registered
3992            if (window.__vstSampleCallback) {
3993              window.__vstSampleCallback(msg.content.left, msg.content.right);
3994            }
3995            return;
3996          }
3997          
3998          if (msg.type === "vst:enabled") {
3999            console.log("🎛️ VST Bridge Mode enabled in speaker worklet");
4000            return;
4001          }
4002          
4003          if (msg.type === "vst:disabled") {
4004            console.log("🎛️ VST Bridge Mode disabled in speaker worklet");
4005            return;
4006          }
4007        };
4008        
4009        // 🎛️ Expose VST Bridge API on window.AC for native plugin integration
4010        window.AC = window.AC || {};
4011        window.AC.enableVSTBridge = function(sampleCallback) {
4012          console.log("🎛️ Enabling VST Audio Bridge...");
4013          window.__vstSampleCallback = sampleCallback;
4014          speakerProcessor.port.postMessage({ type: "vst:enable" });
4015        };
4016        window.AC.disableVSTBridge = function() {
4017          console.log("🎛️ Disabling VST Audio Bridge...");
4018          window.__vstSampleCallback = null;
4019          speakerProcessor.port.postMessage({ type: "vst:disable" });
4020        };
4021        window.AC.isVSTMode = function() {
4022          // Check if we're running inside a VST plugin
4023          return new URLSearchParams(window.location.search).get('vst') === 'true';
4024        };
4025
4026        // 🎸 Pedal Effect Chain (for DAW mode audio processing)
4027        // Create effect nodes that can be dynamically enabled/controlled
4028        let pedalFilter = null;
4029        let pedalDelay = null;
4030        let pedalDelayGain = null;
4031        let pedalDryGain = null;
4032        let pedalWetGain = null;
4033        let pedalEffectsEnabled = false;
4034        
4035        // Initialize pedal effects (but don't connect yet)
4036        function initPedalEffects() {
4037          const forceStereo = (node) => {
4038            node.channelCount = 2;
4039            node.channelCountMode = "explicit";
4040            node.channelInterpretation = "speakers";
4041          };
4042
4043          // Biquad filter (lowpass/highpass)
4044          pedalFilter = audioContext.createBiquadFilter();
4045          forceStereo(pedalFilter);
4046          pedalFilter.type = "lowpass";
4047          pedalFilter.frequency.value = 20000; // Start with no filtering
4048          pedalFilter.Q.value = 1.0;
4049          
4050          // Delay effect
4051          pedalDelay = audioContext.createDelay(2.0); // Max 2 second delay
4052          forceStereo(pedalDelay);
4053          pedalDelay.delayTime.value = 0.25;
4054          
4055          pedalDelayGain = audioContext.createGain();
4056          forceStereo(pedalDelayGain);
4057          pedalDelayGain.gain.value = 0.4; // Feedback amount
4058          
4059          // Dry/Wet mix
4060          pedalDryGain = audioContext.createGain();
4061          forceStereo(pedalDryGain);
4062          pedalDryGain.gain.value = 1.0;
4063          
4064          pedalWetGain = audioContext.createGain();
4065          forceStereo(pedalWetGain);
4066          pedalWetGain.gain.value = 0.0; // Start dry
4067          
4068          console.log("🎸 Pedal effects initialized");
4069        }
4070        
4071        // Enable effects and insert into signal chain
4072        function enablePedalEffects() {
4073          if (pedalEffectsEnabled) return;
4074          if (!pedalFilter) initPedalEffects();
4075          
4076          // Disconnect speaker from destinations
4077          speakerProcessor.disconnect();
4078          
4079          // New chain: speakerProcessor -> filter -> dry/wet split -> destinations
4080          speakerProcessor.connect(pedalFilter);
4081          
4082          // Dry path
4083          pedalFilter.connect(pedalDryGain);
4084          pedalDryGain.connect(sfxStreamGain);
4085          pedalDryGain.connect(speakerGain);
4086          
4087          // Wet path (delay)
4088          pedalFilter.connect(pedalDelay);
4089          pedalDelay.connect(pedalDelayGain);
4090          pedalDelayGain.connect(pedalDelay); // Feedback loop
4091          pedalDelay.connect(pedalWetGain);
4092          pedalWetGain.connect(sfxStreamGain);
4093          pedalWetGain.connect(speakerGain);
4094          
4095          pedalEffectsEnabled = true;
4096          console.log("🎸 Pedal effects enabled in audio chain");
4097        }
4098        
4099        // Set pedal effect parameters
4100        window.acPedalSetEffect = function(params) {
4101          if (!pedalEffectsEnabled) enablePedalEffects();
4102          
4103          if (params.filterType) {
4104            pedalFilter.type = params.filterType; // "lowpass", "highpass", "bandpass"
4105          }
4106          if (params.filterFreq !== undefined) {
4107            pedalFilter.frequency.setValueAtTime(params.filterFreq, audioContext.currentTime);
4108          }
4109          if (params.filterQ !== undefined) {
4110            pedalFilter.Q.setValueAtTime(params.filterQ, audioContext.currentTime);
4111          }
4112          if (params.delayTime !== undefined) {
4113            pedalDelay.delayTime.setValueAtTime(params.delayTime, audioContext.currentTime);
4114          }
4115          if (params.delayFeedback !== undefined) {
4116            pedalDelayGain.gain.setValueAtTime(params.delayFeedback, audioContext.currentTime);
4117          }
4118          if (params.wetMix !== undefined) {
4119            pedalWetGain.gain.setValueAtTime(params.wetMix, audioContext.currentTime);
4120            pedalDryGain.gain.setValueAtTime(1.0 - params.wetMix, audioContext.currentTime);
4121          }
4122        };
4123        
4124        // Connect a custom audio source to the pedal effects chain
4125        // Used by M4L effect mode to route incoming samples through effects
4126        window.acPedalConnectSource = function(sourceNode) {
4127          if (!pedalEffectsEnabled) {
4128            initPedalEffects();
4129            enablePedalEffects();
4130          }
4131          
4132          // In effect mode, disconnect speakerProcessor since we only want external audio
4133          // (the speakerProcessor is AC's internal audio which we don't want to mix in)
4134          try {
4135            speakerProcessor.disconnect();
4136          } catch (e) {
4137            // Already disconnected
4138          }
4139          
4140          // Connect the source to the pedal filter (start of effects chain)
4141          sourceNode.connect(pedalFilter);
4142          console.log("🎸 External source connected to pedal effects chain (internal audio muted)");
4143        };
4144        
4145        // Get the output destinations for M4L sample source
4146        // (bypasses speakerProcessor entirely)
4147        window.acPedalGetOutputNodes = function() {
4148          return {
4149            sfxStreamGain: sfxStreamGain,
4150            speakerGain: speakerGain
4151          };
4152        };
4153        
4154        // Auto-enable pedal effects if ?daw parameter is present
4155        const hasDawParam = new URLSearchParams(window.location.search).has("daw");
4156        if (hasDawParam) {
4157          initPedalEffects();
4158          enablePedalEffects();
4159        } else {
4160          // Normal mode - direct connection
4161          speakerProcessor.connect(sfxStreamGain); // Connect to the mediaStream.
4162          speakerProcessor.connect(speakerGain);
4163        }
4164        if (!(typeof window !== "undefined" && window.acPACK_MODE)) {
4165          console.log("🔊 Speaker processor connected to audio graph!");
4166        }
4167
4168        applyMasterVolume(masterVolume);
4169
4170        activatedSoundCallback?.();
4171
4172        // Process any queued sound effects now that audioContext is available
4173        processPendingSfx();
4174
4175        modal.classList.remove("on");
4176        audioStarting = false;
4177        } catch (workletError) {
4178          if (!checkPackMode()) {
4179            console.error("🎵 BIOS: Audio worklet setup failed:", workletError);
4180          } else if (debug) {
4181            console.warn(
4182              "🎵 PACK: Audio worklet unavailable:",
4183              workletError?.message || workletError,
4184            );
4185          }
4186        audioStarting = false;
4187        }
4188      })();
4189    } catch (e) {
4190      console.error("🎵 BIOS: Sound failed to initialize:", e);
4191    audioStarting = false;
4192    }
4193
4194    function enableAudioPlayback(skip = false) {
4195      try {
4196        if (backgroundMusicEl.paused && currentBackgroundTrack !== null) {
4197          backgroundMusicEl.play().catch(error => {
4198            // Audio playback failed, probably due to browser policy
4199            console.log("Background music play failed (user gesture required):", error.message);
4200          });
4201        }
4202        if (
4203          !skip &&
4204          audioContext &&
4205          ["suspended", "interrupted"].includes(audioContext.state)
4206        ) {
4207          resumeAudioContext("gesture-enableAudioPlayback");
4208        }
4209      } catch (error) {
4210        console.log("Audio playback initialization failed:", error.message);
4211      }
4212    }
4213
4214    //enableAudioPlayback(true);
4215    window.addEventListener("pointerdown", () => enableAudioPlayback());
4216    window.addEventListener("keydown", () => enableAudioPlayback());
4217    window.addEventListener("touchstart", () => enableAudioPlayback());
4218  }
4219
4220  // 🔊 Eagerly register audio activation on first user interaction.
4221  // This ensures the first tap/key is never missed, even if the piece
4222  // hasn't finished loading yet (e.g., on notepat.com where the user may
4223  // tap before the piece's disk-loaded event fires).
4224  function eagerAudioActivation() {
4225    startSound();
4226    window.removeEventListener("pointerdown", eagerAudioActivation);
4227    window.removeEventListener("keydown", eagerAudioActivation);
4228    window.removeEventListener("touchstart", eagerAudioActivation);
4229  }
4230  window.addEventListener("pointerdown", eagerAudioActivation);
4231  window.addEventListener("keydown", eagerAudioActivation);
4232  window.addEventListener("touchstart", eagerAudioActivation);
4233
4234  // 🔍 Density Keyboard Controls (Cmd/Ctrl + / - / 0)
4235  // Uses standard zoom keys - browser may also zoom, but density will change too
4236  const DENSITY_MIN = 0.5;
4237  const DENSITY_MAX = 32;
4238  const DENSITY_STEP = 0.5;
4239  const DENSITY_DEFAULT = 2;
4240
4241  function changeDensity(direction) {
4242    const current = density;
4243    let newDensity;
4244    
4245    if (direction === 0) {
4246      // Reset to default
4247      newDensity = DENSITY_DEFAULT;
4248    } else {
4249      newDensity = current + (direction * DENSITY_STEP);
4250      newDensity = Math.max(DENSITY_MIN, Math.min(DENSITY_MAX, newDensity));
4251      newDensity = Math.round(newDensity * 2) / 2; // Round to nearest 0.5
4252    }
4253    
4254    if (newDensity === current) return;
4255
4256    // Update module-level density variable
4257    density = newDensity;
4258    window.acPACK_DENSITY = newDensity;
4259
4260    // Manual density change overrides auto-density scaling
4261    window.acAutoDensityOverride = true;
4262
4263    // Persist to localStorage
4264    try {
4265      localStorage.setItem("ac-density", newDensity.toString());
4266    } catch {}
4267
4268    // Trigger reframe to apply new density
4269    frame();
4270
4271    console.log(`🔍 Density: ${newDensity}`);
4272  }
4273
4274  // Keyboard listener for density controls (capture phase to intercept early)
4275  window.addEventListener("keydown", (e) => {
4276    const isMeta = e.metaKey || e.ctrlKey;
4277    if (!isMeta) return;
4278    
4279    // Cmd/Ctrl + Plus/Equal (increase density)
4280    if (e.key === "=" || e.key === "+") {
4281      e.preventDefault();
4282      e.stopPropagation();
4283      changeDensity(1); // Increase
4284    } 
4285    // Cmd/Ctrl + Minus (decrease density)
4286    else if (e.key === "-") {
4287      e.preventDefault();
4288      e.stopPropagation();
4289      changeDensity(-1); // Decrease
4290    } 
4291    // Cmd/Ctrl + 0 (reset density)
4292    else if (e.key === "0") {
4293      e.preventDefault();
4294      e.stopPropagation();
4295      changeDensity(0); // Reset to default
4296    }
4297  }, { capture: true });
4298
4299  // 🎯 Auto-density message handler - listen for density change requests from KidLisp
4300  window.addEventListener("message", (e) => {
4301    if (e.data && e.data.type === 'ac-density-change' && typeof e.data.density === 'number') {
4302      const newDensity = e.data.density;
4303      if (newDensity !== density) {
4304        density = newDensity;
4305        window.acPACK_DENSITY = newDensity;
4306        try {
4307          localStorage.setItem("ac-density", newDensity.toString());
4308        } catch {}
4309        frame();
4310        // console.log(`🎯 Auto-density applied: ${newDensity}`);
4311      }
4312    }
4313    // FPS reporting for parent window (device.kidlisp.com)
4314    if (e.data && e.data.type === 'ac:request-fps') {
4315      window.acReportFpsToParent = true;
4316    }
4317    // Navigate to a new piece (device.html slideshow jump)
4318    if (e.data?.type === 'ac:navigate' && typeof e.data.to === 'string') {
4319      console.log('🧭 bios: relaying ac:navigate →', e.data.to);
4320      send({ type: 'navigate', content: { to: e.data.to } });
4321    }
4322    // Warm the kidlisp code cache with pre-fetched sources
4323    if (e.data?.type === 'ac:warm-cache' && e.data.codes) {
4324      send({ type: 'warm-kidlisp-cache', content: { codes: e.data.codes } });
4325    }
4326    // 🎵 ac-electron drag-drop: a file dropped onto the Dock icon (or
4327    // window) was registered with a loopback http server in the Electron
4328    // host; forward the resulting url to disk.mjs so the running piece
4329    // (or play.mjs after auto-jump) can consume it.
4330    if (e.data?.type === 'ac-dropped-file' && typeof e.data.url === 'string') {
4331      console.log('🎵 bios: dropped file →', e.data.name, e.data.url);
4332      send({
4333        type: 'dropped:file',
4334        content: { url: e.data.url, name: e.data.name, mime: e.data.mime },
4335      });
4336    }
4337  });
4338
4339  // Play a sound back through the sfx system.
4340  // 🌡️ TODO: `sfx` could be scraped for things that need to be decoded
4341  //          upon audio activation. This would probably be helpful
4342  //          in terms of creating a sampler and asynchronously
4343  //          decoding all the sounds after an initial tap.
4344
4345  async function playSfx(id, soundData, options, completed) {
4346    // console.log("🎵 BIOS playSfx called:", { id: id?.substring?.(0, 50), soundData: soundData?.substring?.(0, 50), options, hasAudioContext: !!audioContext });
4347    
4348    if (audioContext) {
4349      
4350      if (sfxCancel.includes(id)) {
4351        // console.log("🎵 BIOS playSfx cancelled for:", id);
4352        sfxCancel.length = 0;
4353        return;
4354      }
4355
4356      // Handle stream option - audio should be silent for streaming
4357      if (options?.stream) {
4358        // console.log("🎵 BIOS stream option detected, audio will be silent:", soundData);
4359        // Create a dummy playback object for tracking
4360        sfxPlaying[id] = {
4361          kill: () => {
4362            // No-op for silent stream audio
4363          }
4364        };
4365        return;
4366      }
4367
4368      // Instantly decode the audio before playback if it hasn't been already.
4369      // console.log("🎵 BIOS attempting to decode sfx:", soundData?.substring?.(0, 50), "current type:", typeof sfx[soundData]);
4370      await decodeSfx(soundData);
4371      // console.log("🎵 BIOS decode complete, sfx type now:", typeof sfx[soundData], "isAudioBuffer:", sfx[soundData] instanceof AudioBuffer);
4372
4373      if (sfx[soundData] instanceof ArrayBuffer) {
4374        // console.log("🎵 BIOS sfx still ArrayBuffer, returning early");
4375        return;
4376      }
4377
4378      if (!sfx[soundData]) {
4379        // console.log("🎵 BIOS sfx not found after decode, queuing:", soundData?.substring?.(0, 50));
4380        // Queue the sound effect to be played once it's loaded
4381        pendingSfxQueue.push({
4382          id,
4383          soundData,
4384          options,
4385          completed,
4386          queuedAt: Date.now(),
4387        });
4388        return;
4389      }
4390
4391      const channels = [];
4392      for (let i = 0; i < sfx[soundData].numberOfChannels; i += 1) {
4393        channels.push(sfx[soundData].getChannelData(i)); // Get raw Float32Array.
4394      }
4395
4396      // 🔇 Silent sample detection - check if audio is essentially empty/corrupted
4397      const channelData = channels[0];
4398      if (channelData && channelData.length > 100) {
4399        let maxAmplitude = 0;
4400        // Stride-scan the WHOLE buffer (≤4096 probes, early exit) — the
4401        // old 7-point check (first 0.2s + midpoint) deleted real tapes
4402        // whose audio happened to be quiet at those exact spots.
4403        const stride = Math.max(1, Math.floor(channelData.length / 4096));
4404        for (let i = 0; i < channelData.length; i += stride) {
4405          const a = Math.abs(channelData[i]);
4406          if (a > maxAmplitude) maxAmplitude = a;
4407          if (maxAmplitude >= 0.001) break;
4408        }
4409        
4410        // If max amplitude is below threshold, this sample is effectively silent
4411        if (maxAmplitude < 0.001) {
4412          console.warn("🔇 BIOS detected silent/corrupted sample:", soundData?.substring?.(0, 50), "maxAmplitude:", maxAmplitude);
4413          // Clear the bad cache entry so it can be re-fetched
4414          delete sfx[soundData];
4415          // Also clear from speakAPI cache and mark for cache bust if it's a speech sample
4416          if (soundData.startsWith("speech:") && speakAPI.sfx) {
4417            delete speakAPI.sfx[soundData];
4418            // Track labels that need server cache bust
4419            speakAPI.bustCache = speakAPI.bustCache || new Set();
4420            speakAPI.bustCache.add(soundData);
4421            console.log("🔇 Cleared speech cache for:", soundData, "(marked for server bust)");
4422          }
4423          return; // Don't play silence
4424        }
4425      }
4426
4427      const sample = {
4428        channels,
4429        sampleRate: sfx[soundData].sampleRate,
4430        length: sfx[soundData].length,
4431      };
4432
4433      /*console.log("🎵 BIOS sample prepared:", {
4434        soundData: soundData?.substring?.(0, 50),
4435        sampleChannels: sample.channels.length,
4436        sampleRate: sample.sampleRate,
4437        length: sample.length,
4438        triggerSoundAvailable: !!triggerSound
4439      });*/
4440
4441      // TODO: ⏰ Memoize the buffer data after first playback so it doesn't have to
4442      //          keep being sent on every playthrough. 25.02.15.08.22
4443
4444      // console.log("👮 Sample ID:", id, "Sound data:", soundData);
4445
4446      // Calculate speed from pitch if pitch is provided (frequency in Hz)
4447      // Assumes sample's base pitch is A4 (440 Hz) - speed = pitch / 440
4448      let speed = 1;
4449      if (isFinite(options?.speed)) {
4450        speed = options.speed;
4451      } else if (isFinite(options?.pitch)) {
4452        const basePitch = options?.basePitch || 440; // Default to A4
4453        speed = options.pitch / basePitch;
4454      }
4455
4456      /*console.log("🎵 BIOS about to call triggerSound:", {
4457        id: id?.substring?.(0, 30),
4458        speed,
4459        volume: options?.volume,
4460        pan: options?.pan,
4461        loop: options?.loop,
4462        targetDuration: options?.targetDuration,
4463        sfxLoadedForData: !!sfxLoaded[soundData]
4464      });*/
4465
4466      const playResult = triggerSound?.({
4467        id,
4468        type: "sample",
4469        options: {
4470          buffer: sfxLoaded[soundData] ? soundData : sample, // Alternatively send a memoized code using a lookup table.
4471          label: soundData, // TODO: 🚩 This needs to be invalidated by `tape`.
4472          // TODO: 🚩 Cached speaker sounds need to be dumped on a piece swap.
4473          from: isFinite(options?.from) ? options.from : 0,
4474          to: isFinite(options?.to) ? options.to : 1,
4475          speed,
4476          loop: options?.loop || false,
4477          targetDuration: options?.targetDuration || 0, // Time stretch to target duration (ms), then pitch shift
4478        },
4479        volume: isFinite(options?.volume) ? options.volume : 1,
4480        pan: isFinite(options?.pan) ? options.pan : 0,
4481        // options: { buffer: sample },
4482        // ⏰ TODO: If duration / 'not specified then use speed.
4483        // beats: undefined, // ((sample.length / sample.sampleRate) * sound.bpm / 60),
4484        // attack: 0, // 🩷 TODO: These should have saner defaults.
4485        // decay: 0,
4486      });
4487
4488      /*console.log("🎵 BIOS triggerSound result:", {
4489        id: id?.substring?.(0, 30),
4490        playResult,
4491        playResultType: typeof playResult
4492      });*/
4493
4494      sfxPlaying[id] = playResult;
4495
4496      // Store the completion callback if provided
4497      if (completed) {
4498        sfxCompletionCallbacks[id] = completed;
4499      }
4500
4501      if (triggerSound) sfxLoaded[soundData] = true;
4502    } else {
4503      // Queue the sound effect to be played once audioContext is available
4504      pendingSfxQueue.push({
4505        id,
4506        soundData,
4507        options,
4508        completed,
4509        queuedAt: Date.now(),
4510      });
4511    }
4512  }
4513
4514  // 📼 Start the tape soundtrack as a free-running full-range loop and nudge
4515  // the read head to `fromPosition`. `from` can't express this — in the
4516  // sample synth it sets the LOOP START boundary, which would trap the loop
4517  // in the tail of the tape instead of wrapping over the whole thing.
4518  function startTapeAudioLoop(fromPosition) {
4519    const id = "tape:audio_" + performance.now();
4520    playSfx(id, "tape:audio", { loop: true }).then(() => {
4521      const pos = Math.max(0, Math.min(1, fromPosition || 0));
4522      if (pos > 0) sfxPlaying[id]?.update({ samplePosition: pos });
4523    });
4524    return id;
4525  }
4526
4527  function stopTapeAudioLoops() {
4528    Object.keys(sfxPlaying).forEach((id) => {
4529      if (!id.startsWith("tape:audio_")) return;
4530      sfxPlaying[id]?.kill();
4531      delete sfxPlaying[id];
4532    });
4533  }
4534
4535  function startPreparedTapeAudio(fromPosition = 0) {
4536    stopTapeAudioLoops();
4537    const position = Math.max(0, Math.min(1, fromPosition));
4538    currentTapePosition = position;
4539    if (
4540      !sfx["tape:audio"] ||
4541      window.audioWorkletReady !== true ||
4542      audioContext?.state !== "running"
4543    ) {
4544      return null;
4545    }
4546    return startTapeAudioLoop(position);
4547  }
4548
4549  async function prepareMp4TapeAudio(source, label = "mp4 tape") {
4550    const arrayBuffer =
4551      source instanceof ArrayBuffer
4552        ? source
4553        : source?.arrayBuffer
4554          ? await source.arrayBuffer()
4555          : await (async () => {
4556              const response = await fetch(source);
4557              if (!response.ok) throw new Error(`HTTP ${response.status}`);
4558              return response.arrayBuffer();
4559            })();
4560    // decodeAudioData detaches the buffer it's given, so hand it a copy and
4561    // keep the original for later export/upload reuse — one copy, not tw
4561o.
4562    window.tapeAudioArrayBuffer = arrayBuffer;
4563    const decodeContext =
4564      audioContext && audioContext.state !== "closed"
4565        ? audioContext
4566        : new OfflineAudioContext(1, 1, 48000);
4567    const decoded = await decodeContext.decodeAudioData(arrayBuffer.slice(0));
4568    delete sfxLoaded["tape:audio"];
4569    sfx["tape:audio"] = decoded;
4570    console.log(
4571      `📼 ${label} audio prepared for sfx: ${decoded.duration.toFixed(2)}s, ` +
4572        `${decoded.numberOfChannels}ch @ ${decoded.sampleRate}Hz`,
4573    );
4574    return decoded;
4575  }
4576
4577  speakAPI.playSfx = playSfx;
4578
4579  // TODO: Add mute
4580  // function mute() {
4581  //   audioContext.suspend();
4582  //   // Or... audioContext.resume();
4583  // }
4584
4585  // Try to load the disk boilerplate as a worker first.
4586  // Safari and FF support is coming for worker module imports: https://bugs.webkit.org/show_bug.cgi?id=164860
4587  //const worker = new Worker("./aesthetic.computer/lib/disk.js", {
4588  //  type: "module",
4589  //});
4590  const standardWorkerPath =
4591    (window.acPACK_MODE ? "./aesthetic.computer/lib/disk.mjs" : "/aesthetic.computer/lib/disk.mjs") +
4592    window.location.search +
4593    "#" +
4594    Date.now(); // bust the cache. This prevents an error related to Safari loading workers from memory.
4595  const workerBundlePath = await workerBundlePathPromise;
4596  let usingBundledWorker = Boolean(workerBundlePath);
4597  let activeWorkerPath = workerBundlePath
4598    ? workerBundlePath + window.location.search + "#" + Date.now()
4599    : standardWorkerPath;
4600  workerBundleState.active = usingBundledWorker;
4601
4602  const sandboxed =
4603    (window.origin === "null" || !window.origin || window.acPACK_MODE || window.acSPIDER) && !window.acVSCODE;
4604
4605  // 🕷️ SPIDER MODE: Debug sandboxed state
4606  if (window.acSPIDER) {
4607    console.log("🕷️ SPIDER: Sandboxed check:", {
4608      origin: window.origin,
4609      acPACK_MODE: window.acPACK_MODE,
4610      acVSCODE: window.acVSCODE,
4611      sandboxed
4612    });
4613  }
4614
4615  // Extract embedded source if available
4616  let embeddedSource = null;
4617  try {
4618    const embeddedScript = document.getElementById("embedded-source");
4619    if (embeddedScript) {
4620      embeddedSource = JSON.parse(embeddedScript.textContent);
4621    }
4622  } catch (err) {
4623    console.warn("⚠️ Failed to parse embedded source:", err);
4624  }
4625
4626  const firstMessage = {
4627    type: "init-from-bios",
4628    content: {
4629      parsed,
4630      debug,
4631      debugHud: false,
4632      rootPiece: window.acSTARTING_PIECE,
4633      user: window.acUSER,
4634      lanHost: window.acLAN_HOST,
4635      iframe: window.self !== window.top,
4636      sandboxed,
4637      shareSupported: (iOS || Android) && navigator.share !== undefined,
4638      previewOrIcon: window.acPREVIEW_OR_ICON,
4639      vscode: window.acVSCODE,
4640      objktMode: window.acPACK_MODE || false,
4641      objktKidlispCodes: window.objktKidlispCodes || globalThis.objktKidlispCodes || {},
4642      microphonePermission: null,
4643      resolution,
4644      embeddedSource,
4645      noauth: window.acNOAUTH || false,
4646      bootId: window.acBOOT_ID || null,
4647      pixelRatio: window.devicePixelRatio, // For the worker's `hd()` layer.
4648      // The shell may have fetched the piece's source already
4649      // (window.acPIECE_SOURCE); ride it down so the worker skips its own
4650      // fetch. If it hasn't landed yet it follows as a 'piece-source' message.
4651      pieceSource: pieceSourceHint(),
4652    },
4653  };
4654  function pieceSourceHint() {
4655    const h = window.acPIECE_SOURCE;
4656    return h && typeof h.code === "string" ? { slug: h.slug, code: h.code } : null;
4657  }
4658
4659  // 🔍 Debug logging flags (opt-in; avoids spamming console during normal runs)
4660  const DEBUG_MESSAGE_FLOW = !!window.acDEBUG_MESSAGE_FLOW;
4661
4662  const onMessage = (m) => {
4663    // Log all non-frame messages at the earliest point
4664    if (DEBUG_MESSAGE_FLOW && m?.data?.type && m.data.type !== "frame") {
4665      console.log(`🔍 BIOS onMessage: type="${m.data.type}"`);
4666    }
4667    receivedChange(m);
4668  };
4669
4670  let send = (msg) => {
4671    console.warn("Send has not been wired yet!", msg);
4672  };
4673
4674  let firstMessageSent = false;
4675  const sendFirstMessage = () => {
4676    if (firstMessageSent) return;
4677    firstMessageSent = true;
4678    send(firstMessage);
4679    if (masterClockRate !== 1) send({ type: "clock:rate", content: { rate: masterClockRate } });
4680    if (!firstMessage.content.pieceSource && window.acPIECE_SOURCE?.promise) {
4681      window.acPIECE_SOURCE.promise.then(() => {
4682        const late = pieceSourceHint();
4683        if (late) send({ type: "piece-source", content: late });
4684      });
4685    }
4686    consumeDiskSends(send);
4687  };
4688
4689  const TAPE_PREVIEW_MAX_FRAMES = 90;
4690  const TAPE_PREVIEW_WIDTH = 256;
4691  const TAPE_PREVIEW_HEIGHT = 192;
4692  let tapePreviewCanvas = null;
4693  let tapePreviewCtx = null;
4694
4695  function getTapePreviewContext(width = TAPE_PREVIEW_WIDTH, height = TAPE_PREVIEW_HEIGHT) {
4696    if (typeof OffscreenCanvas !== "undefined") {
4697      if (!(tapePreviewCanvas instanceof OffscreenCanvas)) {
4698        tapePreviewCanvas = new OffscreenCanvas(width, height);
4699      }
4700      if (tapePreviewCanvas.width !== width) tapePreviewCanvas.width = width;
4701      if (tapePreviewCanvas.height !== height) tapePreviewCanvas.height = height;
4702      tapePreviewCtx = tapePreviewCanvas.getContext("2d");
4703      return tapePreviewCtx;
4704    }
4705
4706    if (!tapePreviewCanvas && typeof document !== "undefined") {
4707      tapePreviewCanvas = document.createElement("canvas");
4708    }
4709    if (tapePreviewCanvas) {
4710      if (tapePreviewCanvas.width !== width) tapePreviewCanvas.width = width;
4711      if (tapePreviewCanvas.height !== height) tapePreviewCanvas.height = height;
4712      tapePreviewCtx = tapePreviewCanvas.getContext("2d");
4713    }
4714    return tapePreviewCtx;
4715  }
4716
4717  async function buildTapePreviewFrames(sourceFrames, limit = TAPE_PREVIEW_MAX_FRAMES) {
4718    const previews = [];
4719    if (!Array.isArray(sourceFrames) || sourceFrames.length === 0) return previews;
4720
4721    const total = sourceFrames.length;
4722    const step = Math.max(1, Math.floor(total / limit));
4723
4724    for (let i = 0; i < total && previews.length < limit; i += step) {
4725      const frame = sourceFrames[i];
4726      if (!frame) continue;
4727      // Synth placeholders render on demand — previews aren't needed for
4728      // synthtapes, so skip rather than choke on a non-ImageData entry.
4729      if (frame[1]?.synth !== undefined) continue;
4730      
4731      // Keep original frames at full resolution for Ken Burns panning
4732      // No downscaling - just select frames and pass them through
4733      previews.push(frame);
4734    }
4735
4736    return previews;
4737  }
4738
4739  //  👷️ Always use workers if they are supported, except for
4740  //     when we are in VR (MetaBrowser).
4741  // Disable workers if we are in a sandboxed iframe.
4742  // Test if workers are actually available instead of just checking sandboxed state
4743  let workersEnabled = true;
4744  try {
4745    // Try to create a simple test worker to see if they're supported
4746    if (typeof Worker === 'undefined') {
4747      workersEnabled = false;
4748    }
4749  } catch (e) {
4750    workersEnabled = false;
4751  }
4752  
4753  // Override: force disable workers for OBJKT, SPIDER, and other sandboxed environments
4754  if (sandboxed || window.origin === "null" || window.acSPIDER) {
4755    // Disable workers in any sandboxed environment, including OBJKT and SPIDER
4756    workersEnabled = false;
4757    if (debug) console.log("🚫 Workers disabled due to sandboxed/null origin environment");
4758  }
4759  
4760  // Workers enabled logging removed for cleaner console
4761
4762  // Notify parent that we're loading the disk
4763  const diskLoadStartTime = performance.now();
4764  const bootElapsed = Math.round(diskLoadStartTime - bootStartTime);
4765  perf.markBoot("disk-load-start");
4766  if (window.acBOOT_LOG) {
4767    window.acBOOT_LOG(`loading disk: ${parsed.text || 'prompt'} (${bootElapsed}ms)`);
4768  } else if (window.parent) {
4769    window.parent.postMessage({ 
4770      type: "boot-log", 
4771      message: `loading disk: ${parsed.text || 'prompt'} (${bootElapsed}ms)` 
4772    }, "*");
4773  }
4774
4775  if (/*!MetaBrowser &&*/ workersEnabled) {
4776    perf.markBoot("worker-create-start");
4777    
4778    let workerFailed = false; // Guard to prevent double-initialization
4779    let workerReady = false;  // Track if worker has responded
4780    let workerInitialized = false; // Guard to prevent sending firstMessage multiple times
4781    let retryCount = 0;
4782    let activeWorker = null;
4783    const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
4784    // On localhost, fail fast (1 retry) since HTTP proxy is flaky - noWorker fallback works fine
4785    // In production, retry more since network issues are usually transient
4786    const maxRetries = isLocalhost ? 1 : 3;
4787    
4788    // Note: Can't use blob URLs for workers because dynamic imports inside the worker
4789    // (like loading pieces) would resolve relative to blob: origin which fails.
4790    // Workers need to load via HTTP so their dynamic imports work correctly.
4791
4792    const createWorker = () => {
4793      const worker = new Worker(new URL(activeWorkerPath, window.location.href), {
4794        type: "module",
4795      });
4796      activeWorker = worker;
4797
4798      // Rewire things a bit if workers with modules are not supported (Firefox).
4799      worker.onerror = async (err) => {
4800        if (workerFailed) return; // Already handling fallback
4801        if (usingBundledWorker && !workerInitialized) {
4802          worker.terminate();
4803          usingBundledWorker = false;
4804          workerBundleState.active = false;
4805          workerBundleState.fallback = `worker error: ${err.message || "unknown"}`;
4806          activeWorkerPath = standardWorkerPath;
4807          retryCount = 0;
4808          createWorker();
4809          return;
4810        }
4811        if (workerInitialized) {
4812          // Worker already started successfully - this is a late error, don't double-init
4813          console.warn("🟡 Worker error after init:", err.message || "(no message)");
4814          return;
4815        }
4816        
4817        // Detect transient/network errors that are worth retrying
4818        const isTransientError = !err.message;
4819        const isNetworkError = err.message?.includes('ERR_') || 
4820                               err.message?.includes('Failed to fetch') ||
4821                               err.message?.includes('NetworkError') ||
4822                               err.message?.includes('Content-Length');
4823        
4824        // Try retrying the worker a couple times for transient/network errors
4825        if (retryCount < maxRetries && (isTransientError || isNetworkError)) {
4826          retryCount++;
4827          const delay = isLocalhost ? 100 : 200 * retryCount; // Fast retry on localhost
4828          // Only log retry attempts in non-localhost (localhost proxy issues are expected)
4829          if (!isLocalhost) {
4830            console.warn(`📦 Worker load failed (attempt ${retryCount}/${maxRetries}), retrying in ${delay}ms...`);
4831          }
4832          worker.terminate();
4833          await new Promise(r => setTimeout(r, delay));
4834          createWorker();
4835          return;
4836        }
4837        
4838        // Log error only for real errors with messages (not transient proxy issues on localhost)
4839        if (!isTransientError) {
4840          console.error("🛑 Disk worker error:", err);
4841          console.error("🚨 Message:", err.message);
4842          console.error("🚨 Filename:", err.filename || "(no filename)");
4843          console.error("🚨 Line:", err.lineno, "Col:", err.colno);
4844        }
4845        
4846        workerFailed = true;
4847        worker.terminate(); // Clean up failed worker
4848        
4849        // https://bugzilla.mozilla.org/show_bug.cgi?id=1247687
4850        // Try to use WebSocket module loader for the fallback import (avoids HTTP proxy issues)
4851        let module;
4852        try {
4853          const loader = window.acModuleLoader;
4854          if (loader?.connected && loader.blobUrls?.has('lib/disk.mjs')) {
4855            const blobUrl = loader.blobUrls.get('lib/disk.mjs');
4856            module = await import(blobUrl);
4857          } else {
4858            if (!isLocalhost) console.warn("🟡 Worker failed, using noWorker mode");
4859            const vfsBlobUrl = window.VFS_BLOB_URLS?.["lib/disk.mjs"];
4860            module = await import(vfsBlobUrl || `./lib/disk.mjs`);
4861          }
4862        } catch (err) {
4863          console.error("Failed to load disk module:", err);
4864        }
4865        if (!module?.noWorker) return;
4866        module.noWorker.postMessage = (e) => onMessage(e);
4867        send = (e) => module.noWorker.onMessage(e);
4868        window.acSEND = send;
4869        sendFirstMessage();
4870      };
4871
4872      if (worker.postMessage) {
4873        // console.log("🟢 Worker");
4874        send = (e, shared) => {
4875          if (workerFailed) return; // Don't send if worker has failed
4876          worker.postMessage(e, shared);
4877        };
4878        window.acSEND = send; // Make the message handler global, used in `speech.mjs` and also useful for debugging.
4879        
4880        // 🔍 Debug utility: Toggle HUD hitbox visualization from console
4881        window.toggleHudDebug = () => {
4882          console.log("🔍 toggleHudDebug(): sending debug:hud-hitbox:toggle");
4883          send({ type: "debug:hud-hitbox:toggle" });
4884        };
4885        
4886        worker.onmessage = (e) => {
4887          if (workerFailed) return; // Ignore messages if we've switched to fallback
4888          
4889          // Handle worker-ready signal from disk.mjs
4890          if (e.data?.type === "worker-ready") {
4891            if (workerInitialized) return; // Already initialized (shouldn't happen)
4892            workerInitialized = true;
4893            workerReady = true;
4894            workerBundleState.ready = usingBundledWorker;
4895            perf.markBoot("worker-connected");
4896            
4897            // Notify parent that worker is connected
4898            const workerConnectTime = performance.now();
4899            const workerElapsed = Math.round(workerConnectTime - diskLoadStartTime);
4900            if (window.acBOOT_LOG) {
4901              window.acBOOT_LOG(`connecting to worker (${workerElapsed}ms)`);
4902            } else if (window.parent) {
4903              window.parent.postMessage({ 
4904                type: "boot-log", 
4905                message: `connecting to worker (${workerElapsed}ms)` 
4906              }, "*");
4907            }
4908
4909            // NOW send the initial message - disk.mjs is ready to receive it
4910            perf.markBoot("first-message-sent");
4911            sendFirstMessage();
4912            return;
4913          }
4914          
4915          workerReady = true;
4916          onMessage(e);
4917        };
4918      }
4919    };
4920    
4921    // Start the first worker attempt
4922    createWorker();
4923
4924    if (usingBundledWorker) {
4925      setTimeout(() => {
4926        if (workerReady || workerFailed || !usingBundledWorker) return;
4927        activeWorker?.terminate();
4928        usingBundledWorker = false;
4929        workerBundleState.active = false;
4930        workerBundleState.fallback = "worker ready timeout";
4931        activeWorkerPath = standardWorkerPath;
4932        retryCount = 0;
4933        createWorker();
4934      }, 4000);
4935    }
4936
4937    // Timeout: fall back to noWorker mode if worker never responds
4938    const workerTimeoutMs = isLocalhost ? 3000 : 10000;
4939    setTimeout(async () => {
4940      if (workerReady || workerFailed) return; // Already resolved
4941      console.warn(`🟡 Worker timeout (${workerTimeoutMs}ms) — falling back to noWorker mode`);
4942      workerFailed = true;
4943
4944      let module;
4945      try {
4946        const loader = window.acModuleLoader;
4947        if (loader?.connected && loader.blobUrls?.has('lib/disk.mjs')) {
4948          const blobUrl = loader.blobUrls.get('lib/disk.mjs');
4949          module = await import(blobUrl);
4950        } else {
4951          const vfsBlobUrl = window.VFS_BLOB_URLS?.["lib/disk.mjs"];
4952          module = await import(vfsBlobUrl || `./lib/disk.mjs`);
4953        }
4954      } catch (err) {
4955        console.error("Worker timeout: failed to load disk module:", err);
4956      }
4957      if (!module?.noWorker) return;
4958      module.noWorker.postMessage = (e) => onMessage(e);
4959      send = (e) => module.noWorker.onMessage(e);
4960      window.acSEND = send;
4961      sendFirstMessage();
4962    }, workerTimeoutMs);
4963  } else {
4964    // B. No Worker Mode
4965    if (debug) console.log("🔴 No Worker");
4966    let module;
4967    try {
4968      // Prefer VFS blob URL (works in offline bundles from file://)
4969      const blobUrl = window.VFS_BLOB_URLS?.["lib/disk.mjs"];
4970      module = await import(blobUrl || `./lib/disk.mjs`);
4971    } catch (err) {
4972      console.error("No-worker mode: failed to load disk module:", err);
4973    }
4974    if (!module?.noWorker) return;
4975    module.noWorker.postMessage = (e) => onMessage(e);
4976    send = (e) => module.noWorker.onMessage(e);
4977    window.acSEND = send;
4978  }
4979
4980  // The initial message sends the path and host to load the disk.
4981  // Note: In worker mode, this is now handled inside the if(worker.postMessage) block
4982  // to ensure proper timing. In no-worker mode, it's handled here.
4983  if (!workersEnabled) {
4984    sendFirstMessage();
4985  }
4986
4987  // 🛑 HALT Detection - Watchdog for unresponsive disk worker
4988  let lastPongTime = Date.now();
4989  let haltDetected = false;
4990  let watchdogStarted = false;
4991  const HALT_TIMEOUT_MS = 12000; // 12 seconds before triggering HALT (more forgiving)
4992  const PING_INTERVAL_MS = 2000; // Ping every 2 seconds
4993  let missedPongs = 0; // Track consecutive missed pongs
4994
4995  // Start the watchdog - called after worker first responds
4996  function startWatchdog() {
4997    if (watchdogStarted || !workersEnabled) return;
4998    watchdogStarted = true;
4999    lastPongTime = Date.now(); // Reset the pong time when watchdog actually starts
5000    missedPongs = 0;
5001    console.log("🐕 Watchdog started after worker initialization");
5002    
5003    // Send periodic pings to the worker
5004    setInterval(() => {
5005      if (!haltDetected) {
5006        send({ type: "watchdog:ping", content: { timestamp: Date.now() } });
5007      }
5008    }, PING_INTERVAL_MS);
5009
5010    // Check for HALT condition - require multiple missed pongs
5011    setInterval(() => {
5012      if (haltDetected) return;
5013      const timeSinceLastPong = Date.now() - lastPongTime;
5014      if (timeSinceLastPong > PING_INTERVAL_MS * 1.5) {
5015        missedPongs++;
5016        console.warn("🐕 Missed pong #" + missedPongs + " (last pong " + Math.round(timeSinceLastPong / 1000) + "s ago)");
5017      } else {
5018        missedPongs = 0; // Reset on successful pong
5019      }
5020      // Only HALT after sustained unresponsiveness
5021      if (timeSinceLastPong > HALT_TIMEOUT_MS && missedPongs >= 4) {
5022        haltDetected = true;
5023        console.error("🛑 HALT DETECTED! Worker unresponsive for", timeSinceLastPong, "ms after", missedPongs, "missed pongs");
5024        triggerHaltSequence();
5025      }
5026    }, PING_INTERVAL_MS);
5027  }
5028
5029  // Expose startWatchdog globally so receivedChange can call it
5030  window.acStartWatchdog = startWatchdog;
5031
5032  // Handle pong responses from worker
5033  window.acHandleWatchdogPong = () => {
5034    lastPongTime = Date.now();
5035  };
5036
5037  // HALT sequence: show black screen with clickable reload
5038  function triggerHaltSequence() {
5039    // Create HALT overlay - black background, white text, click to reload
5040    const overlay = document.createElement("div");
5041    overlay.id = "halt-overlay";
5042    overlay.style.cssText = `
5043      position: fixed;
5044      top: 0; left: 0; right: 0; bottom: 0;
5045      background: #000;
5046      display: flex;
5047      flex-direction: column;
5048      align-items: center;
5049      justify-content: center;
5050      font-family: monospace;
5051      z-index: 999999;
5052      cursor: pointer;
5053    `;
5054    overlay.innerHTML = `
5055      <div style="color: #fff; font-size: 6vw; font-weight: bold; margin-bottom: 20px;">
5056        ⚠️ HALT
5057      </div>
5058      <div style="color: #aaa; font-size: 2vw; margin-bottom: 40px;">
5059        Worker became unresponsive
5060      </div>
5061      <div style="color: #fff; font-size: 3vw; padding: 20px 40px; border: 2px solid #fff; border-radius: 8px;">
5062        Click anywhere to reload
5063      </div>
5064    `;
5065    
5066    // Click anywhere to reload
5067    overlay.addEventListener("click", () => {
5068      window.location.reload();
5069    });
5070    
5071    // Also allow pressing any key to reload
5072    document.addEventListener("keydown", () => {
5073      window.location.reload();
5074    }, { once: true });
5075    
5076    document.body.appendChild(overlay);
5077  }
5078
5079  // 🎮 Initialize Game Boy emulator in main thread
5080  // (Placed after worker setup so `send` is properly wired)
5081  async function initGameboy() {
5082    if (!window.WasmBoy) {
5083      console.log("🎮 Loading wasmboy library...");
5084      // Load wasmboy library dynamically
5085      const wasmBoyModule = await import('./dep/wasmboy/wasmboy.ts.esm.js');
5086      console.log("🎮 wasmBoyModule:", wasmBoyModule);
5087      console.log("🎮 wasmBoyModule.WasmBoy:", wasmBoyModule.WasmBoy);
5088      console.log("🎮 wasmBoyModule keys:", Object.keys(wasmBoyModule));
5089      window.WasmBoy = wasmBoyModule.WasmBoy;
5090    }
5091    
5092    if (!gameboyEmulator) {
5093      console.log("🎮 Initializing Game Boy emulator with invisible canvas...");
5094      
5095      // Create a canvas but make it completely invisible
5096      const hiddenCanvas = document.createElement('canvas');
5097      hiddenCanvas.width = 160;
5098      hiddenCanvas.height = 144;
5099      hiddenCanvas.id = 'wasmboy-hidden-canvas';
5100      
5101      // Make canvas completely invisible using multiple methods
5102      hiddenCanvas.style.cssText = `
5103        position: fixed !important;
5104        left: -99999px !important;
5105        top: -99999px !important;
5106        width: 1px !important;
5107        height: 1px !important;
5108        visibility: hidden !important;
5109        opacity: 0 !important;
5110        pointer-events: none !important;
5111        z-index: -99999 !important;
5112        display: none !important;
5113      `;
5114      
5115      // Add comprehensive CSS to hide any wasmboy canvases
5116      if (!document.getElementById('wasmboy-hide-style')) {
5117        const style = document.createElement('style');
5118        style.id = 'wasmboy-hide-style';
5119        style.textContent = `
5120          canvas[width="160"][height="144"] {
5121            display: none !important;
5122            visibility: hidden !important;
5123            opacity: 0 !important;
5124            position: fixed !important;
5125            left: -99999px !important;
5126            top: -99999px !important;
5127            pointer-events: none !important;
5128            z-index: -99999 !important;
5129          }
5130          #wasmboy-hidden-canvas {
5131            display: none !important;
5132          }
5133        `;
5134        document.head.appendChild(style);
5135      }
5136      
5137      document.body.appendChild(hiddenCanvas);
5138      window.gameboyCanvas = hiddenCanvas; // Store reference for cleanup
5139      
5140      gameboyEmulator = window.WasmBoy; // It's a singleton, not a constructor
5141      
5142      await gameboyEmulator.config({
5143        headless: false, // Must be false for updateGraphicsCallback to work
5144        isAudioEnabled: true,
5145        updateGraphicsCallback: (imageDataArray) => {
5146          // Direct access to wasmboy's pixel buffer - much more efficient than canvas extraction
5147          if (imageDataArray && imageDataArray.length === 92160) { // 160 * 144 * 4 = 92160 RGBA pixels
5148            // Send the raw pixel data directly along with ROM metadata
5149            acDISK_SEND({
5150              type: "gameboy:frame-data", 
5151              content: { 
5152                pixels: imageDataArray,
5153                romName: currentGameboyROM?.originalName || "unknown",
5154                title: currentGameboyROM?.title || currentGameboyROM?.name || "unknown",
5155                isGameBoyColor: currentGameboyROM?.isGameBoyColor || false,
5156                customLabel: currentGameboyROM?.customLabel // Pass through custom label if provided
5157              }
5158            });
5159          }
5160        },
5161        updateAudioCallback: (audioContext, audioBufferSourceNode) => {
5162          // Route audio through AC's speaker system by connecting to sfxStreamGain
5163          if (sfxStreamGain && audioBufferSourceNode) {
5164            try {
5165              // Check if audio contexts match
5166              if (audioBufferSourceNode.context === sfxStreamGain.context) {
5167                audioBufferSourceNode.connect(sfxStreamGain);
5168              } else {
5169                console.log("🎮 Audio context mismatch - Game Boy audio isolated");
5170                // Let wasmboy handle its own audio output
5171              }
5172            } catch (error) {
5173              console.log("🎮 Game Boy audio connection failed:", error.message);
5174            }
5175          }
5176          return audioBufferSourceNode;
5177        }
5178      }, hiddenCanvas); // Pass the hidden canvas to setCanvas during config
5179      
5180      // Disable WasmBoy's default joypad handling so we can use our custom controls
5181      gameboyEmulator.disableDefaultJoypad();
5182      console.log("🎮 Disabled default joypad - using AC custom controls");
5183      
5184      console.log("🎮 Game Boy emulator initialized");
5185    }
5186  }
5187
5188  // 🎮 Load a Game Boy ROM
5189  async function loadGameboyROM(romData) {
5190    try {
5191      if (!gameboyEmulator) {
5192        await initGameboy();
5193      }
5194      
5195      console.log("🎮 Loading ROM:", romData.originalName);
5196      
5197      // Reset emulator state before loading new ROM
5198      try {
5199        await gameboyEmulator.pause();
5200        await gameboyEmulator.reset();
5201        
5202        // Clear the canvas to ensure clean slate
5203        const canvas = gameboyEmulator.getCanvas();
5204        if (canvas) {
5205          const ctx = canvas.getContext('2d');
5206          if (ctx) {
5207            ctx.fillStyle = '#ffffff';
5208            ctx.fillRect(0, 0, canvas.width, canvas.height);
5209          }
5210        }
5211        
5212        console.log("🎮 Emulator reset and canvas cleared");
5213      } catch (error) {
5214        console.log("🎮 Could not reset emulator:", error);
5215      }
5216      
5217      // Convert ArrayBuffer to Uint8Array for wasmboy
5218      const romBytes = new Uint8Array(romData.romData);
5219      await gameboyEmulator.loadROM(romBytes);
5220      await gameboyEmulator.play();
5221      
5222      // Try to get cartridge info/metadata from wasmboy
5223      try {
5224        const cartridgeInfo = await gameboyEmulator.getCartridgeInfo();
5225        console.log("🎮 Cartridge Info:", cartridgeInfo);
5226        
5227        // Add cartridge metadata to ROM data
5228        romData.cartridgeInfo = cartridgeInfo;
5229        romData.title = cartridgeInfo.titleAsString?.trim() || romData.name;
5230        romData.isGameBoyColor = cartridgeInfo.isGameBoyColor || romData.isGameBoyColor;
5231      } catch (error) {
5232        console.log("🎮 Could not get cartridge info:", error);
5233        romData.title = romData.name;
5234      }
5235      
5236      currentGameboyROM = romData;
5237      console.log("🎮 ROM loaded and playing:", romData.originalName);
5238      
5239      // Navigate to gameboy disk using AC's jump system
5240      console.log("🎮 About to jump to gameboy piece");
5241      send({ 
5242        type: "jump", 
5243        content: { 
5244          piece: "gameboy",
5245          ahistorical: false, // Add to history so user can go back
5246          alias: true
5247        }
5248      });
5249      console.log("🎮 Jump command sent to gameboy piece");
5250      
5251    } catch (error) {
5252      console.error("🎮 Failed to load ROM:", error);
5253    }
5254  }
5255
5256  // 🎮 Handle Game Boy input from worker
5257  function handleGameboyInput(joypadState) {
5258    if (gameboyEmulator && currentGameboyROM) {
5259      // WasmBoy.setJoypadState expects an object with UPPERCASE keys
5260      // It internally converts them to the format the core needs
5261      const wasmBoyJoypadState = {
5262        UP: joypadState.up || false,
5263        RIGHT: joypadState.right || false,
5264        DOWN: joypadState.down || false,
5265        LEFT: joypadState.left || false,
5266        A: joypadState.a || false,
5267        B: joypadState.b || false,
5268        SELECT: joypadState.select || false,
5269        START: joypadState.start || false
5270      };
5271      gameboyEmulator.setJoypadState(wasmBoyJoypadState);
5272    }
5273  }
5274
5275  // Beat
5276
5277  // Set the default bpm.
5278  sound.bpm = bpm;
5279
5280  // 🎹 DAW Sync (for Max for Live integration)
5281  // Connect if ?daw query param is present (for M4L browser-based embedding)
5282  // or if window.acFORCE_DAW is set (for packed offline amxd bundles loaded
5283  // via data: URI, where window.location.search is always empty).
5284  const hasDawParam =
5285    new URLSearchParams(window.location.search).has("daw") ||
5286    window.acFORCE_DAW === true;
5287  if (hasDawParam) {
5288    _dawConnectSend(send, updateMetronome);
5289
5290    // 🎹 Low-latency keyboard bridge for M4L devices.
5291    // jweb~ captures keyboard focus, which means Max's own [key]/[keyup]
5292    // objects never see keystrokes. Capture them here on the main thread and
5293    // forward directly via window.max.outlet — sub-ms, no worker round-trip,
5294    // no iframe focus fight.
5295    //
5296    // BIOS owns the notepat key→pitch layout and octave state so we can emit
5297    // a finished MIDI pitch and keep the Max patcher trivial. The piece UI
5298    // also listens to the same keystrokes to render matching visual feedback.
5299    const _dawKeyOffsets = {
5300      z: -2, x: -1,
5301      c: 0, v: 1, d: 2, s: 3, e: 4, f: 5, w: 6,
5302      g: 7, r: 8, a: 9, q: 10, b: 11,
5303      h: 12, t: 13, i: 14, y: 15, j: 16, k: 17, u: 18,
5304      l: 19, o: 20, m: 21, p: 22, n: 23,
5305      ";": 24, "'": 25, "]": 26,
5306    };
5307    let _dawBaseOctave = 4;
5308    const _dawHeldPitch = {}; // keyLower → emitted pitch (for correct note-off across octave shifts)
5309
5310    // Round-trip latency probe. BIOS emits a "ping" with a monotonic
5311    // timestamp alongside each notedown; the Max patcher echoes it back
5312    // via `script window.acMaxPong(...)` and we log the delta. This captures
5313    // the full iframe → Max → iframe hop — the real pipeline cost.
5314    const _dawRttSamples = []; // rolling buffer of recent RTTs (ms)
5315    window.acMaxPong = function (t0) {
5316      if (typeof t0 !== "number" || !Number.isFinite(t0)) return;
5317      const rtt = performance.now() - t0;
5318      _dawRttSamples.push(rtt);
5319      if (_dawRttSamples.length > 20) _dawRttSamples.shift();
5320      const avg =
5321        _dawRttSamples.reduce((s, v) => s + v, 0) / _dawRttSamples.length;
5322      console.log(
5323        `🎹 rtt ${rtt.toFixed(2)}ms (avg of ${_dawRttSamples.length}: ${avg.toFixed(2)}ms)`,
5324      );
5325    };
5326
5327    function _dawEmitMax(sym, value) {
5328      if (
5329        typeof window !== "undefined" &&
5330        window.max &&
5331        typeof window.max.outlet === "function"
5332      ) {
5333        try { window.max.outlet(sym, value); } catch (_err) {}
5334      }
5335    }
5336    function _dawComputePitch(k) {
5337      const off = _dawKeyOffsets[k];
5338      if (off === undefined) return null;
5339      return (_dawBaseOctave + 1) * 12 + off; // baseOctave 4 → C = 60
5340    }
5341
5342    window.addEventListener("keydown", (e) => {
5343      if (e.repeat) return;
5344      const k = typeof e.key === "string" ? e.key : "";
5345      if (k.length !== 1) return;
5346      // Spacebar: bubble up to Live's transport (play/pause). When the
5347      // jweb iframe holds focus Live's own [key] never sees the event,
5348      // so route it through the Max patcher's `transport` symbol. Stop
5349      // browser default to avoid scroll/button activation in jweb.
5350      if (k === " ") {
5351        // Diagnostic: forward into the Max console so we can confirm
5352        // the iframe actually saw the key (Live's host window grabs
5353        // spacebar as its transport shortcut before jweb in some
5354        // focus states). If "🎹 SPACE" never prints, the event isn't
5355        // reaching the iframe and we need a different approach.
5356        console.log("🎹 SPACE keydown → transport");
5357        try { window.max?.outlet?.("log", "🎹 SPACE keydown → transport"); } catch (_) {}
5358        if (typeof e.preventDefault === "function") e.preventDefault();
5359        if (typeof e.stopPropagation === "function") e.stopPropagation();
5360        _dawEmitMax("transport", 1);
5361        return;
5362      }
5363      // Octave hot-switch 1-9
5364      if (k >= "1" && k <= "9") {
5365        _dawBaseOctave = parseInt(k, 10);
5366        _dawEmitMax("octave", _dawBaseOctave);
5367        return;
5368      }
5369      const low = k.toLowerCase();
5370      const pitch = _dawComputePitch(low);
5371      if (pitch === null) return;
5372      _dawHeldPitch[low] = pitch;
5373      _dawEmitMax("notedown", pitch);
5374      // RTT ping: use Math.round so the int fits %ld in Max's [sprintf]
5375      // round-trip path. Delta is logged in window.acMaxPong above.
5376      _dawEmitMax("ping", Math.round(performance.now()));
5377    }, true);
5378
5379    window.addEventListener("keyup", (e) => {
5380      const k = typeof e.key === "string" ? e.key : "";
5381      if (k.length !== 1) return;
5382      const low = k.toLowerCase();
5383      const pitch = _dawHeldPitch[low];
5384      if (pitch === undefined) return;
5385      delete _dawHeldPitch[low];
5386      _dawEmitMax("noteup", pitch);
5387    }, true);
5388
5389    // Focus/blur indicator. On blur, release all held pitches so we never
5390    // leave a note hanging when the iframe loses focus (common during an
5391    // Ableton window switch). The piece UI uses the same events to show a
5392    // "tap me!" attract state.
5393    window.addEventListener("focus", () => {
5394      _dawEmitMax("focus", 1);
5395    }, true);
5396    window.addEventListener("blur", () => {
5397      for (const k of Object.keys(_dawHeldPitch)) {
5398        _dawEmitMax("noteup", _dawHeldPitch[k]);
5399      }
5400      for (const k of Object.keys(_dawHeldPitch)) delete _dawHeldPitch[k];
5401      _dawEmitMax("focus", 0);
5402    }, true);
5403
5404    // 🎹 Max → piece bridge. Patcher calls these via
5405    //   `executejavascript window.acSetLiveFocus(N)` /
5406    //   `executejavascript window.acSetLiveTrackColor(N)`.
5407    // The piece runs in a Web Worker so it can't define these itself;
5408    // we forward the state in via the same `send()` used for other
5409    // main→worker messages. Existing "focus-change" path surfaces as
5410    // e.is("focus") / e.is("defocus") in the piece's act().
5411    window.acSetLiveFocus = (f) => {
5412      const focused = !!f;
5413      console.log(`🎹 acSetLiveFocus ${focused ? "ACTIVE" : "inactive"}`);
5414      try { send({ type: "focus-change", content: focused }); } catch (_e) {}
5415    };
5416    window.acSetLiveTrackColor = (colorInt) => {
5417      const n = Number(colorInt) >>> 0;
5418      if (!Number.isFinite(n)) {
5419        console.log(`🎹 acSetLiveTrackColor ignored (bad ${colorInt})`);
5420        return;
5421      }
5422      const rgb = [(n >> 16) & 255, (n >> 8) & 255, n & 255];
5423      console.log(`🎹 acSetLiveTrackColor ${n} → rgb(${rgb.join(",")})`);
5424      try { send({ type: "live:track-color", content: rgb }); } catch (_e) {}
5425    };
5426
5427    // 🎹 Env info for the piece — packed vs live, build hash, and the
5428    // latest commit the server is currently serving (so a packed bundle
5429    // can know whether its baked-in version is behind). Fetched lazily
5430    // once then cached — piece asks via send({type:"env:request"}).
5431    const envInfo = {
5432      packMode: !!window.acPACK_MODE,
5433      packGit: window.acPACK_GIT || null,
5434      packDate: window.acPACK_DATE || null,
5435      latestCommit: null,   // piece_git from notepat.com/latest.json
5436      latestAmxd: null,     // { sizeBytes, sha256, versionedPath, built }
5437    };
5438    let envLatestFetched = false;
5439    async function _dawFetchLatestCommit() {
5440      if (envLatestFetched) return;
5441      envLatestFetched = true;
5442      // Amxd-specific manifest — the piece_git here is the commit the
5443      // *amxd* was built from, not whichever commit lith happens to be
5444      // serving. Avoids false "UPDATE AVAILABLE" on unrelated repo
5445      // changes (docs, other pieces, infra).
5446      try {
5447        const r = await fetch(
5448          "https://aesthetic.computer/m4l/notepat.com/latest.json",
5449          { cache: "no-cache", mode: "cors" },
5450        );
5451        if (!r.ok) return;
5452        const manifest = await r.json();
5453        if (manifest?.piece_git) envInfo.latestCommit = manifest.piece_git;
5454        if (manifest?.amxd) envInfo.latestAmxd = manifest.amxd;
5455      } catch (_e) { /* offline */ }
5456    }
5457    function _dawSendEnvInfo() {
5458      try { send({ type: "env:info", content: { ...envInfo } }); } catch (_e) {}
5459    }
5460    // Kick off the fetch immediately; when it resolves, push to the
5461    // piece. The piece also re-requests on boot (via env:request)
5462    // which will re-send whatever we've got.
5463    _dawFetchLatestCommit().then(_dawSendEnvInfo);
5464    window.acDawEnvRequest = _dawSendEnvInfo;
5465  }
5466
5467  function requestBeat(time) {
5468    send({
5469      type: "beat",
5470      content: { time, bpm: sound.bpm },
5471    });
5472  }
5473
5474  // Called inside of `requestFrame`, and on the `beat` message.
5475  function updateSynths(content) {
5476    function beat() {
5477      if (sound.bpm !== content.bpm) {
5478        sound.bpm = content.bpm;
5479        updateMetronome(sound.bpm);
5480      }
5481      for (const sound of content.sounds) triggerSound(sound);
5482      for (const bubble of content.bubbles) updateBubble(bubble);
5483      if (content.farts) for (const fart of content.farts) updateFart(fart);
5484      if (content.organics) for (const o of content.organics) updateOrganic(o);
5485      for (const item of content.kills) killSound(item.id, item.fade);
5486    }
5487
5488    if (
5489      (!triggerSound || audioContext?.state !== "running") &&
5490      (sound.bpm !== content.bpm ||
5491        content.sounds.length > 0 ||
5492        content.bubbles.length > 0 ||
5493        (content.farts && content.farts.length > 0) ||
5494        (content.organics && content.organics.length > 0) ||
5495        content.kills.length > 0)
5496    ) {
5497      activatedSoundCallback = beat;
5498      // 📓 Hold a single update frame if audio cuts out or is just beginning.
5499      return;
5500    } else beat();
5501  }
5502
5503  // Update & Render
5504  let frameAlreadyRequested = false;
5505
5506  // 🛑 Frame pump stall watchdog
5507  // If bios requests a frame and the worker never replies with render/update,
5508  // the UI can appear to freeze silently. Detect that and surface an error.
5509  // 🚀 OPTIMIZED: Uses frame counting instead of setTimeout/clearTimeout per frame
5510  const FRAME_STALL_FRAMES = 480; // ~8 seconds at 60fps
5511  let frameStallArmedAt = 0;
5512  let frameStallArmedFrame = 0n;
5513  let framesSinceLastRender = 0;
5514
5515  function clearFrameStallWatchdog() {
5516    framesSinceLastRender = 0;
5517  }
5518
5519  function showFrameStallOverlay(message) {
5520    // Minimal DOM fallback so the user sees *something* even if the worker is dead.
5521    try {
5522      const existing = document.getElementById("ac-stall-overlay");
5523      if (existing) existing.remove();
5524      const overlay = document.createElement("div");
5525      overlay.id = "ac-stall-overlay";
5526      overlay.style.position = "fixed";
5527      overlay.style.inset = "0";
5528      overlay.style.zIndex = "2147483647";
5529      overlay.style.background = "rgba(0,0,0,0.92)";
5530      overlay.style.color = "white";
5531      overlay.style.font = "14px/1.35 monospace";
5532      overlay.style.padding = "16px";
5533      overlay.style.whiteSpace = "pre-wrap";
5534      overlay.textContent = message;
5535      document.body.appendChild(overlay);
5536    } catch (e) {
5537      // Ignore overlay failures.
5538    }
5539  }
5540
5541  function triggerFrameStall(message) {
5542    if (window.acFRAME_STALL_TRIPPED) return;
5543    window.acFRAME_STALL_TRIPPED = true;
5544
5545    console.error("🛑 BIOS frame pump stalled:", message);
5546    showFrameStallOverlay(message);
5547
5548    // Release the latch so the main loop can keep pumping if possible.
5549    frameAlreadyRequested = false;
5550    clearFrameStallWatchdog();
5551
5552    // Attempt to route to a dedicated error piece (MatrixChunky8) if the worker is alive.
5553    try {
5554      const encoded = encodeURIComponent(message);
5555      send({
5556        type: "jump",
5557        content: {
5558          piece: `error~${encoded}`,
5559          ahistorical: true,
5560          alias: true,
5561        },
5562      });
5563    } catch (e) {
5564      // If the worker is dead/unresponsive, the DOM overlay remains.
5565    }
5566  }
5567
5568  function armFrameStallWatchdog() {
5569    // 🚀 OPTIMIZED: Just increment counter, check threshold
5570    if (document.visibilityState && document.visibilityState !== "visible") {
5571      framesSinceLastRender = 0;
5572      return;
5573    }
5574    
5575    framesSinceLastRender++;
5576    
5577    if (framesSinceLastRender >= FRAME_STALL_FRAMES && frameAlreadyRequested) {
5578      const piece = typeof currentPiece === "string" ? currentPiece : "(unknown piece)";
5579      triggerFrameStall(
5580        `SYSTEM STALL\n\nNo render/update from disk after requesting a frame.\n\nPiece: ${piece}\nFrame: ${String(frameStallArmedFrame)}\nWaited: ~${Math.round(framesSinceLastRender / 60)}s`,
5581      );
5582    }
5583  }
5584
5585  function requestFrame(needsRender, updateCount, nowUpdate) {
5586    now = nowUpdate;
5587
5588    // Reframe should happen immediately when needed, not wait for render timing
5589    if (needsReframe) {
5590      frame(undefined, undefined, lastGap);
5591      pen?.retransformPosition();
5592      return;
5593    }
5594
5595    if (frameAlreadyRequested) return;
5596
5597    frameAlreadyRequested = true;
5598    frameCount += 1n;
5599    armFrameStallWatchdog();
5600
5601    // 📊 Update DOM stats overlay if enabled
5602    updateStatsOverlay();
5603
5604    // TODO: 📏 Measure performance of frame: test with different resolutions.
5605
5606    // console.log("Sending frame...", frameCount, performance.now())
5607
5608    // Grab a sample of any playing background music, calculate the frequency
5609    // and send as needed.
5610    let amplitude = 0;
5611    if (backgroundMusicEl.paused === false) {
5612      // Get the frequency data from the audio element
5613      analyser.getByteFrequencyData(frequencyData);
5614
5615      // Calculate the maximum amplitude in the frequency data for this period.
5616      for (let i = 0; i < frequencyData.length; i += 1) {
5617        if (frequencyData[i] > amplitude) {
5618          amplitude = frequencyData[i];
5619        }
5620      }
5621    } else if (resolution.spoofaudio) {
5622      // Synthetic audio for headless captures (oven) — slow sine wave
5623      const t = performance.now() / 1000;
5624      amplitude = Math.round(((Math.sin(t * Math.PI * 1.0) + 1) / 2) * 200);
5625    }
5626
5627    // Transferrable objects
5628    let transferrableObjects = [];
5629    let pixelsBuffer = null;
5630    
5631    // Only transfer pixels buffer if it's not detached
5632    try {
5633      if (screen.pixels.buffer.byteLength > 0) {
5634        transferrableObjects = [screen.pixels.buffer];
5635        pixelsBuffer = screen.pixels.buffer;
5636      } else {
5637        // Buffer is detached, create a fresh copy from imageData
5638        if (imageData && imageData.data && imageData.data.buffer.byteLength > 0) {
5639          pixelsBuffer = imageData.data.slice().buffer;
5640        } else {
5641          // Last resort: create empty buffer with correct size
5642          const emptyPixels = new Uint8ClampedArray(canvas.width * canvas.height * 4);
5643          pixelsBuffer = emptyPixels.buffer;
5644        }
5645        transferrableObjects = [pixelsBuffer];
5646      }
5647    } catch (e) {
5648      // If we can't access the buffer at all, create a fresh copy from imageData or empty buffer
5649      try {
5650        if (imageData && imageData.data && imageData.data.buffer.byteLength > 0) {
5651          pixelsBuffer = imageData.data.slice().buffer;
5652        } else {
5653          // Last resort: create empty buffer with correct size
5654          const emptyPixels = new Uint8ClampedArray(canvas.width * canvas.height * 4);
5655          pixelsBuffer = emptyPixels.buffer;
5656        }
5657        transferrableObjects = [pixelsBuffer];
5658      } catch (fallbackError) {
5659        console.error("Failed to create fallback buffer:", fallbackError);
5660        // Create minimal empty buffer as absolute last resort
5661        const emptyPixels = new Uint8ClampedArray(64 * 64 * 4); // 64x64 fallback
5662        pixelsBuffer = emptyPixels.buffer;
5663        transferrableObjects = [pixelsBuffer];
5664      }
5665    }
5666
5667    // TODO: Eventually make frequencyData transferrable?
5668    // if (frequencyData) {
5669    // transferrableObjects.push(frequencyData.buffer);
5670    // }
5671
5672    send(
5673      {
5674        type: "frame",
5675        content: {
5676          needsRender,
5677          updateCount,
5678          pixels: pixelsBuffer,
5679          audioTime: audioContext?.currentTime || 0,
5680          audioBpm: sound.bpm, // TODO: Turn this into a messaging thing.
5681          audioMusicAmplitude: amplitude,
5682          audioMusicSampleData: amplitude > 0 ? frequencyData : [],
5683          width: canvas.width,
5684          height: canvas.height,
5685          // TODO: Do all fields of `pointer` need to be sent? 22.09.19.23.30
5686          pen: { 
5687            events: resolution.tv ? [] : (pen?.events || []), // Skip pen events in TV mode
5688            pointers: pen?.pointers || {} 
5689          },
5690          pen3d: ThreeD?.pollControllers(), // TODO: Implement pointers in 3D.
5691          hand: handData,
5692          keyboard: keyboard.events,
5693          gamepad: gamepad.events,           // Keep for backwards compat
5694          gamepads: gamepad.eventsByGamepad, // NEW: Separate streams per gamepad
5695          paused: window.__acLoopPaused || false, // Send loop paused state to worker
5696          // clipboardText: pastedText,
5697        },
5698      },
5699      transferrableObjects,
5700    );
5701
5702    // if (Object.keys(pen.pointers).length > 1) {
5703    //   console.log(pen.events, pen.pointers);
5704    // }
5705
5706    //pastedText = undefined; // Clear any pasted text.
5707
5708    pen?.updatePastPositions();
5709
5710    // Time budgeting stuff...
5711    //const updateDelta = performance.now() - updateNow;
5712    //console.log("Update Budget: ", round((updateDelta / updateRate) * 100));
5713    // TODO: Output this number graphically.
5714
5715    // const renderNow = performance.now();
5716    // const renderDelta = performance.now() - renderNow;
5717    // console.log("Render Budget: ", round((renderDelta / renderRate) * 100));
5718
5719    // TODO: Output this number graphically.
5720
5721    //render3d();
5722    // Clear pen events.
5723    if (pen) pen.events.length = 0;
5724    if (ThreeD?.penEvents) ThreeD.penEvents.length = 0;
5725
5726    // Clear keyboard and gamepad events.
5727    keyboard.events.length = 0;
5728    gamepad.clearEvents();
5729  }
5730
5731  let frameCached = false;
5732  let pixelsDidChange = false; // TODO: Can this whole thing be removed? 2021.11.28.03.50
5733
5734  
5735  let contentFrame;
5736  let ticketWrapper;
5737  let underlayFrame;  // const bakedCan = document.createElement("canvas", {
5738  //  willReadFrequently: true,
5739  // });
5740  let tapeManager; // Multi-tape manager for smooth transitions (tv.mjs)
5741  let mp4PlaybackVideo; // DOM <video> for kind:"mp4" tape playback (camera-roll clips)
5742
5743    async function reinitAudioSystem(options = {}) {
5744      const { latencyHint, sampleRate, speakerPerformanceMode, requestId = 0 } = options;
5745
5746      if (latencyHint !== undefined) {
5747        window.__acLatencyHint = latencyHint;
5748      }
5749      if (Number.isFinite(sampleRate)) {
5750        window.__acSampleRate = sampleRate;
5751      }
5752      if (speakerPerformanceMode) {
5753        window.__acSpeakerPerformanceMode = speakerPerformanceMode;
5754      }
5755
5756      try {
5757        speakerProcessorNode?.port?.postMessage?.({ type: "kill:all" });
5758      } catch (e) {
5759        console.warn("🎵 Audio reinit: failed to kill sounds", e);
5760      }
5761
5762      while (audioStarting) {
5763        await new Promise((resolve) => setTimeout(resolve, 50));
5764      }
5765
5766      try {
5767        analyserSrc?.disconnect?.();
5768        analyser?.disconnect?.();
5769        await analyserCtx?.close?.();
5770      } catch (e) {
5771        console.warn("🎵 Audio reinit: failed to close analyser context", e);
5772      }
5773
5774      try {
5775        if (backgroundMusicEl) {
5776          backgroundMusicEl.pause?.();
5777          backgroundMusicEl.src = "";
5778          backgroundMusicEl.remove?.();
5779        }
5780      } catch (e) {
5781        console.warn("🎵 Audio reinit: failed to reset background music", e);
5782      }
5783
5784      backgroundMusicEl = document.createElement("audio");
5785      backgroundMusicEl.id = "background-music";
5786      backgroundMusicEl.crossOrigin = "anonymous";
5787      wrapper.appendChild(backgroundMusicEl);
5788      currentBackgroundTrack = null;
5789
5790      try {
5791        if (audioContext) {
5792          await audioContext.close();
5793        }
5794      } catch (e) {
5795        console.warn("🎵 Audio reinit: failed to close AudioContext", e);
5796      }
5797
5798      audioContext = null;
5799      speakerProcessorNode = null;
5800      window.audioWorkletReady = false;
5801
5802      window.__acForceStartSound = true;
5803      startSound();
5804      window.__acForceStartSound = false;
5805
5806      // Wait for the worklet to be fully initialized before returning
5807      const maxWaitMs = 5000;
5808      const startWait = performance.now();
5809      while (!window.audioWorkletReady && performance.now() - startWait < maxWaitMs) {
5810        await new Promise((resolve) => setTimeout(resolve, 20));
5811      }
5812      
5813      if (!window.audioWorkletReady) {
5814        console.error("🎵 Audio reinit: Timed out waiting for worklet to initialize");
5815      } else {
5816        console.log("🎵 Audio reinit complete, sample rate:", audioContext?.sampleRate);
5817        acDISK_SEND({
5818          type: "audio:reinit-complete",
5819          content: {
5820            requestId,
5821            requestedSampleRate: Number.isFinite(sampleRate) ? sampleRate : audioContext?.sampleRate,
5822            sampleRate: audioContext?.sampleRate,
5823            base: (Number(audioContext?.baseLatency) || 0) * 1000,
5824            output: (Number(audioContext?.outputLatency) || 0) * 1000,
5825            quantum: audioContext?.sampleRate ? 128 / audioContext.sampleRate * 1000 : 0,
5826          },
5827        });
5828      }
5829    }
5830
5831  // 🔒 Show a permission modal for untrusted piece requests (from worker)
5832  function showPermissionModal(pieceCode, permission, details = {}) {
5833    return new Promise((resolve) => {
5834      const messages = {
5835        network: {
5836          title: "Network Access",
5837          message: `The piece "${pieceCode}" wants to make network requests.`,
5838          warning: "This allows the piece to send data to external servers.",
5839          details: details.url ? `URL: ${details.url}` : null,
5840        },
5841        auth: {
5842          title: "Authentication Access",
5843          message: `The piece "${pieceCode}" wants to access your authentication tokens.`,
5844          warning: "This gives the piece access to your account credentials.",
5845          details: null,
5846        },
5847        "storage-full": {
5848          title: "Full Storage Access",
5849          message: `The piece "${pieceCode}" wants to access all stored data.`,
5850          warning: "This allows the piece to read data from other pieces.",
5851          details: null,
5852        },
5853        "upload-external": {
5854          title: "External Upload",
5855          message: `The piece "${pieceCode}" wants to upload a file to an external server.`,
5856          warning: "The file will be sent outside aesthetic.computer.",
5857          details: details.url ? `Destination: ${details.url}` : null,
5858        },
5859        "navigate-external": {
5860          title: "External Navigation",
5861          message: `The piece "${pieceCode}" wants to navigate to an external URL.`,
5862          warning: "You will leave aesthetic.computer.",
5863          details: details.url ? `URL: ${details.url}` : null,
5864        },
5865      };
5866
5867      const config = messages[permission] || {
5868        title: "Permission Request",
5869        message: `The piece "${pieceCode}" wants permission for: ${permission}`,
5870        warning: null,
5871        details: null,
5872      };
5873
5874      const modal = document.createElement("div");
5875      modal.style.cssText = `
5876        position: fixed; top: 0; left: 0; width: 100%; height: 100%;
5877        background: rgba(0, 0, 0, 0.8); display: flex; align-items: center;
5878        justify-content: center; z-index: 100000; font-family: sans-serif;
5879      `;
5880
5881      const dialog = document.createElement("div");
5882      dialog.style.cssText = `
5883        background: rgb(235, 235, 235); color: black; padding: 24px;
5884        max-width: 400px; border: 2px solid black;
5885      `;
5886
5887      const titleEl = document.createElement("h2");
5888      titleEl.textContent = config.title;
5889      titleEl.style.cssText = "margin: 0 0 16px 0; font-size: 18px; font-weight: bold;";
5890
5891      const messageEl = document.createElement("p");
5892      messageEl.textContent = config.message;
5893      messageEl.style.cssText = "margin: 0 0 12px 0; line-height: 1.4;";
5894
5895      dialog.appendChild(titleEl);
5896      dialog.appendChild(messageEl);
5897
5898      if (config.warning) {
5899        const warningEl = document.createElement("p");
5900        warningEl.textContent = config.warning;
5901        warningEl.style.cssText = "margin: 0 0 12px 0; color: rgb(200, 0, 0); font-weight: bold;";
5902        dialog.appendChild(warningEl);
5903      }
5904
5905      if (config.details) {
5906        const detailsEl = document.createElement("p");
5907        detailsEl.textContent = config.details;
5908        detailsEl.style.cssText = "margin: 0 0 12px 0; font-family: monospace; font-size: 12px; word-break: break-all;";
5909        dialog.appendChild(detailsEl);
5910      }
5911
5912      const buttonContainer = document.createElement("div");
5913      buttonContainer.style.cssText = "display: flex; gap: 12px; margin-top: 20px;";
5914
5915      const allowButton = document.createElement("button");
5916      allowButton.textContent = "Allow";
5917      allowButton.style.cssText = `
5918        flex: 1; padding: 10px; background: black; color: white;
5919        border: none; cursor: pointer; font-size: 14px; font-weight: bold;
5920      `;
5921
5922      const denyButton = document.createElement("button");
5923      denyButton.textContent = "Deny";
5924      denyButton.style.cssText = `
5925        flex: 1; padding: 10px; background: white; color: black;
5926        border: 2px solid black; cursor: pointer; font-size: 14px; font-weight: bold;
5927      `;
5928
5929      allowButton.addEventListener("click", () => {
5930        document.body.removeChild(modal);
5931        resolve(true);
5932      });
5933
5934      denyButton.addEventListener("click", () => {
5935        document.body.removeChild(modal);
5936        resolve(false);
5937      });
5938
5939      buttonContainer.appendChild(allowButton);
5940      buttonContainer.appendChild(denyButton);
5941      dialog.appendChild(buttonContainer);
5942      modal.appendChild(dialog);
5943      document.body.appendChild(modal);
5944      denyButton.focus();
5945    });
5946  }
5947
5948  // 📦 Make a hot update feel intentional: a tiny ascending chord and warm
5949  // visual pulse, then `graceMs` of room for Xbox/TV output buffers to play it
5950  // before the page goes. Both ways in — the badge's checkbox and an update
5951  // arriving with auto-reload already on — land here so they sound alike.
5952  function armUpdateReload(graceMs, quiet = false) {
5953    if (window.acUpdateReloadTimer) clearTimeout(window.acUpdateReloadTimer);
5954    // A chime and a full-screen flash are how a person is told an update is
5955    // about to land. A preview card has nobody to tell, so it skips the cue
5956    // rather than raising one — this is not an error path.
5957    if (quiet) {
5958      window.acUpdateReloadTimer = setTimeout(() => window.location.reload(), graceMs);
5959      return;
5960    }
5961    try {
5962      const now = audioContext?.currentTime;
5963      if (audioContext && Number.isFinite(now)) {
5964        [523.25, 659.25, 783.99].forEach((frequency, index) => {
5965          const oscillator = audioContext.createOscillator();
5966          const gain = audioContext.createGain();
5967          oscillator.type = index === 2 ? "triangle" : "sine";
5968          oscillator.frequency.value = frequency;
5969          gain.gain.setValueAtTime(0.0001, now + index * 0.055);
5970          gain.gain.exponentialRampToValueAtTime(0.085, now + index * 0.055 + 0.012);
5971          gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.55 + index * 0.055);
5972          oscillator.connect(gain).connect(speakerGain || audioContext.destination);
5973          oscillator.start(now + index * 0.055);
5974          oscillator.stop(now + 0.65 + index * 0.055);
5975        });
5976      }
5977      const reduced = window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches;
5978      const flash = document.createElement("div");
5979      Object.assign(flash.style, {
5980        position: "fixed", inset: "0", zIndex: "2147483647",
5981        pointerEvents: "none", background: "rgba(255,210,40,.48)",
5982        transition: reduced ? "opacity .35s ease" : "background .16s ease, opacity .5s ease",
5983      });
5984      document.body.appendChild(flash);
5985      requestAnimationFrame(() => {
5986        if (!reduced) flash.style.background = "rgba(255,70,45,.42)";
5987      });
5988      setTimeout(() => { flash.style.opacity = "0"; }, reduced ? 180 : 360);
5989    } catch (error) {
5990      console.warn("📦 Update awareness cue unavailable:", error);
5991    }
5992    window.acUpdateReloadTimer = setTimeout(
5993      () => window.location.reload(),
5994      graceMs,
5995    );
5996  }
5997
5998  // *** Received Frame ***
5999  async function receivedChange({ data: { type, content } }) {
6000    // 🔍 DEBUG: Log ALL incoming messages to trace what's being received
6001    if (DEBUG_MESSAGE_FLOW && type !== "frame") {
6002      console.log(`🔍 BIOS receivedChange: type="${type}"`);
6003    }
6004
6005    // 🔬 Arena perf snapshot — pieces inside the worker can't touch `window`,
6006    // so arena.mjs batches its perf ring through send(); we mirror the latest
6007    // batch onto window.__arena_perfStats so external probes can read it via
6008    // CDP Runtime.evaluate without needing main-thread instrumentation.
6009    if (type === "perf:arena") {
6010      window.__arena_perfStats_latest = content;
6011      if (typeof window.__arena_perfStats !== "function") {
6012        window.__arena_perfStats = function () {
6013          return window.__arena_perfStats_latest || { samples: [], meta: { absent: true } };
6014        };
6015      }
6016      return;
6017    }
6018
6019    // Relay boot-log messages to parent window and update the overlay
6020    if (type === "boot-log") {
6021      // console.log("📢 BIOS relaying boot-log:", content);
6022      // Start the watchdog when the worker first responds (signals it's alive)
6023      if (content === "worker received load" && window.acStartWatchdog) {
6024        window.acStartWatchdog();
6025      }
6026      // Update the boot log overlay
6027      if (window.acBOOT_LOG) {
6028        window.acBOOT_LOG(content);
6029      }
6030      // Also relay to parent for embedded contexts
6031      if (window.parent) {
6032        window.parent.postMessage({ type: "boot-log", message: content }, "*");
6033      }
6034      return;
6035    }
6036    
6037    // 📄 Boot file content - display source in boot canvas
6038    if (type === "boot-file") {
6039      // NOTE: Rendering/tokenizing source for boot UI can be surprisingly expensive in some contexts.
6040      // Keep it opt-in to avoid freezing the main thread during piece loads.
6041      const DEBUG_BOOT_FILES = window.acDEBUG_BOOT_FILES === true;
6042
6043      if (DEBUG_BOOT_FILES) {
6044        console.log(
6045          `🔍 BIOS: Received boot-file for "${content?.filename}", source length = ${content?.source?.length}`,
6046        );
6047      }
6048
6049      if (DEBUG_BOOT_FILES && window.acBOOT_ADD_FILE && content?.filename && content?.source) {
6050        // Use setTimeout to make this non-blocking - prevents hangs from tokenizer regex
6051        setTimeout(() => {
6052          try {
6053            if (DEBUG_BOOT_FILES) {
6054              console.log(`🔍 BIOS: boot-file setTimeout executing for "${content?.filename}"`);
6055            }
6056            window.acBOOT_ADD_FILE(content.filename, content.source);
6057            if (DEBUG_BOOT_FILES) {
6058              console.log(`🔍 BIOS: boot-file acBOOT_ADD_FILE completed for "${content?.filename}"`);
6059            }
6060          } catch (e) {
6061            console.warn("boot-file display error:", e);
6062          }
6063        }, 0);
6064      }
6065      return;
6066    }
6067
6068    // 📊 Receive disk worker timing data (silent)
6069    if (type === "disk-timings") {
6070      window.acDiskTimings = content;
6071      return;
6072    }
6073
6074    // 🔒 Permission request from worker (piece-permissions.mjs)
6075    if (type === "permission-request") {
6076      const { requestId, pieceCode, permission, details } = content;
6077      showPermissionModal(pieceCode, permission, details).then((granted) => {
6078        send({ type: "permission-response", content: { requestId, granted } });
6079      });
6080      return;
6081    }
6082
6083    // 🛑 Watchdog pong response from worker
6084    if (type === "watchdog:pong") {
6085      if (window.acHandleWatchdogPong) window.acHandleWatchdogPong();
6086      return;
6087    }
6088
6089    // 🎹 Debug messages from disk worker (DAW sync debugging)
6090    if (type === "disk:debug") {
6091      console.log("🎹 BIOS received disk:debug from worker:", content);
6092      return;
6093    }
6094
6095    // 🔊 Debug log messages from worker (visible in console tunnel)
6096    if (type === "debug:log") {
6097      console.log("🔊 WORKER LOG:", content?.msg);
6098      return;
6099    }
6100
6101    // 🎸 Pedal effect control messages from worker
6102    if (type === "pedal:effect") {
6103      if (window.acPedalSetEffect) {
6104        window.acPedalSetEffect(content);
6105      }
6106      return;
6107    }
6108    
6109    // 🎸 Start sample source for M4L effect mode
6110    if (type === "pedal:start-sample-source") {
6111      if (audioContext && window.acReadSamples) {
6112        console.log("🎸 Starting sample source from Max...");
6113        
6114        // Create a ScriptProcessorNode to read from the ring buffer
6115        const BUFFER_SIZE = 256; // Small for low latency
6116        const sampleProcessor = audioContext.createScriptProcessor(BUFFER_SIZE, 0, 2);
6117        
6118        sampleProcessor.onaudioprocess = (e) => {
6119          const outputL = e.outputBuffer.getChannelData(0);
6120          const outputR = e.outputBuffer.getChannelData(1);
6121          
6122          // Read samples from the disk's ring buffer
6123          const samples = window.acReadSamples(BUFFER_SIZE);
6124          if (samples) {
6125            outputL.set(samples.left);
6126            outputR.set(samples.right);
6127          }
6128        };
6129        
6130        // Connect through the pedal effects chain if available
6131        if (window.acPedalConnectSource) {
6132          window.acPedalConnectSource(sampleProcessor);
6133        } else {
6134          // Fallback: connect directly to output
6135          const outputs = window.acPedalGetOutputNodes?.();
6136          if (outputs) {
6137            sampleProcessor.connect(outputs.sfxStreamGain);
6138            sampleProcessor.connect(outputs.speakerGain);
6139          } else {
6140            // Last resort: connect to sfxStreamGain and speakerGain directly
6141            sampleProcessor.connect(sfxStreamGain);
6142            sampleProcessor.connect(speakerGain);
6143          }
6144        }
6145        
6146        console.log("🎸 Sample source connected to audio output");
6147      }
6148      return;
6149    }
6150
6151    // 🔍 Debug state from pieces for CDP automation
6152    if (type === "debug:state") {
6153      window.acPieceState = content;
6154      return;
6155    }
6156
6157    // 🎹 DAW phase update - auto-resume AudioContext once for DAW mode
6158    if (type === "daw:phase:ack" && !dawAudioResumed) {
6159      if (audioContext && audioContext.state === "suspended") {
6160        dawAudioResumed = true;
6161        console.log("🎹 DAW phase: Auto-resuming AudioContext...");
6162        audioContext.resume().then(() => {
6163          console.log("🎹 AudioContext resumed from phase! State:", audioContext.state);
6164        }).catch(e => console.warn("🎹 AudioContext resume failed:", e));
6165      }
6166      return;
6167    }
6168    
6169    // 🎹 DAW sync acknowledgements from disk worker
6170    if (type === "daw:tempo:ack") {
6171      console.log("🎹🎹🎹 BIOS received daw:tempo:ack from worker! BPM:", content?.bpm);
6172      // 🎹 Auto-resume AudioContext for DAW mode (jweb~ has no user interaction)
6173      if (audioContext && audioContext.state === "suspended") {
6174        console.log("🎹 DAW mode: Auto-resuming AudioContext...");
6175        audioContext.resume().then(() => {
6176          console.log("🎹 AudioContext resumed for DAW mode! State:", audioContext.state);
6177        }).catch(e => console.warn("🎹 AudioContext resume failed:", e));
6178      }
6179      return;
6180    }
6181    if (type === "daw:transport:ack") {
6182      console.log("🎹🎹🎹 BIOS received daw:transport:ack from worker! Playing:", content?.playing);
6183      // 🎹 Auto-resume AudioContext for DAW mode (jweb~ has no user interaction)
6184      if (audioContext && audioContext.state === "suspended") {
6185        console.log("🎹 DAW mode: Auto-resuming AudioContext (from transport)...");
6186        audioContext.resume().then(() => {
6187          console.log("🎹 AudioContext resumed for DAW mode! State:", audioContext.state);
6188        }).catch(e => console.warn("🎹 AudioContext resume failed:", e));
6189      }
6190      return;
6191    }
6192
6193    // 🎹 MIDI note emit from a worker piece → Max (jweb~ main-thread bridge).
6194    // Pieces running inside an M4L device's jweb~ send
6195    //   send({ type: "daw:midi", content: { pitch, velocity, channel? } })
6196    // and BIOS forwards to `window.max.outlet` which the patcher routes via
6197    // [route note channel] → [noteout].
6198    if (type === "daw:midi" && content) {
6199      if (typeof window !== "undefined" && window.max && typeof window.max.outlet === "function") {
6200        const pitch = Number(content.pitch);
6201        const velocity = Number(content.velocity);
6202        const channel = Number.isFinite(Number(content.channel)) ? Number(content.channel) : 0;
6203        if (!Number.isFinite(pitch) || !Number.isFinite(velocity)) return;
6204        try { window.max.outlet("channel", channel); } catch (_e) {}
6205        try { window.max.outlet("note", pitch, velocity); } catch (_e) {}
6206      }
6207      return;
6208    }
6209
6210    // 🎹 Piece → Max: toggle Live's transport (play/pause). Used in
6211    // place of the spacebar-shortcut path because Live's host window
6212    // claims spacebar as a global hotkey before the jweb iframe ever
6213    // sees the keydown — so the piece dispatches transport from a UI
6214    // button (or any non-spacebar input) and we forward to Max here.
6215    // Patcher routes `transport` to v8.codebox `function transport()`,
6216    // which calls live_set.start_playing / stop_playing.
6217    if (type === "daw:transport") {
6218      if (typeof window !== "undefined" && window.max?.outlet) {
6219        try { window.max.outlet("transport", 1); } catch (_e) {}
6220      }
6221      return;
6222    }
6223
6224    // 🎹 Piece → Max: ask max to re-emit state values whose initial
6225    // fire happened before the piece mounted its handlers (focus,
6226    // track color). Matches the `route ready goonline requestTrackColor
6227    // requestFocus …` outlets on the patcher's route-top.
6228    if (type === "daw:request-focus") {
6229      if (typeof window !== "undefined" && window.max?.outlet) {
6230        try { window.max.outlet("requestFocus", 1); } catch (_e) {}
6231      }
6232      return;
6233    }
6234    if (type === "daw:request-track-color") {
6235      if (typeof window !== "undefined" && window.max?.outlet) {
6236        try { window.max.outlet("requestTrackColor", 1); } catch (_e) {}
6237      }
6238      return;
6239    }
6240    // Piece asks bios for the current build env (pack mode, git hash,
6241    // latest commit on the server for staleness checks).
6242    if (type === "env:request") {
6243      if (typeof window !== "undefined" && typeof window.acDawEnvRequest === "function") {
6244        try { window.acDawEnvRequest(); } catch (_e) {}
6245      }
6246      return;
6247    }
6248
6249    // Helper function to generate appropriate filenames for tape recordings
6250    function generateTapeFilename(extension, suffix = "") {
6251      const options = window.currentRecordingOptions || {
6252        pieceName: "tape",
6253        pieceParams: "",
6254        originalCommand: ""
6255      };
6256      
6257      // Debug logging for filename issues
6258      console.log("🎬 generateTapeFilename debug:", {
6259        extension,
6260        suffix,
6261        options,
6262        baseName: options.pieceName,
6263        cachedCode: options.cachedCode,
6264        HANDLE,
6265        location: location?.host || "unknown"
6266      });
6267      
6268      let baseName = options.pieceName || "tape";
6269      // Handle special cases for kidlisp codes
6270      if (baseName === "$code") {
6271        // For kidlisp source code, use the cached code if available
6272        if (options.cachedCode) {
6273          baseName = `$${options.cachedCode}`;
6274        } else {
6275          // Try to extract a $code from the original command as fallback
6276          const codeMatch = options.originalCommand.match(/\$([a-zA-Z0-9]+)/);
6277          if (codeMatch) {
6278            baseName = `$${codeMatch[1]}`;
6279          } else {
6280            // No $code available, use "kidlisp" instead of literal "$code"
6281            baseName = "kidlisp";
6282          }
6283        }
6284      } else if (baseName.startsWith("$") && options.cachedCode) {
6285        // If baseName is already a $code (like $erl) and we have cached code,
6286        // don't apply the cached code again to avoid duplication
6287      } else if (baseName === options.cachedCode) {
6288        // If baseName matches cachedCode exactly (like "clock" === "clock"),
6289        // don't apply cachedCode again to avoid duplication like "clockclock"
6290      }
6291      // Note: If pieceName already has $ prefix (from cached code), use it as-is
6292      
6293      // Add parameters if they exist
6294      const params = options.pieceParams || "";
6295      const paramsStr = params ? params.replace(/~/g, "-") : "";
6296      
6297      // Include user handle if available for personalized filenames
6298      // Use cached computed timestamp for perfect filename/visual synchronization,
6299      // otherwise fall back to recording start timestamp or current timestamp
6300      let fileTimestamp;
6301      let durationPart = "";
6302      
6303      if (window.firstFrameComputedTimestamp && extension === "gif") {
6304        // For GIF recordings, use the exact same computed timestamp as shown in the first frame
6305        // Use the same formatting as the visual timestamp (no zero-padding except milliseconds)
6306        const d = new Date(window.firstFrameComputedTimestamp);
6307        const year = d.getFullYear();
6308        const month = d.getMonth() + 1; // getMonth() returns 0-11
6309        const day = d.getDate();
6310        const hour = d.getHours();
6311        const minute = d.getMinutes();
6312        const second = d.getSeconds();
6313        const millisecond = d.getMilliseconds();
6314        
6315        // Match visual timestamp format exactly: no zero-padding except for milliseconds
6316        fileTimestamp = `${year}.${month}.${day}.${hour}.${minute}.${second}.${millisecond.toString().padStart(3, "0")}`;
6317        
6318        // Check if this was a frame-based recording (check frameMode first, then fallback to gifDurationMs)
6319        const isFrameBased = window.currentRecordingOptions && 
6320                            window.currentRecordingOptions.frameMode && 
6321                            window.currentRecordingOptions.frameCount;
6322        
6323        if (isFrameBased) {
6324          // For frame-based recordings, use frame count with 'f' suffix
6325          const frameCount = window.currentRecordingOptions.frameCount;
6326          durationPart = `-${frameCount}f`;
6327        } else if (window.gifDurationMs) {
6328          // For time-based recordings, use seconds with 's' suffix
6329          const totalSeconds = Math.round(window.gifDurationMs / 1000 * 10) / 10; // Round to 1 decimal place
6330          durationPart = `-${totalSeconds}s`;
6331        }
6332        
6333        console.log(`🎬 Using cached computed timestamp for GIF filename: ${d.toISOString()}${durationPart ? ` (duration: ${durationPart})` : ''}`);
6334      } else if (window.recordingStartTimestamp && extension === "gif") {
6335        // Fallback to recording start timestamp if no cached computed timestamp
6336        // Use the same formatting as the visual timestamp (no zero-padding except milliseconds)
6337        const d = new Date(window.recordingStartTimestamp);
6338        const year = d.getFullYear();
6339        const month = d.getMonth() + 1;
6340        const day = d.getDate();
6341        const hour = d.getHours();
6342        const minute = d.getMinutes();
6343        const second = d.getSeconds();
6344        const millisecond = d.getMilliseconds();
6345        
6346        // Match visual timestamp format exactly: no zero-padding except for milliseconds
6347        fileTimestamp = `${year}.${month}.${day}.${hour}.${minute}.${second}.${millisecond.toString().padStart(3, "0")}`;
6348        
6349        // Check if this was a frame-based recording (check frameMode first, then fallback to gifDurationMs)
6350        const isFrameBased = window.currentRecordingOptions && 
6351                            window.currentRecordingOptions.frameMode && 
6352                            window.currentRecordingOptions.frameCount;
6353        
6354        if (isFrameBased) {
6355          // For frame-based recordings, use frame count with 'f' suffix
6356          const frameCount = window.currentRecordingOptions.frameCount;
6357          durationPart = `-${frameCount}f`;
6358        } else if (window.gifDurationMs) {
6359          // For time-based recordings, use seconds with 's' suffix
6360          const totalSeconds = Math.round(window.gifDurationMs / 1000 * 10) / 10; // Round to 1 decimal place
6361          durationPart = `-${totalSeconds}s`;
6362        }
6363        
6364        console.log(`🎬 Using recording start timestamp for GIF filename: ${d.toISOString()}${durationPart ? ` (duration: ${durationPart})` : ''}`);
6365      } else {
6366        // For other files or when no recording timestamp is available
6367        // Add safety check for longer recordings
6368        try {
6369          fileTimestamp = timestamp();
6370        } catch (error) {
6371          console.warn("⚠️ Timestamp generation failed, using simple fallback:", error.message);
6372          const now = new Date();
6373          fileTimestamp = `${now.getFullYear()}.${now.getMonth()+1}.${now.getDate()}.${now.getHours()}.${now.getMinutes()}.${now.getSeconds()}`;
6374        }
6375      }
6376      
6377      // Assemble filename parts: [@handle-][pieceName[params]-]timestamp-duration[suffix].extension
6378      // Skip command entirely in mystery mode
6379      const parts = [];
6380      
6381      // Add handle prefix if user has one
6382      if (HANDLE && typeof HANDLE === 'string' && HANDLE.length > 0) {
6383        console.log(`🎬 Adding handle to filename: "${HANDLE}"`);
6384        // Ensure handle doesn't already have @ prefix to avoid @@
6385        const handlePrefix = HANDLE.startsWith('@') ? HANDLE : `@${HANDLE}`;
6386        parts.push(handlePrefix);
6387      }
6388      
6389      // Add piece name with parameters (skip entirely if mystery mode)
6390      if (!options.mystery && baseName && typeof baseName === 'string') {
6391        console.log(`🎬 Adding piece name to filename: "${baseName}" (params: "${paramsStr}")`);
6392        parts.push(baseName + paramsStr);
6393      } else if (options.mystery) {
6394        console.log(`🎬 Skipping piece name due to mystery mode`);
6395      } else {
6396        console.log(`🎬 Skipping piece name - baseName: "${baseName}", type: ${typeof baseName}`);
6397      }
6398      
6399      // Add timestamp with duration - ensure these are valid strings
6400      if (fileTimestamp && typeof fileTimestamp === 'string') {
6401        parts.push(fileTimestamp + durationPart + suffix);
6402      } else {
6403        // Emergency fallback timestamp
6404        const emergency = Date.now().toString();
6405        console.warn("⚠️ Using emergency timestamp fallback:", emergency);
6406        parts.push(emergency + suffix);
6407      }
6408      
6409      // Final safety check and assembly
6410      const validParts = parts.filter(part => part && typeof part === 'string' && part.length > 0);
6411      if (validParts.length === 0) {
6412        console.warn("⚠️ No valid filename parts, using emergency fallback");
6413        return `tape-${Date.now()}.${extension}`;
6414      }
6415      
6416      return validParts.join("-") + "." + extension;
6417    }
6418
6419    if (type === "pen:lock") {
6420      // console.log("🖋️ Request pen lock...");
6421      wrapper.requestPointerLock();
6422      return;
6423    }
6424
6425    // 🎮 Handle Game Boy input from worker
6426    if (type === "gameboy:input") {
6427      handleGameboyInput(content);
6428      return;
6429    }
6430
6431    // 🎮 Handle ROM loading request from disk (e.g., slgb piece)
6432    if (type === "gameboy:load-rom") {
6433      console.log("🎮 BIOS received ROM load request:", content);
6434      loadGameboyROM(content);
6435      return;
6436    }
6437
6438    if (type === "usb:connect") {
6439      USB.connectToUsb();
6440      return;
6441    }
6442
6443    // USB flash support (Electron only)
6444    if (type === "usb:list-devices") {
6445      const isElectron = /Electron/i.test(navigator.userAgent || "");
6446      if (isElectron && window.acElectron?.listBlockDevices) {
6447        window.acElectron.listBlockDevices().then((result) => {
6448          send({ type: "usb:devices-listed", content: result });
6449        }).catch((err) => {
6450          send({ type: "usb:devices-listed", content: { success: false, error: err.message } });
6451        });
6452      } else {
6453        send({ type: "usb:devices-listed", content: { success: false, error: "Not running in Electron" } });
6454      }
6455      return;
6456    }
6457
6458    if (type === "usb:flash-image") {
6459      const isElectron = /Electron/i.test(navigator.userAgent || "");
6460      if (isElectron && window.acElectron?.flashImage) {
6461        const progressHandler = (e) => {
6462          send({ type: "usb:flash-progress", content: e.detail });
6463        };
6464        window.addEventListener('usb:flash-progress', progressHandler);
6465
6466        window.acElectron.flashImage(content).then((result) => {
6467          window.removeEventListener('usb:flash-progress', progressHandler);
6468          if (result.success) {
6469            send({ type: "usb:flash-complete", content: result });
6470          } else {
6471            send({ type: "usb:flash-error", content: { error: result.error } });
6472          }
6473        }).catch((err) => {
6474          window.removeEventListener('usb:flash-progress', progressHandler);
6475          send({ type: "usb:flash-error", content: { error: err.message } });
6476        });
6477      } else {
6478        send({ type: "usb:flash-error", content: { error: "Not running in Electron" } });
6479      }
6480      return;
6481    }
6482
6483    if (type === "midi:connect") {
6484      MIDI.initialize(); // Start 🎹 Detection.
6485      return;
6486    }
6487
6488    // Handle audio context resume request from video piece
6489    if (type === "audio-context:resume-request") {
6490      if (!window.acPACK_MODE) console.log("🎵 BIOS received audio context resume request:", content);
6491      if (
6492        audioContext &&
6493        ["suspended", "interrupted"].includes(audioContext.state)
6494      ) {
6495        if (!window.acPACK_MODE) console.log("🎵 BIOS resuming audio context from user gesture");
6496        resumeAudioContext("video-resume-request", {
6497          force: true,
6498          onResumed: () => {
6499            if (!audioContext) return;
6500            if (!window.acPACK_MODE)
6501              console.log(
6502                "🎵 BIOS audio context resumed, new state:",
6503                audioContext.state,
6504              );
6505            // Notify video piece of successful resume
6506            if (!window.acPACK_MODE)
6507              console.log(
6508                "🎵 BIOS sending updated AudioContext state after resume:",
6509                { state: audioContext.state, hasAudio: true },
6510              );
6511            send({
6512              type: "tape:audio-context-state",
6513              content: {
6514                state: audioContext.state,
6515                hasAudio: true,
6516              },
6517            });
6518          },
6519        });
6520      } else if (audioContext) {
6521        if (!window.acPACK_MODE) console.log("🎵 BIOS audio context already running:", audioContext.state);
6522        // Send current state back to video piece
6523        send({ 
6524          type: "tape:audio-context-state", 
6525          content: { 
6526            state: audioContext.state,
6527            hasAudio: true
6528          } 
6529        });
6530      } else {
6531        // console.log("🎵 BIOS no audio context available");
6532      }
6533      return;
6534    }
6535
6536
6537
6538    // Respond to a request to send a message through to the iMessage extension.
6539    if (type === "imessage-extension:send") {
6540      let body = content.body.pixels
6541        ? await bufferToBlob(content.body.pixels, undefined, {
6542            scale: 6,
6543            dataURL: true,
6544          })
6545        : content.body;
6546      const message = { type: content.type, body };
6547      const packedMessage = JSON.stringify(message);
6548      if (debug) console.log("🗨️ Sending to iMessage:", packedMessage);
6549      window.webkit?.messageHandlers?.iMessageExtension.postMessage(
6550        packedMessage,
6551      );
6552      return;
6553    }
6554
6555    if (type === "ios:send") {
6556      iOSAppSend({ type: content.type, body: content.body });
6557      return;
6558    }
6559
6560    // Window control requests from pieces (e.g., prompt.mjs running in worker)
6561    if (type === "window:open") {
6562      const url = content?.url || content?.href || content?.path;
6563      const index = content?.index ?? 0;
6564      const total = content?.total ?? 1;
6565      if (url) {
6566        const isElectron = /Electron/i.test(navigator.userAgent || "");
6567        // Use the Electron webview preload API if available (preferred)
6568        if (isElectron && window.acElectron?.openWindow) {
6569          console.log('[bios] Using acElectron.openWindow:', url, index, total);
6570          window.acElectron.openWindow({ url, index, total });
6571        } else if (isElectron) {
6572          // Fallback: window.open triggers setWindowOpenHandler in main.js
6573          console.log('[bios] Using window.open for new window:', url);
6574          try {
6575            window.open(url, "_blank");
6576          } catch (err) {
6577            console.warn('[bios] window.open failed:', err);
6578          }
6579        } else {
6580          // Browser: just open a new tab
6581          window.open(url, "_blank", "noopener");
6582        }
6583      }
6584      return;
6585    }
6586
6587    if (type === "window:reload") {
6588      window.location.reload();
6589      return;
6590    }
6591
6592    if (type === "window:close") {
6593      const isElectron = /Electron/i.test(navigator.userAgent || "");
6594      if (isElectron) {
6595        // Use the Electron webview preload API if available (preferred)
6596        if (window.acElectron?.closeWindow) {
6597          console.log('[bios] Using acElectron.closeWindow');
6598          window.acElectron.closeWindow();
6599        } else {
6600          // Fallback: window.open with ac://close triggers setWindowOpenHandler
6601          console.log('[bios] Using window.open ac://close');
6602          try {
6603            const result = window.open("ac://close", "_blank");
6604            if (!result) {
6605              // If popup blocked, try navigation (will-navigate handler catches it)
6606              location.href = "ac://close";
6607            }
6608          } catch (err) {
6609            location.href = "ac://close";
6610          }
6611        }
6612      } else {
6613        try {
6614          window.close();
6615        } catch (err) {
6616          // ignore
6617        }
6618      }
6619      return;
6620    }
6621
6622    // Post a message to a potential iframe parent, like in the VSCode extension.
6623    if (type === "post-to-parent") {
6624      // Note: kidlisp-code-created and setCode contain cached $identifiers (like "nece"),
6625      // not actual source code. The actual source is set via kidlisp-reload in boot.mjs.
6626      // if (debug) console.log("🏃‍♂️ Posting up to parent...", content);
6627      if (window.parent) window.parent.postMessage(content, "*");
6628      return;
6629    }
6630
6631    // Connect to a UDP server,
6632    // which will pass messages to the disk runner.
6633    if (type === "udp:connect") {
6634      const udp = await loadUDP();
6635      udp.connect(content.port, content.url, send, content.turnHost);
6636      return;
6637    }
6638
6639    // Send a message to the UDP server.
6640    if (type === "udp:send") {
6641      const udp = await loadUDP();
6642      udp.send(content);
6643      return;
6644    } // Disconect from the UDP server.
6645    if (type === "udp:disconnect") {
6646      const udp = await loadUDP();
6647      udp.disconnect(content.outageSeconds);
6648      return;
6649    }
6650
6651    if (type === "ticket-wall") {
6652      if (!window.Stripe) await loadStripe();
6653      let pretext = ``;
6654      let button = `buy ticket`;
6655      let color = `white;`;
6656      let desc = `Chat now or check email for tickets.<br><br><span id="stripe">Processed by <a href="https://stripe.com">Stripe</a></span>`;
6657
6658      if (content.item === "botce") {
6659        pretext = `
6660        <div id="pretext">
6661          <img style="image-rendering: pixelated;" cross-origin="anonymous" src="https://sotce-media.aesthetic.computer/botce-b.gif">
6662          <div id="pretext-bullets">
6663            <span id="desc">visit with <code>botce</code></span>
6664            <ul id="features">
6665              <li><code>botce</code> is a helpful ai</li>
6666              <li><code>botce</code> has advice</li>
6667              <!--<li><code>botce</code> is here until 11/25</li>-->
6668            </ul>
6669          </div>
6670        </div>
6671        <style>
6672         #pretext {
6673           display: flex;
6674           flex-direction: row;
6675           padding-bottom: 1.5em;
6676         }
6677         #pretext-bullets {
6678          display: flex;
6679           flex-direction: column;
6680           color: black;
6681         }
6682         #pretext img {
6683          /* border: 2px solid white; */
6684          border-radius: 10%;
6685          max-width: 30%;
6686          width: 100%;
6687          height: 100%;
6688         }
6689         #features {
6690          padding-left: 1.7em;
6691          margin-top: 0.4em;
6692          list-style-type: none;
6693         }
6694         #desc {
6695          color: #30313D;
6696          margin: 0 auto 0 1em;
6697          background: rgba(255, 100, 100, 0.5);me
6698          border-radius: 6px;
6699          padding: 8px 12px;
6700         }
6701         code {
6702          font-weight: bold;
6703         }
6704        </style>
6705        `;
6706
6707        button = "$6";
6708        color = "rgb(255, 200, 200, 0.95)";
6709      }
6710
6711      const template = `
6712        <link rel="stylesheet" href="aesthetic.computer/checkout.css" />
6713        <form style="background: ${color};" id="payment-form" class=${content.item}>
6714          ${pretext}
6715          <div id="link-authentication-element">
6716          </div>
6717          <div id="payment-element">
6718          </div>
6719          <!-- <div id="payment-request-button"></div> -->
6720          <button id="submit">
6721            <div class="spinner hidden" id="spinner">Processing...</div>
6722            <span id="button-text">${button}</span>
6723          </button>
6724          <div id="payment-description">${desc}</div>
6725          <div id="payment-message-wrapper">
6726            <div id="payment-message" class="hidden"></div>
6727          </div>
6728        </form>
6729      `;
6730      ticketWrapper = document.createElement("div");
6731      ticketWrapper.id = "ticket";
6732      ticketWrapper.innerHTML = template;
6733      ticketWrapper.classList.add("hidden");
6734      wrapper.appendChild(ticketWrapper);
6735      const { ticket } = await import(`./lib/ticket.mjs`);
6736      ticket(content.from, content.item, () => {
6737        ticketWrapper.classList.remove("hidden");
6738      }); // Open the ticket overlay.
6739      return;
6740    }
6741
6742    if (type === "handle") {
6743      HANDLE = content; // Set the global HANDLE const to the user handle.
6744      // Join the handle's presence room so a linked agent lights the mark.
6745      import("./lib/agent-presence.mjs")
6746        .then((mod) => mod.startAgentPresence({ room: HANDLE }))
6747        .catch(() => {});
6748      return;
6749    }
6750
6751    if (type === "kidlisp:cached-code") {
6752      // Update recording options with the newly cached code
6753      if (window.currentRecordingOptions) {
6754        window.currentRecordingOptions.cachedCode = content;
6755        // If pieceName was initially set to "$code", update it to the actual cached code
6756        if (window.currentRecordingOptions.pieceName === "$code") {
6757          window.currentRecordingOptions.pieceName = `$${content}`;
6758          console.log(`🎬 Updated pieceName to: $${content}`);
6759        }
6760      }
6761      return;
6762    }
6763
6764    // 🎵 Clock piece cached code - forward back to disk.mjs for QR display
6765    if (type === "clock:cached") {
6766      // Forward to disk.mjs so it can display the QR code
6767      send({ type: "clock:cached", content });
6768      // Also update recording options if active
6769      if (window.currentRecordingOptions) {
6770        window.currentRecordingOptions.cachedCode = content?.code;
6771        if (window.currentRecordingOptions.pieceName === "*code") {
6772          window.currentRecordingOptions.pieceName = `*${content?.code}`;
6773          console.log(`🎬 Updated pieceName to: *${content?.code}`);
6774        }
6775      }
6776      return;
6777    }
6778
6779    // Handle export events forwarded from worker via send() fallback
6780    if (type === "export-event-fallback") {
6781      // Route export events to current piece through actEvents
6782      if (actEvents && content && content.eventType && content.eventContent) {
6783        const event = {
6784          is: (eventType) => eventType === content.eventType,
6785          type: content.eventType,
6786          content: content.eventContent,
6787          progress: content.eventType === "recorder:transcode-progress" ? content.eventContent : undefined
6788        };
6789        
6790        console.log("🔗 ✅ Routed fallback export event to piece:", content.eventType);
6791        actEvents.add(event);
6792      } else {
6793        console.log("🔗 ❌ Cannot route fallback event - missing actEvents or content:", !!actEvents, !!content);
6794      }
6795      return;
6796    }
6797
6798    // Handle direct export events from worker (when actEvents unavailable during export)
6799    if (
6800      type === "recorder:export-status" ||
6801      type === "recorder:export-progress" ||
6802      type === "recorder:transcode-progress" ||
6803      type === "recorder:export-complete"
6804    ) {
6805      // Route direct export events to current piece through actEvents
6806      if (actEvents) {
6807        const event = {
6808          is: (eventType) => eventType === type,
6809          type: type,
6810          content: content,
6811          progress: type === "recorder:transcode-progress" ? content : undefined
6812        };
6813        
6814        console.log("🔗 ✅ Routed direct export event to piece:", type);
6815        actEvents.add(event);
6816      }
6817      return;
6818    }
6819
6820    // Sync labelBack state between worker and main thread
6821    if (type === "labelBack:set") {
6822      mainThreadLabelBack = true;
6823      window.safeSessionStorageSet("aesthetic-labelBack", "true");
6824      console.log("🔗 Main thread: Set labelBack in sessionStorage");
6825      return;
6826    }
6827
6828    if (type === "labelBack:source") {
6829      // Store the source piece when labelBack is activated
6830      const sourcePiece = content?.sourcePiece;
6831      if (sourcePiece) {
6832        window.safeSessionStorageSet("aesthetic-labelBack-source", sourcePiece);
6833        console.log(`🔗 Main thread: Stored labelBack source: ${sourcePiece}`);
6834      }
6835      return;
6836    }
6837
6838    if (type === "labelBack:clear") {
6839      mainThreadLabelBack = false;
6840      window.safeSessionStorageRemove("aesthetic-labelBack");
6841      window.safeSessionStorageRemove("aesthetic-labelBack-source");
6842      console.log("🔗 Main thread: Cleared labelBack from sessionStorage");
6843      return;
6844    }
6845
6846    if (type === "sw:clear-cache") {
6847      // Forwarded from prompt.mjs's deployment poll. Tell the active SW
6848      // to drop its module cache so the next page load fetches fresh
6849      // bios.mjs / disk.mjs etc. instead of stale-while-revalidating.
6850      try {
6851        navigator.serviceWorker?.controller?.postMessage("clearCache");
6852        console.log("🔧 Main thread: requested SW cache clear");
6853      } catch (e) {
6854        console.warn("🔧 Could not post clearCache to SW:", e);
6855      }
6856      return;
6857    }
6858
6859    // Helper function to ensure fonts are loaded before use
6860    let fontsLoaded = false;
6861    async function ensureFontsLoaded() {
6862      if (fontsLoaded) {
6863        return; // Fonts already loaded
6864      }
6865
6866      if ("fonts" in document) {
6867        try {
6868          // Force font loading with multiple strategies
6869          const fontPromises = [
6870            document.fonts.load("1em YWFTProcessing-Regular"),
6871            document.fonts.load("1em YWFTProcessing-Light"),
6872            document.fonts.load("bold 1em YWFTProcessing-Regular"),
6873            document.fonts.load("normal 1em YWFTProcessing-Light"),
6874          ];
6875
6876          await Promise.all(fontPromises);
6877          console.log("✅ Fonts loaded for tape overlay");
6878
6879          // Additional verification: check if fonts are actually available
6880          const fontFaces = Array.from(document.fonts);
6881          const hasRegular = fontFaces.some((font) =>
6882            font.family.includes("YWFTProcessing-Regular"),
6883          );
6884          const hasLight = fontFaces.some((font) =>
6885            font.family.includes("YWFTProcessing-Light"),
6886          );
6887
6888          if (!hasRegular || !hasLight) {
6889            console.warn(
6890              "⚠️ Font verification failed - fonts may not be fully loaded",
6891            );
6892            // Wait a bit more and try again
6893            await new Promise((resolve) => setTimeout(resolve, 500));
6894            await Promise.all(fontPromises);
6895          }
6896
6897          fontsLoaded = true; // Mark fonts as loaded
6898        } catch (error) {
6899          console.warn("⚠️ Failed to load fonts for tape overlay:", error);
6900          // Fallback: wait for document.fonts.ready
6901          await document.fonts.ready;
6902          fontsLoaded = true; // Mark as loaded even if fallback was used
6903        }
6904      }
6905    }
6906
6907    // Helper function to add the sideways AC stamp (same as video recording)
6908    async function addAestheticComputerStamp(ctx, canvasWidth, canvasHeight, progress = 0, frameData = null, frameMetadata = null, frameIndex = null, totalFrames = null) {
6909      // Skip all stamps in clean mode
6910      if (window.currentRecordingOptions?.cleanMode) {
6911        console.log("🎬 🧹 Skipping Aesthetic Computer stamp in clean mode");
6912        return;
6913      }
6914      
6915      // Ensure fonts are loaded before drawing
6916      await ensureFontsLoaded();
6917
6918      // 2. Set up the font.
6919      const typeSize = Math.min(
6920        18,
6921        Math.max(12, Math.floor(canvasHeight / 40)),
6922      );
6923      const gap = typeSize * 0.75;
6924
6925      ctx.save(); // Save the current state of the canvas
6926
6927      // Calculate stamp visibility for timestamp fade coordination
6928      let bothStampsInvisible = false;
6929      const isFrameBasedRecording = window.currentRecordingOptions?.frameMode;
6930      
6931      if (isFrameBasedRecording) {
6932        // For frame-based recordings longer than 60 frames: use alternating side timing logic
6933        const frameCount = window.currentRecordingOptions?.frameCount || 0;
6934        
6935        if (frameCount > 60 && frameIndex !== null) {
6936          const cyclesPerGif = 2;
6937          const frameProgress = frameIndex / frameCount;
6938          const cyclePosition = (frameProgress * cyclesPerGif) % 1.0;
6939          
6940          // Check if we're in the "both off" phases (0-20% and 40-60%)
6941          if ((cyclePosition < 0.2) || (cyclePosition >= 0.4 && cyclePosition < 0.6)) {
6942            bothStampsInvisible = true;
6943          }
6944        }
6945      } else {
6946        // Time-based recordings
6947        const cyclesPerGif = 2;
6948        const cyclePosition = (progress * cyclesPerGif) % 1.0;
6949        
6950        // Check if we're in the "both off" phases (0-20% and 40-60%)
6951        if ((cyclePosition < 0.2) || (cyclePosition >= 0.4 && cyclePosition < 0.6)) {
6952          bothStampsInvisible = true;
6953        }
6954      }
6955
6956      // Add film camera style timestamp at bottom-left corner
6957      addFilmTimestamp(ctx, canvasWidth, canvasHeight, typeSize, progress, frameData, frameMetadata, frameIndex, totalFrames, bothStampsInvisible);
6958
6959      // Add Tezos watermark if enabled
6960      if (window.currentRecordingOptions?.showTezosStamp) {
6961        // Calculate both stamp visibility for Tezos blink sync
6962        let showLeftStamp = false;
6963        let showRightStamp = false;
6964        const isFrameBasedRecording = window.currentRecordingOptions?.frameMode;
6965        
6966        if (isFrameBasedRecording) {
6967          showLeftStamp = true;
6968          showRightStamp = true;
6969        } else {
6970          const cyclesPerGif = 2;
6971          const cyclePosition = (progress * cyclesPerGif) % 1.0;
6972          
6973          if (cyclePosition < 0.2) {
6974            // Phase 1 (0-20%): Both off
6975            showLeftStamp = false;
6976            showRightStamp = false;
6977          } else if (cyclePosition < 0.4) {
6978            // Phase 2 (20-40%): Both on
6979            showLeftStamp = true;
6980            showRightStamp = true;
6981          } else if (cyclePosition < 0.6) {
6982            // Phase 3 (40-60%): Both off
6983            showLeftStamp = false;
6984            showRightStamp = false;
6985          } else if (cyclePosition < 0.8) {
6986            // Phase 4 (60-80%): Left only
6987            showLeftStamp = true;
6988            showRightStamp = false;
6989          } else {
6990            // Phase 5 (80-100%): Right only
6991            showLeftStamp = false;
6992            showRightStamp = true;
6993          }
6994        }
6995        
6996        // Removed addTezosStamp from top right - now positioned with timestamp
6997      }
6998
6999      // Add Tezos watermark to top-right if enabled (but not when right stamp is showing)
7000      if (window.currentRecordingOptions?.showTezosStamp) {
7001        const isFrameBasedRecording = window.currentRecordingOptions?.frameMode;
7002        
7003        // Use the SAME visibility logic as the actual stamps to avoid conflicts
7004        let showRightStamp = false;
7005        
7006        if (isFrameBasedRecording) {
7007          // For frame-based recordings longer than 60 frames: use alternating side timing logic
7008          const frameCount = window.currentRecordingOptions?.frameCount || 0;
7009          
7010          if (frameCount > 60 && frameIndex !== null) {
7011            // 5-phase pattern across total frame count
7012            const cyclesPerGif = 2;
7013            const frameProgress = frameIndex / frameCount;
7014            const cyclePosition = (frameProgress * cyclesPerGif) % 1.0;
7015            
7016            if (cyclePosition < 0.2) {
7017              showRightStamp = false;
7018            } else if (cyclePosition < 0.4) {
7019              showRightStamp = true;
7020            } else if (cyclePosition < 0.6) {
7021              showRightStamp = false;
7022            } else if (cyclePosition < 0.8) {
7023              showRightStamp = false; // Left only
7024            } else {
7025              showRightStamp = true; // Right only
7026            }
7027          } else {
7028            // For shorter recordings: always show both stamps, so Tezos should never show
7029            showRightStamp = true;
7030          }
7031        } else {
7032          // Time-based recordings: use progress-based visibility (SAME as stamp logic)
7033          const cyclesPerGif = 2;
7034          const cyclePosition = (progress * cyclesPerGif) % 1.0;
7035          
7036          if (cyclePosition < 0.2) {
7037            showRightStamp = false; // Both off
7038          } else if (cyclePosition < 0.4) {
7039            showRightStamp = true; // Both on
7040          } else if (cyclePosition < 0.6) {
7041            showRightStamp = false; // Both off
7042          } else if (cyclePosition < 0.8) {
7043            showRightStamp = false; // Left only
7044          } else {
7045            showRightStamp = true; // Right only
7046          }
7047        }
7048        
7049        // Calculate fade alpha for Tezos logo (inverse of right stamp)
7050        let tezosFadeAlpha = 1.0;
7051        
7052        if (isFrameBasedRecording) {
7053          const frameCount = window.currentRecordingOptions?.frameCount || 0;
7054          
7055          if (frameCount > 60 && frameIndex !== null) {
7056            const cyclesPerGif = 2;
7057            const frameProgress = frameIndex / frameCount;
7058            const cyclePosition = (frameProgress * cyclesPerGif) % 1.0;
7059            
7060            // Tezos is visible when right stamp is NOT visible, with longer fades
7061            if (cyclePosition < 0.15) {
7062              tezosFadeAlpha = 1.0; // Fully visible
7063            } else if (cyclePosition >= 0.15 && cyclePosition <= 0.25) {
7064              tezosFadeAlpha = 1.0 - ((cyclePosition - 0.15) / 0.10); // Fade out as right stamp fades in
7065            } else if (cyclePosition > 0.25 && cyclePosition < 0.35) {
7066              tezosFadeAlpha = 0.0; // Hidden while both stamps on
7067            } else if (cyclePosition >= 0.35 && cyclePosition <= 0.45) {
7068              tezosFadeAlpha = (cyclePosition - 0.35) / 0.10; // Fade in as stamps fade out
7069            } else if (cyclePosition > 0.45 && cyclePosition < 0.75) {
7070              tezosFadeAlpha = 1.0; // Visible during off phases and left-only
7071            } else if (cyclePosition >= 0.75 && cyclePosition <= 0.85) {
7072              tezosFadeAlpha = 1.0 - ((cyclePosition - 0.75) / 0.10); // Fade out as right stamp fades in
7073            } else {
7074              tezosFadeAlpha = 0.0; // Hidden during right-only phase
7075            }
7076          } else {
7077            tezosFadeAlpha = 0.0; // Hidden for shorter recordings
7078          }
7079        } else {
7080          // Time-based recordings
7081          const cyclesPerGif = 2;
7082          const cyclePosition = (progress * cyclesPerGif) % 1.0;
7083          
7084          if (cyclePosition < 0.15) {
7085            tezosFadeAlpha = 1.0;
7086          } else if (cyclePosition >= 0.15 && cyclePosition <= 0.25) {
7087            tezosFadeAlpha = 1.0 - ((cyclePosition - 0.15) / 0.10);
7088          } else if (cyclePosition > 0.25 && cyclePosition < 0.35) {
7089            tezosFadeAlpha = 0.0;
7090          } else if (cyclePosition >= 0.35 && cyclePosition <= 0.45) {
7091            tezosFadeAlpha = (cyclePosition - 0.35) / 0.10;
7092          } else if (cyclePosition > 0.45 && cyclePosition < 0.75) {
7093            tezosFadeAlpha = 1.0;
7094          } else if (cyclePosition >= 0.75 && cyclePosition <= 0.85) {
7095            tezosFadeAlpha = 1.0 - ((cyclePosition - 0.75) / 0.10);
7096          } else {
7097            tezosFadeAlpha = 0.0;
7098          }
7099        }
7100        
7101        // Clamp alpha and show with fade
7102        tezosFadeAlpha = Math.max(0.0, Math.min(1.0, tezosFadeAlpha));
7103        
7104        if (tezosFadeAlpha > 0.0) {
7105          addTezosStamp(ctx, canvasWidth, canvasHeight, typeSize, isFrameBasedRecording, true, true, frameIndex, tezosFadeAlpha);
7106        }
7107      }
7108
7109      drawTextAtPosition(0, 90); // Left
7110      drawTextAtPosition(canvasWidth, -90); // Right
7111
7112      ctx.restore();
7113      ctx.globalAlpha = 1;
7114
7115      function drawTextAtPosition(positionX, deg) {
7116        // Skip fade animations for frame-based recordings
7117        const isFrameBasedRecording = window.currentRecordingOptions?.frameMode;
7118        
7119        // Debug logging
7120        if (frameIndex === 0) {
7121          console.log("🎬 Stamp visibility debug:", {
7122            isFrameBasedRecording,
7123            frameMode: window.currentRecordingOptions?.frameMode,
7124            showTezosStamp: window.currentRecordingOptions?.showTezosStamp
7125          });
7126        }
7127        
7128        let showLeftStamp = false;
7129        let showRightStamp = false;
7130        
7131        if (isFrameBasedRecording) {
7132          // For frame-based recordings longer than 60 frames: use alternating side timing logic
7133          const frameCount = window.currentRecordingOptions?.frameCount || 0;
7134          
7135          if (frameCount > 60 && frameIndex !== null) {
7136            // 5-phase pattern across total frame count: both off, both on, both off, left only, right only
7137            const cyclesPerGif = 2; // Pattern loops 2 times across the full recording
7138            const frameProgress = frameIndex / frameCount; // 0-1 progress through recording
7139            const cyclePosition = (frameProgress * cyclesPerGif) % 1.0; // 0-1 within current cycle
7140            
7141            if (cyclePosition < 0.2) {
7142              // Phase 1 (0-20%): Both off
7143              showLeftStamp = false;
7144              showRightStamp = false;
7145            } else if (cyclePosition < 0.4) {
7146              // Phase 2 (20-40%): Both on
7147              showLeftStamp = true;
7148              showRightStamp = true;
7149            } else if (cyclePosition < 0.6) {
7150              // Phase 3 (40-60%): Both off
7151              showLeftStamp = false;
7152              showRightStamp = false;
7153            } else if (cyclePosition < 0.8) {
7154              // Phase 4 (60-80%): Left only
7155              showLeftStamp = true;
7156              showRightStamp = false;
7157            } else {
7158              // Phase 5 (80-100%): Right only
7159              showLeftStamp = false;
7160              showRightStamp = true;
7161            }
7162          } else {
7163            // For shorter frame recordings: always show both stamps
7164            showLeftStamp = true;
7165            showRightStamp = true;
7166          }
7167        } else {
7168          // 5-phase pattern stretched across GIF duration: both off, both on, both off, left only, right only
7169          // Use progress (0-1) to determine cycle position - allows multiple loops per GIF
7170          const cyclesPerGif = 2; // Pattern loops 2 times across the full GIF duration
7171          const cyclePosition = (progress * cyclesPerGif) % 1.0; // 0-1 within current cycle
7172          
7173          if (cyclePosition < 0.2) {
7174            // Phase 1 (0-20%): Both off
7175            showLeftStamp = false;
7176            showRightStamp = false;
7177          } else if (cyclePosition < 0.4) {
7178            // Phase 2 (20-40%): Both on
7179            showLeftStamp = true;
7180            showRightStamp = true;
7181          } else if (cyclePosition < 0.6) {
7182            // Phase 3 (40-60%): Both off
7183            showLeftStamp = false;
7184            showRightStamp = false;
7185          } else if (cyclePosition < 0.8) {
7186            // Phase 4 (60-80%): Left only
7187            showLeftStamp = true;
7188            showRightStamp = false;
7189          } else {
7190            // Phase 5 (80-100%): Right only
7191            showLeftStamp = false;
7192            showRightStamp = true;
7193          }
7194        }
7195        
7196        // Determine if this specific stamp should show with fade transitions
7197        let stampFadeAlpha = 0.0;
7198        
7199        // Calculate fade alpha for smooth transitions instead of hard cuts
7200        const fadeSpeed = 0.05; // How much of each phase is fade transition (5%)
7201        
7202        if (deg === 90) {
7203          // Left side - calculate fade alpha
7204          if (showLeftStamp) {
7205            stampFadeAlpha = 1.0;
7206          } else {
7207            stampFadeAlpha = 0.0;
7208          }
7209          
7210          // Add fade transitions at boundaries
7211          if (isFrameBasedRecording) {
7212            // For frame-based: add quick fades at phase transitions
7213            const frameCount = window.currentRecordingOptions?.frameCount || 0;
7214            if (frameCount > 60 && frameIndex !== null) {
7215              const cyclesPerGif = 2;
7216              const frameProgress = frameIndex / frameCount;
7217              const cyclePosition = (frameProgress * cyclesPerGif) % 1.0;
7218              
7219              // Quick fade in/out at transition points - longer fades for visibility
7220              if (cyclePosition >= 0.15 && cyclePosition <= 0.25) {
7221                // Fade in to "both on" phase (10% fade period)
7222                stampFadeAlpha = (cyclePosition - 0.15) / 0.10; // 0 to 1 over 10% of cycle
7223              } else if (cyclePosition >= 0.35 && cyclePosition <= 0.45) {
7224                // Fade out from "both on" phase (10% fade period)
7225                stampFadeAlpha = 1.0 - ((cyclePosition - 0.35) / 0.10); // 1 to 0 over 10% of cycle
7226              } else if (cyclePosition >= 0.55 && cyclePosition <= 0.65) {
7227                // Fade in to "left only" phase (10% fade period)
7228                stampFadeAlpha = (cyclePosition - 0.55) / 0.10; // 0 to 1 over 10% of cycle
7229              } else if (cyclePosition >= 0.75 && cyclePosition <= 0.85) {
7230                // Fade out from "left only" phase (10% fade period)
7231                stampFadeAlpha = 1.0 - ((cyclePosition - 0.75) / 0.10); // 1 to 0 over 10% of cycle
7232              }
7233            }
7234          } else {
7235            // Time-based recordings: add fade transitions (longer fades)
7236            const cyclesPerGif = 2;
7237            const cyclePosition = (progress * cyclesPerGif) % 1.0;
7238            
7239            if (cyclePosition >= 0.15 && cyclePosition <= 0.25) {
7240              stampFadeAlpha = (cyclePosition - 0.15) / 0.10;
7241            } else if (cyclePosition >= 0.35 && cyclePosition <= 0.45) {
7242              stampFadeAlpha = 1.0 - ((cyclePosition - 0.35) / 0.10);
7243            } else if (cyclePosition >= 0.55 && cyclePosition <= 0.65) {
7244              stampFadeAlpha = (cyclePosition - 0.55) / 0.10;
7245            } else if (cyclePosition >= 0.75 && cyclePosition <= 0.85) {
7246              stampFadeAlpha = 1.0 - ((cyclePosition - 0.75) / 0.10);
7247            }
7248          }
7249        } else if (deg === -90) {
7250          // Right side - calculate fade alpha
7251          if (showRightStamp) {
7252            stampFadeAlpha = 1.0;
7253          } else {
7254            stampFadeAlpha = 0.0;
7255          }
7256          
7257          // Add fade transitions at boundaries  
7258          if (isFrameBasedRecording) {
7259            const frameCount = window.currentRecordingOptions?.frameCount || 0;
7260            if (frameCount > 60 && frameIndex !== null) {
7261              const cyclesPerGif = 2;
7262              const frameProgress = frameIndex / frameCount;
7263              const cyclePosition = (frameProgress * cyclesPerGif) % 1.0;
7264              
7265              if (cyclePosition >= 0.15 && cyclePosition <= 0.25) {
7266                stampFadeAlpha = (cyclePosition - 0.15) / 0.10;
7267              } else if (cyclePosition >= 0.35 && cyclePosition <= 0.45) {
7268                stampFadeAlpha = 1.0 - ((cyclePosition - 0.35) / 0.10);
7269              } else if (cyclePosition >= 0.75 && cyclePosition <= 0.85) {
7270                stampFadeAlpha = (cyclePosition - 0.75) / 0.10;
7271              }
7272            }
7273          } else {
7274            const cyclesPerGif = 2;
7275            const cyclePosition = (progress * cyclesPerGif) % 1.0;
7276            
7277            if (cyclePosition >= 0.15 && cyclePosition <= 0.25) {
7278              stampFadeAlpha = (cyclePosition - 0.15) / 0.10;
7279            } else if (cyclePosition >= 0.35 && cyclePosition <= 0.45) {
7280              stampFadeAlpha = 1.0 - ((cyclePosition - 0.35) / 0.10);
7281            } else if (cyclePosition >= 0.75 && cyclePosition <= 0.85) {
7282              stampFadeAlpha = (cyclePosition - 0.75) / 0.10;
7283            }
7284          }
7285        }
7286        
7287        // Clamp alpha to valid range
7288        stampFadeAlpha = Math.max(0.0, Math.min(1.0, stampFadeAlpha));
7289        
7290        if (stampFadeAlpha <= 0.0) {
7291          return; // Skip drawing this stamp
7292        }
7293
7294        ctx.save();
7295        ctx.translate(positionX, 0);
7296        ctx.rotate((deg * Math.PI) / 180); // Convert degrees to radians
7297        
7298        // Separate positioning for left and right sides for better spacing
7299        const leftYDist = 0.10; // Closer to corner (was 0.18)
7300        const rightYDist = 0.08; // Closer to corner, but still avoid Tezos overlap (was 0.15)
7301
7302        // Use smaller size to match timestamp better  
7303        ctx.font = `${typeSize * 1.4}px YWFTProcessing-Regular`;
7304        
7305        // Always use Aesthetic.Computer text
7306        const text = "Aesthetic.Computer";
7307        const measured = ctx.measureText(text);
7308        const textWidth = measured.width;
7309
7310        // Helper function to render text with multicolored shooting star dot
7311        function renderTextWithStarDot(ctx, fullText, x, y, baseColor, alpha, offsetX, offsetY, frameIndex) {
7312          const parts = fullText.split('.');
7313          if (parts.length !== 2) {
7314            // Fallback to normal rendering if not split correctly
7315            ctx.fillText(fullText, x + offsetX, y + offsetY);
7316            return;
7317          }
7318          
7319          const beforeDot = parts[0]; // "Aesthetic"
7320          const afterDot = parts[1];  // "Computer"
7321          
7322          // Measure parts
7323          const beforeWidth = ctx.measureText(beforeDot).width;
7324          const dotWidth = ctx.measureText('.').width;
7325          
7326          // Render "Aesthetic"
7327          ctx.fillStyle = baseColor;
7328          ctx.globalAlpha = alpha;
7329          ctx.fillText(beforeDot, x + offsetX, y + offsetY);
7330          
7331          // Render CRT-style piercing dot - bright, small, sharp like asteroids
7332          const dotX = x + beforeWidth + offsetX;
7333          const dotY = y + offsetY;
7334          
7335          // CRT cathode ray piercing colors - bright, sharp, high-contrast
7336          const crtColors = ["#FFFFFF", "#00FFFF", "#FFFF00", "#FF0000", "#00FF00", "#FF00FF"];
7337          let dotColor;
7338          let dotAlpha = alpha;
7339          
7340          if (frameIndex !== null) {
7341            // Frame-based CRT flicker animation - faster changes for piercing effect
7342            const colorIndex = Math.floor(frameIndex / 1) % crtColors.length; // Change color every frame
7343            dotColor = crtColors[colorIndex];
7344            
7345            // Sharp, bright pulse - no gentle curves, pure CRT intensity
7346            dotAlpha = alpha * (frameIndex % 2 === 0 ? 1.0 : 0.9); // Sharp flicker between 90% and 100%
7347          } else {
7348            // Time-based fallback - faster CRT-style changes
7349            const timeIndex = Math.floor(Date.now() / 100) % crtColors.length; // Change every 100ms
7350            dotColor = crtColors[timeIndex];
7351            dotAlpha = alpha * (Math.floor(Date.now() / 100) % 2 === 0 ? 1.0 : 0.9); // Sharp time-based flicker
7352          }
7353          
7354          ctx.fillStyle = dotColor;
7355          ctx.globalAlpha = dotAlpha;
7356          ctx.fillText('.', dotX, dotY);
7357          
7358          // Render "Computer"
7359          ctx.fillStyle = baseColor;
7360          ctx.globalAlpha = alpha;
7361          ctx.fillText(afterDot, dotX + dotWidth, dotY);
7362        }
7363
7364        ["red", "lime", "blue", "white"].forEach((color) => {
7365          // Skip fade animations for frame-based recordings
7366          const isFrameBasedRecording = window.currentRecordingOptions?.frameMode;
7367          
7368          // Frame-based glitch effects for frame recordings
7369          let glitchAlpha = 1.0;
7370          if (isFrameBasedRecording && frameIndex !== null) {
7371            // Different glitch patterns for each color layer
7372            if (color === "red") {
7373              glitchAlpha = (frameIndex + 1) % 5 === 0 ? 0.2 : 1.0; // Dim every 5th frame
7374            } else if (color === "lime") {
7375              glitchAlpha = (frameIndex + 2) % 8 === 0 ? 0.0 : 1.0; // Off every 8th frame (offset)
7376            } else if (color === "blue") {
7377              glitchAlpha = (frameIndex + 3) % 6 === 0 ? 0.4 : 1.0; // Dim every 6th frame (offset)
7378            } else if (color === "white") {
7379              glitchAlpha = (frameIndex + 1) % 4 === 0 ? 0.6 : 1.0; // Dim every 4th frame
7380            }
7381          }
7382          
7383          let offsetX, offsetY;
7384          if (color !== "white") {
7385            ctx.globalAlpha = (isFrameBasedRecording ? 0.5 * glitchAlpha : 0.5) * stampFadeAlpha; // Restore multicolor balance
7386            offsetX = choose([-1, -2, 0, 1, 2]); // More offsets for multicolor flickering
7387            offsetY = choose([-1, -2, 0, 1, 2]); // More offsets for multicolor flickering
7388          } else {
7389            ctx.globalAlpha = (isFrameBasedRecording ? 0.85 * glitchAlpha : choose([0.75, 0.85, 0.95])) * stampFadeAlpha; // Brighter white for dot visibility
7390            offsetX = choose([-1, 0, 1]); // Some offsets for multicolor effect
7391            offsetY = choose([-1, 0, 1]); // Some offsets for multicolor effect
7392            color = choose(["white", "white", "white", "magenta", "yellow"]);
7393          }
7394
7395          ctx.fillStyle = color;
7396
7397          if (deg === 90) {
7398            // Left side - move lower (closer to bottom)
7399            renderTextWithStarDot(
7400              ctx,
7401              text,
7402              floor(canvasHeight * (1 - leftYDist) - textWidth),
7403              -floor(gap),
7404              color,
7405              ctx.globalAlpha,
7406              offsetX,
7407              offsetY,
7408              frameIndex
7409            );
7410          } else if (deg === -90) {
7411            // Right side - move higher (closer to top)
7412            renderTextWithStarDot(
7413              ctx,
7414              text,
7415              -Math.floor(canvasHeight * rightYDist + textWidth),
7416              Math.floor(-gap),
7417              color,
7418              ctx.globalAlpha,
7419              offsetX,
7420              offsetY,
7421              frameIndex
7422            );
7423          }
7424        });
7425
7426        if (HANDLE) {
7427          ctx.font = `${typeSize * 1.3}px YWFTProcessing-Light`; // Smaller handle text (was 1.5)
7428          const handleWidth = textWidth / 2 + ctx.measureText(HANDLE).width / 2;
7429          const handleSpace = typeSize * 2.0; // More spacing (was 1.5)
7430          
7431          // Skip fade animations for frame-based recordings
7432          const isFrameBasedRecording = window.currentRecordingOptions?.frameMode;
7433          
7434          // Restore original 4-layer system with warmer color palette
7435          ["red", "lime", "blue", "white"].forEach((color) => {
7436            // Frame-based glitch effects for frame recordings
7437            let glitchAlpha = 1.0;
7438            if (isFrameBasedRecording && frameIndex !== null) {
7439              // Different glitch patterns for handle text (offset from main text)
7440              if (color === "red") {
7441                glitchAlpha = (frameIndex + 2) % 7 === 0 ? 0.1 : 1.0; // Off every 7th frame (offset)
7442              } else if (color === "lime") {
7443                glitchAlpha = (frameIndex + 3) % 5 === 0 ? 0.3 : 1.0; // Dim every 5th frame (offset)
7444              } else if (color === "blue") {
7445                glitchAlpha = (frameIndex + 4) % 9 === 0 ? 0.0 : 1.0; // Off every 9th frame (offset)
7446              } else if (color === "white") {
7447                glitchAlpha = (frameIndex + 2) % 3 === 0 ? 0.4 : 1.0; // Dim every 3rd frame (offset)
7448              }
7449            }
7450            
7451            let offsetX, offsetY;
7452            if (color !== "white") {
7453              ctx.globalAlpha = (isFrameBasedRecording ? 0.6 * glitchAlpha : 0.6) * stampFadeAlpha; // Less bright to keep multicolor look
7454              offsetX = choose([-1, -2, 0, 1, 2]); // More offsets for multicolor flickering
7455              offsetY = choose([-1, -2, 0, 1, 2]); // More offsets for multicolor flickering
7456            } else {
7457              ctx.globalAlpha = (isFrameBasedRecording ? 0.8 * glitchAlpha : choose([0.7, 0.8, 0.9])) * stampFadeAlpha; // Keep bright white layer
7458              offsetX = choose([-1, 0, 1]); // Some offsets for multicolor effect
7459              offsetY = choose([-1, 0, 1]); // Some offsets for multicolor effect
7460              color = choose(["white", "white", "white", "magenta", "yellow"]); // Original warmer palette
7461            }
7462
7463            ctx.fillStyle = color;
7464
7465            if (deg === 90) {
7466              // Handle
7466 - left side positioning (lower)
7467              ctx.fillText(
7468                HANDLE,
7469                floor(canvasHeight * (1 - leftYDist) - handleWidth + offsetY),
7470                -floor(offsetX + handleSpace + gap),
7471              );
7472            } else if (deg === -90) {
7473              // Handle - right side positioning (higher)
7474              ctx.fillText(
7475                HANDLE,
7476                -Math.floor(canvasHeight * rightYDist + handleWidth + offsetY),
7477                Math.floor(offsetX - handleSpace - gap),
7478              );
7479            }
7480          });
7481        }
7482
7483        // Add Tezos stamp as a third element (only for frame-based recordings)
7484        // Remove the old timestamp code from here since it's now in addFilmTimestamp
7485
7486        ctx.restore();
7487      }
7488
7489      // Helper function to add Tezos support stamp - Vegas-style magical watermark
7490      function addTezosStamp(ctx, canvasWidth, canvasHeight, typeSize, isFrameBasedRecording, showLeftStamp = true, showRightStamp = true, frameIndex = null, fadeAlpha = 1.0) {
7491        ctx.save();
7492        
7493        // Show Tezos when either stamp is visible (hide only when both are off)
7494        if (!showLeftStamp && !showRightStamp) {
7495          ctx.restore();
7496          return;
7497        }
7498        
7499        // Much smaller watermark size with thinner font
7500        const billSize = Math.max(32, Math.floor(canvasHeight / 16)); // Bit smaller (was 44 and /11)
7501        ctx.font = `${billSize}px YWFTProcessing-Light, Arial, "Segoe UI Symbol", "Apple Color Emoji", sans-serif`;
7502        
7503        // Use the official Tezos symbol
7504        const tezosSymbol = "ꜩ"; // U+A729 (official Tezos symbol)
7505        
7506        const textWidth = ctx.measureText(tezosSymbol).width;
7507        
7508        // Position with more margin from edges
7509        const marginX = Math.max(12, Math.floor(canvasHeight / 70)); // More margin (was /100)
7510        const marginY = Math.max(15, Math.floor(canvasHeight / 60)); // More margin (was /80)
7511        const x = Math.floor(canvasWidth - textWidth - marginX); // Pixel boundary
7512        const y = Math.floor(marginY + billSize * 0.6); // More snug position (was 0.7), pixel boundary
7513        
7514        // Gentler shake animation for tiny logo
7515        const billFrameSeed = Math.floor(Date.now() / 400); // Slower, calmer (was 200ms)
7516        const billShakeX = Math.floor((billFrameSeed % 3) - 1); // Range -1 to 1, much gentler (was -3 to 3)
7517        const billShakeY = Math.floor(((billFrameSeed + 4) % 3) - 1); // Range -1 to 1, much gentler (was -3 to 3)
7518        
7519        // Vegas-style magical color cycling
7520        const colorCycle = Math.floor(Date.now() / 150) % 6; // Fast color cycling
7521        let magicColors;
7522        
7523        switch (colorCycle) {
7524          case 0: magicColors = ["gold", "yellow", "white"]; break;
7525          case 1: magicColors = ["cyan", "blue", "white"]; break;
7526          case 2: magicColors = ["magenta", "red", "white"]; break;
7527          case 3: magicColors = ["lime", "green", "white"]; break;
7528          case 4: magicColors = ["orange", "red", "yellow"]; break;
7529          case 5: magicColors = ["purple", "blue", "cyan"]; break;
7530        }
7531        
7532        // Draw with chaotic magical effects for maximum energy
7533        magicColors.forEach((color, index) => {
7534          let offsetX, offsetY;
7535          
7536          // Frame-based glitch effects for frame recordings
7537          let glitchAlpha = 1.0;
7538          if (isFrameBasedRecording && frameIndex !== null) {
7539            // Different glitch patterns for Tezos watermark layers
7540            if (index === 0) {
7541              glitchAlpha = (frameIndex + 5) % 13 === 0 ? 0.0 : (frameIndex + 5) % 4 === 0 ? 0.2 : 1.0; // Off every 13th, dim every 4th
7542            } else if (index === 1) {
7543              glitchAlpha = (frameIndex + 6) % 11 === 0 ? 0.1 : (frameIndex + 6) % 6 === 0 ? 0.4 : 1.0; // Different pattern
7544            } else {
7545              glitchAlpha = (frameIndex + 7) % 7 === 0 ? 0.3 : (frameIndex + 7) % 15 === 0 ? 0.0 : 1.0; // Complex pattern
7546            }
7547          }
7548          
7549          // Set watermark blend mode for authentic look
7550          ctx.globalCompositeOperation = index === 0 ? 'multiply' : index === 1 ? 'screen' : 'overlay';
7551          
7552          if (index < 2) {
7553            ctx.globalAlpha = (isFrameBasedRecording ? 0.35 * glitchAlpha : 0.35) * fadeAlpha; // Apply fade alpha
7554            offsetX = Math.floor(choose([-1, 0, 1])); // Much gentler offsets (was chaotic -4 to 5)
7555            offsetY = Math.floor(choose([-1, 0, 1])); // Much gentler offsets (was chaotic -4 to 5)
7556          } else {
7557            ctx.globalAlpha = (isFrameBasedRecording ? 0.45 * glitchAlpha : 0.45) * fadeAlpha; // Apply fade alpha
7558            offsetX = Math.floor(choose([0, 1])); // Minimal offsets for readability (was -3 to 3)
7559            offsetY = Math.floor(choose([0, 1])); // Minimal offsets for readability (was -3 to 3)
7560            // Keep white layer more magical
7561            if (color === "white") {
7562              color = choose(["white", "white", "yellow", "cyan", "magenta", "lime"]); // More magical colors
7563            }
7564          }
7565          
7566          ctx.fillStyle = color;
7567          ctx.fillText(
7568            tezosSymbol, 
7569            x + offsetX + billShakeX, 
7570            y + offsetY + billShakeY
7571          );
7572          
7573          // Reset blend mode after each layer
7574          ctx.globalCompositeOperation = 'source-over';
7575        });
7576        
7577        ctx.restore();
7578      }
7579
7580      // Helper function to get syntax highlighting colors from kidlisp module
7581      function getSyntaxHighlightColors() {
7582        // Get cached kidlisp source code
7583        const cachedCode = window.currentRecordingOptions?.cachedCode;
7584        if (!cachedCode) {
7585          // Fallback to default red if no code available
7586          return [{ type: "default", r: 220, g: 60, b: 60, weight: 1.0 }];
7587        }
7588        
7589        // Use the centralized syntax highlighting from kidlisp module
7590        const colors = getSyntaxHighlightingColors(cachedCode);
7591        
7592        // Debug logging to show we're using kidlisp syntax highlighting
7593        console.log("🎨 Using kidlisp syntax highlighting:", {
7594          sourceLength: cachedCode.length,
7595          tokenCount: colors.length,
7596          colors: colors.slice(0, 5) // Show first 5 colors
7597        });
7598        
7599        return colors;
7600      }
7601      
7602      // Helper function to draw cyan crosshair cursor overlay
7603      function addCyanCrosshair(ctx, canvasWidth, canvasHeight, penData, scale = 1) {
7604        if (!penData || penData.x === undefined || penData.y === undefined) {
7605          return; // No pen data available
7606        }
7607        
7608        // Scale pen coordinates to match canvas size
7609        const x = Math.round(penData.x * scale);
7610        const y = Math.round(penData.y * scale);
7611        
7612        // Skip drawing if cursor is outside canvas bounds
7613        if (x < 0 || x >= canvasWidth || y < 0 || y >= canvasHeight) {
7614          return;
7615        }
7616        
7617        ctx.save();
7618        
7619        // Cyan crosshair settings
7620        const crosshairColor = "rgba(0, 255, 255, 0.8)"; // Bright cyan with slight transparency
7621        const shadowColor = "rgba(0, 0, 0, 0.5)"; // Black shadow for visibility
7622        const lineWidth = 2;
7623        const shadowOffset = 1;
7624        const crossSize = Math.max(8, Math.floor(Math.min(canvasWidth, canvasHeight) / 60)); // Responsive size
7625        
7626        // Draw shadow first
7627        ctx.strokeStyle = shadowColor;
7628        ctx.lineWidth = lineWidth + shadowOffset;
7629        ctx.lineCap = "round";
7630        
7631        ctx.beginPath();
7632        // Horizontal line shadow
7633        ctx.moveTo(x - crossSize + shadowOffset, y + shadowOffset);
7634        ctx.lineTo(x + crossSize + shadowOffset, y + shadowOffset);
7635        // Vertical line shadow
7636        ctx.moveTo(x + shadowOffset, y - crossSize + shadowOffset);
7637        ctx.lineTo(x + shadowOffset, y + crossSize + shadowOffset);
7638        ctx.stroke();
7639        
7640        // Draw main crosshair
7641        ctx.strokeStyle = crosshairColor;
7642        ctx.lineWidth = lineWidth;
7643        
7644        ctx.beginPath();
7645        // Horizontal line
7646        ctx.moveTo(x - crossSize, y);
7647        ctx.lineTo(x + crossSize, y);
7648        // Vertical line
7649        ctx.moveTo(x, y - crossSize);
7650        ctx.lineTo(x, y + crossSize);
7651        ctx.stroke();
7652        
7653        ctx.restore();
7654      }
7655
7656      // Helper function to get syntax color at a specific position in the progress bar
7657      function getSyntaxColorAtPosition(syntaxColors, position) {
7658        if (syntaxColors.length === 0) {
7659          return { r: 220, g: 60, b: 60 }; // Red fallback
7660        }
7661
7662        if (syntaxColors.length === 1) {
7663          return syntaxColors[0];
7664        }
7665
7666        // Calculate which color segment we're in based on cumulative weights
7667        let totalWeight = syntaxColors.reduce(
7668          (sum, color) => sum + color.weight,
7669          0,
7670        );
7671        let currentWeight = 0;
7672
7673        for (let i = 0; i < syntaxColors.length; i++) {
7674          const color = syntaxColors[i];
7675          const segmentStart = currentWeight / totalWeight;
7676          const segmentEnd = (currentWeight + color.weight) / totalWeight;
7677
7678          if (position >= segmentStart && position <= segmentEnd) {
7679            // Optionally blend with adjacent colors for smoother transitions
7680            if (i < syntaxColors.length - 1 && position > segmentEnd - 0.1) {
7681              const nextColor = syntaxColors[i + 1];
7682              const blendFactor = (position - (segmentEnd - 0.1)) / 0.1;
7683              return {
7684                r: Math.floor(
7685                  color.r * (1 - blendFactor) + nextColor.r * blendFactor,
7686                ),
7687                g: Math.floor(
7688                  color.g * (1 - blendFactor) + nextColor.g * blendFactor,
7689                ),
7690                b: Math.floor(
7691                  color.b * (1 - blendFactor) + nextColor.b * blendFactor,
7692                ),
7693              };
7694            }
7695            return color;
7696          }
7697
7698          currentWeight += color.weight;
7699        }
7700
7701        // Fallback to last color if position >is beyond the end
7702        return syntaxColors[syntaxColors.length - 1];
7703      }
7704      
7705      // Helper function to draw cyan crosshair cursor overlay
7706      function addCyanCrosshair(ctx, canvasWidth, canvasHeight, penData, scale = 1) {
7707        if (!penData || penData.x === undefined || penData.y === undefined) {
7708          return; // No pen data available
7709        }
7710        
7711        // Scale pen coordinates to match canvas size
7712        const x = Math.round(penData.x * scale);
7713        const y = Math.round(penData.y * scale);
7714        
7715        // Skip drawing if cursor is outside canvas bounds
7716        if (x < 0 || x >= canvasWidth || y < 0 || y >= canvasHeight) {
7717          return;
7718        }
7719        
7720        ctx.save();
7721        
7722        // Cyan crosshair settings
7723        const crosshairColor = "rgba(0, 255, 255, 0.8)"; // Bright cyan with slight transparency
7724        const shadowColor = "rgba(0, 0, 0, 0.5)"; // Black shadow for visibility
7725        const lineWidth = 2;
7726        const shadowOffset = 1;
7727        const crossSize = Math.max(8, Math.floor(Math.min(canvasWidth, canvasHeight) / 60)); // Responsive size
7728        
7729        // Draw shadow first
7730        ctx.strokeStyle = shadowColor;
7731        ctx.lineWidth = lineWidth + shadowOffset;
7732        ctx.lineCap = "round";
7733        
7734        ctx.beginPath();
7735        // Horizontal line shadow
7736        ctx.moveTo(x - crossSize + shadowOffset, y + shadowOffset);
7737        ctx.lineTo(x + crossSize + shadowOffset, y + shadowOffset);
7738        // Vertical line shadow
7739        ctx.moveTo(x + shadowOffset, y - crossSize + shadowOffset);
7740        ctx.lineTo(x + shadowOffset, y + crossSize + shadowOffset);
7741        ctx.stroke();
7742        
7743        // Draw main crosshair
7744        ctx.strokeStyle = crosshairColor;
7745        ctx.lineWidth = lineWidth;
7746        
7747        ctx.beginPath();
7748        // Horizontal line
7749        ctx.moveTo(x - crossSize, y);
7750        ctx.lineTo(x + crossSize, y);
7751        // Vertical line
7752        ctx.moveTo(x, y - crossSize);
7753        ctx.lineTo(x, y + crossSize);
7754        ctx.stroke();
7755        
7756        ctx.restore();
7757      }
7758
7759      // Film camera style timestamp at bottom-left corner
7760      function addFilmTimestamp(ctx, canvasWidth, canvasHeight, typeSize, progress = 0, frameData = null, frameMetadata = null, frameIndex = null, totalFrames = null, bothStampsInvisible = false) {
7761        // Calculate smooth alpha fade based on progress - same for both frame and time recordings
7762        let timestampAlpha = 1.0; // Default to fully visible
7763        
7764        // Smooth fade transitions: visible at start, fade out towards middle, visible at end
7765        if (progress < 0.15) {
7766          // Fully visible at start (0-15%)
7767          timestampAlpha = 1.0;
7768        } else if (progress >= 0.15 && progress <= 0.35) {
7769          // Fade out from 15% to 35% (20% fade-out period)
7770          const fadeProgress = (progress - 0.15) / 0.20;
7771          timestampAlpha = 1.0 - Math.pow(fadeProgress, 2); // Smooth quadratic fade out
7772        } else if (progress > 0.35 && progress < 0.65) {
7773          // Fully hidden from 35% to 65% (30% hidden period)
7774          timestampAlpha = 0.0;
7775        } else if (progress >= 0.65 && progress <= 0.85) {
7776          // Fade in from 65% to 85% (20% fade-in period)
7777          const fadeProgress = (progress - 0.65) / 0.20;
7778          timestampAlpha = Math.pow(fadeProgress, 2); // Smooth quadratic fade in
7779        } else {
7780          // Fully visible at end (85%-100%)
7781          timestampAlpha = 1.0;
7782        }
7783        
7784        // Skip drawing timecode if it's completely transparent
7785        if (timestampAlpha <= 0.0) {
7786          return;
7787        }
7788        
7789        // Define progress bar height consistently across all contexts
7790        const progressBarHeight = 1;
7791        
7792        // Define timestamp margin for consistent spacing
7793        const timestampMargin = Math.max(8, Math.floor(canvasHeight / 50)); // Responsive margin
7794        
7795        // NEW: Use actual real-world timestamp when this frame was originally captured
7796        let timestamp;
7797        if (frameMetadata && frameMetadata.originalTimestamp) {
7798          // Display the actual time when this frame was captured
7799          const frameDate = new Date(frameMetadata.originalTimestamp);
7800          const year = frameDate.getFullYear();
7801          const month = frameDate.getMonth() + 1; // getMonth() returns 0-11
7802          const day = frameDate.getDate();
7803          const hour = frameDate.getHours();
7804          const minute = frameDate.getMinutes();
7805          const second = frameDate.getSeconds();
7806          const millisecond = frameDate.getMilliseconds();
7807          
7808          // Format as: year.month.day.hour.minute.second.milliseconds (no zero-padding except milliseconds)
7809          timestamp = `${year}.${month}.${day}.${hour}.${minute}.${second}.${millisecond.toString().padStart(3, "0")}`;
7810        } else if (frameMetadata && frameMetadata.gifElapsedSeconds !== undefined) {
7811          // For GIF playback: use actual GIF elapsed time
7812          timestamp = `${frameMetadata.gifElapsedSeconds.toFixed(1)}s`;
7813        } else if (mediaRecorderStartTime !== undefined) {
7814          // During recording: show seconds elapsed since recording started
7815          const elapsedMs = performance.now() - mediaRecorderStartTime;
7816          const elapsedSeconds = elapsedMs / 1000;
7817          timestamp = `${elapsedSeconds.toFixed(1)}s`;
7818        } else if (window.recordingStartTimestamp && progress >= 0) {
7819          // Use progress to map to intended recording duration
7820          let actualRecordingDurationMs = 5000; // Default fallback
7821          
7822          // Prioritize intended duration for accurate real-time mapping
7823          if (window.currentRecordingOptions?.intendedDuration) {
7824            actualRecordingDurationMs = window.currentRecordingOptions.intendedDuration * 1000;
7825          } else if (mediaRecorderDuration && mediaRecorderDuration > 0) {
7826            actualRecordingDurationMs = mediaRecorderDuration;
7827          } else if (window.gifDurationMs && window.gifDurationMs > 0) {
7828            actualRecordingDurationMs = window.gifDurationMs;
7829          }
7830          
7831          // Calculate elapsed seconds
7832          const elapsedSeconds = (progress * actualRecordingDurationMs) / 1000;
7833          timestamp = `${elapsedSeconds.toFixed(1)}s`;
7834        } else {
7835          // Fallback: show current time
7836          const now = new Date();
7837          const year = now.getFullYear();
7838          const month = now.getMonth() + 1;
7839          const day = now.getDate();
7840          const hour = now.getHours();
7841          const minute = now.getMinutes();
7842          const second = now.getSeconds();
7843          const millisecond = now.getMilliseconds();
7844          
7845          timestamp = `${year}.${month}.${day}.${hour}.${minute}.${second}.${millisecond.toString().padStart(3, "0")}`;
7846        }
7847
7848        ctx.save();
7849        
7850        // Remove shake/jitter effect - keep timestamp steady
7851        const shakeX = 0; // No more shaking
7852        const shakeY = 0; // No more shaking
7853        
7854        // Position timestamp above the progress bar with proper margin, more flush left and larger
7855        // Adjust margin to make room for Tezos logo if enabled
7856        let adjustedMargin = timestampMargin * 0.5;
7857        if (window.currentRecordingOptions?.showTezosStamp) {
7858          const tezosSize = Math.max(20, Math.floor(typeSize * 1.5 * 1.2));
7859          adjustedMargin = timestampMargin * 1.8; // Less space since we moved Tezos closer
7860        }
7861        const timestampY = Math.floor(canvasHeight - progressBarHeight - timestampMargin * 0.6 - (typeSize * 0.2) - 4); // Move UP 4px more
7862        const timestampX = Math.floor(typeSize * 0.5); // Move back right a bit (was 0.3)
7863        
7864        // Make timestamp smaller and more readable
7865        const baseWatermarkSize = Math.floor(typeSize * 1.7); // Smaller timestamp (was 2.0)
7866        // Keep timestamp same size for both frame and time recordings
7867        const watermarkSize = baseWatermarkSize;
7868        
7869        // Draw multiple layered versions for that vibey aesthetic.computer stamp effect
7870        ["red", "lime", "blue", "white"].forEach((color, index) => {
7871          let offsetX, offsetY;
7872          if (color !== "white") {
7873            ctx.globalAlpha = 0.45 * timestampAlpha; // Simple multicolor layers
7874            offsetX = choose([-3, -5, 0, 3, 5]); // Larger offsets for bigger text
7875            offsetY = choose([-3, -5, 0, 3, 5]);
7876          } else {
7877            ctx.globalAlpha = 0.65 * timestampAlpha; // Consistent white layer
7878            offsetX = choose([-1, 0, 1]);
7879            offsetY = choose([-1, 0, 1]);
7880            color = "white"; // Consistent white color, no blinking
7881          }
7882
7883          ctx.fillStyle = color;
7884          ctx.font = `bold ${watermarkSize}px YWFTProcessing-Regular`;
7885          
7886          ctx.fillText(
7887            timestamp,
7888            timestampX + offsetX + shakeX,
7889            timestampY + offsetY + shakeY,
7890          );
7891        });
7892        
7893        // Removed Tezos logo from timestamp area - moving back to top-right
7894
7895        ctx.restore();
7896      }
7897
7898      // Helper function to sample representative colors from frame data
7899      function sampleFrameColors(frameData, canvasWidth, canvasHeight, numColors = 5) {
7900        if (!frameData || frameData.length < 4) {
7901          // Fallback to original colors if no frame data
7902          return ["#FF4444", "#FF6666", "#FFAA44", "#FF8888", "#FFCCCC"];
7903        }
7904
7905        const sampledColors = [];
7906        const totalPixels = canvasWidth * canvasHeight;
7907        
7908        // Sample colors from different regions of the frame
7909        let samplePositions;
7910        if (numColors <= 5) {
7911          samplePositions = [0.1, 0.3, 0.5, 0.7, 0.9]; // Original 5 positions
7912        } else {
7913          // Generate more evenly distributed positions for higher numColors
7914          samplePositions = [];
7915          for (let i = 0; i < numColors; i++) {
7916            samplePositions.push((i + 1) / (numColors + 1)); // Evenly distributed from ~0.1 to ~0.9
7917          }
7918        }
7919
7920        for (let i = 0; i < numColors; i++) {
7921          const samplePos = Math.floor(samplePositions[i % samplePositions.length] * totalPixels);
7922          const pixelIndex = Math.min(samplePos * 4, frameData.length - 4);
7923          
7924          const r = frameData[pixelIndex];
7925          const g = frameData[pixelIndex + 1];
7926          const b = frameData[pixelIndex + 2];
7927          
7928          // Enhance the colors to make them more vibrant for the progress bar
7929          const enhancedR = Math.min(255, Math.floor(r * 1.2));
7930          const enhancedG = Math.min(255, Math.floor(g * 1.2));
7931          const enhancedB = Math.min(255, Math.floor(b * 1.2));
7932          
7933          const hexColor = `#${enhancedR.toString(16).padStart(2, '0')}${enhancedG.toString(16).padStart(2, '0')}${enhancedB.toString(16).padStart(2, '0')}`;
7934          sampledColors.push(hexColor);
7935        }
7936
7937        // If all sampled colors are too dark, add some brightness
7938        const avgBrightness = sampledColors.reduce((sum, color) => {
7939          const r = parseInt(color.slice(1, 3), 16);
7940          const g = parseInt(color.slice(3, 5), 16);
7941          const b = parseInt(color.slice(5, 7), 16);
7942          return sum + (r + g + b) / 3;
7943        }, 0) / sampledColors.length;
7944
7945        if (avgBrightness < 60) {
7946          // Mix in some brighter colors if the frame is too dark
7947          return sampledColors.map((color, i) => {
7948            if (i % 2 === 0) {
7949              return choose(["#FF6666", "#FFAA44", "#FF8888", "#FFCCCC", "#66FF66", "#6666FF"]);
7950            }
7951            return color;
7952          });
7953        }
7954
7955        return sampledColors;
7956      }
7957
7958      // Helper function for choosing random values (simplified version)
7959      function choose(options) {
7960        return options[Math.floor(Math.random() * options.length)];
7961      }
7962    }
7963
7964    // Helper function to upscale pixels with nearest neighbor
7965    function upscalePixels(imageData, originalWidth, originalHeight, scale) {
7966      if (scale === 1) {
7967        return imageData;
7968      }
7969
7970      const scaledWidth = originalWidth * scale;
7971      const scaledHeight = originalHeight * scale;
7972      const scaledImageData = new Uint8ClampedArray(
7973        scaledWidth * scaledHeight * 4,
7974      );
7975
7976      for (let y = 0; y < scaledHeight; y++) {
7977        for (let x = 0; x < scaledWidth; x++) {
7978          const sourceX = Math.floor(x / scale);
7979          const sourceY = Math.floor(y / scale);
7980          const sourceIndex = (sourceY * originalWidth + sourceX) * 4;
7981          const targetIndex = (y * scaledWidth + x) * 4;
7982
7983          scaledImageData[targetIndex] = imageData[sourceIndex]; // R
7984          scaledImageData[targetIndex + 1] = imageData[sourceIndex + 1]; // G
7985          scaledImageData[targetIndex + 2] = imageData[sourceIndex + 2]; // B
7986          scaledImageData[targetIndex + 3] = imageData[sourceIndex + 3]; // A
7987        }
7988      }
7989
7990      return scaledImageData;
7991    }
7992
7993    // Helper function to add stamp to pixel data by rendering to a canvas first
7994    async function addStampToPixelData(pixelData, width, height, scale = 1, progress = 0, frameMetadata = null) {
7995      // Create a temporary canvas to render the stamp
7996      const tempCanvas = document.createElement("canvas");
7997      const tempCtx = tempCanvas.getContext("2d");
7998
7999      tempCanvas.width = width * scale;
8000      tempCanvas.height = height * scale;
8001
8002      // Put the original image data
8003      const scaledData = upscalePixels(pixelData, width, height, scale);
8004      const imageData = new ImageData(
8005        scaledData,
8006        width * scale,
8007        height * scale,
8008      );
8009      tempCtx.putImageData(imageData, 0, 0);
8010
8011      // Add the stamp (await to ensure fonts are loaded)
8012      await addAestheticComputerStamp(tempCtx, width * scale, height * scale, progress, pixelData, frameMetadata);
8013
8014      // Get the stamped image data back
8015      const stampedImageData = tempCtx.getImageData(
8016        0,
8017        0,
8018        width * scale,
8019        height * scale,
8020      );
8021      return stampedImageData.data;
8022    }
8023
8024    // 🎬 Create animated WebP from frame data
8025    if (type === "create-animated-webp") {
8026      console.log(
8027        "📼 Creating animated WebP from",
8028        content.frames.length,
8029        "frames",
8030      );
8031
8032      // Send initial progress to show the progress bar immediately
8033      send({
8034        type: "recorder:transcode-progress",
8035        content: 0.01, // 1% to start
8036      });
8037
8038      try {
8039        // Create a canvas for frame processing
8040        const canvas = document.createElement("canvas");
8041        const ctx = canvas.getContext("2d");
8042
8043        if (content.frames.length === 0) {
8044          console.warn("No frames provided for WebP creation");
8045          return;
8046        }
8047
8048        // Set canvas size based on first frame
8049        const firstFrame = content.frames[0];
8050        canvas.width = firstFrame.width;
8051        canvas.height = firstFrame.height;
8052        console.log(`🖼️ Canvas size: ${canvas.width}x${canvas.height}`);
8053
8054        // For now, create a zip of WebP frames with timing info
8055        // TODO: Implement proper animated WebP encoding when browser support improves
8056        if (!window.JSZip) await loadJSZip();
8057        const zip = new window.JSZip();
8058
8059        // Add timing info
8060        const timingInfo = content.frames.map((frame, i) => ({
8061          frame: i,
8062          duration: frame.duration,
8063          timestamp: frame.timestamp,
8064        }));
8065
8066        zip.file("timing.json", JSON.stringify(timingInfo, null, 2));
8067        console.log(
8068          "📋 Added timing.json with",
8069          timingInfo.length,
8070          "frame entries",
8071        );
8072
8073        // Convert each frame to WebP and add to zip
8074        for (let i = 0; i < content.frames.length; i++) {
8075          const frame = content.frames[i];
8076
8077          if (i % 50 === 0 || i === content.frames.length - 1) {
8078            console.log(
8079              `🎞️ Processing frame ${i + 1}/${content.frames.length} (${Math.round(((i + 1) / content.frames.length) * 100)}%)`,
8080            );
8081          }
8082
8083          // Create ImageData from frame data
8084          const imageData = new ImageData(
8085            new Uint8ClampedArray(frame.data),
8086            frame.width,
8087            frame.height,
8088          );
8089
8090          // Draw to canvas and convert to WebP
8091          ctx.putImageData(imageData, 0, 0);
8092
8093          // Add sideways AC stamp like in video recordings (await to ensure fonts are loaded)
8094          const currentProgress = (i + 1) / content.frames.length;
8095          await addAestheticComputerStamp(ctx, frame.width, frame.height, currentProgress, frame.data, frame, i, content.frames.length);
8096
8097          // Convert to WebP blob
8098          const webpBlob = await new Promise((resolve) => {
8099            canvas.toBlob(resolve, "image/webp", 0.8);
8100          });
8101
8102          // Add to zip
8103          zip.file(`frame_${i.toString().padStart(4, "0")}.webp`, webpBlob);
8104        }
8105
8106        console.log("📦 Generating ZIP file...");
8107
8108        // Generate and download the zip
8109        const zipBlob = await zip.generateAsync({ type: "blob" });
8110        const filename = generateTapeFilename("zip", "-webp");
8111
8112        console.log(
8113          `💾 ZIP generated: ${filename} (${Math.round((zipBlob.size / 1024 / 1024) * 100) / 100} MB)`,
8114        );
8115
8116        // Use the existing download function
8117        receivedDownload({ filename, data: zipBlob });
8118
8119        console.log("🎬 WebP frames exported successfully as ZIP");
8120      } catch (error) {
8121        console.error("Error creating animated WebP:", error);
8122      }
8123      return;
8124    }
8125
8126    // 🎬 Create single animated WebP file
8127    if (type === "create-single-animated-webp") {
8128      console.log(
8129        "🎞️ Creating animated image from",
8130        content.frames.length,
8131        "frames",
8132      );
8133
8134      try {
8135        if (content.frames.length === 0) {
8136          console.warn("No frames provided for animated image creation");
8137          return;
8138        }
8139
8140        // Try WebP animation first using webpxmux.js
8141        console.log("🔄 Creating animated WebP using webpxmux.js library");
8142
8143        // Load webpxmux.js if not already loaded
8144        if (!window.WebPXMux) {
8145          console.log("📦 Loading webpxmux.js library...");
8146          await new Promise((resolve, reject) => {
8147            const script = document.createElement("script");
8148            script.src =
8149              "https://cdn.jsdelivr.net/npm/[email protected]/dist/webpxmux.min.js";
8150            script.onload = resolve;
8151            script.onerror = reject;
8152            document.head.appendChild(script);
8153          });
8154        }
8155
8156        console.log(
8157          `🖼️ Canvas size: ${content.frames[0].width}x${content.frames[0].height}`,
8158        );
8159
8160        // Initialize WebPXMux
8161        const xMux = window.WebPXMux(
8162          "https://cdn.jsdelivr.net/npm/[email protected]/dist/webpxmux.wasm",
8163        );
8164        await xMux.waitRuntime();
8165
8166        // Convert our frame data to WebPXMux format
8167        const frames = [];
8168
8169        // Create a canvas for rendering stamps
8170        const tempCanvas = document.createElement("canvas");
8171        const tempCtx = tempCanvas.getContext("2d");
8172        tempCanvas.width = content.frames[0].width;
8173        tempCanvas.height = content.frames[0].height;
8174
8175        for (let i = 0; i < content.frames.length; i++) {
8176          if (i % 50 === 0 || i === content.frames.length - 1) {
8177            console.log(
8178              `🎞️ Processing frame ${i + 1}/${content.frames.length} (${Math.round(((i + 1) / content.frames.length) * 100)}%)`,
8179            );
8180          }
8181
8182          const frame = content.frames[i];
8183
8184          // Create ImageData from frame data and draw to canvas
8185          const imageData = new ImageData(
8186            new Uint8ClampedArray(frame.data),
8187            frame.width,
8188            frame.height,
8189          );
8190          tempCtx.putImageData(imageData, 0, 0);
8191
8192          // Add sideways AC stamp like in other video recordings
8193          const currentProgress = (i + 1) / content.frames.length;
8194          await addAestheticComputerStamp(tempCtx, frame.width, frame.height, currentProgress, frame.data, frame);
8195
8196          // Get the stamped image data back
8197          const stampedImageData = tempCtx.getImageData(
8198            0,
8199            0,
8200            frame.width,
8201            frame.height,
8202          );
8203          const stampedData = stampedImageData.data;
8204
8205          // Convert frame data to RGBA Uint32Array format expected by webpxmux
8206          const rgba = new Uint32Array(frame.width * frame.height);
8207
8208          for (let j = 0; j < rgba.length; j++) {
8209            const pixelIndex = j * 4;
8210            const r = stampedData[pixelIndex];
8211            const g = stampedData[pixelIndex + 1];
8212            const b = stampedData[pixelIndex + 2];
8213            const a = stampedData[pixelIndex + 3];
8214
8215            // Pack RGBA into 32-bit integer (0xRRGGBBAA format)
8216            rgba[j] = (r << 24) | (g << 16) | (b << 8) | a;
8217          }
8218
8219          frames.push({
8220            duration: Math.max(frame.duration || 100, 10),
8221            isKeyframe: i === 0, // First frame is keyframe
8222            rgba: rgba,
8223          });
8224        }
8225
8226        console.log("🔄 Encoding animated WebP...");
8227
8228        const webpFrames = {
8229          frameCount: frames.length,
8230          width: content.frames[0].width,
8231          height: content.frames[0].height,
8232          loopCount: 0, // Infinite loop
8233          bgColor: 0xffffffff, // White background
8234          frames: frames,
8235        };
8236
8237        const webpData = await xMux.encodeFrames(webpFrames);
8238
8239        const webpBlob = new Blob([webpData], { type: "image/webp" });
8240        const filename = generateTapeFilename("webp", "-animated");
8241
8242        console.log(
8243          `💾 Animated WebP generated: ${filename} (${Math.round((webpBlob.size / 1024 / 1024) * 100) / 100} MB)`,
8244        );
8245
8246        // Use the existing download function
8247        receivedDownload({ filename, data: webpBlob });
8248
8249        console.log("🎬 Animated WebP exported successfully!");
8250      } catch (error) {
8251        console.error("Error creating animated WebP:", error);
8252        console.log("🔄 Falling back to animated PNG (APNG)");
8253
8254        try {
8255          // Fallback to APNG using UPNG.js
8256          console.log("🔄 Creating animated PNG (APNG) - fallback option");
8257
8258          // Load pako (required for UPNG.js compression) and UPNG.
8258js library
8259          if (!window.pako) {
8260            console.log("📦 Loading pako compression library...");
8261            await new Promise((resolve, reject) => {
8262              const script = document.createElement("script");
8263              script.src =
8264                "https://cdn.jsdelivr.net/npm/[email protected]/dist/pako.min.js";
8265              script.onload = resolve;
8266              script.onerror = reject;
8267              document.head.appendChild(script);
8268            });
8269          }
8270
8271          if (!window.UPNG) {
8272            console.log("📦 Loading UPNG.js library...");
8273            await new Promise((resolve, reject) => {
8274              const script = document.createElement("script");
8275              script.src = "https://cdn.jsdelivr.net/npm/[email protected]/UPNG.js";
8276              script.onload = resolve;
8277              script.onerror = reject;
8278              document.head.appendChild(script);
8279            });
8280          }
8281
8282          console.log(
8283            `🖼️ Canvas size: ${content.frames[0].width}x${content.frames[0].height}`,
8284          );
8285
8286          // Convert frames to the format UPNG expects
8287          const frameBuffers = [];
8288          const delays = [];
8289
8290          for (let i = 0; i < content.frames.length; i++) {
8291            const frame = content.frames[i];
8292
8293            if (i % 50 === 0 || i === content.frames.length - 1) {
8294              console.log(
8295                `🎞️ Processing frame ${i + 1}/${content.frames.length} (${Math.round(((i + 1) / content.frames.length) * 100)}%)`,
8296              );
8297            }
8298
8299            // UPNG expects RGBA data as ArrayBuffer
8300            const frameData = new Uint8Array(frame.data);
8301            frameBuffers.push(frameData.buffer);
8302
8303            // Convert to milliseconds for APNG delays
8304            delays.push(Math.max(frame.duration || 100, 10));
8305          }
8306
8307          console.log("🔄 Encoding animated PNG...");
8308
8309          // Create animated PNG using UPNG
8310          const apngBuffer = window.UPNG.encode(
8311            frameBuffers,
8312            content.frames[0].width,
8313            content.frames[0].height,
8314            0, // 0 = lossless, or use 256 for lossy
8315            delays,
8316          );
8317
8318          const apngBlob = new Blob([apngBuffer], { type: "image/png" });
8319          const filename = generateTapeFilename("png", "-animated");
8320
8321          console.log(
8322            `💾 Animated PNG generated: ${filename} (${Math.round((apngBlob.size / 1024 / 1024) * 100) / 100} MB)`,
8323          );
8324
8325          // Use the existing download function
8326          receivedDownload({ filename, data: apngBlob });
8327
8328          console.log("🎬 Animated PNG (APNG) exported successfully!");
8329        } catch (apngError) {
8330          console.error("Error creating animated PNG:", apngError);
8331
8332          // Final fallback: create a static WebP of the first frame
8333          console.log("🔄 Falling back to static WebP of first frame");
8334          try {
8335            const canvas = document.createElement("canvas");
8336            const ctx = canvas.getContext("2d");
8337            const firstFrame = content.frames[0];
8338
8339            canvas.width = firstFrame.width;
8340            canvas.height = firstFrame.height;
8341
8342            const imageData = new ImageData(
8343              new Uint8ClampedArray(firstFrame.data),
8344              firstFrame.width,
8345              firstFrame.height,
8346            );
8347
8348            ctx.putImageData(imageData, 0, 0);
8349
8350            const webpBlob = await new Promise((resolve) => {
8351              canvas.toBlob(resolve, "image/webp", 0.9);
8352            });
8353
8354            const filename = generateTapeFilename("webp", "-static");
8355            receivedDownload({ filename, data: webpBlob });
8356
8357            console.log("📸 Static WebP fallback exported successfully");
8358          } catch (fallbackError) {
8359            console.error("Error in fallback WebP creation:", fallbackError);
8360          }
8361        }
8362      }
8363      return;
8364    }
8365
8366    // 🎬 Handle WebP frame chunks
8367    if (type === "create-animated-webp-only-chunk") {
8368      if (window.webpFrameChunks) {
8369        window.webpFrameChunks.push(content.frames);
8370        window.webpReceivedChunks++;
8371        
8372        console.log(
8373          `🎞️ Received WebP chunk ${window.webpReceivedChunks}/${window.webpTotalChunks}:`,
8374          content.frames.length,
8375          "frames"
8376        );
8377
8378        if (window.webpReceivedChunks === window.webpTotalChunks) {
8379          console.log("🎞️ All WebP chunks received, reassembling...");
8380          const allFrames = window.webpFrameChunks.flat();
8381          console.log("🎞️ Total frames after reassembly:", allFrames.length);
8382
8383          delete window.webpFrameChunks;
8384          delete window.webpTotalChunks;
8385          delete window.webpReceivedChunks;
8386
8387          receivedChange({
8388            data: {
8389              type: "create-animated-webp-only",
8390              content: { frames: allFrames }
8391            }
8392          });
8393        }
8394      }
8395      return;
8396    }
8397
8398    // 🎬 Create animated WebP only (no fallback to APNG)
8399    if (type === "create-animated-webp-only") {
8400      if (content.totalChunks && content.totalChunks > 1) {
8401        console.log(
8402          `🎞️ Receiving WebP frames in chunks (1/${content.totalChunks}):`,
8403          content.frames.length,
8404          "frames"
8405        );
8406        window.webpFrameChunks = [content.frames];
8407        window.webpTotalChunks = content.totalChunks;
8408        window.webpReceivedChunks = 1;
8409        return;
8410      }
8411
8412      console.log(
8413        "🎞️ Creating animated WebP from",
8414        content.frames.length,
8415        "frames",
8416      );
8417
8418      // Helper function to upscale pixels with nearest neighbor - with memory safety
8419      function upscalePixels(rgba, originalWidth, originalHeight, scale) {
8420        if (scale === 1) {
8421          return { rgba: rgba, actualScale: 1 };
8422        }
8423
8424        const scaledWidth = originalWidth * scale;
8425        const scaledHeight = originalHeight * scale;
8426        const totalPixels = scaledWidth * scaledHeight;
8427
8428        // Safety check to prevent array allocation errors
8429        const maxSafeArraySize = 100 * 1024 * 1024; // 100M elements max
8430        if (totalPixels > maxSafeArraySize) {
8431          console.warn(
8432            `⚠️ Scaled array too large (${totalPixels} pixels), falling back to 1x scaling`,
8433          );
8434          return { rgba: rgba, actualScale: 1 };
8435        }
8436
8437        let scaledRgba;
8438        try {
8439          scaledRgba = new Uint32Array(totalPixels);
8440        } catch (error) {
8441          console.warn(
8442            `⚠️ Failed to allocate scaled array: ${error.message}, falling back to 1x scaling`,
8443          );
8444          return { rgba: rgba, actualScale: 1 };
8445        }
8446
8447        for (let y = 0; y < scaledHeight; y++) {
8448          for (let x = 0; x < scaledWidth; x++) {
8449            const sourceX = Math.floor(x / scale);
8450            const sourceY = Math.floor(y / scale);
8451            const sourceIndex = sourceY * originalWidth + sourceX;
8452            const targetIndex = y * scaledWidth + x;
8453            scaledRgba[targetIndex] = rgba[sourceIndex];
8454          }
8455        }
8456
8457        return { rgba: scaledRgba, actualScale: scale };
8458      }
8459
8460      try {
8461        if (content.frames.length === 0) {
8462          console.warn("No frames provided for animated WebP creation");
8463          return;
8464        }
8465
8466        console.log("🔄 Creating animated WebP using webpxmux.js library");
8467
8468        // Load webpxmux.js using local dep files
8469        if (!window.WebPXMux) {
8470          console.log("📦 Loading webpxmux.js library...");
8471          console.log(
8472            "📍 Script URL: /aesthetic.computer/dep/webpxmux/webpxmux.min.js",
8473          );
8474
8475          await new Promise((resolve, reject) => {
8476            const script = document.createElement("script");
8477            script.src = "/aesthetic.computer/dep/webpxmux/webpxmux.min.js";
8478            script.type = "text/javascript";
8479
8480            script.onload = () => {
8481              console.log("✅ webpxmux.js script loaded successfully");
8482              console.log(
8483                "🔍 Checking window.WebPXMux:",
8484                typeof window.WebPXMux,
8485              );
8486              if (window.WebPXMux) {
8487                console.log("✅ WebPXMux constructor found on window");
8488              } else {
8489                console.error(
8490                  "❌ WebPXMux not found on window after script load",
8491                );
8492              }
8493              resolve();
8494            };
8495
8496            script.onerror = (error) => {
8497              console.error("❌ Failed to load webpxmux.js script");
8498              console.error("📍 Error event:", error);
8499              console.error("📍 Script src:", script.src);
8500              console.error("📍 Script type:", script.type);
8501              reject(error);
8502            };
8503
8504            console.log("📎 Appending script to document head");
8505            document.head.appendChild(script);
8506          });
8507        } else {
8508          console.log("✅ webpxmux.js already loaded");
8509        }
8510
8511        console.log(
8512          `🖼️ Canvas size: ${content.frames[0].width}x${content.frames[0].height} -> checking memory requirements...`,
8513        );
8514
8515        // Pre-calculate optimal scale to avoid memory issues
8516        const originalWidth = content.frames[0].width;
8517        const originalHeight = content.frames[0].height;
8518        const frameCount = content.frames.length;
8519
8520        // Ultra-conservative memory management for WebP animations
8521        // Each frame needs significant memory overhead beyond just pixel data
8522        const baseMemoryLimit = 5 * 1024 * 1024; // 5M pixels base limit (much smaller)
8523
8524        // Much more aggressive scaling penalties for large frame counts
8525        let framePenalty = 1;
8526        if (frameCount > 500)
8527          framePenalty = 16; // 500+ frames: divide by 16
8528        else if (frameCount > 300)
8529          framePenalty = 12; // 300+ frames: divide by 12
8530        else if (frameCount > 200)
8531          framePenalty = 8; // 200+ frames: divide by 8
8532        else if (frameCount > 100)
8533          framePenalty = 6; // 100+ frames: divide by 6
8534        else if (frameCount > 50)
8535          framePenalty = 4; // 50+ frames: divide by 4
8536        else if (frameCount > 25) framePenalty = 2; // 25+ frames: divide by 2
8537
8538        const adjustedMemoryLimit = Math.max(
8539          baseMemoryLimit / framePenalty,
8540          256 * 1024, // Minimum 256K pixels (very small for safety)
8541        );
8542
8543        // Start with smaller default scaling
8544        let optimalScale = frameCount > 400 ? 1 : 2; // Default to 2x for more animations, only 1x for very large ones
8545        let scaledPixels =
8546          originalWidth * originalHeight * optimalScale * optimalScale;
8547
8548        console.log(
8549          `🧮 Conservative memory calculation: ${frameCount} frames, penalty: ${framePenalty}x, ${scaledPixels} pixels per frame (${optimalScale}x), limit: ${Math.round(adjustedMemoryLimit / 1024)}K pixels`,
8550        );
8551
8552        // More aggressive downscaling if needed
8553        if (scaledPixels > adjustedMemoryLimit) {
8554          optimalScale = 1;
8555          scaledPixels = originalWidth * originalHeight;
8556          console.warn(
8557            `⚠️ Forcing 1x scaling for ${frameCount} frames to prevent memory errors`,
8558          );
8559
8560          // If even 1x is too large, we have a problem
8561          if (scaledPixels > adjustedMemoryLimit) {
8562            console.error(
8563              `❌ Video too large even at 1x scaling: ${Math.round(scaledPixels / 1024)}K pixels > ${Math.round(adjustedMemoryLimit / 1024)}K limit`,
8564            );
8565            throw new Error(
8566              `Video dimensions too large for WebP encoding: ${originalWidth}x${originalHeight} with ${frameCount} frames`,
8567            );
8568          }
8569        }
8570
8571        console.log(
8572          `📏 Using ${optimalScale}x scaling: ${originalWidth}x${originalHeight} -> ${originalWidth * optimalScale}x${originalHeight * optimalScale}`,
8573        );
8574
8575        // Initialize WebPXMux
8576        console.log("🔧 Initializing WebPXMux...");
8577        console.log(
8578          "📍 WASM URL: /aesthetic.computer/dep/webpxmux/webpxmux.wasm",
8579        );
8580
8581        const xMux = window.WebPXMux(
8582          "/aesthetic.computer/dep/webpxmux/webpxmux.wasm",
8583        );
8584        console.log("✅ WebPXMux instance created:", xMux);
8585
8586        console.log("⏳ Waiting for WebAssembly runtime...");
8587        await xMux.waitRuntime();
8588        console.log("✅ WebAssembly runtime ready!");
8589
8590        // Verify xMux instance methods are available
8591        console.log("🔍 Verifying xMux methods:", {
8592          hasEncodeFrames: typeof xMux.encodeFrames === "function",
8593          hasWaitRuntime: typeof xMux.waitRuntime === "function",
8594          xMuxKeys: Object.keys(xMux),
8595        });
8596
8597        // Convert our frame data to WebPXMux format with smart upscaling
8598        const frames = [];
8599
8600        for (let i = 0; i < content.frames.length; i++) {
8601          if (i % 50 === 0 || i === content.frames.length - 1) {
8602            console.log(
8603              `🎞️ Processing frame ${i + 1}/${content.frames.length} (${Math.round(((i + 1) / content.frames.length) * 100)}%)`,
8604            );
8605          }
8606
8607          const frame = content.frames[i];
8608
8609          // Convert frame data to RGBA Uint32Array format expected by webpxmux
8610          let rgba = new Uint32Array(frame.width * frame.height);
8611          const data = frame.data;
8612
8613          for (let j = 0; j < rgba.length; j++) {
8614            const pixelIndex = j * 4;
8615            const r = data[pixelIndex];
8616            const g = data[pixelIndex + 1];
8617            const b = data[pixelIndex + 2];
8618            const a = data[pixelIndex + 3];
8619
8620            // Pack RGBA into 32-bit integer (0xRRGGBBAA format)
8621            rgba[j] = (r << 24) | (g << 16) | (b << 8) | a;
8622          }
8623
8624          // Add AC stamp to the frame data by rendering through canvas
8625          const currentProgress = (i + 1) / content.frames.length;
8626          const stampedPixelData = await addStampToPixelData(
8627            new Uint8ClampedArray(data),
8628            frame.width,
8629            frame.height,
8630            optimalScale,
8631            currentProgress,
8632            frame,
8633          );
8634
8635          // Convert stamped pixel data back to RGBA Uint32Array
8636          rgba = new Uint32Array(
8637            frame.width * frame.height * optimalScale * optimalScale,
8638          );
8639          for (let j = 0; j < rgba.length; j++) {
8640            const pixelIndex = j * 4;
8641            const r = stampedPixelData[pixelIndex];
8642            const g = stampedPixelData[pixelIndex + 1];
8643            const b = stampedPixelData[pixelIndex + 2];
8644            const a = stampedPixelData[pixelIndex + 3];
8645
8646            // Pack RGBA into 32-bit integer (0xRRGGBBAA format)
8647            rgba[j] = (r << 24) | (g << 16) | (b << 8) | a;
8648          }
8649
8650          // Skip the original upscaling since we already did it with the stamp
8651          const upscaleResult = { rgba: rgba, actualScale: optimalScale };
8652
8653          // Update actual scale if upscaling failed
8654          if (upscaleResult.actualScale !== optimalScale && i === 0) {
8655            console.warn(
8656              `⚠️ Upscaling failed, using ${upscaleResult.actualScale}x instead of ${optimalScale}x`,
8657            );
8658            optimalScale = upscaleResult.actualScale; // Update for remaining frames
8659          }
8660
8661          frames.push({
8662            duration: Math.max(frame.duration || 16.67, 10), // Default 60fps (~16.67ms)
8663            isKeyframe: i === 0, // First frame is keyframe
8664            rgba: upscaleResult.rgba,
8665          });
8666
8667          // Aggressive memory cleanup
8668          rgba.fill(0);
8669          rgba = null; // Help GC
8670        }
8671
8672        console.log("🔄 Encoding animated WebP...");
8673
8674        // Use the actual scale that was achieved (may be different if upscaling failed)
8675        const actualWidth =
8676          frames.length > 0
8677            ? Math.sqrt(
8678                frames[0].rgba.length / (originalWidth * originalHeight),
8679              ) * originalWidth
8680            : originalWidth;
8681        const actualHeight =
8682          frames.length > 0
8683            ? Math.sqrt(
8684                frames[0].rgba.length / (originalWidth * originalHeight),
8685              ) * originalHeight
8686            : originalHeight;
8687
8688        const webpFrames = {
8689          frameCount: frames.length,
8690          width: Math.round(actualWidth),
8691          height: Math.round(actualHeight),
8692          loopCount: 0, // Infinite loop
8693          bgColor: 0xffffffff, // White background
8694          frames: frames,
8695        };
8696
8697        console.log("� WebP frames data structure:", {
8698          frameCount: webpFrames.frameCount,
8699          width: webpFrames.width,
8700          height: webpFrames.height,
8701          loopCount: webpFrames.loopCount,
8702          bgColor: webpFrames.bgColor.toString(16),
8703          firstFrameDuration: webpFrames.frames[0]?.duration,
8704          firstFrameRgbaLength: webpFrames.frames[0]?.rgba?.length,
8705          totalFrames: webpFrames.frames.length,
8706        });
8707
8708        console.log("�🔧 Calling xMux.encodeFrames...");
8709        const webpData = await xMux.encodeFrames(webpFrames);
8710        console.log("✅ WebP encoding complete! Data length:", webpData.length);
8711
8712        const webpBlob = new Blob([webpData], { type: "image/webp" });
8713        const filename = generateTapeFilename("webp", "-animated");
8714
8715        console.log(
8716          `💾 Animated WebP generated: ${filename} (${Math.round((webpBlob.size / 1024 / 1024) * 100) / 100} MB)`,
8717        );
8718
8719        // Use the existing download function
8720        receivedDownload({ filename, data: webpBlob });
8721
8722        console.log("🎬 Animated WebP exported successfully!");
8723      } catch (error) {
8724        console.error("Error creating animated WebP:", error);
8725        console.log("🔄 Falling back to static WebP of first frame");
8726
8727        try {
8728          const canvas = document.createElement("canvas");
8729          const ctx = canvas.getContext("2d");
8730          const firstFrame = content.frames[0];
8731
8732          canvas.width = firstFrame.width;
8733          canvas.height = firstFrame.height;
8734
8735          const imageData = new ImageData(
8736            new Uint8ClampedArray(firstFrame.data),
8737            firstFrame.width,
8738            firstFrame.height,
8739          );
8740
8741          ctx.putImageData(imageData, 0, 0);
8742
8743          // Add sideways AC stamp to fallback WebP as well (await to ensure fonts are loaded)
8744          await addAestheticComputerStamp(
8745            ctx,
8746            firstFrame.width,
8747            firstFrame.height,
8748            0,
8749            firstFrame.data,
8750            firstFrame,
8751          );
8752
8753          const webpBlob = await new Promise((resolve) => {
8754            canvas.toBlob(resolve, "image/webp", 0.9);
8755          });
8756
8757          const filename = generateTapeFilename("webp", "-static");
8758          receivedDownload({ filename, data: webpBlob });
8759
8760          console.log("📸 Static WebP fallback exported successfully");
8761        } catch (fallbackError) {
8762          console.error("Error in fallback WebP creation:", fallbackError);
8763        }
8764      }
8765      return;
8766    }
8767
8768    // 🎬 Handle APNG frame chunks
8769    if (type === "create-single-animated-apng-chunk") {
8770      if (window.apngFrameChunks) {
8771        window.apngFrameChunks.push(content.frames);
8772        window.apngReceivedChunks++;
8773        
8774        console.log(
8775          `🎞️ Received APNG chunk ${window.apngReceivedChunks}/${window.apngTotalChunks}:`,
8776          content.frames.length,
8777          "frames"
8778        );
8779
8780        if (window.apngReceivedChunks === window.apngTotalChunks) {
8781          console.log("🎞️ All APNG chunks received, reassembling...");
8782          const allFrames = window.apngFrameChunks.flat();
8783          console.log("🎞️ Total frames after reassembly:", allFrames.length);
8784
8785          delete window.apngFrameChunks;
8786          delete window.apngTotalChunks;
8787          delete window.apngReceivedChunks;
8788
8789          receivedChange({
8790            data: {
8791              type: "create-single-animated-apng",
8792              content: { frames: allFrames }
8793            }
8794          });
8795        }
8796      }
8797      return;
8798    }
8799
8800    // 🎬 Create APNG only
8801    if (type === "create-single-animated-apng") {
8802      if (content.totalChunks && content.totalChunks > 1) {
8803        console.log(
8804          `🎞️ Receiving APNG frames in chunks (1/${content.totalChunks}):`,
8805          content.frames.length,
8806          "frames"
8807        );
8808        window.apngFrameChunks = [content.frames];
8809        window.apngTotalChunks = content.totalChunks;
8810        window.apngReceivedChunks = 1;
8811        return;
8812      }
8813
8814      console.log("🎞️ Creating APNG from", content.frames.length, "frames");
8815
8816      // Helper function to upscale pixels with nearest neighbor (same as WebP)
8817      function upscalePixels(imageData, originalWidth, originalHeight, scale) {
8818        if (scale === 1) {
8819          return imageData;
8820        }
8821
8822        const scaledWidth = originalWidth * scale;
8823        const scaledHeight = originalHeight * scale;
8824        const scaledImageData = new Uint8ClampedArray(
8825          scaledWidth * scaledHeight * 4,
8826        );
8827
8828        for (let y = 0; y < scaledHeight; y++) {
8829          for (let x = 0; x < scaledWidth; x++) {
8830            const sourceX = Math.floor(x / scale);
8831            const sourceY = Math.floor(y / scale);
8832            const sourceIndex = (sourceY * originalWidth + sourceX) * 4;
8833            const targetIndex = (y * scaledWidth + x) * 4;
8834
8835            scaledImageData[targetIndex] = imageData[sourceIndex]; // R
8836            scaledImageData[targetIndex + 1] = imageData[sourceIndex + 1]; // G
8837            scaledImageData[targetIndex + 2] = imageData[sourceIndex + 2]; // B
8838            scaledImageData[targetIndex + 3] = imageData[sourceIndex + 3]; // A
8839          }
8840        }
8841
8842        return scaledImageData;
8843      }
8844
8845      try {
8846        if (content.frames.length === 0) {
8847          console.warn("No frames provided for APNG creation");
8848          return;
8849        }
8850
8851        console.log("🔄 Creating animated PNG (APNG)");
8852
8853        // Pre-calculate optimal scale for APNG (same logic as WebP)
8854        const originalWidth = content.frames[0].width;
8855        const originalHeight = content.frames[0].height;
8856        const frameCount = content.frames.length;
8857
8858        // Be more conservative with memory limits for large frame counts
8859        const baseMemoryLimit = 25 * 1024 * 1024; // 25M pixels base limit
8860        const adjustedMemoryLimit = Math.max(
8861          baseMemoryLimit / Math.max(1, frameCount / 500), // Reduce limit for many frames
8862          1024 * 1024, // Minimum 1M pixels
8863        );
8864
8865        let optimalScale = 4;
8866        let scaledPixels =
8867          originalWidth * originalHeight * optimalScale * optimalScale;
8868
8869        console.log(
8870          `🧮 APNG Memory calculation: ${frameCount} frames, ${scaledPixels} pixels per frame (4x), limit: ${Math.round(adjustedMemoryLimit / 1024)}K pixels`,
8871        );
8872
8873        if (scaledPixels > adjustedMemoryLimit) {
8874          optimalScale = 2;
8875          scaledPixels =
8876            originalWidth * originalHeight * optimalScale * optimalScale;
8877          console.warn(
8878            `⚠️ 4x scaling would exceed memory limit for ${frameCount} frames, using 2x scaling`,
8879          );
8880
8881          if (scaledPixels > adjustedMemoryLimit) {
8882            optimalScale = 1;
8883            console.warn(
8884              `⚠️ Even 2x scaling too large for ${frameCount} frames, using original size (1x)`,
8885            );
8886          }
8887        }
8888
8889        console.log(
8890          `📏 APNG Using ${optimalScale}x scaling: ${originalWidth}x${originalHeight} -> ${originalWidth * optimalScale}x${originalHeight * optimalScale}`,
8891        );
8892
8893        // Load pako (required for UPNG.js compression) and UPNG.js library
8894        if (!window.pako) {
8895          console.log("📦 Loading pako compression library...");
8896          await new Promise((resolve, reject) => {
8897            const script = document.createElement("script");
8898            script.src =
8899              "https://cdn.jsdelivr.net/npm/[email protected]/dist/pako.min.js";
8900            script.onload = resolve;
8901            script.onerror = reject;
8902            document.head.appendChild(script);
8903          });
8904        }
8905
8906        if (!window.UPNG) {
8907          console.log("📦 Loading UPNG.js library...");
8908          await new Promise((resolve, reject) => {
8909            const script = document.createElement("script");
8910            script.src = "https://cdn.jsdelivr.net/npm/[email protected]/UPNG.js";
8911            script.onload = resolve;
8912            script.onerror = reject;
8913            document.head.appendChild(script);
8914          });
8915        }
8916
8917        console.log(
8918          `🖼️ Canvas size: ${originalWidth}x${originalHeight} -> ${originalWidth * optimalScale}x${originalHeight * optimalScale}`,
8919        );
8920
8921        // Convert frames to the format UPNG expects with upscaling
8922        const frameBuffers = [];
8923        const delays = [];
8924
8925        for (let i = 0; i < content.frames.length; i++) {
8926          const frame = content.frames[i];
8927
8928          if (i % 50 === 0 || i === content.frames.length - 1) {
8929            console.log(
8930              `🎞️ Processing APNG frame ${i + 1}/${content.frames.length} (${Math.round(((i + 1) / content.frames.length) * 100)}%)`,
8931            );
8932          }
8933
8934          // Apply upscaling and add AC stamp to frame data
8935          const originalData = new Uint8ClampedArray(frame.data);
8936          const currentProgress = (i + 1) / content.frames.length;
8937          const scaledData = await addStampToPixelData(
8938            originalData,
8939            frame.width,
8940            frame.height,
8941            optimalScale,
8942            currentProgress,
8943            frame,
8944          );
8945
8946          // UPNG expects RGBA data as ArrayBuffer
8947          const frameData = new Uint8Array(scaledData);
8948          frameBuffers.push(frameData.buffer);
8949
8950          // Convert to milliseconds for APNG delays with 60fps default
8951          delays.push(Math.max(frame.duration || 16.67, 10)); // Default 60fps (~16.67ms)
8952        }
8953
8954        console.log("🔄 Encoding animated PNG...");
8955
8956        // Create animated PNG using UPNG with scaled dimensions
8957        const apngBuffer = window.UPNG.encode(
8958          frameBuffers,
8959          originalWidth * optimalScale, // Use scaled width
8960          originalHeight * optimalScale, // Use scaled height
8961          0, // 0 = lossless, or use 256 for lossy
8962          delays,
8963        );
8964
8965        const apngBlob = new Blob([apngBuffer], { type: "image/png" });
8966        const filename = generateTapeFilename("png", "-animated");
8967
8968        console.log(
8969          `💾 Animated PNG generated: ${filename} (${Math.round((apngBlob.size / 1024 / 1024) * 100) / 100} MB)`,
8970        );
8971
8972        // Use the existing download function
8973        receivedDownload({ filename, data: apngBlob });
8974
8975        console.log("🎬 Animated PNG (APNG) exported successfully!");
8976      } catch (error) {
8977        console.error("Error creating APNG:", error);
8978        console.log("🔄 Falling back to static PNG of first frame");
8979
8980        try {
8981          const canvas = document.createElement("canvas");
8982          const ctx = canvas.getContext("2d");
8983          const firstFrame = content.frames[0];
8984
8985          canvas.width = firstFrame.width;
8986          canvas.height = firstFrame.height;
8987
8988          const imageData = new ImageData(
8989            new Uint8ClampedArray(firstFrame.data),
8990            firstFrame.width,
8991            firstFrame.height,
8992          );
8993
8994          ctx.putImageData(imageData, 0, 0);
8995
8996          // Add sideways AC stamp to fallback PNG as well (await to ensure fonts are loaded)
8997          await addAestheticComputerStamp(
8998            ctx,
8999            firstFrame.width,
9000            firstFrame.height,
9001            0,
9002            firstFrame.data,
9003            firstFrame,
9004          );
9005
9006          const pngBlob = await new Promise((resolve) => {
9007            canvas.toBlob(resolve, "image/png");
9008          });
9009
9010          const filename = generateTapeFilename("png", "-static");
9011          receivedDownload({ filename, data: pngBlob });
9012
9013          console.log("📸 Static PNG fallback exported successfully");
9014        } catch (fallbackError) {
9015          console.error("Error in fallback PNG creation:", fallbackError);
9016        }
9017      }
9018      return;
9019    }
9020
9021    // 🎬 Handle ZIP frame chunks
9022    if (type === "create-animated-frames-zip-chunk") {
9023      if (window.zipFrameChunks) {
9024        window.zipFrameChunks.push(content.frames);
9025        window.zipReceivedChunks++;
9026        
9027        console.log(
9028          `📦 Received ZIP chunk ${window.zipReceivedChunks}/${window.zipTotalChunks}:`,
9029          content.frames.length,
9030          "frames"
9031        );
9032
9033        if (window.zipReceivedChunks === window.zipTotalChunks) {
9034          console.log("📦 All ZIP chunks received, reassembling...");
9035          const allFrames = window.zipFrameChunks.flat();
9036          console.log("📦 Total frames after reassembly:", allFrames.length);
9037
9038          // Clean up chunk tracking
9039          delete window.zipFrameChunks;
9040          delete window.zipTotalChunks;
9041          delete window.zipReceivedChunks;
9042
9043          // Process as if we received all frames at once
9044          receivedChange({
9045            data: {
9046              type: "create-animated-frames-zip",
9047              content: { frames: allFrames }
9048            }
9049          });
9050        }
9051      }
9052      return;
9053    }
9054
9055    // 🎬 Create ZIP of high-resolution frames
9056    if (type === "create-animated-frames-zip") {
9057      // Handle chunked frame transfer for large recordings
9058      if (content.totalChunks && content.totalChunks > 1) {
9059        console.log(
9060          `📦 Receiving ZIP frames in chunks (1/${content.totalChunks}):`,
9061          content.frames.length,
9062          "frames"
9063        );
9064        window.zipFrameChunks = [content.frames];
9065        window.zipTotalChunks = content.totalChunks;
9066        window.zipReceivedChunks = 1;
9067        return;
9068      }
9069
9070      console.log(
9071        "📦 Creating ZIP of high-resolution frames from",
9072        content.frames.length,
9073        "frames",
9074      );
9075
9076      // Send initial progress to show the progress bar immediately
9077      send({
9078        type: "recorder:transcode-progress",
9079        content: 0.01, // 1% to start
9080      });
9081
9082      try {
9083        if (content.frames.length === 0) {
9084          console.warn("No frames provided for ZIP creation");
9085          return;
9086        }
9087
9088        // Load JSZip if not already loaded
9089        if (!window.JSZip) await loadJSZip();
9090        const zip = new window.JSZip();
9091
9092        // Use 6x scaling for ultra-high resolution frames
9093        const scale = 6;
9094        const originalWidth = content.frames[0].width;
9095        const originalHeight = content.frames[0].height;
9096        const scaledWidth = originalWidth * scale;
9097        const scaledHeight = originalHeight * scale;
9098
9099        console.log(
9100          `📏 Using ${scale}x scaling: ${originalWidth}x${originalHeight} -> ${scaledWidth}x${scaledHeight}`,
9101        );
9102
9103        // Create a canvas for frame processing
9104        const canvas = document.createElement("canvas");
9105        const ctx = canvas.getContext("2d");
9106        canvas.width = scaledWidth;
9107        canvas.height = scaledHeight;
9108
9109        // Process each frame and add to ZIP
9110        for (let i = 0; i < content.frames.length; i++) {
9111          const frame = content.frames[i];
9112
9113          // Update progress
9114          const progress = (i / content.frames.length) * 0.9 + 0.1; // 10-100%
9115          send({
9116            type: "recorder:transcode-progress",
9117            content: progress,
9118          });
9119
9120          if (i % 10 === 0 || i === content.frames.length - 1) {
9121            console.log(
9122              `🖼️ Processing frame ${i + 1}/${content.frames.length} (${Math.round(progress * 100)}%)`,
9123            );
9124          }
9125
9126          // Create ImageData from frame data
9127          const imageData = new ImageData(
9128            new Uint8ClampedArray(frame.data),
9129            frame.width,
9130            frame.height,
9131          );
9132
9133          // Clear canvas and draw scaled frame
9134          ctx.clearRect(0, 0, scaledWidth, scaledHeight);
9135          
9136          // Create a temporary canvas for the original frame
9137          const tempCanvas = document.createElement("canvas");
9138          const tempCtx = tempCanvas.getContext("2d");
9139          tempCanvas.width = frame.width;
9140          tempCanvas.height = frame.height;
9141          
9142          // Put original image data on temp canvas
9143          tempCtx.putImageData(imageData, 0, 0);
9144          
9145          // Add stamp to the original frame with correct frame metadata for timestamp
9146          await addAestheticComputerStamp(
9147            tempCtx,
9148            frame.width,
9149            frame.height,
9150            0, // Progress is not used for individual frame timestamps
9151            frame.data,
9152            frame, // Frame metadata with timestamp
9153            i, // Frame index for timestamp calculation
9154            content.frames.length, // Total frames for timestamp calculation
9155          );
9156          
9157          // Scale up the stamped frame using nearest neighbor
9158          ctx.imageSmoothingEnabled = false;
9159          ctx.drawImage(tempCanvas, 0, 0, scaledWidth, scaledHeight);
9160
9161          // Convert to PNG blob
9162          const pngBlob = await new Promise((resolve) => {
9163            canvas.toBlob(resolve, "image/png");
9164          });
9165
9166          // Add to ZIP with timestamp-based filename
9167          let filename;
9168          if (frame.timestamp !== undefined) {
9169            // Create proper date/time timestamp from frame timestamp
9170            const frameDate = new Date(frame.timestamp);
9171            const year = frameDate.getFullYear();
9172            const month = frameDate.getMonth() + 1;
9173            const day = frameDate.getDate();
9174            const hour = frameDate.getHours();
9175            const minute = frameDate.getMinutes();
9176            const second = frameDate.getSeconds();
9177            const millisecond = frameDate.getMilliseconds();
9178            
9179            // Use same format as main tape filename: year.month.day.hour.minute.second.millisecond
9180            const dateTimestamp = `${year}.${month}.${day}.${hour}.${minute}.${second}.${millisecond.toString().padStart(3, "0")}`;
9181            filename = `frame_${dateTimestamp}.png`;
9182          } else {
9183            // Fallback to index-based naming if timestamp is missing
9184            const frameNumber = String(i).padStart(4, "0");
9185            filename = `frame_${frameNumber}.png`;
9186          }
9187          zip.file(filename, pngBlob);
9188        }
9189
9190        // Function to convert raw audio data to WAV format
9191        function createWavFromRawAudio(rawAudioChunks, sampleRate = 48000) {
9192          if (!rawAudioChunks || rawAudioChunks.length === 0) {
9193            return null;
9194          }
9195
9196          // Calculate total samples
9197          const totalSamples = rawAudioChunks.length * 4096; // 4096 samples per chunk
9198          const numChannels = 2; // Stereo
9199          const bytesPerSample = 2; // 16-bit
9200          const byteRate = sampleRate * numChannels * bytesPerSample;
9201          const blockAlign = numChannels * bytesPerSample;
9202          const dataSize = totalSamples * numChannels * bytesPerSample;
9203          const fileSize = 36 + dataSize;
9204
9205          // Create WAV file buffer
9206          const buffer = new ArrayBuffer(44 + dataSize);
9207          const view = new DataView(buffer);
9208
9209          // WAV header
9210          const writeString = (offset, string) => {
9211            for (let i = 0; i < string.length; i++) {
9212              view.setUint8(offset + i, string.charCodeAt(i));
9213            }
9214          };
9215
9216          writeString(0, 'RIFF');
9217          view.setUint32(4, fileSize, true);
9218          writeString(8, 'WAVE');
9219          writeString(12, 'fmt ');
9220          view.setUint32(16, 16, true); // fmt chunk size
9221          view.setUint16(20, 1, true); // PCM format
9222          view.setUint16(22, numChannels, true);
9223          view.setUint32(24, sampleRate, true);
9224          view.setUint32(28, byteRate, true);
9225          view.setUint16(32, blockAlign, true);
9226          view.setUint16(34, 16, true); // bits per sample
9227          writeString(36, 'data');
9228          view.setUint32(40, dataSize, true);
9229
9230          // Convert float32 audio data to 16-bit PCM
9231          let offset = 44;
9232          for (let i = 0; i < rawAudioChunks.length; i++) {
9233            const chunk = rawAudioChunks[i];
9234            for (let j = 0; j < chunk.left.length; j++) {
9235              // Left channel
9236              const leftSample = Math.max(-1, Math.min(1, chunk.left[j]));
9237              view.setInt16(offset, leftSample * 0x7FFF, true);
9238              offset += 2;
9239              
9240              // Right channel
9241              const rightSample = Math.max(-1, Math.min(1, chunk.right[j]));
9242              view.setInt16(offset, rightSample * 0x7FFF, true);
9243              offset += 2;
9244            }
9245          }
9246
9247          return new Blob([buffer], { type: 'audio/wav' });
9248        }
9249
9250        // Add timing information as JSON
9251        const timingInfo = content.frames.map((frame, i) => {
9252          let filename;
9253          if (frame.timestamp !== undefined) {
9254            const frameDate = new Date(frame.timestamp);
9255            const year = frameDate.getFullYear();
9256            const month = frameDate.getMonth() + 1;
9257            const day = frameDate.getDate();
9258            const hour = frameDate.getHours();
9259            const minute = frameDate.getMinutes();
9260            const second = frameDate.getSeconds();
9261            const millisecond = frameDate.getMilliseconds();
9262            
9263            const dateTimestamp = `${year}.${month}.${day}.${hour}.${minute}.${second}.${millisecond.toString().padStart(3, "0")}`;
9264            filename = `frame_${dateTimestamp}.png`;
9265          } else {
9266            filename = `frame_${String(i).padStart(4, "0")}.png`;
9267          }
9268          
9269          return {
9270            frame: i,
9271            filename: filename,
9272            duration: frame.duration,
9273            timestamp: frame.timestamp,
9274          };
9275        });
9276        
9277        zip.file("timing.json", JSON.stringify(timingInfo, null, 2));
9278        
9279        // Add metadata
9280        const metadata = {
9281          originalSize: { width: originalWidth, height: originalHeight },
9282          scaledSize: { width: scaledWidth, height: scaledHeight },
9283          scale: scale,
9284          frameCount: content.frames.length,
9285          totalDuration: content.frames.reduce((sum, f) => sum + (f.duration || 100), 0),
9286          exportedAt: new Date().toISOString(),
9287          pieceChanges: recordedPieceChanges, // Track piece changes during recording
9288        };
9289        
9290        console.log(`🎬 📍 Piece changes included in metadata: ${metadata.pieceChanges.length} changes`);
9291        
9292        zip.file("metadata.json", JSON.stringify(metadata, null, 2));
9293
9294        // Add audio file if available - convert raw audio to WAV
9295        try {
9296          if (rawAudioData && rawAudioData.length > 0) {
9297            console.log("🎵 Converting raw audio data to WAV for ZIP...");
9298            const wavBlob = createWavFromRawAudio(rawAudioData, rawAudioSampleRate);
9299            if (wavBlob) {
9300              zip.file("soundtrack.wav", wavBlob);
9301              console.log("🎵 Audio added to ZIP as soundtrack.wav");
9302            } else {
9303              console.log("🎵 Failed to create WAV from raw audio data");
9304            }
9305          } else {
9306            console.log("🎵 No raw audio data available for ZIP export");
9307          }
9308        } catch (audioError) {
9309          console.warn("🎵 Could not add audio to ZIP:", audioError);
9310        }
9311
9312        console.log("🗜️ Generating ZIP file...");
9313        const zipBlob = await zip.generateAsync({ type: "blob" });
9314        
9315        const filename = generateTapeFilename("zip");
9316        
9317        console.log(
9318          `💾 ZIP file generated: ${filename} (${Math.round((zipBlob.size / 1024 / 1024) * 100) / 100} MB)`,
9319        );
9320
9321        // Use the existing download function
9322        receivedDownload({ filename, data: zipBlob });
9323
9324        console.log("📦 High-resolution frames ZIP exported successfully!");
9325        
9326        // Send completion
9327        send({
9328          type: "recorder:transcode-progress",
9329          content: 1.0,
9330        });
9331        
9332      } catch (error) {
9333        console.error("Error creating frames ZIP:", error);
9334      }
9335      return;
9336    }
9337
9338    // Helper function to convert raw audio data to WAV format
9339    function createWavFromRawAudio(rawAudioChunks, sampleRate = 48000) {
9340      if (!rawAudioChunks || rawAudioChunks.length === 0) {
9341        return null;
9342      }
9343
9344      // Calculate total samples
9345      const totalSamples = rawAudioChunks.length * 4096; // 4096 samples per chunk
9346      const numChannels = 2; // Stereo
9347      const bytesPerSample = 2; // 16-bit
9348      const byteRate = sampleRate * numChannels * bytesPerSample;
9349      const blockAlign = numChannels * bytesPerSample;
9350      const dataSize = totalSamples * numChannels * bytesPerSample;
9351      const fileSize = 36 + dataSize;
9352
9353      // Create WAV file buffer
9354      const buffer = new ArrayBuffer(44 + dataSize);
9355      const view = new DataView(buffer);
9356
9357      // WAV header
9358      const writeString = (offset, string) => {
9359        for (let i = 0; i < string.length; i++) {
9360          view.setUint8(offset + i, string.charCodeAt(i));
9361        }
9362      };
9363
9364      writeString(0, 'RIFF');
9365      view.setUint32(4, fileSize, true);
9366      writeString(8, 'WAVE');
9367      writeString(12, 'fmt ');
9368      view.setUint32(16, 16, true); // fmt chunk size
9369      view.setUint16(20, 1, true); // PCM format
9370      view.setUint16(22, numChannels, true);
9371      view.setUint32(24, sampleRate, true);
9372      view.setUint32(28, byteRate, true);
9373      view.setUint16(32, blockAlign, true);
9374      view.setUint16(34, 16, true); // bits per sample
9375      writeString(36, 'data');
9376      view.setUint32(40, dataSize, true);
9377
9378      // Convert float32 audio data to 16-bit PCM
9379      let offset = 44;
9380      for (let i = 0; i < rawAudioChunks.length; i++) {
9381        const chunk = rawAudioChunks[i];
9382        for (let j = 0; j < chunk.left.length; j++) {
9383          // Left channel
9384          const leftSample = Math.max(-1, Math.min(1, chunk.left[j]));
9385          view.setInt16(offset, leftSample * 0x7FFF, true);
9386          offset += 2;
9387          
9388          // Right channel
9389          const rightSample = Math.max(-1, Math.min(1, chunk.right[j]));
9390          view.setInt16(offset, rightSample * 0x7FFF, true);
9391          offset += 2;
9392        }
9393      }
9394
9395      return new Blob([buffer], { type: 'audio/wav' });
9396    }
9397
9398    // 🎬 Handle POST tape frame chunks
9399    if (type === "create-and-post-tape-chunk") {
9400      if (window.postFrameChunks) {
9401        window.postFrameChunks.push(content.frames);
9402        window.postReceivedChunks++;
9403        
9404        console.log(
9405          `📼 Received POST chunk ${window.postReceivedChunks}/${window.postTotalChunks}:`,
9406          content.frames.length,
9407          "frames"
9408        );
9409
9410        if (window.postReceivedChunks === window.postTotalChunks) {
9411          console.log("📼 All POST chunks received, reassembling...");
9412          const allFrames = window.postFrameChunks.flat();
9413          console.log("📼 Total frames after reassembly:", allFrames.length);
9414
9415          const rawAudio = window.postRawAudio;
9416          const piece = window.postPiece;
9417          const draftOnly = window.postDraftOnly;
9418
9419          delete window.postFrameChunks;
9420          delete window.postTotalChunks;
9421          delete window.postReceivedChunks;
9422          delete window.postRawAudio;
9423          delete window.postPiece;
9424          delete window.postDraftOnly;
9425
9426          receivedChange({
9427            data: {
9428              type: "create-and-post-tape",
9429              content: { frames: allFrames, rawAudio, piece, draftOnly }
9430            }
9431          });
9432        }
9433      }
9434      return;
9435    }
9436
9437    // 🎬 Create ZIP and POST tape to cloud (MongoDB + ATProto)
9438    if (type === "create-and-post-tape") {
9439      if (content.draftOnly) cancelTapeDraftRequested = false;
9440      if (content.totalChunks && content.totalChunks > 1) {
9441        console.log(
9442          `📼 Receiving POST frames in chunks (1/${content.totalChunks}):`,
9443          content.frames.length,
9444          "frames"
9445        );
9446        window.postFrameChunks = [content.frames];
9447        window.postTotalChunks = content.totalChunks;
9448        window.postReceivedChunks = 1;
9449        window.postRawAudio = content.rawAudio; // Store rawAudio from first chunk
9450        window.postPiece = content.piece; // Store piece name from first chunk
9451        window.postDraftOnly = content.draftOnly === true;
9452        return;
9453      }
9454
9455      console.log(
9456        "📼 Creating and posting tape from",
9457        content.frames.length,
9458        "frames",
9459      );
9460
9461      // Send initial progress with status
9462      send({
9463        type: "recorder:export-status",
9464        content: { message: "PREPARING FRAMES", phase: "preparing" },
9465      });
9466      send({
9467        type: "recorder:transcode-progress",
9468        content: 0.01,
9469      });
9470
9471      try {
9472        if (content.frames.length === 0) {
9473          console.warn("No frames provided for tape posting");
9474          send({ type: "tape:post-error", content: { error: "No frames" } });
9475          return;
9476        }
9477
9478        // Load JSZip if not already loaded
9479        if (!window.JSZip) await loadJSZip();
9480        const zip = new window.JSZip();
9481
9482        // Use original resolution (no scaling for tape uploads)
9483        const originalWidth = content.frames[0].width;
9484        const originalHeight = content.frames[0].height;
9485
9486        console.log(
9487          `📏 Creating tape ZIP at original resolution: ${originalWidth}x${originalHeight}`,
9488        );
9489
9490        // Timing data for timing.json
9491        const timingData = content.frames.map((frame, i) => {
9492          const filename = `frame-${String(i).padStart(5, "0")}.png`;
9493          return {
9494            frame: i,
9495            filename,
9496            duration: frame.duration,
9497            timestamp: frame.timestamp,
9498          };
9499        });
9500        const totalDuration = timingData.reduce(
9501          (sum, frame) => sum + frame.duration,
9502          0,
9503        );
9504
9505        // PNG encoding dominates posting time on phones. Use a small,
9506        // bounded pool so modern devices can encode several frames at once
9507        // without multiplying memory use across the whole recording.
9508        const workerCount = Math.min(
9509          content.frames.length,
9510          (navigator.hardwareConcurrency || 2) >= 6 ? 3 : 2,
9511        );
9512        let nextFrame = 0;
9513        let completedFrames = 0;
9514        const encodeWorker = async () => {
9515          const canvas = document.createElement("canvas");
9516          canvas.width = originalWidth;
9517          canvas.height = originalHeight;
9518          const ctx = canvas.getContext("2d");
9519          while (nextFrame < content.frames.length) {
9520            const i = nextFrame++;
9521            const frame = content.frames[i];
9522            ctx.putImageData(
9523              new ImageData(
9524                new Uint8ClampedArray(frame.data),
9525                frame.width,
9526                frame.height,
9527              ),
9528              0,
9529              0,
9530            );
9531            const pngBlob = await new Promise((resolve, reject) => {
9532              canvas.toBlob(
9533                (blob) => blob ? resolve(blob) : reject(new Error("PNG encoding failed")),
9534                "image/png",
9535              );
9536            });
9537            zip.file(timingData[i].filename, pngBlob);
9538            completedFrames += 1;
9539            if (completedFrames % 10 === 0 || completedFrames === content.frames.length) {
9540              const progress = (completedFrames / content.frames.length) * 0.65 + 0.1;
9541              send({
9542                type: "recorder:export-status",
9543                content: {
9544                  message: `PROCESSING FRAME ${completedFrames}/${content.frames.length}`,
9545                  phase: "processing",
9546                },
9547              });
9548              send({ type: "recorder:transcode-progress", content: progress });
9549            }
9550          }
9551        };
9552        await Promise.all(Array.from({ length: workerCount }, encodeWorker));
9553
9554        // Add timing.json (75-80%)
9555        send({
9556          type: "recorder:export-status",
9557          content: { message: "CREATING TIMING DATA", phase: "metadata" },
9558        });
9559        send({
9560          type: "recorder:transcode-progress",
9561          content: 0.75,
9562        });
9563        
9564        zip.file("timing.json", JSON.stringify(timingData, null, 2));
9565
9566        // Add metadata.json (80-85%)
9567        send({
9568          type: "recorder:export-status",
9569          content: { message: "ADDING METADATA", phase: "metadata" },
9570        });
9571        send({
9572          type: "recorder:transcode-progress",
9573          content: 0.8,
9574        });
9575        
9576        // Generate full timestamp slug (for authenticated users)
9577        const token = await authorize().catch(() => null);
9578        let slug;
9579        if (token) {
9580          // User upload: use full timestamp format like paintings
9581          slug = new Date()
9582            .toISOString()
9583            .replace(/[-:]/g, ".")
9584            .replace("T", ".")
9585            .replace("Z", "") // Remove trailing Z
9586            .split(".").slice(0, 7).join("."); // Include milliseconds: YYYY.MM.DD.HH.MM.SS.mmm
9587        } else {
9588          // Guest upload: server will generate nanoid slug
9589          slug = null;
9590        }
9591        
9592        const metadata = {
9593          resolution: { width: originalWidth, height: originalHeight },
9594          frameCount: content.frames.length,
9595          totalDuration: totalDuration / 1000, // Convert from milliseconds to seconds
9596          piece: content.piece || "video",
9597          exportedAt: new Date().toISOString(),
9598          audioSampleRate: content.rawAudio?.sampleRate || null, // Store original audio sample rate
9599          pieceChanges: recordedPieceChanges, // Track piece changes during recording
9600          slug: slug, // Include slug for backend to use for filename
9601        };
9602        
9603        console.log(`🎬 📍 Piece changes included in metadata: ${metadata.pieceChanges.length} changes`);
9604        
9605        zip.file("metadata.json", JSON.stringify(metadata, null, 2));
9606
9607        // Add audio file if available - convert raw audio to WAV
9608        if (content.rawAudio && content.rawAudio.left && content.rawAudio.right) {
9609          try {
9610            console.log("🎵 Converting raw audio data to WAV for tape ZIP...");
9611            console.log(`🎵 Audio: ${content.rawAudio.totalSamples} samples at ${content.rawAudio.sampleRate}Hz`);
9612            
9613            // Convert Float32Arrays to the format expected by createWavFromRawAudio
9614            const audioChunks = [];
9615            const chunkSize = 4096;
9616            const numChunks = Math.ceil(content.rawAudio.totalSamples / chunkSize);
9617            
9618            for (let i = 0; i < numChunks; i++) {
9619              const start = i * chunkSize;
9620              const end = Math.min(start + chunkSize, content.rawAudio.totalSamples);
9621              const left = content.rawAudio.left.slice(start, end);
9622              const right = content.rawAudio.right.slice(start, end);
9623              audioChunks.push({ left, right });
9624            }
9625            
9626            const wavBlob = createWavFromRawAudio(audioChunks, content.rawAudio.sampleRate);
9627            if (wavBlob) {
9628              zip.file("soundtrack.wav", wavBlob);
9629              console.log("🎵 Audio added to ZIP as soundtrack.wav");
9630            } else {
9631              console.log("🎵 Failed to create WAV from raw audio data");
9632            }
9633          } catch (audioError) {
9634            console.warn("🎵 Could not add audio to ZIP:", audioError);
9635          }
9636        } else {
9637          console.log("🎵 No raw audio data available for tape ZIP export");
9638        }
9639
9640        // Generate ZIP blob (85-90%)
9641        send({
9642          type: "recorder:export-status",
9643          content: { message: "COMPRESSING ZIP", phase: "zipping" },
9644        });
9645        send({
9646          type: "recorder:transcode-progress",
9647          content: 0.85,
9648        });
9649        
9650        console.log("🗜️ Generating ZIP file...");
9651        const zipBlob = await zip.generateAsync({ type: "blob" });
9652        
9653        // Generate filename using slug (already generated above for metadata)
9654        let filename;
9655        
9656        if (slug) {
9657          // User upload: use slug.zip format (slug already contains full timestamp)
9658          filename = `${slug}.zip`;
9659        } else {
9660          // Guest upload: use generic name, server will generate nanoid
9661          filename = "tape.zip";
9662        }
9663        
9664        console.log(
9665          `💾 ZIP file generated: ${filename} (${Math.round((zipBlob.size / 1024 / 1024) * 100) / 100} MB)`,
9666        );
9667
9668        // Upload to cloud (90-100%)
9669        send({
9670          type: "recorder:export-status",
9671          content: { message: "UPLOADING TO CLOUD", phase: "uploading" },
9672        });
9673        send({
9674          type: "recorder:transcode-progress",
9675          content: 0.90,
9676        });
9677        
9678        if (content.draftOnly) {
9679          receivedTapeDraftUpload(zipBlob, metadata);
9680        } else {
9681          // Use existing receivedUpload pattern with metadata
9682          receivedUpload(
9683            { filename, data: zipBlob },
9684            "tape:posted",  // Success callback type
9685            metadata,       // Pass metadata for database
9686          );
9687        }
9688
9689        console.log("📼 Tape ZIP created and queued for upload!");
9690        
9691      } catch (error) {
9692        console.error("Error creating/posting tape:", error);
9693        send({
9694          type: content.draftOnly ? "tape:draft-error" : "tape:post-error",
9695          content: { error: error.message },
9696        });
9697      }
9698      return;
9699    }
9700
9701    // 🎬 Handle GIF frame chunks
9702    if (type === "create-animated-gif-chunk") {
9703      if (window.gifFrameChunks) {
9704        window.gifFrameChunks.push(content.frames);
9705        window.gifReceivedChunks++;
9706        
9707        console.log(
9708          `🎞️ Received GIF chunk ${window.gifReceivedChunks}/${window.gifTotalChunks}:`,
9709          content.frames.length,
9710          "frames"
9711        );
9712
9713        if (window.gifReceivedChunks === window.gifTotalChunks) {
9714          console.log("🎞️ All GIF chunks received, reassembling...");
9715          const allFrames = window.gifFrameChunks.flat();
9716          console.log("🎞️ Total frames after reassembly:", allFrames.length);
9717
9718          // Clean up chunk tracking
9719          delete window.gifFrameChunks;
9720          delete window.gifTotalChunks;
9721          delete window.gifReceivedChunks;
9722
9723          // Process as if we received all frames at once
9724          // Trigger the create-animated-gif handler with complete frames
9725          receivedChange({
9726            data: {
9727              type: "create-animated-gif",
9728              content: { frames: allFrames }
9729            }
9730          });
9731        }
9732      }
9733      return;
9734    }
9735
9736    // 🎬 Create animated GIF
9737    if (type === "create-animated-gif") {
9738      // Handle chunked frame transfer for large recordings
9739      if (content.totalChunks && content.totalChunks > 1) {
9740        console.log(
9741          `🎞️ Receiving GIF frames in chunks (1/${content.totalChunks}):`,
9742          content.frames.length,
9743          "frames"
9744        );
9745        window.gifFrameChunks = [content.frames];
9746        window.gifTotalChunks = content.totalChunks;
9747        window.gifReceivedChunks = 1;
9748        return;
9749      }
9750
9751      console.log(
9752        "🎞️ Creating animated GIF from",
9753        content.frames.length,
9754        "frames",
9755      );
9756
9757      // Calculate GIF duration for filename - ALWAYS prioritize intended duration for real-time accuracy
9758      // When user says "tape 15" they expect exactly 15 seconds of real wall-clock time
9759      let totalDurationMs = 0;
9760      if (window.currentRecordingOptions?.intendedDuration) {
9761        // Use the EXACT duration specified in the tape command for perfect real-time mapping
9762        totalDurationMs = window.currentRecordingOptions.intendedDuration * 1000; // Convert to milliseconds
9763        console.log(`🎬 GIF duration from EXACT intended tape duration: ${window.currentRecordingOptions.intendedDuration}s (${totalDurationMs}ms) for real-time accuracy`);
9764      } else if (mediaRecorderDuration && mediaRecorderDuration > 0) {
9765        // Fallback to actual recording duration if no intended duration available
9766        totalDurationMs = mediaRecorderDuration; // mediaRecorderDuration is already in milliseconds
9767        console.log(`🎬 GIF duration from measured recording duration: ${Math.round(totalDurationMs / 1000 * 10) / 10}s (${totalDurationMs}ms)`);
9768      } else if (content.frames && content.frames.length > 0) {
9769        // Final fallback: sum frame durations if no recording duration available
9770        totalDurationMs = content.frames.reduce((sum, frame) => {
9771          return sum + (frame.duration || 100); // Default to 100ms if no duration
9772        }, 0);
9773        console.log(`🎬 GIF duration from frame durations: ${Math.round(totalDurationMs / 1000 * 10) / 10}s (${totalDurationMs}ms)`);
9774      }
9775      window.gifDurationMs = totalDurationMs;
9776
9777      // Set the recording start timestamp to match the first frame's visual timestamp
9778      // so the filename matches the first frame timestamp exactly
9779      // We'll cache the actual computed timestamp from the first frame
9780      window.firstFrameComputedTimestamp = null; // Reset cache
9781      
9782      if (content.frames.length > 0) {
9783        const firstFrame = content.frames[0];
9784        console.log(`🎬 Will use computed timestamp from first frame processing for GIF filename`);
9785        // Don't modify recordingStartTimestamp here - it's needed for proper video playback timing
9786        // The filename timestamp will be calculated during frame processing instead
9787      } else {
9788        console.warn(`⚠️ No frames available, using current time for filename`);
9789        window.recordingStartTimestamp = Date.now();
9790      }
9791
9792      // GIF encoder selection flag
9793      // Default: gifenc with Floyd-Steinberg dithering enabled
9794      
9795      // gifenc is the default GIF encoder (fast, efficient, beautiful dithering)
9796      // Set window.acUSE_DITHERING = false to disable dithering for faster encoding
9797      if (window.acUSE_DITHERING === undefined) {
9798        window.acUSE_DITHERING = true; // Default to dithering enabled
9799      }
9800      
9801      const useDithering = window.acUSE_DITHERING === true;
9802      
9803      console.log(`🎨 GIF Encoder: gifenc with ${useDithering ? '32-color palette + Floyd-Steinberg dithering' : '256-color fixed palette (no dithering)'}`);
9804      if (useDithering) {
9805        console.log(`💡 To disable dithering, run: window.acUSE_DITHERING = false`);
9806      } else {
9807        console.log(`💡 To enable 32-color dithered mode, run: window.acUSE_DITHERING = true`);
9808      }
9809
9810
9811      // Helper function to draw exact AC cursor overlay (matches pen.mjs "precise" cursor)
9812      function addCyanCrosshair(ctx, canvasWidth, canvasHeight, penData, scale = 1) {
9813        if (!penData || penData.x === undefined || penData.y === undefined) {
9814          return; // No pen data available
9815        }
9816        
9817        // Scale pen coordinates to match canvas size
9818        const x = Math.round(penData.x * scale);
9819        const y = Math.round(penData.y * scale);
9820        
9821        // Skip drawing if cursor is outside canvas bounds
9822        if (x < 0 || x >= canvasWidth || y < 0 || y >= canvasHeight) {
9823          return;
9824        }
9825        
9826        ctx.save();
9827        
9828        // AC cursor settings at 1.5x scale for good visibility without being too large
9829        const cursorScale = 1.25; // Moderate scale for optimal visibility in GIFs
9830        const radius = 2 * cursorScale;     // White center circle radius
9831        const gap = 7.5 * cursorScale;      // Gap from center to crosshair start
9832        const to = 10 * cursorScale;        // Length of crosshair lines
9833        const lineWidth = 4 * cursorScale;  // Crosshair line width
9834        
9835        // Shadow offset for visibility
9836        const offsetX = 2 * cursorScale;
9837        const offsetY = 2 * cursorScale;
9838        
9839        ctx.lineCap = "round";
9840        
9841        // Draw shadow graphics first
9842        ctx.save();
9843        ctx.translate(x + offsetX, y + offsetY);
9844        
9845        // Shadow circle in center
9846        ctx.beginPath();
9847        ctx.arc(0, 0, radius, 0, 2 * Math.PI);
9848        ctx.fillStyle = "rgba(0, 0, 0, 0.5)"; // Half-opacity black shadow
9849        ctx.fill();
9850        
9851        // Shadow crosshair lines
9852        ctx.beginPath();
9853        ctx.moveTo(0, -gap);
9854        ctx.lineTo(0, -to);
9855        ctx.moveTo(0, gap);
9856        ctx.lineTo(0, to);
9857        ctx.moveTo(-gap, 0);
9858        ctx.lineTo(-to, 0);
9859        ctx.moveTo(gap, 0);
9860        ctx.lineTo(to, 0);
9861        
9862        ctx.strokeStyle = "rgba(0, 0, 0, 0.5)"; // Half-opacity black shadow
9863        ctx.lineWidth = lineWidth;
9864        ctx.stroke();
9865        ctx.restore();
9866        
9867        // Draw main cursor graphics
9868        ctx.save();
9869        ctx.translate(x, y);
9870        
9871        // White center circle
9872        ctx.beginPath();
9873        ctx.arc(0, 0, radius, 0, 2 * Math.PI);
9874        ctx.fillStyle = "white";
9875        ctx.fill();
9876        
9877        // Cyan crosshair lines (exact AC color)
9878        ctx.beginPath();
9879        ctx.moveTo(0, -gap); // Top
9880        ctx.lineTo(0, -to);
9881        ctx.moveTo(0, gap);  // Bottom
9882        ctx.lineTo(0, to);
9883        ctx.moveTo(-gap, 0); // Left
9884        ctx.lineTo(-to, 0);
9885        ctx.moveTo(gap, 0);  // Right
9886        ctx.lineTo(to, 0);
9887        
9888        ctx.strokeStyle = "rgb(0, 255, 255)"; // Exact AC cyan color
9889        ctx.lineWidth = lineWidth;
9890        ctx.stroke();
9891        ctx.restore();
9892        
9893        ctx.restore();
9894      }
9895
9896      try {
9897        // Load gifenc library if not already loaded
9898        if (!window.gifenc) {
9899          console.log("📦 Loading gifenc library...");
9900          const { GIFEncoder, quantize, applyPalette, prequantize } = await import(
9901            "/aesthetic.computer/dep/gifenc/gifenc.esm.js"
9902          );
9903          window.gifenc = { GIFEncoder, quantize, applyPalette, prequantize };
9904          console.log("✅ gifenc library loaded successfully");
9905        }
9906
9907        // Smart scaling for GIF output based on final size
9908        const originalWidth = content.frames[0].width;
9909        const originalHeight = content.frames[0].height;
9910        const frameCount = content.frames.length;
9911
9912        // Always use 3x scaling for GIFs
9913        const optimalScale = 3;
9914        console.log(
9915          `📏 Using 3x scaling for GIF (gifenc): ${originalWidth}x${originalHeight} -> ${originalWidth * optimalScale}x${originalHeight * optimalScale}`,
9916        );
9917
9918        // 🎯 Properly resample frames to 30fps for optimal GIF size/quality balance
9919        let processedFrames = content.frames;
9920        const targetGifFPS = 30;
9921        
9922        if (content.frames.length > 0) {
9923          const totalDuration = content.frames[content.frames.length - 1].timestamp - content.frames[0].timestamp;
9924          const actualFrameRate = Math.round((content.frames.length / totalDuration) * 1000);
9925          
9926          // EARLY HIGH REFRESH RATE DETECTION - before resampling!
9927          let shouldUseHighRefreshRateNormalization = false;
9928          if (content.frames.length > 1) {
9929            const avgOriginalTiming = totalDuration / (content.frames.length - 1);
9930            console.log(`🎞️ EARLY TIMING ANALYSIS: ${content.frames.length} frames, totalDuration=${totalDuration.toFixed(2)}ms, avgTiming=${avgOriginalTiming.toFixed(2)}ms (${(1000/avgOriginalTiming).toFixed(1)}fps)`);
9931            
9932            // Detect high refresh rate from original capture (before resampling)
9933            if (avgOriginalTiming < 13.5) { // Less than 13.5ms indicates >74fps
9934              shouldUseHighRefreshRateNormalization = true;
9935              window.earlyHighRefreshRateDetected = true;
9936              console.log(`🎞️ EARLY HIGH REFRESH RATE DETECTED: ${(1000/avgOriginalTiming).toFixed(1)}fps capture - will normalize to 60fps timing`);
9937            } else {
9938              window.earlyHighRefreshRateDetected = false;
9939              console.log(`🎞️ EARLY NORMAL REFRESH RATE: ${(1000/avgOriginalTiming).toFixed(1)}fps capture - will use original timing`);
9940            }
9941          }
9942          
9943          if (actualFrameRate > targetGifFPS && totalDuration > 0) {
9944            // Calculate how many frames we need for 30fps
9945            const targetFrameCount = Math.round((totalDuration / 1000) * targetGifFPS);
9946            processedFrames = [];
9947            
9948            console.log(`🎬 Resampling GIF from ${actualFrameRate}fps to ${targetGifFPS}fps (${content.frames.length} -> ${targetFrameCount} frames)`);
9949            
9950            // REGULAR EVENLY-SPACED frame selection (no blending - use actual frames for crisp motion)
9951            // Use perfectly even index spacing and round to nearest frame
9952            const step = (content.frames.length - 1) / (targetFrameCount - 1);
9953            
9954            for (let i = 0; i < targetFrameCount; i++) {
9955              // Calculate exact fractional index and round to nearest frame
9956              const exactIndex = i * step;
9957              const frameIndex = Math.round(exactIndex);
9958              
9959              // Clamp to valid range
9960              const safeIndex = Math.min(Math.max(0, frameIndex), content.frames.length - 1);
9961              
9962              // Calculate evenly-spaced timestamp for this frame
9963              const evenTimestamp = content.frames[0].timestamp + (i / (targetFrameCount - 1)) * totalDuration;
9964              
9965              // Use actual frame (no blending) for crisp, frame-aligned motion
9966              const correctedFrame = { ...content.frames[safeIndex] };
9967              correctedFrame.originalTimestamp = evenTimestamp;
9968              
9969              processedFrames.push(correctedFrame);
9970            }
9971            
9972            console.log(`🎬 GIF frame count: ${content.frames.length} -> ${processedFrames.length} frames (evenly spaced every ${step.toFixed(2)} frames)`);
9973          } else {
9974            console.log(`🎬 No resampling needed: ${actualFrameRate}fps <= ${targetGifFPS}fps target`);
9975            // Initialize early high refresh rate detection for non-resampled case
9976            if (content.frames.length > 1) {
9977              const avgOriginalTiming = totalDuration / (content.frames.length - 1);
9978              window.earlyHighRefreshRateDetected = avgOriginalTiming < 13.5;
9979              console.log(`🎞️ EARLY TIMING (no resampling): avgTiming=${avgOriginalTiming.toFixed(2)}ms, highRefresh=${window.earlyHighRefreshRateDetected}`);
9980            } else {
9981              window.earlyHighRefreshRateDetected = false;
9982            }
9983          }
9984        }
9985
9986        // Helper function to upscale pixels with nearest neighbor
9987        function upscalePixels(
9988          imageData,
9989          originalWidth,
9990          originalHeight,
9991          scale,
9992        ) {
9993          if (scale === 1) {
9994            return imageData;
9995          }
9996
9997          const scaledWidth = originalWidth * scale;
9998          const scaledHeight = originalHeight * scale;
9999          const scaledImageData = new Uint8ClampedArray(
10000            scaledWidth * scaledHeight * 4,
10001          );
10002
10003          for (let y = 0; y < scaledHeight; y++) {
10004            for (let x = 0; x < scaledWidth; x++) {
10005              const sourceX = Math.floor(x / scale);
10006              const sourceY = Math.floor(y / scale);
10007              const sourceIndex = (sourceY * originalWidth + sourceX) * 4;
10008              const targetIndex = (y * scaledWidth + x) * 4;
10009
10010              scaledImageData[targetIndex] = imageData[sourceIndex]; // R
10011              scaledImageData[targetIndex + 1] = imageData[sourceIndex + 1]; // G
10012              scaledImageData[targetIndex + 2] = imageData[sourceIndex + 2]; // B
10013              scaledImageData[targetIndex + 3] = imageData[sourceIndex + 3]; // A
10014            }
10015          }
10016
10017          return scaledImageData;
10018        }
10019
10020        // Use gifenc encoder
10021        console.log("🎞️ Processing frames for GIF with gifenc...");
10022        
10023        // Send initial status
10024        send({
10025          type: "recorder:export-status",
10026          content: { 
10027            type: "gif", 
10028            phase: "analyzing", 
10029            message: "Analyzing frames" 
10030          }
10031        });
10032        
10033        const { GIFEncoder, applyPalette } = window.gifenc;
10034          
10035          // Calculate a better initial capacity based on frame count and size
10036          const estimatedFrameSize = content.frames[0].width * content.frames[0].height * optimalScale * optimalScale;
10037          const estimatedSize = content.frames.length * estimatedFrameSize * 0.5; // Rough estimate
10038          const initialCapacity = Math.max(4096, Math.min(estimatedSize, 1024 * 1024)); // Between 4KB and 1MB
10039          
10040          const gif = GIFEncoder({
10041            auto: true, // Auto mode for simpler usage
10042            initialCapacity: initialCapacity // Pre-allocate buffer for better performance
10043          });
10044          
10045          const finalFrames = [];
10046          
10047          // Check if cursor actually moved during recording to avoid static cursor overlay
10048          let showCursor = false;
10049          if (processedFrames.length > 1) {
10050            const firstFrame = processedFrames[0];
10051            const movementThreshold = 5; // pixels - minimum movement to show cursor
10052            
10053            if (firstFrame.penData && firstFrame.penData.x !== undefined && firstFrame.penData.y !== undefined) {
10054              const startX = firstFrame.penData.x;
10055              const startY = firstFrame.penData.y;
10056              
10057              // Check if cursor moved significantly from starting position in any frame
10058              for (let i = 1; i < processedFrames.length; i++) {
10059                const frame = processedFrames[i];
10060                if (frame.penData && frame.penData.x !== undefined && frame.penData.y !== undefined) {
10061                  const deltaX = Math.abs(frame.penData.x - startX);
10062                  const deltaY = Math.abs(frame.penData.y - startY);
10063                  const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
10064                  
10065                  if (distance > movementThreshold) {
10066                    showCursor = true;
10067                    console.log(`🎯 Cursor moved ${distance.toFixed(1)}px from start - will show cursor overlay`);
10068                    break;
10069                  }
10070                }
10071              }
10072              
10073              if (!showCursor) {
10074                console.log(`🎯 Cursor stayed within ${movementThreshold}px of start position - hiding cursor overlay`);
10075              }
10076            }
10077          }
10078          
10079          // Send frame processing status
10080          send({
10081            type: "recorder:export-status",
10082            content: { 
10083              type: "gif", 
10084              phase: "processing", 
10085              message: "Processing frames" 
10086            }
10087          });
10088          
10089          // Calculate per-frame timing using KidLisp FPS timeline or fallback methods
10090          console.log("🎞️ === GIFENC TIMING ANALYSIS START ===");
10091          console.log(`🎬 GIF Encoding Debug - KidLisp FPS Timeline:`, window.currentRecordingOptions?.kidlispFpsTimeline);
10092          console.log(`🎬 GIF Encoding Debug - Recording Options:`, window.currentRecordingOptions);
10093          
10094          // Helper function to get FPS at a specific timestamp
10095          function getFpsAtTimestamp(timestamp, fpsTimeline, fallbackFps = 60) {
10096            if (!fpsTimeline || fpsTimeline.length === 0) {
10097              return fallbackFps;
10098            }
10099            
10100            // Find the last FPS change before or at this timestamp
10101            let activeFps = fallbackFps;
10102            for (const fpsChange of fpsTimeline) {
10103              if (fpsChange.timestamp <= timestamp) {
10104                activeFps = fpsChange.fps;
10105              } else {
10106                break; // Timeline should be chronological
10107              }
10108            }
10109            return activeFps;
10110          }
10111          
10112          // Process each frame with individual timing
10113          const frameDelays = [];
10114          const fpsTimeline = window.currentRecordingOptions?.kidlispFpsTimeline;
10115          let fallbackFps = window.currentRecordingOptions?.kidlispFps || 60;
10116          
10117          // Detect high refresh rate displays for non-KidLisp pieces
10118          if (!fpsTimeline && processedFrames.length > 1) {
10119            const totalOriginalDuration = processedFrames[processedFrames.length - 1].timestamp - processedFrames[0].timestamp;
10120            const avgFrameTiming = totalOriginalDuration / (processedFrames.length - 1);
10121            
10122            console.log(`🎞️ GIFENC TIMING DEBUG: totalDuration=${totalOriginalDuration.toFixed(2)}ms, frames=${processedFrames.length}, avgTiming=${avgFrameTiming.toFixed(2)}ms (${(1000/avgFrameTiming).toFixed(1)}fps)`);
10123            
10124            // Check if early high refresh rate detection was triggered (stored 
10124in window)
10125            const earlyHighRefreshDetected = window.earlyHighRefreshRateDetected;
10126            console.log(`🎞️ Early high refresh rate detection result: ${earlyHighRefreshDetected}`);
10127            
10128            // Use early detection result or fallback to current detection
10129            const isHighRefreshRate = earlyHighRefreshDetected || avgFrameTiming < 13.5;
10130            
10131            if (isHighRefreshRate) {
10132              // For high refresh rate displays, normalize to 30fps instead of 60fps for proper playback speed
10133              fallbackFps = 30; // Use 30fps for smoother, slower GIF playback on high refresh displays
10134              console.log(`🎞️ HIGH REFRESH RATE DETECTED in gifenc (${earlyHighRefreshDetected ? 'early detection' : 'fallback detection'}), normalizing to 30fps instead of 60fps`);
10135            } else {
10136              console.log(`🎞️ Normal refresh rate detected in gifenc (${(1000/avgFrameTiming).toFixed(1)}fps), using original timing`);
10137            }
10138          }
10139          
10140          // Calculate frame delays with intended duration taking absolute priority
10141          if (window.currentRecordingOptions?.intendedDuration && processedFrames.length > 0) {
10142            // PRIORITY: Use intended duration for perfect real-time accuracy (e.g., "tape 5" = 5 second playback)
10143            const totalIntendedMs = window.currentRecordingOptions.intendedDuration * 1000;
10144            const uniformDelay = Math.round(totalIntendedMs / processedFrames.length);
10145            console.log(`🎞️ USING INTENDED DURATION: ${window.currentRecordingOptions.intendedDuration}s for ${processedFrames.length} frames = ${uniformDelay}ms per frame`);
10146            
10147            // Set all frame delays to the uniform delay for real-time accuracy
10148            for (let i = 0; i < processedFrames.length; i++) {
10149              frameDelays.push(uniformDelay);
10150            }
10151          } else {
10152            // FALLBACK: Use FPS-based timing only when no intended duration is specified
10153            for (let i = 0; i < processedFrames.length; i++) {
10154              const frame = processedFrames[i];
10155              const frameTimestamp = frame.timestamp;
10156              
10157              // Get the active FPS for this specific frame
10158              const activeFps = getFpsAtTimestamp(frameTimestamp, fpsTimeline, fallbackFps);
10159              const frameDelay = Math.round(1000 / activeFps);
10160              frameDelays.push(frameDelay);
10161              
10162              if (i < 5 || i % 20 === 0) { // Log first few frames and every 20th frame
10163                console.log(`🎞️ Frame ${i}: timestamp=${frameTimestamp.toFixed(2)}ms, fps=${activeFps}, delay=${frameDelay}ms, fallbackFps=${fallbackFps}`);
10164              }
10165            }
10166          }
10167          
10168          console.log(`🎞️ Using per-frame KidLisp timing. Frame delays range: ${Math.min(...frameDelays)}ms to ${Math.max(...frameDelays)}ms`);
10169          console.log(`🎞️ All frame delays:`, frameDelays.slice(0, 10), frameDelays.length > 10 ? `... (${frameDelays.length} total)` : '');
10170          console.log(`🎞️ DEFAULT DELAY: ${Math.round(1000 / fallbackFps)}ms (${fallbackFps}fps)`);
10171          
10172          // Default delay for frames without timeline data (fallback)
10173          const defaultDelay = Math.round(1000 / fallbackFps);
10174          
10175          // Process each frame
10176          for (let index = 0; index < processedFrames.length; index++) {
10177            const frame = processedFrames[index];
10178            const frameDelay = frameDelays[index] || defaultDelay;
10179            
10180            // Create temporary canvas for adding progress bar at original size
10181            const tempCanvas = document.createElement("canvas");
10182            const tempCtx = tempCanvas.getContext("2d");
10183            tempCanvas.width = originalWidth;
10184            tempCanvas.height = originalHeight;
10185            
10186            // Put original frame data
10187            const originalImageData = new Uint8ClampedArray(frame.data);
10188            const tempImageData = new ImageData(originalImageData, originalWidth, originalHeight);
10189            tempCtx.putImageData(tempImageData, 0, 0);
10190            
10191            // Add 2px VHS-style progress bar at the bottom (before scaling) with transparency buildup
10192            // Skip progress bar in clean mode (tape:neat recordings)
10193            if (!window.currentRecordingOptions?.cleanMode) {
10194              const barProgress = (index + 1) / processedFrames.length;
10195              
10196              // Use shared VHS progress bar renderer
10197              renderVHSProgressBar({
10198                ctx: tempCtx,
10199                width: originalWidth,
10200                height: originalHeight,
10201                imageData: originalImageData,
10202                progress: barProgress,
10203                animFrame: index,
10204              });
10205            }
10206            
10207            // Get the frame data with progress bar for scaling
10208            const frameWithProgressBar = tempCtx.getImageData(0, 0, originalWidth, originalHeight);
10209            
10210            // Now create the final scaled canvas
10211            const canvas = document.createElement("canvas");
10212            const ctx = canvas.getContext("2d");
10213            canvas.width = originalWidth * optimalScale;
10214            canvas.height = originalHeight * optimalScale;
10215
10216            // Scale the frame with progress bar
10217            const scaledImageData = upscalePixels(
10218              frameWithProgressBar.data,
10219              originalWidth,
10220              originalHeight,
10221              optimalScale,
10222            );
10223
10224            const imageData = new ImageData(
10225              scaledImageData,
10226              originalWidth * optimalScale,
10227              originalHeight * optimalScale,
10228            );
10229
10230            ctx.putImageData(imageData, 0, 0);
10231
10232            // Add cyan crosshair cursor overlay if pen data is available AND cursor moved during recording
10233            if (showCursor && frame.penData) {
10234              addCyanCrosshair(ctx, canvas.width, canvas.height, frame.penData, optimalScale);
10235            }
10236
10237            // Add sideways AC stamp like in video recordings
10238            const progress = (index + 1) / processedFrames.length;
10239            
10240            // For GIF: calculate elapsed time based on the actual delay being used in encoding
10241            // This ensures timestamp matches the actual GIF playback speed
10242            const gifElapsedSeconds = (index * frameDelay) / 1000;
10243            const gifFrameMetadata = {
10244              ...frame,
10245              gifElapsedSeconds: gifElapsedSeconds // Pass actual GIF timing
10246            };
10247            
10248            await addAestheticComputerStamp(
10249              ctx,
10250              originalWidth * optimalScale,
10251              originalHeight * optimalScale,
10252              progress,
10253              frame.data,
10254              gifFrameMetadata, // Use enhanced metadata with GIF timing
10255              index, // frameIndex
10256              processedFrames.length // totalFrames
10257            );
10258
10259            // Get RGBA data for gifenc
10260            const frameImageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
10261            
10262            finalFrames.push({
10263              data: frameImageData.data,
10264              width: canvas.width,
10265              height: canvas.height
10266            });
10267
10268            // Send detailed progress for frame processing (first 40% of total progress)
10269            const frameProgress = (index + 1) / processedFrames.length;
10270            const totalProgress = frameProgress * 0.4; // Frames take up 40% of total progress
10271            
10272            // Update more frequently for smoother progress - every 5 frames or key milestones
10273            if ((index + 1) % 5 === 0 || index === processedFrames.length - 1) {
10274              // Use the same progress mechanism as MP4 export for consistency
10275              send({
10276                type: "recorder:transcode-progress",
10277                content: totalProgress
10278              });
10279            }
10280            
10281            // Log less frequently to avoid console spam
10282            if ((index + 1) % 50 === 0 || index === processedFrames.length - 1) {
10283              console.log(
10284                `🎞️ Processed frame ${index + 1}/${processedFrames.length} (${Math.round(frameProgress * 100)}%)`,
10285              );
10286            }
10287          }
10288          
10289          // Send color optimization status
10290          send({
10291            type: "recorder:transcode-progress",
10292            content: 0.5
10293          });
10294          
10295          console.log("🔄 Encoding GIF with gifenc (fixed palette)...");
10296          
10297          // Create palette based on dithering mode
10298          let palette = [];
10299          
10300          if (useDithering) {
10301            // 128-color palette for dithered mode: 8×4×4 RGB (good balance between quality and file size)
10302            console.log(`🎨 Using 128-color palette with Floyd-Steinberg dithering...`);
10303            for (let r = 0; r < 8; r++) {
10304              for (let g = 0; g < 4; g++) {
10305                for (let b = 0; b < 4; b++) {
10306                  palette.push([
10307                    Math.floor(r * 255 / 7),  // 8 steps: 0, 36, 73, 109, 146, 182, 219, 255
10308                    Math.floor(g * 255 / 3),  // 4 steps: 0, 85, 170, 255
10309                    Math.floor(b * 255 / 3)   // 4 steps: 0, 85, 170, 255
10310                  ]);
10311                }
10312              }
10313            }
10314          } else {
10315            // 256-color palette for non-dithered mode: 8×8×4 RGB
10316            console.log(`🎨 Using perceptually optimized 256-color palette (no dithering)...`);
10317            // Perceptually distributed color cube: 8 red × 8 green × 4 blue = 256 colors
10318            // More green levels because human eyes are most sensitive to green
10319            // Fewer blue levels because we're less sensitive to blue variations
10320            for (let r = 0; r < 8; r++) {
10321              for (let g = 0; g < 8; g++) {
10322                for (let b = 0; b < 4; b++) {
10323                  palette.push([
10324                    Math.floor(r * 255 / 7),  // 8 steps: 0, 36, 73, 109, 146, 182, 219, 255
10325                    Math.floor(g * 255 / 7),  // 8 steps: 0, 36, 73, 109, 146, 182, 219, 255
10326                    Math.floor(b * 255 / 3)   // 4 steps: 0, 85, 170, 255
10327                  ]);
10328                }
10329              }
10330            }
10331          }
10332          
10333          // Floyd-Steinberg dithering function
10334          // Applies error diffusion to create smooth gradients with limited palette
10335          function floydSteinbergDither(imageData, palette, width, height) {
10336            const pixels = new Uint8ClampedArray(imageData); // Copy data
10337            
10338            // Helper to find nearest palette color
10339            function nearestColor(r, g, b) {
10340              let minDist = Infinity;
10341              let nearest = palette[0];
10342              
10343              for (const color of palette) {
10344                const dr = r - color[0];
10345                const dg = g - color[1];
10346                const db = b - color[2];
10347                const dist = dr * dr + dg * dg + db * db;
10348                
10349                if (dist < minDist) {
10350                  minDist = dist;
10351                  nearest = color;
10352                }
10353              }
10354              
10355              return nearest;
10356            }
10357            
10358            // Apply Floyd-Steinberg error diffusion
10359            for (let y = 0; y < height; y++) {
10360              for (let x = 0; x < width; x++) {
10361                const idx = (y * width + x) * 4;
10362                
10363                const oldR = pixels[idx];
10364                const oldG = pixels[idx + 1];
10365                const oldB = pixels[idx + 2];
10366                
10367                // Find nearest palette color
10368                const newColor = nearestColor(oldR, oldG, oldB);
10369                
10370                // Set new color
10371                pixels[idx] = newColor[0];
10372                pixels[idx + 1] = newColor[1];
10373                pixels[idx + 2] = newColor[2];
10374                
10375                // Calculate error
10376                const errR = oldR - newColor[0];
10377                const errG = oldG - newColor[1];
10378                const errB = oldB - newColor[2];
10379                
10380                // Distribute error to neighboring pixels (Floyd-Steinberg kernel)
10381                // Right pixel (x+1, y): 7/16 of error
10382                if (x + 1 < width) {
10383                  const rightIdx = (y * width + (x + 1)) * 4;
10384                  pixels[rightIdx] = Math.max(0, Math.min(255, pixels[rightIdx] + errR * 7 / 16));
10385                  pixels[rightIdx + 1] = Math.max(0, Math.min(255, pixels[rightIdx + 1] + errG * 7 / 16));
10386                  pixels[rightIdx + 2] = Math.max(0, Math.min(255, pixels[rightIdx + 2] + errB * 7 / 16));
10387                }
10388                
10389                // Bottom-left pixel (x-1, y+1): 3/16 of error
10390                if (x > 0 && y + 1 < height) {
10391                  const blIdx = ((y + 1) * width + (x - 1)) * 4;
10392                  pixels[blIdx] = Math.max(0, Math.min(255, pixels[blIdx] + errR * 3 / 16));
10393                  pixels[blIdx + 1] = Math.max(0, Math.min(255, pixels[blIdx + 1] + errG * 3 / 16));
10394                  pixels[blIdx + 2] = Math.max(0, Math.min(255, pixels[blIdx + 2] + errB * 3 / 16));
10395                }
10396                
10397                // Bottom pixel (x, y+1): 5/16 of error
10398                if (y + 1 < height) {
10399                  const bottomIdx = ((y + 1) * width + x) * 4;
10400                  pixels[bottomIdx] = Math.max(0, Math.min(255, pixels[bottomIdx] + errR * 5 / 16));
10401                  pixels[bottomIdx + 1] = Math.max(0, Math.min(255, pixels[bottomIdx + 1] + errG * 5 / 16));
10402                  pixels[bottomIdx + 2] = Math.max(0, Math.min(255, pixels[bottomIdx + 2] + errB * 5 / 16));
10403                }
10404                
10405                // Bottom-right pixel (x+1, y+1): 1/16 of error
10406                if (x + 1 < width && y + 1 < height) {
10407                  const brIdx = ((y + 1) * width + (x + 1)) * 4;
10408                  pixels[brIdx] = Math.max(0, Math.min(255, pixels[brIdx] + errR * 1 / 16));
10409                  pixels[brIdx + 1] = Math.max(0, Math.min(255, pixels[brIdx + 1] + errG * 1 / 16));
10410                  pixels[brIdx + 2] = Math.max(0, Math.min(255, pixels[brIdx + 2] + errB * 1 / 16));
10411                }
10412              }
10413            }
10414            
10415            return pixels;
10416          }
10417          
10418          // Send encoding status
10419          send({
10420            type: "recorder:export-status",
10421            content: { 
10422              type: "gif", 
10423              phase: "encoding", 
10424              message: "Encoding GIF" 
10425            }
10426          });
10427          
10428          try {
10429            // Calculate correct delay to preserve intended duration
10430          // Use window.gifDurationMs which is already calculated with proper fallbacks
10431          const intendedDurationMs = window.gifDurationMs || 1000; // Fallback to 1s if not set
10432          const intendedDurationSeconds = intendedDurationMs / 1000;
10433          const targetDelayMs = intendedDurationMs / finalFrames.length;
10434          
10435          // gifenc expects delay in MILLISECONDS (it converts to centiseconds internally)
10436          const delayInMilliseconds = Math.floor(targetDelayMs);
10437          const totalPlaybackSeconds = (finalFrames.length * delayInMilliseconds) / 1000;
10438          const playbackFps = 1000 / delayInMilliseconds;
10439          
10440          console.log(`🎞️ Encoding ${finalFrames.length} frames to match ${intendedDurationSeconds.toFixed(2)}s duration`);
10441          console.log(`🎞️ Calculated delay: ${delayInMilliseconds}ms (${playbackFps.toFixed(1)}fps)`);
10442          console.log(`🎞️ GIF will play for ${totalPlaybackSeconds.toFixed(2)}s (${finalFrames.length} frames × ${delayInMilliseconds}ms)`);
10443          
10444          // Encode frames with optimized settings
10445          for (let i = 0; i < finalFrames.length; i++) {
10446            const frame = finalFrames[i];
10447            
10448            // Apply dithering if enabled
10449            let frameData = frame.data;
10450            if (useDithering) {
10451              frameData = floydSteinbergDither(frame.data, palette, frame.width, frame.height);
10452              
10453              if (i === 0) {
10454                console.log(`🎨 Applied Floyd-Steinberg dithering to frames`);
10455              }
10456            }
10457            
10458            const index = applyPalette(frameData, palette, "rgb565"); // rgb565 for better quality with fixed palette
10459            
10460            if (i === 0) {
10461              console.log(`🎞️ First frame options:`, {
10462                delay: delayInMilliseconds,
10463                hasPalette: true,
10464                repeat: 0,
10465                transparent: false,
10466                dispose: -1,
10467                dithered: useDithering,
10468                paletteSize: palette.length
10469              });
10470            }
10471            
10472            gif.writeFrame(index, frame.width, frame.height, {
10473              palette: i === 0 ? palette : undefined, // Only include palette for first frame (global palette)
10474              delay: delayInMilliseconds, // gifenc expects MILLISECONDS (converts to centiseconds internally)
10475              repeat: i === 0 ? 0 : undefined, // Set infinite loop only on first frame
10476              transparent: false, // Disable transparency for smaller file size
10477              dispose: -1 // Use default dispose method for better compression
10478            });
10479            
10480            // Progress updates for encoding (40% to 90% of total progress)
10481            if ((i + 1) % 5 === 0 || i === finalFrames.length - 1) {
10482              const encodingProgress = (i + 1) / finalFrames.length;
10483              const totalProgress = 0.4 + (encodingProgress * 0.5); // 40-90% of total
10484              console.log(`🔄 GIF encoding progress: ${Math.round(encodingProgress * 100)}%`);
10485              
10486              // Use the same progress mechanism as MP4 export for consistency
10487              send({
10488                type: "recorder:transcode-progress",
10489                content: totalProgress
10490              });
10491              
10492              // Yield control to allow UI updates (every 5 frames)
10493              await new Promise(resolve => setTimeout(resolve, 0));
10494            }
10495          }
10496          
10497          // Send 100% completion progress before generating the blob
10498          send({
10499            type: "recorder:transcode-progress",
10500            content: 1.0
10501          });
10502          
10503          const gifBytes = gif.bytes();
10504          const blob = new Blob([gifBytes], { type: "image/gif" });
10505          
10506          console.log(
10507            `💾 GIF generated with gifenc: ${Math.round((blob.size / 1024 / 1024) * 100) / 100} MB`,
10508          );
10509
10510          const filename = generateTapeFilename("gif");
10511          receivedDownload({ filename, data: blob });
10512
10513          console.log("🎬 Animated GIF exported successfully with gifenc!");
10514
10515          // Send completion events
10516          send({
10517            type: "recorder:export-complete",
10518            content: { 
10519              type: "gif",
10520              filename: filename,
10521              size: blob.size
10522            }
10523          });
10524          
10525          send({
10526            type: "signal", 
10527            content: "recorder:transcoding-done"
10528          });
10529          } catch (error) {
10530            console.error("Error creating animated GIF:", error);
10531            console.log("🔄 Falling back to static GIF of first frame");
10532
10533                try {
10534              const progressBarHeight = 3; // Define progress bar height for fallback
10535              const canvas = document.createElement("canvas");
10536              const ctx = canvas.getContext("2d");
10537              const firstFrame = content.frames[0];
10538
10539              canvas.width = firstFrame.width;
10540              canvas.height = firstFrame.height + progressBarHeight;
10541
10542              const imageData = new ImageData(
10543                new Uint8ClampedArray(firstFrame.data),
10544                firstFrame.width,
10545                firstFrame.height,
10546              );
10547
10548              ctx.putImageData(imageData, 0, 0);
10549
10550              // Add cyan crosshair cursor overlay if pen data is available
10551              if (firstFrame.penData) {
10552                addCyanCrosshair(ctx, canvas.width, canvas.height, firstFrame.penData, 1); // No scaling for fallback
10553              }
10554
10555              // Add sideways AC stamp to fallback GIF as well (await to ensure fonts are loaded)
10556              await addAestheticComputerStamp(
10557                ctx,
10558                firstFrame.width,
10559                firstFrame.height + progressBarHeight,
10560                0,
10561                firstFrame.data,
10562                firstFrame,
10563              );
10564
10565              const gifBlob = await new Promise((resolve) => {
10566                canvas.toBlob(resolve, "image/gif");
10567              });
10568
10569              const filename = generateTapeFilename("gif", "-static");
10570              receivedDownload({ filename, data: gifBlob });
10571
10572              console.log("📸 Static GIF fallback exported successfully");
10573            } catch (fallbackError) {
10574              console.error("Error in fallback GIF creation:", fallbackError);
10575            }
10576          }
10577      } catch (error) {
10578        console.error("Error in GIF processing:", error);
10579      }
10580      return;
10581    }
10582
10583    // 🎬 Handle MP4 frame chunks
10584    if (type === "create-animated-mp4-chunk") {
10585      if (window.mp4FrameChunks) {
10586        window.mp4FrameChunks.push(content.frames);
10587        window.mp4ReceivedChunks++;
10588        
10589        console.log(
10590          `🎞️ Received MP4 chunk ${window.mp4ReceivedChunks}/${window.mp4TotalChunks}:`,
10591          content.frames.length,
10592          "frames"
10593        );
10594
10595        if (window.mp4ReceivedChunks === window.mp4TotalChunks) {
10596          console.log("🎞️ All MP4 chunks received, reassembling...");
10597          const allFrames = window.mp4FrameChunks.flat();
10598          console.log("🎞️ Total frames after reassembly:", allFrames.length);
10599
10600          delete window.mp4FrameChunks;
10601          delete window.mp4TotalChunks;
10602          delete window.mp4ReceivedChunks;
10603
10604          // Process reassembled frames by calling handler recursively
10605          receivedChange({
10606            data: {
10607              type: "create-animated-mp4",
10608              content: { frames: allFrames }
10609            }
10610          });
10611        }
10612      }
10613      return;
10614    }
10615
10616    // 🎬 Create animated MP4 from frame data (same pipeline as GIF)
10617    if (type === "create-animated-mp4") {
10618      // 🖼️📼 HD tape fast path — a live-encoded hd recording of this tape
10619      // already exists, so skip the low-res frame re-render entirely and
10620      // export the hd blob directly (video+audio, encoded during recording).
10621      if (hdTapeBlob && !hdTapeAborted) {
10622        const extension = hdTapeBlob.type.includes("webm") ? "webm" : "mp4";
10623        const filename = generateTapeFilename(extension);
10624        send({ type: "recorder:transcode-progress", content: 1 });
10625        receivedDownload({ filename, data: hdTapeBlob });
10626        console.log(`🖼️📼 ✅ HD tape exported directly: ${(hdTapeBlob.size / 1048576).toFixed(1)}MB ${extension}`);
10627        send({
10628          type: "recorder:export-complete",
10629          content: { type: "video", filename },
10630        });
10631        return;
10632      }
10633      if (content.totalChunks && content.totalChunks > 1) {
10634        console.log(
10635          `🎞️ Receiving MP4 frames in chunks (1/${content.totalChunks}):`,
10636          content.frames.length,
10637          "frames"
10638        );
10639        window.mp4FrameChunks = [content.frames];
10640        window.mp4TotalChunks = content.totalChunks;
10641        window.mp4ReceivedChunks = 1;
10642        return;
10643      }
10644
10645      console.log(
10646        "🎞️ Creating animated MP4 from",
10647        content.frames.length,
10648        "frames",
10649      );
10650
10651      try {
10652        if (content.frames.length === 0) {
10653          console.warn("No frames provided for MP4 creation");
10654          return;
10655        }
10656
10657        console.log("🔄 Creating MP4 with MediaRecorder API");
10658
10659        // Send initial progress to show the progress bar immediately
10660        send({
10661          type: "recorder:transcode-progress",
10662          content: 0.01, // 1% to start
10663        });
10664
10665        send({
10666          type: "recorder:export-status",
10667          content: { 
10668            type: "video", 
10669            phase: "preparing", 
10670            message: "Preparing MP4 export" 
10671          }
10672        });
10673
10674        // Use higher scaling for clean mode: 6x vs 3x for ultra crisp output
10675        const originalWidth = content.frames[0].width;
10676        const originalHeight = content.frames[0].height;
10677        const optimalScale = window.currentRecordingOptions?.cleanMode ? 6 : 3;
10678
10679        console.log(
10680          `📏 Using ${optimalScale}x scaling for ${window.currentRecordingOptions?.cleanMode ? 'ultra-crisp clean mode' : 'standard'} MP4: ${originalWidth}x${originalHeight} -> ${originalWidth * optimalScale}x${originalHeight * optimalScale}`,
10681        );
10682
10683        // Create canvas for frame processing (same as GIF)
10684        const canvas = document.createElement("canvas");
10685        const ctx = canvas.getContext("2d");
10686        canvas.width = originalWidth * optimalScale;
10687        canvas.height = originalHeight * optimalScale;
10688        ctx.imageSmoothingEnabled = false;
10689
10690        // Use fixed 60fps for MP4 files to avoid timing issues
10691        const targetFrameRate = 60;
10692        const targetFrameInterval = 1000 / targetFrameRate; // 16.67ms per frame
10693        
10694        // Calculate original recording timing for reference
10695        let totalOriginalDuration = 0;
10696        if (content.frames.length > 1) {
10697          totalOriginalDuration = content.frames[content.frames.length - 1].timestamp - content.frames[0].timestamp;
10698        } else if (content.frames.length === 1) {
10699          totalOriginalDuration = 100; // Default 100ms for single frame
10700        }
10701        
10702        // For MP4 export, we need to match the audio timing exactly
10703        // Calculate the original recording frame rate and duration
10704        const recordingDuration = content.frames[content.frames.length - 1].timestamp - content.frames[0].timestamp;
10705        const originalFrameRate = Math.round((content.frames.length / recordingDuration) * 1000);
10706        
10707        // 🎯 Resample frames to target frame rate for optimal MP4 playback
10708        // Use same logic as GIF to handle high refresh rate displays (120Hz -> 60fps)
10709        let processedFrames = content.frames;
10710        const targetMp4FPS = 60; // Target 60fps for smooth MP4 playback
10711        
10712        if (originalFrameRate > targetMp4FPS && recordingDuration > 0) {
10713          // Calculate how many frames we need for target fps
10714          const targetFrameCount = Math.round((recordingDuration / 1000) * targetMp4FPS);
10715          processedFrames = [];
10716          
10717          console.log(`🎬 Resampling MP4 from ${originalFrameRate}fps to ${targetMp4FPS}fps (${content.frames.length} -> ${targetFrameCount} frames)`);
10718          
10719          // Properly resample frames evenly across the entire duration
10720          for (let i = 0; i < targetFrameCount; i++) {
10721            // Calculate the exact timestamp we want for this frame
10722            // Ensure we span from first frame to last frame inclusive
10723            let targetTimestamp;
10724            if (i === targetFrameCount - 1) {
10725              // For the last frame, use the actual last timestamp to ensure we capture the end
10726              targetTimestamp = content.frames[content.frames.length - 1].timestamp;
10727            } else {
10728              // For all other frames, distribute evenly across the duration
10729              targetTimestamp = content.frames[0].timestamp + (i / (targetFrameCount - 1)) * recordingDuration;
10730            }
10731            
10732            // Find the closest source frame to this timestamp
10733            let closestIndex = 0;
10734            let minDistance = Math.abs(content.frames[0].timestamp - targetTimestamp);
10735            
10736            for (let j = 1; j < content.frames.length; j++) {
10737              const distance = Math.abs(content.frames[j].timestamp - targetTimestamp);
10738              if (distance < minDistance) {
10739                minDistance = distance;
10740                closestIndex = j;
10741              }
10742            }
10743            
10744            processedFrames.push(content.frames[closestIndex]);
10745            
10746            // Send progress updates during resampling (first 5% of total progress)
10747            if (i % 100 === 0 || i === targetFrameCount - 1) {
10748              const resampleProgress = (i / targetFrameCount) * 0.05; // 0-5%
10749              send({
10750                type: "recorder:transcode-progress",
10751                content: resampleProgress,
10752              });
10753            }
10754            
10755            // Debug logging for first and last few frames
10756            if (i < 3 || i >= targetFrameCount - 3) {
10757              console.log(`🎬 Frame ${i}: target=${targetTimestamp.toFixed(1)}ms, closest=${content.frames[closestIndex].timestamp.toFixed(1)}ms, index=${closestIndex}`);
10758            }
10759          }
10760          
10761          console.log(`🎬 MP4 frame count: ${content.frames.length} -> ${processedFrames.length} frames`);
10762        } else {
10763          console.log(`🎬 No resampling needed: ${originalFrameRate}fps <= ${targetMp4FPS}fps target`);
10764        }
10765
10766        // Use consistent 60fps timing for smooth playback
10767        const totalFrames = processedFrames.length;
10768        const exportFrameRate = 60; // 60fps for smooth playback
10769        const frameDuration = 1000 / exportFrameRate; // 16.67ms per frame
10770        
10771        console.log(
10772          `🎬 MP4 export timing: ${recordingDuration.toFixed(1)}ms original, ${totalFrames} frames`
10773        );
10774        console.log(
10775          `🎬 Export frame rate: ${exportFrameRate}fps (${frameDuration.toFixed(1)}ms per frame)`
10776        );
10777
10778        // Pre-render all scaled frames with stamps before encoding
10779        console.log("🎨 Setting up streaming frame processing (render-on-demand)...");
10780        
10781        // Instead of pre-rendering all frames, we'll render them on-demand during encoding
10782        // This saves massive amounts of memory for high-resolution exports
10783        
10784        send({
10785          type: "recorder:export-status",
10786          content: { 
10787            type: "video", 
10788            phase: "rendering", 
10789            message: "Pre-rendering frames" 
10790          }
10791        });
10792
10793        // Instead of pre-rendering all frames, we'll render them on-demand during encoding
10794        // This saves massive amounts of memory for high-resolution exports
10795        
10796        // Pixel-perfect upscaling function (same as GIF export)
10797        function upscalePixels(imageData, originalWidth, originalHeight, scale) {
10798          if (scale === 1) {
10799            return imageData;
10800          }
10801
10802          const scaledWidth = originalWidth * scale;
10803          const scaledHeight = originalHeight * scale;
10804          const scaledImageData = new Uint8ClampedArray(
10805            scaledWidth * scaledHeight * 4,
10806          );
10807
10808          for (let y = 0; y < scaledHeight; y++) {
10809            for (let x = 0; x < scaledWidth; x++) {
10810              const sourceX = Math.floor(x / scale);
10811              const sourceY = Math.floor(y / scale);
10812              const sourceIndex = (sourceY * originalWidth + sourceX) * 4;
10813              const targetIndex = (y * scaledWidth + x) * 4;
10814
10815              scaledImageData[targetIndex] = imageData[sourceIndex]; // R
10816              scaledImageData[targetIndex + 1] = imageData[sourceIndex + 1]; // G
10817              scaledImageData[targetIndex + 2] = imageData[sourceIndex + 2]; // B
10818              scaledImageData[targetIndex + 3] = imageData[sourceIndex + 3]; // A
10819            }
10820          }
10821
10822          return scaledImageData;
10823        }
10824        
10825        // Function to render a specific frame on-demand
10826        async function renderFrameOnDemand(frameIndex) {
10827          const frame = processedFrames[frameIndex];
10828          if (!frame) return null;
10829
10830          // Create temporary canvas for adding progress bar at original size (before scaling)
10831          const tempCanvas = document.createElement("canvas");
10832          const tempCtx = tempCanvas.getContext("2d");
10833          tempCanvas.width = frame.width;
10834          tempCanvas.height = frame.height;
10835          
10836          // Put original frame data
10837          const originalImageData = new Uint8ClampedArray(frame.data);
10838          const tempImageData = new ImageData(originalImageData, frame.width, frame.height);
10839          tempCtx.putImageData(tempImageData, 0, 0);
10840          
10841          // Add 2px VHS-style progress bar at the bottom (before scaling)
10842          // Skip progress bar in clean mode (tape:neat recordings)
10843          if (!window.currentRecordingOptions?.cleanMode) {
10844            const barProgress = (frameIndex + 1) / processedFrames.length;
10845            
10846            // Use shared VHS progress bar renderer
10847            renderVHSProgressBar({
10848              ctx: tempCtx,
10849              width: frame.width,
10850              height: frame.height,
10851              imageData: originalImageData,
10852              progress: barProgress,
10853              animFrame: frameIndex,
10854            });
10855          }
10856          
10857          // Get the frame data with progress bar for scaling
10858          const frameWithProgressBar = tempCtx.getImageData(0, 0, frame.width, frame.height);
10859
10860          // Now scale up the frame WITH progress bar using pixel-perfect upscaling
10861          const scaledImageData = upscalePixels(
10862            frameWithProgressBar.data,
10863            frame.width,
10864            frame.height,
10865            optimalScale,
10866          );
10867
10868          // Create a canvas at the SCALED size for adding stamps
10869          const scaledCanvas = document.createElement("canvas");
10870          const scaledCtx = scaledCanvas.getContext("2d");
10871          scaledCanvas.width = originalWidth * optimalScale;
10872          scaledCanvas.height = originalHeight * optimalScale;
10873          
10874          // Put the scaled frame data onto the canvas
10875          const scaledImageDataObj = new ImageData(
10876            scaledImageData,
10877            originalWidth * optimalScale,
10878            originalHeight * optimalScale,
10879          );
10880          scaledCtx.putImageData(scaledImageDataObj, 0, 0);
10881
10882          // Now add the stamp at the SCALED dimensions (so text/bars are correct size)
10883          const progress = (frameIndex + 1) / processedFrames.length;
10884          await addAestheticComputerStamp(
10885            scaledCtx,
10886            originalWidth * optimalScale,
10887            originalHeight * optimalScale,
10888            progress,
10889            scaledImageData, // Pass scaled data
10890            frame,
10891            frameIndex,
10892            processedFrames.length
10893          );
10894
10895          // Get the final ImageData with stamps
10896          const finalImageData = scaledCtx.getImageData(
10897            0, 0, 
10898            originalWidth * optimalScale,
10899            originalHeight * optimalScale
10900          );
10901          
10902          // Clean up temporary canvases immediately
10903          tempCanvas.width = 1;
10904          tempCanvas.height = 1;
10905          scaledCanvas.width = 1;
10906          scaledCanvas.height = 1;
10907          
10908          return finalImageData;
10909        }
10910
10911        const preRenderedFrames = null; // No longer pre-rendering frames
10912        
10913        console.log(`✅ Ready for streaming frame processing of ${processedFrames.length} frames`);
10914
10915        // Send encoding status
10916        send({
10917          type: "recorder:export-status",
10918          content: { 
10919            type: "video", 
10920            phase: "encoding", 
10921            message: "Encoding MP4" 
10922          }
10923        });
10924
10925        // Create MediaRecorder for MP4 export with H.264 codec
10926        const canvasStream = canvas.captureStream(targetFrameRate);
10927        
10928        // For MP4 export, we'll create video-only first, then combine with audio using Web Audio API
10929        // This avoids the sync issues of real-time recording
10930        
10931        let mimeType;
10932        // Detect browser for codec selection
10933        const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
10934        const isChrome = /chrome/i.test(navigator.userAgent) && !isSafari;
10935        
10936        console.log(`🔍 Browser detection: Safari=${isSafari}, Chrome=${isChrome}`);
10937        
10938        // Browser-specific codec candidates
10939        let mp4Candidates;
10940        
10941        if (isSafari) {
10942          // Safari on macOS has better codec support
10943          mp4Candidates = [
10944            "video/mp4; codecs=avc1.640028,mp4a.40.2", // H.264 High + AAC-LC (Twitter optimal)
10945            "video/mp4; codecs=avc1.4D401E,mp4a.40.2", // H.264 Main + AAC-LC
10946            "video/mp4; codecs=avc1.42E01E,mp4a.40.2", // H.264 Baseline + AAC-LC
10947            "video/mp4; codecs=avc1.640028", // H.264 High only
10948            "video/mp4; codecs=avc1.4D401E", // H.264 Main only
10949            "video/mp4; codecs=avc1.42E01E", // H.264 Baseline only
10950            "video/mp4", // Generic MP4
10951          ];
10952        } else {
10953          // Chrome/Chromium - more conservative approach
10954          mp4Candidates = [
10955            "video/mp4; codecs=avc1.42E01E", // H.264 Baseline only (most compatible)
10956            "video/mp4; codecs=avc1.4D401E", // H.264 Main Profile 
10957            "video/mp4; codecs=avc1.42001E", // H.264 Baseline 3.0
10958            "video/mp4", // Generic MP4 (let browser choose audio codec)
10959            "video/mp4; codecs=avc1.42E01E,mp4a.40.5", // H.264 + AAC-HE (sometimes works)
10960            "video/mp4; codecs=avc1.42E01E,mp4a.40.2", // H.264 + AAC-LC (often fails in Chrome)
10961          ];
10962        }
10963        
10964        let mp4Found = false;
10965        for (const candidate of mp4Candidates) {
10966          console.log(`🔍 Testing codec: ${candidate}`);
10967          if (MediaRecorder.isTypeSupported(candidate)) {
10968            mimeType = candidate;
10969            mp4Found = true;
10970            console.log(`✅ Using MP4 codec: ${candidate}`);
10971            console.log(`🎯 Browser: ${isSafari ? 'Safari' : isChrome ? 'Chrome' : 'Unknown'}`);
10972            break;
10973          } else {
10974            console.log(`❌ Codec not supported: ${candidate}`);
10975          }
10976        }
10977        
10978        // Fallback to WebM if no MP4 works
10979        if (!mp4Found) {
10980          if (MediaRecorder.isTypeSupported("video/webm; codecs=vp9,opus")) {
10981            mimeType = "video/webm; codecs=vp9,opus"; // VP9 + Opus (high quality)
10982            console.log("⚠️ MP4 not supported, using WebM VP9 + Opus");
10983          } else if (MediaRecorder.isTypeSupported("video/webm; codecs=vp8,opus")) {
10984            mimeType = "video/webm; codecs=vp8,opus"; // VP8 + Opus (compatible)
10985            console.log("⚠️ MP4 not supported, using WebM VP8 + Opus");
10986          } else if (MediaRecorder.isTypeSupported("video/webm")) {
10987            mimeType = "video/webm"; // WebM fallback
10988            console.log("⚠️ Falling back to WebM (default codecs)");
10989          } else if (MediaRecorder.isTypeSupported("video/webm; codecs=vp8")) {
10990            mimeType = "video/webm; codecs=vp8"; // VP8 fallback (avoid VP9)
10991            console.log("⚠️ Falling back to WebM VP8");
10992          } else {
10993            console.error("🔴 No supported video mimetypes found");
10994            return;
10995          }
10996        }
10997
10998        // Check if we have recorded audio for combination
10999        let hasRecordedAudio = false;
11000        let audioBuffer = null;
11001        
11002        console.log("🔊 Checking for recorded audio...");
11003        console.log("🔊 sfx[\"tape:audio\"] exists:", !!sfx["tape:audio"]);
11004        console.log("🔊 sfx[\"tape:audio\"] type:", typeof sfx["tape:audio"]);
11005        console.log("🔊 sfx[\"tape:audio\"] constructor:", sfx["tape:audio"]?.constructor?.name);
11006        
11007        if (sfx["tape:audio"]) {
11008          try {
11009            // Check if it's already an AudioBuffer
11010            if (sfx["tape:audio"] instanceof AudioBuffer) {
11011              console.log("🔊 ✅ Audio is already an AudioBuffer!");
11012              audioBuffer = sfx["tape:audio"];
11013              hasRecordedAudio = true;
11014              console.log("🔊 Audio buffer duration:", audioBuffer.duration, "seconds");
11015              console.log("🔊 Audio buffer channels:", audioBuffer.numberOfChannels);
11016              console.log("🔊 Audio buffer sample rate:", audioBuffer.sampleRate);
11017              console.log("🔊 Recorded audio available for MP4 export");
11018            } else if (sfx["tape:audio"] instanceof ArrayBuffer || (sfx["tape:audio"] && sfx["tape:audio"].byteLength !== undefined)) {
11019              console.log("🔊 Audio is ArrayBuffer, decoding...");
11020              // Create a temporary audio context to decode the audio
11021              const tempAudioContext = new AudioContext();
11022              console.log("🔊 Temp audio context created, sample rate:", tempAudioContext.sampleRate);
11023              
11024              console.log("🔊 Starting audio decode...");
11025              audioBuffer = await tempAudioContext.decodeAudioData(sfx["tape:audio"].slice());
11026              hasRecordedAudio = true;
11027              console.log("🔊 ✅ Audio decoded successfully!");
11028              console.log("🔊 Audio buffer duration:", audioBuffer.duration, "seconds");
11029              console.log("🔊 Audio buffer channels:", audioBuffer.numberOfChannels);
11030              console.log("🔊 Audio buffer sample rate:", audioBuffer.sampleRate);
11031              console.log("🔊 Recorded audio available for MP4 export");
11032            } else {
11033              console.log("🔊 ❌ Unknown audio format:", typeof sfx["tape:audio"], sfx["tape:audio"]?.constructor?.name);
11034            }
11035          } catch (error) {
11036            console.error("🔊 ❌ Failed to process recorded audio:", error);
11037            console.log("🔊 Error details:", error.message);
11038          }
11039        } else {
11040          console.log("🔊 ❌ No recorded audio found in sfx[\"tape:audio\"]");
11041        }
11042
11043        // If we have audio, create a combined stream; otherwise video-only
11044        let finalStream = canvasStream;
11045        let audioDestination = null;
11046        
11047        console.log("🔊 Setting up final stream...");
11048        console.log("🔊 hasRecordedAudio:", hasRecordedAudio);
11049        console.log("🔊 audioBuffer exists:", !!audioBuffer);
11050        
11051        if (hasRecordedAudio && audioBuffer) {
11052          try {
11053            console.log("🔊 Creating export audio context...");
11054            // Create audio context for export
11055            const exportAudioContext = new AudioContext();
11056            console.log("🔊 Export audio context created, sample rate:", exportAudioContext.sampleRate);
11057            console.log("🔊 Export audio context state:", exportAudioContext.state);
11058            
11059            // Resume audio context if suspended with timeout
11060            if (exportAudioContext.state === 'suspended') {
11061              console.log("🔊 Resuming suspended audio context...");
11062              try {
11063                // Add timeout to prevent hanging - increased for longer recordings
11064                const resumePromise = exportAudioContext.resume();
11065                const timeoutPromise = new Promise((_, reject) => 
11066                  setTimeout(() => reject(new Error('Audio context resume timeout')), 15000) // Increased to 15 seconds
11067                );
11068                
11069                await Promise.race([resumePromise, timeoutPromise]);
11070                console.log("🔊 Audio context resumed, state:", exportAudioContext.state);
11071              } catch (error) {
11072                console.warn("🔊 ⚠️ Audio context resume failed:", error.message);
11073                // Continue without audio if resume fails
11074                hasRecordedAudio = false;
11075                audioBuffer = null;
11076                exportAudioContext.close();
11077              }
11078            }
11079            
11080            if (hasRecordedAudio && audioBuffer) {
11081              audioDestination = exportAudioContext.createMediaStreamDestination();
11082              console.log("🔊 Audio destination created");
11083              console.log("🔊 Audio destination stream:", audioDestination.stream);
11084              console.log("🔊 Audio destination stream tracks:", audioDestination.stream.getTracks().length);
11085            }
11086            
11087            // Create combined stream
11088            finalStream = new MediaStream();
11089            console.log("🔊 Combined stream created");
11090            
11091            // Add video tracks
11092            const videoTracks = canvasStream.getVideoTracks();
11093            console.log("🔊 Video tracks found:", videoTracks.length);
11094            videoTracks.forEach((track, index) => {
11095              finalStream.addTrack(track);
11096              console.log(`🔊 Added video track ${index}:`, track.kind, track.enabled, track.readyState);
11097            });
11098            
11099            // Add audio tracks from our export destination
11100            const audioTracks = audioDestination.stream.getAudioTracks();
11101            console.log("🔊 Audio tracks found:", audioTracks.length);
11102            audioTracks.forEach((track, index) => {
11103              finalStream.addTrack(track);
11104              console.log(`🔊 Added audio track ${index}:`, track.kind, track.enabled, track.readyState);
11105            });
11106            
11107            console.log("🔊 Final stream tracks:", finalStream.getTracks().length);
11108            console.log("🔊 Final stream video tracks:", finalStream.getVideoTracks().length);
11109            console.log("🔊 Final stream audio tracks:", finalStream.getAudioTracks().length);
11110            
11111            console.log("🔊 ✅ Created combined audio/video stream for MP4 export");
11112          } catch (error) {
11113            console.error("🔊 ❌ Failed to create combined stream, falling back to video-only:", error);
11114            console.log("🔊 Error details:", error.message);
11115            finalStream = canvasStream;
11116            hasRecordedAudio = false;
11117          }
11118        } else {
11119          console.log("🔊 Using video-only stream (no audio available)");
11120        }
11121
11122
11123        // Test codec support before creating MediaRecorder
11124        console.log("🔊 Testing codec support...");
11125        console.log("🔊 Original mimeType:", mimeType);
11126        console.log("🔊 Codec support test:", MediaRecorder.isTypeSupported(mimeType));
11127
11128        let selectedMimeType = mimeType;
11129        let mp4Supported = MediaRecorder.isTypeSupported(mimeType);
11130        let webmVp8Opus = "video/webm; codecs=vp8,opus";
11131        let webmVp9Opus = "video/webm; codecs=vp9,opus";
11132        let webmBasic = "video/webm";
11133        let fallbackUsed = false;
11134
11135        if (!mp4Supported) {
11136          console.warn("🔴 MP4/H.264/AAC not supported for MediaRecorder in this browser. Falling back to WebM/VP8+Opus.");
11137          if (MediaRecorder.isTypeSupported(webmVp8Opus)) {
11138            selectedMimeType = webmVp8Opus;
11139            fallbackUsed = true;
11140            console.log("🔊 ✅ Using WebM/VP8+Opus");
11141          } else if (MediaRecorder.isTypeSupported(webmVp9Opus)) {
11142            selectedMimeType = webmVp9Opus;
11143            fallbackUsed = true;
11144            console.log("🔊 ✅ Using WebM/VP9+Opus");
11145          } else if (MediaRecorder.isTypeSupported(webmBasic)) {
11146            selectedMimeType = webmBasic;
11147            fallbackUsed = true;
11148            console.log("🔊 ✅ Using basic WebM");
11149          } else {
11150            selectedMimeType = "";
11151            fallbackUsed = true;
11152            console.warn("🔴 No supported codecs found for MediaRecorder. Browser default will be used (may fail).");
11153          }
11154        }
11155
11156        // Adaptive bitrate based on content and duration
11157        // For shorter recordings, we can afford higher quality
11158        // For longer recordings, reduce bitrate to keep file sizes manageable
11159        const estimatedDuration = (mediaRecorderDuration || 5000) / 1000; // Convert to seconds
11160        const frameCount = recordedFrames?.length || 300; // Estimate if not available
11161        
11162        // Analyze content complexity by sampling frame differences (only for shorter recordings)
11163        let contentComplexity = 1.0; // Default complexity multiplier
11164        
11165        // Skip complex analysis for longer recordings to avoid issues
11166        if (recordedFrames && recordedFrames.length > 10 && estimatedDuration <= 30) {
11167          try {
11168            let totalDifference = 0;
11169            let samples = 0;
11170            const sampleStep = Math.max(1, Math.floor(recordedFrames.length / 10)); // Sample every N frames
11171            
11172            for (let i = sampleStep; i < recordedFrames.length && samples < 10; i += sampleStep) {
11173              const prevFrame = recordedFrames[i - sampleStep][1];
11174              const currentFrame = recordedFrames[i][1];
11175              
11176              // Safety check for valid frame data
11177              if (!prevFrame?.data || !currentFrame?.data) continue;
11178              
11179              // Quick difference check using a subset of pixels
11180              let pixelDiff = 0;
11181              const checkStep = 100; // Check every 100th pixel for speed
11182              const maxPixels = Math.min(prevFrame.data.length, currentFrame.data.length);
11183              
11184              for (let p = 0; p < maxPixels; p += checkStep * 4) {
11185                pixelDiff += Math.abs(prevFrame.data[p] - currentFrame.data[p]);
11186              }
11187              totalDifference += pixelDiff;
11188              samples++;
11189            }
11190            
11191            if (samples > 0) {
11192              const avgDifference = totalDifference / samples;
11193              // Normalize complexity (typical values range from 0-10000+)
11194              contentComplexity = Math.min(2.0, Math.max(0.5, avgDifference / 5000));
11195            }
11196          } catch (error) {
11197            console.warn("⚠️ Content complexity analysis failed, using default:", error.message);
11198            contentComplexity = 1.0; // Fallback to default
11199          }
11200        }
11201        
11202        if (debug) console.log(`📊 Content complexity analysis: ${contentComplexity.toFixed(2)}x (1.0 = normal, >1.0 = high motion/detail)`);
11203        
11204        let videoBitrate;
11205        let audioBitrate = 128000; // 128 kbps - good quality audio
11206        
11207        // Clean mode: Use highest quality settings regardless of duration
11208        if (window.currentRecordingOptions?.cleanMode) {
11209          videoBitrate = Math.round(200000000 * contentComplexity); // 200 Mbps base for clean mode
11210          audioBitrate = 320000; // 320 kbps audio for clean mode (ultra high quality)
11211          videoBitrate = Math.min(videoBitrate, 200000000); // Max 200 Mbps for clean mode
11212          console.log(`🎬 🧹 Clean mode: Using ultra-high bitrate ${(videoBitrate/1000000).toFixed(1)}Mbps video, ${audioBitrate/1000}kbps audio`);
11213        } else if (estimatedDuration <= 10) {
11214          // Short recordings (≤10s): High quality
11215          videoBitrate = Math.round(24000000 * contentComplexity); // 24 Mbps base
11216        } else if (estimatedDuration <= 30) {
11217          // Medium recordings (10-30s): High quality
11218          videoBitrate = Math.round(24000000 * contentComplexity);  // 24 Mbps base
11219        } else {
11220          // Longer recordings (>30s): Maintain good quality
11221          if (estimatedDuration <= 60) {
11222            videoBitrate = 24000000;  // Fixed 24 Mbps for 30-60s recordings
11223          } else {
11224            videoBitrate = 24000000;  // Fixed 24 Mbps for very long recordings
11225            audioBitrate = 128000;    // Keep 128 kbps audio for consistency
11226          }
11227        }
11228        
11229        // Ensure minimum 24 Mbps for all non-clean recordings
11230        if (!window.currentRecordingOptions?.cleanMode) {
11231          videoBitrate = Math.max(videoBitrate, 24000000); // Minimum 24 Mbps
11232        }
11233        
11234        if (debug) console.log(`📊 Adaptive bitrate: ${(videoBitrate/1000000).toFixed(1)}Mbps video, ${audioBitrate/1000}kbps audio (duration: ${estimatedDuration.toFixed(1)}s)`);
11235
11236        const recorderOptions = selectedMimeType ? {
11237          mimeType: selectedMimeType,
11238          videoBitsPerSecond: videoBitrate,
11239          audioBitsPerSecond: audioBitrate,
11240        } : {
11241          videoBitsPerSecond: videoBitrate,
11242          audioBitsPerSecond: audioBitrate,
11243        };
11244
11245        // Add frame rate constraint for consistent 60fps output
11246        if (finalStream.getVideoTracks().length > 0) {
11247          const videoTrack = finalStream.getVideoTracks()[0];
11248          const settings = videoTrack.getSettings();
11249          if (settings.frameRate && settings.frameRate > 60) {
11250            console.log(`🎬 Constraining video track from ${settings.frameRate}fps to 60fps`);
11251            videoTrack.applyConstraints({
11252              frameRate: { ideal: 60, max: 60 }
11253            }).catch(err => console.warn("Failed to apply frame rate constraint:", err));
11254          }
11255        }
11256
11257        if (fallbackUsed) {
11258          console.warn("⚠️ Only WebM output is possible in this browser. If you need MP4, you must transcode after recording.");
11259        }
11260
11261        console.log("🔊 Creating MediaRecorder with options:", recorderOptions);
11262        const videoRecorder = new MediaRecorder(finalStream, recorderOptions);
11263        
11264        console.log("🔊 MediaRecorder created");
11265        console.log("🔊 MediaRecorder mimeType:", videoRecorder.mimeType);
11266        console.log("🔊 MediaRecorder stream:", videoRecorder.stream);
11267        console.log("🔊 MediaRecorder stream tracks:", videoRecorder.stream.getTracks().length);
11268        console.log("🔊 MediaRecorder stream video tracks:", videoRecorder.stream.getVideoTracks().length);
11269        console.log("🔊 MediaRecorder stream audio tracks:", videoRecorder.stream.getAudioTracks().length);
11270        
11271        // Debug track states
11272        finalStream.getTracks().forEach((track, index) => {
11273          console.log(`🔊 Track ${index}: ${track.kind} enabled=${track.enabled} muted=${track.muted} readyState=${track.readyState}`);
11274        });
11275
11276        const chunks = [];
11277        let chunkCount = 0;
11278        videoRecorder.ondataavailable = function (e) {
11279          if (e.data && e.data.size > 0) {
11280            chunks.push(e.data);
11281            chunkCount++;
11282            if (chunkCount % 50 === 0) {
11283              console.log(`📦 Received ${chunkCount} data chunks (${(e.data.size / 1024).toFixed(1)}KB each)`);
11284            }
11285          }
11286        };
11287
11288        // Add error handling 
11289        videoRecorder.onerror = function (e) {
11290          console.error("MediaRecorder error:", e);
11291          console.error("Error details:", e.error);
11292          console.error("Used mimeType:", selectedMimeType);
11293          console.error("MediaRecorder state:", videoRecorder.state);
11294        };
11295
11296        // Add state change monitoring
11297        videoRecorder.onstart = function () {
11298          // MediaRecorder started successfully
11299        };
11300
11301        videoRecorder.onpause = function () {
11302          // MediaRecorder paused
11303        };
11304
11305        videoRecorder.onresume = function () {
11306          console.log("🎬 ▶️ MediaRecorder resumed");
11307        };
11308
11309        // Send encoding status
11310        send({
11311          type: "recorder:export-status",
11312          content: { 
11313            type: "video", 
11314            phase: "encoding", 
11315            message: "Encoding MP4" 
11316          }
11317        });
11318
11319        // Set up audio source if we have recorded audio
11320        let audioSource = null;
11321        let audioStarted = false;
11322        
11323        console.log("🔊 Setting up audio source...");
11324        console.log("🔊 hasRecordedAudio:", hasRecordedAudio);
11325        console.log("🔊 audioBuffer exists:", !!audioBuffer);
11326        console.log("🔊 audioDestination exists:", !!audioDestination);
11327        
11328        if (hasRecordedAudio && audioBuffer && audioDestination) {
11329          try {
11330            console.log("🔊 Creating audio source from buffer...");
11331            // Create audio source from the decoded buffer
11332            audioSource = audioDestination.context.createBufferSource();
11333            audioSource.buffer = audioBuffer;
11334            audioSource.connect(audioDestination);
11335            
11336            console.log("🔊 Audio source created and connected");
11337            console.log("🔊 Audio source buffer:", audioSource.buffer);
11338            console.log("🔊 Audio source context:", audioSource.context);
11339            console.log("🔊 Audio source context state:", audioSource.context.state);
11340            console.log("🔊 ✅ Audio source prepared for synchronized playback");
11341          } catch (error) {
11342            console.error("🔊 ❌ Failed to create audio source:", error);
11343            console.log("🔊 Error details:", error.message);
11344            audioSource = null;
11345          }
11346        } else {
11347          console.log("🔊 ❌ Cannot create audio source - missing requirements");
11348          console.log("🔊   hasRecordedAudio:", hasRecordedAudio);
11349          console.log("🔊   audioBuffer:", !!audioBuffer);
11350          console.log("🔊   audioDestination:", !!audioDestination);
11351        }
11352
11353        // Fast playback of pre-rendered frames with synchronized audio
11354        let frameIndex = 0;
11355        const audioBufferDuration = audioBuffer ? audioBuffer.duration * 1000 : processedFrames.length * frameDuration;
11356        
11357        // Use original frame timing for perfect sync instead of artificial even spacing
11358        const useOriginalTiming = content.frames && content.frames.length > 0 && content.frames[0].timestamp !== undefined;
11359        
11360        if (useOriginalTiming) {
11361          console.log("🔊 Using original frame timing for perfect audio sync");
11362          console.log("🔊 Audio duration:", audioBufferDuration.toFixed(1), "ms");
11363          console.log("🔊 Original recording duration:", (content.frames[content.frames.length - 1].timestamp - content.frames[0].timestamp).toFixed(1), "ms");
11364        } else {
11365          console.log("🔊 Using calculated frame timing - audioDuration:", audioBufferDuration.toFixed(1), "ms");
11366          console.log("🔊 Frame timing setup - actualFrameDuration:", (audioBufferDuration / processedFrames.length).toFixed(3), "ms");
11367        }
11368        console.log("🔊 Frame timing setup - totalFrames:", processedFrames.length);
11369        
11370        // Track timing for frame synchronization
11371        let startTime;
11372        
11373        // Define the frame rendering function with streaming approach
11374        async function renderNextFrame() {
11375          if (frameIndex >= processedFrames.length) {
11376            console.log(`🎬 ✅ Rendered all ${frameIndex} frames for MP4 export`);
11377            try {
11378              videoRecorder.stop();
11379            } catch (error) {
11380              console.error("Error stopping MediaRecorder:", error);
11381            }
11382            return;
11383          }
11384
11385          // Start audio playback on first frame for perfect sync
11386          if (!audioStarted && audioSource) {
11387            try {
11388              // Resume audio context if suspended
11389              if (audioSource.context.state === 'suspended') {
11390                const resumeStartTime = performance.now();
11391                audioSource.context.resume().then(() => {
11392                  const resumeEndTime = performance.now();
11393                  window.audioContextResumeTimestamps.requestedAt = resumeStartTime;
11394                  window.audioContextResumeTimestamps.completedAt = resumeEndTime;
11395                  audioSource.start(0);
11396                  audioStarted = true;
11397                }).catch((resumeError) => {
11398                  console.error("Failed to resume audio context:", resumeError);
11399                  // Try to start anyway
11400                  audioSource.start(0);
11401                  audioStarted = true;
11402                });
11403              } else {
11404                audioSource.start(0);
11405                audioStarted = true;
11406              }
11407            } catch (error) {
11408              console.error("Failed to start audio source:", error);
11409            }
11410          }
11411
11412          // Render frame on-demand to save memory
11413          const frameImageData = await renderFrameOnDemand(frameIndex);
11414          if (frameImageData) {
11415            ctx.putImageData(frameImageData, 0, 0);
11416          } else {
11417            console.warn(`Failed to render frame ${frameIndex}, using blank frame`);
11418            ctx.fillStyle = 'black';
11419            ctx.fillRect(0, 0, canvas.width, canvas.height);
11420          }
11421          
11422          // Log every 100 frames to track progress
11423          if (frameIndex % 100 === 0) {
11424            console.log(`🎬 Rendered frame ${frameIndex}/${processedFrames.length}`);
11425          }
11426          
11427          // Send progress update
11428          if (frameIndex % 30 === 0 || frameIndex === processedFrames.length - 1) {
11429            const progress = (frameIndex + 1) / processedFrames.length;
11430            
11431            send({
11432              type: "recorder:transcode-progress",
11433              content: 0.05 + (progress * 0.90), // 5% to 95% (resampling was 0-5%)
11434            });
11435          }
11436
11437          frameIndex++;
11438          
11439          // Calculate next frame time - force 60fps output timing
11440          const targetFrameRate = 60;
11441          const frameDuration = 1000 / targetFrameRate; // 16.67ms per frame
11442          
11443          let targetTime;
11444          if (useOriginalTiming && frameIndex < content.frames.length) {
11445            // Use original timing but clamp to 60fps maximum
11446            const baseTimestamp = content.frames[0].timestamp;
11447            const currentFrameTimestamp = content.frames[Math.min(frameIndex, content.frames.length - 1)].timestamp;
11448            const originalDelta = currentFrameTimestamp - baseTimestamp;
11449            
11450            // Ensure we don't exceed 60fps (minimum 16.67ms between frames)
11451            const minFrameTime = frameIndex * frameDuration;
11452            targetTime = Math.max(originalDelta, minFrameTime);
11453          } else {
11454            // Force exactly 60fps timing
11455            targetTime = frameIndex * frameDuration;
11456          }
11457          
11458          const currentTime = performance.now() - startTime;
11459          const delay = Math.max(0, targetTime - currentTime);
11460          
11461          setTimeout(renderNextFrame, delay);
11462        }
11463
11464        // Handle recording completion - define this before starting
11465        videoRecorder.onstop = async function () {
11466          console.log(`🎬 MediaRecorder stopped - received ${chunks.length} chunks, total size: ${(chunks.reduce((sum, c) => sum + c.size, 0) / 1024 / 1024).toFixed(2)}MB`);
11467          
11468          // Clean up audio resources
11469          if (audioSource) {
11470            try {
11471              audioSource.disconnect();
11472            } catch (error) {
11473              // Ignore disconnect errors as they're usually normal
11474            }
11475          }
11476          if (audioDestination) {
11477            try {
11478              await audioDestination.context.close();
11479            } catch (error) {
11480              // Ignore close errors as they're usually normal
11481            }
11482          }
11483          
11484          send({
11485            type: "recorder:export-status",
11486            content: { 
11487              type: "video", 
11488              phase: "finalizing", 
11489              message: "Finalizing MP4" 
11490            }
11491          });
11492
11493          const blob = new Blob(chunks, { type: mimeType });
11494
11495          // Determine file extension based on actual codec used
11496          const extension = selectedMimeType.includes('webm') ? 'webm' : 'mp4';
11497          const filename = generateTapeFilename(extension);
11498          console.log("🎬 Animated MP4 Export - currentRecordingOptions:", JSON.stringify(window.currentRecordingOptions, null, 2));
11499          
11500          receivedDownload({ filename, data: blob });
11501
11502          console.log(`🎬 ✅ Animated ${extension.toUpperCase()} exported successfully! ${processedFrames.length} frames → ${blob.size / 1024 / 1024}MB`);
11503
11504
11505          // Send completion message to video piece
11506          send({
11507            type: "recorder:export-complete",
11508            content: { type: "video", filename }
11509          });
11510        };
11511        
11512        // Start recording and begin frame rendering after a small delay
11513        console.log("🎬 Starting MediaRecorder...");
11514        console.log("🎬 MediaRecorder state before start:", videoRecorder.state);
11515        console.log("🎬 Stream active before start:", finalStream.active);
11516        console.log("🎬 Stream tracks before start:", finalStream.getTracks().map(t => `${t.kind}: ${t.readyState}`));
11517        
11518        try {
11519          console.log("🎬 About to start MediaRecorder with timeslice:", 100);
11520          console.log("🎬 MediaRecorder mimeType:", videoRecorder.mimeType);
11521          console.log("🎬 Final stream tracks before start:", finalStream.getTracks().length);
11522          console.log("🎬 Final stream video tracks before start:", finalStream.getVideoTracks().length);
11523          console.log("🎬 Final stream audio tracks before start:", finalStream.getAudioTracks().length);
11524          
11525          videoRecorder.start(100);
11526          
11527          console.log("🎬 MediaRecorder state after start():", videoRecorder.state);
11528          console.log("🎬 Stream active after start:", finalStream.active);
11529          
11530          // Verify tracks are still active after MediaRecorder start
11531          console.log("🎬 Final stream tracks after start:", finalStream.getTracks().length);
11532          finalStream.getTracks().forEach((track, index) => {
11533            console.log(`🎬 Track ${index} after start: ${track.kind} enabled=${track.enabled} muted=${track.muted} readyState=${track.readyState}`);
11534          });
11535          
11536          // Wait for MediaRecorder to start before beginning frame rendering
11537          setTimeout(() => {
11538            console.log("🎬 Beginning frame rendering...");
11539            console.log("🎬 MediaRecorder state at frame start:", videoRecorder.state);
11540            console.log("🎬 Stream active at frame start:", finalStream.active);
11541            console.log("🎬 Stream tracks at frame start:", finalStream.getTracks().map(t => `${t.kind}: ${t.readyState}`));
11542            
11543            // Check if any data has been received yet
11544            console.log("🎬 Chunks collected so far:", chunks.length);
11545            
11546            startTime = performance.now(); // Initialize timing reference
11547            renderNextFrame();
11548          }, 100); // 100ms delay to let MediaRecorder initialize
11549          
11550          // Add a longer timeout to check for data collection issues
11551          setTimeout(() => {
11552            console.log("🎬 🔍 DEBUG: 2 second check - chunks collected:", chunks.length);
11553            console.log("🎬 🔍 MediaRecorder state:", videoRecorder.state);
11554            console.log("🎬 🔍 Stream active:", finalStream.active);
11555            console.log("🎬 🔍 Stream tracks:", finalStream.getTracks().map(t => `${t.kind}: ${t.readyState}`));
11556          }, 2000);
11557          
11558        } catch (startError) {
11559          console.error("🎬 ❌ Failed to start MediaRecorder:", startError);
11560          
11561          // Clean up audio resources
11562          if (audioSource && !audioStarted) {
11563            audioSource.disconnect();
11564          }
11565          if (exportAudioContext && exportAudioContext.state !== 'closed') {
11566            exportAudioContext.close();
11567          }
11568          
11569          console.warn("⚠️ MP4 recording failed to start. Please try again.");
11570          return;
11571        }
11572
11573      } catch (error) {
11574        console.error("Error creating animated MP4:", error);
11575        
11576        // Send error status
11577        send({
11578          type: "recorder:export-status",
11579          content: { 
11580            type: "video", 
11581            phase: "error", 
11582            message: "MP4 export failed" 
11583          }
11584        });
11585      }
11586      return;
11587    }
11588
11589    // Zip up some data and download it.
11590    if (type === "zip") {
11591      try {
11592        if (!window.JSZip) await loadJSZip();
11593        const zip = new window.JSZip(); // https://github.com/Stuk/jszip
11594
11595        if (content.painting) {
11596          const steps = [];
11597          const images = {};
11598
11599          // Encode `painting:recording` format.
11600          content.painting.record.forEach((step) => {
11601            const format = `${step.timestamp} - ${step.label}`;
11602            const encodedStep = { step: format };
11603            if (step.gesture?.length > 0) encodedStep.gesture = step.gesture;
11604            steps.push(encodedStep);
11605            if (step.painting) {
11606              images[format] = bufferToBlob(step.painting, "image/png");
11607            }
11608          });
11609
11610          const stepFile = JSON.stringify(steps); // Encode a JSON file for steps.
11611
11612          zip.file("painting.json", stepFile);
11613
11614          // Add all images based on step and index.
11615          keys(images).forEach((label) => {
11616            zip.file(`${label}.png`, images[label]);
11617          });
11618
11619          const finalTimestamp =
11620            content.painting.record[content.painting.record.length - 1].timestamp;
11621
11622          const zipped = await zip.generateAsync({ type: "blob" });
11623          const filename = `painting-${finalTimestamp}.zip`;
11624
11625          if (content.destination === "download") {
11626            // See also: `receivedDownload`.
11627            const a = document.createElement("a");
11628            a.href = URL.createObjectURL(zipped);
11629            a.target = "_blank";
11630            a.download = filename; // Remove any extra paths.
11631            a.click();
11632            URL.revokeObjectURL(a.href);
11633            send({ type: "zipped", content: { result: "success", data: true } });
11634          } else if (content.destination === "upload") {
11635            // TODO: Put this on the S3 server somewhere...
11636            console.log("🤐 Uploading zip...", zipped);
11637            await receivedUpload({ filename, data: zipped }
11637, "zipped");
11638          }
11639        } else {
11640          send({ type: "zipped", content: { result: "error", data: false } });
11641        }
11642      } catch (error) {
11643        send({ type: "zipped", content: {
11644          result: "error", data: null,
11645          error: error?.message || "Could not prepare painting steps",
11646        } });
11647      }
11648
11649      return;
11650    }
11651
11652    // Load a zip from a URL and return its unpacked contents to the piece.
11653    if (type === "zip:load") {
11654      fetch(decodeURI(content))
11655        .then((response) => {
11656          // console.log("Response", response);
11657          if (response.status === 200 || response.status === 304) {
11658            return response.arrayBuffer();
11659          } else {
11660            throw new Error(`Zip not found. Status: ${response.status}`);
11661          }
11662        })
11663        .then(async (buffer) => {
11664          if (!window.JSZip) await loadJSZip();
11665          const record = await unzip(buffer);
11666          if (record.length === 0) throw new Error("Record is an empty array");
11667          send({
11668            type: "loaded-zip-success",
11669            content: { url: content, data: record },
11670          });
11671        })
11672        .catch((error) => {
11673          send({ type: "loaded-zip-rejection", content: { url: content } });
11674        });
11675      return;
11676    }
11677
11678    // 📼 Load and play tape from URL (for replay piece)
11679    if (type === "tape:play") {
11680      console.log("📼 Loading and playing tape:", content);
11681      
11682      (async () => {
11683        try {
11684          const { code, zipUrl, metadata: tapeMeta } = content;
11685          
11686          // Fetch the ZIP file
11687          let knownTotalBytes;
11688          try {
11689            const headResponse = await fetch(decodeURI(zipUrl), { method: "HEAD" });
11690            if (headResponse?.ok) {
11691              const headLength = headResponse.headers.get("content-length");
11692              if (headLength) {
11693                const parsedLength = Number(headLength);
11694                if (Number.isFinite(parsedLength) && parsedLength > 0) {
11695                  knownTotalBytes = parsedLength;
11696                }
11697              }
11698            }
11699          } catch (headError) {
11700            console.warn("📼 Unable to fetch tape HEAD for size", headError);
11701          }
11702
11703          const response = await fetch(decodeURI(zipUrl));
11704          if (!response.ok) {
11705            throw new Error(`Tape ZIP not found. Status: ${response.status}`);
11706          }
11707          
11708          let arrayBuffer;
11709          const headerContentLength = response.headers.get("content-length");
11710          if (!knownTotalBytes && headerContentLength) {
11711            const parsedLength = Number(headerContentLength);
11712            if (Number.isFinite(parsedLength) && parsedLength > 0) {
11713              knownTotalBytes = parsedLength;
11714            }
11715          }
11716
11717          const exposedTotalBytes = Number.isFinite(knownTotalBytes) ? knownTotalBytes : null;
11718          if (response.body) {
11719            const reader = response.body.getReader();
11720            const chunks = [];
11721            let receivedBytes = 0;
11722            let lastReportedProgress = -1;
11723            let lastReportedBytes = 0;
11724
11725            send({
11726              type: "tape:download-progress",
11727              content: {
11728                code,
11729                progress: exposedTotalBytes ? 0 : null,
11730                receivedBytes: 0,
11731                totalBytes: exposedTotalBytes,
11732              },
11733            });
11734
11735            while (true) {
11736              const { done, value } = await reader.read();
11737              if (done) break;
11738              chunks.push(value);
11739              receivedBytes += value.length;
11740
11741              const hasKnownTotal = Number.isFinite(knownTotalBytes) && knownTotalBytes > 0;
11742              const progress = hasKnownTotal
11743                ? Math.min(receivedBytes / knownTotalBytes, 1)
11744                : null;
11745
11746              const shouldReport = hasKnownTotal
11747                ? progress !== null && (progress - lastReportedProgress >= 0.01 || progress === 1)
11748                : receivedBytes - lastReportedBytes >= 262144 || receivedBytes === 0;
11749
11750              if (shouldReport) {
11751                send({
11752                  type: "tape:download-progress",
11753                  content: {
11754                    code,
11755                    progress,
11756                    receivedBytes,
11757                    totalBytes: exposedTotalBytes,
11758                  },
11759                });
11760                lastReportedBytes = receivedBytes;
11761                if (progress !== null) {
11762                  lastReportedProgress = progress;
11763                }
11764              }
11765            }
11766
11767            const concatenated = new Uint8Array(receivedBytes);
11768            let offset = 0;
11769            for (const chunk of chunks) {
11770              concatenated.set(chunk, offset);
11771              offset += chunk.length;
11772            }
11773            arrayBuffer = concatenated.buffer;
11774            if (!knownTotalBytes && receivedBytes > 0) {
11775              knownTotalBytes = receivedBytes;
11776            }
11777
11778            send({
11779              type: "tape:download-progress",
11780              content: {
11781                code,
11782                progress: Number.isFinite(knownTotalBytes) && knownTotalBytes > 0 ? 1 : null,
11783                receivedBytes,
11784                totalBytes: Number.isFinite(knownTotalBytes) && knownTotalBytes > 0 ? knownTotalBytes : null,
11785              },
11786            });
11787          } else {
11788            arrayBuffer = await response.arrayBuffer();
11789            const receivedBytes = arrayBuffer.byteLength;
11790            if (!knownTotalBytes && receivedBytes > 0) {
11791              knownTotalBytes = receivedBytes;
11792            }
11793            send({
11794              type: "tape:download-progress",
11795              content: {
11796                code,
11797                progress: Number.isFinite(knownTotalBytes) && knownTotalBytes > 0 ? 1 : null,
11798                receivedBytes,
11799                totalBytes: Number.isFinite(knownTotalBytes) && knownTotalBytes > 0 ? knownTotalBytes : null,
11800              },
11801            });
11802          }
11803
11804          console.log(`📼 ZIP downloaded: ${(arrayBuffer.byteLength / 1024 / 1024).toFixed(2)} MB`);
11805          
11806          // Parse ZIP with JSZip
11807          if (!window.JSZip) await loadJSZip();
11808          send({
11809            type: "tape:load-progress",
11810            content: { code, phase: "unpacking", progress: 0 },
11811          });
11812          const zip = await window.JSZip.loadAsync(arrayBuffer);
11813          
11814          console.log("📼 ZIP files:", Object.keys(zip.files));
11815          
11816          // Extract timing.json
11817          let timingData = [];
11818          const timingFile = zip.file("timing.json");
11819          if (timingFile) {
11820            const timingText = await timingFile.async("text");
11821            timingData = JSON.parse(timingText);
11822            console.log(`📼 Timing data: ${timingData.length} frames`);
11823          }
11824          
11825          // Extract metadata.json for audio sample rate
11826          let tapeMetadata = {};
11827          const metadataFile = zip.file("metadata.json");
11828          if (metadataFile) {
11829            const metadataText = await metadataFile.async("text");
11830            tapeMetadata = JSON.parse(metadataText);
11831            console.log(`📼 Metadata:`, tapeMetadata);
11832          }
11833          
11834          // Extract frame PNGs
11835          const frameFiles = Object.keys(zip.files)
11836            .filter(name => name.startsWith('frame-') && name.endsWith('.png'))
11837            .sort((a, b) => {
11838              const aNum = parseInt(a.match(/frame-(\d+)/)[1]);
11839              const bNum = parseInt(b.match(/frame-(\d+)/)[1]);
11840              return aNum - bNum;
11841            });
11842          
11843          console.log(`📼 Loading ${frameFiles.length} frames into recordedFrames...`);
11844          
11845          // Clear existing recorded frames
11846          recordedFrames.length = 0;
11847          
11848          const defaultFrameDuration = 1000 / 30; // Assume ~30fps when timing data is missing
11849          const baseTimestamp = (() => {
11850            for (let i = 0; i < timingData.length; i += 1) {
11851              const entry = timingData[i];
11852              if (entry && Number.isFinite(entry.timestamp)) {
11853                return entry.timestamp;
11854              }
11855            }
11856            return null;
11857          })();
11858
11859          // Load frames as ImageData
11860          let fallbackElapsed = 0;
11861          let lastFrameProgress = 0;
11862          for (let i = 0; i < frameFiles.length; i++) {
11863            const frameFile = zip.file(frameFiles[i]);
11864            const frameBlob = await frameFile.async("blob");
11865            const frameBitmap = await createImageBitmap(frameBlob);
11866            
11867            // Create canvas to extract ImageData
11868            const tempCanvas = document.createElement("canvas");
11869            tempCanvas.width = frameBitmap.width;
11870            tempCanvas.height = frameBitmap.height;
11871            const tempCtx = tempCanvas.getContext("2d");
11872            tempCtx.drawImage(frameBitmap, 0, 0);
11873            const imageData = tempCtx.getImageData(0, 0, frameBitmap.width, frameBitmap.height);
11874            
11875            // Use timing from timing.json if available
11876            const frameTiming = timingData[i];
11877            const frameDuration = frameTiming && Number.isFinite(frameTiming.duration)
11878              ? frameTiming.duration
11879              : defaultFrameDuration;
11880            let timestamp;
11881
11882            if (frameTiming && Number.isFinite(frameTiming.timestamp) && baseTimestamp !== null) {
11883              timestamp = frameTiming.timestamp - baseTimestamp;
11884              if (!Number.isFinite(timestamp) || timestamp < 0) {
11885                timestamp = fallbackElapsed;
11886              }
11887            } else {
11888              timestamp = fallbackElapsed;
11889            }
11890
11891            recordedFrames.push([timestamp, imageData]);
11892            fallbackElapsed = timestamp + frameDuration;
11893            
11894            if (i % 50 === 0 || i === frameFiles.length - 1) {
11895              console.log(`  Loaded ${i + 1}/${frameFiles.length} frames`);
11896            }
11897
11898            if (frameFiles.length > 0) {
11899              const progress = Math.min((i + 1) / frameFiles.length, 1);
11900              if (progress - lastFrameProgress >= 0.01 || progress === 1) {
11901                send({
11902                  type: "tape:load-progress",
11903                  content: {
11904                    code,
11905                    phase: "frames",
11906                    progress,
11907                    loadedFrames: i + 1,
11908                    totalFrames: frameFiles.length,
11909                  },
11910                });
11911                lastFrameProgress = progress;
11912              }
11913            }
11914          }
11915
11916          if (frameFiles.length === 0) {
11917            send({
11918              type: "tape:load-progress",
11919              content: {
11920                code,
11921                phase: "frames",
11922                progress: 1,
11923                loadedFrames: 0,
11924                totalFrames: 0,
11925              },
11926            });
11927          }
11928          
11929          // Calculate total duration from timing data
11930          if (timingData.length === 0) {
11931            console.log("📼 Timing data missing; using fallback frame duration");
11932          }
11933
11934          if (recordedFrames.length > 0) {
11935            const lastFrameTimestamp = recordedFrames[recordedFrames.length - 1][0];
11936            const lastFrameTiming = timingData.length > 0 ? timingData[Math.min(timingData.length - 1, recordedFrames.length - 1)] : null;
11937            const lastFrameDuration = lastFrameTiming && Number.isFinite(lastFrameTiming.duration)
11938              ? lastFrameTiming.duration
11939              : defaultFrameDuration;
11940
11941            mediaRecorderDuration = Math.max(0, lastFrameTimestamp + lastFrameDuration);
11942            console.log(`📼 Calculated duration: ${mediaRecorderDuration}ms (normalized)`);
11943          } else {
11944            mediaRecorderDuration = 0;
11945            console.warn("📼 No frames loaded from tape; duration set to 0");
11946          }
11947          
11948          // Extract and load audio if available
11949          // 🛟 Imported tapes (atproto etc.) may ship a zip whose
11950          // soundtrack.wav is a silent placeholder — or no wav at all —
11951          // while the mp4 rendition carries the real audio. This fallback
11952          // decodes the mp4's audio track instead of playing silence
11953          // (which the corrupted-sample watchdog would kill anyway).
11954          const loadMp4RenditionAudio = async () => {
11955            try {
11956              const mp4Res = await fetch(`/media/tapes/${code}.mp4`);
11957              if (!mp4Res.ok) {
11958                console.log("📼 No mp4 rendition either — tape plays silent");
11959                return false;
11960              }
11961              const mp4Buf = await mp4Res.arrayBuffer();
11962              window.tapeAudioArrayBuffer = mp4Buf.slice(0);
11963              const ctx2 =
11964                audioContext && audioContext.state !== "closed"
11965                  ? audioContext
11966                  : new OfflineAudioContext(1, 1, 48000);
11967              const audioBuffer = await ctx2.decodeAudioData(mp4Buf);
11968              sfx["tape:audio"] = audioBuffer;
11969              console.log(
11970                `📼 mp4 rendition audio loaded: ${audioBuffer.duration.toFixed(2)}s, ` +
11971                  `${audioBuffer.numberOfChannels}ch @ ${audioBuffer.sampleRate}Hz`,
11972              );
11973              return true;
11974            } catch (err) {
11975              console.warn("📼 mp4 rendition audio decode failed:", err?.message);
11976              return false;
11977            }
11978          };
11979          const bufferIsSilent = (buf) => {
11980            // "Has content" bar: anything under −40dB peak across the
11981            // whole buffer counts as a placeholder, not a soundtrack.
11982            const ch = buf.getChannelData(0);
11983            for (let i = 0; i < ch.length; i += 128) {
11984              if (Math.abs(ch[i]) > 0.01) return false;
11985            }
11986            return true;
11987          };
11988          const soundtrackFile = zip.file("soundtrack.wav");
11989          if (soundtrackFile) {
11990            console.log("📼 Found soundtrack.wav, loading audio...");
11991            try {
11992              const audioBlob = await soundtrackFile.async("blob");
11993              const audioArrayBuffer = await audioBlob.arrayBuffer();
11994              
11995              // Decode WITHOUT creating a bios AudioContext. A bare context
11996              // here would make startSound() bail on the user's first tap,
11997              // so the speaker worklet — the only thing that can actually
11998              // play tape audio — would never boot and the tape would stay
11999              // silent forever. When no real context exists yet, decode
12000              // through a throwaway OfflineAudioContext; the worklet-ready
12001              // hook in startSound() starts the soundtrack after the tap.
12002              const targetSampleRate = tapeMetadata.audioSampleRate || 48000;
12003              const ctx =
12004                audioContext && audioContext.state !== "closed"
12005                  ? audioContext
12006                  : new OfflineAudioContext(1, 1, targetSampleRate);
12007              if (ctx === audioContext) {
12008                console.log(`📼 Using existing AudioContext: ${ctx.sampleRate}Hz, state: ${ctx.state}`);
12009              } else {
12010                console.log(`📼 Decoding tape audio via OfflineAudioContext at ${targetSampleRate}Hz`);
12011              }
12012
12013              // Store raw audio data for potential re-decoding when AudioContext becomes running
12014              window.tapeAudioArrayBuffer = audioArrayBuffer.slice();
12015
12016              const audioBuffer = await ctx.decodeAudioData(audioArrayBuffer);
12017              if (bufferIsSilent(audioBuffer)) {
12018                console.log("📼 soundtrack.wav is a silent placeholder — trying mp4 rendition audio...");
12019                await loadMp4RenditionAudio();
12020              } else {
12021                sfx["tape:audio"] = audioBuffer;
12022                console.log(`📼 Audio loaded: ${audioBuffer.duration.toFixed(2)}s, ${audioBuffer.numberOfChannels} channels, ${audioBuffer.sampleRate}Hz`);
12023                console.log(`📼 Duration comparison - Video: ${(mediaRecorderDuration / 1000).toFixed(2)}s, Audio: ${audioBuffer.duration.toFixed(2)}s, Δ: ${Math.abs(audioBuffer.duration - mediaRecorderDuration / 1000).toFixed(2)}s`);
12024              }
12025            } catch (error) {
12026              console.error("📼 Error loading audio:", error);
12027              console.warn("📼 Tape will play without audio");
12028            }
12029          } else {
12030            console.log("📼 No soundtrack.wav in zip — trying mp4 rendition audio...");
12031            await loadMp4RenditionAudio();
12032          }
12033
12034          // 🎵 If the worklet is already up (warm context — Electron panes
12035          // often boot with audio running), the worklet-ready hook that
12036          // normally starts the soundtrack has ALREADY fired and missed
12037          // this decode. Start the loop now or the tape plays silent.
12038          if (
12039            sfx["tape:audio"] &&
12040            audioContext?.state === "running" &&
12041            !Object.keys(sfxPlaying).some((id) => id.startsWith("tape:audio_"))
12042          ) {
12043            startTapeAudioLoop(currentTapePosition || 0);
12044            console.log("🎵 📼 Tape audio started post-decode (worklet already up)");
12045          }
12046
12047          // Trigger presentation using existing underlay system
12048          // Instead of send({ type: "recorder:present" }), directly invoke presentation
12049          // since we're already in the bios context with frames loaded
12050          
12051          // First, send notification to disk that tape is loaded
12052          send({
12053            type: "tape:loaded",
12054            content: {
12055              code,
12056              framesReady: recordedFrames.length,
12057              totalFrames: frameFiles.length,
12058              streaming: false,
12059            },
12060          });
12061          
12062          console.log("📼 Tape loaded and ready to play");
12063          
12064          // Send AudioContext state to video piece so it knows about audio
12065          console.log("📼 🎵 Sending AudioContext state to video piece:", { state: audioContext?.state || "suspended", hasAudio: !!sfx["tape:audio"] });
12066          send({
12067            type: "tape:audio-context-state",
12068            content: {
12069              state: audioContext?.state || "suspended",
12070              hasAudio: !!sfx["tape:audio"],
12071            },
12072          });
12073          
12074          // Now trigger presentation by calling the same logic as recorder:present
12075          // We'll use a setTimeout to allow the tape:loaded message to be processed first
12076          setTimeout(async () => {
12077            console.log("📼 Triggering presentation for loaded tape");
12078            await receivedChange({ data: { type: "recorder:present", content: {} } });
12079          }, 100);
12080        } catch (error) {
12081          console.error("📼 Error loading/playing tape:", error);
12082          send({ type: "tape:error", content: error.message });
12083        }
12084      })();
12085
12086      return;
12087    }
12088
12089    // 🧪 Synthesize a scrubbable test tape (a "synthtape") entirely
12090    // client-side — no zip, no network. Deterministic frames (frame number,
12091    // timecode, position hue, per-frame parity flicker) plus a soundtrack
12092    // whose pitch encodes tape position, so scrub speed, direction, inertia,
12093    // and AV sync can all be judged against ground truth. Entry: `video scrub`.
12094    if (type === "tape:play-synth") {
12095      (async () => {
12096        try {
12097          const duration = Math.max(1, Math.min(30, content?.duration || 8)); // seconds
12098          const fps = Math.max(5, Math.min(60, content?.fps || 30));
12099          const width = content?.width || 320;
12100          const height = content?.height || 180;
12101          const style = content?.style || "bed"; // "bed" | "break"
12102          const totalFrames = Math.round(duration * fps);
12103          console.log(`🧪 Synthesizing tape: ${totalFrames} frames @ ${fps}fps, ${duration}s, style "${style}"`);
12104
12105          // Soundtrack first (the frames render its waveform): a 120 BPM
12106          // bed — kick on every beat, hats on the offbeats, a bass root per
12107          // bar, pentatonic plucks — with a quiet position-pitch sweep
12108          // underneath. Every pitched voice is quantized to whole cycles
12109          // per loop, so the loop seam is phase-continuous: musically
12110          // seamless by construction, not by fade.
12111          const sampleRate = 48000;
12112          const sampleCount = Math.floor(duration * sampleRate);
12113          const pcm = new Int16Array(sampleCount);
12114          const TAU = Math.PI * 2;
12115          const BEAT = 0.5; // 120 BPM
12116          const BAR = BEAT * 4;
12117          // Roots quantized to 0.5Hz — whole cycles per 2s bar: ~E2 C2 G2 D2.
12118          const bassRoots = [82.5, 65.5, 98, 73.5];
12119          // Plucks quantized to 4Hz — whole cycles per quarter-beat note.
12120          const pent = [328, 392, 440, 492, 588];
12121          let bassPhase = 0;
12122          let melPhase = 0;
12123          let ph1 = 0; // Generic per-style phase accumulators
12124          let ph2 = 0;
12125          let ph3 = 0;
12126          let sineF = 164; // Sineline's gliding frequency (~E3 start)
12127          let acidF = 82; // Acid line's sliding frequency (~E2 start)
12128          let acidPh = 0; // Acid oscillator phase
12129          let arpPh = 0; // Arp oscillator phase
12130          let bellPh1 = 0; // Bell partial phases
12131          let bellPh2 = 0;
12132          let bellPh3 = 0;
12133          let bellLastHit = -10; // Time of the most recent bell strike
12134          let bellPrevStep = -1;
12135          const q05 = (f) => Math.round(f * 2) / 2; // Quantize to 0.5Hz — whole cycles per loop
12136          // 🥁 "break" style: one bar of 16th-note breakbeat, repeated —
12137          // scratch material with hard transients on a funk grid.
12138          const STEP = BEAT / 4; // 16ths at 120 BPM
12139          const BREAK_KICK = [1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0];
12140          const BREAK_SNARE = [0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 1];
12141          const BREAK_HAT = [1, 0, 1, 0, 1, 0, 1, 1, 1, 0, 1, 0, 1, 1, 0, 1];
12142          for (let s = 0; s < sampleCount; s++) {
12143            const tt = s / sampleRate;
12144            const pos = tt / duration;
12145            const inBeat = tt % BEAT;
12146            const beatIdx = Math.floor(tt / BEAT) % 4;
12147            const nz = Math.sin(s * 12.9898) * 43758.5453;
12148            const noise = nz - Math.floor(nz) - 0.5;
12149            let v = 0;
12150
12151            if (style === "break") {
12152              const step16 = Math.floor(tt / STEP) % 16;
12153              const inStep = tt % STEP;
12154              if (BREAK_KICK[step16]) {
12155                v +=
12156                  Math.sin(TAU * (50 + 130 * Math.exp(-inStep * 30)) * inStep) *
12157                  Math.exp(-inStep * 16) * 0.75;
12158              }
12159              if (BREAK_SNARE[step16]) {
12160                v += noise * Math.exp(-inStep * 40) * 0.55; // Crack
12161                v += Math.sin(TAU * 190 * inStep) * Math.exp(-inStep * 30) * 0.3; // Body
12162              }
12163              if (BREAK_HAT[step16]) {
12164                v += noise * Math.exp(-inStep * 220) * 0.3;
12165              }
12166            } else if (style === "house") {
12167              // 🏠 Four-on-the-floor: kick every beat, clap on 2 & 4, open
12168              // hats and a 55Hz bass stab on the offbeats.
12169              const step8 = Math.floor(tt / (BEAT / 2)) % 8;
12170              const inHalf = tt % (BEAT / 2);
12171              v +=
12172                Math.sin(TAU * (48 + 110 * Math.exp(-inBeat * 28)) * inBeat) *
12173                Math.exp(-inBeat * 15) * 0.7;
12174              if (beatIdx === 1 || beatIdx === 3) {
12175                v += noise * Math.exp(-inBeat * 30) * 0.4; // Clap
12176              }
12177              ph1 += (TAU * 55) / sampleRate; // A1 — whole cycles per bar
12178              if (step8 % 2 === 1) {
12179                v += noise * Math.exp(-inHalf * 25) * 0.26; // Open hat
12180                v +=
12181                  (Math.sin(ph1) + Math.sin(ph1 * 2) * 0.4) *
12182                  Math.exp(-inHalf * 12) * 0.42; // Bass stab
12183              }
12184            } else if (style === "acid") {
12185              // 🧪 Acid line: a 303-ish 16-step bassline — pent-derived low
12186              // notes with slides, octave jumps, and accented steps whose
12187              // "filter" opens (more harmonics, brighter). One continuous-
12188              // phase oscillator; the squelch is all envelope + harmonics.
12189              const step16 = Math.floor(tt / STEP) % 16;
12190              const inStep = tt % STEP;
12191              // Pattern: pent index (0-4), +10 = octave up, -1 = rest.
12192              const ACID = [0, 0, 12, 0, 3, -1, 0, 13, 0, 10, 3, 0, 14, 0, 1, -1];
12193              const ACCENT = [1, 0, 1, 0, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 0];
12194              const a = ACID[step16];
12195              if (a >= 0) {
12196                const idx = a % 10;
12197                const oct = a >= 10 ? 2 : 1;
12198                const target = q05((pent[idx] / 4) * oct);
12199                acidF += (target - acidF) * 0.0015; // The 303 slide
12200                acidPh += (TAU * acidF) / sampleRate;
12201                const acc = ACCENT[step16];
12202                const env = Math.exp(-inStep * (acc ? 7 : 12));
12203                const bright = acc ? 1 : 0.35; // Accent opens the filter
12204                const edge = Math.min(1, tt / 0.01, (duration - tt) / 0.01);
12205                v +=
12206                  (Math.sin(acidPh) +
12207                    Math.sin(acidPh * 2) * 0.5 * bright +
12208                    Math.sin(acidPh * 3) * 0.33 * bright) *
12209                  env * (acc ? 0.5 : 0.36) * edge;
12210              }
12211            } else if (style === "arp") {
12212              // ✨ Arp: plucky 16th-note arpeggio bouncing up an octave and
12213              // back — the sparkling top line. Chord root walks per bar.
12214              const step16 = Math.floor(tt / STEP) % 16;
12215              const inStep = tt % STEP;
12216              const bar = Math.floor(tt / BAR) % 4;
12217              const SHAPE = [0, 2, 4, 12, 14, 12, 4, 2]; // Up-and-over
12218              const s = SHAPE[step16 % 8];
12219              const idx = (s % 10) + bar; // Root walks with the bars
12220              const oct = s >= 10 ? 2 : 1;
12221              const f = q05(pent[idx % pent.length] * oct);
12222              arpPh += (TAU * f) / sampleRate;
12223              const att = Math.min(1, inStep / 0.003);
12224              const env = Math.exp(-inStep * 20) * att;
12225              const edge = Math.min(1, tt / 0.01, (duration - tt) / 0.01);
12226              v += Math.sin(arpPh) * env * 0.42 * edge;
12227              v += Math.sin(arpPh * 2) * env * env * 0.12 * edge; // Sparkle
12228            } else if (style === "bell") {
12229              // 🔔 Bells: sparse gamelan-ish strikes with inharmonic-leaning
12230              // partials (each quantized to whole cycles), long rings damped
12231              // by the next strike. Mid-register color for the band.
12232              const HALFB = BEAT / 2;
12233              const step = Math.floor(tt / HALFB) % 16;
12234              const BELLHITS = [0, 3, 6, 10, 13]; // Lilting strike pattern
12235              if (step !== bellPrevStep) {
12236                bellPrevStep = step;
12237                if (BELLHITS.includes(step)) bellLastHit = Math.floor(tt / HALFB) * HALFB;
12238              }
12239              const hitNo = Math.floor(tt / HALFB); // For note choice stability
12240              const noteIdx = (Math.floor(hitNo / 3) * 2 + hitNo) % pent.length;
12241              const f = pent[noteIdx]; // Already 4Hz-quantized
12242              bellPh1 += (TAU * q05(f)) / sampleRate;
12243              bellPh2 += (TAU * q05(f * 2.756)) / sampleRate;
12244              bellPh3 += (TAU * q05(f * 5.404)) / sampleRate;
12245              const since = tt - bellLastHit;
12246              if (since >= 0 && since < 2) {
12247                const ring = Math.exp(-since * 2.4);
12248                const edge = Math.min(1, tt / 0.01, (duration - tt) / 0.01);
12249                v +=
12250                  (Math.sin(bellPh1) * 0.4 +
12251                    Math.sin(bellPh2) * 0.22 * Math.exp(-since * 4) +
12252                    Math.sin(bellPh3) * 0.1 * Math.exp(-since * 7)) *
12253                  ring * edge;
12254                v += noise * Math.exp(-since * 90) * 0.08; // Mallet tick
12255              }
12256            } else if (
12257              style === "kick" ||
12258              style === "hat" ||
12259              style === "snare"
12260            ) {
12261              // 🥁 Isolated stems of the break kit — one instrument per
12262              // tape. For stem-testing a band: scrub ONE voice while its
12263              // siblings hold the grid, and the ear knows exactly whose
12264              // hands moved.
12265              const step16 = Math.floor(tt / STEP) % 16;
12266              const inStep = tt % STEP;
12267              if (style === "kick" && BREAK_KICK[step16]) {
12268                v +=
12269                  Math.sin(TAU * (50 + 130 * Math.exp(-inStep * 30)) * inStep) *
12270                  Math.exp(-inStep * 16) * 0.85;
12271              }
12272              if (style === "snare" && BREAK_SNARE[step16]) {
12273                v += noise * Math.exp(-inStep * 40) * 0.6; // Crack
12274                v += Math.sin(TAU * 190 * inStep) * Math.exp(-inStep * 30) * 0.35; // Body
12275              }
12276              if (style === "hat" && BREAK_HAT[step16]) {
12277                v += noise * Math.exp(-inStep * 220) * 0.5;
12278              }
12279            } else if (style === "sine") {
12280              // 〰️ Sineline, plucked: one continuous-phase sine walking a
12281              // 16-step half-beat melody in a lower register, struck with a
12282              // percussive envelope + a tiny thock — a bass-y kalimba line
12283              // rather than a drone. Rests and octave dips give it motion.
12284              // Amplitude makes the pluck; the phase never breaks, and all
12285              // pitches stay 0.5Hz-quantized, so the seam is still perfect.
12286              const HALF = BEAT / 2;
12287              const step = Math.floor(tt / HALF) % 16;
12288              const inStep = tt % HALF;
12289              // Pent indices; negative = rest, 10+ = same note an octave down.
12290              const MELODY = [0, 2, 4, 12, 3, 0, -1, 1, 4, 2, 10, 2, 1, -1, 3, 0];
12291              const m = MELODY[step];
12292              const rest = m < 0;
12293              const idx = rest ? 0 : m % 10;
12294              const octDown = m >= 10;
12295              const target = (pent[idx] / 2) * (octDown ? 0.5 : 1);
12296              sineF += (target - sineF) * 0.002; // Fast settle — pluck pitch
12297              ph1 += (TAU * sineF) / sampleRate;
12298              ph2 += (TAU * sineF) / 2 / sampleRate;
12299              const sineEdge = Math.min(1, tt / 0.015, (duration - tt) / 0.015);
12300              if (!rest) {
12301                const att = Math.min(1, inStep / 0.004); // 4ms bite
12302                const env = Math.exp(-inStep * 7) * att;
12303                v += Math.sin(ph1) * 0.5 * env * sineEdge;
12304                v += Math.sin(ph2) * 0.2 * Math.exp(-inStep * 4) * att * sineEdge; // Sub body
12305                v += noise * Math.exp(-inStep * 160) * 0.1; // Thock
12306              }
12307            } else if (style === "dub") {
12308              // 🌫️ Halftime dub: deep kicks on 1 & 3, rim on 3, a sub line
12309              // per half-bar, and offbeat skank chords.
12310              const inHalfBar = tt % (BAR / 2);
12311              if (beatIdx === 0 || beatIdx === 2) {
12312                v +=
12313                  Math.sin(TAU * (38 + 90 * Math.exp(-inBeat * 18)) * inBeat) *
12314                  Math.exp(-inBeat * 9) * 0.75;
12315              }
12316              if (beatIdx === 2) {
12317                v += noise * Math.exp(-inBeat * 12) * 0.14; // Airy tail
12318                v += Math.sin(TAU * 620 * inBeat) * Math.exp(-inBeat * 45) * 0.22; // Rim
12319              }
12320              const half = Math.floor(tt / (BAR / 2)) % 2;
12321              ph1 += (TAU * (half ? 41.5 : 55)) / sampleRate; // A1 ↔ ~E1 sub
12322              v += Math.sin(ph1) * Math.exp(-inHalfBar * 1.2) * 0.42;
12323              ph2 += (TAU * 220) / sampleRate; // Skank chord: A3 + ~C4
12324              ph3 += (TAU * 261.5) / sampleRate;
12325              const off = (tt + BEAT / 2) % BEAT;
12326              if (off < 0.12 && (beatIdx === 1 || beatIdx === 3)) {
12327                v += (Math.sin(ph2) + Math.sin(ph3)) * Math.exp(-off * 30) * 0.16;
12328              }
12329            } else {
12330              // Kick: a 120→45Hz chirp with a fast decay.
12331              v +=
12332                Math.sin(TAU * (45 + 120 * Math.exp(-inBeat * 26)) * inBeat) *
12333                Math.exp(-inBeat * 18) * 0.6;
12334
12335              // Hat: a short burst of deterministic pseudo-noise on the offbeat.
12336              const inOff = (tt + BEAT / 2) % BEAT;
12337              if (inOff < 0.03) {
12338                const n = Math.sin(s * 12.9898) * 43758.5453;
12339                v += (n - Math.floor(n) - 0.5) * Math.exp(-inOff * 160) * 0.35;
12340              }
12341
12342              // Bass: one root per bar, pulsed per beat.
12343              const bar = Math.floor(tt / BAR) % bassRoots.length;
12344              bassPhase += (TAU * bassRoots[bar]) / sampleRate;
12345              v +=
12346                (Math.sin(bassPhase) + Math.sin(bassPhase * 2) * 0.3) *
12347                Math.exp(-inBeat * 4) * 0.25;
12348
12349              // Melody: a pentatonic pluck every half beat, stepped deterministically.
12350              const noteIdx = Math.floor(tt / (BEAT / 2));
12351              const inNote = tt % (BEAT / 2);
12352              const mf = pent[(noteIdx * 3 + Math.floor(noteIdx / 5)) % pent.length];
12353              melPhase += (TAU * mf) / sampleRate;
12354              v += Math.sin(melPhase) * Math.exp(-inNote * 9) * 0.22;
12355            }
12356
12357            // (The 220→880Hz position-sweep diagnostic is gone — the tapes
12358            // are musical material now;
12358 position lives in the visuals.)
12359            pcm[s] = Math.tanh(v) * 32767; // Soft-clip the mix.
12360          }
12361
12362          // Min/max waveform envelope per ruler pixel, so the frames can
12363          // draw the actual soundtrack scrolling by.
12364          const pxPerSec = width / 2; // One tape-second spans half a frame-width
12365          const waveCols = Math.ceil(duration * pxPerSec);
12366          const waveMin = new Float32Array(waveCols);
12367          const waveMax = new Float32Array(waveCols);
12368          const waveZc = new Float32Array(waveCols); // Per-column brightness (zero-cross rate)
12369          const samplesPerCol = sampleCount / waveCols;
12370          for (let c = 0; c < waveCols; c++) {
12371            let lo = 1;
12372            let hi = -1;
12373            let zc = 0;
12374            let prevS = 0;
12375            const colStart = Math.floor(c * samplesPerCol);
12376            const colEnd = Math.min(sampleCount, Math.ceil((c + 1) * samplesPerCol));
12377            for (let s = colStart; s < colEnd; s += 4) {
12378              const vv = pcm[s] / 32767;
12379              if (vv * prevS < 0) zc++;
12380              prevS = vv;
12381              if (vv < lo) lo = vv;
12382              if (vv > hi) hi = vv;
12383            }
12384            waveMin[c] = lo;
12385            waveMax[c] = hi;
12386            // Normalized zero-cross rate (0..1-ish): bassy ≈ 0, hissy ≈ 1.
12387            waveZc[c] = Math.min(1, zc / Math.max(1, (colEnd - colStart) / 4) * 6);
12388          }
12389
12390          recordedFrames.length = 0;
12391          const synthCanvas = document.createElement("canvas");
12392          synthCanvas.width = width;
12393          synthCanvas.height = height;
12394          const sctx = synthCanvas.getContext("2d", { willReadFrequently: true });
12395
12396          const drawSynthFrame = (i, sctx) => {
12397            const t = i / fps; // seconds
12398            const progress = totalFrames > 1 ? i / (totalFrames - 1) : 0;
12399            // Position-hue background: an instant "where am I" cue. Full
12400            // 360° wheel so the hue is CONTINUOUS across the loop seam.
12401            sctx.fillStyle = `hsl(${Math.round(progress * 360) % 360}, 60%, 16%)`;
12402            sctx.fillRect(0, 0, width, height);
12403
12404            // Scrolling ruler: the tape's timeline slides horizontally under
12405            // a fixed center playhead, so scrub velocity reads directly as
12406            // pixel velocity (and direction as scroll direction).
12407            const bandTop = 8;
12408            const bandH = height - bandTop - 4;
12409            const halfSpanSec = width / 2 / pxPerSec + 1;
12410            for (
12411              let s = Math.floor(t - halfSpanSec);
12412              s <= Math.ceil(t + halfSpanSec);
12413              s++
12414            ) {
12415              const x = Math.round(width / 2 + (s - t) * pxPerSec);
12416              // The tape is a loop — wrap the ruler past both edges so
12417              // there's never dead black; the material just repeats.
12418              const sw = ((s % duration) + duration) % duration;
12419              // Alternating per-second panels make speed and direction
12420              // legible; a faint line marks each second. No numbers, no
12421              // tick rows — the waveform is the marking.
12422              sctx.fillStyle = `hsl(${Math.round((sw / duration) * 300)}, 55%, ${sw % 2 ? 26 : 18}%)`;
12423              sctx.fillRect(x, bandTop, Math.ceil(pxPerSec), bandH);
12424              sctx.fillStyle = "rgba(255, 255, 255, 0.25)";
12425              sctx.fillRect(x, bandTop, 1, bandH);
12426            }
12427
12428            // The soundtrack's actual waveform, scrolling with the ruler
12429            // (wrapped past the seam like the panels).
12430            const midY = bandTop + bandH / 2;
12431            for (let x = 0; x < width; x++) {
12432              let c = Math.round(t * pxPerSec + x - width / 2);
12433              c = ((c % waveCols) + waveCols) % waveCols;
12434              // 🌈 Chromatic texture: hue follows the audio's brightness
12435              // (zero-cross rate — bass warm, hiss cool), luminance follows
12436              // its energy, so the ruler shows WHAT is passing through.
12437              const amp = Math.min(1, (waveMax[c] - waveMin[c]) * 1.2);
12438              const hue = Math.round(30 + waveZc[c] * 170);
12439              sctx.fillStyle = `hsl(${hue}, 90%, ${Math.round(48 + amp * 30)}%)`;
12440              const yTop = midY + waveMin[c] * (bandH / 2 - 2);
12441              const yBot = midY + waveMax[c] * (bandH / 2 - 2);
12442              sctx.fillRect(
12443                x,
12444                Math.min(yTop, yBot),
12445                1,
12446                Math.max(1, Math.abs(yBot - yTop)),
12447              );
12448            }
12449
12450            // (No parity chip, timecode, or baked-in progress bar anymore —
12451            // the diagnostics served their purpose; the piece draws the red
12452            // bottom marker from live playback state.)
12453            // Subtle center reading-head marker the ruler slides beneath.
12454            sctx.fillStyle = "rgba(255, 255, 255, 0.35)";
12455            sctx.fillRect(Math.floor(width / 2), 8, 1, height - 12);
12456
12457          };
12458
12459          // 🧠 Frames render ON DEMAND: recordedFrames holds placeholders
12460          // (~10KB) instead of ~55MB of pre-baked ImageData per pane, and
12461          // "synthesis" is instant. The playback paths call
12462          // window.__synthFrameDraw when they meet a placeholder — each
12463          // frame is one bg fill + panel fills + ~320 waveform rects,
12464          // well under a millisecond.
12465          window.__synthFrameDraw = drawSynthFrame;
12466          for (let i = 0; i < totalFrames; i++) {
12467            recordedFrames.push([(i * 1000) / fps, { synth: i, width, height }]);
12468          }
12469          send({
12470            type: "tape:load-progress",
12471            content: {
12472              code: "synth",
12473              phase: "frames",
12474              progress: 1,
12475              loadedFrames: totalFrames,
12476              totalFrames,
12477            },
12478          });
12479
12480          mediaRecorderDuration = (totalFrames * 1000) / fps;
12481
12482          // (Soundtrack was synthesized above, before the frames, so the
12483          // ruler could render its waveform.)
12484          const wavBytes = new ArrayBuffer(44 + pcm.length * 2);
12485          const dv = new DataView(wavBytes);
12486          const writeStr = (off, str) => {
12487            for (let c = 0; c < str.length; c++) dv.setUint8(off + c, str.charCodeAt(c));
12488          };
12489          writeStr(0, "RIFF");
12490          dv.setUint32(4, 36 + pcm.length * 2, true);
12491          writeStr(8, "WAVE");
12492          writeStr(12, "fmt ");
12493          dv.setUint32(16, 16, true);
12494          dv.setUint16(20, 1, true); // PCM
12495          dv.setUint16(22, 1, true); // Mono
12496          dv.setUint32(24, sampleRate, true);
12497          dv.setUint32(28, sampleRate * 2, true);
12498          dv.setUint16(32, 2, true);
12499          dv.setUint16(34, 16, true);
12500          writeStr(36, "data");
12501          dv.setUint32(40, pcm.length * 2, true);
12502          new Int16Array(wavBytes, 44).set(pcm);
12503
12504          window.tapeAudioArrayBuffer = wavBytes.slice(0);
12505          // Decode WITHOUT creating a bios AudioContext. A bare context here
12506          // would make startSound() bail on the user's first tap, so the
12507          // speaker worklet — the only thing that can actually play tape
12508          // audio — would never boot and the tape would stay silent forever.
12509          const decodeCtx =
12510            audioContext && audioContext.state !== "closed"
12511              ? audioContext
12512              : new OfflineAudioContext(1, 1, sampleRate);
12513          try {
12514            sfx["tape:audio"] = await decodeCtx.decodeAudioData(wavBytes);
12515            console.log(`🧪 Synthtape audio ready: ${sfx["tape:audio"].duration.toFixed(2)}s`);
12516          } catch (audioError) {
12517            console.error("🧪 Synthtape audio decode failed:", audioError);
12518          }
12519
12520          // Boot the audio system eagerly — the context starts suspended
12521          // (no gesture yet) but the speaker worklet loads now, so the
12522          // first tap only needs a resume instead of a full bootstrap.
12523          if (!audioContext) startSound();
12524
12525          send({
12526            type: "tape:loaded",
12527            content: {
12528              code: "synth",
12529              framesReady: recordedFrames.length,
12530              totalFrames,
12531              streaming: false,
12532            },
12533          });
12534          send({
12535            type: "tape:audio-context-state",
12536            content: {
12537              state: audioContext?.state || "suspended",
12538              hasAudio: !!sfx["tape:audio"],
12539            },
12540          });
12541
12542          console.log("🧪 Synthtape ready — presenting");
12543          setTimeout(async () => {
12544            await receivedChange({ data: { type: "recorder:present", content: {} } });
12545          }, 100);
12546        } catch (error) {
12547          console.error("🧪 Error synthesizing tape:", error);
12548          send({ type: "tape:error", content: error.message });
12549        }
12550      })();
12551
12552      return;
12553    }
12554
12555    // 📼 Play a video-backed tape (kind:"mp4", e.g. a posted camera-roll
12556    // clip). Frame/zip tapes go through tape:play above; this path skips all
12557    // the frame extraction and just drops a DOM <video> into the #underlay so
12558    // the (transparent) AC canvas overlays it — same layering the camera uses.
12559    if (type === "tape:play-mp4") {
12560      console.log("📼 Playing MP4 tape:", content);
12561      const { mp4Url } = content || {};
12562      if (!mp4Url) {
12563        send({ type: "tape:error", content: "No mp4Url for MP4 tape" });
12564        return;
12565      }
12566
12567      // Tear down any prior frame-tape playback / underlay first.
12568      stopTapePlayback?.();
12569      tapeManager?.cleanup?.();
12570      tapeManager = null;
12571      underlayFrame?.remove();
12572      underlayFrame = undefined;
12573
12574      underlayFrame = document.createElement("div");
12575      underlayFrame.id = "underlay";
12576
12577      const video = document.createElement("video");
12578      mp4PlaybackVideo = video;
12579      video.src = mp4Url;
12580      video.playsInline = true;
12581      video.setAttribute("playsinline", "");
12582      video.loop = true;
12583      // Play immediately off the streaming <video>; the scrub-audio decode
12584      // below re-downloads and decodes the whole file, which scales with
12585      // length and must never gate first playback.
12586      let tapeAudioReady = false;
12587      video.muted = false; // Falls back to muted if unmuted autoplay is refused.
12588      video.controls = false;
12589      video.preload = "auto";
12590
12591      underlayFrame.appendChild(video);
12592      document.body.insertBefore(underlayFrame, document.body.firstChild);
12593
12594      video.onloadedmetadata = () => {
12595        send({
12596          type: "tape:info-reply",
12597          content: {
12598            frameCount: 0,
12599            totalDuration: Number.isFinite(video.duration) ? video.duration : 0,
12600            hasAudio: true,
12601            kind: "mp4",
12602          },
12603        });
12604      };
12605
12606      video.ontimeupdate = () => {
12607        if (!video.duration) return;
12608        currentTapePosition = video.currentTime / video.duration;
12609        send({
12610          type: "tape:playback-progress",
12611          content: { progress: video.currentTime / video.duration },
12612        });
12613      };
12614
12615      video.onerror = () => {
12616        console.error("📼 MP4 tape failed to load:", mp4Url);
12617        send({ type: "tape:error", content: "MP4 tape failed to load" });
12618      };
12619
12620      stopTapePlayback = () => {
12621        stopTapeAudioLoops();
12622        try { video.pause(); } catch {}
12623      };
12624      pauseTapePlayback = () => {
12625        stopTapeAudioLoops();
12626        video.pause();
12627      };
12628      resumeTapePlayback = () => {
12629        const progress = video.duration > 0 ? video.currentTime / video.duration : 0;
12630        video.play().catch(() => {});
12631        if (tapeAudioReady && startPreparedTapeAudio(progress)) {
12632          video.muted = true; // Web Audio owns the soundtrack from here.
12633        }
12634      };
12635      seekTapePlayback = (
12636        progress,
12637        { scrubbing = false, scrubEnd = false, speedScrub = false } = {},
12638      ) => {
12639        if (!Number.isFinite(video.duration) || video.duration <= 0) return;
12640        const clamped = Math.max(0, Math.min(1, progress));
12641        currentTapePosition = clamped;
12642        const t = clamped * video.duration;
12643        if (
12644          (speedScrub || scrubbing) &&
12645          !scrubEnd &&
12646          typeof video.fastSeek === "function"
12647        ) {
12648          // Mid-scrub: nearest-keyframe seek — constant-time no matter how
12649          // long the tape is. The precise seek lands at scrub end.
12650          video.fastSeek(t);
12651        } else {
12652          video.currentTime = t;
12653        }
12654        if (
12655          !speedScrub &&
12656          tapeAudioReady &&
12657          !video.paused &&
12658          (scrubEnd || !scrubbing)
12659        ) {
12660          if (startPreparedTapeAudio(clamped)) video.muted = true;
12661        }
12662      };
12663
12664      // Background scrub-audio decode; hands the soundtrack to Web Audio
12665      // mid-play once it lands.
12666      prepareMp4TapeAudio(mp4Url, "posted MP4")
12667        .then((decoded) => {
12668          if (mp4PlaybackVideo !== video) return; // Superseded by a re-present.
12669          tapeAudioReady = true;
12670          send({
12671            type: "tape:audio-context-state",
12672            content: {
12673              state: audioContext?.state || "suspended",
12674              hasAudio: true,
12675              duration: decoded.duration,
12676            },
12677          });
12678          if (!video.paused) {
12679            const progress =
12680              Number.isFinite(video.duration) && video.duration > 0
12681                ? video.currentTime / video.duration
12682                : 0;
12683            if (startPreparedTapeAudio(progress)) video.muted = true;
12684          }
12685        })
12686        .catch((error) => {
12687          console.warn("📼 Posted MP4 audio decode failed; using media element audio:", error);
12688        });
12689
12690      // Autoplay (best-effort — browsers may block until a user gesture, in
12691      // which case the first tap in the video piece resumes it).
12692      video.play().catch(() => {
12693        video.muted = true; // Retry silent — motion beats silence-with-stillness.
12694        video.play().catch((err) => {
12695          console.warn("📼 MP4 autoplay blocked (will resume on tap):", err?.message || err);
12696        });
12697      });
12698
12699      send({ type: "tape:mp4-ready", content: { mp4Url } });
12700      return;
12701    }
12702
12703    // 📼 Toggle play/pause for the MP4 tape (tap gesture in the video piece).
12704    if (type === "tape:toggle-play-mp4") {
12705      const video = mp4PlaybackVideo;
12706      if (!video) return;
12707      if (video.paused) {
12708        resumeTapePlayback?.();
12709        send({ type: "tape:mp4-playing" });
12710      } else {
12711        pauseTapePlayback?.();
12712        send({ type: "tape:mp4-paused" });
12713      }
12714      return;
12715    }
12716
12717    // 📼 Get tape information (duration, frame count, etc.)
12718    if (type === "tape:get-info") {
12719      const info = {
12720        frameCount: recordedFrames.length,
12721        totalDuration: 0,
12722        hasAudio: !!recordedVideoBlob || !!window.tapeAudioArrayBuffer,
12723        kind: recordedVideoBlob ? "video" : "frames",
12724        // Source of the recording, so the video piece can tell KidLisp
12725        // $code tapes (which get the GIF/MP4/ZIP export trio) from others.
12726        pieceName: window.currentRecordingOptions?.pieceName || null,
12727        cachedCode: window.currentRecordingOptions?.cachedCode || null,
12728      };
12729      
12730      // Total duration must include the LAST frame's display time —
12731      // mediaRecorderDuration has it. The timestamp span alone runs one
12732      // frame short, which made the piece's wrap point land ~a frame
12733      // before the audio loop's and drift by that much every lap.
12734      if (mediaRecorderDuration > 0) {
12735        info.totalDuration = mediaRecorderDuration / 1000;
12736      } else if (recordedFrames.length > 1) {
12737        const firstTimestamp = recordedFrames[0][0];
12738        const lastTimestamp = recordedFrames[recordedFrames.length - 1][0];
12739        info.totalDuration = (lastTimestamp - firstTimestamp) / 1000;
12740      }
12741      
12742      // Reply back to the disk
12743      send({ type: "tape:info-reply", content: info });
12744      return;
12745    }
12746
12747    // 📼 Stop tape playback (when leaving video disk)
12748    if (type === "tape:stop") {
12749      console.log("📼 Stopping tape playback");
12750      stopTapePlayback?.();
12751      tapeManager?.cleanup();
12752      tapeManager = null;
12753      // Tear down MP4-tape playback if it was a video-backed tape.
12754      if (mp4PlaybackVideo) {
12755        try {
12756          mp4PlaybackVideo.pause();
12757          mp4PlaybackVideo.removeAttribute("src");
12758          mp4PlaybackVideo.load();
12759        } catch {}
12760        mp4PlaybackVideo = undefined;
12761        if (compactPlaybackUrl) {
12762          URL.revokeObjectURL(compactPlaybackUrl);
12763          compactPlaybackUrl = null;
12764        }
12765        underlayFrame?.remove();
12766        underlayFrame = undefined;
12767      }
12768      return;
12769    }
12770
12771    // 📼 Toggle play/pause for a tape (for tv.mjs tap gesture)
12772    if (type === "tape:toggle-play") {
12773      if (!tapeManager) return;
12774      
12775      const { tapeId } = content;
12776      const tape = tapeManager.tapes.get(tapeId);
12777      
12778      if (tape && !tape.isLoading) {
12779        if (tape.isPlaying) {
12780          tape.pause();
12781          console.log(`⏸️ Paused tape ${tapeId}`);
12782        } else {
12783          tape.play(tapeManager.audioContext);
12784          console.log(`▶️ Playing tape ${tapeId}`);
12785        }
12786      }
12787      
12788      return;
12789    }
12790
12791    // 📼 Seek tape to specific progress (for tv.mjs scrubbing)
12792    if (type === "tape:seek") {
12793      if (!tapeManager) return;
12794      
12795      const { tapeId, progress } = content;
12796      const tape = tapeManager.tapes.get(tapeId);
12797      
12798      if (tape && !tape.isLoading) {
12799        const frameIndex = Math.floor(progress * tape.frames.length);
12800        tape.seekToFrame(frameIndex, tapeManager.audioContext);
12801        console.log(`⏩ Seeked tape ${tapeId} to ${(progress * 100).toFixed(1)}% (frame ${frameIndex})`);
12802      }
12803      
12804      return;
12805    }
12806
12807    // 📼 Preload a tape into cache (for tv.mjs smart preloading)
12808    if (type === "tape:preload") {
12809      console.log("📼 Preloading tape:", content);
12810      console.log("📼 Received zipUrl:", content.zipUrl);
12811      console.log("📼 Metadata.zip:", content.metadata?.zip);
12812      
12813      (async () => {
12814        try {
12815          const { tapeId, code, zipUrl, metadata: tapeMeta } = content;
12816          
12817          // Create underlayFrame if needed
12818          if (!underlayFrame) {
12819            underlayFrame = document.createElement("div");
12820            underlayFrame.id = "underlay";
12821            const frameCan = document.createElement("canvas");
12822            frameCan.width = 1920;
12823            frameCan.height = 1080;
12824            underlayFrame.appendChild(frameCan);
12825            underlayFrame.style.cssText = "position: fixed; top: 0; left: 0; width: 100%; height: 100%; z-index: -1; pointer-events: none;";
12826            document.body.insertBefore(underlayFrame, document.body.firstChild);
12827            console.log("📼 Created underlayFrame for TapeManager");
12828          }
12829          
12830          // Ensure audioContext exists
12831          if (!audioContext) {
12832            audioContext = new (window.AudioContext || window.webkitAudioContext)();
12833            console.log("📼 Created AudioContext for TapeManager");
12834          }
12835          
12836          // Initialize tape manager if needed
12837          if (!tapeManager) {
12838            const frameCan = underlayFrame.querySelector("canvas");
12839            if (frameCan) {
12840              const maxTapes = window.innerWidth > 768 ? 12 : 6;
12841              tapeManager = new TapeManager(frameCan, audioContext, maxTapes);
12842              globalThis.tapeManager = tapeManager; // Expose to disks for progress access (globalThis works in workers)
12843              console.log(`📼 Initialized TapeManager (max ${maxTapes} tapes)`);
12844              
12845              // Start independent render loop for TapeManager
12846              function renderTapeManager() {
12847                if (tapeManager) {
12848                  tapeManager.renderFrame(performance.now(), send);
12849                  requestAnimationFrame(renderTapeManager);
12850                }
12851              }
12852              requestAnimationFrame(renderTapeManager);
12853              console.log("📼 Started TapeManager render loop");
12854            } else {
12855              console.error("📼 Cannot initialize TapeManager - no canvas found");
12856              return;
12857            }
12858          }
12859          
12860          if (!tapeManager) {
12861            console.warn("📼 Cannot preload - TapeManager not initialized");
12862            return;
12863          }
12864          
12865          const tape = tapeManager.getTape(tapeId);
12866          if (tape.frames.length > 0) {
12867            console.log(`📼 Tape ${tapeId} already loaded`);
12868            send({
12869              type: "tape:preloaded",
12870              content: { tapeId, frameCount: tape.frames.length }
12871            });
12872            
12873            // If requestFrames is true, send frames to disk
12874            if (content.requestFrames) {
12875              const previewFrames = await buildTapePreviewFrames(tape.frames);
12876              const framesToSend = previewFrames.length > 0 ? previewFrames : tape.frames.slice(0, TAPE_PREVIEW_MAX_FRAMES);
12877              console.log(`📼 Sending ${framesToSend.length}/${tape.frames.length} preview frames to disk (cached)`);
12878              send({
12879                type: "tape:frames",
12880                content: {
12881                  tapeId,
12882                  frames: framesToSend,
12883                  preview: previewFrames.length > 0,
12884                  totalFrames: tape.frames.length
12885                }
12886              });
12887            }
12888            return;
12889          }
12890          
12891          tape.isLoading = true;
12892          tape.code = code;
12893          tape.metadata = tapeMeta;
12894          
12895          // Send initial load progress
12896          send({
12897            type: "tape:load-progress",
12898            content: { code, phase: "downloading", progress: 0 }
12899          });
12900          
12901          // Fetch ZIP
12902          console.log(`📼 About to fetch ZIP for tape ${code} (${tapeId}) from:`, zipUrl);
12903          const response = await fetch(decodeURI(zipUrl));
12904          if (!response.ok) {
12905            console.error(`📼 ZIP fetch failed for ${code}: ${response.status} from URL:`, zipUrl);
12906            throw new Error(`Tape ZIP not found. Status: ${response.status}`);
12907          }
12908          
12909          const arrayBuffer = await response.arrayBuffer();
12910          console.log(`📼 Preload ZIP downloaded: ${(arrayBuffer.byteLength / 1024 / 1024).toFixed(2)} MB`);
12911          
12912          send({
12913            type: "tape:load-progress",
12914            content: { code, phase: "unpacking", progress: 0.3 }
12915          });
12916          
12917          // Parse ZIP
12918          if (!window.JSZip) await loadJSZip();
12919          const zip = await window.JSZip.loadAsync(arrayBuffer);
12920          
12921          send({
12922            type: "tape:load-progress",
12923            content: { code, phase: "frames", progress: 0.5 }
12924          });
12925          
12926          // Extract frames
12927          const frameFiles = Object.keys(zip.files)
12928            .filter(name => name.startsWith('frame-') && name.endsWith('.png'))
12929            .sort((a, b) => {
12930              const aNum = parseInt(a.match(/frame-(\d+)/)[1]);
12931              const bNum = parseInt(b.match(/frame-(\d+)/)[1]);
12932              return aNum - bNum;
12933            });
12934          
12935          let loadedFrames = 0;
12936          for (const filename of frameFiles) {
12937            const frameFile = zip.file(filename);
12938            const blob = await frameFile.async("blob");
12939            const bitmap = await createImageBitmap(blob);
12940            tape.frames.push(bitmap);
12941            loadedFrames++;
12942            
12943            // Send progress every 50 frames
12944            if (loadedFrames % 50 === 0 || loadedFrames === frameFiles.length) {
12945              send({
12946                type: "tape:load-progress",
12947                content: {
12948                  code,
12949                  phase: "frames",
12950                  progress: 0.5 + (loadedFrames / frameFiles.length) * 0.3,
12951                  loadedFrames,
12952                  totalFrames: frameFiles.length
12953                }
12954              });
12955            }
12956          }
12957          
12958          // Extract timing
12959          const timingFile = zip.file("timing.json");
12960          if (timingFile) {
12961            const timingText = await timingFile.async("text");
12962            tape.timingData = JSON.parse(timingText);
12963          }
12964          
12965          send({
12966            type: "tape:load-progress",
12967            content: { code, phase: "audio", progress: 0.9 }
12968          });
12969          
12970          // Extract audio
12971          const audioFile = zip.file("soundtrack.wav");
12972          if (audioFile && audioContext) {
12973            const audioBlob = await audioFile.async("blob");
12974            const audioArrayBuffer = await audioBlob.arrayBuffer();
12975            tape.audioBuffer = await audioContext.decodeAudioData(audioArrayBuffer);
12976            console.log(`📼 Preloaded audio: ${tape.audioBuffer.duration.toFixed(2)}s`);
12977          }
12978          
12979          tape.isLoading = false;
12980          console.log(`📼 Preloaded tape ${tapeId}: ${tape.frames.length} frames`);
12981          
12982          send({
12983            type: "tape:load-progress",
12984            content: { code, phase: "complete", progress: 1 }
12985          });
12986          
12987          send({
12988            type: "tape:preloaded",
12989            content: { tapeId, frameCount: tape.frames.length }
12990          });
12991          
12992          // If requestFrames is true, send frames to disk for preview
12993          if (content.requestFrames) {
12994            const previewFrames = await buildTapePreviewFrames(tape.frames);
12995            const framesToSend = previewFrames.length > 0 ? previewFrames : tape.frames.slice(0, TAPE_PREVIEW_MAX_FRAMES);
12996            console.log(`📼 Sending ${framesToSend.length}/${tape.frames.length} preview frames to disk`);
12997            send({
12998              type: "tape:frames",
12999              content: {
13000                tapeId,
13001                frames: framesToSend,
13002                preview: previewFrames.length > 0,
13003                totalFrames: tape.frames.length
13004              }
13005            });
13006          }
13007        } catch (error) {
13008          console.error("📼 Error preloading tape:", error);
13009          send({ type: "tape:preload-error", content: { tapeId: content.tapeId, error: error.message } });
13010        }
13011      })();
13012      
13013      return;
13014    }
13015
13016    // 📼 Request frames from a loaded tape (for disk-side preview/playback)
13017    if (type === "tape:request-frames") {
13018      const { tapeId } = content;
13019      
13020      if (!tapeManager) {
13021        console.warn("📼 Cannot send frames - TapeManager not initialized");
13022        return;
13023      }
13024      
13025      const tape = tapeManager.getTape(tapeId);
13026      if (tape && tape.frames.length > 0) {
13027        const previewFrames = await buildTapePreviewFrames(tape.frames);
13028        const framesToSend = previewFrames.length > 0 ? previewFrames : tape.frames.slice(0, TAPE_PREVIEW_MAX_FRAMES);
13029        console.log(`📼 Sending ${framesToSend.length}/${tape.frames.length} preview frames to disk for ${tapeId}`);
13030        send({
13031          type: "tape:frames",
13032          content: {
13033            tapeId,
13034            frames: framesToSend,
13035            preview: previewFrames.length > 0,
13036            totalFrames: tape.frames.length
13037          }
13038        });
13039      } else {
13040        console.warn(`📼 Cannot send frames for ${tapeId} - not loaded`);
13041      }
13042      
13043      return;
13044    }
13045
13046    // 📼 Update tape scroll offsets and alpha (for tv.mjs smooth transitions)
13047    if (type === "tape:scroll-offset") {
13048      if (!tapeManager) return;
13049      
13050      const { tapePositions } = content; // [{ id, yOffset, alpha }, ...]
13051      tapeManager.updateAudioCrossfade(tapePositions);
13052      
13053      return;
13054    }
13055
13056    // 📼 Scrub to specific progress within tape (for tv.mjs horizontal gestures)
13057    if (type === "tape:scrub") {
13058      if (!tapeManager) return;
13059      
13060      const { tapeId, progress } = content;
13061      const tape = tapeManager.tapes.get(tapeId);
13062      
13063      if (tape && !tape.isLoading) {
13064        const targetFrame = Math.floor(progress * tape.frames.length);
13065        tape.seekToFrame(targetFrame, tapeManager.audioContext);
13066        console.log(`📼 Scrubbed tape ${tapeId} to ${(progress * 100).toFixed(1)}%`);
13067      }
13068      
13069      return;
13070    }
13071
13072    // 📼 Get current playback progress (for tv.mjs UI)
13073    if (type === "tape:get-progress") {
13074      if (!tapeManager) return;
13075      
13076      const { tapeId } = content;
13077      const tape = tapeManager.tapes.get(tapeId);
13078      
13079      if (tape) {
13080        send({
13081          type: "tape:progress-reply",
13082          content: {
13083            tapeId,
13084            progress: tape.getProgress(),
13085            isPlaying: tape.isPlaying,
13086            isPaused: tape.isPaused
13087          }
13088        });
13089      }
13090      
13091      return;
13092    }
13093
13094    // 📼 Set active tape (for tv.mjs when transitioning)
13095    if (type === "tape:set-active") {
13096      if (!tapeManager) return;
13097      
13098      const { tapeId } = content;
13099      tapeManager.setActive(tapeId);
13100      console.log(`📼 Set active tape: ${tapeId}`);
13101      
13102      return;
13103    }
13104
13105    // 📼 🎵 Adjust tape audio pitch (for scrubbing)
13106    if (type === "tape:audio-shift") {
13107      // Find the currently playing tape audio
13108      const tapeAudioId = Object.keys(sfxPlaying).find(id => id.startsWith("tape:audio_"));
13109      if (tapeAudioId && sfxPlaying[tapeAudioId]) {
13110        const shift = typeof content === "number" ? content : 0;
13111        // console.log(`📼 🎵 Shifting tape audio pitch: ${shift.toFixed(3)}`);
13112        sfxPlaying[tapeAudioId].update({ shift });
13113      }
13114      return;
13115    }
13116
13117    if (type === "tape:audio-pitch") {
13118      // 🎼 Independent pitch factor for the playing tape — tempo, grid,
13119      // and the needle stay untouched (two-tap shifter in the worklet).
13120      const tapeAudioId = Object.keys(sfxPlaying).find((id) =>
13121        id.startsWith("tape:audio_"),
13122      );
13123      if (tapeAudioId && sfxPlaying[tapeAudioId]) {
13124        const f = typeof content === "number" && content > 0 ? content : 1;
13125        sfxPlaying[tapeAudioId].update({ pitch: f });
13126      }
13127      return;
13128    }
13129
13130    if (type === "tape:volume") {
13131      // 🎚️ Live volume for the playing tape loop (0..1) — the per-player
13132      // mix fader and the multi-window auto-mixer both land here.
13133      const tapeAudioId = Object.keys(sfxPlaying).find((id) =>
13134        id.startsWith("tape:audio_"),
13135      );
13136      if (tapeAudioId && sfxPlaying[tapeAudioId]) {
13137        const v =
13138          typeof content === "number" ? Math.max(0, Math.min(1, content)) : 1;
13139        sfxPlaying[tapeAudioId].update({ volume: v, duration: 0.08 });
13140      } else if (mp4PlaybackVideo) {
13141        mp4PlaybackVideo.volume =
13142          typeof content === "number" ? Math.max(0, Math.min(1, content)) : 1;
13143      }
13144      return;
13145    }
13146
13147    if (type === "clock:state") {
13148      window.AC.clockState = content;
13149      window.dispatchEvent(new CustomEvent("ac-clock-state", { detail: content }));
13150      return;
13151    }
13152
13153    if (type === "tape:telemetry") {
13154      // Stash on window so an external jam/test harness can read live
13155      // rate + net-time sync offset numerically.
13156      window.__tapeTelemetry = {
13157        ...content,
13158        // 🔇 Surface the audio context state — "suspended" is the silent
13159        // killer: visuals run, seeks shift the ruler, and no sound plays
13160        // until a trusted gesture unlocks the context.
13161        audio: audioContext?.state || "none",
13162        at: performance.now(),
13163      };
13164      return;
13165    }
13166
13167    if (type === "tape:audio-pos") {
13168      // Absolute read-head position (0..1) for the tape audio — used by
13169      // beat jumps and chop repeats to relocate the free-running loop.
13170      const tapeAudioId = Object.keys(sfxPlaying).find((id) =>
13171        id.startsWith("tape:audio_"),
13172      );
13173      if (tapeAudioId && sfxPlaying[tapeAudioId]) {
13174        const p = typeof content === "number" ? Math.max(0, Math.min(1, content)) : 0;
13175        sfxPlaying[tapeAudioId].update({ samplePosition: p });
13176      } else if (
13177        mp4PlaybackVideo &&
13178        Number.isFinite(mp4PlaybackVideo.duration) &&
13179        mp4PlaybackVideo.duration > 0
13180      ) {
13181        const p = typeof content === "number" ? Math.max(0, Math.min(1, content)) : 0;
13182        mp4PlaybackVideo.currentTime = p * mp4PlaybackVideo.duration;
13183      }
13184      return;
13185    }
13186
13187    if (type === "tape:audio-rate") {
13188      // Absolute playback rate for the tape audio — drift-proof, unlike
13189      // accumulating relative shifts against a piece-side estimate.
13190      const tapeAudioId = Object.keys(sfxPlaying).find((id) =>
13191        id.startsWith("tape:audio_"),
13192      );
13193      if (tapeAudioId && sfxPlaying[tapeAudioId]) {
13194        const rate = typeof content === "number" ? content : 1;
13195        sfxPlaying[tapeAudioId].update({ sampleSpeed: rate });
13196      } else if (mp4PlaybackVideo) {
13197        const rate = typeof content === "number" ? content : 1;
13198        if (rate > 0) {
13199          mp4PlaybackVideo.preservesPitch = false;
13200          mp4PlaybackVideo.playbackRate = Math.max(0.25, Math.min(4, rate));
13201          mp4PlaybackVideo.play().catch(() => {});
13202        } else {
13203          // HTMLMediaElement has no reverse playback. The video piece still
13204          // seeks frames explicitly while dragging backward; pause native
13205          // audio here instead of letting it run in the wrong direction.
13206          mp4PlaybackVideo.pause();
13207        }
13208      }
13209      return;
13210    }
13211
13212    // 📼 Load and parse tape ZIP from URL (for replay piece)
13213    if (type === "tape:load-zip") {
13214      console.log("📼 Loading tape ZIP from:", content);
13215      
13216      (async () => {
13217        try {
13218          // Fetch the ZIP file
13219          const response = await fetch(decodeURI(content));
13220          if (!response.ok) {
13221            throw new Error(`Tape ZIP not found. Status: ${response.status}`);
13222          }
13223          
13224          const arrayBuffer = await response.arrayBuffer();
13225          console.log(`📼 ZIP downloaded: ${(arrayBuffer.byteLength / 1024 / 1024).toFixed(2)} MB`);
13226          
13227          // Parse ZIP with JSZip
13228          if (!window.JSZip) await loadJSZip();
13229          const zip = await window.JSZip.loadAsync(arrayBuffer);
13230          
13231          console.log("📼 ZIP files:", Object.keys(zip.files));
13232          
13233          // Extract metadata.json
13234          let metadata = null;
13235          const metadataFile = zip.file("metadata.json");
13236          if (metadataFile) {
13237            const metadataText = await metadataFile.async("text");
13238            metadata = JSON.parse(metadataText);
13239          }
13240          
13241          // Extract timing.json
13242          let timing = null;
13243          const timingFile = zip.file("timing.json");
13244          if (timingFile) {
13245            const timingText = await timingFile.async("text");
13246            timing = JSON.parse(timingText);
13247          }
13248          
13249          // Extract frame blobs
13250          const frameFiles = Object.keys(zip.files)
13251            .filter(name => name.startsWith('frame-') && name.endsWith('.png'))
13252            .sort((a, b) => {
13253              const aNum = parseInt(a.match(/frame-(\d+)/)[1]);
13254              const bNum = parseInt(b.match(/frame-(\d+)/)[1]);
13255              return aNum - bNum;
13256            });
13257          
13258          console.log(`📼 Extracting ${frameFiles.length} frames...`);
13259          const frameBlobs = [];
13260          for (const filename of frameFiles) {
13261            const frameFile = zip.file(filename);
13262            const blob = await frameFile.async("blob");
13263            frameBlobs.push(blob);
13264          }
13265          
13266          // Send parsed data back to worker
13267          send({
13268            type: "tape:zip-parsed",
13269            content: { metadata, timing, frameBlobs },
13270          });
13271          
13272          console.log("📼 Tape ZIP parsed and sent to worker");
13273        } catch (error) {
13274          console.error("📼 Error loading/parsing tape ZIP:", error);
13275          send({ type: "tape:zip-error", content: error.message });
13276        }
13277      })();
13278      
13279      return;
13280    }
13281
13282    // Capture device motion.
13283    if (type === "motion:start") {
13284      startCapturingMotion();
13285      return;
13286    }
13287
13288    if (type === "motion:stop") {
13289      stopCapturingMotion();
13290      return;
13291    }
13292
13293    // Speech synthesis. (local and remote)
13294    if (type === "speak") {
13295      speak(content.utterance, content.voice, content.mode, content.opts);
13296      return;
13297    }
13298
13299    // Show a classic DOM / window style alert box.
13300    if (type === "alert") {
13301      window.alert(content);
13302      return;
13303    }
13304
13305    // Add a DOM event hitbox for the `Button Hitboxes`
13306    // event listener on the document.
13307    // 📓 Re-adding the same label updates the hitbox in place. This matters
13308    //    for pieces whose layout reflows on resize — the hitbox needs to
13309    //    track the latest button box. Pieces that repaint every frame
13310    //    (laklok's marquee, blinking cursors) re-publish their buttons every
13311    //    frame, replacing this handler mid-tap — so the down/up state is
13312    //    carried over from the handler being replaced, otherwise pointerup
13313    //    never sees the pointerdown and the button can't fire.
13314    if (type === "button:hitbox:add") {
13315      // Event handler for each button press.
13316      const handler = async (e) => {
13317        const frame = canvas.getBoundingClientRect();
13318        const xscale = projectedWidth / canvas.width;
13319        const yscale = projectedHeight / canvas.height;
13320        const hitbox = Box.from({
13321          x: frame.left + content.box.x * xscale,
13322          y: frame.top + content.box.y * yscale,
13323          w: content.box.w * xscale,
13324          h: content.box.h * yscale,
13325        });
13326
13327        // 📓 Uncomment to debug the hitboxes and see how they line up.
13328        // const dbg = Box.from({
13329        //   x: content.box.x * xscale,
13330        //   y: content.box.y * yscale,
13331        //   w: content.box.w * xscale,
13332        //   h: content.box.h * yscale,
13333        // });
13334        // debugCtx.fillStyle = "red";
13335        // debugCtx.globalAlpha = 0.5;
13336        // debugCtx.fillRect(dbg.x, dbg.y, dbg.w, dbg.h);
13337
13338        const hit = hitbox.contains({ x: e.x, y: e.y });
13339
13340        if (e.type === "pointerup" && handler.state === "down" && hit) {
13341          // This is pretty specific to the "copy" clipboard
13342          // stuff for now. 23.06.16.15.03
13343          // console.log("🔘 Button tap label:", content.label);
13344
13345          // 🧑‍💻 Generic "open a URL" action. Because this fires inside
13346          // the native DOM pointerup listener, the user gesture is still
13347          // active — iOS Safari treats `window.open` as a real user
13348          // navigation and honors Content-Disposition for downloads.
13349          // Pieces that want tap-to-download use this instead of
13350          // `send({ type: "download-url" })`, which happens post-
13351          // postMessage and loses the gesture on iPhone.
13352          if (content.action === "open-url" && content.url) {
13353            try {
13354              window.open(content.url, "_blank");
13355              if (content.notify) {
13356                send({ type: "hitbox:opened", content: { label: content.label, url: content.url } });
13357              }
13358            } catch (err) {
13359              console.warn("🔘 open-url hitbox failed:", err);
13360            }
13361          }
13362
13363          // Handle QR corner tap (toggle to fullscreen)
13364          if (content.label === "qr-corner") {
13365            console.log("🔘 QR corner tapped - toggling to fullscreen");
13366            send({ type: "qr:toggle-fullscreen" });
13367          }
13368
13369          // Handle QR fullscreen tap (toggle back to corner)
13370          if (content.label === "qr-fullscreen") {
13371            console.log("🔘 QR fullscreen tapped - toggling to corner");
13372            send({ type: "qr:toggle-fullscreen" });
13373          }
13374
13375          // 📦 Update-ready badge tap → full reload (new deployment available).
13376          if (content.label === "update-corner") {
13377            console.log("🔘 Update badge tapped — reloading");
13378            window.location.reload();
13379          }
13380
13381          // 📦 Auto-reload checkbox. It toggles the flag we hold rather than
13382          // re-reading storage, so one tap always flips what the box shows.
13383          if (content.label === "update-auto-corner") {
13384            const enabled = !updateAutoReload;
13385            setUpdateAutoReload(enabled);
13386            send({ type: "update:auto-state", content: enabled });
13387            if (enabled) {
13388              armUpdateReload(2500);
13389            } else if (window.acUpdateReloadTimer) {
13390              clearTimeout(window.acUpdateReloadTimer);
13391              window.acUpdateReloadTimer = null;
13392            }
13393          }
13394
13395          if (content.label === "copy") {
13396            try {
13397              await navigator.clipboard.writeText(content.message);
13398              send({ type: "copy:copied" });
13399            } catch (err) {
13400              console.warn("📋 Clipboard copy failed:", err);
13401              if (window.parent) {
13402                console.log("📋 Trying via message...");
13403                window.parent.postMessage(
13404                  { type: "clipboard:copy", value: content.message },
13405                  "*",
13406                );
13407              } else {
13408                send({ type: "copy:failed" });
13409              }
13410            }
13411          }
13412
13413          // Paste should always happen on a pointerdown.
13414          if (content.label === "paste") {
13415            try {
13416              let pastedText = await navigator.clipboard.readText();
13417              // This routes through to the `pasted:text` event in `disk`.
13418              // where `pastedText` is sent on the next frame.
13419              if (pastedText.length === 0) {
13420                send({ type: "paste:pasted:empty" });
13421              } else {
13422                // Insert pasted text at current caret position.
13423                if (keyboard) {
13424                  const start = keyboard.input.selectionStart;
13425                  const end = keyboard.input.selectionEnd;
13426                  const selLen = end - start;
13427
13428                  const beforeCursor = keyboard.input.value.substring(0, start);
13429                  const afterCursor = keyboard.input.value.substring(
13430                    selLen > 0 ? end : start,
13431                  );
13432
13433                  // Enforce character limit for prompt input
13434                  const currentLen = beforeCursor.length + afterCursor.length;
13435                  const availableSpace = Math.max(0, keyboardMaxChars - currentLen);
13436                  if (pastedText.length > availableSpace) {
13437                    pastedText = pastedText.slice(0, availableSpace);
13438                  }
13439
13440                  keyboard.input.value =
13441                    beforeCursor + pastedText + afterCursor;
13442
13443                  // Also enforce limit on final result
13444                  if (keyboard.input.value.length > keyboardMaxChars) {
13445                    keyboard.input.value = keyboard.input.value.slice(0, keyboardMaxChars);
13446                  }
13447
13448                  const newCursorPosition = start + pastedText.length;
13449                  keyboard.input.setSelectionRange(
13450                    newCursorPosition,
13451                    newCursorPosition,
13452                  );
13453
13454                  send({
13455                    type: "prompt:text:replace",
13456                    content: {
13457                      text: keyboard.input.value,
13458                      cursor: keyboard.input.selectionStart,
13459                    },
13460                  });
13461
13462                  if (document.activeElement !== keyboard.input) {
13463                    keyboard.input.focus();
13464                  }
13465                }
13466              }
13467              // send({
13468              //   type:
13469              //     pastedText.length > 0 ? "paste:pasted" : "paste:pasted:empty",
13470              // });
13471            } catch (err) {
13472              console.warn(err);
13473              send({ type: "paste:failed" });
13474            }
13475          }
13476
13477          handler.state = "up";
13478        } else if (e.type === "pointerdown" && hit) {
13479          handler.state = "down";
13480        } else if (e.type === "pointerup" && !hit) {
13481          handler.state = "up";
13482        }
13483      };
13484      handler.contains = (e) => {
13485        const frame = canvas.getBoundingClientRect();
13486        const xscale = projectedWidth / canvas.width;
13487        const yscale = projectedHeight / canvas.height;
13488        return Box.from({
13489          x: frame.left + content.box.x * xscale,
13490          y: frame.top + content.box.y * yscale,
13491          w: content.box.w * xscale,
13492          h: content.box.h * yscale,
13493        }).contains({ x: e.x, y: e.y });
13494      };
13495      handler.state = hitboxes[content.label]?.state || "up";
13496      hitboxes[content.label] = handler;
13497
13498      return;
13499    }
13500
13501    // Remove a hitbox via its label.
13502    if (type === "button:hitbox:remove") {
13503      delete hitboxes[content];
13504      return;
13505    }
13506
13507    // Direct copy text to clipboard (re-enabled for chat modal and other uses)
13508    // Mobile Safari requires execCommand fallback as clipboard API often fails
13509    if (type === "copy") {
13510      let success = false;
13511      
13512      // Try modern clipboard API first
13513      if (navigator.clipboard?.writeText) {
13514        try {
13515          await navigator.clipboard.writeText(content);
13516          success = true;
13517        } catch (err) {
13518          console.warn("📋 Clipboard API failed, trying fallback:", err);
13519        }
13520      }
13521      
13522      // Fallback: Use execCommand with a temporary textarea (works on mobile Safari)
13523      if (!success) {
13524        try {
13525          const textarea = document.createElement("textarea");
13526          textarea.value = content;
13527          textarea.style.position = "fixed";
13528          textarea.style.left = "-9999px";
13529          textarea.style.top = "-9999px";
13530          textarea.style.opacity = "0";
13531          textarea.setAttribute("readonly", ""); // Prevent keyboard popup on mobile
13532          document.body.appendChild(textarea);
13533          
13534          // iOS Safari specific: need to select with setSelectionRange
13535          textarea.focus();
13536          textarea.setSelectionRange(0, textarea.value.length);
13537          
13538          success = document.execCommand("copy");
13539          document.body.removeChild(textarea);
13540          
13541          if (!success) {
13542            console.warn("📋 execCommand copy failed");
13543          }
13544        } catch (err) {
13545          console.warn("📋 Fallback copy failed:", err);
13546        }
13547      }
13548      
13549      if (success) {
13550        send({ type: "copy:copied" });
13551      } else if (window.parent && window.self !== window.top) {
13552        // Try via parent message for embedded contexts
13553        window.parent.postMessage(
13554          { type: "clipboard:copy", value: content },
13555          "*",
13556        );
13557      } else {
13558        send({ type: "copy:failed" });
13559      }
13560      return;
13561    }
13562
13563    // 🎯 Set cursor style from piece
13564    if (type === "cursor:set") {
13565      const code = content?.style || "auto";
13566      // 🐭 Translate AC cursor CODES → valid CSS. "native" / "precise" / "tiny"
13567      // / "dot" aren't CSS cursor keywords, so writing them verbatim is a silent
13568      // no-op — the browser rejects the value and leaves whatever inline cursor
13569      // was there stuck. That's the "no cursor in the prompt" bug: an earlier
13570      // piece sets a valid `cursor: none`, then the prompt asks for "native",
13571      // the invalid write is dropped, and the stale "none" keeps the OS cursor
13572      // hidden. pen.render() owns the on-canvas reticles + the native-cursor
13573      // class; here we only drive the OS cursor. Valid CSS values (none,
13574      // crosshair, …) pass straight through.
13575      const CURSOR_CSS = {
13576        native: "auto",
13577        precise: "url('/aesthetic.computer/cursors/precise.svg') 12 12, auto",
13578        active: "url('/aesthetic.computer/cursors/active.svg') 12 12, pointer",
13579        viewpoint: "url('/aesthetic.computer/cursors/viewpoint.svg') 12 12, auto",
13580        tiny: "none",
13581        dot: "none",
13582      };
13583      if (code === "native") document.body.classList.add("native-cursor");
13584      else document.body.classList.remove("native-cursor");
13585      const cursorCss = code in CURSOR_CSS ? CURSOR_CSS[code] : code;
13586      window.acPieceCursorCss = cursorCss;
13587      document.body.style.cursor = cursorCss;
13588      return;
13589    }
13590
13591    // 🐚 Relay worker → hosting shell (prompt.ac): DOM notices + the prompt's
13592    // mirrored reply text, since the shell covers the pixel buffer.
13593    if (type === "notice:shell") {
13594      if (window.parent !== window) {
13595        window.parent.postMessage(
13596          { type: "ac:notice", text: content?.text, color: content?.color },
13597          "*",
13598        );
13599      }
13600      return;
13601    }
13602
13603    // The raster's corner label, as text: the shell paints it in DOM so a
13604    // kidlisp piece shows its colored source there too, not just its slug.
13605    if (type === "hud:label:shell") {
13606      if (window.parent !== window) {
13607        window.parent.postMessage(
13608          {
13609            type: "ac:label",
13610            text: content?.text || "",
13611            plain: content?.plain || "",
13612            color: content?.color || null,
13613          },
13614          "*",
13615        );
13616      }
13617      return;
13618    }
13619
13620    if (type === "prompt:text:shell") {
13621      if (window.parent !== window) {
13622        window.parent.postMessage(
13623          {
13624            type: "ac:prompt-text",
13625            text: content?.text || "",
13626            thinking: !!content?.thinking,
13627          },
13628          "*",
13629        );
13630      }
13631      return;
13632    }
13633
13634    if (type === "onboarding:open") {
13635      if (window.acSignup && window.auth0Client && !window.acTOKEN) {
13636        window.acSignup.resume(window.auth0Client, window.acUSER, true);
13637      } else {
13638        send({ type: "jump", content: { piece: "prompt~handle " } });
13639      }
13640      return;
13641    }
13642    if (type === "onboarding:complete") {
13643      if (window.acSignup && !window.acTOKEN) window.acSignup.complete();
13644      else send({ type: "jump", content: { piece: "chat" } });
13645      return;
13646    }
13647
13648    // Authenticate / signup or login a user.
13649    if (type === "login") {
13650      if (window.self !== window.top) {
13651        window.parent.postMessage({ type: "login" }, "*");
13652      } else {
13653        window.acLOGIN?.();
13654      }
13655      return;
13656    }
13657
13658    if (type === "signup") {
13659      if (window.self === window.top) {
13660        window.acLOGIN?.("signup");
13661      } else {
13662        console.log("🟠 Cannot sign up in an embedded view.");
13663      }
13664      return;
13665    }
13666
13667    if (type === "logout") {
13668      accountActivity.stop();
13669      if (window.acTOKEN) {
13670        if (window.parent) {
13671          window.parent.postMessage({ type: "logout" }, "*");
13672          window.safeLocalStorageRemove("session-aesthetic");
13673        }
13674        // Just use the logout services of the host.
13675      } else {
13676        window.acLOGOUT?.();
13677        window.flutter_inappwebview?.callHandler("closeWebview"); // Close A.C. webview on logout inside of Autonomy wallet.
13678      }
13679      return;
13680    }
13681
13682    // Send a locally opened file across the thread.
13683    // `content` may carry { mode, accept } to pick a video instead of an image.
13684    if (type === "file-open:request") {
13685      try {
13686        const file = await openFile(content || {});
13687        send({
13688          type: "file-open:response",
13689          content: { data: file, result: file ? "success" : "error" },
13690        });
13691      } catch (err) {
13692        send({
13693          type: "file-open:response",
13694          content: { data: null, result: "error", error: String(err) },
13695        });
13696      }
13697      return;
13698    }
13699
13700    // Send a locally opened file across the thread.
13701    if (type === "file-encode:request") {
13702      let file;
13703      if (content.type === "png")
13704        file = await bufferToBlob(content.file, "image/png", content.modifiers);
13705      send({
13706        type: "file-encode:response",
13707        content: { data: file, result: file ? "success" : "error" },
13708      });
13709      return;
13710    }
13711
13712    // Send a user authorization token (or undefined) across the thread.
13713    if (type === "authorization:request") {
13714      // console.log("Getting token...");
13715      const token = await authorize();
13716      // console.log("Failure token:", token);
13717      send({
13718        type: "authorization:response",
13719        content: { data: token || null, result: token ? "success" : "error" },
13720      });
13721      return;
13722    }
13723
13724    // *** Route to different functions if this change is not a full frame update.
13725    if (type === "load-failure" && MetaBrowser) {
13726      document.querySelector("#software-keyboard-input")?.blur();
13727      return;
13728    }
13729
13730    // if (type === "alert-popup:instagram" && Instagram) {
13731    //   window.alert(content);
13732    //   return;
13733    // }
13734
13735    // Connect to an ethereum wallet extension.
13736    if (type === "web3-connect") {
13737      if (window.ethereum) {
13738        const addresses = await (typeof window.ethereum.request === "function"
13739          ? window.ethereum.request({ method: "eth_requestAccounts" })
13740          : window.ethereum.enable());
13741
13742        const address = addresses[0];
13743        await loadWeb3(); // Load the web3.js library.
13744        // const w3 = new Web3(window.ethereum);
13745
13746        // From: https://github.com/web3/web3.js/issues/2683#issuecomment-1304496119
13747        async function ensReverse(address) {
13748          const web3 = new Web3("https://eth.public-rpc.com/");
13749          const namehash = await web3.eth.call({
13750            to: "0x084b1c3c81545d370f3634392de611caabff8148", // ENS: Reverse Registrar
13751            data: web3.eth.abi.encodeFunctionCall(
13752              {
13753                name: "node",
13754                type: "function",
13755                inputs: [{ type: "address", name: "addr" }],
13756              },
13757              [address],
13758            ),
13759          });
13760          return web3.eth.abi.decodeParameter(
13761            "string",
13762            await web3.eth.call({
13763              to: "0xa2c122be93b0074270ebee7f6b7292c7deb45047", // ENS: Default Reverse Resolver
13764              data: web3.eth.abi.encodeFunctionCall(
13765                {
13766                  name: "name",
13767                  type: "function",
13768                  inputs: [{ type: "bytes32", name: "hash" }],
13769                },
13770                [namehash],
13771              ),
13772            }),
13773          );
13774        }
13775
13776        const ensName = await ensReverse(address);
13777        const id = ensName || address;
13778        if (debug) console.log("🕸️3️⃣ Connected to:", id);
13779        send({
13780          type: "web3-connect-response",
13781          content: { result: "success", id },
13782        });
13783      } else {
13784        send({ type: "web3-connect-response", content: { result: "error" } });
13785        console.warn(
13786          "🔴 Web3 is unavailable. Please install an Ethereum wallet or enable your extension.",
13787        );
13788      }
13789      return;
13790    }
13791
13792    // Connect to a Tezos wallet (Beacon SDK)
13793    if (type === "tezos-connect") {
13794      try {
13795        const network = content?.network || "ghostnet";
13796        const address = await connectTezosWallet(network);
13797        send({
13798          type: "tezos-connect-response",
13799          content: { result: "success", address },
13800        });
13801      } catch (err) {
13802        console.error("🔴 Tezos connect error:", err);
13803        send({
13804          type: "tezos-connect-response",
13805          content: { result: "error", error: err.message },
13806        });
13807      }
13808      return;
13809    }
13810
13811    // Disconnect Tezos wallet
13812    if (type === "tezos-disconnect") {
13813      try {
13814        await disconnectTezosWallet();
13815        send({
13816          type: "tezos-disconnect-response",
13817          content: { result: "success" },
13818        });
13819      } catch (err) {
13820        send({
13821          type: "tezos-disconnect-response",
13822          content: { result: "error", error: err.message },
13823        });
13824      }
13825      return;
13826    }
13827
13828    // Get current Tezos wallet address
13829    if (type === "tezos-address") {
13830      try {
13831        const address = await getTezosAddress();
13832        send({
13833          type: "tezos-address-response",
13834          content: { result: "success", address },
13835        });
13836      } catch (err) {
13837        send({
13838          type: "tezos-address-response",
13839          content: { result: "error", error: err.message },
13840        });
13841      }
13842      return;
13843    }
13844
13845    // Sign a message with connected Tezos wallet
13846    if (type === "tezos-sign") {
13847      try {
13848        const { message } = content;
13849        const signature = await signTezosMessage(message);
13850        send({
13851          type: "tezos-sign-response",
13852          content: { result: "success", signature, message },
13853        });
13854      } catch (err) {
13855        console.error("🔴 Tezos sign error:", err);
13856        send({
13857          type: "tezos-sign-response",
13858          content: { result: "error", error: err.message },
13859        });
13860      }
13861      return;
13862    }
13863
13864    // Call a Tezos contract method
13865    if (type === "tezos-call") {
13866      try {
13867        const { contractAddress, method, args, amount } = content;
13868        const opHash = await callTezosContract(contractAddress, method, args, amount || 0);
13869        send({
13870          type: "tezos-call-response",
13871          content: { result: "success", opHash },
13872        });
13873      } catch (err) {
13874        console.error("🔴 Tezos call error:", err);
13875        send({
13876          type: "tezos-call-response",
13877          content: { result: "error", error: err.message },
13878        });
13879      }
13880      return;
13881    }
13882
13883    // 🔷 Wallet API messages (for wallet.mjs piece)
13884    // Get current wallet state (used when pieces load)
13885    if (type === "wallet:get-state") {
13886      broadcastWalletState();
13887      return;
13888    }
13889    
13890    if (type === "wallet:connect") {
13891      try {
13892        const network = content?.network || "ghostnet";
13893        const address = await connectTezosWallet(network);
13894        // connectTezosWallet already updates walletState and broadcasts
13895      } catch (err) {
13896        console.error("🔴 Wallet connect error:", err);
13897        walletState.connected = false;
13898        walletState.error = err.message;
13899        broadcastWalletState();
13900      }
13901      return;
13902    }
13903
13904    if (type === "wallet:disconnect") {
13905      try {
13906        await disconnectTezosWallet();
13907        // disconnectTezosWallet already updates walletState and broadcasts
13908      } catch (err) {
13909        console.error("🔴 Wallet disconnect error:", err);
13910      }
13911      return;
13912    }
13913
13914    if (type === "wallet:refresh-balance") {
13915      try {
13916        if (walletState.address && walletState.connected) {
13917          const balance = await fetchTezosBalanceForBios(walletState.address, walletState.network);
13918          walletState.balance = balance;
13919          broadcastWalletState();
13920        }
13921      } catch (err) {
13922        console.error("🔴 Wallet balance refresh error:", err);
13923      }
13924      return;
13925    }
13926
13927    // Sign a message with the connected wallet
13928    if (type === "wallet:sign") {
13929      try {
13930        const message = content?.message || "Hello from Aesthetic Computer!";
13931        const signature = await signTezosMessage(message);
13932        send({
13933          type: "wallet:sign-response",
13934          content: { result: "success", signature, message },
13935        });
13936      } catch (err) {
13937        console.error("🔴 Wallet sign error:", err);
13938        send({
13939          type: "wallet:sign-response",
13940          content: { result: "error", error: err.message },
13941        });
13942      }
13943      return;
13944    }
13945
13946    if (type === "url-freeze") {
13947      const freeze = !!content?.freeze;
13948      if (freeze) {
13949        frozenUrlPath = content?.path || window.location.pathname + window.location.search + window.location.hash;
13950        try {
13951          if (!checkPackMode()) {
13952            performHistoryRewrite(frozenUrlPath, false);
13953          }
13954        } catch (e) {
13955          /* Ignore in restricted context */
13956        }
13957      } else {
13958        frozenUrlPath = null;
13959      }
13960      return;
13961    }
13962
13963    if (type === "rewrite-url-path") {
13964      const newPath = content.path;
13965      const historical = !!content.historical;
13966
13967      if (typeof document !== "undefined" && !document.hasFocus()) {
13968        pendingUrlRewrite = { path: newPath, historical };
13969        if (typeof window !== "undefined" && !rewriteFocusListenerAttached) {
13970          rewriteFocusListenerAttached = true;
13971          window.addEventListener(
13972            "focus",
13973            () => {
13974              rewriteFocusListenerAttached = false;
13975              const pending = pendingUrlRewrite;
13976              pendingUrlRewrite = null;
13977              if (pending) {
13978                performHistoryRewrite(pending.path, pending.historical);
13979              }
13980            },
13981            { once: true },
13982          );
13983        }
13984      } else {
13985        performHistoryRewrite(newPath, historical);
13986      }
13987
13988      // Notify the VSCode extension (or any parent frame) of the slug change.
13989      const slug = newPath.startsWith("/") ? newPath.slice(1) : newPath;
13990      window.parent?.postMessage({ type: "url:updated", slug }, "*");
13991
13992      return;
13993    }
13994
13995    if (type === "bgm-change") {
13996      playBackgroundMusic(content.trackNumber, content.volume || 1);
13997      return;
13998    }
13999
14000    if (type === "bgm-stop") {
14001      stopBackgroundMusic();
14002      return;
14003    }
14004
14005    if (type === "disk-defaults-loaded") {
14006      // Pen (also handles touch & pointer events)
14007      pen = new Pen((x, y) => {
14008        const p = {
14009          x: floor(((x - canvasRect.x) / projectedWidth) * screen.width),
14010          y: floor(((y - canvasRect.y) / projectedHeight) * screen.height),
14011        };
14012        return p;
14013      });
14014
14015      // ⌨️ Keyboard
14016      keyboard = new Keyboard();
14017      {
14018        // console.log("⌨️ 🤖 Initializing Virtual Keyboard");
14019        /**
14020         * Insert a hidden input element that is used to toggle the software
14021         * keyboard on touchscreen devices like iPhones and iPads.
14022         * *Only works in "disks/prompt".
14023         */
14024        let keyboardOpen = false;
14025        let keyboardOpenMethod;
14026        const input = document.createElement("textarea");
14027        const form = document.createElement("form");
14028        form.id = "software-keyboard-input-form";
14029        form.style.opacity = 0;
14030        input.style.position = "absolute";
14031        input.id = "software-keyboard-input";
14032        input.autocapitalize = "none";
14033        // input.autofocus = true;
14034        // input.type = "text";
14035        input.autocomplete = "off";
14036        input.style.opacity = 0;
14037        input.style.width = 0 + "px";
14038        input.style.height = 0 + "px";
14039        // input.setAttribute("readonly", true);
14040        // input.enterkeyhint = "go"; // Why doesn't this work?
14041
14042        // 📓 Uncomment to debug text editing form synchronization.
14043        // form.style.opacity = 1;
14044        // input.style.zIndex = 100;
14045        // input.style.top = "50px";
14046        // input.style.left = "100px";
14047        // input.style.opacity = 1;
14048        // input.style.width = 200 + "px";
14049        // input.style.height = 50 + "px";
14050
14051        form.append(input);
14052        wrapper.append(form);
14053
14054        keyboard.focusHandler = function (e) {
14055          if (!currentPieceHasKeyboard) return;
14056          if (keyboardFocusLock || keyboardSoftLock) return;
14057          if (
14058            document.activeElement !== input &&
14059            e.key !== "`" &&
14060            e.key !== "Escape"
14061          ) {
14062            keyboardOpenMethod = "keyboard";
14063            input.focus();
14064
14065            if (e.key.length !== 1 || e.ctrl) {
14066              send({
14067                type: "prompt:text:replace",
14068                content: { text: "", cursor: 0, mute: true },
14069              });
14070            }
14071
14072            return true;
14073          } else if (e.key === "Enter" && e.shiftKey === false) {
14074            // input.blur(); // Deprecated 23.07.29.17.44
14075            return false;
14076          }
14077        };
14078
14079        keyboard.input = input;
14080
14081        // Generate an "Enter" keyboard event if the form was submitted.
14082        // - Don't use the submit event if we are sandboxed though!
14083        // - Which is required for the Meta Browser keyboard 23.02.08.12.30
14084
14085        const enterEvent = {
14086          name: "keyboard:down:enter",
14087          key: "Enter",
14088          repeat: false,
14089          shift: false,
14090          alt: false,
14091          ctrl: false,
14092        };
14093
14094        form.addEventListener("submit", (e) => {
14095          e.preventDefault();
14096        });
14097
14098        form.addEventListener("keydown", (e) => {
14099          if (e.key === "Enter") {
14100            e.preventDefault();
14101
14102            const enter = { ...enterEvent };
14103            enter.shift = e.shiftKey;
14104            enter.alt = e.altKey;
14105            enter.ctrl = e.ctrlKey;
14106            keyboard.events.push(enter);
14107
14108            if (
14109              (input.value === "dl" || input.value === "download") &&
14110              shareFile
14111            ) {
14112              const share = () => {
14113                navigator
14114                  .share({
14115                    files: [shareFile],
14116                    title: "Share Painting",
14117                    // text: "Share your painting!",
14118                  })
14119                  .then(() => {
14120                    console.log("📥 Share was successful.");
14121                    shareFile = null;
14122                  })
14123                  .catch((error) => {
14124                    console.log("📥 Sharing failed:", error);
14125                    shareFile = null;
14126                  });
14127                shareFileCallback = null;
14128              };
14129
14130              if (shareFile) {
14131                share();
14132              } else {
14133                shareFileCallback = share;
14134              }
14135            }
14136          } else if (e.key === "Home") {
14137            e.preventDefault();
14138            const home = { name: "keyboard:down:home", key: "Home" };
14139            home.shift = e.shiftKey;
14140            home.alt = e.altKey;
14141            home.ctrl = e.ctrlKey;
14142            keyboard.events.push(home);
14143          } else if (e.key === "Tab") {
14144            e.preventDefault();
14145            const tab = { name: "keyboard:down:tab", key: "Tab" };
14146            tab.shift = e.shiftKey;
14147            tab.alt = e.altKey;
14148            tab.ctrl = e.ctrlKey;
14149            keyboard.events.push(tab);
14150          } /*else if (
14151            // Don't send the backtick unless we are on the prompt.
14152            e.key === "`" &&
14153            currentPiece.split("/").pop() !== "prompt"
14154          ) {
14155            e.preventDefault();
14156            keyboard.events.push({ name: "keyboard:down:`", key: "`" });
14157          }*/
14158        });
14159
14160        input.addEventListener("beforeinput", (e) => {
14161          // console.log("Input Type:", e.inputType, input, e);
14162
14163          const pressedKeys = [];
14164
14165          if (e.inputType === "deleteContentBackward") {
14166            // console.log(e.inputType, e.target.value);
14167            // alert(e.inputType);
14168            // pressedKeys.push("Backspace");
14169          } else if (
14170            ["insertText", "insertCompositionText"].includes(e.inputType)
14171          ) {
14172            // Sanitize input if it arrives in chunks... like if it was dictated.
14173            // This is still basic, and is usable in the Meta Quest Browser. 22.10.24.17.07
14174            // let sanitizedInput = input;
14175            // if (input.length > 1) {
14176            //   sanitizedInput = input
14177            //     .trim()
14178            //     .toLowerCase()
14179            //     .replace(",", "")
14180            //     .replace(".", "");
14181            //   console.log("👄 Spoken / pasted input:", sanitizedInput);
14182            // }
14183            // [...sanitizedInput].forEach((chr) => pressedKeys.push(chr));
14184          }
14185
14186          pressedKeys.forEach((pk) => {
14187            keyboard.events.push({
14188              name: "keyboard:down:" + pk.toLowerCase(),
14189              key: pk,
14190              repeat: false,
14191              shift: false,
14192              alt: false,
14193              ctrl: false,
14194            });
14195          });
14196        });
14197
14198        function handleInput(e) {
14199          input.removeEventListener("input", handleInput);
14200
14201          let text = e.target.value;
14202
14203          // Replace curly single and double quotes with straight quotes
14204          text = text
14205            .replace(/[\u2018\u2019]/g, "'")
14206            .replace(/[\u201C\u201D]/g, '"');
14207
14208          // Enforce character limit for prompt input
14209          if (text.length > keyboardMaxChars) {
14210            text = text.slice(0, keyboardMaxChars);
14211          }
14212
14213          e.target.value = text;
14214
14215          send({
14216            type: "prompt:text:replace",
14217            content: {
14218              text: text,
14219              cursor: Math.min(input.selectionStart, keyboardMaxChars),
14220            },
14221          });
14222
14223          input.addEventListener("input", handleInput);
14224        }
14225
14226        input.addEventListener("input", handleInput);
14227
14228        input.addEventListener("keydown", (e) => {
14229          if (keyboardFocusLock) {
14230            e.preventDefault();
14231            return;
14232          }
14233
14234          if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
14235            let cursor =
14236              input.selectionDirection === "backward"
14237                ? input.selectionStart
14238                : input.selectionEnd;
14239
14240            const selectionLength = input.selectionEnd - input.selectionStart;
14241
14242            if (!e.shiftKey && selectionLength > 1) {
14243              cursor =
14244                e.key === "ArrowLeft"
14245                  ? max(0, input.selectionStart)
14246                  : input.selectionEnd;
14247            } else {
14248              cursor += e.key === "ArrowLeft" ? -1 : 1;
14249              cursor = max(0, min(input.value.length, cursor));
14250            }
14251
14252            let start, end;
14253            if (e.shiftKey) {
14254              if (e.key === "ArrowLeft") {
14255                if (input.selectionDirection === "backward") {
14256                  start = cursor;
14257                  end = input.selectionEnd;
14258                } else {
14259                  start = input.selectionStart;
14260                  end =
14261                    cursor > input.selectionStart ? cursor : input.selectionEnd;
14262                }
14263              } else {
14264                if (input.selectionDirection === "forward") {
14265                  start = input.selectionStart;
14266                  end = cursor;
14267                } else {
14268                  end =
14269                    cursor < input.selectionEnd ? cursor : input.selectionStart;
14270                  start = cursor;
14271                }
14272              }
14273            }
14274
14275            send({
14276              type: "prompt:text:cursor",
14277              content: {
14278                cursor,
14279                start,
14280                end,
14281              },
14282            });
14283          }
14284        });
14285
14286        window.addEventListener("blur", (e) => {
14287          input.blur();
14288        });
14289
14290        window.addEventListener("pointerdown", (e) => {
14291          if (currentPieceHasKeyboard) {
14292            e.preventDefault();
14293          }
14294          if (!document.hasFocus()) window.focus();
14295        });
14296
14297        window.addEventListener("pointerup", (e) => {
14298          //if (keyboard.needsImmediateOpen) {
14299          //  keyboard.needsImmediateOpen = false;
14300          //  return;
14301          //}
14302          // console.log("🌬️ Blurrred", "Target:", e.target);
14303
14304          // return;
14305
14306          if (e.target === window) {
14307            // console.log("WINDOW BLURRED");
14308            e.preventDefault();
14309            return; // This prevents.
14310          }
14311
14312          if (currentPieceHasKeyboard) e.preventDefault();
14313
14314          if (
14315            currentPieceHasKeyboard &&
14316            !keyboardFocusLock &&
14317            !keyboardSoftLock
14318          ) {
14319            if (keyboardOpen) {
14320              // console.log("Target:", e.target);
14321              if (MetaBrowser && e.target !== window) {
14322                // Skip dragging the finger outside of the Meta Browser.
14323              } else {
14324                console.log("⌨️🔴 [bios pointerup] calling input.blur() | keyboardOpen:", keyboardOpen, "target:", e.target?.tagName);
14325                input.blur();
14326              }
14327            } else {
14328              keyboardOpenMethod = "pointer";
14329              // input.removeAttribute("readonly");
14330              console.log("⌨️🟢 [bios pointerup] calling input.focus() | keyboardOpen:", keyboardOpen);
14331              window.focus();
14332              input.focus();
14333            }
14334          }
14335        });
14336
14337        input.addEventListener("focus", (e) => {
14338          console.log("⌨️🟢 [input focus event] keyboardOpen was:", keyboardOpen, "method:", keyboardOpenMethod);
14339          if (keyboardOpen) return;
14340          // input.removeAttribute("readonly");
14341          keyboardOpen = true;
14342          keyboard.events.push({
14343            name: "keyboard:open",
14344            method: keyboardOpenMethod,
14345          });
14346          keyboardOpenMethod = undefined;
14347          console.log("⌨️🟢 [input focus event] pushed keyboard:open event");
14348        });
14349
14350        input.addEventListener("blur", (e) => {
14351          console.log("⌨️🔴 [input blur event] keyboardOpen was:", keyboardOpen, new Error().stack);
14352          // input.setAttribute("readonly", true);
14353          // const temp = input.value;
14354          // input.value = "";
14355          // input.value = temp;
14356          keyboardOpen = false;
14357          keyboard.events.push({ name: "keyboard:close" });
14358          console.log("⌨️🔴 [input blur event] pushed keyboard:close event");
14359        });
14360
14361        window.addEventListener("blur", (e) => {
14362          // console.log("blurred window...");
14363          // keyboardOpen = false;
14364          // keyboard.events.push({ name: "keyboard:close" });
14365        });
14366      }
14367
14368      // 🎮 Gamepad
14369      gamepad = new Gamepad();
14370
14371      // Turn off all layers onbeforeunload. (Prevents a white flicker in chrome.)
14372      window.addEventListener("beforeunload", (e) => {
14373        send({ type: "before-unload" });
14374        wrapper.classList.add("reloading");
14375        
14376        // 🎮 Clean up GameBoy emulator on page unload
14377        if (gameboyEmulator) {
14378          try {
14379            console.log("🎮 Cleaning up GameBoy emulator on page unload");
14380            gameboyEmulator.pause();
14381            // Remove the hidden canvas
14382            if (window.gameboyCanvas) {
14383              window.gameboyCanvas.remove();
14384              window.gameboyCanvas = null;
14385            }
14386          } catch (error) {
14387            console.log("🎮 Error during GameBoy cleanup:", error);
14388          }
14389        }
14390      });
14391
14392      // Listen for resize events on the visual viewport
14393      window.visualViewport.addEventListener("resize", () => {
14394        const y = window.visualViewport.height;
14395        window.acDISK_SEND({ type: "viewport-height:changed", content: { y } });
14396      }); // 🌒 Detect light or dark mode.
14397      // See also: https://flaviocopes.com/javascript-detect-dark-mode,
14398      //           https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-color-scheme
14399
14400      // Skip initial OS theme detection if embedded in kidlisp.com (will receive theme from parent)
14401      if (window.matchMedia && !window.acWAIT_FOR_PARENT_THEME) {
14402        if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
14403          document.documentElement.style.setProperty("color-scheme", "dark");
14404          send({ type: "dark-mode", content: { enabled: true } });
14405        } else {
14406          document.documentElement.style.setProperty("color-scheme", "light");
14407          send({ type: "dark-mode", content: { enabled: false } });
14408        }
14409      }
14410
14411      // Listen for OS theme changes (but respect manual override from kidlisp.com)
14412      if (window.matchMedia) {
14413        window
14414          .matchMedia("(prefers-color-scheme: dark)")
14415          .addEventListener("change", (event) => {
14416            // Skip if theme was manually set from kidlisp.com
14417            if (window.acMANUAL_THEME_OVERRIDE) {
14418              console.log('🎨 [bios.mjs] Ignoring OS theme change - manual override active');
14419              return;
14420            }
14421            if (event.matches) {
14422              document.documentElement.style.setProperty(
14423                "color-scheme",
14424                "dark",
14425              );
14426              send({ type: "dark-mode", content: { enabled: true } });
14427            } else {
14428              document.documentElement.style.setProperty(
14429                "color-scheme",
14430                "light",
14431              );
14432              send({ type: "dark-mode", content: { enabled: false } });
14433            }
14434          });
14435      }
14436
14437      // 📋 User pasting of content.
14438      //window.addEventListener("paste", (event) => {
14439      // pastedText = event.clipboardData.getData("text/plain");
14440      //});
14441
14442      // 🖥️ Display (Load the display, with 0 margin if sandboxed)
14443      frame(
14444        resolution?.width,
14445        resolution?.height,
14446        resolution?.gap ?? (sandboxed ? 0 : undefined),
14447      );
14448
14449      // 🔊 Sound
14450      // TODO: Disable sound engine entirely... unless it is enabled by a disk. 2022.04.07.03.33
14451      // Only start this after a user-interaction to prevent warnings.
14452      // Exception: device mode auto-starts audio (for FF1 and display devices)
14453
14454      activateSound = () => {
14455        startSound();
14456        window.removeEventListener("keydown", activateSound);
14457        window.removeEventListener("pointerdown", activateSound);
14458      };
14459
14460      // 📺 Device mode: Auto-start audio for display devices like FF1
14461      // These devices can't provide user gestures, so we force audio on immediately
14462      if (resolution.device === true) {
14463        console.log("📺 Device mode: Auto-starting audio context");
14464        startSound();
14465      }
14466
14467      diskSupervisor = { requestBeat, requestFrame };
14468
14469      // 🎞️ Cap render framerate if maxfps was requested (e.g. 60 for calm display mode)
14470      if (resolution.maxfps && Number.isFinite(resolution.maxfps) && resolution.maxfps > 0) {
14471        Loop.frameRate(resolution.maxfps);
14472        const msg = `maxfps: ${resolution.maxfps}fps cap applied`;
14473        console.log(`🎞️ ${msg}`);
14474        window.parent?.postMessage({ type: "boot-log", message: msg }, "*");
14475      }
14476
14477      // ➰ Core Loops for User Input, Music, Object Updates, and Rendering
14478      Loop.start(
14479        () => {
14480          // TODO: What is this now?
14481          // pen.poll();
14482          // TODO: Key.input();
14483          // TODO: Voice.input();
14484        },
14485        function (needsRender, updateTimes, now) {
14486          // 🖱️ OBJKT mode: Hide CSS cursor after 2 seconds of inactivity
14487          if (window.acPACK_MODE && pen?.pointers[1]) {
14488            const pointer = pen.pointers[1];
14489            const timeSinceMove = performance.now() - (pointer.lastMoveTime || 0);
14490            const shouldShowCursor = timeSinceMove < 2000;
14491            
14492            if (shouldShowCursor && document.body.style.cursor === 'none') {
14493              // Use simple CSS cursor in PACK mode to avoid SVG 404
14494              document.body.style.cursor = 'default';
14495            } else if (!shouldShowCursor && document.body.style.cursor !== 'none') {
14496              document.body.style.cursor = 'none';
14497            }
14498          }
14499
14500          // TODO: How can I get the pen data into the disk and back
14501          //       to Three.JS as fast as possible? 22.10.26.23.25
14502          diskSupervisor?.requestFrame?.(needsRender, updateTimes, now);
14503
14504          if (ThreeD?.status.alive === true && ThreeDBakeQueue.length > 0) {
14505            ThreeD.collectGarbage();
14506            // Bake all forms, while keeping track of baked forms, and any form that is missing after the queue ends needs to be cleared.
14507            const touchedForms = [];
14508            ThreeDBakeQueue.forEach((baker) => touchedForms.push(...baker()));
14509            ThreeD.checkForRemovedForms(touchedForms);
14510            ThreeDBakeQueue.length = 0;
14511            ThreeD?.render(now);
14512          }
14513
14514          TwoD?.render();
14515        },
14516      );
14517    } // 💾 Disk Loading
14518    // Initialize some global stuff after the first piece loads.
14519    // Unload some already initialized stuff if this wasn't the first load.
14520    if (type === "disk-loaded") {
14521      accountActivity.load(content.path);
14522      // console.log(`🔍 BIOS: Received disk-loaded for "${content.text}", path="${content.path}"`);
14523
14524      // 🐚 Tell a hosting shell (prompt.ac) about every load, including the
14525      // boot piece — the url:updated below skips first loads and history
14526      // replays, but the shell needs them all to track the stage and to know
14527      // when the prompt is actually ready for dom-input submits.
14528      if (window.parent !== window) {
14529        const shellSlug =
14530          content.text === "/prompt"
14531            ? "prompt"
14532            : typeof content.text === "string"
14533              ? content.text.replace(/^\//, "")
14534              : "";
14535        window.parent.postMessage({ type: "ac:piece", slug: shellSlug }, "*");
14536      }
14537      
14538      // 🧹 Clean up any GPU/stats/glaze state from previous piece
14539      // This ensures cleanup even if the previous piece's leave() failed
14540      let needsGPUCleanup = false;
14541      
14542      if (statsOverlayEnabled) {
14543        statsOverlayEnabled = false;
14544        statsOverlay.style.display = "none";
14545        needsGPUCleanup = true;
14546      }
14547      if (webgpuCanvas.style.display !== "none") {
14548        webgpuCanvas.style.display = "none";
14549        needsGPUCleanup = true;
14550      }
14551      if (canvas.style.visibility === "hidden") {
14552        canvas.style.visibility = "visible";
14553        needsGPUCleanup = true;
14554      }
14555      // Reset glaze state only if it was actually on
14556      if (glaze.on) {
14557        Glaze.clear(); // Clear WebGL buffer to prevent stale content
14558        Glaze.off();
14559        glaze.on = false;
14560        // Glaze pieces (like KidLisp) need a reframe to properly refresh the screen
14561        needsReframe = true;
14562      }
14563      canvas.style.removeProperty("opacity");
14564      // Clear freeze frame if stuck
14565      if (freezeFrame || freezeFrameFrozen) {
14566        if (wrapper.contains(freezeFrameCan)) {
14567          freezeFrameCan.remove();
14568        }
14569        freezeFrame = false;
14570        freezeFrameGlaze = false;
14571        freezeFrameFrozen = false;
14572      }
14573      
14574      // 🎨 Force a reframe only if coming from a GPU piece (WebGPU canvas was visible)
14575      // This prevents breaking the piece transition on normal disk loads
14576      if (needsGPUCleanup) {
14577        needsReframe = true;
14578      }
14579      
14580      try {
14581        // Clear any active parameters once the disk has been loaded.
14582        // Skip URL manipulation in pack mode (sandboxed iframe)
14583        if (!checkPackMode() && !frozenUrlPath) {
14584          // Special handling for prompt piece with kidlisp content
14585          if (
14586            content.path === "aesthetic.computer/disks/prompt" &&
14587            content.params &&
14588            content.params.length > 0 &&
14589            isKidlispSource(content.params[0])
14590          ) {
14591            // For prompt piece with kidlisp parameters, preserve the prompt~ URL structure
14592            const encodedContent = encodeKidlispForUrl(content.params[0]);
14593            const encodedPath = "/prompt~" + encodedContent;
14594            // Use pushState instead of replaceState to preserve history navigation
14595            try {
14596              if (!content.fromHistory) {
14597                performHistoryRewrite(encodedPath, true);
14598              } else {
14599                performHistoryRewrite(encodedPath, false);
14600              }
14601            } catch (e) { /* Ignore in restricted context */ }
14602          } else if (content.text && isKidlispSource(content.text)) {
14603            // For standalone kidlisp pieces, use centralized URL encoding
14604            const encodedPath = "/" + encodeKidlispForUrl(content.text);
14605            // Use pushState instead of replaceState to preserve history navigation
14606            try {
14607              if (!content.fromHistory) {
14608                performHistoryRewrite(encodedPath, true);
14609              } else {
14610                performHistoryRewrite(encodedPath, false);
14611              }
14612            } catch (e) { /* Ignore in restricted context */ }
14613          } else if (content.clockShortcode) {
14614            // ⏰ For clock shortcodes (*wibe, *abc, etc.), display the shortcode URL
14615            const clockPath = "/" + content.clockShortcode;
14616            try {
14617              if (!content.fromHistory) {
14618                performHistoryRewrite(clockPath, true);
14619              } else {
14620                performHistoryRewrite(clockPath, false);
14621              }
14622            } catch (e) { /* Ignore in restricted context */ }
14623          } else {
14624            // Drop piece arguments, retaining the embedding flags on every load.
14625            const newUrl = window.location.pathname.replace(/%5[eE]/g, "^");
14626            try {
14627              performHistoryRewrite(newUrl, false);
14628            } catch (e) { /* Ignore in restricted context */ }
14629          }
14630        }
14631
14632      // if (currentPiece !== null) firstPiece = false;
14633      // console.log("🎮 currentPiece CHANGING FROM:", currentPiece, "TO:", content.path);
14634      
14635      // 🎮 GameBoy emulator lifecycle management
14636      if (gameboyEmulator) {
14637        if (currentPiece && currentPiece.includes('gameboy') && !content.path.includes('gameboy')) {
14638          // Leaving gameboy piece - pause emulator
14639          console.log("🎮 Leaving gameboy piece - pausing emulator");
14640          try {
14641            gameboyEmulator.pause();
14642          } catch (error) {
14643            console.log("🎮 Error pausing emulator:", error);
14644          }
14645        } else if (!currentPiece?.includes('gameboy') && content.path.includes('gameboy')) {
14646          // Entering gameboy piece - resume emulator if ROM is loaded
14647          console.log("🎮 Entering gameboy piece - resuming emulator");
14648          try {
14649            if (currentGameboyROM) {
14650              gameboyEmulator.play();
14651            }
14652          } catch (error) {
14653            console.log("🎮 Error resuming emulator:", error);
14654          }
14655        }
14656      }
14657      
14658      currentPiece = content.path;
14659      keyboardMaxChars = 256; // Reset to default on each piece load
14660      // console.log("🎮 currentPiece SET TO:", currentPiece);
14661      
14662      // 🎬 Track piece changes during recording
14663      if (mediaRecorder && mediaRecorder.state === "recording" && window.recordingStartTimestamp) {
14664        const changeTimestamp = Date.now();
14665        const relativeTime = changeTimestamp - window.recordingStartTimestamp;
14666        
14667        recordedPieceChanges.push({
14668          timestamp: changeTimestamp,
14669          relativeTime: relativeTime,
14670          path: content.path,
14671          params: content.params,
14672          text: content.text,
14673        });
14674        
14675        console.log(`🎬 📍 Piece change recorded at ${relativeTime}ms:`, content.path);
14676      }
14677      
14678      // Don't disable keyboard for prompt piece (check if path contains 'prompt')
14679      if (content.path && !content.path.includes("prompt")) {
14680        currentPieceHasKeyboard = false;
14681      }
14682      
14683      if (keyboard) keyboard.input.value = "";
14684
14685      if (!content.taping) {
14686        detachMicrophone?.(); // Remove any attached microphone unless we
14687        //                       are taping 📼.
14688      }
14689
14690      // Kill any previously loading media.
14691      keys(mediaPathsLoading).forEach((key) => mediaPathsLoading[key].abort());
14692
14693      killAllSound?.(); // Kill any pervasive sounds in `speaker`.
14694      clearSoundSampleCache?.();
14695
14696      // ⚠️ Remove any sounds that aren't in the whitelist.
14697      const sfxKeys = keys(sfx);
14698      sfxKeys.forEach((key) => {
14699        if (key !== sound) delete sfx[key];
14700      });
14701      if (sfxKeys.length > 0 && logs.audio && debug)
14702        console.log("🔉 SFX Cleaned up:", sfx);
14703
14704      // Stop any playing samples.
14705      keys(sfxPlaying).forEach((sfx) => sfxPlaying[sfx]?.kill());
14706
14707      // Reset preloading.
14708      window.waitForPreload = false;
14709      window.preloaded = false;
14710
14711      // Clear any 3D content.
14712      ThreeD?.clear();
14713
14714      // Kill the 3D engine.
14715      ThreeD?.kill();
14716
14717      // Clear any DOM hitboxes added for Buttons that need
14718      // user interaction to trigger browser APIs. (Like clipboard)
14719      hitboxes = {};
14720
14721      // Clear any DOM content that was added by a piece.
14722      contentFrame?.remove(); // Remove the contentFrame if it exists.
14723      contentFrame = undefined;
14724
14725      // Clear any ticket overlay that was added by a piece.
14726      ticketWrapper?.remove();
14727      ticketWrapper = undefined;
14728
14729      underlayFrame?.remove(); // Remove the underlayFrame if it exists.
14730      underlayFrame = undefined;
14731      stopTapePlayback?.();
14732
14733      // Remove any event listeners added by the content frame.
14734      // Guard: this may be undefined in some embedded/early-load contexts.
14735      window.acCONTENT_EVENTS?.forEach?.((e) => e());
14736      window.acCONTENT_EVENTS = []; // And clear all events from the list.
14737
14738      // Remove existing video tags.
14739      videos.forEach(({ video, buffer, getAnimationRequest }) => {
14740        console.log("🎥 Removing:", video, buffer, getAnimationRequest());
14741
14742        if (video.srcObject) {
14743          const stream = video.srcObject;
14744          const tracks = stream.getTracks();
14745          tracks.forEach((track) => track.stop());
14746        }
14747
14748        video.remove();
14749
14750        // buffer.remove();
14751        cancelAnimationFrame(getAnimationRequest());
14752        handData = undefined; // Clear any handData.
14753      });
14754
14755      videos.length = 0;
14756      // Note: Any other disk state cleanup that needs to take place on unload
14757      //       should happen here.
14758
14759      // Reset the framing to a system default when unloading a disk if using
14760      // a customized resolution.
14761      // TODO: Do disks with custom resolutions need to be reset
14762      //       if they are being reloaded?
14763
14764      if (fixedWidth && fixedHeight) {
14765        freezeFrame = true;
14766        freezeFrameGlaze = glaze.on;
14767
14768        freezeFrameCan.width = imageData.width;
14769        freezeFrameCan.height = imageData.height;
14770
14771        fixedWidth = undefined;
14772        fixedHeight = undefined;
14773        needsReframe = true;
14774      }
14775
14776      if (lastGap !== 0) {
14777        // lastGap = 0; No longer needed... 22.10.04.15.28
14778        freezeFrame = true;
14779        freezeFrameCan.width = imageData.width;
14780        freezeFrameCan.height = imageData.height;
14781        needsReframe = true;
14782      }
14783
14784      // Turn off glaze.
14785      glaze.on = false;
14786      canvas.style.removeProperty("opacity");
14787
14788      if (pen) pen.events.length = 0; // Clear pen events.
14789      keyboard.events.length = 0; // Clear keyboard events.
14790      gamepad.clearEvents(); // Clear gamepad events.
14791
14792      // Clear when events but preserve core signal handlers.
14793      const coreHandlers = {};
14794      if (whens["recorder:cut"]) {
14795        coreHandlers["recorder:cut"] = whens["recorder:cut"];
14796        // console.log("📻 Preserving recorder:cut handler during reset");
14797      }
14798      whens = coreHandlers;
14799
14800      // Close (defocus) software keyboard if we are NOT entering the prompt.
14801      if (content.text && content.text.split("~")[0] !== "prompt") {
14802        document.querySelector("#software-keyboard-input")?.blur();
14803      }
14804      // keyboard.events.push({ name: "keyboard:close" });
14805
14806      setMetatags(content.meta);
14807
14808      // TODO: Make this automatic for pieces that use 3d.
14809      if (
14810        content.text === "wand" ||
14811        content.text?.indexOf("wand") === 0 ||
14812        content.text === "oldwand" ||
14813        content.text?.indexOf("oldwand") === 0
14814      ) {
14815        loadThreeD();
14816      }
14817
14818      // Show an "audio engine: off" message.
14819      //if (content.noBeat === false && audioContext?.state !== "running") {
14820      //bumper.innerText = "audio engine off";
14821      //modal.classList.add("on");
14822      //}
14823
14824      // Clear the ThreeD buffer.
14825      // ThreeD.clear();      // Emit a push state for the old disk if it was not the first. This is so
14826      // a user can use browser history to switch between disks.
14827      if ((content.pieceCount > 0 || content.alias === true) && !frozenUrlPath) {
14828        if (content.fromHistory === false /*&& window.origin !== "null"*/) {
14829          // Handle URL encoding for different piece types
14830          let urlPath;
14831          if (content.text === "/prompt") {
14832            urlPath = "/";
14833          } else if (
14834            content.path === "aesthetic.computer/disks/painting" &&
14835            content.hash &&
14836            /^[A-Za-z0-9]{3,12}$/.test(content.hash)
14837          ) {
14838            // Preserve short painting codes as a pure hash URL
14839            urlPath = "/#" + content.hash;
14840          } else if (
14841            content.path === "aesthetic.computer/disks/prompt" &&
14842            content.params &&
14843            content.params.length > 0 &&
14844            isKidlispSource(content.params[0])
14845          ) {
14846            // For prompt piece with kidlisp parameters, preserve the prompt~ URL structure
14847            urlPath = "/prompt~" + encodeKidlispForUrl(content.params[0]);
14848          } else if (isKidlispSource(content.text)) {
14849            // For standalone kidlisp pieces, use centralized URL encoding
14850            urlPath = "/" + encodeKidlispForUrl(content.text);
14851          } else {
14852            // For regular pieces, use normal text
14853            // Encode {# as {%23 to preserve stample codes in clock syntax
14854            // e.g., clock~{#abc}cdefg becomes clock~{%23abc}cdefg in URL
14855            let encodedText = content.text;
14856            if (encodedText && encodedText.includes("{#")) {
14857              encodedText = encodedText.replace(/\{#/g, "{%23");
14858            }
14859            urlPath = "/" + encodedText;
14860          }
14861
14862          performHistoryRewrite(urlPath, true);
14863          window.parent?.postMessage(
14864            {
14865              type: "url:updated",
14866              slug: (() => {
14867                if (content.text?.startsWith("/")) {
14868                  return content.text.slice(1);
14869                } else if (
14870                  content.path === "aesthetic.computer/disks/painting" &&
14871                  content.hash &&
14872                  /^[A-Za-z0-9]{3,12}$/.test(content.hash)
14873                ) {
14874                  return "#" + content.hash;
14875                } else if (
14876                  content.path === "aesthetic.computer/disks/prompt" &&
14877                  content.params &&
14878                  content.params.length > 0 &&
14879                  isKidlispSource(content.params[0])
14880                ) {
14881                  return "prompt~" + encodeKidlispForUrl(content.params[0]);
14882                } else if (isKidlispSource(content.text)) {
14883                  return encodeKidlispForUrl(content.text);
14884                } else {
14885                  return content.text;
14886                }
14887              })(),
14888            },
14889            "*",
14890          );
14891        } // Replace the state if we are running an aliased `load` or `jump`.
14892        // (That doesn't avoid the history stack.)
14893        // Note: History state changes do not work in a sandboxed iframe!
14894        if (
14895          content.fromHistory === true &&
14896          content.alias === false //&&
14897          // window.origin !== "null"
14898        ) {
14899          try {
14900            // Handle URL encoding for different piece types
14901            let urlPath;
14902            if (content.text === "/prompt") {
14903              urlPath = "/";
14904            } else if (
14905              content.path === "aesthetic.computer/disks/painting" &&
14906              content.hash &&
14907              /^[A-Za-z0-9]{3,12}$/.test(content.hash)
14908            ) {
14909              urlPath = "/#" + content.hash;
14910            } else if (
14911              content.path === "aesthetic.computer/disks/prompt" &&
14912              content.params &&
14913              content.params.length > 0 &&
14914              isKidlispSource(content.params[0])
14915            ) {
14916              // For prompt piece with kidlisp parameters, preserve the prompt~ URL structure
14917              urlPath = "/prompt~" + encodeKidlispForUrl(content.params[0]);
14918            } else if (isKidlispSource(content.text)) {
14919              // For standalone kidlisp pieces, use centralized URL encoding
14920              urlPath = "/" + encodeKidlispForUrl(content.text);
14921            } else {
14922              // For regular pieces, use normal encoding
14923              urlPath = "/" + content.text;
14924            }
14925
14926            if (!checkPackMode()) {
14927              performHistoryRewrite(urlPath, false);
14928            }
14929          } catch (err) {
14930            console.warn("⚠️ Couldn't change url state. Going too fast!? ➿🚗");
14931          }
14932        }
14933      }
14934
14935      UI.spinnerReset(); // Reset the timer on the yellow UI loading spinner.
14936
14937      if (content.pieceHasSound && !audioContext) {
14938        // Enable sound engine on interaction.
14939        window.addEventListener("keydown", activateSound, { once: true });
14940        window.addEventListener("pointerdown", activateSound, { once: true });
14941      }
14942
14943      // ⏱️ Enable background sim ticks for pieces that export `background: true`
14944      // (e.g. clock.mjs — keeps audio scheduling alive when tab/lid is hidden)
14945      Loop.setBackgroundEnabled(!!content.background);
14946
14947      } catch (err) {
14948        console.error("🛑 BIOS disk-loaded handler error:", err);
14949      } finally {
14950        if (DEBUG_MESSAGE_FLOW) {
14951          console.log(`🔍 BIOS: About to send loading-complete for ${content.text}`);
14952        }
14953        try {
14954          send({ type: "loading-complete" });
14955          // 🎨 Force immediate repaint after disk loads to clear previous piece's frame
14956          send({ type: "needs-paint" });
14957        } catch (e) {
14958          console.error("🛑 BIOS failed to send loading-complete:", e);
14959        }
14960        if (DEBUG_MESSAGE_FLOW) {
14961          console.log(`🔍 BIOS: loading-complete sent for ${content.text}`);
14962        }
14963      }
14964      return;
14965    }
14966
14967    if (type === "forms") {
14968      const willBake = content.cam !== undefined;
14969
14970      if (willBake) {
14971        // if (ThreeD?.status.alive === false) ThreeD.initialize(wrapper);
14972
14973        // Add / update forms in a queue and then run all the bakes in render.
14974        ThreeDBakeQueue.push(() => {
14975          return ThreeD?.bake(content, screen, {
14976            width: projectedWidth,
14977            height: projectedHeight,
14978          });
14979        });
14980
14981        //send({ type: "forms:baked", content: true });
14982      } else {
14983        //send({ type: "forms:baked", content: false });
14984      }
14985
14986      // TODO: Measure the time this takes.
14987      //const pixels = ThreeD.bake(content, screen, {width: projectedWidth, height: projectedHeight});
14988      // bakedCan.width = screen.width;
14989      // bakedCan.height = screen.height;
14990      // bakedCtx.drawImage(ThreeD.domElement, 0, 0);
14991      // const pixels = bakedCtx.getImageData(0, 0, screen.width, screen.height).data;
14992
14993      // send({
14994      //   type: "forms:baked",
14995      //   content: { width: screen.width, height: screen.height, pixels },
14996      // }, [pixels]);
14997
14998      return;
14999    }
15000
15001    if (type === "$creenshot") {
15002      needs$creenshot = (data) => receivedDownload({ ...content, data });
15003      return;
15004    }
15005
15006    if (type === "keyboard:enabled") {
15007      currentPieceHasKeyboard = true;
15008      keyboardFocusLock = false;
15009      keyboardSoftLock = false;
15010      // console.log("enabling keyboard...");
15011      return;
15012    }
15013
15014    if (type === "keyboard:disabled") {
15015      currentPieceHasKeyboard = false;
15016      return;
15017    }
15018
15019    if (type === "keyboard:close") {
15020      // if (keyboardFocusLock) return; // Deprecated: 23.10.02.23.18
15021      console.log("⌨️🔴 [bios keyboard:close handler] calling keyboard.input.blur()");
15022      keyboard?.input.blur();
15023      return;
15024    }
15025
15026    if (type === "keyboard:open") {
15027      // 🐚 shellhtml: at the prompt the host page's DOM bar is the text
15028      // surface — never summon the soft keyboard over the buffer there.
15029      if (preservedParams?.shellhtml && currentPiece?.endsWith("/prompt")) {
15030        console.log("⌨️🐚 [bios keyboard:open handler] shellhtml prompt — host bar owns typing, skipping");
15031        return;
15032      }
15033      console.log("⌨️🟢 [bios keyboard:open handler] calling keyboard.input.focus()");
15034      if (keyboardFocusLock) return;
15035      keyboardFocusLock = false;
15036      currentPieceHasKeyboard = true;
15037      // 📱 On iOS Safari, programmatic focus outside a user gesture silently
15038      // sets document.activeElement but does NOT open the virtual keyboard.
15039      // It does still fire the focus event though, which would flip
15040      // keyboardOpen=true and make the next real tap take the blur branch
15041      // (wasting the first tap). Skip programmatic focus on iOS — the first
15042      // user pointerup will open it inside the gesture.
15043      if (iOS) {
15044        console.log("⌨️🟢 [bios keyboard:open handler] iOS — deferring focus to next user pointerup");
15045        return;
15046      }
15047      keyboard?.input.focus();
15048      // if (keyboard) keyboard.needsImmediateOpen = true; // For iOS.
15049      return;
15050    }
15051
15052    // Prevents any touch or keyboard activation events directly on the input.
15053    if (type === "keyboard:soft-lock") {
15054      keyboardSoftLock = true;
15055      console.log("⌨️🔒 [bios] keyboard:soft-lock received, keyboardSoftLock =", keyboardSoftLock);
15056    }
15057
15058    if (type === "keyboard:soft-unlock") {
15059      keyboardSoftLock = false;
15060      keyboardFocusLock = false;
15061      console.log("⌨️🔓 [bios] keyboard:soft-unlock received, keyboardSoftLock =", keyboardSoftLock);
15062    }
15063
15064    if (type === "keyboard:lock") {
15065      keyboardFocusLock = true;
15066      if (logs.hid && debug) console.log("⌨️ Virtual Keyboard: Locked");
15067      return;
15068    }
15069
15070    if (type === "keyboard:unlock") {
15071      keyboardFocusLock = false;
15072      if (logs.hid && debug) console.log("⌨️ Virtual Keyboard: Unlocked");
15073      return;
15074    }
15075
15076    if (type === "keyboard:set-max-chars") {
15077      keyboardMaxChars = typeof content === "number" && content > 0 ? content : 256;
15078      return;
15079    }
15080
15081    if (type === "keyboard:cursor") {
15082      const input = keyboard.input;
15083      // Get the current position of the caret
15084
15085      // Quit if keyboard is not open.
15086      if (document.activeElement !== keyboard.input) return;
15087
15088      if (typeof content === "number") {
15089        const currentPosition =
15090          input.selectionDirection === "backward"
15091            ? input.selectionStart
15092            : input.selectionEnd;
15093        let newPosition = currentPosition + content;
15094        if (newPosition < 0) newPosition = 0;
15095        if (newPosition > input.value.length) newPosition = input.value.length;
15096        input.setSelectionRange(newPosition, newPosition);
15097      } else {
15098        // Set based on a specific value.
15099        input.setSelectionRange(content.cursor, content.cursor);
15100      }
15101      return;
15102    }
15103
15104    if (type === "keyboard:text:replace") {
15105      const input = keyboard.input;
15106      input.value = content.text;
15107      if (content.cursor && document.activeElement === keyboard.input)
15108        input.setSelectionRange(content.cursor, content.cursor);
15109      return;
15110    }
15111
15112    if (type === "gpu-event") {
15113      ThreeD?.handleEvent(content);
15114      return;
15115    }
15116
15117    // 📊 DOM Stats Overlay Control
15118    if (type === "stats-overlay") {
15119      if (content.enabled !== undefined) {
15120        statsOverlayEnabled = content.enabled;
15121        statsOverlay.style.display = statsOverlayEnabled ? "block" : "none";
15122        if (statsOverlayEnabled) {
15123          statsLastTime = performance.now();
15124          statsFrameCount = 0;
15125        }
15126      }
15127      if (content.data) {
15128        Object.assign(statsOverlayData, content.data);
15129      }
15130      return;
15131    }
15132
15133    if (type === "webgpu-command") {
15134      // Use new backend system if available, fallback to old WebGPU
15135      if (activeGPUBackend) {
15136        activeGPUBackend.handleCommand(content);
15137      } else {
15138        WebGPU.handleCommand(content);
15139      }
15140      return;
15141    }
15142
15143    // Adding custom DOM content.
15144    if (type === "content-create") {
15145      // Create a DOM container, if it doesn't already exist,
15146      // and add it here along with the requested content in the
15147      // template
15148      if (!contentFrame) {
15149        contentFrame = document.createElement("div");
15150        contentFrame.id = "content";
15151        wrapper.appendChild(contentFrame);
15152        contentFrame.innerHTML += content.content; // Add content to contentFrame.
15153      } else {
15154        contentFrame.innerHTML += content.content; // Add content to contentFrame.
15155      }
15156
15157      // Evaluate any added scripts inside of contentFrame.
15158      // TODO: This should only evaluate new scripts, as they are added...
15159      // It should also run if new scripts are added with the `html` function.
15160      const script = contentFrame.querySelector("script");
15161
15162      if (script && !script.dataset.evaluated) {
15163        if (script?.src.length > 0) {
15164          const s = document.createElement("script");
15165          s.type = "module";
15166          // s.onload = callback; // s.onerror = callback;
15167
15168          // The hash `time` parameter busts the cache so that the environment is
15169          // reset if a disk is re-entered while the system is running.
15170          // Why a hash? See also: https://github.com/denoland/deno/issues/6946#issuecomment-668230727
15171          s.src = script.src + "#" + Date.now();
15172          contentFrame.appendChild(s); // Re-insert the new script tag.
15173          script.remove(); // Remove old script element.
15174          s.dataset.evaluated = true;
15175        } else if (script?.innerText.length > 0) {
15176          window.eval(script.innerText);
15177          script.dataset.evaluated = true;
15178        }
15179      }
15180
15181      send({
15182        type: "content-created",
15183        content: { id: content.id, response: "Content was made!" }, // TODO: Return an API / better object?
15184      });
15185
15186      return;
15187    }
15188
15189    // Removing custom DOM content.
15190    if (type === "content-remove") {
15191      // Clear any DOM content that was added by a piece.
15192      contentFrame?.remove(); // Remove the contentFrame if it exists.
15193      contentFrame = undefined;
15194      // Remove any event listeners added by the content frame.
15195      window?.acCONTENT_EVENTS.forEach((e) => e());
15196      window.acCONTENT_EVENTS = []; // And clear all events from the list.
15197      return;
15198    }
15199
15200    if (type === "signal") {
15201      if (debug) console.log("📻 Signal received:", content);
15202      if (typeof content === "string") content = { type: content };
15203      
15204      console.log(`📻 Signal processing: type="${content.type}", handler exists:`, !!whens[content.type]);
15205      
15206      if (whens[content.type]) {
15207        try {
15208          console.log(`📻 Calling signal handler for: ${content.type}`);
15209          await whens[content.type](content.content);
15210          console.log(`📻 Signal handler completed for: ${content.type}`);
15211        } catch (error) {
15212          console.error(`📻 Error in signal handler for ${content.type}:`, error);
15213          console.error(`📻 Stack trace:`, error.stack);
15214        }
15215      } else {
15216        console.warn(`📻 No handler found for signal: ${content.type}`);
15217      }
15218    }
15219
15220    // 📦 Storage
15221
15222    // Can store data to localStorage (user settings),
15223    //                   indexedDB (large files)
15224    //                   remote (with user account or anonymous)
15225
15226    // *** 🏪 Store: Persist ***
15227    if (type === "store:persist") {
15228      // Local Storage
15229      if (content.method === "local") {
15230        try {
15231          window.safeLocalStorageSet(content.key, JSON.stringify(content.data));
15232        } catch (e) {
15233          // console.warn(e);
15234        }
15235
15236        if (debug && logs.store)
15237          console.log("📦 Persisted locally:", content, localStorage);
15238      }
15239
15240      // IndexedDB
15241      // Potentially use this library: github.com/jakearchibald/idb
15242      // For images: https://hacks.mozilla.org/2012/02/storing-images-and-files-in-indexeddb/
15243      // Using: https://github.com/jakearchibald/idb
15244      // See also: web.dev/indexeddb-best-practices
15245      if (content.method === "local:db") {
15246        await Store.set(content.key, content.data);
15247        // const set = await Store.set(content.key, content.data);
15248        // const get = await Store.get(content.key);
15249        if (debug && logs.store)
15250          console.log("📦 Persisted on local:db:", content);
15251      }
15252
15253      if (content.method === "remote:temporary") {
15254        // Upload to S3 with a code.
15255      }
15256
15257      if (content.method === "remote:permanent") {
15258        // Combine token-gated web3 authentication here along
15259        // with IPFS storage.
15260        // This would be used as part of a `mint` function. 22.10.04.11.31
15261      }
15262
15263      return;
15264    }
15265
15266    // Store: Retrieve
15267    if (type === "store:retrieve") {
15268      if (content.method === "local") {
15269        let data;
15270
15271        if (!window.acLOCALSTORAGE_BLOCKED) {
15272          try {
15273            const item = window.safeLocalStorageGet(content.key);
15274            if (item) data = JSON.parse(item);
15275          } catch (err) {
15276            console.warn("📦 Retrieval error:", err);
15277            // Probably in a sandboxed environment here...
15278          }
15279        }
15280
15281        if (debug && logs.store)
15282          console.log("📦 Retrieved local data:", content.key, data);
15283
15284        send({
15285          type: "store:retrieved",
15286          content: { key: content.key, data },
15287        });
15288      }
15289
15290      if (content.method === "local:db") {
15291        const retrievedContent = await Store.get(content.key);
15292        if (debug && logs.store)
15293          console.log(
15294            "📦 Retrieved local:db data:",
15295            content.key,
15296            retrievedContent,
15297          );
15298        send({
15299          type: "store:retrieved",
15300          content: { key: content.key, data: retrievedContent },
15301        });
15302      }
15303
15304      return;
15305    }
15306
15307    // Store: Delete
15308    if (type === "store:delete") {
15309      if (content.method === "local") {
15310        if (debug && logs.store)
15311          console.log("📦 Delete local data:", content.key);
15312        window.safeLocalStorageRemove(content.key);
15313        send({
15314          type: "store:deleted",
15315          content: { key: content.key, data: true },
15316        });
15317        // Just assume this is deleted.
15318      }
15319
15320      if (content.method === "local:db") {
15321        const hasKey = (await Store.keys())?.includes(content.key);
15322        let deleted;
15323
15324        if (hasKey) {
15325          await Store.del(content.key);
15326          const alteredKeys = await Store.keys();
15327          deleted = !alteredKeys.includes(content.key);
15328        } else {
15329          deleted = false;
15330        }
15331
15332        if (debug && logs.store)
15333          console.log("📦 Delete local:db data:", content.key, deleted);
15334        send({
15335          type: "store:deleted",
15336          content: { key: content.key, data: true },
15337        });
15338      }
15339      return;
15340    }
15341
15342    if (type === "meta") {
15343      setMetatags(content);
15344      return;
15345    }
15346
15347    if (type === "refresh") {
15348      // Use the same contract as history rewrites, including autoreload/zoom.
15349      window.location.href = new URL(preserveViewParams(window.location.href), window.location.href).href;
15350      return;
15351    }
15352
15353    if (type === "web") {
15354      // console.log("Jumping to:", content.url, content.blank);
15355      if (content.blank === true) {
15356        if (window.acElectron?.openExternal) {
15357          // Electron app: MUST be checked before the Aesthetic (iOS) branch —
15358          // the desktop app's user-agent contains "Aesthetic", so `Aesthetic`
15359          // is true here and would otherwise swallow the link into the iOS
15360          // path (a no-op in Electron). Open in the system browser via the
15361          // webview-preload bridge → main's shell.openExternal. Avoids
15362          // window.open, whose user gesture is lost across the worker boundary.
15363          window.acElectron.openExternal(content.url);
15364        } else if (Aesthetic) {
15365          iOSAppSend({ type: "url", body: content.url });
15366        } else if (window.acVSCODE && window.parent !== window) {
15367          // In VSCode extension, send message to parent to handle external URL
15368          window.parent.postMessage(
15369            {
15370              type: "openExternal",
15371              url: content.url,
15372            },
15373            "*",
15374          );
15375        } else {
15376          // Try to open in a new tab. The user gesture was lost crossing the
15377          // worker postMessage boundary, so popup blockers may veto this.
15378          // Fall back to same-tab navigation when blocked so the link always
15379          // works. See disk.mjs jump() + net.web() for the out: flow.
15380          let opened = null;
15381          try {
15382            opened = window.open(content.url, "_blank", "noopener");
15383          } catch (err) {
15384            opened = null;
15385          }
15386          if (!opened || opened.closed || typeof opened.closed === "undefined") {
15387            window.location.href = content.url;
15388          }
15389        }
15390      } else {
15391        window.location.href = content.url; // Redirect in the current tab
15392      }
15393      return;
15394    }
15395
15396    if (type === "open-devtools") {
15397      window.ac?.openDevTools();
15398      return;
15399    }
15400
15401    if (type === "preload-ready") {
15402      window.preloaded = true;
15403      return;
15404    }
15405
15406    if (type === "wait-for-preload") {
15407      window.waitForPreload = true;
15408      return;
15409    }
15410
15411    // Deprecated in favor of frame-synced communication.
15412    if (type === "beat") {
15413      updateSynths(content);
15414      return;
15415    }
15416
15417    if (type === "beat:skip") {
15418      beatSkip?.();
15419      return;
15420    }
15421    if (type === "synth:update") {
15422      updateSound?.(content);
15423      return;
15424    }
15425    if (type === "synth:trigger") {
15426      triggerSound?.(content);
15427      return;
15428    }
15429    if (type === "synth:kill") {
15430      killSound?.(content.id, content.fade);
15431      return;
15432    }
15433
15434    if (type === "bubble:update") {
15435      updateSound?.(content);
15436      return;
15437    }
15438
15439    if (type === "fart:update") {
15440      updateSound?.(content);
15441      return;
15442    }
15443
15444    // 🏠🧩 Room/Reverb + Glitch control messages
15445    if (
15446      type === "room:toggle" ||
15447      type === "room:set" ||
15448      type === "room:get" ||
15449      type === "glitch:toggle" ||
15450      type === "glitch:set" ||
15451      type === "glitch:get"
15452    ) {
15453      sendRoomMessage?.(type, content);
15454      return;
15455    }
15456
15457    if (type === "audio:reinit") {
15458      await reinitAudioSystem(content || {});
15459      return;
15460    }
15461
15462    // 💾 Stick: Offline audio rendering for USB export
15463    if (type === "stick:render") {
15464      await handleStickRender(content);
15465      return;
15466    }
15467
15468    if (type === "download") {
15469      receivedDownload(content);
15470      return;
15471    }
15472
15473    // Trigger a file download from a remote URL without navigating away.
15474    // Opens in a new tab — the browser auto-converts to a download for
15475    // binary content types (application/octet-stream) without leaving
15476    // the current page.
15477    if (type === "download-url") {
15478      window.open(content.url, "_blank");
15479      return;
15480    }
15481
15482    if (type === "upload") {
15483      // Extract recordingSlug if present in content
15484      const { recordingSlug, metadata, ...uploadData } = content;
15485      console.log("🔍 UPLOAD MESSAGE HANDLER: recordingSlug=", recordingSlug, "content keys=", Object.keys(content));
15486      receivedUpload(uploadData, "upload", metadata, recordingSlug);
15487      return;
15488    }
15489
15490    // 📼 Upload a camera-roll clip as an MP4-backed tape. Mirrors the ZIP
15491    // tape flow (create-and-post-tape → receivedUpload(..., "tape:posted",
15492    // metadata)) but the data is the already-encoded clip bytes, so there's
15493    // no frame/zip step. track-tape mints a !code and marks kind "mp4".
15494    if (type === "upload-video-tape") {
15495      const { data, mime, duration, callback, source } = content || {};
15496      // Logged-in users get a dotted-timestamp filename (no "-" so the
15497      // presigned server sorts it under {sub}/<ts>.mp4, mirroring zip tapes);
15498      // guests fall back to a generic name and the server mints a nanoid.
15499      const token = await authorize().catch(() => null);
15500      let filename;
15501      if (token) {
15502        const slug = new Date()
15503          .toISOString()
15504          .replace(/[-:]/g, ".")
15505          .replace("T", ".")
15506          .replace("Z", "")
15507          .split(".").slice(0, 7).join(".");
15508        filename = `${slug}.mp4`;
15509      } else {
15510        filename = "tape.mp4";
15511      }
15512      const blob = new Blob([data], { type: mime || "video/mp4" });
15513      const metadata = {
15514        totalDuration: duration || 0,
15515        source: source || "camera-roll",
15516      };
15517      receivedUpload(
15518        { filename, data: blob },
15519        callback || "tape:posted",
15520        metadata,
15521      );
15522      return;
15523    }
15524
15525    if (type === "import") {
15526      receivedImport(content);
15527      return;
15528    }
15529
15530    if (type === "microphone") {
15531      receivedMicrophone(content);
15532      return;
15533    }
15534
15535    if (type === "microphone-permission-request") {
15536      const permission = await checkMicrophonePermission();
15537      send({ type: "microphone-permission", content: permission });
15538      return;
15539    }
15540
15541    if (type === "notifications:web") {
15542      if (window.webkit?.messageHandlers?.iOSApp) return;
15543      window.acRequestNotifications?.(content);
15544      return;
15545    }
15546
15547    if (type === "microphone-record") {
15548      requestMicrophoneRecordingStart?.();
15549      return;
15550    }
15551
15552    if (type === "microphone-cut") {
15553      requestMicrophoneRecordingStop?.();
15554      return;
15555    }
15556
15557    if (type === "get-microphone-amplitude") {
15558      requestMicrophoneAmplitude?.();
15559      return;
15560    }
15561
15562    if (type === "get-microphone-waveform") {
15563      requestMicrophoneWaveform?.();
15564      return;
15565    }
15566
15567    if (type === "get-microphone-recording-buffer") {
15568      requestMicrophoneRecordingBuffer?.();
15569      return;
15570    }
15571
15572    if (type === "get-waveforms") {
15573      requestSpeakerWaveforms?.();
15574      return;
15575    }
15576
15577    if (type === "get-amplitudes") {
15578      if (requestSpeakerAmplitudes) {
15579        requestSpeakerAmplitudes();
15580      } else if (resolution.spoofaudio) {
15581        // Synthetic amplitude for headless captures (no speaker processor)
15582        const t = performance.now() / 1000;
15583        const left = (Math.sin(t * Math.PI * 1.0) + 1) / 2;
15584        const right = (Math.sin(t * Math.PI * 1.0 + 0.5) + 1) / 2;
15585        send({ type: "amplitudes", content: { left, right } });
15586      }
15587      return;
15588    }
15589
15590    if (type === "get-frequencies") {
15591      requestSpeakerFrequencies?.();
15592      return;
15593    }
15594
15595    if (type === "get-microphone-pitch") {
15596      requestMicrophonePitch?.();
15597      return;
15598    }
15599
15600    if (type === "video") {
15601      receivedVideo(content);
15602      return;
15603    }
15604
15605  // Audio-visual recording of the main audio track and microphone.
15606  if (type === "recorder:rolling") {
15607    recorderGeneration += 1;
15608    // mediaRecorderBlob = null; // Clear the current blob when we start recording.
15609
15610    // Store recording metadata for filename generation
15611    let recordingOptions = {};
15612    let actualContent = content;
15613    
15614    if (typeof content === "object") {
15615      // New format with piece metadata
15616      recordingOptions = {
15617        pieceName: content.pieceName || "tape",
15618        pieceParams: content.pieceParams || "",
15619        originalCommand: content.originalCommand || "",
15620        intendedDuration: content.intendedDuration || null, // Store intended duration from tape command
15621        mystery: content.mystery || false, // Store mystery flag to hide command in filename
15622        frameMode: content.frameMode || false, // Store frame-based recording mode
15623        frameCount: content.frameCount || null, // Store target frame c
15623ount
15624        kidlispFps: content.kidlispFps || null, // Store KidLisp framerate from fps function
15625        cleanMode: content.cleanMode || false, // Store clean mode flag (no overlays, no progress bar)
15626        showTezosStamp: content.showTezosStamp || false, // Store Tezos stamp visibility
15627        freshSession: content.freshSession === true,
15628        avSync: content.avSync === true,
15629        compactVideo: content.compactVideo === true,
15630      };
15631      actualContent = content.type || "video";
15632    } else {
15633      // Legacy string format - default to "tape"
15634      recordingOptions = {
15635        pieceName: "tape",
15636        pieceParams: "",
15637        originalCommand: "",
15638        intendedDuration: null,
15639        mystery: false,
15640        cleanMode: false, // Default to false for legacy recordings
15641        kidlispFps: null, // Initialize for KidLisp framerate updates
15642        freshSession: false,
15643        avSync: false,
15644        compactVideo: false,
15645      };
15646      actualContent = content;
15647    }
15648    
15649    // Store for use during export
15650    window.currentRecordingOptions = recordingOptions;
15651    console.log("🎬 Recording options set:", JSON.stringify(recordingOptions, null, 2));
15652
15653    const colonSplit = actualContent.split(":");
15654    // tiktokVideo = colonSplit[1] === "tiktok";
15655    actualContent = colonSplit[0];
15656
15657    // Always clear frames and underlay when starting new recording
15658    // (Must happen before the paused check to avoid early return skipping cleanup)
15659    recordedFrames.length = 0;
15660    recordedPieceChanges.length = 0; // Clear piece changes tracking
15661    recordedVideoBlob = null;
15662    recordedVideoMime = "";
15663    recordedVideoSource = "";
15664    if (compactPlaybackUrl) {
15665      URL.revokeObjectURL(compactPlaybackUrl);
15666      compactPlaybackUrl = null;
15667    }
15668    clearCompactRecorderStream();
15669    startTapePlayback = undefined;
15670    hdTapeReset(); // 🖼️📼 Fresh hd-tape state; arms itself on the first hd frame.
15671    console.log("🎬 Initialized recording state");
15672    
15673    // Clear any active underlay from previous playback
15674    if (underlayFrame) {
15675      const media = underlayFrame.querySelector("video, audio");
15676      if (media?.src) URL.revokeObjectURL(media.src);
15677      underlayFrame?.remove();
15678      underlayFrame = undefined;
15679      console.log("🎬 Cleared underlay from previous playback");
15680    }
15681
15682    if (mediaRecorder && shouldResumeCaptureRecorder(
15683      mediaRecorder.state,
15684      recordingOptions.freshSession,
15685    )) {
15686        mediaRecorder.resume();
15687        mediaRecorderStartTime = performance.now();
15688        // Initialize recording start timestamp for frame recording if not already set
15689        if (!window.recordingStartTimestamp) {
15690          window.recordingStartTimestamp = Date.now();
15691        }
15692        send({
15693          type: "recorder:rolling:resumed",
15694          content: {
15695            mime: mediaRecorder.mimeType,
15696            time: audioContext?.currentTime,
15697          },
15698        });
15699        if (debug && logs.recorder)
15700          console.log("🔴 Recorder: Resumed", actualContent);
15701        return;
15702      }
15703
15704      if (mediaRecorder) {
15705        stop();
15706      }
15707
15708      function stop() {
15709        
15710        // Properly stop and clean up MediaRecorder before trashing it
15711        if (mediaRecorder && mediaRecorder.state !== "inactive") {
15712          mediaRecorder.onstop = null;
15713          mediaRecorder.stop();
15714        }
15715        mediaRecorder = undefined; // ❌ Trash the recorder.
15716        mediaRecorderStartTime = undefined;
15717        mediaRecorderDuration = undefined; // Reset to undefined for clean initialization
15718        mediaRecorderChunks.length = 0;
15719        
15720        clearRawAudioCapture();
15721        clearCompactRecorderStream();
15722        captureSession = null;
15723        
15724        // Clear recording timestamp for next recording
15725        if (window.recordingStartTimestamp) {
15726          delete window.recordingStartTimestamp;
15727        }
15728        if (window.currentTapeProgress !== undefined) {
15729          delete window.currentTapeProgress;
15730        }
15731        if (window.lastVideoProgress !== undefined) {
15732          delete window.lastVideoProgress;
15733        }
15734        // Clear cached tape data when stopping to start a new recording
15735        Store.del("tape").catch(() => {}); // Clear cache, ignore errors
15736      }
15737
15738      let mimeType;
15739
15740      // if (content === "audio" || content === "video") {
15741
15742      // if (MediaRecorder.isTypeSupported(content + "/mp4")) {
15743      //   mimeType = content + "/mp4"; // This is the setup for Safari.
15744      // } else if (MediaRecorder.isTypeSupported(content + "/webm")) {
15745      //   mimeType = content + "/webm"; // And for Chrome & Firefox.
15746      //   // mimeType = content + "/webm;codecs=h264"; // Possible optimization.
15747      // } else {
15748      //   console.error("🔴 Mimetypes mp4 and webm are unsupported.");
15749      // }
15750
15751      // } else {
15752      //   console.error("🔴 Option must be 'audio' or 'video'.");
15753      // }
15754
15755      // Set the audio recorder mimetypes.
15756      // TODO: Should WAV also be here?
15757      if (MediaRecorder.isTypeSupported("audio/webm;codecs=opus")) {
15758        mimeType = "audio/webm;codecs=opus";
15759      } else if (MediaRecorder.isTypeSupported("audio/ogg;codecs=vorbis")) {
15760        mimeType = "audio/ogg;codecs=vorbis";
15761      } else if (MediaRecorder.isTypeSupported("audio/aac")) {
15762        mimeType = "audio/aac";
15763      }
15764
15765      let options = { mimeType };
15766
15767      if (actualContent === "video") {
15768        // Camera recordings use the browser encoder over the already-rendered
15769        // AC canvas. This preserves cap's crop/mirror/zoom while avoiding a
15770        // second getImageData plus an unbounded array of RGBA frames.
15771        if (
15772          recordingOptions.compactVideo &&
15773          typeof MediaStream === "function"
15774        ) {
15775          const compactMime = chooseCompactVideoMime(
15776            MediaRecorder.isTypeSupported.bind(MediaRecorder),
15777          );
15778
15779          if (compactMime) {
15780            try {
15781              const cameraVideo = document.getElementById("camera-feed");
15782              const cameraTrack = cameraVideo?.srcObject
15783                ?.getVideoTracks?.()
15784                ?.find((track) => track.readyState === "live");
15785              let videoTrack;
15786              if (cameraTrack && cameraVideo.videoWidth > 0) {
15787                // 🔍 Zoom compositor: record the camera through a
15788                // native-resolution canvas that applies the same digital
15789                // center-crop as the preview. Camera zoom constraints are
15790                // never touched — on iOS they switch physical lenses
15791                // mid-recording (visible jump cuts) and front cameras rarely
15792                // expose a zoom range at all — so the tape ramps smoothly
15793                // and linearly, exactly like the screen.
15794                const zoomCanvas = document.createElement("canvas");
15795                zoomCanvas.width = cameraVideo.videoWidth;
15796                zoomCanvas.height = cameraVideo.videoHeight;
15797                const zoomCtx = zoomCanvas.getContext("2d");
15798                cameraFeedState.zoom = 1; // Each take starts unzoomed.
15799                let liveZoom = 1;
15800                let compositorFrame;
15801                const drawCompositorFrame = () => {
15802                  const target = Math.max(1, cameraFeedState.zoom || 1);
15803                  liveZoom += (target - liveZoom) * 0.25; // Per-frame ease.
15804                  if (Math.abs(target - liveZoom) < 0.002) liveZoom = target;
15805                  const vw = cameraVideo.videoWidth;
15806                  const vh = cameraVideo.videoHeight;
15807                  if (vw > 0 && vh > 0) {
15808                    if (zoomCanvas.width !== vw || zoomCanvas.height !== vh) {
15809                      zoomCanvas.width = vw;
15810                      zoomCanvas.height = vh;
15811                    }
15812                    const cropW = vw / liveZoom;
15813                    const cropH = vh / liveZoom;
15814                    zoomCtx.save();
15815                    if (cameraFeedState.mirrored) {
15816                      zoomCtx.translate(zoomCanvas.width, 0);
15817                      zoomCtx.scale(-1, 1);
15818                    }
15819                    zoomCtx.drawImage(
15820                      cameraVideo,
15821                      (vw - cropW) / 2,
15822                      (vh - cropH) / 2,
15823                      cropW,
15824                      cropH,
15825                      0,
15826                      0,
15827                      zoomCanvas.width,
15828                      zoomCanvas.height,
15829                    );
15830                    zoomCtx.restore();
15831                  }
15832                  compositorFrame = requestAnimationFrame(drawCompositorFrame);
15833                };
15834                drawCompositorFrame();
15835                compactZoomCompositor = {
15836                  stop: () => cancelAnimationFrame(compositorFrame),
15837                };
15838                const compositorStream = zoomCanvas.captureStream(30);
15839                videoTrack = compositorStream.getVideoTracks()[0];
15840                recordedVideoSource = "camera-zoom-canvas";
15841              } else if (cameraTrack) {
15842                videoTrack = cameraTrack.clone();
15843                recordedVideoSource = "camera-track";
15844              } else {
15845                if (typeof canvas.captureStream !== "function") {
15846                  throw new Error("no live camera track or canvas capture stream");
15847                }
15848                const canvasStream = canvas.captureStream(30);
15849                videoTrack = canvasStream.getVideoTracks()[0];
15850                recordedVideoSource = "canvas";
15851              }
15852              if (!videoTrack) throw new Error("compact recorder has no video track");
15853
15854              compactRecorderStream = new MediaStream();
15855              compactRecorderStream.addTrack(videoTrack);
15856              audioStreamDest?.stream?.getAudioTracks().forEach((track) =>
15857                compactRecorderStream.addTrack(track.clone()),
15858              );
15859
15860              const videoSettings = videoTrack.getSettings?.() || {};
15861              const videoWidth = videoSettings.width || canvas.width;
15862              const videoHeight = videoSettings.height || canvas.height;
15863              const pixelsPerSecond = videoWidth * videoHeight * 30;
15864              const videoBitsPerSecond = Math.min(
15865                8_000_000,
15866                Math.max(1_500_000, Math.round(pixelsPerSecond * 0.12)),
15867              );
15868              mediaRecorder = new MediaRecorder(compactRecorderStream, {
15869                mimeType: compactMime,
15870                videoBitsPerSecond,
15871                // Dense keyframes keep scrub seeks ~constant-time however
15872                // long the tape runs; browsers without this option ignore it.
15873                videoKeyFrameIntervalDuration: 1000,
15874              });
15875              compactVideoRecording = true;
15876              recordedVideoMime = compactMime;
15877              send({
15878                type: "recorder:compact",
15879                content: {
15880                  phase: "ready",
15881                  mime: compactMime,
15882                  source: recordedVideoSource,
15883                  videoBitsPerSecond,
15884                  width: videoWidth,
15885                  height: videoHeight,
15886                  fps: 30,
15887                },
15888              });
15889              console.log("🎬 Compact video recorder ready:", {
15890                mime: compactMime,
15891                videoBitsPerSecond,
15892              });
15893            } catch (error) {
15894              console.warn("Compact video recording unavailable; using frame tape:", error);
15895              send({
15896                type: "recorder:compact",
15897                content: { phase: "fallback", reason: error?.message || String(error) },
15898              });
15899              clearCompactRecorderStream();
15900              mediaRecorder = undefined;
15901            }
15902          } else {
15903            send({
15904              type: "recorder:compact",
15905              content: { phase: "fallback", reason: "mp4-media-recorder-unsupported" },
15906            });
15907          }
15908        }
15909
15910        // Feature-detection fallback: retain the existing audio + frame tape.
15911        if (!mediaRecorder) {
15912          try {
15913            mediaRecorder = new MediaRecorder(audioStreamDest.stream, options);
15914            console.log("🎬 MediaRecorder created successfully:", mediaRecorder.state);
15915          } catch (error) {
15916            console.error("MediaRecorder creation failed:", error);
15917            return;
15918          }
15919        }
15920
15921        // Frame tapes need raw PCM for their custom scrub player. Compact
15922        // videos carry their audio in the encoded media stream.
15923        if (!compactVideoRecording && audioContext && sfxStreamGain) {
15924          try {
15925            rawAudioData = [];
15926            rawAudioSampleRate = audioContext.sampleRate;
15927            
15928            // Create a script processor node to capture raw audio
15929            rawAudioProcessor = audioContext.createScriptProcessor(4096, 2, 2);
15930            rawAudioProcessor.onaudioprocess = function(event) {
15931              // Only start capturing after MediaRecorder has started
15932              if (mediaRecorderStartTime === undefined) return;
15933              
15934              const inputBuffer = event.inputBuffer;
15935              const leftChannel = inputBuffer.getChannelData(0);
15936              const rightChannel = inputBuffer.getChannelData(1);
15937              
15938              const callbackMs = performance.now();
15939              const chunkStartMs = callbackMs - inputBuffer.duration * 1000;
15940              if (captureSession && captureSession.firstAudioMs === null) {
15941                captureSession.firstAudioMs = chunkStartMs;
15942                reportCaptureAVSync();
15943              }
15944
15945              // Store the audio data with timing information
15946              rawAudioData.push({
15947                left: new Float32Array(leftChannel),
15948                right: new Float32Array(rightChannel),
15949                timestamp: chunkStartMs - mediaRecorderStartTime,
15950              });
15951            };
15952            
15953            console.log("🎵 Raw audio capture prepared (will connect when recording starts)");
15954          } catch (error) {
15955            console.error("Raw audio capture setup failed:", error);
15956          }
15957        }
15958      }
15959
15960      // 🗺️ mediaRecorder:Start
15961      if (mediaRecorder) {
15962        console.log("🎬 Setting up MediaRecorder callbacks");
15963        mediaRecorder.onstart = function () {
15964          window.acVisits?.action("recording_started");
15965          // mediaRecorderResized = false;
15966          mediaRecorderStartTime = performance.now();
15967          captureSession = {
15968            id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
15969            firstVideoMs: null,
15970            firstAudioMs: null,
15971            reported: false,
15972          };
15973          // Initialize recording start timestamp for frame recording
15974          window.recordingStartTimestamp = Date.now();
15975          window.currentTapeProgress = 0;
15976          window.lastVideoProgress = 0;
15977          
15978          if (rawAudioProcessor && sfxStreamGain) {
15979            const connectRawAudio = (reason) => {
15980              if (rawAudioConnected || !rawAudioProcessor) return;
15981              try {
15982                sfxStreamGain.connect(rawAudioProcessor);
15983                micStreamGain?.connect(rawAudioProcessor);
15984                rawAudioProcessor.connect(audioContext.destination);
15985                rawAudioConnected = true;
15986                if (rawAudioStartInterval) clearInterval(rawAudioStartInterval);
15987                if (rawAudioStartTimeout) clearTimeout(rawAudioStartTimeout);
15988                rawAudioStartInterval = null;
15989                rawAudioStartTimeout = null;
15990                console.log(`🎵 Raw audio capture connected (${reason})`);
15991              } catch (error) {
15992                console.warn("Raw audio capture connection failed:", error);
15993              }
15994            };
15995
15996            if (recordingOptions.avSync) {
15997              // Camera takes capture from the recorder epoch. Waiting 100ms here
15998              // used to shift its entire soundtrack ahead of the film frames.
15999              connectRawAudio("camera recorder epoch");
16000            } else {
16001              const detectionStartTime = performance.now();
16002              rawAudioStartInterval = setInterval(() => {
16003                const progressStarted = window.currentTapeProgress > 0;
16004                const audioRouted = sfxStreamGain.numberOfOutputs > 0;
16005                if (
16006                  progressStarted ||
16007                  audioRouted ||
16008                  performance.now() - detectionStartTime > 100
16009                ) {
16010                  connectRawAudio("audio detected");
16011                }
16012              }, 16);
16013              rawAudioStartTimeout = setTimeout(
16014                () => connectRawAudio("timeout"),
16015                1000,
16016              );
16017            }
16018          }
16019          
16020          // Clear KidLisp FPS timeline for new recording
16021          window.kidlispFpsTimeline = [];
16022          console.log("🎬 Cleared KidLisp FPS timeline for new recording");
16023          
16024          // 🎵 ENSURE AUDIO CONTEXT IS ACTIVATED FOR PROGRAMMATIC PLAYBACK
16025          // This is critical for pieces like wipppps that need to auto-play during recording
16026          if (audioContext && audioContext.state !== 'running') {
16027            console.log("🎵 TAPE_START: Activating audio context for programmatic playback, current state:", audioContext.state);
16028            const resumeStartTime = performance.now();
16029            audioContext.resume().then(() => {
16030              const resumeEndTime = performance.now();
16031              window.audioContextResumeTimestamps.requestedAt = resumeStartTime;
16032              window.audioContextResumeTimestamps.completedAt = resumeEndTime;
16033              console.log("🎵 TAPE_START: Audio context resumed, new state:", audioContext.state);
16034              
16035              // Notify video piece of AudioContext state change
16036              send({ 
16037                type: "tape:audio-context-state", 
16038                content: { 
16039                  state: audioContext.state,
16040                  hasAudio: true
16041                } 
16042              });
16043            }).catch(error => {
16044              console.warn("🎵 TAPE_START: Audio context resume failed:", error.message);
16045            });
16046          } else if (audioContext) {
16047            console.log("🎵 TAPE_START: Audio context already running, state:", audioContext.state);
16048            // Even if running, explicitly call resume to ensure user gesture activation
16049            const resumeStartTime = performance.now();
16050            audioContext.resume().then(() => {
16051              const resumeEndTime = performance.now();
16052              window.audioContextResumeTimestamps.requestedAt = resumeStartTime;
16053              window.audioContextResumeTimestamps.completedAt = resumeEndTime;
16054              
16055              // Notify video piece of AudioContext state change
16056              send({ 
16057                type: "tape:audio-context-state", 
16058                content: { 
16059                  state: audioContext.state,
16060                  hasAudio: true
16061                } 
16062              });
16063            }).catch(error => {
16064              console.log("🎵 TAPE_START: Audio context re-resume (user gesture activation):", error.message);
16065            });
16066          }
16067          
16068          // Audio unlock tone removed - it was causing unwanted sound on piece load
16069          
16070          // 🎵 PRELOAD AND DECODE PIECE AUDIO FOR INSTANT PLAYBACK
16071          // For pieces like wipppps that need immediate audio, force decode their audio assets
16072          if (actualContent.pieceName === 'wipppps') {
16073            try {
16074              console.log("🎵 TAPE_START: Pre-decoding wipppps audio for instant playback");
16075              // Force decode the wipppps audio file to ensure it's ready as Web Audio buffer
16076              const wippppsUrl = "https://assets.aesthetic.computer/wipppps/zzzZWAP.wav";
16077              
16078              // Check if already decoded, if not, trigger decode
16079              if (!sfx[wippppsUrl] || typeof sfx[wippppsUrl] === 'string') {
16080                console.log("🎵 TAPE_START: Triggering decode for", wippppsUrl);
16081                decodeSfx(wippppsUrl).then(() => {
16082                  console.log("🎵 TAPE_START: wipppps audio decoded successfully as Web Audio buffer");
16083                }).catch(error => {
16084                  console.log("🎵 TAPE_START: wipppps audio decode failed:", error);
16085                });
16086              } else {
16087                console.log("🎵 TAPE_START: wipppps audio already decoded as Web Audio buffer");
16088              }
16089            } catch (error) {
16090              console.log("🎵 TAPE_START: wipppps audio pre-decode error:", error.message);
16091            }
16092          }
16093          
16094          console.log(`🎬 🔴 Recording STARTED at ${mediaRecorderStartTime}, frame capture enabled, recordedFrames: ${recordedFrames.length}`);
16095          
16096          send({
16097            type: "recorder:rolling:started",
16098            content: {
16099              mime: mediaRecorder.mimeType,
16100              time: audioContext?.currentTime,
16101              cleanMode: recordingOptions.cleanMode,
16102            },
16103          });
16104          if (debug && logs.recorder)
16105            console.log("🔴 Recorder: Rolling", actualContent);
16106
16107          // window.addEventListener("resize", () => (mediaRecorderResized = true), {
16108          // once: true,
16109          // });
16110        };
16111      }
16112
16113      // 🗺️ mediaRecorder:Stop (Recorder Printing)
16114      if (mediaRecorder) {
16115        mediaRecorder.onstop = async function (evt) {
16116          stop();
16117        // recordingDuration = (performance.now() - recordingStartTime) / 1000;
16118
16119        // Reset global streamCanvas state.
16120        // streamCanvasContext = undefined;
16121        // resizeToStreamCanvas = null;
16122
16123        // let blob = new Blob(chunks, {
16124        //  type: options.mimeType,
16125        // });
16126
16127        // Load FFmpeg so the recording can be transcribed to a proper video format.
16128        // if (content === "video") {
16129        //   if (options.mimeType === "video/mp4") {
16130        //     console.warn("Encoding can be skipped!");
16131        //     // TODO: Skip encoding.
16132        //   }
16133
16134        //   const { createFFmpeg, fetchFile } = await loadFFmpeg();
16135
16136        //   let transcodeProgress = 0;
16137
16138        //   const ffmpeg = createFFmpeg({
16139        //     log: debug,
16140        //     progress: (p) => {
16141        //       // Send a message to the piece that gives the transcode progress.
16142        //       let time = p.time;
16143        //       if (time === undefined) {
16144        //         if (transcodeProgress === 0) {
16145        //           time = 0;
16146        //         } else {
16147        //           time = recordingDuration;
16148        //         }
16149        //       }
16150        //       transcodeProgress = min(1, time / recordingDuration);
16151        //       send({
16152        //         type: "recorder:transcode-progress",
16153        //         content: transcodeProgress,
16154        //       });
16155        //     },
16156        //   });
16157
16158        //   ffmpeg.setLogging(debug); // Enable ffmpeg logging only if we are in `debug` mode.
16159
16160        //   await ffmpeg.load();
16161        //   ffmpeg.FS("writeFile", "input.video", await fetchFile(blob));
16162
16163        //   await ffmpeg.run(
16164        //     "-i",
16165        //     "input.video",
16166        //     "-movflags",
16167        //     "+faststart",
16168        //     "-vf",
16169        //     // General shaving to make even sides (required by the pixel format)
16170        //     // "pad=ceil(iw/2)*2:ceil(ih/2)*2",
16171        //     // TikTok
16172        //     //"fps=30, scale=1080x1920:flags=neighbor:force_original_aspect_ratio=decrease, pad=1080:1920:(ow-iw)/2:(oh-ih)/2",
16173        //     "fps=30",
16174        //     "output.mp4",
16175        //   );
16176        //   // Notes on these options:
16177        //   // width expression: https://stackoverflow.com/a/20848224/8146077
16178        //   // scaling: https://trac.ffmpeg.org/wiki/Scaling
16179        //   // general info: https://avpres.net/FFmpeg/im_H264
16180
16181        //   const file = ffmpeg.FS("readFile", "output.mp4");
16182
16183        //   blob = new Blob([file.buffer], { type: "video/mp4" }); // Re-assign blob.
16184        // }
16185
16186        // Add the recording wrapper to the DOM, among other recordings that may exist.
16187
16188        // if (debug) console.log("📼 Recorder: Printed");
16189
16190        // mediaRecorderBlob = blob;
16191
16192        // TODO: Store an index into the blob if its an audio clip or loaded sample.
16193
16194        // send({ type: "recorder:printed", content: { id: "test-sample-id" } });
16195        // TODO: Can send the download code back here...
16196        // send({ type: "recorder:uploaded", code });
16197
16198        // mediaRecorderBlob = new Blob(mediaRecorderChunks, {
16199        //   type: mediaRecorder.mimeType,
16200        // });
16201
16202        // if (content === "video") {
16203        //   await receivedChange({
16204        //     data: {
16205        //       type: "store:persist",
16206        //       content: {
16207        //         key: "tape",
16208        //         method: "local:db",
16209        //         data: {
16210        //           blob: mediaRecorderBlob,
16211        //           duration: mediaRecorderDuration,
16212        //         },
16213        //       },
16214        //     },
16215        //   });
16216        // }
16217
16218        // send({ type: "recorder:rolling:ended" });
16219        };
16220
16221        mediaRecorder.ondataavailable = function (e) {
16222          if (e.data && e.data.size > 0) mediaRecorderChunks.push(e.data);
16223        };
16224
16225        // Always cut off mediaRecorders on unload.
16226        window.addEventListener("unload", function () {
16227          mediaRecorder?.stop();
16228        });
16229
16230        window.addEventListener("beforeunload", function () {
16231          mediaRecorder?.stop();
16232        });
16233      }
16234
16235      //if (content === "video") {
16236      // Start media recorder once svg loads.
16237      //svgCursor.onload = function (e) {
16238      // Use small chunk sizes. (`1000` broke TikTok)
16239      //  mediaRecorder.start(100);
16240      //};
16241      //svgCursor.src = "/aesthetic.computer/cursors/precise.svg";
16242      //} else {
16243      // console.log("Start audio recording...");
16244
16245      // Clear any cached tape data and audio before starting new recording
16246      Store.del("tape").catch(() => {}); // Clear cache, ignore errors
16247      delete sfx["tape:audio"]; // Clear any cached audio data
16248      mediaRecorderDuration = 0; // Reset duration for new recording
16249      mediaRecorderFrameCount = 0; // Reset frame capture counter for optimization
16250
16251      console.log("🎬 Starting MediaRecorder, state before start:", mediaRecorder.state);
16252      // One-second encoded chunks keep event overhead low for c
16252ompact camera
16253      // clips. Frame tapes retain their existing cadence for compatibility.
16254      mediaRecorder.start(compactVideoRecording ? 1000 : 100);
16255      console.log("🎬 MediaRecorder.start() called, state after start:", mediaRecorder.state);
16256      //}
16257      return;
16258    }
16259
16260    if (type === "recorder:present") {
16261      // Compact camera tapes are already playable media. Present the Blob
16262      // directly instead of rebuilding a timeline from retained ImageData.
16263      if (!recordedVideoBlob) {
16264        try {
16265          const cachedTape = await Store.get("tape");
16266          if (cachedTape?.kind === "video" && cachedTape.blob) {
16267            recordedVideoBlob = cachedTape.blob;
16268            recordedVideoMime = cachedTape.mime || cachedTape.blob.type || "video/mp4";
16269            recordedVideoSource = cachedTape.source || "canvas";
16270            mediaRecorderDuration = cachedTape.duration || 0;
16271          }
16272        } catch (error) {
16273          console.warn("Could not restore compact video tape:", error);
16274        }
16275      }
16276
16277      // Re-present of the tape that's already on screen (the cut handler
16278      // auto-presents before the video piece finishes booting) — keep it
16279      // rolling instead of rebuilding and restarting the element.
16280      if (
16281        recordedVideoBlob &&
16282        mp4PlaybackVideo &&
16283        mp4PlaybackVideo.isConnected &&
16284        compactPlaybackUrl &&
16285        mp4PlaybackVideo.src === compactPlaybackUrl
16286      ) {
16287        if (!content?.noplay && mp4PlaybackVideo.paused) {
16288          mp4PlaybackVideo.play().catch(() => {});
16289        }
16290        send({ type: "recorder:presented" });
16291        send({
16292          type: mp4PlaybackVideo.paused
16293            ? "recorder:present-paused"
16294            : "recorder:present-playing",
16295        });
16296        return;
16297      }
16298
16299      if (recordedVideoBlob) {
16300        // Present instantly — audio decoding scales with tape length, so it
16301        // runs in the background below and must never gate first playback.
16302        // Until the decoded soundtrack lands, the media element carries audio.
16303        let tapeAudioReady = false;
16304        stopTapePlayback?.();
16305        underlayFrame?.remove();
16306        underlayFrame = document.createElement("div");
16307        underlayFrame.id = "underlay";
16308        underlayFrame.style.cssText =
16309          "position: fixed; top: 0; left: 0; width: 100%; height: 100%; " +
16310          "z-index: -1; pointer-events: none; background: black;";
16311
16312        const video = document.createElement("video");
16313        mp4PlaybackVideo = video;
16314        if (compactPlaybackUrl) URL.revokeObjectURL(compactPlaybackUrl);
16315        compactPlaybackUrl = URL.createObjectURL(recordedVideoBlob);
16316        video.src = compactPlaybackUrl;
16317        video.playsInline = true;
16318        video.setAttribute("playsinline", "");
16319        video.loop = true;
16320        video.muted = false; // Falls back to muted if unmuted autoplay is refused.
16321        video.controls = false;
16322        video.preload = "auto";
16323        video.style.cssText =
16324          "position: absolute; inset: 0; width: 100%; height: 100%; " +
16325          `object-fit: contain; image-rendering: ${
16326            recordedVideoSource === "canvas" ? "pixelated" : "auto"
16327          };`;
16328
16329        let playbackFrame = 0;
16330        const transmitProgress = () => {
16331          if (mp4PlaybackVideo !== video || !video.isConnected) return;
16332          if (Number.isFinite(video.duration) && video.duration > 0) {
16333            currentTapePosition = video.currentTime / video.duration;
16334            send({
16335              type: "recorder:present-progress",
16336              content: video.currentTime / video.duration,
16337            });
16338          }
16339          playbackFrame = requestAnimationFrame(transmitProgress);
16340        };
16341
16342        stopTapePlayback = () => {
16343          cancelAnimationFrame(playbackFrame);
16344          stopTapeAudioLoops();
16345          try { video.pause(); } catch {}
16346        };
16347        pauseTapePlayback = () => {
16348          stopTapeAudioLoops();
16349          video.pause();
16350        };
16351        resumeTapePlayback = () => {
16352          const progress = video.duration > 0 ? video.currentTime / video.duration : 0;
16353          video.play().catch(() => {});
16354          if (tapeAudioReady && startPreparedTapeAudio(progress)) {
16355            video.muted = true; // Web Audio owns the soundtrack from here.
16356          }
16357        };
16358        seekTapePlayback = (
16359          progress,
16360          { scrubbing = false, scrubEnd = false, speedScrub = false } = {},
16361        ) => {
16362          if (!Number.isFinite(video.duration) || video.duration <= 0) return;
16363          const clamped = Math.max(0, Math.min(1, progress));
16364          currentTapePosition = clamped;
16365          const t = clamped * video.duration;
16366          if (
16367            (speedScrub || scrubbing) &&
16368            !scrubEnd &&
16369            typeof video.fastSeek === "function"
16370          ) {
16371            // Mid-scrub: nearest-keyframe seek — constant-time no matter how
16372            // long the tape is. The precise seek lands at scrub end.
16373            video.fastSeek(t);
16374          } else {
16375            video.currentTime = t;
16376          }
16377          if (
16378            !speedScrub &&
16379            tapeAudioReady &&
16380            !video.paused &&
16381            (scrubEnd || !scrubbing)
16382          ) {
16383            if (startPreparedTapeAudio(clamped)) video.muted = true;
16384          }
16385        };
16386
16387        video.onloadedmetadata = () => {
16388          const duration = Number.isFinite(video.duration)
16389            ? video.duration
16390            : mediaRecorderDuration / 1000;
16391          send({
16392            type: "recorder:compact",
16393            content: {
16394              phase: "preview",
16395              mime: recordedVideoMime || recordedVideoBlob.type || "video/mp4",
16396              bytes: recordedVideoBlob.size,
16397              duration,
16398              source: recordedVideoSource,
16399            },
16400          });
16401          send({
16402            type: "tape:info-reply",
16403            content: {
16404              frameCount: 0,
16405              totalDuration: duration,
16406              hasAudio: true,
16407              kind: "video",
16408              pieceName: window.currentRecordingOptions?.pieceName || "cap",
16409            },
16410          });
16411        };
16412        video.onplay = () => send({ type: "recorder:present-playing" });
16413        video.onpause = () => send({ type: "recorder:present-paused" });
16414        video.onerror = () => {
16415          console.error("Compact video tape failed to load");
16416          send({ type: "recorder:presented:failure" });
16417        };
16418
16419        // Background soundtrack decode for scrub/loop audio. When it lands,
16420        // Web Audio takes over from the media element mid-play.
16421        prepareMp4TapeAudio(recordedVideoBlob, "fresh compact tape")
16422          .then((decoded) => {
16423            if (mp4PlaybackVideo !== video) return; // Superseded by a re-present.
16424            tapeAudioReady = true;
16425            send({
16426              type: "recorder:compact",
16427              content: {
16428                phase: "audio-ready",
16429                duration: decoded.duration,
16430                channels: decoded.numberOfChannels,
16431                sampleRate: decoded.sampleRate,
16432              },
16433            });
16434            send({
16435              type: "tape:audio-context-state",
16436              content: {
16437                state: audioContext?.state || "suspended",
16438                hasAudio: true,
16439                duration: decoded.duration,
16440              },
16441            });
16442            if (!video.paused) {
16443              const progress =
16444                Number.isFinite(video.duration) && video.duration > 0
16445                  ? video.currentTime / video.duration
16446                  : 0;
16447              if (startPreparedTapeAudio(progress)) video.muted = true;
16448            }
16449          })
16450          .catch((error) => {
16451            console.warn(
16452              "📼 Compact tape audio decode failed; using media element audio:",
16453              error,
16454            );
16455          });
16456
16457        underlayFrame.appendChild(video);
16458        document.body.insertBefore(underlayFrame, document.body.firstChild);
16459
16460        if (freezeFrame || freezeFrameFrozen || wrapper.contains(freezeFrameCan)) {
16461          freezeFrameCan.remove();
16462          freezeFrame = false;
16463          freezeFrameGlaze = false;
16464          freezeFrameFrozen = false;
16465        }
16466        const glazeCan = Glaze.getCan();
16467        if (glazeCan) glazeCan.style.display = "none";
16468        if (webglCompositeCanvas) webglCompositeCanvas.style.display = "none";
16469        if (overlayCan) overlayCan.style.display = "none";
16470        ctx.clearRect(0, 0, canvas.width, canvas.height);
16471        canvas.style.visibility = "visible";
16472        document.body.style.background = "transparent";
16473        document.body.style.backgroundImage = "none";
16474        wrapper.style.background = "transparent";
16475
16476        send({ type: "recorder:presented" });
16477        playbackFrame = requestAnimationFrame(transmitProgress);
16478        if (!content?.noplay) {
16479          video.play().catch(() => {
16480            // Unmuted autoplay can be refused once the tap's gesture window
16481            // closes. Keep the motion instant; the decoded soundtrack takes
16482            // over the audio when it lands.
16483            video.muted = true;
16484            video.play().catch(() => send({ type: "recorder:present-paused" }));
16485          });
16486        } else send({ type: "recorder:present-paused" });
16487        return;
16488      }
16489
16490      // Check for cached video if no active recording AND no recorded frames
16491      if (
16492        (!mediaRecorder || mediaRecorder.state !== "paused") &&
16493        recordedFrames.length === 0
16494      ) {
16495        const cachedTape = await Store.get("tape");
16496        
16497        if (cachedTape && cachedTape.blob) {
16498          console.log("🎬 Cached tape structure:", {
16499            hasBlob: !!cachedTape.blob,
16500            blobSize: cachedTape.blob?.size,
16501            blobType: cachedTape.blob?.type,
16502            hasRawAudio: !!cachedTape.rawAudio,
16503            rawAudioSamples: cachedTape.rawAudio?.totalSamples,
16504            rawAudioLeftType: cachedTape.rawAudio?.left?.constructor?.name,
16505            rawAudioRightType: cachedTape.rawAudio?.right?.constructor?.name,
16506            frameCount: cachedTape.frames?.length,
16507            duration: cachedTape.duration,
16508            hasAudioContext: !!audioContext
16509          });
16510          
16511          // Use raw audio arrays to create AudioBuffer for playback if available
16512          if (cachedTape.rawAudio && audioContext) {
16513            try {
16514              console.log("🎬 📼 Creating AudioBuffer from raw audio data");
16515              const rawAudio = cachedTape.rawAudio;
16516              const audioBuffer = audioContext.createBuffer(2, rawAudio.totalSamples, rawAudio.sampleRate);
16517              
16518              audioBuffer.getChannelData(0).set(rawAudio.left);
16519              audioBuffer.getChannelData(1).set(rawAudio.right);
16520              
16521              sfx["tape:audio"] = audioBuffer;
16522              console.log(`🎬 📼 Successfully created AudioBuffer: ${rawAudio.totalSamples} samples, ${rawAudio.totalSamples / rawAudio.sampleRate}s duration`);
16523            } catch (error) {
16524              console.error("🎬 📼 Failed to create AudioBuffer from raw audio:", error);
16525              // Fall back to blob for export only
16526              sfx["tape:audio"] = await blobToArrayBuffer(cachedTape.blob);
16527            }
16528          } else if (cachedTape.rawAudio && !audioContext) {
16529            // Have rawAudio but no audioContext yet - store it for later conversion
16530            console.warn("🎬 📼 Raw audio available but no AudioContext yet - will create on first play");
16531            sfx["tape:audio"] = cachedTape.rawAudio; // Store rawAudio object directly
16532          } else if (audioContext) {
16533            // Try to decode the compressed audio blob
16534            try {
16535              console.log("🎬 📼 No raw audio available, attempting to decode compressed audio blob");
16536              const arrayBuffer = await blobToArrayBuffer(cachedTape.blob);
16537              const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
16538              sfx["tape:audio"] = audioBuffer;
16539              console.log(`🎬 📼 Successfully decoded audio: ${audioBuffer.duration}s, ${audioBuffer.numberOfChannels} channels`);
16540            } catch (error) {
16541              console.error("🎬 📼 Failed to decode audio blob:", error);
16542              // Store the blob ArrayBuffer for export only (won't be playable)
16543              sfx["tape:audio"] = await blobToArrayBuffer(cachedTape.blob);
16544              console.log("🎬 📼 Stored blob for export only (not playable)");
16545            }
16546          } else {
16547            // Store the blob for export purposes but we won't be able to play it
16548            sfx["tape:audio"] = await blobToArrayBuffer(cachedTape.blob);
16549          }
16550
16551          // Restore frame data if available
16552          if (cachedTape.frames) {
16553            recordedFrames.length = 0;
16554            recordedFrames.push(...cachedTape.frames);
16555          }
16556
16557          // Set duration if available
16558          if (cachedTape.duration) {
16559            mediaRecorderDuration = cachedTape.duration;
16560          }
16561        }
16562      }
16563
16564      if (
16565        (mediaRecorder && mediaRecorder.state === "paused") ||
16566        recordedFrames.length > 0
16567      ) {
16568        // Handle live recording or cached video
16569        if (mediaRecorder && mediaRecorder.state === "paused") {
16570          const blob = new Blob(mediaRecorderChunks, {
16571            type: mediaRecorder.mimeType,
16572          });
16573          
16574          // Use raw audio data for live playback
16575          if (rawAudioData.length > 0 && audioContext) {
16576            try {
16577              console.log("🎬 📼 Creating AudioBuffer from live raw audio data");
16578              const totalSamples = rawAudioData.length * 4096; // 4096 samples per chunk
16579              const audioBuffer = audioContext.createBuffer(2, totalSamples, rawAudioSampleRate);
16580              
16581              const leftChannelData = audioBuffer.getChannelData(0);
16582              const rightChannelData = audioBuffer.getChannelData(1);
16583              
16584              let sampleIndex = 0;
16585              for (let i = 0; i < rawAudioData.length; i++) {
16586                const chunk = rawAudioData[i];
16587                for (let j = 0; j < chunk.left.length; j++) {
16588                  if (sampleIndex < totalSamples) {
16589                    leftChannelData[sampleIndex] = chunk.left[j];
16590                    rightChannelData[sampleIndex] = chunk.right[j];
16591                    sampleIndex++;
16592                  }
16593                }
16594              }
16595              
16596              sfx["tape:audio"] = audioBuffer;
16597              console.log(`🎬 📼 Successfully created live AudioBuffer: ${totalSamples} samples, ${totalSamples / rawAudioSampleRate}s duration`);
16598            } catch (error) {
16599              console.error("🎬 📼 Failed to create live AudioBuffer:", error);
16600              // Store compressed data as fallback for export
16601              sfx["tape:audio"] = await blobToArrayBuffer(blob);
16602            }
16603          } else {
16604            console.log("🎬 📼 No raw audio data available, storing compressed blob for export only");
16605            sfx["tape:audio"] = await blobToArrayBuffer(blob);
16606          }
16607        }
16608
16609        underlayFrame = document.createElement("div");
16610        underlayFrame.id = "underlay";
16611
16612        const frameCan = document.createElement("canvas");
16613        const fctx = frameCan.getContext("2d");
16614
16615        if (recordedFrames.length > 0) {
16616          frameCan.width = recordedFrames[0][1].width;
16617          frameCan.height = recordedFrames[0][1].height;
16618        }
16619
16620        // CSS handles layout: `canvas` rule gives position:absolute; top:0; left:0,
16621        // and `#underlay canvas` gives width:100%; height:100% filling the fixed parent.
16622        // object-fit:contain was removed from #underlay canvas to prevent letterboxing.
16623
16624        startTapePlayback = (
16625          transmitProgress = true,
16626          doneCb,
16627          stream,
16628          render,
16629          overrideDurationMs = null, // Optional duration override for MP4 export
16630        ) => {
16631          if (recordedFrames.length === 0) {
16632            console.error("🎬 ❌ No recorded frames to play back!");
16633            return;
16634          }
16635          
16636          // Use overrideDurationMs if provided (for MP4 export), otherwise use mediaRecorderDuration
16637          const playbackDurationMs = overrideDurationMs || mediaRecorderDuration;
16638          
16639          let f = 0;
16640          let playbackStart;
16641          let playbackProgress = 0;
16642          let continuePlaying = true;
16643          let stopped = false;
16644          // Track total recording time for video export duration control
16645          let totalRecordingStart = null;
16646
16647          let tapeSoundId;
16648
16649          stopTapePlayback = () => {
16650            continuePlaying = false;
16651            stopped = true;
16652            // Kill all tape audio instances
16653            Object.keys(sfxPlaying).forEach(id => {
16654              if (id.startsWith("tape:audio_")) {
16655                sfxPlaying[id]?.kill();
16656                delete sfxPlaying[id];
16657              }
16658            });
16659          };
16660
16661          let pauseStart;
16662          let pausedAtPosition; // Store the position when paused
16663          let isResuming = false; // Track if we're resuming from pause
16664
16665          pauseTapePlayback = () => {
16666            continuePlaying = false;
16667            pauseStart = performance.now();
16668            // Store current playback position
16669            pausedAtPosition = (pauseStart - playbackStart) / mediaRecorderDuration;
16670            
16671            // Kill all tape audio instances since pause is not available
16672            Object.keys(sfxPlaying).forEach(id => {
16673              if (id.startsWith("tape:audio_")) {
16674                sfxPlaying[id]?.kill();
16675                delete sfxPlaying[id];
16676              }
16677            });
16678            send({ type: "recorder:present-paused" });
16679          };
16680
16681          resumeTapePlayback = () => {
16682            if (stopped) {
16683              send({ type: "recorder:present-playing" });
16684              return startTapePlayback(true);
16685            }
16686            continuePlaying = true;
16687            isResuming = true; // Flag that we're resuming to prevent audio restart
16688
16689            // Mark the resume time to prevent timing adjustment compensation
16690            const resumeTime = performance.now();
16691            window.tapeResumeTimestamp = resumeTime;
16692
16693            // Calculate how long we were paused and adjust playback start time FIRST
16694            const pauseDuration = resumeTime - pauseStart;
16695            playbackStart += pauseDuration;
16696
16697            // Restart the audio from the paused position
16698            const workletReady = window.audioWorkletReady === true;
16699            const audioContextReady = audioContext?.state === "running";
16700            if (!render && workletReady && audioContextReady && mediaRecorderDuration > 0) {
16701              const clampedPosition = Math.max(0, Math.min(1, pausedAtPosition || 0));
16702
16703              // Kill any existing tape audio before starting new one
16704              Object.keys(sfxPlaying).forEach(id => {
16705                if (id.startsWith("tape:audio_")) {
16706                  sfxPlaying[id]?.kill();
16707                  delete sfxPlaying[id];
16708                }
16709              });
16710
16711              tapeSoundId = startTapeAudioLoop(clampedPosition);
16712            }
16713
16714            window.requestAnimationFrame(update);
16715            send({ type: "recorder:present-playing" });
16716          };
16717
16718          // Track scrubbing state to avoid constant audio restarts
16719          let isScrubbing = false;
16720          let lastSeekProgress = 0;
16721          
16722          seekTapePlayback = (progress, { scrubbing = false, scrubEnd = false, speedScrub = false } = {}) => {
16723            // Seek to a specific position in the tape (0.0 to 1.0)
16724            // If scrubbing=true, we mute audio instead of restarting it every frame
16725            // If scrubEnd=true, we sync audio to final position
16726            // If speedScrub=true, just seek frame — audio managed externally via tape:audio-shift
16727            
16728            // Clamp progress to valid range
16729            progress = Math.max(0, Math.min(1, progress));
16730            
16731            // Calculate target frame
16732            const targetFrame = Math.floor(progress * recordedFrames.length);
16733            f = Math.max(0, Math.min(recordedFrames.length - 1, targetFrame));
16734            
16735            // Update playback progress
16736            playbackProgress = progress;
16737            pausedAtPosition = progress;
16738            lastSeekProgress = progress;
16739            
16740            // Adjust playback start time to match new position
16741            const currentTime = performance.now();
16742            playbackStart = currentTime - (progress * playbackDurationMs);
16743            
16744            // Update display with new frame (placeholders render on demand)
16745            if (recordedFrames[f]) {
16746              const seekPic = recordedFrames[f][1];
16747              if (seekPic?.synth !== undefined && window.__synthFrameDraw) {
16748                window.__synthFrameDraw(seekPic.synth, fctx);
16749              } else {
16750                fctx.putImageData(seekPic, 0, 0);
16751              }
16752              window.__lastTapeDrawnF = f;
16753              send({ type: "recorder:present-progress", content: progress });
16754            }
16755
16756            // Speed-scrub mode: just moved the frame, don't touch audio
16757            if (speedScrub) return;
16758
16759            const workletReady = window.audioWorkletReady === true;
16760            const audioContextReady = audioContext?.state === "running";
16761            
16762            // Handle scrubbing state transitions
16763            if (scrubbing && !isScrubbing) {
16764              // Starting scrub - mute audio but don't kill it
16765              isScrubbing = true;
16766              Object.keys(sfxPlaying).forEach(id => {
16767                if (id.startsWith("tape:audio_")) {
16768                  sfxPlaying[id]?.kill();
16769                  delete sfxPlaying[id];
16770                }
16771              });
16772            } else if (scrubEnd && isScrubbing) {
16773              // Ending scrub - restart audio at final position
16774              isScrubbing = false;
16775              if (!render && workletReady && audioContextReady && mediaRecorderDuration > 0 && continuePlaying) {
16776                tapeSoundId = startTapeAudioLoop(progress);
16777              }
16778            } else if (!scrubbing && !isScrubbing) {
16779              // Normal seek (not scrubbing) - sync audio immediately
16780              if (!render && workletReady && audioContextReady && mediaRecorderDuration > 0) {
16781                // Kill existing audio
16782                Object.keys(sfxPlaying).forEach(id => {
16783                  if (id.startsWith("tape:audio_")) {
16784                    sfxPlaying[id]?.kill();
16785                    delete sfxPlaying[id];
16786                  }
16787                });
16788
16789                // Restart audio from new position if currently playing
16790                if (continuePlaying) {
16791                  tapeSoundId = startTapeAudioLoop(progress);
16792                }
16793              }
16794            }
16795            // During active scrubbing (scrubbing=true, isScrubbing=true), we don't touch audio
16796          };
16797
16798          async function update() {
16799            if (!continuePlaying || !underlayFrame) {
16800              return;
16801            }
16802
16803            // Hang detection: track time spent in this update cycle
16804            const updateStartTime = performance.now();
16805            let frameProcessingTime = 0;
16806            
16807            // Update last activity time for watchdog
16808            window.lastVideoUpdateTime = updateStartTime;
16809            
16810            // Handle tab visibility changes - pause time accumulation when hidden
16811            if (document.hidden && !window.tapePlaybackPaused) {
16812              window.tapePlaybackPaused = true;
16813              window.tapePlaybackPauseTime = updateStartTime;
16814            } else if (!document.hidden && window.tapePlaybackPaused) {
16815              // Tab became visible again - adjust playbackStart to skip the hidden time
16816              const pausedDuration = updateStartTime - window.tapePlaybackPauseTime;
16817              playbackStart += pausedDuration;
16818              window.tapePlaybackPaused = false;
16819              console.log(`⏸️ Tab was hidden for ${pausedDuration.toFixed(0)}ms, adjusted playback timing`);
16820            }
16821
16822            if (f === 0) {
16823              // Reset pause state at beginning
16824              window.tapePlaybackPaused = false;
16825              window.tapePlaybackPauseTime = 0;
16826              
16827              // Set playback timing BEFORE starting audio to ensure sync
16828              if (!isResuming) {
16829                playbackStart = performance.now();
16830                playbackProgress = 0;
16831              }
16832              
16833              // Clear resume flag after handling frame 0
16834              isResuming = false;
16835            }
16836
16837            // Handle audio playback - check if audio should be playing but isn't
16838            const shouldHaveAudio = !render && mediaRecorderDuration > 0;
16839            const hasAudioPlaying = Object.keys(sfxPlaying).some(id => id.startsWith("tape:audio_"));
16840            const audioContextReady = audioContext?.state === "running";
16841            const workletReady = window.audioWorkletReady === true;
16842            
16843            if (shouldHaveAudio && !hasAudioPlaying && audioContextReady && workletReady) {
16844              // Calculate CURRENT position BEFORE any decoding
16845              const currentPlaybackProgress = performance.now() - playbackStart; // ms
16846              const currentAudioPosition = mediaRecorderDuration > 0
16847                ? Math.max(0, Math.min(1, currentPlaybackProgress / mediaRecorderDuration))
16848                : 0;
16849              
16850              // Check if we need to re-decode with running AudioContext
16851              if (window.tapeAudioArrayBuffer && !window.tapeAudioDecodedWithRunningContext) {
16852                try {
16853                  const freshAudioBuffer = await audioContext.decodeAudioData(window.tapeAudioArrayBuffer.slice());
16854                  sfx["tape:audio"] = freshAudioBuffer;
16855                  window.tapeAudioDecodedWithRunningContext = true;
16856                } catch (error) {
16857                  console.error("🎵 Fresh decode failed:", error);
16858                }
16859              }
16860              
16861              // Use the position we calculated BEFORE decoding
16862              const audioPosition = currentAudioPosition;
16863              
16864              // Update global tape position for auto-start sync
16865              currentTapePosition = audioPosition;
16866              
16867              // Kill any existing tape audio before starting new one
16868              Object.keys(sfxPlaying).forEach(id => {
16869                if (id.startsWith("tape:audio_")) {
16870                  sfxPlaying[id]?.kill();
16871                  delete sfxPlaying[id];
16872                }
16873              });
16874              
16875              tapeSoundId = startTapeAudioLoop(audioPosition);
16876            }
16877
16878            // 🎞️ Sound-on-film: once the worklet reports its sample read head,
16879            // that sample range owns the displayed frame. Audio is the single
16880            // clock, so RAF jitter and repeated loops cannot drift picture away.
16881            let soundOnFilmProgress = null;
16882            if (!render && !isScrubbing && recordedFrames[0]?.[3]) {
16883              const activeTapeSoundId =
16884                tapeSoundId ||
16885                Object.keys(sfxPlaying).find((id) => id.startsWith("tape:audio_"));
16886              const audioProgress = activeTapeSoundId
16887                ? sfxPlaying[activeTapeSoundId]?.progress?.()
16888                : null;
16889              if (Number.isFinite(audioProgress)) {
16890                soundOnFilmProgress = ((audioProgress % 1) + 1) % 1;
16891                const filmFrame = frameIndexForSoundtrackProgress(
16892                  recordedFrames,
16893                  soundOnFilmProgress,
16894                );
16895                if (filmFrame >= 0) f = filmFrame;
16896                playbackProgress = soundOnFilmProgress * playbackDurationMs;
16897                playbackStart = performance.now() - playbackProgress;
16898              }
16899            }
16900
16901            // Resize fctx here if the width and
16902            // height is different.
16903            const pic = recordedFrames[f][1];
16904            if (
16905              fctx.canvas.width !== pic.width ||
16906              fctx.canvas.height !== pic.height
16907            ) {
16908              fctx.canvas.width = pic.width;
16909              fctx.canvas.height = pic.height;
16910            }
16911
16912            // Only blit when the frame actually advanced — a 30fps tape on
16913            // a 60-144Hz display wastes 50-80% of putImageData calls
16914            // otherwise (per-pane main-thread canvas work). Synth frames
16915            // are placeholders rendered on demand (memory: ~10KB vs 55MB).
16916            if (f !== window.__lastTapeDrawnF || fctx.__lastTapeCanvas !== fctx.canvas) {
16917              const rafPic = recordedFrames[f][1];
16918              if (rafPic?.synth !== undefined && window.__synthFrameDraw) {
16919                window.__synthFrameDraw(rafPic.synth, fctx);
16920              } else {
16921                fctx.putImageData(rafPic, 0, 0);
16922              }
16923              window.__lastTapeDrawnF = f;
16924              fctx.__lastTapeCanvas = fctx.canvas;
16925            }
16926
16927            // 📼 Render multi-tape manager frames (for tv.mjs smooth transitions)
16928            if (tapeManager) {
16929              tapeManager.renderFrame(performance.now());
16930            }
16931
16932              const renderStartTime = performance.now();
16933            
16934              try {
16935                render?.(frameCan, playbackProgress / playbackDurationMs, f); // Render a video as needed, using this canvas and current frame index.
16936              } catch (error) {
16937                console.error(`Render function failed at frame ${f} (${((f / (recordedFrames.length - 1)) * 100).toFixed(2)}%):`, error);
16938                // Continue processing even if render fails
16939              }
16940            
16941              frameProcessingTime = performance.now() - renderStartTime;
16942            
16943            // Enhanced warning for slow frame processing in critical zone
16944            if (frameProcessingTime > 200) {
16945              console.warn(`Slow frame processing: ${frameProcessingTime.toFixed(2)}ms for frame ${f}`);
16946            }
16947            
16948            // Check for hang in critical 40% zone
16949            const currentProgress = f / (recordedFrames.length - 1);
16950            
16951            // Update global tape position for auto-start sync
16952            currentTapePosition = currentProgress;
16953            
16954            if (currentProgress > 0.35 && currentProgress < 0.45 && frameProcessingTime > 200) {
16955              console.warn(`CRITICAL ZONE SLOW PROCESSING: ${frameProcessingTime.toFixed(2)}ms at ${(currentProgress * 100).toFixed(2)}% (frame ${f})`);
16956            }
16957
16958            playbackProgress = performance.now() - playbackStart;
16959
16960            // Advance frames.
16961            if (f === 0) {
16962              f += 1;
16963            }
16964            
16965            // Convert absolute timestamps back to relative timestamps for playback timing
16966            const firstFrameTimestamp = recordedFrames[0][0];
16967            
16968            // Calculate how long we should wait for the current frame using original recorded timing
16969            let targetFrameTime = recordedFrames[f][0] - firstFrameTimestamp;
16970            
16971            // Additional debug to check if timestamps look reasonable
16972            if (f === 1) {
16973              const frameDiff = recordedFrames[1][0] - recordedFrames[0][0];
16974              if (frameDiff > 10000) {
16975                console.warn(`🎬 ⚠️ Frame timestamps seem to be absolute timestamps (${frameDiff}ms gap), this will cause playback issues!`);
16976              }
16977            }
16978            
16979            // Advance frames while playback has progressed past the current frame's time
16980            if (soundOnFilmProgress !== null && !render) {
16981              // The soundtrack read head selected `f` above.
16982            } else if (isScrubbing && !render) {
16983              // While the piece is speed-scrubbing, its seeks own the frame
16984              // index — the RAF clock must not advance frames at 1× between
16985              // seeks (at slow rates that read as jitter and inaccuracy).
16986            } else if (f >= recordedFrames.length - 1) {
16987              // For video export, don't loop - complete when all frames are processed
16988              if (doneCb && render) {
16989                console.log(`🎬 📹 Video export reaching completion - final frame processed`);
16990                send({ type: "recorder:present-progress", content: 1 });
16991                return doneCb(); // Completed video export.
16992              }
16993              
16994              // For normal playback, loop
16995              f = 0;
16996              // 🕰️ Net-time phase lock: every loop boundary anchors to UTC
16997              // multiples of the tape length, so any two players of the
16998              // same tape converge into phase — and the local period can't
16999              // accumulate RAF drift either.
17000              const gridNow = performance.now();
17001              // Snap to the NEAREST grid boundary — flooring meant a pane
17002              // running ε ahead of the grid re-anchored to almost-end-of-
17003              // loop and REPLAYED the tail (the "skips back when it loops"
17004              // glitch). Negative r = the boundary is ε in the future, so
17005              // frame 0 simply holds for ε ms instead.
17006              let gridR = Date.now() % mediaRecorderDuration;
17007              if (gridR > mediaRecorderDuration / 2) gridR -= mediaRecorderDuration;
17008              playbackStart = gridNow - gridR;
17009              playbackProgress = 0;
17010              // 🔊↔️🔴 Keep the SOUND on the needle — but relocate the
17011              // audio loop SPARINGLY: every lap's 4ms declick ticked
17012              // audibly on transient tapes. Drift accumulates ≲5ms/lap,
17013              // so re-phasing every 4th lap keeps AV inside ~20ms with a
17014              // quarter of the corrections.
17015              window.__tapeAnchorLap = (window.__tapeAnchorLap || 0) + 1;
17016              if (window.__tapeAnchorLap % 4 === 0) {
17017                const anchorPos = Math.max(0, gridR) / mediaRecorderDuration;
17018                const tapeAudioIdA = Object.keys(sfxPlaying).find((id) =>
17019                  id.startsWith("tape:audio_"),
17020                );
17021                if (tapeAudioIdA && sfxPlaying[tapeAudioIdA]) {
17022                  sfxPlaying[tapeAudioIdA].update({ samplePosition: anchorPos });
17023                }
17024              }
17025              // The next update's f===0 branch would reset playbackStart to
17026              // "now", clobbering the grid — isResuming makes it skip once.
17027              isResuming = true;
17028
17029              // The tape soundtrack is a free-running looped sample now
17030              // (loop: true) — the worklet wraps the read at both ends like
17031              // stample's ring buffer, so the seam never retriggers it.
17032              // Only start audio here if it somehow died (recovery).
17033              if (!render && !isScrubbing) {
17034                const tapeAudioLive = Object.keys(sfxPlaying).some((id) =>
17035                  id.startsWith("tape:audio_"),
17036                );
17037                if (!tapeAudioLive) {
17038                  tapeSoundId = startTapeAudioLoop(0);
17039                  console.log("🎵 Tape audio recovered at loop boundary");
17040                }
17041                // Loop-period telemetry vs the tape length.
17042                const loopPeriod = window.__lastTapeLoopAt
17043                  ? gridNow - window.__lastTapeLoopAt
17044                  : null;
17045                window.__lastTapeLoopAt = gridNow;
17046                if (loopPeriod) {
17047                  console.log(
17048                    `🕰️ Video loop period ${loopPeriod.toFixed(1)}ms vs tape ${mediaRecorderDuration.toFixed(1)}ms (drift ${(loopPeriod - mediaRecorderDuration).toFixed(1)}ms, utc phase ${(Date.now() % mediaRecorderDuration).toFixed(0)}ms)`,
17049                  );
17050                }
17051              }
17052            } else {
17053              // Improved frame advancement logic with better hang detection and refresh rate handling
17054              const frameAdvancementStart = performance.now();
17055              let frameAdvancementCount = 0;
17056              const maxFrameJump = 10; // Limit frame jumps to prevent instability
17057              
17058              // Calculate time since last update to detect refresh rate and suspension
17059              const timeSinceLastUpdate = window.lastTapeUpdateTime 
17060                ? (updateStartTime - window.lastTapeUpdateTime) 
17061                : 16.67; // Assume 60fps if first frame
17062              window.lastTapeUpdateTime = updateStartTime;
17063              
17064              // Detect and clamp extreme time jumps (from tab suspension, varying refresh rates, etc.)
17065              // Maximum realistic frame time is ~200ms (5fps) - anything beyond suggests suspension
17066              const maxRealisticFrameTime = 200;
17067              const clampedTimeSinceUpdate = Math.min(timeSinceLastUpdate, maxRealisticFrameTime);
17068              
17069              if (timeSinceLastUpdate > maxRealisticFrameTime) {
17070                // Large gap detected. Normally this indicates tab suspension and we
17071                // compensate by advancing playbackStart. However, if the gap was
17072                // caused by a user-gesture AudioContext resume or manual pause/resume,
17073                // we should NOT perform that compensation.
17074                const resumeCompletedAt = window.audioContextResumeTimestamps?.completedAt;
17075                const tapeResumeAt = window.tapeResumeTimestamp;
17076                const resumeGraceMs = 1000; // If resume completed within the last 1s, treat gap as resume
17077                
17078                const skipCompensation = 
17079                  (resumeCompletedAt && (updateStartTime - resumeCompletedAt) < resumeGraceMs) ||
17080                  (tapeResumeAt && (updateStartTime - tapeResumeAt) < resumeGraceMs);
17081                
17082                if (skipCompensation) {
17083                  // Clear the timestamp after using it
17084                  if (tapeResumeAt && (updateStartTime - tapeResumeAt) < resumeGraceMs) {
17085                    window.tapeResumeTimestamp = null;
17086                  }
17087                } else {
17088                  console.log(`⏰ Large time gap detected: ${timeSinceLastUpdate.toFixed(0)}ms, clamped to ${maxRealisticFrameTime}ms to prevent fast-forward`);
17089                  // Adjust playbackStart to compensate for the suspended time
17090                  const suspendedTime = timeSinceLastUpdate - maxRealisticFrameTime;
17091                  playbackStart += suspendedTime;
17092                  console.log(`⏰ Adjusted playback timing by ${suspendedTime.toFixed(0)}ms`);
17093                }
17094              }
17095              
17096              // Recalculate playback progress after potential adjustment
17097              playbackProgress = performance.now() - playbackStart;
17098              
17099              while (playbackProgress > targetFrameTime && f < recordedFrames.length - 1) {
17100                f = f + 1;
17101                frameAdvancementCount++;
17102                
17103                // A large gap (loop re-anchor, tab wake) must be ONE jump —
17104                // sweeping there over many RAFs at maxFrameJump per frame
17105                // painted as a fast-forward/rewind glitch at the seam.
17106                if (frameAdvancementCount > maxFrameJump) {
17107                  const span =
17108                    recordedFrames[recordedFrames.length - 1][0] -
17109                    recordedFrames[0][0];
17110                  const interval = Math.max(1, span / (recordedFrames.length - 1));
17111                  f = Math.max(
17112                    0,
17113                    Math.min(
17114                      recordedFrames.length - 1,
17115                      Math.floor(playbackProgress / interval),
17116                    ),
17117                  );
17118                  break;
17119                }
17120                
17121                // Recalculate target time for the new frame
17122                targetFrameTime = recordedFrames[f][0] - firstFrameTimestamp;
17123                
17124                // Check for suspicious timestamp values
17125                if (isNaN(targetFrameTime) || targetFrameTime < 0) {
17126                  console.error(`🎬 🚨 INVALID TIMESTAMP at frame ${f}: ${targetFrameTime}, firstFrame: ${firstFrameTimestamp}, frameTime: ${recordedFrames[f][0]}`);
17127                  break;
17128                }
17129                
17130                // Additional safety: if we're jumping too far ahead, slow down
17131                if (frameAdvancementCount > 5) {
17132                  // Break early for large jumps to maintain stability
17133                  break;
17134                }
17135              }
17136              
17137              const frameAdvancementTime = performance.now() - frameAdvancementStart;
17138            }
17139
17140            if (transmitProgress) {
17141              // Use frame-based progress for video export, time-based for normal playback
17142              let currentProgress;
17143              if (render) {
17144                // For video export, use stable frame-based progress
17145                currentProgress = Math.max(0, Math.min(1, f / (recordedFrames.length - 1)));
17146                // Ensure progress always advances
17147                if (currentProgress <= (window.lastVideoProgress || 0)) {
17148                  currentProgress = (window.lastVideoProgress || 0) + 0.001;
17149                }
17150                window.lastVideoProgress = currentProgress;
17151              } else {
17152                // For normal playback, use time-based progress
17153                currentProgress =
17154                  soundOnFilmProgress ?? playbackProgress / playbackDurationMs;
17155              }
17156              
17157              // Store global progress for overlay breathing pattern
17158              window.currentTapeProgress = currentProgress;
17159              
17160              // Enhanced debugging for hang detection
17161              if (render) {
17162                console.log(`🎬 📊 Frame ${f}/${recordedFrames.length - 1}, Progress: ${(currentProgress * 100).toFixed(2)}%`);
17163                
17164                // Check for potential hang conditions
17165                if (f > 0 && currentProgress > 0.35 && currentProgress < 0.45) {
17166                  console.log(`🎬 ⚠️ Critical zone detected - frame timing: ${targetFrameTime}ms, playback: ${playbackProgress}ms`);
17167                  console.log(`🎬 ⚠️ Frame timestamp: ${recordedFrames[f][0]}, First frame: ${firstFrameTimestamp}`);
17168                  
17169                  // Memory monitoring in critical zone
17170                  if (performance.memory) {
17171                    const memInfo = performance.memory;
17172                    console.log(`🎬 💾 Memory: Used ${(memInfo.usedJSHeapSize / 1024 / 1024).toFixed(2)}MB / ${(memInfo.totalJSHeapSize / 1024 / 1024).toFixed(2)}MB`);
17173                  }
17174                }
17175                
17176                // Log every 10% progress for general monitoring
17177                if (f % Math.floor(recordedFrames.length / 10) === 0) {
17178                  console.log(`🎬 🔄 Progress checkpoint: ${(currentProgress * 100).toFixed(1)}% (frame ${f})`);
17179                }
17180              }
17181              
17182              send({
17183                type: "recorder:present-progress",
17184                content: currentProgress,
17185              });
17186            }
17187
17188            window.requestAnimationFrame(update);
17189            
17190            // Check total update time and warn if excessive
17191            const totalUpdateTime = performance.now() - updateStartTime;
17192            if (totalUpdateTime > 50) {
17193              console.warn(`🎬 ⚠️ Very slow update cycle: ${totalUpdateTime.toFixed(2)}ms at frame ${f} (${((f / (recordedFrames.length - 1)) * 100).toFixed(2)}%)`);
17194            }
17195          }
17196
17197          update();
17198        };
17199
17200        // CRITICAL: Clear freeze frame that would layer above the underlay video (z-index 3 > 0)
17201        if (freezeFrame || freezeFrameFrozen || wrapper.contains(freezeFrameCan)) {
17202          freezeFrameCan.remove();
17203          freezeFrame = false;
17204          freezeFrameGlaze = false;
17205          freezeFrameFrozen = false;
17206        }
17207
17208        // Style the underlay to be positioned BEHIND everything
17209        // z-index: -1 places it below the main canvas (1), glaze (2), and UI (6)
17210        underlayFrame.style.cssText = "position: fixed; top: 0; left: 0; width: 100%; height: 100%; z-index: -1; pointer-events: none;";
17211
17212        underlayFrame.appendChild(frameCan);
17213        // Insert at the very beginning of body to ensure it's below the wrapper
17214        document.body.insertBefore(underlayFrame, document.body.firstChild);
17215        // Start only after the first frame canvas is in the document. This makes
17216        // cap → video reveal frame zero in the same presentation turn.
17217        startTapePlayback();
17218        
17219        // Hide only the opaque compositor layers during presentation
17220        // Keep the main canvas visible - the piece uses wipe(0,0,0,0) for transparency
17221        const glazeCan = Glaze.getCan();
17222        if (glazeCan) glazeCan.style.display = "none";
17223        if (webglCompositeCanvas) webglCompositeCanvas.style.display = "none";
17224        if (overlayCan) overlayCan.style.display = "none";
17225        // Clear the main canvas to transparent before revealing it — when the WebGL
17226        // compositor is active, the main canvas has been hidden and holds stale pixels
17227        // from whatever ran before (e.g. the dark prompt background). Without clearing,
17228        // those pixels show as a black overlay over the tape video underlay.
17229        ctx.clearRect(0, 0, canvas.width, canvas.height);
17230        canvas.style.visibility = "visible";
17231
17232        // Make backgrounds transparent so the underlay shows through (avoid checkerboard bleed)
17233        document.body.style.background = "transparent";
17234        document.body.style.backgroundImage = "none";
17235        wrapper.style.background = "transparent";
17236        
17237        send({ type: "recorder:presented" });
17238        send({ type: "recorder:present-playing" });
17239      } else {
17240        if (debug && logs.recorder)
17241          console.error(
17242            "📼 No media recorder or cached video to present from!",
17243          );
17244        send({ type: "recorder:presented:failure" });
17245      }
17246      return;
17247    }
17248
17249    if (type === "recorder:present:play") {
17250      if (underlayFrame) resumeTapePlayback?.();
17251      return;
17252    }
17253
17254    if (type === "recorder:present:pause") {
17255      if (underlayFrame && pauseTapePlayback) {
17256        pauseTapePlayback?.();
17257      }
17258      return;
17259    }
17260
17261    if (type === "recorder:present:seek") {
17262      if (underlayFrame && seekTapePlayback) {
17263        // Support both simple progress value and object with scrubbing options
17264        if (typeof content === "object") {
17265          seekTapePlayback?.(content.progress, {
17266            scrubbing: content.scrubbing,
17267            scrubEnd: content.scrubEnd,
17268            speedScrub: content.speedScrub,
17269          });
17270        } else {
17271          const progress = content; // 0.0 to 1.0
17272          seekTapePlayback?.(progress);
17273        }
17274      }
17275      return;
17276    }
17277
17278    if (type === "recorder:unpresent") {
17279      if (underlayFrame) {
17280        const media = underlayFrame.querySelector("video, audio");
17281        if (media?.src) URL.revokeObjectURL(media.src);
17282        if (media === mp4PlaybackVideo) mp4PlaybackVideo = undefined;
17283        if (compactPlaybackUrl) {
17284          URL.revokeObjectURL(compactPlaybackUrl);
17285          compactPlaybackUrl = null;
17286        }
17287        underlayFrame?.remove();
17288        underlayFrame = undefined;
17289        
17290        // Restore compositor layers visibility
17291        const glazeCan = Glaze.getCan();
17292        if (glazeCan) glazeCan.style.display = "";
17293        if (webglCompositeCanvas) webglCompositeCanvas.style.removeProperty("display");
17294        if (overlayCan) overlayCan.style.removeProperty("display");
17295        canvas.style.removeProperty("visibility");
17296
17297        // Restore backgrounds
17298        document.body.style.removeProperty("background");
17299        document.body.style.removeProperty("background-image");
17300        wrapper.style.removeProperty("background");
17301        
17302        send({ type: "recorder:unpresented" });
17303      }
17304      return;
17305    }
17306
17307    // 🎞️ 🎥 Exporting stamped media.
17308    if (type === "recorder:print") {
17309      if (!mediaRecorder) return;
17310      
17311      // Send initial status
17312      send({
17313        type: "recorder:export-status",
17314        content: { 
17315          type: "video", 
17316          phase: "preparing", 
17317          message: "Preparing video export" 
17318        }
17319      });
17320      
17321      send({ type: "recorder:present-playing" });
17322
17323      let mimeType;
17324      const content = "video";
17325      if (MediaRecorder.isTypeSupported(content + "/mp4")) {
17326        mimeType = content + "/mp4"; // This is the setup for Safari.
17327      } else if (MediaRecorder.isTypeSupported(content + "/webm")) {
17328        mimeType = content + "/webm"; // And for Chrome & Firefox.
17329      } else {
17330        console.error("🔴 Mimetypes mp4 and webm are unsupported.");
17331      }
17332
17333      streamCanCtx = document.createElement("canvas").getContext("2d", {
17334        alpha: false,
17335        willReadFrequently: true,
17336      });
17337      const sctx = streamCanCtx;
17338
17339      // TODO: `tiktokVideo` could eventually be defined by an export option
17340      //        instead of in `recorder:rolling`.
17341      //if (tiktokVideo) {
17342      // Portrait Mode / TikTok (this is the default for recording)
17343      // This is hardcoded at half 720p for TikTok right now.
17344      // sctx.canvas.width = 1080; //720 / 2;
17345      // sctx.canvas.height = 1920; //1280 / 2;
17346      //} else {
17347      const originalWidth = canvas.width;
17348      const originalHeight = canvas.height;
17349      const aspectRatio = originalWidth / originalHeight;
17350
17351      let newWidth = originalWidth * 4;
17352      let newHeight = originalHeight * 4;
17353
17354      if (newWidth > 1080) {
17355        newWidth = originalWidth * 2;
17356        newHeight = newWidth / aspectRatio;
17357      }
17358
17359      if (newHeight > 1920) {
17360        newHeight = originalHeight * 2;
17361        newWidth = newHeight * aspectRatio;
17362      }
17363      
17364      // Add progress bar height to the final export canvas
17365      const progressBarHeight = 3;
17366      
17367      sctx.canvas.width = newWidth;
17368      sctx.canvas.height = newHeight + progressBarHeight;
17369      //}
17370
17371      sctx.imageSmoothingEnabled = false; // Must be set after resize.
17372
17373      // Draw initial black frame to ensure canvas stream has content
17374      sctx.fillStyle = 'black';
17375      sctx.fillRect(0, 0, sctx.canvas.width, sctx.canvas.height);
17376
17377      const canvasStream = sctx.canvas.captureStream(30); // Specify framerate
17378      const tapeRenderStreamDest = audioContext.createMediaStreamDestination();
17379
17380      tapeRenderStreamDest.stream.getAudioTracks().forEach((track) => {
17381        canvasStream.addTrack(track);
17382      });
17383
17384      const options = {
17385        mimeType,
17386        videoBitsPerSecond: 5000000,
17387        audioBitsPerSecond: 256000,
17388      };
17389
17390      const videoRecorder = new MediaRecorder(canvasStream, options);
17391
17392      const chunks = [];
17393
17394      videoRecorder.ondataavailable = function (e) {
17395        console.log("📹 Video data available:", e.data.size, "bytes");
17396        if (e.data && e.data.size > 0) chunks.push(e.data);
17397      };
17398
17399      function startRendering() {
17400        stopTapePlayback?.();
17401        
17402        console.log(`🎬 📹 Starting video export with ${recordedFrames.length} recorded frames`);
17403        
17404        // Send encoding start status
17405        send({
17406          type: "recorder:export-status",
17407          content: { 
17408            type: "video", 
17409            phase: "encoding", 
17410            message: "Starting video encoding" 
17411          }
17412        });
17413        
17414        videoRecorder.start(100);
17415        
17416        // Enhanced MediaRecorder monitoring
17417        let lastDataTime = performance.now();
17418        const monitoringInterval = setInterval(() => {
17419          const timeSinceLastData = performance.now() - lastDataTime;
17420          console.log(`🎬 📊 MediaRecorder state: ${videoRecorder.state}, Time since last data: ${timeSinceLastData}ms`);
17421          
17422          if (timeSinceLastData > 5000 && videoRecorder.state === "recording") {
17423            console.warn(`🎬 ⚠️ No data received for ${timeSinceLastData}ms - possible hang detected`);
17424          }
17425        }, 2000);
17426        
17427        // Add video export watchdog
17428        window.lastVideoUpdateTime = performance.now();
17429        window.videoExportWatchdog = setInterval(() => {
17430          const timeSinceLastUpdate = performance.now() - (window.lastVideoUpdateTime || 0);
17431          if (timeSinceLastUpdate > 10000) { // 10 second timeout
17432            console.error(`🎬 🚨 VIDEO EXPORT HANG DETECTED! No update for ${timeSinceLastUpdate}ms`);
17433            console.error(`🎬 🚨 Attempting to restart or abort export...`);
17434            
17435            // Try to stop the current process
17436            try {
17437              if (videoRecorder.state === "recording") {
17438                console.log(`🎬 🚨 Force stopping MediaRecorder...`);
17439                videoRecorder.stop();
17440              }
17441            } catch (e) {
17442              console.error(`🎬 ❌ Failed to stop MediaRecorder:`, e);
17443            }
17444            
17445            // Clear the watchdog to prevent spam
17446            clearInterval(window.videoExportWatchdog);
17447            window.videoExportWatchdog = null;
17448          }
17449        }, 5000);
17450        
17451        // Update last data time when data is received
17452        const originalOnDataAvailable = videoRecorder.ondataavailable;
17453        videoRecorder.ondataavailable = function(e) {
17454          lastDataTime = performance.now();
17455          console.log(`🎬 📊 Data received: ${e.data.size} bytes at progress ${window.currentTapeProgress || 0}`);
17456          if (originalOnDataAvailable) originalOnDataAvailable.call(this, e);
17457        };
17458        
17459        // Clear monitoring when done
17460        const originalOnStop = videoRecorder.onstop;
17461        videoRecorder.onstop = function(e) {
17462          clearInterval(monitoringInterval);
17463          if (window.videoExportWatchdog) {
17464            clearInterval(window.videoExportWatchdog);
17465            window.videoExportWatchdog = null;
17466            console.log(`🎬 🐕 Watchdog cleared - MediaRecorder stopped`);
17467          }
17468          if (originalOnStop) originalOnStop.call(this, e);
17469        };
17470        
17471        // Pixel-perfect upscaling function (same as GIF export)
17472        function upscalePixels(imageData, originalWidth, originalHeight, scale) {
17473          if (scale === 1) {
17474            return imageData;
17475          }
17476
17477          const scaledWidth = originalWidth * scale;
17478          const scaledHeight = originalHeight * scale;
17479          const scaledImageData = new Uint8ClampedArray(
17480            scaledWidth * scaledHeight * 4,
17481          );
17482
17483          for (let y = 0; y < scaledHeight; y++) {
17484            for (let x = 0; x < scaledWidth; x++) {
17485              const sourceX = Math.floor(x / scale);
17486              const sourceY = Math.floor(y / scale);
17487              const sourceIndex = (sourceY * originalWidth + sourceX) * 4;
17488              const targetIndex = (y * scaledWidth + x) * 4;
17489
17490              scaledImageData[targetIndex] = imageData[sourceIndex]; // R
17491              scaledImageData[targetIndex + 1] = imageData[sourceIndex + 1]; // G
17492              scaledImageData[targetIndex + 2] = imageData[sourceIndex + 2]; // B
17493              scaledImageData[targetIndex + 3] = imageData[sourceIndex + 3]; // A
17494            }
17495          }
17496
17497          return scaledImageData;
17498        }
17499        
17500        startTapePlayback(
17501          true,
17502          () => {
17503            console.log(`🎬 📹 Video export completed, stopping recorder`);
17504            // Clear watchdog when export completes
17505            if (window.videoExportWatchdog) {
17506              clearInterval(window.videoExportWatchdog);
17507              window.videoExportWatchdog = null;
17508              console.log(`🎬 🐕 Watchdog cleared - export completed normally`);
17509            }
17510            // Stop video recorder immediately when all frames are processed
17511            videoRecorder.stop();
17512          },
17513          tapeRenderStreamDest,
17514          // 🎫 Renders and watermarks the frames for export.
17515          function renderTape(can, progress, frameIndex) {
17516            // Helper function for choosing random values
17517            function choose(options) {
17518              return options[Math.floor(Math.random() * options.length)];
17519            }
17520            
17521            // Use the actual frame index from playback instead of separate counter
17522            const currentFrameIndex = frameIndex !== undefined ? frameIndex : f;
17523            
17524            // Log progress every 60 frames to reduce console spam
17525            if (currentFrameIndex % 60 === 0 || currentFrameIndex <= 5) {
17526              console.log(`🎬 📹 Rendering frame ${currentFrameIndex + 1}/${recordedFrames.length} (${(progress * 100).toFixed(1)}%)`);
17527            }
17528            
17529            const progressBarHeight = 3;
17530            
17531            // Use fixed 3x upscaling like GIF export for pixel-perfect rendering
17532            const optimalScale = 3;
17533            const originalWidth = can.width;
17534            const originalHeight = can.height;
17535            const scaledWidth = originalWidth * optimalScale;
17536            const scaledHeight = originalHeight * optimalScale;
17537            
17538            // Set screen canvas to match scaled dimensions
17539            if (sctx.canvas.width !== scaledWidth || sctx.canvas.height !== scaledHeight) {
17540              sctx.canvas.width = scaledWidth;
17541              sctx.canvas.height = scaledHeight;
17542            }
17543
17544            sctx.clearRect(0, 0, scaledWidth, scaledHeight);
17545
17546            // Get source canvas as ImageData
17547            const sourceCtx = can.getContext('2d');
17548            const sourceImageData = sourceCtx.getImageData(0, 0, originalWidth, originalHeight);
17549            
17550            // Apply pixel-perfect upscaling (same as GIF export)
17551            const scaledData = upscalePixels(sourceImageData.data, originalWidth, originalHeight, optimalScale);
17552            
17553            // Create ImageData for the scaled result
17554            const scaledImageData = new ImageData(
17555              new Uint8ClampedArray(scaledData),
17556              scaledWidth,
17557              scaledHeight
17558            );
17559            
17560            // Put the pixel-perfect upscaled image onto the output canvas
17561            sctx.putImageData(scaledImageData, 0, 0);
17562
17563            if (pen?.pointers[1]) {
17564              const pointer = pen.pointers[1];
17565              const originalX = pointer.x;
17566              const originalY = pointer.y;
17567              // Scale cursor position by optimalScale
17568              const scaledX = originalX * optimalScale;
17569              const scaledY = originalY * optimalScale;
17570
17571              if (pointer.device === "mouse") {
17572                sctx.drawImage(
17573                  svgCursor,
17574                  floor(scaledX - 12),
17575                  floor(scaledY - 12),
17576                  svgCursor.naturalWidth,
17577                  svgCursor.naturalHeight,
17578                );
17579              } else {
17580                // Draw cyan crosshair for touch devices
17581                const crosshairSize = 16;
17582                const crosshairThickness = 2;
17583                
17584                sctx.globalAlpha = 0.8;
17585                sctx.strokeStyle = "cyan";
17586                sctx.lineWidth = crosshairThickness;
17587                sctx.lineCap = "round";
17588                
17589                // Draw crosshair lines
17590                sctx.beginPath();
17591                // Horizontal line
17592                sctx.moveTo(scaledX - crosshairSize, scaledY);
17593                sctx.lineTo(scaledX + crosshairSize, scaledY);
17594                // Vertical line
17595                sctx.moveTo(scaledX, scaledY - crosshairSize);
17596                sctx.lineTo(scaledX, scaledY + crosshairSize);
17597                sctx.stroke();
17598                
17599                // Draw center dot
17600                sctx.fillStyle = "cyan";
17601                sctx.beginPath();
17602                sctx.arc(scaledX, scaledY, 2, 0, 2 * Math.PI);
17603                sctx.fill();
17604                
17605                sctx.globalAlpha = 1;
17606              }
17607            }
17608
17609            // if (pen.pointers[1]) {
17610            // console.log(pen.pointers[1]);
17611
17612            // TODO: Draw a circle based on pen.points[1].x and y
17613            //       that fits inside of the scaled can drawImage
17614            //       below, because these ranges are within
17615            //       the original width and height before the aspect
17616            //       ratio scale.
17617            // }
17618
17619            // 2. Set up the font.
17620            const typeSize = min(18, max(12, floor(sctx.canvas.height / 40)));
17621            // const textHeight = typeSize;
17622            const gap = typeSize * 0.75;
17623
17624            // Film camera style timestamp at bottom-left corner
17625            function addFilmTimestamp(
17626              sctx,
17627              canvasWidth,
17628              canvasHeight,
17629              typeSize,
17630              progress, // Export progress for the progress bar
17631            ) {
17632              // Skip all timestamp and progress bar overlays in clean mode (tape:neat)
17633              if (window.currentRecordingOptions?.cleanMode) {
17634                return; // Exit early, don't draw any overlays
17635              }
17636              
17637              // Always show timestamp and progress bar for the full duration of the work (no breathing pattern)
17638              let showTimecodeAndProgressBar = true; // Always visible throughout the entire duration
17639              
17640              // Progress bar height (should match what's set above)
17641              const progressBarHeight = 20;
17642              
17643              // Calculate the content area height (canvas height minus progress bar)
17644              const contentHeight = sctx.canvas.height - progressBarHeight;
17645              
17646              // Calculate simple second count starting from 0
17647              let elapsedSeconds = 0;
17648              
17649              if (mediaRecorderStartTime !== undefined) {
17650                // During recording: show seconds elapsed since recording started
17651                const elapsedMs = performance.now() - mediaRecorderStartTime;
17652                elapsedSeconds = elapsedMs / 1000;
17653              } else if (window.recordingStartTimestamp && progress >= 0) {
17654                // Use progress to map to intended recording duration
17655                let actualRecordingDurationMs = 5000; // Default fallback
17656                
17657                // Prioritize intended duration for accurate real-time mapping
17658                if (window.currentRecordingOptions?.intendedDuration) {
17659                  actualRecordingDurationMs = window.currentRecordingOptions.intendedDuration * 1000;
17660                } else if (mediaRecorderDuration && mediaRecorderDuration > 0) {
17661                  actualRecordingDurationMs = mediaRecorderDuration;
17662                } else if (window.gifDurationMs && window.gifDurationMs > 0) {
17663                  actualRecordingDurationMs = window.gifDurationMs;
17664                }
17665                
17666                // Calculate elapsed seconds
17667                elapsedSeconds = (progress * actualRecordingDurationMs) / 1000;
17668              } else {
17669                // Fallback: show 0 seconds (for non-recording contexts)
17670                elapsedSeconds = 0;
17671              }
17672
17673              // Format as simple seconds with one decimal place
17674              const timestamp = `${elapsedSeconds.toFixed(1)}s`;
17675
17676              sctx.save();
17677
17678              // Only draw timecode and progress bar if they should be visible (breathing pattern)
17679              if (showTimecodeAndProgressBar) {
17680                // Use original typeSize for timestamp
17681                sctx.font = `bold ${typeSize}px YWFTProcessing-Regular`;
17682                
17683                // Smooth fade transitions: visible at start, fade out towards middle, visible at end
17684                let timestampAlpha = 1.0; // Default to fully visible
17685                let timestampColor = "white"; // Consistent white color
17686                
17687                // Apply fade pattern based on progress (same as other addFilmTimestamp)
17688                if (progress < 0.15) {
17689                  // Fully visible at start (0-15%)
17690                  timestampAlpha = 1.0;
17691                } else if (progress >= 0.15 && progress <= 0.35) {
17692                  // Fade out from 15% to 35% (20% fade-out period)
17693                  const fadeProgress = (progress - 0.15) / 0.20;
17694                  timestampAlpha = 1.0 - Math.pow(fadeProgress, 2); // Smooth quadratic fade out
17695                } else if (progress > 0.35 && progress < 0.65) {
17696                  // Fully hidden from 35% to 65% (30% hidden period)
17697                  timestampAlpha = 0.0;
17698                } else if (progress >= 0.65 && progress <= 0.85) {
17699                  // Fade in from 65% to 85% (20% fade-in period)
17700                  const fadeProgress = (progress - 0.65) / 0.20;
17701                  timestampAlpha = Math.pow(fadeProgress, 2); // Smooth quadratic fade in
17702                } else {
17703                  // Fully visible at end (85%-100%)
17704                  timestampAlpha = 1.0;
17705                }
17706                
17707                // Skip drawing if completely transparent
17708                if (timestampAlpha <= 0.0) {
17709                  sctx.restore();
17710                  return;
17711                }
17712                
17713                sctx.globalAlpha = timestampAlpha;
17714                sctx.fillStyle = timestampColor;
17715
17716                const timestampMargin = typeSize * 0.5; // Small margin from edges
17717
17718                // Smoother, gentler shake effect
17719                const frameBasedSeed = Math.floor(Date.now() / 300); // Slower change (was 200ms)
17720                const shakeX = (frameBasedSeed % 3) - 1; // Range -1 to 1 (was -3 to 3)
17721                const shakeY = ((frameBasedSeed + 2) % 3) - 1; // Range -1 to 1, offset for variation
17722
17723                // Position timestamp in the content area (not in the progress bar area)
17724                const timestampY = contentHeight - timestampMargin - typeSize * 0.5;
17725
17726                sctx.fillText(
17727                  timestamp,
17728                  timestampMargin + shakeX,
17729                  timestampY + shakeY,
17730                );
17731
17732                // Draw progress bar in the extended area at the bottom
17733                const progressBarY = contentHeight; // Start right after content
17734                const progressBarWidth = Math.floor(progress * canvasWidth);
17735                
17736                // Fill the entire progress bar area with a dark background
17737                sctx.fillStyle = "#000000";
17738                sctx.fillRect(0, progressBarY, canvasWidth, progressBarHeight);
17739                
17740                // Sample colors from the source frame for the progress bar
17741                let frameColors = []; // Start empty, no fallback yet
17742                try {
17743                  // Get frame data from the source canvas ('can' parameter)
17744                  const tempCtx = can.getContext('2d');
17745                  const frameImageData = tempCtx.getImageData(0, 0, can.width, can.height);
17746                  
17747                  // Sample colors across the width of the progress bar for pixel-by-pixel variety
17748                  const numSamples = Math.min(progressBarWidth, 50); // Cap at 50 samples for performance
17749                  
17750                  if (numSamples > 0 && frameImageData.data.length > 0) {
17751                    for (let i = 0; i < numSamples; i++) {
17752                      const xPos = Math.floor((i / Math.max(numSamples - 1, 1)) * (can.width - 1));
17753                      const yPos = Math.floor(can.height * 0.5); // Sample from middle row
17754                      const pixelIndex = (yPos * can.width + xPos) * 4;
17755                      
17756                      if (pixelIndex >= 0 && pixelIndex < frameImageData.data.length - 3) {
17757                        const r = frameImageData.data[pixelIndex];
17758                        const g = frameImageData.data[pixelIndex + 1];
17759                        const b = frameImageData.data[pixelIndex + 2];
17760                        
17761                        // Very subtle enhancement - just barely brighten for visibility
17762                        const subtleBoost = 1.05; // Very minimal boost (5% instead of 10-40%)
17763                        const enhancedR = Math.min(255, Math.floor(r * subtleBoost));
17764                        const enhancedG = Math.min(255, Math.floor(g * subtleBoost));
17765                        const enhancedB = Math.min(255, Math.floor(b * subtleBoost));
17766                        
17767                        const hexColor = `#${enhancedR.toString(16).padStart(2, '0')}${enhancedG.toString(16).padStart(2, '0')}${enhancedB.toString(16).padStart(2, '0')}`;
17768                        frameColors.push(hexColor);
17769                      }
17770                    }
17771                  }
17772                } catch (error) {
17773                  console.warn("Failed to sample frame colors:", error);
17774                }
17775                
17776                // Fallback to subtle natural colors if sampling failed or produced no colors
17777                if (frameColors.length === 0) {
17778                  // Use more natural, muted colors as fallback
17779                  const naturalColors = ["#888888", "#999999", "#AAAAAA", "#BBBBBB", "#CCCCCC"];
17780                  const numFallbackSegments = Math.min(progressBarWidth, 20);
17781                  for (let i = 0; i < numFallbackSegments; i++) {
17782                    frameColors.push(choose(naturalColors));
17783                  }
17784                }
17785                
17786                // Draw the progress bar with frame-sampled colors
17787                if (frameColors.length === 1) {
17788                  // Single color fallback
17789                  sctx.fillStyle = frameColors[0];
17790                  sctx.fillRect(0, progressBarY, progressBarWidth, progressBarHeight);
17791                } else {
17792                  // Draw pixel-by-pixel or segment-by-segment with sampled colors
17793                  const segmentWidth = progressBarWidth / frameColors.length;
17794                  for (let i = 0; i < frameColors.length; i++) {
17795                    const segmentX = i * segmentWidth;
17796                    const actualSegmentWidth = Math.ceil(segmentWidth); // Ensure no gaps
17797                    
17798                    sctx.fillStyle = frameColors[i];
17799                    sctx.fillRect(segmentX, progressBarY, actualSegmentWidth, progressBarHeight);
17800                  }
17801                }
17802                
17803                // Add a subtle border
17804                sctx.strokeStyle = "#333333";
17805                sctx.lineWidth = 1;
17806                sctx.strokeRect(0, progressBarY, canvasWidth, progressBarHeight);
17807              }
17808
17809              sctx.restore();
17810            }
17811
17812            sctx.save(); // Save the current state of the canvas
17813
17814            // Skip all stamp rendering in clean mode but still do frame processing
17815            if (!window.currentRecordingOptions?.cleanMode) {
17816              // Calculate stamp visibility for timestamp fade coordination
17817              let bothStampsInvisible = false;
17818              const isFrameBasedRecording = window.currentRecordingOptions?.frameMode;
17819              
17820              if (isFrameBasedRecording) {
17821                // For frame-based recordings longer than 60 frames: use alternating side timing logic
17822                const frameCount = window.currentRecordingOptions?.frameCount || 0;
17823                
17824                if (frameCount > 60 && frameIndex !== null) {
17825                  const cyclesPerGif = 2;
17826                  const frameProgress = frameIndex / frameCount;
17827                  const cyclePosition = (frameProgress * cyclesPerGif) % 1.0;
17828                  
17829                  // Check if we're in the "both off" phases (0-20% and 40-60%)
17830                  if ((cyclePosition < 0.2) || (cyclePosition >= 0.4 && cyclePosition < 0.6)) {
17831                    bothStampsInvisible = true;
17832                  }
17833                }
17834              } else {
17835                // Time-based recordings
17836                const cyclesPerGif = 2;
17837                const cyclePosition = (progress * cyclesPerGif) % 1.0;
17838                
17839                // Check if we're in the "both off" phases (0-20% and 40-60%)
17840                if ((cyclePosition < 0.2) || (cyclePosition >= 0.4 && cyclePosition < 0.6)) {
17841                  bothStampsInvisible = true;
17842                }
17843              }
17844
17845              // Add film camera style timestamp at bottom-left corner
17846              addFilmTimestamp(
17847                sctx,
17848                sctx.canvas.width,
17849                sctx.canvas.height,
17850                typeSize,
17851                progress, // Pass progress to the timestamp function
17852                null, // frameData
17853                null, // frameMetadata  
17854                frameIndex, // frameIndex
17855                null, // totalFrames
17856                bothStampsInvisible, // bothStampsInvisible
17857              );
17858
17859              // Removed Tezos watermark from top right - now positioned with timestamp
17860              
17861              // Add Tezos watermark to top-right if enabled (but not when right stamp is showing)
17862              if (window.currentRecordingOptions?.showTezosStamp) {
17863                const isFrameBasedRecording = window.currentRecordingOptions?.frameMode;
17864              
17865              // Use the SAME visibility logic as the actual stamps to avoid conflicts
17866              let showRightStamp = false;
17867              
17868              if (isFrameBasedRecording) {
17869                // For frame recordings: always show both stamps, so Tezos should never show
17870                showRightStamp = true;
17871              } else {
17872                // Time-based recordings: use progress-based visibility (SAME as stamp logic)
17873                const cyclesPerGif = 2;
17874                const cyclePosition = (progress * cyclesPerGif) % 1.0;
17875                
17876                if (cyclePosition < 0.2) {
17877                  showRightStamp = false; // Both off
17878                } else if (cyclePosition < 0.4) {
17879                  showRightStamp = true; // Both on
17880                } else if (cyclePosition < 0.6) {
17881                  showRightStamp = false; // Both off
17882                } else if (cyclePosition < 0.8) {
17883                  showRightStamp = false; // Left only
17884                } else {
17885                  showRightStamp = true; // Right only
17886                }
17887              }
17888              
17889              // Calculate fade alpha for Tezos logo (inverse of right stamp)
17890              let tezosFadeAlpha = 1.0;
17891              
17892              if (isFrameBasedRecording) {
17893                tezosFadeAlpha = 0.0; // Hidden for frame recordings
17894              } else {
17895                // Time-based recordings
17896                const cyclesPerGif = 2;
17897                const cyclePosition = (progress * cyclesPerGif) % 1.0;
17898                
17899                if (cyclePosition < 0.15) {
17900                  tezosFadeAlpha = 1.0;
17901                } else if (cyclePosition >= 0.15 && cyclePosition <= 0.25) {
17902                  tezosFadeAlpha = 1.0 - ((cyclePosition - 0.15) / 0.10);
17903                } else if (cyclePosition > 0.25 && cyclePosition < 0.35) {
17904                  tezosFadeAlpha = 0.0;
17905                } else if (cyclePosition >= 0.35 && cyclePosition <= 0.45) {
17906                  tezosFadeAlpha = (cyclePosition - 0.35) / 0.10;
17907                } else if (cyclePosition > 0.45 && cyclePosition < 0.75) {
17908                  tezosFadeAlpha = 1.0;
17909                } else if (cyclePosition >= 0.75 && cyclePosition <= 0.85) {
17910                  tezosFadeAlpha = 1.0 - ((cyclePosition - 0.75) / 0.10);
17911                } else {
17912                  tezosFadeAlpha = 0.0;
17913                }
17914              }
17915              
17916              // Clamp alpha and show with fade
17917              tezosFadeAlpha = Math.max(0.0, Math.min(1.0, tezosFadeAlpha));
17918              
17919              if (tezosFadeAlpha > 0.0) {
17920                addTezosStamp(sctx, sctx.canvas.width, sctx.canvas.height, typeSize, isFrameBasedRecording, true, true, null, tezosFadeAlpha);
17921              }
17922            }
17923
17924            drawTextAtPosition(0, 90); // Left side stamp (rotated 90 degrees)
17925            drawTextAtPosition(sctx.canvas.width, -90); // Right side stamp (rotated -90 degrees)
17926            
17927            } else {
17928              console.log("🎬 🧹 Skipping all stamp rendering in clean mode");
17929            }
17930
17931            sctx.restore();
17932            sctx.globalAlpha = 1;
17933
17934            function drawTextAtPosition(positionX, deg) {
17935              sctx.save();
17936              sctx.translate(positionX, 0);
17937              sctx.rotate(radians(deg));
17938            const leftYDist = 0.10; // Closer to corner (was 0.18)
17939            const rightYDist = 0.08; // Closer to corner, but still avoid Tezos overlap (was 0.15)
17940
17941              // Use smaller size to match timestamp better
17942              sctx.font = `${typeSize * 1.4}px YWFTProcessing-Regular`;
17943              
17944              // Always use Aesthetic.Computer text
17945              const text = "Aesthetic.Computer";
17946              const measured = sctx.measureText(text);
17947              const textWidth = measured.width;
17948
17949              // Helper function to render text with multicolored shooting star dot
17950              function renderTextWithStarDot(sctx, fullText, x, y, baseColor, alpha, offsetX, offsetY, frameIndex) {
17951                const parts = fullText.split('.');
17952                if (parts.length !== 2) {
17953                  // Fallback to normal rendering if not split correctly
17954                  sctx.fillText(fullText, x + offsetX, y + offsetY);
17955                  return;
17956                }
17957                
17958                const beforeDot = parts[0]; // "Aesthetic"
17959                const afterDot = parts[1];  // "Computer"
17960                
17961                // Measure parts
17962                const beforeWidth = sctx.measureText(beforeDot).width;
17963                const dotWidth = sctx.measureText('.').width;
17964                
17965                // Render "Aesthetic"
17966                sctx.fillStyle = baseColor;
17967                sctx.globalAlpha = alpha;
17968                sctx.fillText(beforeDot, x + offsetX, y + offsetY);
17969                
17970                // Render CRT-style piercing dot - bright, small, sharp like asteroids
17971                const dotX = x + beforeWidth + offsetX;
17972                const dotY = y + offsetY;
17973                
17974                // CRT cathode ray piercing colors - bright, sharp, high-contrast
17975                const crtColors = ["#FFFFFF", "#00FFFF", "#FFFF00", "#FF0000", "#00FF00", "#FF00FF"];
17976                let dotColor;
17977                let dotAlpha = alpha;
17978                
17979                if (frameIndex !== null) {
17980                  // Frame-based CRT flicker animation - faster changes for piercing effect
17981                  const colorIndex = Math.floor(frameIndex / 1) % crtColors.length; // Change color every frame
17982                  dotColor = crtColors[colorIndex];
17983                  
17984                  // Sharp, bright pulse - no gentle curves, pure CRT intensity
17985                  dotAlpha = alpha * (frameIndex % 2 === 0 ? 1.0 : 0.9); // Sharp flicker between 90% and 100%
17986                } else {
17987                  // Time-based fallback - faster CRT-style changes
17988                  const timeIndex = Math.floor(Date.now() / 100) % crtColors.length; // Change every 100ms
17989                  dotColor = crtColors[timeIndex];
17990                  dotAlpha = alpha * (Math.floor(Date.now() / 100) % 2 === 0 ? 1.0 : 0.9); // Sharp time-based flicker
17991                }
17992                
17993                sctx.fillStyle = dotColor;
17994                sctx.globalAlpha = dotAlpha;
17995                sctx.fillText('.', dotX, dotY);
17996                
17997                // Render "Computer"
17998                sctx.fillStyle = baseColor;
17999                sctx.globalAlpha = alpha;
18000                sctx.fillText(afterDot, dotX + dotWidth, dotY);
18001              }
18002
18003              ["red", "lime", "blue", "white"].forEach((color) => {
18004                let offsetX, offsetY;
18005                if (color !== "white") {
18006                  sctx.globalAlpha = 0.5; // Restore multicolor balance (was 0.55)
18007                  offsetX = choose(-1, -2, 0, 1, 2); // More offsets for multicolor flickering
18008                  offsetY = choose(-1, -2, 0, 1, 2); // More offsets for multicolor flickering
18009                } else {
18010                  sctx.globalAlpha = choose(0.75, 0.85, 0.95); // Brighter white for dot visibility (was 0.55, 0.65, 0.75)
18011                  offsetX = choose(-1, 0, 1); // Some offsets for multicolor effect
18012                  offsetY = choose(-1, 0, 1); // Some offsets for multicolor effect
18013                  color = choose(
18014                    "white",
18015                    "white",
18016                    "white",
18017                    "magenta",
18018                    "yellow",
18019                  );
18020                }
18021
18022                sctx.fillStyle = color;
18023
18024                if (deg === 90) {
18025                  // LEFT SIDE: Main text positioning
18026                  renderTextWithStarDot(
18027                    sctx,
18028                    text,
18029                    floor(sctx.canvas.height * (1 - leftYDist) - textWidth),
18030                    -floor(gap),
18031                    color,
18032                    sctx.globalAlpha,
18033                    offsetX,
18034                    offsetY,
18035                    frameIndex
18036                  );
18037                } else if (deg === -90) {
18038                  // RIGHT SIDE: Main text positioning
18039                  renderTextWithStarDot(
18040                    sctx,
18041                    text,
18042                    -floor(sctx.canvas.height * rightYDist),
18043                    floor(gap * 3),
18044                    color,
18045                    sctx.globalAlpha,
18046                    offsetX,
18047                    offsetY,
18048                    frameIndex
18049                  );
18050                }
18051              });
18052
18053              if (HANDLE) {
18054                sctx.font = `${typeSize * 1.3}px YWFTProcessing-Light`; // Smaller handle (was 1.5)
18055                sctx.fillStyle = choose("yellow", "red", "blue");
18056                let offsetX, offsetY;
18057                const handleWidth =
18058                  textWidth / 2 + sctx.measureText(HANDLE).width / 2;
18059                const handleSpace = typeSize * 2.0; // More spacing (was 1.5)
18060                offsetX = choose(-1, 0, 1);
18061                offsetY = choose(-1, 0, 1);
18062
18063                if (deg === 90) {
18064                  // LEFT SIDE: Handle positioning
18065                  sctx.fillText(
18066                    HANDLE,
18067                    floor(
18068                      sctx.canvas.height * (1 - leftYDist) - handleWidth + offsetY,
18069                    ),
18070                    -floor(offsetX + handleSpace + gap),
18071                  );
18072                } else if (deg === -90) {
18073                  // RIGHT SIDE: Handle positioning
18074                  sctx.fillText(
18075                    HANDLE,
18076                    -floor(sctx.canvas.height * rightYDist + handleWidth + offsetY),
18077                    floor(offsetX + handleSpace + gap * 3),
18078                  );
18079                }
18080              }
18081
18082              // Remove the old timestamp code from here since it's now in addFilmTimestamp
18083
18084              sctx.restore();
18085            }
18086
18087            // Film camera style timestamp at bottom-left corner
18088            function addFilmTimestamp(
18089              sctx,
18090              canvasWidth,
18091              canvasHeight,
18092              typeSize,
18093              progress, // Export progress for the progress bar
18094              frameData = null,
18095              frameMetadata = null,
18096              frameIndex = null,
18097              totalFrames = null,
18098              bothStampsInvisible = false,
18099            ) {
18100              // Progress bar height (should match what's set in renderTape)
18101              const progressBarHeight = 20;
18102              
18103              // Calculate the original content area height
18104              const originalCanvasHeight = sctx.canvas.height - progressBarHeight;
18105              
18106              // Calculate simple second count starting from 0
18107              let elapsedSeconds = 0;
18108              
18109              if (mediaRecorderStartTime !== undefined) {
18110                // During recording: show seconds elapsed since recording started
18111                const elapsedMs = performance.now() - mediaRecorderStartTime;
18112                elapsedSeconds = elapsedMs / 1000;
18113              } else if (window.recordingStartTimestamp && progress >= 0) {
18114                // Use progress to map to intended recording duration
18115                let actualRecordingDurationMs = 5000; // Default fallback
18116                
18117                // Prioritize intended duration for accurate real-time mapping
18118                if (window.currentRecordingOptions?.intendedDuration) {
18119                  actualRecordingDurationMs = window.currentRecordingOptions.intendedDuration * 1000;
18120                } else if (mediaRecorderDuration && mediaRecorderDuration > 0) {
18121                  actualRecordingDurationMs = mediaRecorderDuration;
18122                } else if (window.gifDurationMs && window.gifDurationMs > 0) {
18123                  actualRecordingDurationMs = window.gifDurationMs;
18124                }
18125                
18126                // Calculate elapsed seconds
18127                elapsedSeconds = (progress * actualRecordingDurationMs) / 1000;
18128              } else {
18129                // Fallback: show 0 seconds (for non-recording contexts)
18130                elapsedSeconds = 0;
18131              }
18132
18133              // Format as simple seconds with one decimal place
18134              const timestamp = `${elapsedSeconds.toFixed(1)}s`;
18135
18136              sctx.save();
18137              // Make timestamp smaller to match first system
18138              const timestampSize = typeSize * 1.7; // Smaller timestamp (was 2.0)
18139              sctx.font = `bold ${timestampSize}px YWFTProcessing-Regular`;
18140              
18141              // Adjust margin to make room for Tezos logo if enabled
18142              let adjustedMargin = typeSize * 0.5;
18143              if (window.currentRecordingOptions?.showTezosStamp) {
18144                adjustedMargin = typeSize * 1.8; // Less space since we moved Tezos closer
18145              }
18146              
18147              // Calculate timestamp alpha based on progress only, independent of side stamps
18148              let timestampAlpha = 0.8; // Default to more opaque (was 0.45)
18149              
18150              // Add the same smooth fade logic as first system
18151              const isFrameBasedRecording = window.currentRecordingOptions?.frameMode;
18152              
18153              if (!isFrameBasedRecording) {
18154                // Smooth fade transitions with longer periods
18155                if (progress < 0.15) {
18156                  // Fully visible at start
18157                  timestampAlpha = 0.8;
18158                } else if (progress >= 0.15 && progress <= 0.35) {
18159                  // Longer fade out from 15% to 35% (20% fade-out period)
18160                  const fadeProgress = (progress - 0.15) / 0.20;
18161                  timestampAlpha = 0.8 * (1.0 - Math.pow(fadeProgress, 2)); // Smooth quadratic fade out
18162                } else if (progress > 0.35 && progress < 0.65) {
18163                  // Fully hidden from 35% to 65% (30% hidden period)
18164                  timestampAlpha = 0.0;
18165                } else if (progress >= 0.65 && progress <= 0.85) {
18166                  // Longer fade in from 65% to 85% (20% fade-in period)
18167                  const fadeProgress = (progress - 0.65) / 0.20;
18168                  timestampAlpha = 0.8 * Math.pow(fadeProgress, 2); // Smooth quadratic fade in
18169                } else {
18170                  // Fully visible at end (85%-100%)
18171                  timestampAlpha = 0.8;
18172                }
18173              }
18174              
18175              sctx.globalAlpha = timestampAlpha;
18176              sctx.fillStyle = "white"; // Clean white timestamp, no blinking
18177
18178              // Remove shake effect - keep timestamp steady
18179              const shakeX = 0; // No more shaking
18180              const shakeY = 0; // No more shaking
18181
18182              // Position timestamp in the main content area - move up 4px more  
18183              const timestampY = originalCanvasHeight - adjustedMargin * 0.6 - typeSize * 0.2 - 4; // Move UP 4px more
18184
18185              sctx.fillText(
18186                timestamp,
18187                typeSize * 0.5 + shakeX, // Move back right a bit (was 0.3)
18188                timestampY + shakeY,
18189              );
18190              
18191              // Removed Tezos logo from timestamp area - moved back to top-right
18192
18193              // Draw progress bar in the extended area at the bottom of the scaled content
18194              const progressBarY = originalCanvasHeight; // Start right after original content
18195              const progressBarWidth = Math.floor(progress * canvasWidth);
18196              
18197              // Fill the entire progress bar area with a dark background
18198              sctx.fillStyle = "#000000";
18199              sctx.fillRect(0, progressBarY, canvasWidth, progressBarHeight);
18200              
18201              // Sample colors from the source frame for the progress bar
18202              let frameColors = ["#FF0000"]; // Red fallback
18203              try {
18204                // Add timeout protection for this operation
18205                const colorSamplingStart = performance.now();
18206                
18207                // Get frame data from the source canvas ('can' parameter)
18208                const tempCtx = can.getContext('2d');
18209                const frameImageData = tempCtx.getImageData(0, 0, can.width, can.height);
18210                
18211                const colorSamplingTime = performance.now() - colorSamplingStart;
18212                if (colorSamplingTime > 50) {
18213                  console.warn(`🎬 ⚠️ Slow color sampling: ${colorSamplingTime.toFixed(2)}ms at progress ${progress.toFixed(4)}`);
18214                }
18215                
18216                // Sample colors across the width of the progress bar for pixel-by-pixel variety
18217                const numSamples = Math.min(progressBarWidth, 50); // Reduced from 100 to 50 for performance
18218                frameColors = [];
18219                
18220                for (let i = 0; i < numSamples; i++) {
18221                  const xPos = Math.floor((i / numSamples) * can.width);
18222                  const yPos = Math.floor(can.height * 0.5); // Sample from middle row
18223                  const pixelIndex = (yPos * can.width + xPos) * 4;
18224                  
18225                  if (pixelIndex < frameImageData.data.length - 4) {
18226                    const r = frameImageData.data[pixelIndex];
18227                    const g = frameImageData.data[pixelIndex + 1];
18228                    const b = frameImageData.data[pixelIndex + 2];
18229                    
18230                    // Enhance colors for visibility
18231                    const enhancedR = Math.min(255, Math.floor(r * 1.3));
18232                    const enhancedG = Math.min(255, Math.floor(g * 1.3));
18233                    const enhancedB = Math.min(255, Math.floor(b * 1.3));
18234                    
18235                    const hexColor = `#${enhancedR.toString(16).padStart(2, '0')}${enhancedG.toString(16).padStart(2, '0')}${enhancedB.toString(16).padStart(2, '0')}`;
18236                    frameColors.push(hexColor);
18237                  } else {
18238                    frameColors.push("#FF0000"); // Red fallback
18239                  }
18240                }
18241              } catch (error) {
18242                console.error("🎬 ❌ Frame color sampling failed at progress", progress.toFixed(4), error);
18243                frameColors = ["#FF0000"]; // Red fallback
18244              }
18245              
18246              // Draw the progress bar with frame-sampled colors
18247              try {
18248                const progressBarDrawStart = performance.now();
18249                
18250                if (frameColors.length === 1) {
18251                  // Single color fallback
18252                  sctx.fillStyle = frameColors[0];
18253                  sctx.fillRect(0, progressBarY, progressBarWidth, progressBarHeight);
18254                } else {
18255                  // Draw pixel-by-pixel or segment-by-segment with sampled colors
18256                  const segmentWidth = progressBarWidth / frameColors.length;
18257                  for (let i = 0; i < frameColors.length; i++) {
18258                    const segmentX = i * segmentWidth;
18259                    const actualSegmentWidth = Math.ceil(segmentWidth); // Ensure no gaps
18260                    
18261                    sctx.fillStyle = frameColors[i];
18262                    sctx.fillRect(segmentX, progressBarY, actualSegmentWidth, progressBarHeight);
18263                  }
18264                }
18265                
18266                // Add a subtle border
18267                sctx.strokeStyle = "#333333";
18268                sctx.lineWidth = 1;
18269                sctx.strokeRect(0, progressBarY, canvasWidth, progressBarHeight);
18270                
18271                const progressBarDrawTime = performance.now() - progressBarDrawStart;
18272                if (progressBarDrawTime > 10) {
18273                  console.warn(`🎬 ⚠️ Slow progress bar draw: ${progressBarDrawTime.toFixed(2)}ms at progress ${progress.toFixed(4)}`);
18274                }
18275              } catch (error) {
18276                console.error(`🎬 ❌ Progress bar drawing failed at progress ${progress.toFixed(4)}:`, error);
18277              }
18278
18279              sctx.restore();
18280            }
18281
18282            // Send detailed progress with phase information
18283            let phase = "encoding";
18284            let message = "Encoding video";
18285            
18286            if (progress < 0.3) {
18287              phase = "preparing";
18288              message = `Preparing frame ${currentFrameIndex + 1}`;
18289            } else if (progress < 0.7) {
18290              phase = "encoding";
18291              message = `Encoding frame ${currentFrameIndex + 1}`;
18292            } else if (progress < 0.95) {
18293              phase = "transcoding";
18294              message = "Transcoding MP4";
18295            } else {
18296              phase = "finalizing";
18297              message = "Finalizing video";
18298            }
18299
18300            send({
18301              type: "recorder:transcode-progress",
18302              content: progress,
18303            });
18304            
18305            // Send detailed status every few frames to avoid spam
18306            if (currentFrameIndex % 5 === 0 || progress >= 0.95) {
18307              send({
18308                type: "recorder:export-status",
18309                content: { 
18310                  type: "video", 
18311                  phase: phase, 
18312                  message: message 
18313                }
18314              });
18315            }
18316          },
18317        );
18318      }
18319
18320      const svgCursor = new Image();
18321      svgCursor.onload = (e) => startRendering();
18322      svgCursor.src = "/aesthetic.computer/cursors/precise.svg";
18323
18324      // Video rendered, now download...
18325      videoRecorder.onstop = async function (e) {
18326        console.log("📹 Video recording stopped. Chunks collected:", chunks.length);
18327        console.log("📹 Total video data size:", chunks.reduce((total, chunk) => total + chunk.size, 0), "bytes");
18328        
18329        const blob = new Blob(chunks, { type: videoRecorder.mimeType });
18330        console.log("📹 Final video blob size:", blob.size, "bytes, type:", blob.type);
18331        
18332        const filename = generateTapeFilename("mp4");
18333        console.log("🎬 MP4 Export - currentRecordingOptions:", JSON.stringify(window.currentRecordingOptions, null, 2));
18334
18335        // Prepare raw audio data for storage if available
18336        let rawAudioForStorage = null;
18337        if (rawAudioData.length > 0) {
18338          const totalSamples = rawAudioData.length * 4096;
18339          const leftChannel = new Float32Array(totalSamples);
18340          const rightChannel = new Float32Array(totalSamples);
18341          
18342          let sampleIndex = 0;
18343          for (let i = 0; i < rawAudioData.length; i++) {
18344            const chunk = rawAudioData[i];
18345            for (let j = 0; j < chunk.left.length; j++) {
18346              if (sampleIndex < totalSamples) {
18347                leftChannel[sampleIndex] = chunk.left[j];
18348                rightChannel[sampleIndex] = chunk.right[j];
18349                sampleIndex++;
18350              }
18351            }
18352          }
18353          
18354          rawAudioForStorage = {
18355            left: leftChannel,
18356            right: rightChannel,
18357            totalSamples: sampleIndex,
18358            sampleRate: rawAudioSampleRate
18359          };
18360          console.log(`🎵 Prepared raw audio for storage: ${sampleIndex} samples at ${rawAudioSampleRate}Hz`);
18361        }
18362
18363        // Store video with frame data for complete persistence
18364        const storeData = {
18365          blob,
18366          duration: mediaRecorderDuration,
18367          frames: recordedFrames, // Include frame data for WebP/Frame exports
18368          rawAudio: rawAudioForStorage, // Include raw audio for playback
18369          timestamp: Date.now(),
18370          filename, // Store the generated filename
18371        };
18372        
18373        // Debug: Check frame data before storage
18374        if (recordedFrames.length > 0) {
18375          const firstFrame = recordedFrames[0];
18376          console.log("💾 Storing tape with frames:", {
18377            frameCount: recordedFrames.length,
18378            firstFrameTimestamp: firstFrame[0],
18379            firstFrameTimestampType: typeof firstFrame[0],
18380            firstFrameStructure: Array.isArray(firstFrame) ? `Array(${firstFrame.length})` : typeof firstFrame,
18381            hasRawAudio: !!rawAudioForStorage
18382          });
18383        }
18384        
18385        await receivedChange({
18386          data: {
18387            type: "store:persist",
18388            content: {
18389              key: "tape",
18390              method: "local:db",
18391              data: storeData,
18392            },
18393          },
18394        });
18395
18396        // 📥 Download the video.
18397        receivedDownload({ filename, data: blob });
18398        
18399        // Send completion message
18400        send({
18401          type: "recorder:export-complete",
18402          content: { type: "video", filename }
18403        });
18404        
18405        send({ type: "recorder:printed", content: { id: filename } });
18406        stopTapePlayback?.();
18407        send({ type: "recorder:present-paused" });
18408        // startTapePlayback(true);
18409        // pauseTapePlayback?.();
18410      };
18411
18412      send({ type: "recorder:printing:started" });
18413      return;
18414    }
18415
18416    if (type === "recorder:slate") {
18417      recorderGeneration += 1;
18418      stopTapePlayback?.();
18419      if (mediaRecorder && mediaRecorder.state !== "inactive") {
18420        mediaRecorder.onstop = null;
18421        mediaRecorder.stop();
18422      }
18423      mediaRecorder = undefined;
18424      mediaRecorderStartTime = undefined;
18425      mediaRecorderDuration = 0;
18426      mediaRecorderFrameCount = 0;
18427      mediaRecorderChunks.length = 0;
18428      recordedFrames.length = 0;
18429      recordedPieceChanges.length = 0;
18430      clearRawAudioCapture();
18431      clearCompactRecorderStream();
18432      recordedVideoBlob = null;
18433      recordedVideoMime = "";
18434      recordedVideoSource = "";
18435      if (compactPlaybackUrl) {
18436        URL.revokeObjectURL(compactPlaybackUrl);
18437        compactPlaybackUrl = null;
18438      }
18439      captureSession = null;
18440      delete sfx["tape:audio"];
18441      delete window.recordingStartTimestamp;
18442      delete window.currentTapeProgress;
18443      delete window.lastVideoProgress;
18444      underlayFrame?.remove();
18445      underlayFrame = undefined;
18446      await Store.del("tape");
18447      return;
18448    }
18449
18450    if (type === "account:action") {
18451      accountActivity.action(content?.action);
18452      return;
18453    }
18454    if (type === "visit:action") {
18455      window.acVisits?.action(content?.action);
18456      return;
18457    }
18458
18459    if (type === "analytics") {
18460      captureProductAction(content?.action);
18461      return;
18462    }
18463
18464    if (type === "tape:draft-finalize") {
18465      const token = await authorize().catch(() => null);
18466      const headers = { "Content-Type": "application/json" };
18467      if (token) headers.Authorization = `Bearer ${token}`;
18468      try {
18469        const response = await fetch("/api/tape-draft", {
18470          method: "PUT",
18471          headers,
18472          body: JSON.stringify(content),
18473        });
18474        const result = await response.json();
18475        if (!response.ok) throw new Error(result.error || `HTTP ${response.status}`);
18476        activeTapeDraft = null;
18477        window.acVisits?.action(visitMediaAction("zip", result));
18478        activeTapeDraftXHR = null;
18479        send({ type: "tape:posted", content: { result: "success", ...result } });
18480      } catch (error) {
18481        send({ type: "tape:post-error", content: { error: error.message } });
18482      }
18483      return;
18484    }
18485
18486    if (type === "tape:draft-cancel") {
18487      cancelTapeDraftRequested = true;
18488      activeTapeDraftXHR?.abort();
18489      activeTapeDraftXHR = null;
18490      const draft = content?.id ? content : activeTapeDraft;
18491      activeTapeDraft = null;
18492      if (!draft?.id || !draft?.token) return;
18493      const token = await authorize().catch(() => null);
18494      const headers = { "Content-Type": "application/json" };
18495      if (token) headers.Authorization = `Bearer ${token}`;
18496      fetch("/api/tape-draft", {
18497        method: "DELETE",
18498        headers,
18499        body: JSON.stringify({ id: draft.id, token: draft.token }),
18500      }).catch((error) => console.warn("📌 Draft cleanup failed:", error));
18501      return;
18502    }
18503
18504    // Request recorded frames for export
18505    if (type === "recorder:request-frames") {
18506      if (!recordedVideoBlob) {
18507        try {
18508          const cachedTape = await Store.get("tape");
18509          if (cachedTape?.kind === "video" && cachedTape.blob) {
18510            recordedVideoBlob = cachedTape.blob;
18511            recordedVideoMime = cachedTape.mime || cachedTape.blob.type || "video/mp4";
18512            recordedVideoSource = cachedTape.source || "canvas";
18513            mediaRecorderDuration = cachedTape.duration || 0;
18514          }
18515        } catch (error) {
18516          console.warn("Could not retrieve compact video tape:", error);
18517        }
18518      }
18519
18520      if (recordedVideoBlob) {
18521        send({
18522          type: "recorder:frames-response",
18523          content: {
18524            frames: [],
18525            video: {
18526              blob: recordedVideoBlob,
18527              mime: recordedVideoMime || recordedVideoBlob.type || "video/mp4",
18528              duration: mediaRecorderDuration / 1000,
18529              source: recordedVideoSource,
18530            },
18531          },
18532        });
18533        return;
18534      }
18535
18536      if (recordedFrames.length > 0) {
18537        // Try to get raw audio from cached tape
18538        let rawAudio = null;
18539        try {
18540          const cachedTape = await Store.get("tape");
18541          if (cachedTape && cachedTape.rawAudio) {
18542            rawAudio = cachedTape.rawAudio;
18543            console.log(`🎵 Including raw audio in frame response: ${rawAudio.totalSamples} samples`);
18544          }
18545        } catch (error) {
18546          console.warn("Could not retrieve raw audio from cache:", error);
18547        }
18548        
18549        // Send frames in chunks to avoid memory issues with large recordings
18550        const CHUNK_SIZE = 1000; // Send 1000 frames at a time
18551        const totalFrames = recordedFrames.length;
18552        const totalChunks = Math.ceil(totalFrames / CHUNK_SIZE);
18553        
18554        console.log(`📦 Sending ${totalFrames} frames in ${totalChunks} chunks of ${CHUNK_SIZE} frames each`);
18555        
18556        // Send first chunk with metadata
18557        send({
18558          type: "recorder:frames-response",
18559          content: { 
18560            frames: recordedFrames.slice(0, CHUNK_SIZE),
18561            rawAudio: rawAudio, // Include audio data with first chunk
18562            chunkIndex: 0,
18563            totalChunks: totalChunks,
18564            totalFrames: totalFrames
18565          },
18566        });
18567        
18568        // Send remaining chunks
18569        for (let i = 1; i < totalChunks; i++) {
18570          const start = i * CHUNK_SIZE;
18571          const end = Math.min(start + CHUNK_SIZE, totalFrames);
18572          const chunk = recordedFrames.slice(start, end);
18573          
18574          console.log(`📦 Sending chunk ${i + 1}/${totalChunks} (frames ${start}-${end-1})`);
18575          
18576          send({
18577            type: "recorder:frames-chunk",
18578            content: { 
18579              frames: chunk,
18580              chunkIndex: i,
18581              totalChunks: totalChunks
18582            },
18583          });
18584          
18585          // Small delay between chunks to prevent overwhelming the worker
18586          await new Promise(resolve => setTimeout(resolve, 10));
18587        }
18588        
18589        console.log(`✅ Finished sending all ${totalChunks} chunks`);
18590      } else {
18591        send({
18592          type: "recorder:frames-response",
18593          content: { frames: [] },
18594        });
18595      }
18596      return;
18597    }
18598
18599    // Load a bitmap off the network.
18600    if (type === "load-bitmap") {
18601      // 🖼️ Check for embedded painting bitmaps first (pack mode / bundled HTML)
18602      console.log("🖼️ BIOS load-bitmap received:", content);
18603      console.log("🖼️ acEMBEDDED_PAINTING_BITMAPS:", window.acEMBEDDED_PAINTING_BITMAPS ? Object.keys(window.acEMBEDDED_PAINTING_BITMAPS) : "NOT SET");
18604      console.log("🖼️ acPAINTING_CODE_MAP:", window.acPAINTING_CODE_MAP || "NOT SET");
18605      if (window.acEMBEDDED_PAINTING_BITMAPS && window.acPAINTING_CODE_MAP) {
18606        // Try to find this URL in the embedded paintings by matching the slug
18607        for (const [code, info] of Object.entries(window.acPAINTING_CODE_MAP)) {
18608          console.log("🖼️ Checking code:", code, "slug:", info.slug, "includes:", content.includes(info.slug));
18609          if (content.includes(info.slug)) {
18610            const embeddedBitmap = window.acEMBEDDED_PAINTING_BITMAPS[code] ||
18611                                  window.acEMBEDDED_PAINTING_BITMAPS['#' + code];
18612            console.log("🖼️ Found slug match! embeddedBitmap:", embeddedBitmap ? `${embeddedBitmap.width}x${embeddedBitmap.height}` : "NOT FOUND");
18613            if (embeddedBitmap) {
18614              console.log("🖼️ Serving embedded painting for:", content, "→ #" + code);
18615              // Clone the pixels buffer since it will be transferred
18616              const pixelsCopy = new Uint8ClampedArray(embeddedBitmap.pixels);
18617              send(
18618                {
18619                  type: "loaded-bitmap-success",
18620                  content: { url: content, img: {
18621                    width: embeddedBitmap.width,
18622                    height: embeddedBitmap.height,
18623                    pixels: pixelsCopy
18624                  }},
18625                },
18626                [pixelsCopy.buffer],
18627              );
18628              return;
18629            }
18630          }
18631        }
18632      }
18633      
18634      const controller = new AbortController();
18635      mediaPathsLoading[content] = controller;
18636
18637      const img = document.createElement("img");
18638      img.src = content;
18639      img.crossOrigin = "anonymous";
18640
18641      const onLoad = async () => {
18642        const bitmap = await toBitmap(img);
18643        send(
18644          {
18645            type: "loaded-bitmap-success",
18646            content: { url: content, img: bitmap },
18647          },
18648          [bitmap.pixels.buffer],
18649        );
18650        img.removeEventListener("error", onError);
18651        // Clean up the loading tracker since the load completed successfully
18652        delete mediaPathsLoading[content];
18653      };
18654
18655      const onError = (err) => {
18656        // console.error(err);
18657        send({ type: "loaded-bitmap-rejection", content: { url: content } });
18658        img.removeEventListener("load", onLoad); // Remove the other listener too
18659        // Clean up the loading tracker since the load failed
18660        delete mediaPathsLoading[content];
18661      };
18662
18663      img.addEventListener("load", onLoad);
18664      img.addEventListener("error", onError);
18665
18666      controller.signal.addEventListener("abort", () => {
18667        if (debug) console.log("🖼️ Aborted image load:", content);
18668        img.removeEventListener("load", onLoad);
18669        img.removeEventListener("error", onError);
18670        // Update src to stop current loading.
18671        img.src =
18672          "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
18673        // Clean up the loading tracker since the load was aborted
18674        delete mediaPathsLoading[content];
18675      });
18676
18677      return;
18678    }
18679
18680    // 🎬 Load an animated WebP and decode all frames
18681    if (type === "load-animated-webp") {
18682      console.log("🎬 BIOS load-animated-webp received:", content);
18683      
18684      // Helper to convert webpxmux Uint32Array to RGBA Uint8ClampedArray
18685      // Each Uint32 element contains one pixel in RGBA format: 0xRRGGBBAA
18686      // We need RGBA for canvas: R, G, B, A bytes
18687      const convertToRGBA = (u32arr) => {
18688        const rgba = new Uint8ClampedArray(u32arr.length * 4);
18689        for (let i = 0; i < u32arr.length; i++) {
18690          const pixel = u32arr[i];
18691          // RGBA format: 0xRRGGBBAA
18692          const r = (pixel >>> 24) & 0xff;
18693          const g = (pixel >>> 16) & 0xff;
18694          const b = (pixel >>> 8) & 0xff;
18695          const a = pixel & 0xff;
18696          const j = i * 4;
18697          rgba[j] = r;
18698          rgba[j + 1] = g;
18699          rgba[j + 2] = b;
18700          rgba[j + 3] = a;
18701        }
18702        return rgba;
18703      };
18704      
18705      (async () => {
18706        try {
18707          // Start fetch immediately (don't wait for library)
18708          const fetchPromise = fetch(content).then(async (response) => {
18709            if (!response.ok) throw new Error(`HTTP ${response.status}`);
18710            return new Uint8Array(await response.arrayBuffer());
18711          });
18712          
18713          // Load webpxmux library in parallel with fetch (use CDN for reliability)
18714          if (!window.WebPXMux) {
18715            console.log("🎬 Loading webpxmux library...");
18716            await new Promise((resolve, reject) => {
18717              const script = document.createElement("script");
18718              script.src = "https://cdn.jsdelivr.net/npm/[email protected]/dist/webpxmux.min.js";
18719              script.onload = resolve;
18720              script.onerror = (e) => reject(new Error("Failed to load webpxmux library"));
18721              document.head.appendChild(script);
18722            });
18723          }
18724          
18725          // Cache the initialized xMux instance for reuse (use CDN wasm)
18726          if (!window._webpxMuxInstance) {
18727            const xMux = window.WebPXMux("https://cdn.jsdelivr.net/npm/[email protected]/dist/webpxmux.wasm");
18728            await xMux.waitRuntime();
18729            window._webpxMuxInstance = xMux;
18730          }
18731          const xMux = window._webpxMuxInstance;
18732          
18733          // Wait for fetch to complete
18734          const webpData = await fetchPromise;
18735          console.log("🎬 Fetched WebP data:", webpData.length, "bytes");
18736          
18737          const frames = await xMux.decodeFrames(webpData);
18738          console.log("🎬 Decoded animated WebP:", frames.frameCount, "frames,", frames.width, "x", frames.height);
18739          
18740          if (frames.frameCount > 1) {
18741            // Convert frames to transferable format
18742            const frameData = frames.frames.map(f => ({
18743              duration: f.duration,
18744              isKeyframe: f.isKeyframe,
18745              // Convert Uint32Array BGRA to Uint8ClampedArray RGBA
18746              pixels: convertToRGBA(f.rgba)
18747            }));
18748            
18749            // Transfer all pixel buffers
18750            const transfers = frameData.map(f => f.pixels.buffer);
18751            
18752            send({
18753              type: "loaded-animated-webp-success",
18754              content: {
18755                url: content,
18756                width: frames.width,
18757                height: frames.height,
18758                frameCount: frames.frameCount,
18759                loopCount: frames.loopCount,
18760                frames: frameData
18761              }
18762            }, transfers);
18763          } else {
18764            // Single frame - just decode as bitmap
18765            const bitmap = await xMux.decodeWebP(webpData);
18766            // Convert Uint32Array BGRA to Uint8ClampedArray RGBA
18767            const pixels = convertToRGBA(bitmap.rgba);
18768            
18769            send({
18770              type: "loaded-animated-webp-success",
18771              content: {
18772                url: content,
18773                width: bitmap.width,
18774                height: bitmap.height,
18775                frameCount: 1,
18776                loopCount: 0,
18777                frames: [{
18778                  duration: 0,
18779                  isKeyframe: true,
18780                  pixels: pixels
18781                }]
18782              }
18783            }, [pixels.buffer]);
18784          }
18785        } catch (err) {
18786          console.error("🎬 Animated WebP decode failed:", err);
18787          send({
18788            type: "loaded-animated-webp-rejection",
18789            content: { url: content, error: err.message }
18790          });
18791        }
18792      })();
18793      
18794      return;
18795    }
18796
18797    // Abort a loading piece of media if it exists.
18798    // TODO: Only implemented on bitmaps for now. 23.10.02.15.13
18799    if (type === "load:abort") {
18800      mediaPathsLoading[content]?.abort();
18801      return;
18802    }
18803
18804    // Load a sound from a url with instant playback support.
18805    if (type === "sfx:load") {
18806      if (debug && logs.audio)
18807        console.log("🔈 BIOS received sfx:load request for:", content);
18808
18809      let internal = false;
18810
18811      for (let wl of soundWhitelist) {
18812        if (content === wl) {
18813          internal = true;
18814          break;
18815        }
18816      }
18817
18818      let url;
18819      if (internal) {
18820        const ext = Safari ? "m4a" : "ogg";
18821        url = `/sounds/AeCo_${content}.${ext}`;
18822        if (window.production === true) {
18823          url = `https://assets.aesthetic.computer` + url;
18824        } else {
18825          url = `/assets` + url;
18826        }
18827      } else url = content;
18828
18829      if (debug && logs.audio)
18830        console.log("🔈 BIOS loading sound from URL:", url);
18831
18832      // Enhanced instant playback audio loading strategy
18833      const audioId = content;
18834
18835      // Strategy 1: Immediate HTML5 Audio for instant playback
18836      const htmlAudioElement = new Audio();
18837      htmlAudioElement.crossOrigin = "anonymous";
18838      htmlAudioElement.preload = "auto";
18839      htmlAudioElement.src = url;
18840
18841      // Store the HTML5 audio for instant playback
18842      sfx[audioId + "_html5"] = htmlAudioElement;
18843
18844      if (debug && logs.audio)
18845        console.log(
18846          "🔈 BIOS stored HTML5 audio element for:",
18847          audioId + "_html5",
18848        );
18849
18850      // Strategy 2: Background fetch and decode for high-quality playback
18851      fetch(url)
18852        .then((response) => {
18853          return response.arrayBuffer();
18854        })
18855        .then(async (arrayBuffer) => {
18856          if (debug && logs.audio)
18857            console.log("🔈 BIOS fetched audio data for:", audioId);
18858          try {
18859            if (!audioContext) {
18860              sfx[audioId] = arrayBuffer;
18861              if (debug && logs.audio)
18862                console.log(
18863                  "🔈 BIOS stored raw audio buffer (no audioContext):",
18864                  audioId,
18865                );
18866              // Process any queued sounds that might be waiting for this file
18867              processPendingSfx();
18868            } else {
18869              // Background decode the buffer
18870              const audioBuffer =
18871                await audioContext.decodeAudioData(arrayBuffer);
18872              sfx[audioId] = audioBuffer;
18873
18874              if (debug && logs.audio)
18875                console.log(
18876                  "🔈 BIOS decoded and stored audio buffer:",
18877                  audioId,
18878                );
18879
18880              // Clean up HTML5 audio once high-quality buffer is ready
18881              if (sfx[audioId + "_html5"]) {
18882                delete sfx[audioId + "_html5"];
18883              }
18884
18885              if (debug && logs.audio)
18886                console.log(
18887                  "🔈 Background decoded for high-quality playback:",
18888                  audioId,
18889                );
18890
18891              // Process any queued sounds that might be waiting for this file
18892              processPendingSfx();
18893            }
18894
18895            // The decoded buffer is the only place a sample's length is known
18896            // for free. Sending it here is what lets a piece ask how long a
18897            // loaded sound is — `sfx:get-duration` polls for a decode that,
18898            // for a remote file, it never sees finish.
18899            send({
18900              type: "loaded-sfx-success",
18901              content: {
18902                url,
18903                sfx: audioId,
18904                duration: sfx[audioId]?.duration,
18905              },
18906            });
18907            if (debug && logs.audio)
18908              console.log(
18909                "🔈 BIOS sent loaded-sfx-success (background):",
18910                audioId,
18911              );
18912          } catch (error) {
18913            if (debug && logs.audio)
18914              console.error("Background audio decoding failed:", error);
18915            // Keep the HTML5 audio as fallback
18916            send({
18917              type: "loaded-sfx-success",
18918              content: { url, sfx: audioId },
18919            });
18920          }
18921        })
18922        .catch((error) => {
18923          // Keep the HTML5 audio as fallback
18924          send({
18925            type: "loaded-sfx-success",
18926            content: { url, sfx: audioId },
18927          });
18928        });
18929
18930      // Immediately signal success with instant playback capability
18931      send({
18932        type: "loaded-sfx-success",
18933        content: { url, sfx: audioId, instantPlayback: true },
18934      });
18935      if (debug && logs.audio)
18936        console.log("🔈 BIOS sent loaded-sfx-success (instant):", audioId);
18937
18938      return;
18939    }
18940
18941    // Trigger a sound to playback.
18942    if (type === "sfx:play") {
18943      playSfx(content.id, content.sfx, content.options);
18944      return;
18945    }
18946
18947    if (type === "sfx:register") {
18948      const { id, data, sampleRate } = content || {};
18949      if (!id || !data || !Array.isArray(data) || data.length === 0) return;
18950
18951      const safeRate =
18952        typeof sampleRate === "number" && Number.isFinite(sampleRate)
18953          ? sampleRate
18954          : audioContext?.sampleRate || 48000;
18955      const samples = Float32Array.from(data);
18956
18957      if (audioContext) {
18958        const buffer = audioContext.createBuffer(1, samples.length, safeRate);
18959        buffer.getChannelData(0).set(samples);
18960        sfx[id] = buffer;
18961      } else {
18962        sfx[id] = {
18963          left: Array.from(samples),
18964          right: Array.from(samples),
18965          totalSamples: samples.length,
18966          sampleRate: safeRate,
18967        };
18968      }
18969      if (sfxLoaded && typeof sfxLoaded === "object") {
18970        sfxLoaded[id] = false;
18971      }
18972      return;
18973    }
18974
18975    // 🔄 Live buffer update - updates sample data for currently playing sounds
18976    if (type === "sfx:update-sample") {
18977      console.log(`🔴 BIOS: Received sfx:update-sample for ${content?.id}`);
18978      const { id, data, sampleRate } = content || {};
18979      if (!id || !data || !Array.isArray(data) || data.length === 0) return;
18980
18981      const safeRate =
18982        typeof sampleRate === "number" && Number.isFinite(sampleRate)
18983          ? sampleRate
18984          : audioContext?.sampleRate || 48000;
18985      const samples = Float32Array.from(data);
18986
18987      // Update the local sfx cache
18988      if (audioContext) {
18989        const buffer = audioContext.createBuffer(1, samples.length, safeRate);
18990        buffer.getChannelData(0).set(samples);
18991        sfx[id] = buffer;
18992      } else {
18993        sfx[id] = {
18994          left: Array.from(samples),
18995          right: Array.from(samples),
18996          totalSamples: samples.length,
18997          sampleRate: safeRate,
18998        };
18999      }
19000
19001      // Send update to speaker worklet for running sounds
19002      if (speakerProcessorNode) {
19003        speakerProcessorNode.port.postMessage({
19004          type: "sample:update",
19005          data: {
19006            label: id,
19007            buffer: {
19008              channels: [samples],
19009              length: samples.length,
19010              sampleRate: safeRate,
19011            },
19012          },
19013        });
19014      }
19015      return;
19016    }
19017
19018    if (type === "sfx:update") {
19019      sfxPlaying[content.id]?.update(content.properties);
19020      return;
19021    }
19022
19023    if (type === "sfx:get-sample-data") {
19024      async function checkForSampleData() {
19025        if (audioContext) {
19026          const audioBuffer = await decodeSfx(content.id);
19027
19028          if (!audioBuffer) {
19029            setTimeout(checkForSampleData, 100);
19030          } else {
19031            const dataFloat32 = audioBuffer.getChannelData(0);
19032            const data = Array.from(dataFloat32);
19033
19034            send({
19035              type: "sfx:got-sample-data",
19036              content: { id: content.id, data },
19037            });
19038          }
19039        } else {
19040          setTimeout(checkForSampleData, 100);
19041          // await checkForSampleData();
19042        }
19043      }
19044
19045      checkForSampleData();
19046      return;
19047    }
19048
19049    if (type === "sfx:get-duration") {
19050      async function checkForDuration() {
19051        if (audioContext) {
19052          const audioBuffer = await decodeSfx(content.id);
19053
19054          if (!audioBuffer) {
19055            setTimeout(checkForDuration, 100);
19056          } else {
19057            // Just send duration, not the expensive sample data
19058            send({
19059              type: "sfx:got-duration",
19060              content: { id: content.id, duration: audioBuffer.duration },
19061            });
19062          }
19063        } else {
19064          setTimeout(checkForDuration, 100);
19065        }
19066      }
19067
19068      checkForDuration();
19069      return;
19070    }
19071
19072    // Stop a playing sound or sample if it exists,
19073    // with an optional 'after' parameter for a fade out.
19074    if (type === "sfx:kill") {
19075      sfxPlaying[content.id]?.kill(content.fade);
19076      return;
19077    }
19078
19079    //if (type === "sfx:fade") {
19080    // sfxPlaying[content.id]?.kill();
19081    //  return;
19082    //}
19083
19084    // Report progress of a playing sound back to the disk.
19085    if (type === "sfx:progress") {
19086      sfxPlaying[content.id]?.progress();
19087      return;
19088    }
19089
19090    // 🎵 Streaming Audio (Radio, etc.)
19091    // Start playing a streaming audio URL
19092    if (type === "stream:play") {
19093      const { id, url, volume, cors } = content;
19094
19095      // Stop any existing stream with this id
19096      if (streamAudio[id]) {
19097        streamAudio[id].audio.pause();
19098        streamAudio[id].audio.src = "";
19099        delete streamAudio[id];
19100      }
19101
19102      const baseVolume = clampVolume(volume ?? 1);
19103
19104      // Cache-bust the stream URL to force a fresh connection and avoid stale
19105      // buffered data. Matches the approach used by kpbj.fm's own navbar player.
19106      // (blob: object urls — dropped local files — don't take query strings.)
19107      const bustedUrl = url.startsWith("blob:")
19108        ? url
19109        : url + (url.includes("?") ? "&" : "?") + "t=" + Date.now();
19110
19111      // Per-station CORS policy:
19112      //   cors === false → Icecast/no-CORS server (e.g. stream.kpbj.fm); skip
19113      //     crossOrigin entirely so we connect in one round-trip (no analyser).
19114      //   cors !== false → default; try crossOrigin="anonymous" so we can hook
19115      //     up the frequency analyser (e.g. radio.co supports CORS). If the
19116      //     load is blocked, fall back to a no-CORS retry automatically.
19117      const corsAllowed = cors !== false;
19118
19119      // Wire audio lifecycle events to rich telemetry so the UI can show
19120      // "Connecting…" → "Buffering…" → "● LIVE" with real detail.
19121      const attachTelemetry = (audio) => {
19122        const emitState = (state, extra) =>
19123          send({
19124            type: "stream:state",
19125            content: { id, state, ...(extra || {}) },
19126          });
19127        audio.addEventListener("loadstart", () => emitState("connecting"));
19128        audio.addEventListener("progress", () => emitState("loading"));
19129        audio.addEventListener("canplay", () => emitState("ready"));
19130        audio.addEventListener("waiting", () => emitState("buffering"));
19131        audio.addEventListener("playing", () => emitState("playing"));
19132        audio.addEventListener("stalled", () => emitState("stalled"));
19133        audio.addEventListener("error", () => {
19134          const err = audio.error;
19135          emitState("error", {
19136            code: err?.code,
19137            message: err?.message || "audio error",
19138          });
19139        });
19140      };
19141
19142      const tryPlay = (withCors) => {
19143        const audio = new Audio();
19144        if (withCors) audio.crossOrigin = "anonymous";
19145        audio.src = bustedUrl;
19146        audio.volume = baseVolume * masterVolume;
19147
19148        attachTelemetry(audio);
19149
19150        const entry = { audio, baseVolume };
19151
19152        if (withCors && audioContext) {
19153          try {
19154            const source = audioContext.createMediaElementSource(audio);
19155            const analyser = audioContext.createAnalyser();
19156            analyser.fftSize = 256;
19157            source.connect(analyser);
19158            analyser.connect(audioContext.destination);
19159            entry.analyser = analyser;
19160            entry.source = source;
19161          } catch (e) {
19162            // createMediaElementSource throws if the element is already hooked
19163            // up elsewhere; fall through and keep the plain audio entry.
19164          }
19165        }
19166
19167        streamAudio[id] = entry;
19168        send({ type: "stream:state", content: { id, state: "connecting" } });
19169
19170        return audio.play().then(() => {
19171          send({ type: "stream:playing", content: { id } });
19172        });
19173      };
19174
19175      if (!corsAllowed) {
19176        // Skip the CORS probe entirely for known non-CORS servers.
19177        tryPlay(false).catch((err) => {
19178          console.warn("🎵 Stream play failed:", err);
19179          send({
19180            type: "stream:error",
19181            content: { id, error: err.message },
19182          });
19183        });
19184        return;
19185      }
19186
19187      tryPlay(true).catch((err) => {
19188        // CORS rejection surfaces as MediaError code 4 / NotSupportedError.
19189        // Tear down the tainted audio element and retry without crossOrigin.
19190        console.warn(
19191          "🎵 Stream play with CORS failed, retrying without:",
19192          err?.message || err,
19193        );
19194        if (streamAudio[id]) {
19195          try {
19196            streamAudio[id].audio.pause();
19197            streamAudio[id].audio.src = "";
19198          } catch (_) {}
19199          delete streamAudio[id];
19200        }
19201        tryPlay(false).catch((err2) => {
19202          console.warn("🎵 Stream play failed:", err2);
19203          send({
19204            type: "stream:error",
19205            content: { id, error: err2.message },
19206          });
19207        });
19208      });
19209
19210      return;
19211    }
19212    
19213    // Pause a streaming audio
19214    if (type === "stream:pause") {
19215      const { id } = content;
19216      if (streamAudio[id]?.audio) {
19217        streamAudio[id].audio.pause();
19218        send({ type: "stream:paused", content: { id } });
19219      }
19220      return;
19221    }
19222    
19223    // Resume a paused streaming audio
19224    if (type === "stream:resume") {
19225      const { id } = content;
19226      if (streamAudio[id]?.audio) {
19227        streamAudio[id].audio.play().then(() => {
19228          send({ type: "stream:playing", content: { id } });
19229        }).catch(err => {
19230          send({ type: "stream:error", content: { id, error: err.message } });
19231        });
19232      }
19233      return;
19234    }
19235    
19236    // Stop and remove streaming audio
19237    if (type === "stream:stop") {
19238      const { id } = content;
19239      if (streamAudio[id]) {
19240        streamAudio[id].audio.pause();
19241        streamAudio[id].audio.src = "";
19242        delete streamAudio[id];
19243        send({ type: "stream:stopped", content: { id } });
19244      }
19245      return;
19246    }
19247    
19248    // Set volume of streaming audio
19249    if (type === "stream:volume") {
19250      const { id, volume } = content;
19251      if (streamAudio[id]?.audio) {
19252        const baseVolume = clampVolume(volume);
19253        streamAudio[id].baseVolume = baseVolume;
19254        streamAudio[id].audio.volume = baseVolume * masterVolume;
19255      }
19256      return;
19257    }
19258    
19259    // Get frequency data for visualization
19260    if (type === "stream:frequencies") {
19261      const { id } = content;
19262      if (streamAudio[id]?.analyser) {
19263        const analyser = streamAudio[id].analyser;
19264        const dataArray = new Uint8Array(analyser.frequencyBinCount);
19265        analyser.getByteFrequencyData(dataArray);
19266        send({ type: "stream:frequencies-data", content: { id, data: Array.from(dataArray) } });
19267      } else {
19268        send({ type: "stream:frequencies-data", content: { id, data: [] } });
19269      }
19270      return;
19271    }
19272    
19273    // Get waveform (time-domain) data for visualization (fallback for iOS Safari)
19274    if (type === "stream:waveform") {
19275      const { id } = content;
19276      if (streamAudio[id]?.analyser) {
19277        const analyser = streamAudio[id].analyser;
19278        const dataArray = new Uint8Array(analyser.frequencyBinCount);
19279        analyser.getByteTimeDomainData(dataArray);
19280        send({ type: "stream:waveform-data", content: { id, data: Array.from(dataArray) } });
19281      } else {
19282        send({ type: "stream:waveform-data", content: { id, data: [] } });
19283      }
19284      return;
19285    }
19286
19287    // Seek streaming audio to a specific time position
19288    if (type === "stream:seek") {
19289      const { id, time } = content;
19290      if (streamAudio[id]?.audio) {
19291        streamAudio[id].audio.currentTime = time;
19292        send({ type: "stream:seeked", content: { id, time } });
19293      }
19294      return;
19295    }
19296
19297    // Set playback rate (dj pitch fader / scratch). preservesPitch is turned
19298    // off so speed bends pitch like vinyl. HTMLMediaElement can't run in
19299    // reverse, so the rate floors just above zero — backward scratching is
19300    // the piece's job (silent seeks).
19301    if (type === "stream:speed") {
19302      const { id, speed } = content;
19303      const entry = streamAudio[id];
19304      if (entry?.audio) {
19305        const rate = Math.min(4, Math.max(0.0625, speed));
19306        entry.audio.preservesPitch = false;
19307        entry.audio.webkitPreservesPitch = false;
19308        entry.audio.playbackRate = rate;
19309        send({ type: "stream:speed-data", content: { id, speed: rate } });
19310      }
19311      return;
19312    }
19313
19314    // Whole-track peak overview for waveform strips (dj). Fetches and decodes
19315    // the full file alongside the streaming element — needs CORS-readable
19316    // audio (the ac-electron loopback server sends ACAO: *). Replies with an
19317    // empty array on any failure so pieces can fall back to a plain bar.
19318    if (type === "stream:peaks") {
19319      const { id, url, count } = content;
19320      (async () => {
19321        const res = await fetch(url);
19322        const buf = await res.arrayBuffer();
19323        // The main audioContext only exists after sound activation; a
19324        // suspended throwaway context decodes just fine before that.
19325        const ctx = audioContext || new AudioContext();
19326        const decoded = await ctx.decodeAudioData(buf);
19327        if (ctx !== audioContext) ctx.close();
19328        const chan = decoded.getChannelData(0);
19329        const n = Math.max(16, Math.min(4096, count || 1024));
19330        const step = Math.floor(chan.length / n) || 1;
19331        const peaks = new Array(n).fill(0);
19332        for (let i = 0; i < n; i += 1) {
19333          let max = 0;
19334          const end = Math.min((i + 1) * step, chan.length);
19335          for (let j = i * step; j < end; j += 16) {
19336            const v = Math.abs(chan[j]);
19337            if (v > max) max = v;
19338          }
19339          peaks[i] = max;
19340        }
19341        send({ type: "stream:peaks-data", content: { id, peaks } });
19342      })().catch((err) => {
19343        console.warn("🎵 stream:peaks failed:", err?.message || err);
19344        send({ type: "stream:peaks-data", content: { id, peaks: [] } });
19345      });
19346      return;
19347    }
19348
19349    // Get current playback time, duration, and buffer state
19350    if (type === "stream:time") {
19351      const { id } = content;
19352      if (streamAudio[id]?.audio) {
19353        const audio = streamAudio[id].audio;
19354        const buffered = audio.buffered.length > 0
19355          ? audio.buffered.end(audio.buffered.length - 1)
19356          : 0;
19357        send({
19358          type: "stream:time-data",
19359          content: {
19360            id,
19361            currentTime: audio.currentTime,
19362            duration: Number.isFinite(audio.duration) ? audio.duration : 0,
19363            buffered,
19364            paused: audio.paused,
19365            ended: audio.ended,
19366          },
19367        });
19368      } else {
19369        send({
19370          type: "stream:time-data",
19371          content: { id, currentTime: 0, duration: 0, buffered: 0, paused: true, ended: false },
19372        });
19373      }
19374      return;
19375    }
19376
19377    if (type === "fullscreen-enable") {
19378      curReframeDelay = 0;
19379      enableFullscreen();
19380      return;
19381    }
19382
19383    if (type === "fps-change") {
19384      Loop.frameRate(content);
19385      return;
19386    }
19387
19388    if (type === "glaze") {
19389      if (debug && logs.glaze) {
19390        console.log("🪟 Glaze:", content, "Type:", content.type || "prompt");
19391      }
19392      const wasOn = glaze.on;
19393      glaze = content;
19394      if (glaze.on === false) {
19395        Glaze.clear(); // Clear WebGL buffer before hiding to prevent stale content
19396        Glaze.off();
19397        canvas.style.removeProperty("opacity");
19398      } else if (glaze.on === true && !wasOn) {
19399        // 🧊 Clear any stuck freeze frame before enabling glaze
19400        // This fixes a race condition where navigating back to a glaze piece
19401        // (like prompt in dark mode) could leave the previous piece's freeze frame
19402        // visible because glazeReady was false while shaders were loading.
19403        if (freezeFrame || freezeFrameFrozen) {
19404          if (wrapper.contains(freezeFrameCan)) {
19405            freezeFrameCan.remove();
19406          }
19407          canvas.style.removeProperty("opacity");
19408          freezeFrame = false;
19409          freezeFrameGlaze = false;
19410          freezeFrameFrozen = false;
19411        }
19412        // Glaze was just enabled - set flag to trigger reframe on next paint cycle
19413        needsReframe = true;
19414        canvas.style.opacity = 0; // Hide main canvas until glaze is ready
19415        // Force a paint cycle to ensure the reframe happens
19416        send({ type: "needs-paint" });
19417      }
19418      // Note: Glaze gets turned on only on a call to `resize` or `gap` via a piece,
19419      // or when enabling glaze after it was off (handled above).
19420      return;
19421    }
19422
19423    if (type === "disk-loaded-and-booted") {
19424      // The worker also boots a blank init disk before the requested piece
19425      // loads. Its ready signal cannot finish page telemetry or freeze timings.
19426      // A real piece need not define paint; its completed boot is sufficient.
19427      if (currentPiece !== null) {
19428        perf.markBoot("disk-loaded-and-booted");
19429        window.acBOOT_SUCCESS?.(); // Idempotent across later navigations.
19430        accountActivity.ready();
19431      }
19432
19433      // Skip preload marker on default init piece, and toggle it if necessary.
19434      if (currentPiece !== null && !window.waitForPreload)
19435        window.preloaded = true;
19436      //if (debug && logs.loading)
19437      //  console.log("⏳ Preloaded:", window.preloaded ? "✅" : "❌");
19438      
19439      // Note: Boot canvas is now hidden by "piece-paint-ready" signal instead
19440      // This allows the noise16 default to show through the boot overlay
19441      
19442      // 🔷 Auto-restore Tezos wallet session (Beacon stores in localStorage)
19443      restoreWalletSession().catch(() => {}); // Fire and forget, errors logged inside
19444      
19445      // Notify parent that disk is ready
19446      if (window.parent) {
19447        window.parent.postMessage({ 
19448          type: "boot-log", 
19449          message: `ready: ${currentPiece || 'prompt'}` 
19450        }, "*");
19451        window.parent.postMessage({ type: "ready" }, "*");
19452      }
19453      
19454      // Print perf report
19455      perf.printReport();
19456      
19457      // Fallback: if piece uses default paint (no custom paint), still hide boot canvas after a short delay
19458      // This handles edge cases where piece-paint-ready is never sent.
19459      // Only once a real piece is loaded: the worker also sends this event for
19460      // its blank init disk, ~200 ms in, before the requested piece is even
19461      // fetched. With the old animated boot the mood-of-the-day wait hid that;
19462      // the empty boot has no such wait, so this timer declared the page booted
19463      // (window.acBOOTED, the ac:booted event that lets analytics load, the
19464      // iOS boot:ready ping) while the piece was still downloading.
19465      if (currentPiece !== null) {
19466        setTimeout(() => {
19467          if (window.acHIDE_BOOT_LOG) {
19468            window.acHIDE_BOOT_LOG();
19469          }
19470        }, 500);
19471      }
19472    }
19473    
19474    // 🎨 Hide boot canvas when piece's paint function takes over from default noise16
19475    if (type === "aesel-preview") {
19476      window.dispatchEvent(new CustomEvent("aesel-preview", {detail: content}));
19477      consumeDiskSends(send);
19478      return;
19479    }
19480    if (type === "piece-paint-ready") {
19481      if (window.acHIDE_BOOT_LOG) {
19482        window.acHIDE_BOOT_LOG();
19483      }
19484      consumeDiskSends(send);
19485      return;
19486    }
19487    if (type === "back-to-piece") {
19488      // Check if we have labelBack state active
19489      if (mainThreadLabelBack) {
19490        // Clear the labelBack state since we're handling the navigation
19491        mainThreadLabelBack = false;
19492        window.safeSessionStorageRemove("aesthetic-labelBack");
19493
19494        // Navigate directly to the target piece from worker instead of using history.back()
19495        // This avoids the reload cycle for kidlisp pieces
19496        const targetPiece = content?.targetPiece || "chat"; // Use target from worker or fallback to chat
19497
19498        // Update the URL and load the target piece directly
19499        const targetUrl = new URL(window.location);
19500        targetUrl.pathname = "/" + targetPiece;
19501        window.history.pushState(null, "", targetUrl);
19502
19503        const parsed = parse(targetPiece);
19504        send({
19505          type: "history-load",
19506          content: parsed,
19507        });
19508      } else {
19509        history.back();
19510      }
19511      return false;
19512    }
19513
19514    if (type === "disk-unload") {
19515      return;
19516    }
19517
19518    // 🖼️ HD layer teardown — the worker dropped its hi-res surface (piece
19519    // change), so hide ours and release the last bitmap. If an hd tape was
19520    // mid-encode, the tape now spans mixed content — abandon the hd blob and
19521    // let export fall back to the classic frame re-render.
19522    if (type === "hd:clear") {
19523      if (hdTapeRecorder) hdTapeAbort();
19524      if (hdCan) {
19525        hdCan.style.display = "none";
19526        try {
19527          hdCtx.transferFromImageBitmap(null);
19528        } catch (err) {
19529          // Nothing to release.
19530        }
19531      }
19532      return;
19533    }
19534
19535    // 🌟 Update & Render (Compositing)
19536    // IMPORTANT: Always release the frame pump latch for render/update.
19537    // If we return early (pixel mismatch) or throw during compositing, bios can stop
19538    // requesting frames entirely (frameAlreadyRequested stays true).
19539    if (!(type === "render" || type === "update")) return;
19540    if (!content) {
19541      frameAlreadyRequested = false;
19542      clearFrameStallWatchdog();
19543      return;
19544    }
19545
19546    try {
19547
19548    // Mark first render message from worker
19549    if (!window._firstRenderReceived) {
19550      window._firstRenderReceived = true;
19551      perf.markBoot("first-render-from-worker");
19552    }
19553
19554    if (content.TwoD) {
19555      TwoD?.pack(content.TwoD);
19556    }
19557
19558    // 🖼️ HD layer — composite this frame's native-resolution bitmap.
19559    if (content.hd?.bitmap) updateHDLayer(content.hd);
19560
19561    updateSynths(content.sound); // 🔈 Trigger any audio that was called upon.
19562
19563    // 🖥️ Compositing - OPTIMIZED for zero-copy pixel buffer transfer
19564
19565    // TEMPORARILY DISABLE PIXEL OPTIMIZER to debug black buffer dragging issue
19566    /*
19567    if (!window.pixelOptimizer) {
19568      try {
19569        const { pixelOptimizer } = await import('./lib/graphics-optimizer.mjs');
19570        window.pixelOptimizer = pixelOptimizer;
19571      } catch (err) {
19572        console.warn('🟡 Graphics optimizer not available, using fallback:', err);
19573      }
19574    }
19575    */
19576
19577    // This is a bit messy compared to what happens inside of content.reframe -> frame below. 22.10.27.02.05
19578    if (
19579      content.pixels?.byteLength > 0 &&
19580      content.width === screen.width &&
19581      content.height === screen.height
19582    ) {
19583      // 🚀 OPTIMIZATION: Use zero-copy ImageData creation when possible
19584      if (window.pixelOptimizer) {
19585        try {
19586          imageData = window.pixelOptimizer.createImageDataZeroCopy(
19587            content.pixels, 
19588            content.width, 
19589            content.height
19590          );
19591          // Update screen reference to use the zero-copy buffer
19592          assign(screen, {
19593            pixels: imageData.data,
19594            width: content.width,
19595            height: content.height,
19596          });
19597          markReframePixelsReceived("zero-copy", content.width, content.height);
19598          window.pixelOptimizer.stats.framesProcessed++;
19599        } catch (err) {
19600          console.warn('🟡 Zero-copy optimization failed, using fallback:', err);
19601          // Fallback to original method
19602          screen.pixels = new Uint8ClampedArray(content.pixels);
19603          let width = screen.width;
19604          let height = screen.height;
19605          const expectedLength = width * height * 4;
19606          
19607          // Handle reframeJustCompleted separately so it's not blocked by content.reframe
19608          if (reframeJustCompleted) {
19609            if (screen.pixels.length === expectedLength) {
19610              imageData = new ImageData(screen.pixels, width, height);
19611              if (underlayFrame) {
19612                console.log('🔄 REFRAME PATH (pixelOptimizer): Created fresh imageData with dimensions:', width, 'x', height);
19613              }
19614              markReframePixelsReceived("pixelOptimizer-reframe", width, height);
19615            }
19616            reframeJustCompleted = false;
19617          } else if (
19618            !content.reframe &&
19619            screen.pixels.length === expectedLength
19620          ) {
19621            imageData = new ImageData(screen.pixels, width, height);
19622            markReframePixelsReceived("pixelOptimizer", width, height);
19623            if (underlayFrame) {
19624              // console.log("🎬 Fallback ImageData created during tape playback");
19625            }
19626          }
19627        }
19628      } else {
19629        // Original fallback code
19630        screen.pixels = new Uint8ClampedArray(content.pixels);
19631        let width = screen.width;
19632        let height = screen.height;
19633        const expectedLength = width * height * 4;
19634
19635        // Handle reframeJustCompleted separately so it's not blocked by content.reframe
19636        if (reframeJustCompleted) {
19637          if (screen.pixels.length === expectedLength) {
19638            imageData = new ImageData(screen.pixels, width, height);
19639            markReframePixelsReceived("fallback-reframe", width, height);
19640          }
19641          reframeJustCompleted = false;
19642        } else if (
19643          !content.reframe &&
19644          screen.pixels.length === expectedLength
19645        ) {
19646          imageData = new ImageData(screen.pixels, width, height);
19647          markReframePixelsReceived("fallback", width, height);
19648        }
19649        // REMOVED: Special blocking case for underlayFrame + content.reframe
19650        // This was preventing imageData updates during tape playback reframes
19651      }
19652    } else if (reframeJustCompleted && content.pixels?.byteLength > 0) {
19653      // Special case: after reframe with new dimensions, create ImageData even if screen dimensions don't match yet
19654      if (content.width !== canvas.width || content.height !== canvas.height) {
19655        if (window.acReframeDebug) {
19656          console.log("⚠️ REFRAME: post-reframe size mismatch, deferring", {
19657            contentWidth: content.width,
19658            contentHeight: content.height,
19659            canvasWidth: canvas.width,
19660            canvasHeight: canvas.height,
19661          });
19662        }
19663      } else {
19664        try {
19665          if (window.pixelOptimizer) {
19666            // 🚀 OPTIMIZATION: Zero-copy reframe handling
19667            imageData = window.pixelOptimizer.createImageDataZeroCopy(
19668              content.pixels,
19669              content.width,
19670              content.height
19671            );
19672            // Update screen dimensions to match
19673            assign(screen, {
19674              pixels: imageData.data,
19675              width: content.width,
19676              height: content.height,
19677            });
19678            markReframePixelsReceived("post-reframe-zero-copy", content.width, content.height);
19679            window.pixelOptimizer.stats.framesProcessed++;
19680          } else {
19681            // Original fallback
19682            const newPixels = new Uint8ClampedArray(content.pixels);
19683            imageData = new ImageData(newPixels, content.width, content.height);
19684            // Update screen dimensions to match
19685            assign(screen, {
19686              pixels: imageData.data,
19687              width: content.width,
19688              height: content.height,
19689            });
19690            markReframePixelsReceived("post-reframe", content.width, content.height);
19691          }
19692          reframeJustCompleted = false;
19693        } catch (err) {
19694          console.warn("⚠️ Failed to create post-reframe ImageData:", err);
19695          reframeJustCompleted = false;
19696        }
19697      }
19698    }
19699
19700    // old threed garbage collection (remove)
19701
19702    // Check for a change in resolution.
19703    if (content.reframe) {
19704      // Reframe the captured pixels.
19705      frame(content.reframe.width, content.reframe.height, content.reframe.gap);
19706      pen?.retransformPosition();
19707    }
19708
19709    if (content.cursorCode) pen?.setCursorCode(content.cursorCode);
19710
19711    // Abort the render if pixels don't match.
19712    if (
19713      content.dirtyBox === undefined &&
19714      content.pixels?.length !== undefined &&
19715      content.pixels?.length !== screen.pixels.length
19716    ) {
19717      if (underlayFrame) console.log("🛑 VIDEO: Aborted render - pixel buffer mismatch!", "Content:", content.pixels.length, "Screen:", screen.pixels.length);
19718      console.warn("Aborted render. Pixel buffers did not match.");
19719      console.log(
19720        "Content pixels:",
19721        content.pixels.length,
19722        "Screen:",
19723        screen.pixels.length,
19724        content.didntRender,
19725        content.reframe,
19726        "Freeze:",
19727        freezeFrame,
19728      );
19729      frameAlreadyRequested = false; // 🗨️ Tell the system we are ready for another frame.
19730      clearFrameStallWatchdog();
19731      return;
19732    }
19733
19734    let dirtyBoxBitmapCan;
19735
19736    // 👌 Otherwise, grab all the pixels, or some, if `dirtyBox` is present.
19737    if (content.dirtyBox) {
19738      // 🅰️ Cropped update - OPTIMIZED for zero-copy and async rendering
19739      if (window.pixelOptimizer) {
19740        try {
19741          // Use optimized dirty box rendering
19742          const imageData = window.pixelOptimizer.createImageDataZeroCopy(
19743            content.pixels,
19744            content.dirtyBox.w,
19745            content.dirtyBox.h
19746          );
19747
19748          // Reuse the dirtyBox canvas instead of creating new ones
19749          if (
19750            dirtyBoxCanvas.width !== imageData.width ||
19751            dirtyBoxCanvas.height !== imageData.height
19752          ) {
19753            dirtyBoxCanvas.width = imageData.width;
19754            dirtyBoxCanvas.height = imageData.height;
19755          }
19756
19757          // Use async rendering for better performance
19758          if (window.pixelOptimizer.asyncRenderingSupported && imageData.width * imageData.height > 1024) {
19759            window.pixelOptimizer.renderImageDataAsync(imageData, dirtyBoxCtx, 0, 0);
19760          } else {
19761            dirtyBoxCtx.putImageData(imageData, 0, 0);
19762          }
19763          
19764          dirtyBoxBitmapCan = dirtyBoxCanvas; // Reference the reused canvas
19765          
19766        } catch (err) {
19767          console.warn('🟡 Optimized dirty box rendering failed, using fallback:', err);
19768          // Fallback to original method
19769          const imageData = new ImageData(
19770            new Uint8ClampedArray(content.pixels),
19771            content.dirtyBox.w,
19772            content.dirtyBox.h,
19773          );
19774
19775          if (
19776            dirtyBoxCanvas.width !== imageData.width ||
19777            dirtyBoxCanvas.height !== imageData.height
19778          ) {
19779            dirtyBoxCanvas.width = imageData.width;
19780            dirtyBoxCanvas.height = imageData.height;
19781          }
19782
19783          dirtyBoxCtx.putImageData(imageData, 0, 0);
19784          dirtyBoxBitmapCan = dirtyBoxCanvas;
19785        }
19786      } else {
19787        // Original fallback code
19788        const imageData = new ImageData(
19789          new Uint8ClampedArray(content.pixels), // Is this the only necessary part?
19790          content.dirtyBox.w,
19791          content.dirtyBox.h,
19792        );
19793
19794        // Reuse the dirtyBox canvas instead of creating new ones
19795        if (
19796          dirtyBoxCanvas.width !== imageData.width ||
19797          dirtyBoxCanvas.height !== imageData.height
19798        ) {
19799          dirtyBoxCanvas.width = imageData.width;
19800          dirtyBoxCanvas.height = imageData.height;
19801        }
19802
19803        dirtyBoxCtx.putImageData(imageData, 0, 0);
19804        dirtyBoxBitmapCan = dirtyBoxCanvas; // Reference the reused canvas
19805
19806        // Use this alternative once it's faster. 2022.01.29.02.46
19807        // const dbCtx = dirtyBoxBitmapCan.getContext("bitmaprenderer");
19808        // dbCtx.transferFromImageBitmap(dirtyBoxBitmap);
19809      }
19810    } else if (content.paintChanged && content.pixels && (!content.reframe || (content.width === canvas.width && content.height === canvas.height))) {
19811      // 🅱️ Normal full-screen update (allow during reframe if dimensions match to prevent black screen)
19812      if (window.pixelOptimizer) {
19813        try {
19814          // Use zero-copy optimization for full screen updates
19815          imageData = window.pixelOptimizer.createImageDataZeroCopy(
19816            content.pixels,
19817            content.width,
19818            content.height
19819          );
19820        } catch (err) {
19821          console.warn('🟡 Zero-copy full screen update failed, using fallback:', err);
19822          // Fallback to original method
19823          const pixelArray = new Uint8ClampedArray(content.pixels);
19824          const expectedLength = content.width * content.height * 4;
19825
19826          if (pixelArray.length === expectedLength || reframeJustCompleted) {
19827            if (pixelArray.length === expectedLength) {
19828              imageData = new ImageData(pixelArray, content.width, content.height);
19829            }
19830            if (reframeJustCompleted) {
19831              reframeJustCompleted = false;
19832            }
19833          }
19834        }
19835      } else {
19836        // Original fallback code
19837        const pixelArray = new Uint8ClampedArray(content.pixels);
19838        const expectedLength = content.width * content.height * 4;
19839
19840        // Only create ImageData if pixel buffer length matches expected dimensions
19841        // Be more lenient right after reframe completion to restore animation
19842        if (pixelArray.length === expectedLength || reframeJustCompleted) {
19843          if (pixelArray.length === expectedLength) {
19844            imageData = new ImageData(pixelArray, content.width, content.height);
19845          }
19846          // Reset flag regardless of whether ImageData creation succeeded
19847          if (reframeJustCompleted) {
19848            reframeJustCompleted = false;
19849          }
19850        }
19851      }
19852    }
19853
19854    pixelsDidChange = content.paintChanged || false;
19855
19856    const useWebGLComposite = shouldUseWebGLComposite(content);
19857    const webglCompositeActive = useWebGLComposite && webglBlitter?.isReady();
19858    // During tape playback (underlayFrame), overlays must paint to the main canvas
19859    // since overlayCan is hidden to let the video underlay show through.
19860    let overlayTargetCtx = (webglCompositeActive && !underlayFrame) ? octx : ctx;
19861
19862    // ✨ UI Overlay (Composite) Layer
19863    // This currently paints corner labels and tape progress bars only.
19864    // (So they can be skipped for recordings?)
19865    let paintOverlays = {};
19866
19867    // Frame-persistent overlay cache that survives reframes
19868    if (!window.framePersistentOverlayCache) {
19869      window.framePersistentOverlayCache = {};
19870    }
19871
19872    // Per-overlay canvas cache to prevent canvas thrashing
19873    if (!window.overlayCanvasCache) {
19874      window.overlayCanvasCache = {};
19875    }
19876
19877    function buildOverlay(name, o) {
19878      // Skip ALL overlays in clean mode (no stamps, no progress bars, no labels)
19879      if (window.currentRecordingOptions?.cleanMode) {
19880        return;
19881      }
19882      
19883      // Only log reframe operations to debug flicker
19884      const isHudOverlay = name === "label" || name === "qrOverlay" || name === "qrCornerLabel" || name === "qrFullscreenLabel" || name === "authorOverlay" || name === "bumperOverlay";
19885
19886      // Skip tape progress bar in clean mode only
19887      if (name === "tapeProgressBar" && window.currentRecordingOptions?.cleanMode) {
19888        console.log("🎬 📼 Skipping tape progress bar in clean mode");
19889        return;
19890      }
19891
19892      if (!o || !o.img) {
19893        // During reframes, if overlay data is missing but we have a cached version, use it
19894        // EXCEPT for tapeProgressBar, merryProgressBar, durationProgressBar, durationTimecode, qrOverlay and bumperOverlay which should never use cached versions
19895        if (content.reframe && window.framePersistentOverlayCache[name] && name !== "tapeProgressBar" && name !== "merryProgressBar" && name !== "demoplayCard" && name !== "durationProgressBar" && name !== "durationTimecode" && name !== "qrOverlay" && name !== "qrCornerLabel" && name !== "qrFullscreenLabel" && name !== "authorOverlay" && name !== "bumperOverlay" && name !== "updateBadge") {
19896          paintOverlays[name] = window.framePersistentOverlayCache[name];
19897          return;
19898        }
19899        return;
19900      }
19901
19902      // Create or reuse dedicated canvas for this overlay
19903      if (!window.overlayCanvasCache[name]) {
19904        window.overlayCanvasCache[name] = {
19905          canvas: document.createElement("canvas"),
19906          lastKey: null,
19907        };
19908        window.overlayCanvasCache[name].ctx =
19909          window.overlayCanvasCache[name].canvas.getContext("2d");
19910      }
19911
19912      const overlayCache = window.overlayCanvasCache[name];
19913      
19914      // Create a content-aware cache key that includes pixel data hash for HUD overlays
19915      let currentKey = `${o.img.width}x${o.img.height}_${o.x}_${o.y}`;
19916      
19917      // For HUD overlays (like labels), disable caching to ensure real-time updates for KidLisp syntax highlighting
19918      if (isHudOverlay && name === "label") {
19919        overlayCache.lastKey = null; // Force regeneration every frame like QR overlay
19920        delete window.framePersistentOverlayCache[name]; // Clear persistent cache
19921        currentKey += `_${performance.now()}`; // Force unique key every time
19922      }
19923      
19924      // For tape progress bar, completely disable ALL caching to ensure every frame is painted
19925      if (name === "tapeProgressBar") {
19926        overlayCache.lastKey = null; // Force regeneration every frame
19927        delete window.framePersistentOverlayCache[name]; // Clear persistent cache
19928        currentKey += `_${performance.now()}`; // Force unique key every time
19929      }
19930      
19931      // For demoplay card, completely disable ALL caching (opacity changes every frame)
19932      if (name === "demoplayCard") {
19933        overlayCache.lastKey = null;
19934        delete window.framePersistentOverlayCache[name];
19935        currentKey += `_${performance.now()}`;
19936      }
19937
19938      // For merry progress bar, completely disable ALL caching to ensure every frame is painted
19939      if (name === "merryProgressBar") {
19940        overlayCache.lastKey = null; // Force regeneration every frame
19941        delete window.framePersistentOverlayCache[name]; // Clear persistent cache
19942        currentKey += `_${performance.now()}`; // Force unique key every time
19943      }
19944      
19945      // For duration progress bar, completely disable ALL caching to ensure every frame is painted
19946      if (name === "durationProgressBar") {
19947        overlayCache.lastKey = null; // Force regeneration every frame
19948        delete window.framePersistentOverlayCache[name]; // Clear persistent cache
19949        currentKey += `_${performance.now()}`; // Force unique key every time
19950      }
19951      
19952      // For duration timecode, completely disable ALL caching to ensure every frame is painted
19953      if (name === "durationTimecode") {
19954        overlayCache.lastKey = null; // Force regeneration every frame
19955        delete window.framePersistentOverlayCache[name]; // Clear persistent cache
19956        currentKey += `_${performance.now()}`; // Force unique key every time
19957      }
19958      
19959      // For QR overlay, completely disable ALL caching to allow text label font loading
19960      if (name === "qrOverlay") {
19961        overlayCache.lastKey = null; // Force regeneration every frame
19962        delete window.framePersistentOverlayCache[name]; // Clear persistent cache
19963        currentKey += `_${performance.now()}`; // Force unique key every time
19964      }
19965      
19966      // For QR corner label, completely disable ALL caching to allow text label font loading
19967      if (name === "qrCornerLabel") {
19968        overlayCache.lastKey = null; // Force regeneration every frame
19969        delete window.framePersistentOverlayCache[name]; // Clear persistent cache
19970        currentKey += `_${performance.now()}`; // Force unique key every time
19971      }
19972      
19973      // For QR fullscreen label, completely disable ALL caching to allow text label font loading
19974      if (name === "qrFullscreenLabel") {
19975        overlayCache.lastKey = null; // Force regeneration every frame
19976        delete window.framePersistentOverlayCache[name]; // Clear persistent cache
19977        currentKey += `_${performance.now()}`; // Force unique key every time
19978      }
19979
19980      // 📦 Update badge pulses every frame.
19981      if (name === "updateBadge") {
19982        overlayCache.lastKey = null;
19983        delete window.framePersistentOverlayCache[name];
19984        currentKey += `_${performance.now()}`;
19985      }
19986
19987      // Only rebuild if overlay actually changed
19988      // Force rebuild every frame for tape progress bar, merry progress bar, duration progress bar, duration timecode and QR overlay (no caching)
19989      if (
19990        name !== "tapeProgressBar" &&
19991        name !== "merryProgressBar" &&
19992        name !== "demoplayCard" &&
19993        name !== "durationProgressBar" &&
19994        name !== "durationTimecode" &&
19995        name !== "qrOverlay" &&
19996        name !== "qrCornerLabel" &&
19997        name !== "qrFullscreenLabel" &&
19998        overlayCache.lastKey === currentKey &&
19999        window.framePersistentOverlayCache[name]
20000      ) {
20001        paintOverlays[name] = window.framePersistentOverlayCache[name];
20002        return;
20003      }
20004      
20005      // DEBUG: Log cache invalidation for HUD overlays
20006      if (isHudOverlay && overlayCache.lastKey && overlayCache.lastKey !== currentKey) {
20007        // Cache invalidation tracking removed for cleaner console output
20008      }
20009
20010      overlayCache.lastKey = currentKey;
20011
20012      // Debug: Log when creating paint function for qrFullscreenLabel
20013      if (name === "qrFullscreenLabel") {
20014        console.log(`🔍 Creating paint function for qrFullscreenLabel`);
20015      }
20016
20017      paintOverlays[name] = () => {
20018        const canvas = overlayCache.canvas;
20019        const overlayCtx = overlayCache.ctx;
20020
20021        overlayCtx.imageSmoothingEnabled = false;
20022
20023        // Resize this overlay's dedicated canvas if needed
20024        if (canvas.width !== o.img.width || canvas.height !== o.img.height) {
20025          canvas.width = o.img.width;
20026          canvas.height = o.img.height;
20027        }
20028
20029        try {
20030          let imageData;
20031          // Add debug logging for durationTimecode
20032          if (name === "durationTimecode") {
20033            console.log(`🕐 Creating ImageData for timecode:`, {
20034              pixelsType: typeof o.img.pixels,
20035              pixelsLength: o.img.pixels.length,
20036              width: o.img.width,
20037              height: o.img.height,
20038              expectedLength: o.img.width * o.img.height * 4
20039            });
20040          }
20041          
20042          // Add debug logging for qrFullscreenLabel and qrOverlay
20043          if (name === "qrFullscreenLabel") {
20044            console.log(`🔍 Creating ImageData for qrFullscreenLabel:`, {
20045              pixelsType: typeof o.img.pixels,
20046              pixelsLength: o.img.pixels.length,
20047              width: o.img.width,
20048              height: o.img.height,
20049              expectedLength: o.img.width * o.img.height * 4
20050            });
20051          }
20052          
20053
20054          
20055          // Use graphics optimizer if available, fallback to traditional method
20056          if (window.pixelOptimizer) {
20057            imageData = window.pixelOptimizer.createImageDataZeroCopy(
20058              o.img.pixels.buffer || o.img.pixels,
20059              o.img.width,
20060              o.img.height,
20061            );
20062          } else {
20063            imageData = new ImageData(
20064              o.img.pixels,
20065              o.img.width,
20066              o.img.height,
20067            );
20068          }
20069          overlayCtx.putImageData(imageData, 0, 0);
20070          
20071          if (name === "durationTimecode") {
20072            console.log(`🕐 Successfully created and painted ImageData for timecode`);
20073          }
20074          
20075          if (name === "qrFullscreenLabel") {
20076            console.log(`🔍 Successfully created and painted ImageData for qrFullscreenLabel`);
20077          }
20078        } catch (error) {
20079          console.error(`❌ Error creating ImageData for ${name}:`, error);
20080          return;
20081        }
20082
20083        // Paint overlay to main canvas (ctx is the main canvas context)
20084        if (name === "tapeProgressBar") {
20085          overlayTargetCtx.drawImage(canvas, o.x, o.y);
20086        } else {
20087          // Add debug logging for durationTimecode
20088          if (name === "durationTimecode") {
20089            console.log(`🕐 Drawing durationTimecode to main canvas at (${o.x}, ${o.y}) with size ${canvas.width}x${canvas.height}`);
20090          }
20091          
20092          // Add debug logging for qrFullscreenLabel
20093          if (name === "qrFullscreenLabel") {
20094            console.log(`🔍 Drawing qrFullscreenLabel to main canvas at (${o.x}, ${o.y}) with size ${canvas.width}x${canvas.height}`);
20095          }
20096          
20097          overlayTargetCtx.drawImage(canvas, o.x, o.y);
20098          
20099          if (name === "durationTimecode") {
20100            console.log(`🕐 Finished drawing durationTimecode`);
20101          }
20102          
20103          if (name === "qrFullscreenLabel") {
20104            console.log(`🔍 Finished drawing qrFullscreenLabel`);
20105          }
20106        }
20107      };
20108
20109      // Debug logging for tape progress bar painter creation
20110      if (name === "tapeProgressBar" && false) { // Disabled verbose logging
20111        console.log(`🏗️ Created painter for "${name}":`, {
20112          painterExists: !!paintOverlays[name],
20113          canvasSize: `${overlayCache.canvas.width}x${overlayCache.canvas.height}`
20114        });
20115      }
20116
20117      // Don't cache QR overlay painters to allow animation
20118      // Don't cache tapeProgressBar, merryProgressBar or durationProgressBar painters either - force regeneration every frame
20119      // Don't cache authorOverlay or bumperOverlay as they change per piece
20120      if (isHudOverlay && name !== "qrOverlay" && name !== "qrCornerLabel" && name !== "qrFullscreenLabel" && name !== "authorOverlay" && name !== "bumperOverlay" && name !== "tapeProgressBar" && name !== "merryProgressBar" && name !== "demoplayCard" && name !== "durationProgressBar") {
20121        window.framePersistentOverlayCache[name] = paintOverlays[name];
20122      }
20123    }
20124
20125    // 🎬 Recording UI overlay painters (rendered to recordingUICan, NOT captured in tapes)
20126    let recordingUIOverlays = {};
20127    
20128    function buildRecordingUIOverlay(name, o) {
20129      if (!o || !o.img) return;
20130      
20131      // Create or reuse dedicated canvas for this overlay
20132      if (!window.recordingUIOverlayCache) {
20133        window.recordingUIOverlayCache = {};
20134      }
20135      if (!window.recordingUIOverlayCache[name]) {
20136        window.recordingUIOverlayCache[name] = {
20137          canvas: document.createElement("canvas"),
20138          lastKey: null,
20139        };
20140        window.recordingUIOverlayCache[name].ctx =
20141          window.recordingUIOverlayCache[name].canvas.getContext("2d");
20142      }
20143      
20144      const overlayCache = window.recordingUIOverlayCache[name];
20145      const currentKey = `${o.img.width}x${o.img.height}_${o.x}_${o.y}_${performance.now()}`;
20146      
20147      // Always regenerate recording UI overlays (no caching)
20148      overlayCache.lastKey = null;
20149      
20150      if (overlayCache.canvas.width !== o.img.width) {
20151        overlayCache.canvas.width = o.img.width;
20152      }
20153      if (overlayCache.canvas.height !== o.img.height) {
20154        overlayCache.canvas.height = o.img.height;
20155      }
20156      
20157      const overlayCtx = overlayCache.ctx;
20158      overlayCtx.imageSmoothingEnabled = false;
20159      
20160      const imageData = new ImageData(
20161        new Uint8ClampedArray(o.img.pixels),
20162        o.img.width,
20163        o.img.height,
20164      );
20165      overlayCtx.putImageData(imageData, 0, 0);
20166      
20167      // Create painter function that draws to recordingUICtx
20168      recordingUIOverlays[name] = () => {
20169        if (o.opacity !== undefined && o.opacity < 1) {
20170          recordingUICtx.globalAlpha = o.opacity;
20171        }
20172        recordingUICtx.drawImage(overlayCache.canvas, o.x, o.y);
20173        if (o.opacity !== undefined && o.opacity < 1) {
20174          recordingUICtx.globalAlpha = 1;
20175        }
20176      };
20177    }
20178
20179    buildOverlay("bumperOverlay", content.bumperOverlay); // 🎪 Bumper ticker at the top
20180    buildOverlay("label", content.label);
20181    buildOverlay("qrOverlay", content.qrOverlay);
20182    buildOverlay("qrCornerLabel", content.qrCornerLabel);
20183    buildOverlay("qrFullscreenLabel", content.qrFullscreenLabel);
20184    buildOverlay("authorOverlay", content.authorOverlay); // 👤 Author attribution for KidLisp pieces
20185    if (content.updateBadge) {
20186      // The worker draws the checkbox from its own copy of this flag, and that
20187      // copy starts `false` in every fresh worker. Whenever the drawn state
20188      // disagrees with ours, say it again — otherwise the box sits showing ✗
20189      // over a setting that is really on, and the next tap looks like it did
20190      // nothing because it toggled away from a state nobody could see.
20191      if (content.updateBadge.auto !== updateAutoReload) {
20192        send({ type: "update:auto-state", content: updateAutoReload });
20193      }
20194      if (updateAutoReload && !window.acUpdateReloadTimer) {
20195        armUpdateReload(1250, content.updateBadge.silent === true);
20196      }
20197    }
20198    // A silent badge carries no image — it exists only to say an update is
20199    // ready, so the arm above can take it.
20200    if (!content.updateBadge?.silent) {
20201      buildOverlay("updateBadge", content.updateBadge); // 📦 Update-ready ↑ badge (top-right)
20202    }
20203    buildOverlay("merryProgressBar", content.merryProgressBar); // 🎄 Merry pipeline progress bar
20204    buildOverlay("demoplayCard", content.demoplayCard); // 🎬 Demoplay text card overlay
20205    buildOverlay("tapeProgressBar", content.tapeProgressBar);
20206    buildOverlay("durationProgressBar", content.durationProgressBar);
20207    buildOverlay("durationTimecode", content.durationTimecode);
20208    buildOverlay("hitboxDebug", content.hitboxDebug); // Debug overlay for HUD hitbox visualization
20209    
20210    // 🎬 Build recording UI overlays (NOT captured in recordings)
20211    if (content.recordingUI) {
20212      for (const [name, overlayData] of Object.entries(content.recordingUI)) {
20213        buildRecordingUIOverlay(name, overlayData);
20214      }
20215    }
20216    
20217    // Debug: Log overlay data reception
20218    if (content.durationTimecode) {
20219      console.log("🕐 BIOS received durationTimecode:", {
20220        x: content.durationTimecode.x,
20221        y: content.durationTimecode.y,
20222        width: content.durationTimecode.img?.width,
20223        height: content.durationTimecode.img?.height,
20224        pixelsLength: content.durationTimecode.img?.pixels?.length,
20225        hasPixels: !!content.durationTimecode.img?.pixels
20226      });
20227    }
20228    // console.log("🖼️ Received overlay data:", {
20229    //   hasLabel: !!content.label,
20230    //   hasQR: !!content.qrOverlay,
20231    //   hasTapeProgress: !!content.tapeProgressBar,
20232    //   labelDetails: content.label ? {
20233    //     x: content.label.x,
20234    //     y: content.label.y,
20235    //     width: content.label.img?.width,
20236    //     height: content.label.img?.height,
20237    //     pixelLength: content.label.img?.pixels?.length,
20238    //     hasPixels: !!content.label.img?.pixels
20239    //   } : null
20240    // });
20241
20242    function draw() {
20243      // 🎯 Dynamic FPS Detection for display-rate independent recording
20244      const currentTime = performance.now();
20245      if (lastFrameTime > 0) {
20246        const frameTime = currentTime - lastFrameTime;
20247        frameTimes.push(frameTime);
20248        
20249        // Keep only recent frame timings for accurate FPS detection
20250        if (frameTimes.length > FPS_SAMPLE_SIZE) {
20251          frameTimes.shift();
20252        }
20253        
20254        // Calculate actual display FPS after we have enough samples
20255        if (frameTimes.length >= FPS_SAMPLE_SIZE) {
20256          const averageFrameTime = frameTimes.reduce((sum, time) => sum + time, 0) / frameTimes.length;
20257          detectedDisplayFPS = Math.round(1000 / averageFrameTime);
20258          // console.log(`🎯 Detected display FPS: ${detectedDisplayFPS}`);
20259        }
20260      }
20261      lastFrameTime = currentTime;
20262      
20263      // During tape playback, keep canvas fully opaque but transparent in its pixels
20264      // (the video underlay sits behind; the piece uses wipe(0,0,0,0) for transparency)
20265      if (underlayFrame) {
20266        canvas.style.removeProperty("opacity");
20267        canvas.style.removeProperty("mix-blend-mode");
20268        canvas.style.background = "transparent";
20269      } else {
20270        // Restore canvas styling when not playing tape
20271        canvas.style.removeProperty("opacity");
20272        canvas.style.removeProperty("mix-blend-mode");
20273        canvas.style.removeProperty("background");
20274      }
20275
20276      // �🅰️ Draw updated content from the piece.
20277
20278      const db = content.dirtyBox;
20279      if (db) {
20280        if (!underlayFrame && webglCompositeActive && imageData) {
20281          // Don't show rendering canvas if awaiting reframe pixels with dimension mismatch
20282          const canShowCanvas = !awaitingReframePixels || (imageData.width === canvas.width && imageData.height === canvas.height);
20283          if (canvas.style.visibility !== "hidden") {
20284            canvas.style.visibility = "hidden";
20285          }
20286          if (canShowCanvas && webglCompositeCanvas.style.display === "none") {
20287            webglCompositeCanvas.style.display = "block";
20288          }
20289          if (canShowCanvas && overlayCan.style.display === "none") {
20290            overlayCan.style.display = "block";
20291          }
20292          if (webgpuCanvas.style.display !== "none") {
20293            webgpuCanvas.style.display = "none";
20294          }
20295          if (canShowCanvas) {
20296            webglBlitter.render(imageData);
20297          }
20298        } else {
20299          if (canvas.style.visibility !== "visible") {
20300            canvas.style.visibility = "visible";
20301          }
20302          if (webglCompositeCanvas.style.display !== "none") {
20303            webglCompositeCanvas.style.display = "none";
20304          }
20305          if (overlayCan.style.display !== "none") {
20306            overlayCan.style.display = "none";
20307          }
20308          ctx.drawImage(dirtyBoxBitmapCan, db.x, db.y);
20309          if (glaze.on && glazeReady) Glaze.update(dirtyBoxBitmapCan, db.x, db.y);
20310        }
20311      } else if (
20312        pixelsDidChange ||
20313        needs$creenshot ||
20314        mediaRecorder?.state === "recording"
20315      ) {
20316        // 🚀 OPTIMIZATION: Async bitmap rendering with safety checks
20317        let skipImmediateOverlays = false; // Flag to skip immediate overlay painting for async rendering
20318        const isRecording =
20319          mediaRecorder?.state === "recording" &&
20320          mediaRecorderStartTime !== undefined;
20321        const isPlaybackOnly =
20322          underlayFrame && !isRecording && !needs$creenshot;
20323
20324        // 🎪 Calculate bumper offset for piece content positioning (before dimension check)
20325        const bumperOffset = content.bumperOverlay?.img?.height || 0;
20326
20327        if (
20328          imageData &&
20329          imageData.data &&
20330          imageData.data.buffer &&
20331          imageData.data.buffer.byteLength > 0 &&
20332          imageData.width === ctx.canvas.width &&
20333          (imageData.height === ctx.canvas.height || imageData.height === ctx.canvas.height - bumperOffset)
20334        ) {
20335          // Dimensions match (accounting for bumper offset) - clear mismatch counter and log if we just recovered
20336          if (underlayFrame && dimensionMismatchCount > 0 && window.acReframeDebug) {
20337            console.log('✅ REFRAME: Dimension sync restored after', dimensionMismatchCount, 'mismatched frames. Canvas:', ctx.canvas.width, 'x', ctx.canvas.height);
20338            dimensionMismatchCount = 0;
20339          }
20340          // Use async rendering for better performance (except during tape playback for immediate UI)
20341          const forceSynchronousRendering = isRecording || needs$creenshot;
20342
20343          // Skip CPU rendering if WebGPU is enabled
20344          if (content.webgpuEnabled) {
20345            // Switch backend if piece requests a specific one
20346            if (content.gpuBackend && activeGPUBackend?.getName() !== content.gpuBackend) {
20347              log.gpu.debug?.(`Switching to requested backend: ${content.gpuBackend}`);
20348              window.switchGPUBackend?.(content.gpuBackend).catch(err => {
20349                log.gpu.warn?.(`Failed to switch to ${content.gpuBackend}:`, err);
20350              });
20351            }
20352
20353            // Don't show rendering canvas if awaiting reframe pixels with dimension mismatch
20354            const canShowCanvas = !awaitingReframePixels || (imageData.width === canvas.width && imageData.height === canvas.height);
20355
20356            // Hide CPU canvas and rely on WebGPU surface
20357            if (canvas.style.visibility !== "hidden") {
20358              canvas.style.visibility = "hidden";
20359            }
20360            if (canShowCanvas && webgpuCanvas.style.display === "none") {
20361              webgpuCanvas.style.display = "block";
20362            }
20363            if (webglCompositeCanvas.style.display !== "none") {
20364              webglCompositeCanvas.style.display = "none";
20365            }
20366            if (overlayCan.style.display !== "none") {
20367              overlayCan.style.display = "none";
20368            }
20369            skipImmediateOverlays = true; // Overlays will be handled separately
20370          } else if (webglCompositeActive && !underlayFrame) {
20371            // Don't show rendering canvas if awaiting reframe pixels with dimension mismatch
20372            const canShowCanvas = !awaitingReframePixels || (imageData.width === canvas.width && imageData.height === canvas.height);
20373            if (canvas.style.visibility !== "hidden") {
20374              canvas.style.visibility = "hidden";
20375            }
20376            if (canShowCanvas && webglCompositeCanvas.style.display === "none") {
20377              webglCompositeCanvas.style.display = "block";
20378            }
20379            if (canShowCanvas && overlayCan.style.display === "none") {
20380              overlayCan.style.display = "block";
20381            }
20382            if (webgpuCanvas.style.display !== "none") {
20383              webgpuCanvas.style.display = "none";
20384            }
20385            if (canShowCanvas) {
20386              webglBlitter.render(imageData);
20387            }
20388            skipImmediateOverlays = false;
20389          } else if (underlayFrame) {
20390            if (canvas.style.visibility !== "visible") {
20391              canvas.style.visibility = "visible";
20392            }
20393            if (webgpuCanvas.style.display !== "none") {
20394              webgpuCanvas.style.display = "none";
20395            }
20396            if (webglCompositeCanvas.style.display !== "none") {
20397              webglCompositeCanvas.style.display = "none";
20398            }
20399            if (overlayCan.style.display !== "none") {
20400              overlayCan.style.display = "none";
20401            }
20402            // Force sync rendering during tape playback for immediate UI updates
20403            ctx.putImageData(imageData, 0, bumperOffset);
20404          } else if (!forceSynchronousRendering && window.pixelOptimizer && window.pixelOptimizer.asyncRenderingSupported) {
20405            try {
20406              if (canvas.style.visibility !== "visible") {
20407                canvas.style.visibility = "visible";
20408              }
20409              if (webgpuCanvas.style.display !== "none") {
20410                webgpuCanvas.style.display = "none";
20411              }
20412              if (webglCompositeCanvas.style.display !== "none") {
20413                webglCompositeCanvas.style.display = "none";
20414              }
20415              if (overlayCan.style.display !== "none") {
20416                overlayCan.style.display = "none";
20417              }
20418              // Non-blocking async rendering
20419              window.pixelOptimizer.renderImageDataAsync(imageData, ctx, 0, bumperOffset).then(() => {
20420                // Paint overlays after async fallback rendering completes
20421                if (paintOverlays["bumperOverlay"]) paintOverlays["bumperOverlay"](); // Paint bumper first (at the top)
20422                if (paintOverlays["label"]) paintOverlays["label"]();
20423                if (paintOverlays["qrOverlay"]) paintOverlays["qrOverlay"]();
20424                if (paintOverlays["qrCornerLabel"]) paintOverlays["qrCornerLabel"]();
20425                if (paintOverlays["qrFullscreenLabel"]) paintOverlays["qrFullscreenLabel"]();
20426                if (paintOverlays["authorOverlay"]) paintOverlays["authorOverlay"]();
20427                if (paintOverlays["updateBadge"]) paintOverlays["updateBadge"](); // 📦 Update-ready badge (top-right)
20428                if (paintOverlays["tapeProgressBar"] && !window.currentRecordingOptions?.cleanMode) paintOverlays["tapeProgressBar"]();
20429                if (paintOverlays["durationProgressBar"]) paintOverlays["durationProgressBar"]();
20430                if (paintOverlays["hitboxDebug"]) paintOverlays["hitboxDebug"](); // Debug overlay (green hitbox)
20431              }).catch(err => {
20432                console.warn('🟡 Fallback async rendering failed:', err);
20433                ctx.putImageData(imageData, 0, bumperOffset);
20434                skipImmediateOverlays = false;
20435              });
20436              skipImmediateOverlays = true; // Skip immediate overlays; they'll paint in async callback
20437            } catch (err) {
20438              if (canvas.style.visibility !== "visible") {
20439                canvas.style.visibility = "visible";
20440              }
20441              if (webgpuCanvas.style.display !== "none") {
20442                webgpuCanvas.style.display = "none";
20443              }
20444              if (webglCompositeCanvas.style.display !== "none") {
20445                webglCompositeCanvas.style.display = "none";
20446              }
20447              if (overlayCan.style.display !== "none") {
20448                overlayCan.style.display = "none";
20449              }
20450              ctx.putImageData(imageData, 0, bumperOffset);
20451              skipImmediateOverlays = false;
20452            }
20453          } else {
20454            if (canvas.style.visibility !== "visible") {
20455              canvas.style.visibility = "visible";
20456            }
20457            if (webgpuCanvas.style.display !== "none") {
20458              webgpuCanvas.style.display = "none";
20459            }
20460            if (webglCompositeCanvas.style.display !== "none") {
20461              webglCompositeCanvas.style.display = "none";
20462            }
20463            if (overlayCan.style.display !== "none") {
20464              overlayCan.style.display = "none";
20465            }
20466            ctx.putImageData(imageData, 0, bumperOffset);
20467            skipImmediateOverlays = false;
20468          }
20469        } else {
20470          // Dimension mismatch - handle differently for tape playback vs normal
20471          if (underlayFrame) {
20472            // During tape playback, keep the canvas at correct size and wait for matching data
20473            dimensionMismatchCount++;
20474            if (dimensionMismatchCount === 1 || dimensionMismatchCount % 10 === 0) {
20475              console.log('⏸️ REFRAME: Dimension mismatch #' + dimensionMismatchCount + '. Canvas:', ctx.canvas.width, 'x', ctx.canvas.height, '| ImageData:', imageData?.width, 'x', imageData?.height);
20476            }
20477            // Clear canvas to transparent so the video underlay shows through
20478            // instead of stale opaque pixels from before the video piece loaded.
20479            ctx.clearRect(0, 0, canvas.width, canvas.height);
20480            skipImmediateOverlays = true; // Don't paint overlays
20481            // Keep requesting paint so we get fresh data with correct dimensions
20482            setTimeout(() => send({ type: "needs-paint" }), 0);
20483          } else {
20484            // For normal pieces, try to recover by getting fresh imageData from canvas
20485            // BUT: Don't try to recover from canvas if we're awaiting reframe pixels
20486            // (the canvas was just cleared by resize, reading it would give black pixels)
20487            if (
20488              !awaitingReframePixels &&
20489              imageData &&
20490              imageData.data &&
20491              imageData.data.buffer &&
20492              (imageData.data.buffer.byteLength === 0 ||
20493                imageData.width !== ctx.canvas.width ||
20494                imageData.height !== ctx.canvas.height)
20495            ) {
20496              imageData = ctx.getImageData(
20497                0,
20498                0,
20499                ctx.canvas.width,
20500                ctx.canvas.height,
20501              );
20502              if (imageData.data.buffer.byteLength > 0) {
20503                // 🎪 Calculate bumper offset for piece content positioning
20504                const bumperOffset = content.bumperOverlay?.img?.height || 0;
20505
20506                // Use async rendering for better performance (except during tape playback for immediate UI)
20507                const forceSynchronousRendering = isRecording || needs$creenshot;
20508                if (!forceSynchronousRendering && window.pixelOptimizer && window.pixelOptimizer.asyncRenderingSupported) {
20509                  try {
20510                    window.pixelOptimizer.renderImageDataAsync(imageData, ctx, 0, bumperOffset).then(() => {
20511                      if (paintOverlays["bumperOverlay"]) paintOverlays["bumperOverlay"](); // Paint bumper first
20512                      if (paintOverlays["label"]) paintOverlays["label"]();
20513                      if (paintOverlays["qrOverlay"]) paintOverlays["qrOverlay"]();
20514                      if (paintOverlays["qrCornerLabel"]) paintOverlays["qrCornerLabel"]();
20515                      if (paintOverlays["qrFullscreenLabel"]) paintOverlays["qrFullscreenLabel"]();
20516                      if (paintOverlays["authorOverlay"]) paintOverlays["authorOverlay"]();
20517                      if (paintOverlays["updateBadge"]) paintOverlays["updateBadge"](); // 📦 Update-ready badge
20518                      if (paintOverlays["tapeProgressBar"] && !window.currentRecordingOptions?.cleanMode) paintOverlays["tapeProgressBar"]();
20519                      if (paintOverlays["durationProgressBar"]) paintOverlays["durationProgressBar"]();
20520                      if (paintOverlays["hitboxDebug"]) paintOverlays["hitboxDebug"]();
20521                    }).catch(err => {
20522                      console.warn('🟡 Async rendering failed:', err);
20523                      ctx.putImageData(imageData, 0, bumperOffset);
20524                      skipImmediateOverlays = false;
20525                    });
20526                    skipImmediateOverlays = true; // Skip immediate overlays; they'll paint in async callback
20527                  } catch (err) {
20528                    console.warn('🟡 Async render setup failed, using fallback:', err);
20529                    ctx.putImageData(imageData, 0, bumperOffset);
20530                    skipImmediateOverlays = false;
20531                  }
20532                } else {
20533                  ctx.putImageData(imageData, 0, bumperOffset);
20534                  skipImmediateOverlays = false;
20535                }
20536              }
20537            }
20538          }
20539        }
20540
20541        // Store clean pixel data for worker communication (before overlays)
20542        if (
20543          !isPlaybackOnly &&
20544          imageData &&
20545          imageData.data &&
20546          imageData.data.buffer &&
20547          imageData.data.buffer.byteLength > 0
20548        ) {
20549          assign(screen, {
20550            pixels: new Uint8ClampedArray(imageData.data),
20551            width: ctx.canvas.width,
20552            height: ctx.canvas.height,
20553          });
20554        }
20555
20556        if (freezeFrameFrozen && !awaitingReframePixels) {
20557          reframeDrawn = true;
20558          reframeDrawnAt = performance.now();
20559        }
20560
20561        // Check if we need to record frames (before painting overlays)
20562        // const isRecording determined earlier for overlay decisions
20563
20564        // 📸 Capture clean screenshot data BEFORE overlays are painted (only when needed)
20565        let cleanScreenshotData = null;
20566        if (needs$creenshot) {
20567          if (webglCompositeActive && !underlayFrame && webglCompositeCanvas) {
20568            const capCtx = ensureCaptureCompositeCanvas(
20569              ctx.canvas.width,
20570              ctx.canvas.height,
20571            );
20572            capCtx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
20573            capCtx.drawImage(webglCompositeCanvas, 0, 0);
20574            cleanScreenshotData = capCtx.getImageData(
20575              0,
20576              0,
20577              ctx.canvas.width,
20578              ctx.canvas.height,
20579            );
20580          } else {
20581            cleanScreenshotData = ctx.getImageData(
20582              0,
20583              0,
20584              ctx.canvas.width,
20585              ctx.canvas.height,
20586            );
20587          }
20588        }
20589
20590        // Paint overlays (but exclude tape progress from recordings)
20591        if (webglCompositeActive && !underlayFrame) {
20592          octx.clearRect(0, 0, octx.canvas.width, octx.canvas.height);
20593        }
20594        
20595        // console.log("🎨 Available overlay painters:", Object.keys(paintOverlays));
20596        let tapeProgressPainter = paintOverlays["tapeProgressBar"] || null;
20597        if (!tapeProgressPainter && content.tapeProgressBar) {
20598          buildOverlay("tapeProgressBar", content.tapeProgressBar);
20599          tapeProgressPainter = paintOverlays["tapeProgressBar"] || null;
20600        }
20601        let paintTapeProgressAfterCapture = false;
20602
20603        // 🎪 Paint bumper overlay first (at the top)
20604        if (!skipImmediateOverlays && paintOverlays["bumperOverlay"]) {
20605          paintOverlays["bumperOverlay"]();
20606        }
20607
20608        if (paintOverlays["label"]) {
20609          if (!skipImmediateOverlays || isRecording || needs$creenshot) {
20610            paintOverlays["label"]();
20611          } else {
20612            // console.log("🏷️ Skipping immediate label overlay painting (async mode)");
20613          }
20614        } else {
20615          // Label overlay painter not found (no logging)
20616        }
20617
20618        if (!skipImmediateOverlays && paintOverlays["qrOverlay"]) {
20619          paintOverlays["qrOverlay"]();
20620        }
20621
20622        // 👤 Paint author overlay (bottom-left attribution for KidLisp pieces)
20623        if (!skipImmediateOverlays && paintOverlays["authorOverlay"]) {
20624          paintOverlays["authorOverlay"]();
20625        }
20626
20627        // 📦 Update-ready badge (top-right) — drawn after corner overlays so
20628        // it sits on top of everything else.
20629        if (!skipImmediateOverlays && paintOverlays["updateBadge"]) {
20630          paintOverlays["updateBadge"]();
20631        }
20632
20633        // Paint hitbox debug overlay immediately (green, shows button hitbox)
20634        if (!skipImmediateOverlays && paintOverlays["hitboxDebug"]) {
20635          paintOverlays["hitboxDebug"]();
20636        }
20637
20638        // Paint merry progress bar immediately (at the top, green theme)
20639        if (paintOverlays["merryProgressBar"]) {
20640          paintOverlays["merryProgressBar"]();
20641        } else if (content.merryProgressBar) {
20642          buildOverlay("merryProgressBar", content.merryProgressBar);
20643          if (paintOverlays["merryProgressBar"]) {
20644            paintOverlays["merryProgressBar"]();
20645          }
20646        }
20647
20648        // 🎬 Demoplay text card overlay
20649        if (content.demoplayCard) {
20650          if (paintOverlays["demoplayCard"]) {
20651            paintOverlays["demoplayCard"]();
20652          } else {
20653            buildOverlay("demoplayCard", content.demoplayCard);
20654            if (paintOverlays["demoplayCard"]) {
20655              paintOverlays["demoplayCard"]();
20656            }
20657          }
20658        } else {
20659          delete paintOverlays["demoplayCard"];
20660        }
20661
20662        // 🎄⏰ Merry UTC debug overlay (top-right) - DISABLED
20663        // Useful for verifying that `mo`/`merryo` pipelines are aligned to the same UTC authority as `clock`.
20664        // if (!window.currentRecordingOptions?.cleanMode && content.merryUTC) {
20665        //   try {
20666        //     const utcLine1 = `UTC ${content.merryUTC.time}`;
20667        //     const utcLine2 = `cycle ${content.merryUTC.cycle}s · ${content.merryUTC.index}/${content.merryUTC.total} · ${content.merryUTC.piece}`;
20668
20669        //     overlayTargetCtx.save();
20670        //     overlayTargetCtx.globalAlpha = 0.95;
20671        //     overlayTargetCtx.font = `12px YWFTProcessing-Regular, Arial, sans-serif`;
20672        //     overlayTargetCtx.textBaseline = "top";
20673
20674        //     const pad = 6;
20675        //     const lineH = 14;
20676        //     const w1 = overlayTargetCtx.measureText(utcLine1).width;
20677        //     const w2 = overlayTargetCtx.measureText(utcLine2).width;
20678        //     const boxW = Math.ceil(Math.max(w1, w2) + pad * 2);
20679        //     const boxH = Math.ceil(lineH * 2 + pad * 2);
20680        //     const x = Math.max(0, overlayTargetCtx.canvas.width - boxW - 8);
20681        //     const y = 8;
20682
20683        //     // Backplate
20684        //     overlayTargetCtx.fillStyle = "rgba(0, 0, 0, 0.55)";
20685        //     overlayTargetCtx.fillRect(x, y, boxW, boxH);
20686
20687        //     // Text
20688        //     overlayTargetCtx.fillStyle = "white";
20689        //     overlayTargetCtx.fillText(utcLine1, x + pad, y + pad);
20690        //     overlayTargetCtx.fillText(utcLine2, x + pad, y + pad + lineH);
20691        //     overlayTargetCtx.restore();
20692        //   } catch (e) {
20693        //     // Silent fail: debug-only overlay
20694        //   }
20695        // }
20696
20697        // Paint tape progress bar immediately (not affected by async skip)
20698        // Skip in clean mode (neat tapes)
20699        if (tapeProgressPainter && !window.currentRecordingOptions?.cleanMode) {
20700          if (isRecording) {
20701            paintTapeProgressAfterCapture = true;
20702          } else {
20703            tapeProgressPainter();
20704          }
20705        }
20706
20707        // Paint duration progress bar immediately (not affected by async skip)
20708        if (paintOverlays["durationProgressBar"]) {
20709          paintOverlays["durationProgressBar"]();
20710        } else if (content.durationProgressBar) {
20711          buildOverlay("durationProgressBar", content.durationProgressBar);
20712          if (paintOverlays["durationProgressBar"]) {
20713            paintOverlays["durationProgressBar"]();
20714          }
20715        }
20716
20717        // Paint duration timecode immediately (not affected by async skip)
20718        if (paintOverlays["durationTimecode"]) {
20719          paintOverlays["durationTimecode"]();
20720        } else if (content.durationTimecode) {
20721          buildOverlay("durationTimecode", content.durationTimecode);
20722          if (paintOverlays["durationTimecode"]) {
20723            paintOverlays["durationTimecode"]();
20724          }
20725        }
20726
20727        // 📼 Capture frame data AFTER HUD overlays but BEFORE tape progress bar (including HUD in recording)
20728        if (isRecording && !compactVideoRecording) {
20729          // 🎯 Capture EVERY frame - no time-based throttling for maximum quality
20730          // GIF export will downsample to 30fps, MP4 will use all frames
20731          mediaRecorderFrameCount = (mediaRecorderFrameCount || 0) + 1;
20732          
20733          // Update recording progress for tape progress bar breathing pattern
20734          const relativeTimestamp = performance.now() - mediaRecorderStartTime;
20735          if (captureSession && captureSession.firstVideoMs === null) {
20736            captureSession.firstVideoMs = mediaRecorderStartTime + relativeTimestamp;
20737            reportCaptureAVSync();
20738          }
20739          const targetDuration = window.currentRecordingOptions?.duration || 3000; // Default 3 seconds if not set
20740          window.currentTapeProgress = Math.min(1, relativeTimestamp / targetDuration);
20741          
20742          // Convert relative timestamp to absolute timestamp (milliseconds since epoch)
20743          const absoluteTimestamp =
20744            (Number.isFinite(window.recordingStartTimestamp)
20745              ? window.recordingStartTimestamp
20746              : Date.now()) + relativeTimestamp;
20747          let frameDataWithHUD;
20748          const hdLayerLive =
20749            hdStreamCanvas && hdCan && hdCan.style.display !== "none" && !hdTapeAborted;
20750          if (hdLayerLive) {
20751            // 🖼️📼 hd piece: the pixel canvas only holds a wash, so scrub
20752            // frames come from a smooth downscale of the same hd mirror the
20753            // tape encoder sees — memory stays at classic tape size.
20754            const capCtx = ensureCaptureCompositeCanvas(
20755              ctx.canvas.width,
20756              ctx.canvas.height,
20757            );
20758            capCtx.imageSmoothingEnabled = true;
20759            capCtx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
20760            capCtx.drawImage(hdStreamCanvas, 0, 0, ctx.canvas.width, ctx.canvas.height);
20761            capCtx.drawImage(overlayCan, 0, 0);
20762            frameDataWithHUD = capCtx.getImageData(
20763              0,
20764              0,
20765              ctx.canvas.width,
20766              ctx.canvas.height,
20767            );
20768          } else if (webglCompositeActive && webglCompositeCanvas) {
20769            const capCtx = ensureCaptureCompositeCanvas(
20770              ctx.canvas.width,
20771              ctx.canvas.height,
20772            );
20773            capCtx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
20774            capCtx.drawImage(webglCompositeCanvas, 0, 0);
20775            capCtx.drawImage(overlayCan, 0, 0);
20776            frameDataWithHUD = capCtx.getImageData(
20777              0,
20778              0,
20779              ctx.canvas.width,
20780              ctx.canvas.height,
20781            );
20782          } else {
20783            frameDataWithHUD = ctx.getImageData(
20784              0,
20785              0,
20786              ctx.canvas.width,
20787              ctx.canvas.height,
20788            );
20789          }
20790          
20791          // Capture pen position data for crosshair rendering during export
20792          // Only capture pen data if cursor is within canvas bounds
20793          const penData = pen?.pointers[1] ? (() => {
20794            const pointer = pen.pointers[1];
20795            const x = pointer.x;
20796            const y = pointer.y;
20797            const canvasWidth = ctx.canvas.width;
20798            const canvasHeight = ctx.canvas.height;
20799            
20800            // Check if pen is within canvas bounds
20801            if (x < 0 || x >= canvasWidth || y < 0 || y >= canvasHeight) {
20802              return null; // Don't capture pen data when cursor is off-screen
20803            }
20804            
20805            return {
20806              x: x,
20807              y: y,
20808              device: pointer.device
20809            };
20810          })() : null;
20811          
20812          recordedFrames.push([absoluteTimestamp, frameDataWithHUD, penData]);
20813
20814          if (!Number.isFinite(absoluteTimestamp) || !Number.isFinite(relativeTimestamp)) {
20815            console.warn("🎬 ⚠️ Invalid frame timestamps detected", {
20816              absoluteTimestamp,
20817              relativeTimestamp,
20818              recordingStartTimestamp: window.recordingStartTimestamp,
20819            });
20820          }
20821
20822          if (
20823            mediaRecorderFrameCount <= 5 ||
20824            mediaRecorderFrameCount % 60 === 0
20825          ) {
20826            const firstFrameTimestamp = recordedFrames[0]?.[0];
20827            const latestDelta =
20828              firstFrameTimestamp !== undefined
20829                ? absoluteTimestamp - firstFrameTimestamp
20830                : 0;
20831            console.log(
20832              "🎬 Frame capture progress:",
20833              `${recordedFrames.length} frames total — latest Δ ${latestDelta.toFixed(2)}ms`,
20834            );
20835          }
20836        }
20837
20838        //  Return clean screenshot data (without overlays)
20839        if (needs$creenshot) {
20840          needs$creenshot(cleanScreenshotData);
20841          needs$creenshot = null;
20842        }
20843
20844        // Paint tape progress bar after capture (but skip in clean mode for neat tapes)
20845        if (paintTapeProgressAfterCapture && tapeProgressPainter && !window.currentRecordingOptions?.cleanMode) {
20846          tapeProgressPainter();
20847        }
20848
20849        // 🎬 Paint recording UI overlays to recordingUICan (NOT captured in recordings)
20850        // These are UI elements that should be visible on screen but not in the tape
20851        const hasRecordingUIOverlays = Object.keys(recordingUIOverlays).length > 0;
20852        if (hasRecordingUIOverlays) {
20853          recordingUICtx.clearRect(0, 0, recordingUICan.width, recordingUICan.height);
20854          for (const name of Object.keys(recordingUIOverlays)) {
20855            recordingUIOverlays[name]();
20856          }
20857          if (recordingUICan.style.display === "none") {
20858            recordingUICan.style.display = "block";
20859          }
20860        } else {
20861          if (recordingUICan.style.display !== "none") {
20862            recordingUICan.style.display = "none";
20863          }
20864        }
20865
20866        if (glaze.on && glazeReady && !awaitingReframePixels) {
20867          ThreeD?.pasteTo(glazeCompositeCtx);
20868          const glazeSource = webglCompositeActive ? webglCompositeCanvas : canvas;
20869          glazeCompositeCtx.drawImage(glazeSource, 0, 0);
20870          Glaze.update(glazeComposite);
20871        }
20872      }
20873
20874      if (glaze.on) {
20875        Glaze.render(now, pen?.normalizedPosition(canvasRect));
20876      } else {
20877        Glaze.off();
20878        canvas.style.removeProperty("opacity");
20879      }
20880
20881      // 🅱️ Draw anything from the system UI layer on top.
20882
20883      const dpi = window.devicePixelRatio;
20884
20885      // Only scale UI context when DPI changes
20886      if (!uiContextScaled || currentUiScale !== dpi) {
20887        if (uiContextScaled) {
20888          uiCtx.resetTransform();
20889        }
20890        uiCtx.scale(dpi, dpi);
20891        currentUiScale = dpi;
20892        uiContextScaled = true;
20893      }
20894
20895      // Combine clear operations for better performance
20896      // Modified to avoid clearing overlay areas - label overlay is typically at (6,6) with small dimensions
20897      if (content.label) {
20898        // If there's a label overlay, clear around it instead of over it
20899        const labelX = content.label.x;
20900        const labelY = content.label.y;
20901        const labelW = content.label.img?.width || 0;
20902        const labelH = content.label.img?.height || 0;
20903        
20904        // Clear top-left area but avoid the label overlay region
20905        if (labelX > 0) {
20906          uiCtx.clearRect(0, 0, labelX, 64); // Left of label
20907        }
20908        if (labelY > 0) {
20909          uiCtx.clearRect(0, 0, 64, labelY); // Above label
20910        }
20911        if (labelX + labelW < 64) {
20912          uiCtx.clearRect(labelX + labelW, 0, 64 - (labelX + labelW), 64); // Right of label
20913        }
20914        if (labelY + labelH < 64) {
20915          uiCtx.clearRect(0, labelY + labelH, 64, 64 - (labelY + labelH)); // Below label
20916        }
20917      } else {
20918        // No label overlay, clear normally
20919        uiCtx.clearRect(0, 0, 64, 64); // Top left
20920      }
20921      
20922      uiCtx.clearRect(0, uiCtx.canvas.height / dpi - 64, 64, 64); // Bottom left
20923      uiCtx.clearRect(uiCtx.canvas.width / dpi - 64, 0, 64, 64); // Top right
20924
20925      // 🪟 Vector overlay — a piece's api.overlay() display-list, drawn crisp on the
20926      // native UI canvas above the pixel buffer. Gated: only pieces that send one
20927      // pay anything. The piece sends coords in ITS screen units, so we scale by the
20928      // ratio of the UI canvas to the piece resolution (content.width). Cleared
20929      // fresh each frame; the cursor still draws on top afterward.
20930      if (content.overlay) {
20931        const logicalW = uiCtx.canvas.width / dpi;
20932        const logicalH = uiCtx.canvas.height / dpi;
20933        uiCtx.clearRect(0, 0, logicalW, logicalH);
20934        const s = content.width ? logicalW / content.width : 1;
20935        uiCtx.save();
20936        uiCtx.scale(s, s);
20937        drawVectorOverlay(uiCtx, content.overlay);
20938        uiCtx.restore();
20939        hadVectorOverlay = true;
20940      } else if (hadVectorOverlay) {
20941        // The overlay just went away (left the piece) — clear it once so it doesn't
20942        // linger on the UI canvas.
20943        uiCtx.clearRect(0, 0, uiCtx.canvas.width / dpi, uiCtx.canvas.height / dpi);
20944        hadVectorOverlay = false;
20945      }
20946
20947      pen?.render(uiCtx, canvasRect); // ️ 🐭 Draw the cursor.
20948
20949      // Show the spinner on any piece other than the first, and never
20950      // on the prompt.
20951      if (
20952        content.loading === true &&
20953        currentPiece !== null &&
20954        currentPiece !== "aesthetic.computer/disks/prompt"
20955      ) {
20956        UI.spinner(uiCtx, now);
20957      }
20958
20959      if (debug && frameCached && content.loading !== true) UI.cached(uiCtx); // Pause icon.
20960
20961      if (content.aeselPreview && !content.loading && !freezeFrameFrozen) {
20962        window.dispatchEvent(new CustomEvent("aesel-preview", {detail: {...content.aeselPreview, kind: "painted"}}));
20963      }
20964
20965      // Note: We keep the transform scaled for the next frame to avoid redundant operations
20966    }
20967
20968    if (
20969      pixelsDidChange ||
20970      needs$creenshot ||
20971      mediaRecorder?.state === "recording" ||
20972      (pen && pen.changedInPiece) ||
20973      content.tapeProgressBar || // Force draw when tape progress bar is active
20974      (freezeFrame && freezeFrameFrozen && !awaitingReframePixels && !reframeDrawn) // Force draw after reframe pixels arrive
20975    ) {
20976      frameCached = false;
20977      if (pen) pen.changedInPiece = false;
20978      draw();
20979    } else if (frameCached === false) {
20980      frameCached = true;
20981      draw();
20982      //console.log("Caching frame...");
20983      // } else if (content.loading === true && debug === true) {
20984    } else if (content.loading === true) {
20985      draw();
20986    } else if (frameCached === true) {
20987      //draw(); // TODO: This is causing stuttering.
20988      // console.log("Cached...");
20989    }
20990
20991    // Hide the freezeFrame - wait for glaze to be ready if it's enabled
20992    // Also ensure resize has stabilized by waiting 100ms after last reframe request
20993    const reframeStabilized = performance.now() - lastReframeTime > 100;
20994    // Failsafe: force-remove freeze frame if stuck for over 500ms
20995    const reframeFailsafe = freezeFrame && freezeFrameFrozen && performance.now() - lastReframeTime > 500;
20996    if (reframeFailsafe && (awaitingReframePixels || !reframeDrawn || (glaze.on && !glazeReady))) {
20997      console.warn("⚠️ REFRAME: Failsafe - force removing stuck freeze frame after 500ms");
20998      awaitingReframePixels = false;
20999      reframeDrawn = true;
21000      if (glaze.on && !glazeReady) {
21001        glazeReady = true;
21002        Glaze.unfreeze();
21003      }
21004    }
21005    if (freezeFrame && freezeFrameFrozen && !awaitingReframePixels && reframeDrawn && reframeStabilized && (!glaze.on || glazeReady)) {
21006      if (glaze.on === false) {
21007        canvas.style.removeProperty("opacity");
21008      }
21009      // Fade out freeze frame smoothly then remove
21010      freezeFrameCan.style.opacity = "0";
21011      freezeFrameRemovalTimeout = setTimeout(() => {
21012        freezeFrameRemovalTimeout = null;
21013        if (wrapper.contains(freezeFrameCan)) {
21014          freezeFrameCan.remove();
21015          freezeFrameCan.style.removeProperty("opacity");
21016        }
21017      }, 60); // Match CSS transition duration
21018      if (window.acReframeDebug) {
21019        console.log("✅ REFRAME: freeze frame removed");
21020      }
21021      freezeFrame = false;
21022      freezeFrameGlaze = false;
21023      freezeFrameFrozen = false;
21024    }
21025
21026    if (glaze.on && glazeReady && !awaitingReframePixels) {
21027      Glaze.unfreeze();
21028    } else if (!glaze.on) {
21029      canvas.style.removeProperty("opacity");
21030    }
21031
21032    if (needsReappearance /* && wrapper.classList.contains("hidden")*/) {
21033      // wrapper.classList.remove("hidden");
21034      needsReappearance = false;
21035    }
21036
21037    // 📸 PACK mode: Log frame to console every 5 seconds (time-based, framerate independent)
21038    // Skip entirely if snapshots are suppressed (bundles set KIDLISP_SUPPRESS_SNAPSHOT_LOGS = true)
21039    if (window.acPACK_MODE && window.KIDLISP_SUPPRESS_SNAPSHOT_LOGS !== true && canvas) {
21040      const packNow = performance.now();
21041      const packTimeSinceLastSnap = window._lastPackSnapTime ? (packNow - window._lastPackSnapTime) : Infinity;
21042      if (packTimeSinceLastSnap >= 5000) { // 5 seconds
21043        window._lastPackSnapTime = packNow;
21044        try {
21045          const snapCanvas = (webglCompositeActive && !underlayFrame && webglCompositeCanvas) ? webglCompositeCanvas : canvas;
21046          const { dataUrl, displayWidth, displayHeight, dimensions } = captureFrame(snapCanvas, {
21047            scaleFactor: 3,
21048            displayMax: 200
21049          });
21050          const ts = formatTimestamp();
21051
21052          // Get piece code (use acPACK_PIECE for the short name like "roz")
21053          const pieceName = window.acPACK_PIECE || 'piece';
21054          const pieceCode = pieceName.startsWith('$') ? pieceName : `$${pieceName}`;
21055          
21056          console.log(
21057            `%c📸 ${pieceCode} %c@ ${ts} %c• Frame ${frameCount} %c[${dimensions.width}×${dimensions.height}]`,
21058            `color: #4ecdc4; font-weight: bold; font-size: 11px; font-family: monospace;`,
21059            `color: #f8b500; font-size: 10px; font-family: monospace;`,
21060            `color: #888; font-size: 10px; font-family: monospace;`,
21061            `color: #666; font-size: 10px; font-family: monospace;`
21062          );
21063          console.log(
21064            `%c `,
21065            `font-size: 1px; padding: ${displayHeight/2}px ${displayWidth/2}px; background: url("${dataUrl}") no-repeat center; background-size: ${displayWidth}px ${displayHeight}px; image-rendering: pixelated;`
21066          );
21067        } catch (e) {
21068          // Silently fail - frame capture is not essential
21069        }
21070      }
21071    }
21072
21073    // 📸 KidLisp.com console: Auto-snap - initial at frame 30 after play, then every 5 seconds
21074    const consoleEnabled = isKidlispConsoleEnabled();
21075    const notPaused = !window.__acLoopPaused;
21076    if (consoleEnabled && canvas && notPaused) {
21077      const now = performance.now();
21078      const kidlispCode = window.__acCurrentKidlispCode;
21079      // Valid KidLisp code can be just a plain word (e.g., "gray", "red", "purple")
21080      // which implicitly wipes the screen with that color, so we only check for
21081      // non-empty content, not specific syntax like parentheses or commas.
21082      const hasKidlispCode = kidlispCode && kidlispCode.trim().length > 0;
21083      
21084      if (hasKidlispCode) {
21085        // Track when code changes - reset the initial snap tracking
21086        if (window._lastKidlispCodeForSnap !== kidlispCode) {
21087          window._lastKidlispCodeForSnap = kidlispCode;
21088          window._kidlispCodeStartFrame = frameCount;
21089          window._kidlispInitialSnapTaken = false;
21090        }
21091        
21092        const framesSinceCodeStart = typeof frameCount === "bigint" 
21093          ? Number(frameCount - window._kidlispCodeStartFrame)
21094          : frameCount - window._kidlispCodeStartFrame;
21095        
21096        // Initial snapshot at frame 30 after code starts
21097        const shouldTakeInitialSnap = !window._kidlispInitialSnapTaken && framesSinceCodeStart >= 30;
21098        
21099        // Subsequent snapshots every 5 seconds (only after initial snap)
21100        const timeSinceLastSnap = window._lastKidlispSnapTime ? (now - window._lastKidlispSnapTime) : Infinity;
21101        const shouldTakePeriodicSnap = window._kidlispInitialSnapTaken && timeSinceLastSnap >= 5000;
21102        
21103        if (shouldTakeInitialSnap || shouldTakePeriodicSnap) {
21104          if (shouldTakeInitialSnap) {
21105            window._kidlispInitialSnapTaken = true;
21106          }
21107          window._lastKidlispSnapTime = now;
21108          try {
21109            const snapCanvas = (webglCompositeActive && !underlayFrame && webglCompositeCanvas) ? webglCompositeCanvas : canvas;
21110            const { dataUrl, displayWidth, displayHeight, dimensions } = captureFrame(snapCanvas, {
21111              scaleFactor: 3,
21112              displayMax: 200
21113            });
21114            const ts = formatTimestamp();
21115
21116            // Get the source code and look up its cached $code identifier
21117            const embeddedSource = window.__acCurrentKidlispCode || null;
21118            // Prefer codeId passed from kidlisp.com, then fall back to getCachedCode lookup
21119            // This ensures the correct $code is used when loading from URL
21120            const passedCodeId = window.__acCurrentKidlispCodeId || null;
21121            const cachedCodeId = embeddedSource ? getCachedCode(embeddedSource) : null;
21122            const codeId = passedCodeId || cachedCodeId || null;
21123            // Get user handle if available (e.g., "jeffrey")
21124            const userHandle = window.acHANDLE ? `@${window.acHANDLE}` : null;
21125            
21126            // Build the piece label: $code if available, otherwise fall back to piece path
21127            const pieceLabel = codeId ? `$${codeId}` : (currentPiece?.split('/')?.pop() || 'piece');
21128            
21129            // Build filename like: [email protected]
21130            // Example: [email protected]
21131            // Using the standard timestamp() format from num.mjs
21132            const fileTs = timestamp();
21133            
21134            const filenameParts = [pieceLabel];
21135            if (userHandle) filenameParts.push(userHandle);
21136            filenameParts.push(fileTs);
21137            const filename = filenameParts.join('-') + '.png';
21138            
21139            const suppressSnapConsoleLogs = window.KIDLISP_SUPPRESS_SNAPSHOT_LOGS === true;
21140            
21141            // Log to browser console (optional)
21142            if (!suppressSnapConsoleLogs) {
21143              console.log(
21144                `%c📸 ${pieceLabel} %c@ ${ts} %c• Frame ${frameCount} %c[${dimensions.width}×${dimensions.height}]`,
21145                `color: #4ecdc4; font-weight: bold; font-size: 11px; font-family: monospace;`,
21146                `color: #f8b500; font-size: 10px; font-family: monospace;`,
21147                `color: #888; font-size: 10px; font-family: monospace;`,
21148                `color: #666; font-size: 10px; font-family: monospace;`
21149              );
21150              console.log(
21151                `%c `,
21152                `font-size: 1px; padding: ${displayHeight/2}px ${displayWidth/2}px; background: url("${dataUrl}") no-repeat center; background-size: ${displayWidth}px ${displayHeight}px; image-rendering: pixelated;`
21153              );
21154            }
21155            
21156            // Send to kidlisp.com console
21157            postKidlispConsoleImage(dataUrl, {
21158              frameCount: Number(frameCount),
21159              timestamp: ts,
21160              pieceCode: codeId, // The $code identifier (without $)
21161              pieceLabel,              // Full label with $ (e.g., "$nece")
21162              filename,                // Full filename (e.g., "[email protected]")
21163              userHandle,              // User handle (e.g., "@jeffrey")
21164              dimensions,
21165              embeddedSource
21166            });
21167          } catch (e) {
21168            // Silently fail - auto-snap is not essential
21169          }
21170        }
21171      }
21172    }
21173
21174    } catch (err) {
21175      console.error("🛑 BIOS compositing error (render/update):", err);
21176    } finally {
21177      frameAlreadyRequested = false; // 🗨️ Signal readiness for the next frame.
21178      clearFrameStallWatchdog();
21179    }
21180    // if (lastRender) console.log(performance.now() - lastRender)
21181    // lastRender = performance.now()
21182  } // End of receivedChange function
21183
21184  // 📤 Reads a file and uploads it to the server.
21185  let activeTapeDraft = null;
21186  let activeTapeDraftXHR = null;
21187  let cancelTapeDraftRequested = false;
21188
21189  async function receivedTapeDraftUpload(zipBlob, metadata) {
21190    if (cancelTapeDraftRequested) return;
21191    const authToken = await authorize().catch(() => null);
21192    const headers = {};
21193    if (authToken) headers.Authorization = `Bearer ${authToken}`;
21194    try {
21195      const create = await fetch("/api/tape-draft", { method: "POST", headers });
21196      const draft = await create.json();
21197      if (!create.ok || !draft.uploadURL) {
21198        throw new Error(draft.error || `HTTP ${create.status}`);
21199      }
21200      activeTapeDraft = draft;
21201      if (cancelTapeDraftRequested) {
21202        const deleteHeaders = { "Content-Type": "application/json", ...headers };
21203        fetch("/api/tape-draft", {
21204          method: "DELETE",
21205          headers: deleteHeaders,
21206          body: JSON.stringify({ id: draft.id, token: draft.token }),
21207        }).catch(() => {});
21208        activeTapeDraft = null;
21209        return;
21210      }
21211      const xhr = new XMLHttpRequest();
21212      activeTapeDraftXHR = xhr;
21213      xhr.open("PUT", draft.uploadURL, true);
21214      xhr.setRequestHeader("Content-Type", "application/zip");
21215      xhr.setRequestHeader("Content-Disposition", "inline");
21216      xhr.upload.addEventListener("progress", (event) => {
21217        if (event.lengthComputable) {
21218          send({ type: "tape:draft-progress", content: event.loaded / event.total });
21219        }
21220      });
21221      xhr.onerror = () => {
21222        send({ type: "tape:draft-error", content: { error: "Draft upload failed" } });
21223      };
21224      xhr.onreadystatechange = () => {
21225        if (xhr.readyState !== XMLHttpRequest.DONE) return;
21226        if (xhr.status >= 200 && xhr.status < 300) {
21227          activeTapeDraftXHR = null;
21228          send({
21229            type: "tape:draft-ready",
21230            content: { id: draft.id, token: draft.token, slug: draft.slug, metadata },
21231          });
21232        } else if (xhr.status !== 0) {
21233          send({
21234            type: "tape:draft-error",
21235            content: { error: `Draft upload HTTP ${xhr.status}` },
21236          });
21237        }
21238      };
21239      xhr.send(zipBlob);
21240    } catch (error) {
21241      send({ type: "tape:draft-error", content: { error: error.message } });
21242    }
21243  }
21244
21245  async function receivedUpload(
21246    { filename, data, bucket },
21247    callbackMessage = "upload",
21248    metadata = null,
21249    recordingSlug = null,  // Optional recording slug for anonymous paintings
21250  ) {
21251    console.log("📤 Uploading file:", filename, typeof data || "...");
21252    const ext = extension(filename);
21253    let MIME = "application/octet-stream"; // Default content type.
21254
21255    if (ext === "json") {
21256      // JSON
21257      MIME = "application/json";
21258
21259      // Parse JSON strings if they haven't been already, including support for `bigints`.
21260      if (typeof data !== "string") {
21261        data = JSON.stringify(
21262          data,
21263          (k, v) => (typeof v === "bigint" ? v.toString() : v),
21264          // 2 // Also make sure we indent by 2 spaces so it's nicely formatted.
21265        );
21266      }
21267    }
21268
21269    if (ext === "mjs") MIME = "application/javascript";
21270    if (ext === "lisp") MIME = "text/x-lisp";
21271    if (ext === "lua") MIME = "text/x-lua";
21272
21273    if (ext === "obj") MIME = "application/object";
21274    if (ext === "glb") MIME = "model/gltf-binary";
21275
21276    if (ext === "mp4") {
21277      MIME = "video/mp4";
21278      // data = mediaRecorderBlob;
21279    }
21280
21281    if (ext === "png") {
21282      MIME = "image/png";
21283      data = await bufferToBlob(data, MIME); // Could be adding modifiers here...
21284    }
21285
21286    if (ext === "zip") MIME = "application/zip";
21287
21288    let prefetchURL = "/presigned-upload-url/" + ext;
21289
21290    if (bucket === "wand") prefetchURL += "/" + filename + "/" + bucket; // Add filename info.
21291
21292    // if (bucket === undefined) prefetchURL += "/" + filename; // "art" bucket.
21293    // 📓 This is handled on the server if an empty bucket is sent.
21294
21295    // Authorization: Check to see if we will use a user or a guest bucket.
21296    const headers = {};
21297
21298    // If no bucket is specified, then try and use the "user" bucket.
21299    let userMedia = false,
21300      token;
21301    if (!bucket) {
21302      console.log(`🔐 receivedUpload: Calling authorize() for ${filename}`);
21303      token = await authorize();
21304      console.log(`🔐 receivedUpload: authorize() returned token=${token ? 'valid' : 'null/undefined'}`);
21305      if (token) {
21306        userMedia = true;
21307        bucket = "user";
21308        headers.Authorization = `Bearer ${token}`;
21309        // This filename gets sorted into the user bucket via their own
21310        // directory upon uploading.
21311        // Otherwise if there is no authorization, we just send an empty filename
21312        // slug with an extension and an identifier gets generated via nanoid on
21313        // the server.
21314        prefetchURL += "/" + filename + "/" + bucket; // Add filename info.
21315      } else {
21316        console.log(`🔓 receivedUpload: No token, uploading as guest`);
21317      }
21318    }
21319
21320    function error(err) {
21321      send({
21322        type: callbackMessage,
21323        content: {
21324          result: "error",
21325          data: null,
21326          error: err?.message || "Upload failed",
21327        },
21328      });
21329    }
21330
21331    // Now send a request to the server...
21332    fetch(prefetchURL, { headers })
21333      .then(async (res) => {
21334        if (!res.ok) throw new Error(`Upload request failed (HTTP ${res.status})`);
21335        const resData = await res.json();
21336        
21337        // Check for error response
21338        if (resData.error || !resData.uploadURL) {
21339          console.error("❌ Presigned URL error:", resData);
21340          throw new Error(resData.error || resData.details || "Failed to get upload URL");
21341        }
21342
21343        const presignedUrl = resData.uploadURL;
21344
21345        // Probably the download code... maybe something else if a custom
21346        // name is used.
21347        const url = new URL(presignedUrl, location.origin);
21348        const filename = url.pathname.split("/").pop();
21349        const slug = resData.slug ? resData.slug.replace(/\.[^.]+$/, "") : filename.substring(0, filename.lastIndexOf("."));
21350        const path = url.pathname.slice(1); // Remove prepending "/";
21351        
21352        // Log clean URL without query params
21353        const cleanUrl = `${url.origin}${url.pathname}`;
21354        if (debug) console.log("🔐 Presigned URL:", cleanUrl);
21355
21356        const xhr = new XMLHttpRequest();
21357  xhr.open("PUT", presignedUrl, true);
21358  // The presigned URL already encodes the required signed headers.
21359  // Set headers that match what's in the signature.
21360  
21361  // Set Content-Type based on extension to match server's presigned URL
21362  let mimeType;
21363  if (ext === "png") mimeType = "image/png";
21364  else if (ext === "zip") mimeType = "application/zip";
21365  else if (ext === "mjs") mimeType = "application/javascript; charset=utf-8";
21366  else if (ext === "lisp") mimeType = "text/x-lisp; charset=utf-8";
21367  else if (ext === "lua") mimeType = "text/x-lua; charset=utf-8";
21368  else if (ext === "mp4") mimeType = "video/mp4";
21369  else if (ext === "json") mimeType = "application/json";
21370  else if (ext === "gltf") mimeType = "model/gltf+json";
21371  else if (ext === "glb") mimeType = "model/gltf-binary";
21372  else if (ext === "obj") mimeType = "application/object";
21373  
21374  if (mimeType) {
21375    xhr.setRequestHeader("Content-Type", mimeType);
21376  }
21377  
21378  xhr.setRequestHeader("Content-Disposition", "inline");
21379  
21380  // Include ACL header to match presigned URL signature (all files should be public-read)
21381  xhr.setRequestHeader("x-amz-acl", "public-read");
21382
21383  // Create the blob without specifying a MIME so the browser typically won't
21384  // add a Content-Type header that wasn't included in the signature.
21385  const blob = new Blob([data]);
21386
21387        xhr.upload.addEventListener("progress", (event) => {
21388          console.log(`Uploaded ${event.loaded} of ${blob.size} bytes...`);
21389          const uploadProgress = event.loaded / event.total;
21390          
21391          // Send generic upload progress
21392          send({
21393            type: "upload:progress",
21394            content: uploadProgress,
21395          });
21396          
21397          // For tape uploads (ZIP/MP4 with metadata), also send as transcode-progress in 90-100% range
21398          if ((ext === "zip" || ext === "mp4") && metadata) {
21399            const transcodeProgress = 0.90 + (uploadProgress * 0.10); // Map 0-100% upload to 90-100% overall
21400            send({
21401              type: "recorder:transcode-progress",
21402              content: transcodeProgress,
21403            });
21404            console.log(`📤 Upload: ${Math.floor(uploadProgress * 100)}% -> Overall: ${Math.floor(transcodeProgress * 100)}%`);
21405          }
21406        });
21407
21408        // Browser is online, send the request
21409        xhr.onerror = error;
21410
21411        xhr.onreadystatechange = async function () {
21412          if (xhr.readyState !== XMLHttpRequest.DONE) return;
21413          if (xhr.status < 200 || xhr.status >= 300) {
21414            error(new Error(`Image upload failed (HTTP ${xhr.status})`));
21415            return;
21416          }
21417          try {
21418            // Handle tape posting (ZIP frame tapes and MP4 video tapes, with
21419            // metadata) - works for BOTH user and guest. The `ext` flows
21420            // straight through to api/track-tape, which sets kind "zip" vs
21421            // "mp4" and (for mp4) marks the tape complete immediately.
21422            if ((ext === "zip" || ext === "mp4") && metadata) {
21423              if (debug) {
21424                console.log(
21425                  "📼 Adding tape to database:",
21426                  slug,
21427                  path,
21428                  ext,
21429                  metadata,
21430                );
21431              }
21432
21433              const headers = {
21434                "Content-Type": "application/json",
21435              };
21436              
21437              // Add authorization if user is logged in
21438              if (userMedia && token) {
21439                headers.Authorization = `Bearer ${token}`;
21440              }
21441
21442              const options = { method: "POST", headers };
21443              options.body = JSON.stringify({ slug, ext, metadata });
21444              
21445              try {
21446                console.log("📼 Calling api/track-tape with:", { slug, ext, metadata });
21447                const added = await fetch("api/track-tape", options);
21448                
21449                if (!added.ok) {
21450                  throw new Error(`HTTP ${added.status}: ${added.statusText}`);
21451                }
21452                
21453                const addedData = await added.json();
21454                if (debug) console.log("📼 Tape added to database...", addedData);
21455                
21456                // Extract code from response
21457                if (addedData.code) {
21458                  console.log(`📼 Tape code: !${addedData.code}`);
21459                  captureMediaCreated(ext, userMedia);
21460                  window.acVisits?.action(visitMediaAction(ext, addedData));
21461                  
21462                  // Send success callback with code
21463                  console.log(`📼 Sending ${callbackMessage} event with code:`, addedData.code);
21464                  send({
21465                    type: callbackMessage,
21466                    content: { 
21467                      result: "success", 
21468                      code: addedData.code,
21469                      slug: addedData.slug || slug,
21470                      url: url.toString(),
21471                      ext 
21472                    },
21473                  });
21474                  console.log(`📼 ${callbackMessage} event sent`);
21475                  
21476                  // Log clean URL without query params
21477                  const responseUrl = new URL(xhr.responseURL);
21478                  const cleanResponseUrl = `${responseUrl.origin}${responseUrl.pathname}`;
21479                  if (debug) console.log("✔️ Tape uploaded and posted:", cleanResponseUrl);
21480                  return; // Exit early for tape posting
21481                } else {
21482                  console.error("❌ No code in response from api/track-tape:", addedData);
21483                  send({
21484                    type: "tape:post-error",
21485                    content: { error: "No code returned from server" },
21486                  });
21487                  return;
21488                }
21489              } catch (err) {
21490                console.error("❌ Failed to add tape to database:", err);
21491                send({
21492                  type: "tape:post-error",
21493                  content: { error: err.message },
21494                });
21495                return;
21496              }
21497            }
21498            
21499            // Handle regular media (paintings, pieces, kidlisp - both authenticated and anonymous)
21500            if (
21501              ext === "png" ||  // All paintings (user and anonymous)
21502              ext === "mjs" ||  // Pieces
21503              ext === "lisp"    // KidLisp
21504            ) {
21505              // TODO: Go ahead and add this media to the database.
21506              if (debug) {
21507                console.log(
21508                  "🗞️ Adding media to the database:",
21509                  slug,
21510                  path,
21511                  ext,
21512                );
21513              }
21514
21515              // TODO: Write an authorized POST request that contains the slug
21516              //       to "api/track-media"
21517              const headers = {
21518                "Content-Type": "application/json",
21519              };
21520              
21521              // Only add Authorization header if we have a valid token
21522              if (token) {
21523                headers.Authorization = `Bearer ${token}`;
21524              }
21525
21526              const options = { method: "POST", headers };
21527              const body = { slug, ext };
21528              if (ext === "png" && metadata?.paintingWip) body.wip = metadata.paintingWip;
21529              
21530              console.log(`🔍 PRE-CHECK: recordingSlug=${recordingSlug}, userMedia=${userMedia}, ext=${ext}`);
21531              
21532              // If this is an anonymous painting with a recording, send the combined slug
21533              console.log(`🔍 Checking combined slug: recordingSlug=${recordingSlug}, userMedia=${userMedia}, ext=${ext}`);
21534              if (recordingSlug && !userMedia && ext === "png") {
21535                body.slug = `${slug}:${recordingSlug}`;
21536                console.log(`🔗 Creating anonymous painting with combined slug: ${body.slug}`);
21537              } else {
21538                console.log(`⚠️  Not creating combined slug - one of these is false:`, {
21539                  hasRecordingSlug: !!recordingSlug,
21540                  isAnonymous: !userMedia,
21541                  isPNG: ext === "png"
21542                });
21543              }
21544              
21545              // Emit backend processing status updates during DB/code/ATProto phases.
21546              send({
21547                type: "upload:status",
21548                content: { stage: "init", message: "Preparing media record..." },
21549              });
21550
21551              let addedData = null;
21552              try {
21553                options.body = JSON.stringify(body);
21554                const streamResponse = await fetch("api/track-media-stream", options);
21555                if (!streamResponse.ok) {
21556                  throw new Error(`track-media-stream HTTP ${streamResponse.status}`);
21557                }
21558                if (!streamResponse.body) {
21559                  throw new Error("track-media-stream returned empty body");
21560                }
21561
21562                const reader = streamResponse.body.getReader();
21563                const decoder = new TextDecoder();
21564                let buffer = "";
21565                let currentEventType = null;
21566                let streamError = null;
21567
21568                const parseSSE = (lines) => {
21569                  for (const line of lines) {
21570                    if (line.startsWith("event: ")) {
21571                      currentEventType = line.slice(7).trim();
21572                    } else if (line.startsWith("data: ")) {
21573                      const dataRaw = line.slice(6);
21574                      let payload;
21575                      try {
21576                        payload = JSON.parse(dataRaw);
21577                      } catch {
21578                        payload = { message: dataRaw };
21579                      }
21580
21581                      if (currentEventType === "progress") {
21582                        send({
21583                          type: "upload:status",
21584                          content: {
21585                            stage: payload.stage || "processing",
21586                            message: payload.message || "Processing...",
21587                          },
21588                        });
21589                      } else if (currentEventType === "complete") {
21590                        addedData = payload;
21591                      } else if (currentEventType === "error") {
21592                        streamError = payload?.error || "Unknown stream error";
21593                      }
21594                      currentEventType = null;
21595                    }
21596                  }
21597                };
21598
21599                while (true) {
21600                  const { done, value } = await reader.read();
21601                  if (done) {
21602                    buffer += decoder.decode();
21603                    break;
21604                  }
21605                  buffer += decoder.decode(value, { stream: true });
21606                  const lines = buffer.split("\n");
21607                  buffer = lines.pop() || "";
21608                  parseSSE(lines);
21609                  if (streamError) break;
21610                }
21611
21612                if (buffer.trim()) {
21613                  parseSSE(buffer.split("\n"));
21614                }
21615
21616                if (streamError) {
21617                  throw new Error(streamError);
21618                }
21619                if (!addedData) {
21620                  throw new Error("No complete event from track-media-stream");
21621                }
21622                console.log("🗞️ track-media-stream complete:", addedData);
21623              } catch (streamErr) {
21624                console.warn("⚠️ track-media-stream failed, falling back to /api/track-media:", streamErr);
21625                send({
21626                  type: "upload:status",
21627                  content: { stage: "fallback", message: "Using fallback tracking..." },
21628                });
21629
21630                options.body = JSON.stringify(body);
21631                const added = await fetch("api/track-media", options);
21632                if (!added.ok) {
21633                  console.error(`❌ track-media HTTP error: ${added.status} ${added.statusText}`);
21634                  throw new Error(`track-media HTTP ${added.status}`);
21635                }
21636                addedData = await added.json();
21637                console.log("🗞️ track-media response:", addedData, "status:", added.status);
21638              }
21639              
21640              // Create data object first
21641              let data = { slug, url: url.toString(), ext };
21642              
21643              // Extract code from response if present
21644              if (addedData.code) {
21645                console.log(`🎨 Painting code: #${addedData.code}`);
21646                data.code = addedData.code; // Add code to return data
21647              } else {
21648                console.error("❌ No code received from track-media! Response:", addedData);
21649              }
21650              if (addedData.paintingId) data.paintingId = addedData.paintingId;
21651
21652              captureMediaCreated(ext, userMedia);
21653              window.acVisits?.action(visitMediaAction(ext, addedData));
21654
21655              if (!userMedia && (ext === "mjs" || ext === "lisp")) {
21656                data.url =
21657                  "https://art.aesthetic.computer/" + data.slug + "." + data.ext;
21658              }
21659
21660              send({
21661                type: "upload:status",
21662                content: { stage: "complete", message: "Media record complete." },
21663              });
21664
21665              send({
21666                type: callbackMessage,
21667                content: { result: "success", data },
21668              });
21669            } else {
21670              // For non-painting files or when not logged in
21671              let data = { slug, url: url.toString(), ext };
21672
21673              if (!userMedia && (ext === "mjs" || ext === "lisp")) {
21674                data.url =
21675                  "https://art.aesthetic.computer/" + data.slug + "." + data.ext;
21676              }
21677
21678              send({
21679                type: callbackMessage,
21680                content: { result: "success", data },
21681              });
21682            }
21683
21684            if (debug) console.log("✔️ File uploaded:", xhr.responseURL);
21685          } catch (err) {
21686            error(err);
21687          }
21688        };
21689
21690        try {
21691          xhr.send(blob, { type: MIME });
21692        } catch (err) {
21693          error(err);
21694        }
21695      })
21696      .catch((err) => {
21697        if (debug) console.log("⚠️ Failed to get presigned URL:", err);
21698        error(err);
21699      });
21700  }
21701
21702  // Request and open local file from the user.
21703  // `mode` selects what we resolve with:
21704  //   "image" (default) → a bitmap (existing photo-attach behavior).
21705  //   "video"           → the raw clip as
21706  //                        { kind:"video", data:ArrayBuffer, mime, name, duration }
21707  //                        where duration (seconds) is measured off a temp
21708  //                        <video> element so callers can enforce a length cap.
21709  // TODO: Make sure this works on mobile platforms.
21710  async function openFile(opts = {}) {
21711    const mode = opts.mode || "image";
21712    const accept = opts.accept || (mode === "video" ? "video/*" : "image/*");
21713    pen?.up(); // Synthesize a pen `up` event so it doesn't stick
21714    //            due to the modal.
21715    const input = document.createElement("input");
21716    input.type = "file";
21717    input.accept = accept;
21718    input.style.position = "absolute";
21719    input.style.left = "-9999px"; // Position off-screen
21720
21721    return new Promise((resolve, reject) => {
21722      // Simulate click event on a visible element
21723      const button = document.createElement("button");
21724      button.style.opacity = 0;
21725      button.onclick = () => {
21726        input.click();
21727        document.body.removeChild(button);
21728      };
21729
21730      document.body.appendChild(button);
21731      button.click();
21732
21733      input.onchange = () => {
21734        const file = input.files[0];
21735        if (!file) {
21736          reject("No file was selected!");
21737          return;
21738        }
21739
21740        if (mode === "video") {
21741          if (!file.type.startsWith("video/")) {
21742            reject("Selected file is not a video.");
21743            return;
21744          }
21745          const reader = new FileReader();
21746          reader.onload = async () => {
21747            const data = reader.result; // ArrayBuffer
21748            // Measure duration via a throwaway <video> element. The object
21749            // URL is revoked once metadata loads (or on error) so we don't
21750            // leak it.
21751            const blob = new Blob([data], { type: file.type });
21752            const objectUrl = URL.createObjectURL(blob);
21753            const probe = document.createElement("video");
21754            probe.preload = "metadata";
21755            probe.muted = true;
21756            const finish = (duration) => {
21757              URL.revokeObjectURL(objectUrl);
21758              resolve({
21759                kind: "video",
21760                data,
21761                mime: file.type || "video/mp4",
21762                name: file.name || "video.mp4",
21763                duration: Number.isFinite(duration) ? duration : 0,
21764              });
21765            };
21766            probe.onloadedmetadata = () =>
21766 finish(probe.duration);
21767            probe.onerror = () => finish(0); // Resolve anyway; caller validates.
21768            probe.src = objectUrl;
21769          };
21770          reader.onerror = (error) => reject(error);
21771          reader.readAsArrayBuffer(file);
21772          return;
21773        }
21774
21775        if (!file.type.startsWith("image/")) {
21776          reject("Selected file is not an image.");
21777        } else {
21778          const reader = new FileReader();
21779
21780          reader.onload = async () => {
21781            const blob = new Blob([reader.result], { type: file.type });
21782            resolve(await toBitmap(blob));
21783          };
21784          reader.onerror = (error) => {
21785            reject(error);
21786          };
21787          reader.readAsArrayBuffer(file);
21788        }
21789      };
21790    });
21791  }
21792
21793  async function authorize() {
21794    // Skip authorization in SPIDER mode (read-only IPFS environment)
21795    if (window.acSPIDER) {
21796      return null;
21797    }
21798    
21799    // Skip authorization if noauth parameter is present (e.g., for kidlisp.com editor)
21800    const urlParams = new URLSearchParams(window.location.search);
21801    console.log(`🔐 authorize() called. Search: ${window.location.search}, noauth=${urlParams.has('noauth')}, acNOAUTH=${window.acNOAUTH}`);
21802    if (urlParams.has('noauth') || window.acNOAUTH) {
21803      console.log('🔕 Skipping auth (noauth parameter present)');
21804      return null;
21805    }
21806    
21807    let token;
21808    try {
21809      token = window.acTOKEN;
21810      console.log(`🔐 authorize(): window.acTOKEN=${token ? token.substring(0, 20) + '...' : 'undefined'}`);
21811
21812      if (token) {
21813        // console.log("🔐 Hosted token found...");
21814
21815        try {
21816          // Attempt to fetch user info using the token
21817          window.auth0Client.token = token;
21818          await window.auth0Client.getUser();
21819          console.log("✅🔐 Token validation succeeded!");
21820        } catch (error) {
21821          console.error("🔴🔐 Token is invalid or expired - clearing token");
21822          // Clear the invalid token to prevent infinite loop
21823          window.acTOKEN = null;
21824          // Trigger logout to clear the invalid session
21825          if (window.parent) {
21826            window.parent.postMessage({ type: "logout" }, "*");
21827          }
21828          // Return null - don't reload, just continue without auth
21829          return null;
21830        }
21831      } else {
21832        // If acTOKEN is not available, get a new one
21833        console.log("🔐 No acTOKEN, calling getTokenSilently...");
21834        token = await window.auth0Client.getTokenSilently();
21835        console.log(`🔐 getTokenSilently returned: ${token ? 'token received' : 'null'}`);
21836        // await window.auth0Client.getUser();
21837        // console.log("✅ Token is valid");
21838      }
21839
21840      // console.log("🔐 Authorized");
21841    } catch (err) {
21842      // console.log("🔐️ ❌ Unauthorized", err);
21843      token = null; // Ensure token is null on any error
21844    }
21845    return token;
21846  }
21847
21848  // 💾 Stick: Handle offline audio rendering request
21849  // Renders audio without real-time playback using OfflineAudioContext
21850  async function handleStickRender(content) {
21851    const { id, source, melody, duration, sampleRate = 44100 } = content;
21852    console.log("💾 Stick render request:", { id, source, melody, duration });
21853    
21854    try {
21855      // Report progress start
21856      send({ type: "stick:render:progress", content: { id, progress: 0 } });
21857      
21858      if (source === "clock") {
21859        // Parse the melody and render offline
21860        const audioData = await renderClockOffline(melody, duration, sampleRate, (progress) => {
21861          send({ type: "stick:render:progress", content: { id, progress } });
21862        });
21863        
21864        send({
21865          type: "stick:render:complete",
21866          content: { id, audioData }
21867        });
21868        
21869      } else if (source === "tone") {
21870        // Simple tone - handled in stick.mjs directly
21871        send({
21872          type: "stick:render:complete", 
21873          content: { id, audioData: null }
21874        });
21875      }
21876      
21877    } catch (err) {
21878      console.error("💾 Stick render error:", err);
21879      send({
21880        type: "stick:render:error",
21881        content: { id, error: err.message }
21882      });
21883    }
21884  }
21885  
21886  // 🎵 Render clock melody offline using OfflineAudioContext
21887  async function renderClockOffline(melodyString, durationSec, sampleRate, onProgress) {
21888    // Note frequency table (same as disk.mjs)
21889    const noteFrequencies = {
21890      c: 16.35, "c#": 17.32, db: 17.32, d: 18.35, "d#": 19.45, eb: 19.45,
21891      e: 20.6, f: 21.83, "f#": 23.12, gb: 23.12, g: 24.5, "g#": 25.96,
21892      ab: 25.96, a: 27.5, "a#": 29.14, bb: 29.14, b: 30.87,
21893    };
21894    
21895    // Convert note string to frequency
21896    function noteToFreq(input) {
21897      if (typeof input === "number") return input;
21898      if (!input || input === "rest" || input === "_") return null;
21899      
21900      let octave = 4;
21901      let note = String(input).trim().toLowerCase();
21902      
21903      const prefixMatch = note.match(/^(-?\d+)([a-z#]+)$/);
21904      const suffixMatch = note.match(/^([a-z#]+)(-?\d+)$/);
21905      
21906      if (prefixMatch) {
21907        octave = parseInt(prefixMatch[1], 10);
21908        note = prefixMatch[2];
21909      } else if (suffixMatch) {
21910        note = suffixMatch[1];
21911        octave = parseInt(suffixMatch[2], 10);
21912      }
21913      
21914      note = note.replace(/^([a-g])s$/, "$1#");
21915      const baseFreq = noteFrequencies[note];
21916      if (!baseFreq) return 440; // Default to A4
21917      
21918      return baseFreq * Math.pow(2, octave);
21919    }
21920    
21921    // Simple melody parser (subset of full parser)
21922    function parseSimpleMelody(str) {
21923      const notes = [];
21924      let i = 0;
21925      let currentOctave = 4;
21926      let isStruck = false;
21927      let durationMod = 1;
21928      let waveType = "sine";
21929      
21930      while (i < str.length) {
21931        const char = str[i];
21932        
21933        // Skip whitespace and visual separators
21934        if (" |~".includes(char)) { i++; continue; }
21935        
21936        // Struck mode toggle
21937        if (char === "^") { isStruck = !isStruck; i++; continue; }
21938        
21939        // Duration modifiers (sticky)
21940        if (char === ".") {
21941          let dots = 0;
21942          while (str[i] === ".") { dots++; i++; }
21943          durationMod = 1 / Math.pow(2, dots);
21944          continue;
21945        }
21946        if (char === ",") {
21947          let commas = 0;
21948          while (str[i] === ",") { commas++; i++; }
21949          durationMod = Math.pow(2, commas);
21950          continue;
21951        }
21952        
21953        // Octave changes
21954        if (char === "+") { currentOctave++; i++; continue; }
21955        if (char === "-") { currentOctave--; i++; continue; }
21956        if (/[0-9]/.test(char)) { currentOctave = parseInt(char); i++; continue; }
21957        
21958        // Waveform type in braces
21959        if (char === "{") {
21960          const endBrace = str.indexOf("}", i);
21961          if (endBrace > i) {
21962            const content = str.slice(i + 1, endBrace).toLowerCase();
21963            if (["sine", "square", "triangle", "sawtooth", "noise-white"].includes(content)) {
21964              waveType = content;
21965            }
21966            i = endBrace + 1;
21967            continue;
21968          }
21969        }
21970        
21971        // Rest
21972        if (char === "_") {
21973          notes.push({ note: "rest", octave: currentOctave, duration: durationMod, waveType, struck: isStruck });
21974          i++;
21975          continue;
21976        }
21977        
21978        // Note (a-g)
21979        if (/[a-g]/i.test(char)) {
21980          let note = char.toLowerCase();
21981          i++;
21982          // Check for sharp
21983          if (str[i] === "#" || str[i]?.toLowerCase() === "s") {
21984            note += "#";
21985            i++;
21986          }
21987          notes.push({ note, octave: currentOctave, duration: durationMod, waveType, struck: isStruck });
21988          continue;
21989        }
21990        
21991        i++; // Skip unknown characters
21992      }
21993      
21994      return notes;
21995    }
21996    
21997    // Parse the melody
21998    const parsedNotes = parseSimpleMelody(melodyString);
21999    if (parsedNotes.length === 0) {
22000      throw new Error("No notes found in melody");
22001    }
22002    
22003    console.log("💾 Parsed notes:", parsedNotes.length);
22004    
22005    // Create offline audio context
22006    const numChannels = 2;
22007    const totalSamples = Math.ceil(sampleRate * durationSec);
22008    const offlineCtx = new OfflineAudioContext(numChannels, totalSamples, sampleRate);
22009    
22010    // Calculate timing
22011    const bpm = 60; // Default BPM
22012    const beatDuration = 60 / bpm; // 1 second per beat at 60 BPM
22013    
22014    // Schedule all notes
22015    let currentTime = 0;
22016    let noteIndex = 0;
22017    
22018    while (currentTime < durationSec) {
22019      const noteData = parsedNotes[noteIndex % parsedNotes.length];
22020      const noteDuration = beatDuration * noteData.duration;
22021      
22022      if (noteData.note !== "rest") {
22023        const freq = noteToFreq(`${noteData.octave}${noteData.note}`);
22024        
22025        if (freq) {
22026          // Create oscillator
22027          const oscillator = offlineCtx.createOscillator();
22028          const gainNode = offlineCtx.createGain();
22029          
22030          // Set waveform type
22031          if (noteData.waveType === "noise-white") {
22032            // For noise, use a buffer source with random values
22033            const bufferSize = sampleRate * noteDuration;
22034            const noiseBuffer = offlineCtx.createBuffer(1, bufferSize, sampleRate);
22035            const data = noiseBuffer.getChannelData(0);
22036            for (let j = 0; j < bufferSize; j++) {
22037              data[j] = Math.random() * 2 - 1;
22038            }
22039            const noiseSource = offlineCtx.createBufferSource();
22040            noiseSource.buffer = noiseBuffer;
22041            noiseSource.connect(gainNode);
22042            gainNode.connect(offlineCtx.destination);
22043            
22044            // Envelope
22045            const attackTime = 0.01;
22046            const releaseTime = noteData.struck ? noteDuration * 0.5 : noteDuration * 0.1;
22047            gainNode.gain.setValueAtTime(0, currentTime);
22048            gainNode.gain.linearRampToValueAtTime(0.5, currentTime + attackTime);
22049            gainNode.gain.setValueAtTime(0.5, currentTime + noteDuration - releaseTime);
22050            gainNode.gain.linearRampToValueAtTime(0, currentTime + noteDuration);
22051            
22052            noiseSource.start(currentTime);
22053            noiseSource.stop(currentTime + noteDuration);
22054          } else {
22055            oscillator.type = noteData.waveType;
22056            oscillator.frequency.setValueAtTime(freq, currentTime);
22057            
22058            oscillator.connect(gainNode);
22059            gainNode.connect(offlineCtx.destination);
22060            
22061            // Envelope
22062            const attackTime = 0.01;
22063            const releaseTime = noteData.struck ? noteDuration * 0.5 : noteDuration * 0.1;
22064            gainNode.gain.setValueAtTime(0, currentTime);
22065            gainNode.gain.linearRampToValueAtTime(0.5, currentTime + attackTime);
22066            gainNode.gain.setValueAtTime(0.5, currentTime + noteDuration - releaseTime);
22067            gainNode.gain.linearRampToValueAtTime(0, currentTime + noteDuration);
22068            
22069            oscillator.start(currentTime);
22070            oscillator.stop(currentTime + noteDuration);
22071          }
22072        }
22073      }
22074      
22075      currentTime += noteDuration;
22076      noteIndex++;
22077      
22078      // Report progress
22079      if (onProgress && noteIndex % 10 === 0) {
22080        onProgress(Math.min(currentTime / durationSec, 0.9));
22081      }
22082    }
22083    
22084    // Render the audio
22085    console.log("💾 Starting offline render...");
22086    const audioBuffer = await offlineCtx.startRendering();
22087    console.log("💾 Render complete, converting to WAV...");
22088    
22089    // Convert AudioBuffer to WAV
22090    const wavData = audioBufferToWav(audioBuffer);
22091    
22092    onProgress?.(1);
22093    return wavData;
22094  }
22095  
22096  // Convert AudioBuffer to WAV ArrayBuffer
22097  function audioBufferToWav(audioBuffer) {
22098    const numChannels = audioBuffer.numberOfChannels;
22099    const sampleRate = audioBuffer.sampleRate;
22100    const numSamples = audioBuffer.length;
22101    const bytesPerSample = 2; // 16-bit
22102    const dataSize = numSamples * numChannels * bytesPerSample;
22103    
22104    const buffer = new ArrayBuffer(44 + dataSize);
22105    const view = new DataView(buffer);
22106    
22107    // WAV header
22108    writeWavString(view, 0, "RIFF");
22109    view.setUint32(4, 36 + dataSize, true);
22110    writeWavString(view, 8, "WAVE");
22111    writeWavString(view, 12, "fmt ");
22112    view.setUint32(16, 16, true);
22113    view.setUint16(20, 1, true);
22114    view.setUint16(22, numChannels, true);
22115    view.setUint32(24, sampleRate, true);
22116    view.setUint32(28, sampleRate * numChannels * bytesPerSample, true);
22117    view.setUint16(32, numChannels * bytesPerSample, true);
22118    view.setUint16(34, 16, true);
22119    writeWavString(view, 36, "data");
22120    view.setUint32(40, dataSize, true);
22121    
22122    // Get channel data
22123    const channels = [];
22124    for (let c = 0; c < numChannels; c++) {
22125      channels.push(audioBuffer.getChannelData(c));
22126    }
22127    
22128    // Interleave and write samples
22129    let offset = 44;
22130    for (let i = 0; i < numSamples; i++) {
22131      for (let c = 0; c < numChannels; c++) {
22132        const sample = Math.max(-1, Math.min(1, channels[c][i]));
22133        view.setInt16(offset, sample * 0x7FFF, true);
22134        offset += 2;
22135      }
22136    }
22137    
22138    return buffer;
22139  }
22140  
22141  function writeWavString(view, offset, string) {
22142    for (let i = 0; i < string.length; i++) {
22143      view.setUint8(offset + i, string.charCodeAt(i));
22144    }
22145  }
22146
22147  // Reads the extension off of filename to determine the mimetype and then
22148  // handles the data accordingly and downloads the file in the browser.
22149  // Downloads both cached files via `data` and network stored files for
22150  // users and guests.
22151  async function receivedDownload({ filename, data, modifiers }) {
22152    // Mail attachments are exact bytes, never painting/tape inputs. Keep
22153    // filenames out of diagnostics and don't dispatch by their extension.
22154    if (modifiers?.private && modifiers?.encoding === "binary" && typeof data === "string") {
22155      const bytes = Uint8Array.from(data, (c) =>
22155 c.charCodeAt(0));
22156      const url = URL.createObjectURL(new Blob([bytes], { type: "application/octet-stream" }));
22157      const link = document.createElement("a");
22158      link.href = url;
22159      link.download = filename.split(/[\\/]/).pop();
22160      link.rel = "noopener";
22161      document.body.appendChild(link);
22162      link.click();
22163      setTimeout(() => { link.remove(); URL.revokeObjectURL(url); }, 60_000);
22164      return;
22165    }
22166    console.log("💾 Downloading:", filename);
22167    // if (data) console.log("Data:", typeof data);
22168    // if (modifiers.sharing === true) presharingFile = true;
22169
22170    let object, blob;
22171    let MIME = "application/octet-stream"; // Default content type.
22172    const ext = extension(filename);
22173
22174    // Handle binary-encoded strings (latin1 bytes passed as string for reliable worker transfer)
22175    if (modifiers?.encoding === "binary" && typeof data === "string") {
22176      const bytes = new Uint8Array(data.length);
22177      for (let i = 0; i < data.length; i++) bytes[i] = data.charCodeAt(i);
22178      object = URL.createObjectURL(new Blob([bytes], { type: modifiers.type || MIME }));
22179    } else if (ext === "glb") {
22180      MIME = "model/gltf+binary";
22181      object = URL.createObjectURL(new Blob([data], { type: MIME }));
22182    } else if (ext === "json" || ext === "gltf") {
22183      // ✍️ Text + 3D
22184      // JSON
22185      MIME = "application/json";
22186      // GLTF
22187      if (extension(filename === "gltf")) MIME = "model/gltf+json"; // Hacky conditional above...
22188
22189      // Parse JSON strings if they haven't been already, including support for `bigints`.
22190      if (typeof data !== "string") {
22191        data = JSON.stringify(
22192          data,
22193          (k, v) => (typeof v === "bigint" ? v.toString() : v),
22194          // 2 // Also make sure we indent by 2 spaces so it's nicely formatted.
22195        );
22196      }
22197      object = URL.createObjectURL(new Blob([data], { type: MIME }));
22198    } else if (ext === "png" || ext === "webp" || ext === "gif") {
22199      // 🖼️ Images
22200      MIME = "image/png"; // PNG
22201
22202      if (extension(filename) === "webp") {
22203        MIME = "image/webp";
22204      } else if (extension(filename) === "gif") {
22205        MIME = "image/gif";
22206      }
22207
22208      if (data) {
22209        // Download locally if data is provided.
22210        if (data instanceof Blob) {
22211          // If data is already a blob, use it directly
22212          blob = data;
22213          object = URL.createObjectURL(blob);
22214        } else {
22215          // Otherwise, convert pixel data to blob
22216          blob = await bufferToBlob(data, MIME, modifiers);
22217          object = URL.createObjectURL(blob, { type: MIME });
22218        }
22219      } else {
22220        // Or from the storage network.
22221        // Check to see if filename has user handle data.
22222        const hasEmailOrHandle = filename.split("/")[0].indexOf("@") > -1;
22223        if (hasEmailOrHandle) {
22224          // Apply origin-aware URL construction for media files
22225          try {
22226            const isDevelopment = location.hostname === 'localhost' && location.port;
22227            if (isDevelopment) {
22228              object = `https://localhost:${location.port}/media/${filename}`;
22229              if (debug) console.log("🖼️ Media URL (dev):", { filename, object, hostname: location.hostname, port: location.port });
22230            } else {
22231              object = `/media/${filename}`;
22232              if (debug) console.log("🖼️ Media URL (prod):", { filename, object });
22233            }
22234          } catch (err) {
22235            // Fallback if there's any error
22236            object = `/media/${filename}`;
22237            console.warn("🖼️ Media URL fallback:", { filename, object, err });
22238          }
22239        } else {
22240          object = `https://art.aesthetic.computer/${filename}`;
22241        }
22242      }
22243    } else if (ext === "mp4" || ext === "webm") {
22244      // TODO: ⚠️ `webm` could eventually mean audio here...
22245      // 🎥 Video
22246      // Use stored data from the global Media Recorder.
22247      let tape;
22248      let tapeData;
22249      
22250      console.log("💾 🎥 Processing video download:", { ext, filename, dataIsBlobAlready: data instanceof Blob });
22251      
22252      if (data instanceof Blob) {
22253        // If data is already a blob (from video export), use it directly
22254        tape = data;
22255        tapeData = null;
22256        console.log("💾 🎥 Using blob data directly:", { size: tape.size, type: tape.type });
22257      } else {
22258        // Otherwise get from storage
22259        tapeData = data || (await Store.get("tape"));
22260        tape = tapeData?.blob;
22261        console.log("💾 🎥 Retrieved from storage:", { 
22262          hasTapeData: !!tapeData, 
22263          hasTape: !!tape, 
22264          tapeSize: tape?.size,
22265          tapeType: tape?.type 
22266        });
22267      }
22268
22269      // Restore frame data if available for WebP/Frame exports
22270      if (tapeData?.frames && recordedFrames.length === 0) {
22271        recordedFrames.length = 0; // Clear existing
22272        recordedFrames.push(...tapeData.frames);
22273        console.log(
22274          "📼 Restored",
22275          recordedFrames.length,
22276          "frames from cached video",
22277        );
22278        // Debug: Check what the loaded frames look like
22279        if (recordedFrames.length > 0) {
22280          const firstFrame = recordedFrames[0];
22281          console.log("🔍 First loaded frame:", {
22282            structure: Array.isArray(firstFrame) ? `Array(${firstFrame.length})` : typeof firstFrame,
22283            timestamp: Array.isArray(firstFrame) ? firstFrame[0] : 'N/A',
22284            timestampType: Array.isArray(firstFrame) ? typeof firstFrame[0] : 'N/A',
22285            hasImageData: Array.isArray(firstFrame) && firstFrame[1] && typeof firstFrame[1] === 'object'
22286          });
22287        }
22288      }
22289
22290      // Use stored filename if available, otherwise fall back to provided filename
22291      if (tapeData?.filename) {
22292        filename = tapeData.filename;
22293      } else {
22294        // 🫲 Make sure the container matches the extension.
22295        const tapeMIME = tape?.type; // Check the tape's blob's type.
22296        if (tapeMIME?.indexOf("webm") > -1) {
22297          filename = filename.replace(".mp4", ".webm"); // Replaces ".mp4" set from `video`.
22298        } else {
22299          filename = filename.replace(".webm", ".mp4");
22300        }
22301      }
22302
22303      if (tape) {
22304        object = URL.createObjectURL(tape);
22305        console.log("💾 🎥 Created object URL for video:", { 
22306          objectUrlCreated: !!object,
22307          tapeSize: tape.size,
22308          tapeType: tape.type,
22309          finalFilename: filename
22310        });
22311      } else {
22312        console.warn("💾 🎥 No tape blob available! This will cause download issues.");
22313        // console.warn(
22314        //   "🕸️ No local video available... Trying art bucket:",
22315        //   filename,
22316        // );
22317        // object = `https://art.aesthetic.computer/${filename}`;
22318      }
22319    } else if (ext === "mjs") {
22320      MIME = "application/javascript; charset=utf-8";
22321      object = URL.createObjectURL(new Blob([data], { type: MIME }));
22322    } else if (ext === "html" || ext === "htm") {
22323      // 🌐 HTML files
22324      MIME = "text/html; charset=utf-8";
22325      object = URL.createObjectURL(new Blob([data], { type: MIME }));
22326    } else if (ext === "zip") {
22327      MIME = "application/zip";
22328      object = URL.createObjectURL(data, { type: MIME });
22329    } else if (data instanceof Blob) {
22330      // Generic binary blob (e.g. .amxd or other unknown types)
22331      object = URL.createObjectURL(data);
22332    } else if (data instanceof ArrayBuffer || ArrayBuffer.isView(data)) {
22333      // Binary data sent as ArrayBuffer (e.g. from worker postMessage)
22334      object = URL.createObjectURL(new Blob([data], { type: MIME }));
22335    } else if (data) {
22336      // Last resort: wrap whatever data we have in a blob
22337      object = URL.createObjectURL(new Blob([data], { type: MIME }));
22338    }
22339
22340    // Fetch download url from `/presigned-download-url?for=${filename}` if we
22341    // don't already have a blob string.
22342
22343    if (object && !object.startsWith("blob:")) {
22344      console.log("💾 🌐 Fetching presigned download URL for:", filename);
22345      console.log("💾 🌐 Current object URL:", object);
22346      try {
22347        const response = await fetch(`/presigned-download-url?for=${filename}`);
22348        const json = await response.json();
22349        console.log("💾 🌐 Presigned URL response:", json);
22350        if (json.url && json.url !== "example.com") {
22351          object = json.url;
22352        } else {
22353          console.warn("💾 🌐 Invalid presigned URL received, keeping original:", json);
22354        }
22355      } catch (err) {
22356        console.warn("💾 🌐 Presigned URL fetch failed, keeping original object:", err);
22357      }
22358    }
22359
22360    // Check if navigator.share is supported and we are either on
22361    // iOS or Android
22362    // console.log("🧑‍🤝‍🧑 Sharing:", modifiers?.sharing, "Capable:", navigator.share);
22363    if ((iOS || Android) && modifiers?.sharing === true && navigator.share) {
22364      shareFile = new File(
22365        [blob || new Blob([data], { type: MIME })],
22366        filename.split("/").pop(),
22367        { type: MIME, lastModified: new Date().getTime() },
22368      );
22369      shareFileCallback?.(); // Run the callback if necessary, which should
22370      // prevent any special race conditions.
22371    } else {
22372      // Fallback to download if navigator.share is not supported
22373      const a = document.createElement("a");
22374      a.href = object;
22375      a.target = "_blank";
22376      a.download = filename.split("/").pop(); // Remove any extra paths.
22377
22378      // Add the link to DOM for better browser compatibility with large files
22379      document.body.appendChild(a);
22380
22381      console.log(
22382        `💾 Triggering download: ${filename.split("/").pop()} (${blob ? `${Math.round((blob.size / 1024 / 1024) * 100) / 100} MB` : "unknown size"})`,
22383      );
22384
22385      // Safari-specific handling
22386      const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
22387      if (isSafari && (ext === "mp4" || ext === "webm")) {
22388        console.log("🍎 Safari: Using enhanced video download handling");
22389        
22390        // Force user interaction for Safari
22391        a.style.display = 'none';
22392        a.rel = 'noopener';
22393        
22394        // Try click with user gesture
22395        try {
22396          a.click();
22397        } catch (e) {
22398          console.log("🍎 Safari: Standard click failed, trying workaround");
22399          // Fallback: open in new tab for manual save
22400          window.open(object, '_blank');
22401        }
22402      } else {
22403        a.click();
22404      }
22405
22406      // Clean up after a delay to ensure download starts
22407      setTimeout(() => {
22408        document.body.removeChild(a);
22409        if (typeof a.href !== "string") URL.revokeObjectURL(a.href);
22410      }, 1000);
22411    }
22412
22413    // Picture in Picture: Image Download UI? 22.11.24.08.51
22414    //const container = document.createElement('div');
22415    //const iframe = document.createElement('iframe');
22416
22417    //container.id = "pip-wrapper";
22418    //iframe.id = "pip";
22419    //iframe.src = "/blank";
22420
22421    //container.append(iframe);
22422    //wrapper.append(container);
22423  }
22424
22425  // Used above in `receivedUpload` and `receivedDownload` to generate image files.
22426
22427  // Add a crop to square modifier.
22428
22429  async function bufferToBlob(data, MIME = "image/png", modifiers) {
22430    // 🔐 Guard: In sandboxed iframes (opaque origin), toBlob/toDataURL will fail
22431    // Return null early to prevent SecurityError exceptions
22432    if (isOpaqueOrigin) {
22433      console.warn('⚠️ bufferToBlob skipped: canvas export blocked in sandboxed iframe');
22434      return null;
22435    }
22436
22437    let can;
22438    // Encode a pixel buffer as a png.
22439    // See also: https://stackoverflow.com/questions/11112321/how-to-save-canvas-as-png-image
22440
22441    const imageData = data.data
22442      ? data
22443      : new ImageData(data.pixels, data.width, data.height);
22444    // Convert to imageData if it isn't already.
22445
22446    can = document.createElement("canvas");
22447    const ctx = can.getContext("2d");
22448
22449    if (modifiers?.cropToScreen) {
22450      can.width = screen.width;
22451      can.height = screen.height;
22452    } else {
22453      can.width = imageData.width;
22454      can.height = imageData.height;
22455    }
22456
22457    if (modifiers?.crop === "square") {
22458      // debugger;
22459    }
22460
22461    ctx.putImageData(imageData, 0, 0);
22462
22463    // Scale or modify the image as needed.
22464    if ((modifiers?.scale !== 1 && modifiers?.scale > 0) || modifiers?.flipY) {
22465      const scale = modifiers?.scale || 1;
22466      const flipY = modifiers?.flipY;
22467      const can2 = document.createElement("canvas");
22468      const ctx2 = can2.getContext("2d");
22469      can2.width = can.width * scale;
22470      can2.height = can.height * scale;
22471      ctx2.imageSmoothingEnabled = false;
22472      if (flipY) {
22473        ctx2.scale(1, -1);
22474        ctx2.drawImage(can, 0, 0, can2.width, -can2.height);
22475      } else {
22476        ctx2.drawImage(can, 0, 0, can2.width, can2.height);
22477      }
22478      can = can2;
22479    }
22480
22481    const blob = await new Promise((resolve) => {
22482      if (modifiers?.dataURL === true) {
22483        resolve(can.toDataURL(MIME));
22484      } else {
22485        can.toBlob(resolve, MIME, 100);
22486      }
22487    });
22488    return blob;
22489  }
22490
22491  // Opens a file chooser that is filtered by a given extension / mimetype list.
22492  // And sends the text contents of an individual file back to the disk.
22493  function receivedImport(type) {
22494    const input = document.createElement("input");
22495    input.type = "file";
22496    input.accept = type;
22497
22498    input.onchange = (e) => {
22499      // Grab the only selected file in the file input.
22500      const file = e.target.files[0];
22501
22502      // Does type match nothing in the comma separated `input.accept` list?
22503      const noMatch = type.split(",").every((t) => {
22504        return t !== file.type && t !== `.${extension(file.name)}`;
22505      });
22506
22507      // Relay error if chosen file does not match the `input.accept` list.
22508      if (noMatch) {
22509        send({
22510          type: "import",
22511          content: {
22512            result: "error",
22513            data: `Chosen file was not of type "${type}"`,
22514          },
22515        });
22516        return;
22517      }
22518
22519      // Read the file.
22520      const reader = new FileReader();
22521      reader.readAsText(file);
22522
22523      // Send the content back to the disk once the file loads.
22524      reader.onload = (e) => {
22525        send({
22526          type: "import",
22527          content: { result: "success", data: e.target.result },
22528        });
22529      };
22530
22531      // Relay an error if the file fails to load for any reason.
22532      reader.onerror = () => {
22533        send({
22534          type: "import",
22535          content: { result: "error", data: reader.error },
22536        });
22537      };
22538    };
22539
22540    input.click();
22541  }
22542
22543  // Connects the Microphone to the current audioContext.
22544  function receivedMicrophone(data = {}) {
22545    if (data.detach) {
22546      detachMicrophone?.();
22547    } else {
22548      attachMicrophone?.(data);
22549    }
22550  }
22551
22552  // Takes a request for a video and then either uses a media query (for a camera)
22553  // or loads a video file from a given url (unimplemented).
22554
22555  // Then it puts that into a new video tag and starts playing it,
22556  // sending the disk the thread frames as they update (optional).
22557
22558  // This module also is used to pull data from frames for
22559  // features like hand-tracking.
22560  let videoResize; // Holds a function defined after initialization.
22561  let handAPI;
22562  //let handLandmarker, HandLandmarker, FilesetResolver, vision;
22563  async function receivedVideo({ type, options }) {
22564    // if (debug) console.log("🎥 Type:", type, options);
22565
22566    if (type === "camera:update") videoResize?.(options);
22567
22568    if (type === "camera") {
22569      // TODO: Give video and canvas a unique identifier that
22570      //       will create a link in the worker so that frame updates
22571      //       for multiple videos can be routed simultaneously.
22572      const video = document.createElement("video");
22573
22574      // The native iPhone WKWebView replaces its UA with the exact string
22575      // "Aesthetic", so the ordinary iOS detector cannot see it. Camera
22576      // capture must still use the mobile constraint/restart/mirroring path;
22577      // otherwise WKWebView reports portrait dimensions around sideways
22578      // sensor pixels and our dimension-based rotation check cannot recover.
22579      const cameraIOS = iOS || AestheticIOSApp;
22580      const mobileCamera = cameraIOS || Android;
22581
22582      // Camera properties.
22583      let facingMode = options.facing || "user",
22584        zoom = 1;
22585      
22586      // Fit mode: "cover" crops to fill, "contain" letterboxes to fit
22587      const fitMode = options.fit || "cover";
22588      const recordingCamera = options.recording === true;
22589
22590      video.id = "camera-feed";
22591      video.autoplay = true; // Allow video footage to play automatically.
22592      video.setAttribute("playsinline", ""); // Only for iOS.
22593      video.setAttribute("muted", ""); // Don't include audio with video.
22594
22595      const hands = options.hands === true; // Hand-tracking globals.
22596      let handVideoTime = -1;
22597      const useLegacyHandsAPI = true; // Performance of both libraries is
22598      //                                 equivalent on iPhone 14 Pro but vastly
22599      //                                 different on iPhone 13 Pro. 23.05.12.14.23
22600
22601      const buffer = document.createElement("canvas");
22602      let animationRequest;
22603
22604      function getAnimationRequest() {
22605        return animationRequest;
22606      }
22607
22608      videos.push({ video, buffer, getAnimationRequest });
22609
22610      buffer.width = options.width || 1280;
22611      buffer.height = options.height || 720;
22612
22613      const bufferCtx = buffer.getContext("2d", { willReadFrequently: true });
22614
22615      wrapper.appendChild(video);
22616
22617      video.style = `position: absolute;
22618                     top: 0;
22619                     left: 0;
22620                     opacity: 0;
22621                     transform: scaleX(${facingMode === "user" ? -1 : 1});
22622                     width: 100%;`;
22623
22624      buffer.style = `position: absolute;
22625                      opacity: 0;`;
22626
22627      let settings, stream, videoTrack;
22628      let lastProcessedVideoTime = -1;
22629      let facingModeChange = false;
22630      let torchEnabled = false;
22631
22632      try {
22633        // Grab video from the user using a requested width and height based
22634        // on the frame size.
22635        let cWidth = options.width,
22636          cHeight = options.height;
22637
22638        async function getDevice(facingModeChoice) {
22639          // Use local copies so we don't mutate the outer cWidth/cHeight
22640          // across repeated calls (camera swap, resize, etc.).
22641          let reqWidth = cWidth,
22642            reqHeight = cHeight;
22643
22644          const constraints = {
22645            facingMode: facingModeChoice,
22646            // Prefer a high-rate preview — filming feels tighter at 60+.
22647            // `ideal` is best-effort, so 30fps-only sensors still open.
22648            frameRate: { ideal: 60 },
22649          };
22650
22651          // Mobile camera sensors are physically landscape. Per Snap and
22652          // webrtcHacks: always request landscape constraints, even on a
22653          // portrait device — otherwise iOS Safari may refuse the request
22654          // or hand back a stream stuck at the orientation present at
22655          // getUserMedia time. We then rotate the canvas in process()
22656          // below to fit the portrait buffer.
22657          if (
22658            mobileCamera &&
22659            typeof window !== "undefined" &&
22660            window.matchMedia?.("(orientation: portrait)")?.matches &&
22661            (facingModeChoice === "environment" ||
22662              facingModeChoice === "user")
22663          ) {
22664            const tmp = reqWidth;
22665            reqWidth = reqHeight;
22666            reqHeight = tmp;
22667          }
22668
22669          // `cap` records the native camera MediaStreamTrack directly. Ask
22670          // for real camera resolution while retaining the small worker
22671          // buffer below for AC's interactive preview and effects.
22672          if (recordingCamera) {
22673            const captureSize = getCameraCaptureSize(
22674              reqWidth,
22675              reqHeight,
22676              window.devicePixelRatio,
22677            );
22678            reqWidth = captureSize.width;
22679            reqHeight = captureSize.height;
22680          }
22681
22682          constraints.width = { ideal: reqWidth };
22683          constraints.height = { ideal: reqHeight };
22684
22685          const targetAR = reqWidth / reqHeight;
22686          if (targetAR > 0 && isFinite(targetAR)) {
22687            constraints.aspectRatio = { ideal: targetAR };
22688          }
22689
22690          stream = await navigator.mediaDevices.getUserMedia({
22691            video: { ...constraints },
22692            audio: false,
22693          });
22694
22695          video.srcObject = stream;
22696          lastProcessedVideoTime = -1;
22697          videoTrack = stream.getVideoTracks()[0];
22698          const capabilities = videoTrack.getCapabilities?.() || {};
22699          settings = videoTrack.getSettings();
22700
22701          // 🎞️ Chase the sensor's max rate so the preview tracks the world
22702          // with less delay. The encoded tape keeps its own rate — this is
22703          // feel while filming, not format.
22704          const maxFps = capabilities.frameRate?.max;
22705          if (maxFps && (!settings.frameRate || maxFps > settings.frameRate)) {
22706            try {
22707              await videoTrack.applyConstraints({ frameRate: { ideal: maxFps } });
22708              settings = videoTrack.getSettings();
22709            } catch (fpsError) {
22710              console.warn("🎥 Max frame rate constraint failed:", fpsError);
22711            }
22712          }
22713          console.log(
22714            `🎥 Camera preview: ${settings.frameRate || "?"}fps (sensor max ${maxFps || "?"})`,
22715          );
22716
22717          // Update global facingMode in case different from requested.
22718          facingMode = videoTrack.getConstraints().facingMode;
22719          torchEnabled = false;
22720          const actualFacing = settings?.facingMode || facingModeChoice;
22721          send({
22722            type: "camera:capabilities",
22723            content: {
22724              torch: actualFacing === "environment" && capabilities.torch === true,
22725              enabled: false,
22726              facingMode: actualFacing,
22727              zoom: capabilities.zoom
22728                ? {
22729                    min: capabilities.zoom.min,
22730                    max: capabilities.zoom.max,
22731                    step: capabilities.zoom.step,
22732                    current: settings?.zoom ?? 1,
22733                  }
22734                : null,
22735            },
22736          });
22737
22738          // 📊 Send camera debug telemetry to the worker so pieces (e.g. cap)
22739          // can console.log() it and the piece-runs silo captures it.
22740          // This lets us diagnose rotation/orientation issues on devices we
22741          // can't inspect directly.
22742          const orientationAngle =
22743            (typeof screen !== "undefined" && screen.orientation?.angle) ??
22744            (typeof window !== "undefined" ? window.orientation : null) ??
22745            null;
22746          const isPortrait =
22747            typeof window !== "undefined"
22748              ? window.matchMedia?.("(orientation: portrait)")?.matches
22749              : null;
22750          send({
22751            type: "camera:debug",
22752            content: {
22753              requested: { width: reqWidth, height: reqHeight },
22754              bufferRequested: { width: cWidth, height: cHeight },
22755              trackSettings: {
22756                width: settings?.width ?? null,
22757                height: settings?.height ?? null,
22758                aspectRatio: settings?.aspectRatio ?? null,
22759                facingMode: settings?.facingMode ?? null,
22760                frameRate: settings?.frameRate ?? null,
22761                torch: capabilities.torch === true,
22762                zoom: settings?.zoom ?? null,
22763              },
22764              facingMode,
22765              iOS,
22766              cameraIOS,
22767              AestheticIOSApp,
22768              Android,
22769              recordingCamera,
22770              isPortrait,
22771              orientationAngle,
22772              userAgent:
22773                typeof navigator !== "undefined" ? navigator.userAgent : null,
22774            },
22775          });
22776
22777          const devices = await navigator.mediaDevices.enumerateDevices();
22778          const videoDevices = devices.filter(
22779            (device) => device.kind === "videoinput",
22780          );
22781
22782          send({ type: "video-devices", content: videoDevices.length });
22783
22784          if (debug) {
22785            videoDevices.forEach((device, index) => {
22786              if (index === 0) {
22787                console.log(
22788                  `Camera ${index + 1} (usually environment):`,
22789                  device.label,
22790                  device,
22791                );
22792              } else if (index === 1) {
22793                console.log(`Camera ${index + 1} (usually user):`, device);
22794              } else {
22795                console.log(`Camera ${index + 1} (additional camera):`, device);
22796              }
22797            });
22798          }
22799        }
22800
22801        await getDevice(facingMode);
22802
22803        video.addEventListener(
22804          "loadedmetadata",
22805          () => {
22806            video.play();
22807            if (debug)
22808              console.log("🎥 Resolution:", buffer.width, buffer.height);
22809          },
22810          { once: true },
22811        );
22812
22813        // Resizing the video after creation. (Window resize or device rotate.)
22814        videoResize = async function ({ width, height, facing, torch, zoom: requestedZoom }) {
22815          cancelAnimationFrame(getAnimationRequest());
22816
22817          try {
22818            if (Number.isFinite(requestedZoom)) {
22819              // Digital zoom only — camera zoom constraints are never
22820              // applied. On iOS they switch physical lenses mid-recording
22821              // (visible jump cuts) or destabilize the source frames, and
22822              // front cameras rarely expose a zoom range at all. The preview
22823              // crops in the worker and the recorder's zoom compositor reads
22824              // this shared value, so screen and tape ramp together.
22825              cameraFeedState.zoom = Math.max(1, requestedZoom);
22826              send({
22827                type: "camera:zoom",
22828                content: {
22829                  requested: requestedZoom,
22830                  applied: cameraFeedState.zoom,
22831                  available: true,
22832                  digital: true,
22833                },
22834              });
22835              if (
22836                width === undefined &&
22837                height === undefined &&
22838                !facing &&
22839                typeof torch !== "boolean"
22840              ) {
22841                process();
22842                return;
22843              }
22844            }
22845
22846            if (typeof torch === "boolean") {
22847              const capabilities = videoTrack?.getCapabilities?.() || {};
22848              const actualFacing = settings?.facingMode || facingMode;
22849              if (actualFacing !== "environment" || capabilities.torch !== true) {
22850                send({
22851                  type: "camera:torch",
22852                  content: {
22853                    available: false,
22854                    enabled: false,
22855                    facingMode: actualFacing,
22856                  },
22857                });
22858                process();
22859                return;
22860              }
22861              try {
22862                await videoTrack.applyConstraints({ advanced: [{ torch }] });
22863                torchEnabled = torch;
22864                settings = videoTrack.getSettings();
22865                send({
22866                  type: "camera:torch",
22867                  content: {
22868                    available: true,
22869                    enabled: torchEnabled,
22870                    facingMode: actualFacing,
22871                  },
22872                });
22873              } catch (torchError) {
22874                send({
22875                  type: "camera:torch",
22876                  content: {
22877                    available: true,
22878                    enabled: torchEnabled,
22879                    facingMode: actualFacing,
22880                    error: torchError?.message || String(torchError),
22881                  },
22882                });
22883              }
22884              if (
22885                width === undefined &&
22886                height === undefined &&
22887                !facing &&
22888                !Number.isFinite(requestedZoom)
22889              ) {
22890                process();
22891                return;
22892              }
22893            }
22894
22895            const sizeChange = !isNaN(width) && !isNaN(height);
22896
22897            if (sizeChange) {
22898              // Update outer dimensions from the new size.
22899              cWidth = width;
22900              cHeight = height;
22901
22902              video.addEventListener(
22903                "loadedmetadata",
22904                () => {
22905                  // Buffer should match the requested screen dimensions,
22906                  // not the (possibly swapped) constraint dimensions.
22907                  buffer.width = cWidth;
22908                  buffer.height = cHeight;
22909                  process();
22910                  send({ type: "camera:updated" });
22911                  if (debug)
22912                    console.log("🎥 Resolution:", buffer.width, buffer.height);
22913                },
22914                { once: true },
22915              );
22916
22917              if (mobileCamera) {
22918                await getDevice(facing);
22919              } else {
22920                video.srcObject = null; // Refresh the video `srcObject`.
22921                await videoTrack.applyConstraints({
22922                  width: { ideal: cWidth },
22923                  height: { ideal: cHeight },
22924                });
22925              }
22926            }
22927
22928            if (facing && settings?.facingMode !== facing) {
22929              if (torchEnabled) {
22930                try {
22931                  await videoTrack.applyConstraints({ advanced: [{ torch: false }] });
22932                } catch {}
22933                torchEnabled = false;
22934              }
22935              facingModeChange = true;
22936              await getDevice(facing);
22937              facingModeChange = false;
22938
22939              video.addEventListener(
22940                "canplay",
22941                () => {
22942                  process();
22943                  send({ type: "camera:updated", content: facingMode });
22944                },
22945                { once: true },
22946              );
22947            } else {
22948            }
22949
22950            // video.srcObject = stream;
22951            // if (!sizeChange && !facingModeChange) process();
22952          } catch (error) {
22953            process();
22954            if (debug) console.warn("🎥 Resolution update failed.", error);
22955          }
22956        };
22957
22958        // ✋ Optional Hand-tracking (only load once)
22959        if (hands === true && !handAPI) {
22960          if (useLegacyHandsAPI) {
22961            // Load older mediapipe lib.
22962            const script = document.createElement("script");
22963            script.src = "/aesthetic.computer/dep/@mediapipe/hands/hands.js";
22964            script.crossOrigin = "anonymous";
22965
22966            script.onload = function () {
22967              const config = {
22968                locateFile: (file) => {
22969                  return `aesthetic.computer/dep/@mediapipe/hands/${file}`;
22970                },
22971              };
22972
22973              handAPI = { hands: new Hands(config) }; // Globally def. handAPI.
22974              window.handAPI = handAPI; // For production debugging.
22975
22976              handAPI.hands.setOptions({
22977                selfieMode: false,
22978                maxNumHands: 1,
22979                modelComplexity: 0,
22980                minDetectionConfidence: 0.5,
22981                minTrackingConfidence: 0.5,
22982              });
22983
22984              handAPI.hands.onResults((data) => {
22985                diagram({
22986                  screen: data.multiHandLandmarks[0] || [],
22987                  world: data.multiHandWorldLandmarks[0] || [],
22988                  hand: data.multiHandedness[0]?.label.toLowerCase() || "none",
22989                });
22990              });
22991            };
22992
22993            document.head.appendChild(script);
22994          } else {
22995            if (!handAPI.HandLandmarker) {
22996              const { HandLandmarker, FilesetResolver } = await import(
22997                "/aesthetic.computer/dep/@mediapipe/tasks-vision/vision_bundle.js"
22998              );
22999
23000              const vision = await FilesetResolver.forVisionTasks(
23001                "/aesthetic.computer/dep/@mediapipe/tasks-vision/wasm",
23002              );
23003
23004              handAPI.HandLandmarker = HandLandmarker;
23005              handAPI.vision = vision;
23006            }
23007
23008            if (!handAPI.hl) {
23009              handAPI.hl = await handAPI.HandLandmarker.createFromOptions(
23010                handAPI.vision,
23011                {
23012                  baseOptions: {
23013                    modelAssetPath: "../models/hand_landmarker.task",
23014                    delegate: "GPU", // or "CPU"
23015                  },
23016                  canvas: document.createElement("canvas"),
23017                  runningMode: "VIDEO",
23018                  //runningMode: "LIVE_STREAM",
23019                  minHandDetectionConfidence: 0.25,
23020                  minHandPresenceConfidence: 0.25,
23021                  minTrackingConfidence: 0.25,
23022                  numHands: 1,
23023                },
23024              );
23025            }
23026          }
23027        }
23028
23029        process(); // Start processing data.
23030      } catch (err) {
23031        send({ type: "camera:denied" });
23032        console.log(err);
23033      }
23034
23035      function diagram(hand) {
23036        if (facingMode === "user") {
23037          // hand.screen.forEach((l) => (l.x = 1 - l.x));
23038          // Reverse handedness because our data is mirrored.
23039          // if (hand.handedness === "left") {
23040          //   hand.handedness = "right";
23041          // } else if (hand.handedness === "right") {
23042          //   hand.handedness = "left";
23043          // }
23044        }
23045        handData = hand;
23046      }
23047
23048      function process() {
23049        cancelAnimationFrame(getAnimationRequest());
23050        if (facingModeChange) return;
23051        // Camera tracks commonly deliver 30fps while display RAF runs at 60
23052        // or 120Hz. Avoid redrawing, reading, and transferring the same camera
23053        // frame two to four times.
23054        if (video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) {
23055          animationRequest = requestAnimationFrame(process);
23056          return;
23057        }
23058        if (video.currentTime === lastProcessedVideoTime) {
23059          animationRequest = requestAnimationFrame(process);
23060          return;
23061        }
23062        lastProcessedVideoTime = video.currentTime;
23063        // cancelAnimationFrame(getAnimationRequest());
23064        // TODO: Video effects / filter kernels could be added here...
23065        // 💡 For GPU backed visuals. 23.04.29.20.47
23066
23067        // Detect orientation mismatch (landscape video in portrait buffer or vice versa).
23068        // On modern iOS Safari and Android Chrome the camera stream is already
23069        // rotated to match the device orientation, so this path rarely fires —
23070        // but desktop webcams and some older browsers still hand us the raw
23071        // sensor buffer (landscape) and need a manual rotation to fit a
23072        // portrait buffer.
23073        const videoIsLandscape = video.videoWidth > video.videoHeight;
23074        const bufferIsPortrait = buffer.height > buffer.width;
23075        const needsRotation =
23076          video.videoWidth > 0 &&
23077          video.videoHeight > 0 &&
23078          videoIsLandscape === bufferIsPortrait;
23079
23080        // 📊 One-shot diagnostic: report actual video dimensions once the
23081        // first real frame is available. Lands in piece-runs via cap.mjs.
23082        if (!video.__acFrameDebugReported && video.videoWidth > 0) {
23083          video.__acFrameDebugReported = true;
23084          send({
23085            type: "camera:debug:frame",
23086            content: {
23087              video: { width: video.videoWidth, height: video.videoHeight },
23088              buffer: { width: buffer.width, height: buffer.height },
23089              videoIsLandscape,
23090              bufferIsPortrait,
23091              needsRotation,
23092              facingMode,
23093            },
23094          });
23095        }
23096        // Mirror the front camera for selfie framing. Desktop webcams are
23097        // conventionally mirrored too. Mobile rear camera stays unmirrored.
23098        const needsMirror = facingMode === "user" || !mobileCamera;
23099        cameraFeedState.mirrored = needsMirror; // Recorder compositor matches.
23100        // EXIF orientation 6 — the convention iPhone (and most Android
23101        // devices) tag portrait camera captures with — means "rotate 90°
23102        // CW for display". The raw landscape sensor buffer has the
23103        // subject's "up" direction at column 0, and rotating +PI/2 CW
23104        // brings column 0 to the top of the rendered frame.
23105        // 🧪 URL override while we bisect on device:
23106        //   ?camrot=0|cw|180|ccw   manually pick the angle
23107        //   ?camforce=1            force rotation even when dims say no
23108        let rotationAngle = Math.PI / 2;
23109        let forceRotation = false;
23110        if (typeof location !== "undefined" && location.search) {
23111          const camParams = new URLSearchParams(location.search);
23112          const camrot = camParams.get("camrot");
23113          if (camrot === "cw" || camrot === "90") rotationAngle = Math.PI / 2;
23114          else if (camrot === "ccw" || camrot === "-90")
23115            rotationAngle = -Math.PI / 2;
23116          else if (camrot === "180") rotationAngle = Math.PI;
23117          else if (camrot === "0" || camrot === "none") rotationAngle = 0;
23118          if (camParams.get("camforce") === "1") forceRotation = true;
23119        }
23120        const needsRotationFinal = forceRotation
23121          ? video.videoWidth > 0 && video.videoHeight > 0
23122          : needsRotation;
23123
23124        // Send frames by default (non-rotation path only).
23125        if ((!needsRotationFinal || rotationAngle === 0) && needsMirror) {
23126          bufferCtx.translate(buffer.width / 2, buffer.height / 2);
23127          const zoom = 1;
23128          // if (hands) {
23129          // bufferCtx.scale(zoom, zoom);
23130          // } else {
23131          bufferCtx.scale(-zoom, zoom);
23132          // }
23133          bufferCtx.translate(-buffer.width / 2, -buffer.height / 2);
23134        }
23135
23136        // 🤚 Track Hands on the GPU if flagged.
23137        if (hands === true && handAPI) {
23138          if (handVideoTime !== video.currentTime && video.videoWidth > 0) {
23139            handVideoTime = video.currentTime;
23140            if (useLegacyHandsAPI && !handAPI?.legacyProcessing) {
23141              handAPI.hands?.send({ image: bufferCtx.canvas }).then(() => {
23142                handAPI.legacyProcessing = false;
23143                // Don't process more than one frame at a time.
23144              });
23145              handAPI.legacyProcessing = true;
23146            } else {
23147              // const data = handAPI.hl?.detectForVideo(video, handVideoTime);
23148              // TODO: This will no longer work. 23.5.24
23149              //       Check the other `diagram` call.
23150              // diagram(data?.landmarks[0] || []);
23151            }
23152            // send({type: "hand-tracking-data", content: landmarks});
23153          }
23154        }
23155
23156        // Drawing a video frame to the buffer (mirrored, proportion adjusted).
23157        if (needsRotationFinal && rotationAngle !== 0) {
23158          // Landscape sensor pixels in portrait buffer: rotate ±90° so the
23159          // video fills the portrait frame. After rotation the effective
23160          // video width/height are swapped, so fit calculation uses the
23161          // swapped aspect ratio.
23162          const rotatedVideoAR = video.videoHeight / video.videoWidth;
23163          const bufferAR = buffer.width / buffer.height;
23164          let outWidth, outHeight;
23165
23166          if (fitMode === "contain") {
23167            if (rotatedVideoAR >= bufferAR) {
23168              outWidth = buffer.width;
23169              outHeight = outWidth / rotatedVideoAR;
23170            } else {
23171              outHeight = buffer.height;
23172              outWidth = outHeight * rotatedVideoAR;
23173            }
23174          } else {
23175            if (rotatedVideoAR <= bufferAR) {
23176              outWidth = buffer.width;
23177              outHeight = outWidth / rotatedVideoAR;
23178            } else {
23179              outHeight = buffer.height;
23180              outWidth = outHeight * rotatedVideoAR;
23181            }
23182          }
23183
23184          const outX = (buffer.width - outWidth) / 2;
23185          const outY = (buffer.height - outHeight) / 2;
23186
23187          bufferCtx.save();
23188          bufferCtx.translate(outX + outWidth / 2, outY + outHeight / 2);
23189          bufferCtx.rotate(rotationAngle);
23190          // Mirror correction. In the rotated frame, horizontal flip in
23191          // original (buffer) space is scale(1, -1) for CW rotation and
23192          // scale(1, 1) (no-op) for CCW — the CCW case already lands the
23193          // subject in the right handedness after rotation for rear cams.
23194          if (needsMirror) bufferCtx.scale(1, -1);
23195          bufferCtx.drawImage(
23196            video,
23197            -outHeight / 2,
23198            -outWidth / 2,
23199            outHeight,
23200            outWidth,
23201          );
23202          bufferCtx.restore();
23203        } else {
23204          const videoAR = video.videoWidth / video.videoHeight;
23205          const bufferAR = buffer.width / buffer.height;
23206          let outWidth,
23207            outHeight,
23208            outX = 0,
23209            outY = 0;
23210
23211          if (fitMode === "contain") {
23212            // "contain" mode: letterbox to fit entire video in buffer
23213            if (videoAR >= bufferAR) {
23214              // Video is wider - fit to width, letterbox top/bottom
23215              outWidth = buffer.width;
23216              outHeight = outWidth / videoAR;
23217            } else {
23218              // Video is taller - fit to height, letterbox left/right
23219              outHeight = buffer.height;
23220              outWidth = outHeight * videoAR;
23221            }
23222          } else {
23223            // "cover" mode (default): crop to fill buffer completely
23224            if (videoAR <= bufferAR) {
23225              // Tall to wide.
23226              outWidth = buffer.width;
23227              outHeight = outWidth / videoAR;
23228            } else {
23229              // Wide to tall.
23230              outHeight = buffer.height;
23231              outWidth = outHeight * videoAR;
23232            }
23233          }
23234
23235          outY = (buffer.height - outHeight) / 2; // Adjusting position.
23236          outX = (buffer.width - outWidth) / 2;
23237
23238          bufferCtx.drawImage(video, outX, outY, outWidth, outHeight);
23239          bufferCtx.resetTransform();
23240        }
23241
23242        if (options.hidden !== true) {
23243          const pixels = bufferCtx.getImageData(
23244            0,
23245            0,
23246            buffer.width,
23247            buffer.height,
23248          );
23249
23250          send(
23251            {
23252              type: "video-frame",
23253              content: {
23254                width: pixels.width,
23255                height: pixels.height,
23256                pixels: pixels.data,
23257              },
23258            },
23259            [pixels.data.buffer],
23260          );
23261        }
23262
23263        animationRequest = requestAnimationFrame(process);
23264      }
23265    }
23266  }
23267
23268  // Pointer Lock 🔫
23269  let pointerLockCursor = null;
23270  
23271  // Create the pointer lock cursor element
23272  function createPointerLockCursor() {
23273    // Remove any existing cursor first
23274    const existing = document.getElementById("pointer-lock-cursor");
23275    if (existing) existing.remove();
23276    
23277    const cursor = document.createElement("div");
23278    cursor.id = "pointer-lock-cursor";
23279    cursor.innerHTML = `
23280      <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 25 25">
23281        <path d="
23282            M 13,3 L 13,6 
23283            M 13,20 L 13,23 
23284            M 6,13 L 3,13 
23285            M 20,13 L 23,13
23286        " stroke="black" stroke-width="4" stroke-linecap="round"/>
23287        <circle cx="13" cy="13" r="2" fill="black" />
23288        <path d="
23289            M 12,2 L 12,5 
23290            M 12,19 L 12,22 
23291            M 5,12 L 2,12 
23292            M 19,12 L 22,12
23293        " stroke="#00FFFF" stroke-width="4" stroke-linecap="round"/>
23294        <circle cx="12" cy="12" r="2" fill="#ffffff" />
23295      </svg>
23296    `;
23297    
23298    // Use setAttribute for better compatibility
23299    cursor.setAttribute("style", `
23300      position: fixed !important;
23301      top: 50% !important;
23302      left: 50% !important;
23303      width: 24px !important;
23304      height: 24px !important;
23305      transform: translate(-50%, -50%) !important;
23306      pointer-events: none !important;
23307      z-index: 999999 !important;
23308      display: none !important;
23309      user-select: none !important;
23310      -webkit-user-select: none !important;
23311    `.replace(/\s+/g, ' ').trim());
23312    
23313    document.body.appendChild(cursor);
23314    // console.log("🎯 Pointer lock cursor element created with inline SVG", cursor);
23315    // console.log("🎯 Cursor element styles:", cursor.getAttribute("style"));
23316    // console.log("🎯 Document body children count:", document.body.children.length);
23317    return cursor;
23318  }
23319  
23320  document.addEventListener("pointerlockchange", () => {
23321    const isLocked = document.pointerLockElement === wrapper;
23322    // console.log("🔒 Pointer lock change:", isLocked);
23323    // console.log("🔒 Pointer lock element:", document.pointerLockElement);
23324    // console.log("🔒 Wrapper element:", wrapper);
23325    
23326    // Create cursor element if it doesn't exist
23327    if (!pointerLockCursor) {
23328      pointerLockCursor = createPointerLockCursor();
23329    }
23330    
23331    // Show/hide the cursor based on pointer lock state
23332    if (isLocked) {
23333      pointerLockCursor.style.setProperty("display", "block", "important");
23334      // console.log("🎯 Showing pointer lock cursor");
23335      // console.log("🎯 Cursor element display:", pointerLockCursor.style.display);
23336      // console.log("🎯 Cursor element in DOM:", document.getElementById("pointer-lock-cursor"));
23337    } else {
23338      pointerLockCursor.style.setProperty("display", "none", "important");
23339      // console.log("🎯 Hiding pointer lock cursor");
23340    }
23341    
23342    send({
23343      type: isLocked ? "pen:locked" : "pen:unlocked",
23344    });
23345  });
23346
23347  // document.addEventListener("pointerlockerror", () => {
23348  // console.error("Pointer lock failed!");
23349  //});
23350
23351  // Window Scroll 📜
23352  // Horizontal two-finger scroll is piece input (e.g. tape scrubbing), so
23353  // keep Chrome from turning it into back/forward swipe navigation.
23354  document.documentElement.style.overscrollBehavior = "none";
23355  if (document.body) document.body.style.overscrollBehavior = "none";
23356  window.addEventListener(
23357    "wheel",
23358    function (event) {
23359      if (underlayFrame) event.preventDefault(); // Tape presenting: scroll = scrub
23360      send({
23361        type: "scroll",
23362        content: {
23363          x: event.deltaX / subdivisions,
23364          y: event.deltaY / subdivisions,
23365        },
23366      });
23367    },
23368    { passive: false },
23369  );
23370
23371  // Window Focus
23372  window.addEventListener("focus", function (e) {
23373    send({ type: "focus-change", content: true });
23374  });
23375
23376  // Window Blur
23377  window.addEventListener("blur", function (e) {
23378    send({ type: "focus-change", content: false });
23379  });
23380
23381  // Window Visibility
23382  document.addEventListener("visibilitychange", function () {
23383    if (!document.hidden) wrapper.classList.remove("reloading");
23384    // if (document.hidden) mediaRecorder?.stop();
23385    send({
23386      type: "visibility-change",
23387      content: !document.hidden,
23388    });
23389  });
23390
23391  // 🚨 Signal (Used to pass messages via window... important for embedded HTML
23392  //           `content` used within pieces that needs communication with the
23393  //           main system)
23394
23395  // Send signals to the running piece.
23396  window.signal = function (message) {
23397    if (debug) console.log("🚨 Signal sent:", message);
23398    send({
23399      type: "signal",
23400      content: message,
23401    });
23402  };
23403
23404  // Receive signals from the piece & assign callbacks.
23405  // These get flushed between pieces.
23406  // Note: These are useful for
23407  window.when = function (message, callback) {
23408    whens[message] = callback;
23409  };
23410
23411  // 📚 History
23412  // TODO: Extract all the history features into a class of some kind?
23413  // TODO: Eventually add an API so that a disk can list all the history of
23414  //       a user's session. This could also be used for autocompletion of
23415  //       pieces / up + down arrow prev-next etc.
23416
23417  // Track labelBack state in main thread (persists across worker reloads)
23418  let mainThreadLabelBack =
23419    window.safeSessionStorageGet("aesthetic-labelBack") === "true";
23420
23421  window.onpopstate = function (e) {
23422    if (
23423      document.location.hash === "#debug" ||
23424      document.location.hash === "#nodebug"
23425    ) {
23426      document.location.reload();
23427    }
23428
23429    const sluggy = slug(document.location.href);
23430    if (sluggy === "prompt") keyboard?.input.focus();
23431
23432    const parsed = parse(sluggy || window.acSTARTING_PIECE);
23433
23434    // Restore labelBack state for history navigation
23435    if (mainThreadLabelBack) {
23436      console.log("🔗 Main thread restoring labelBack for history navigation");
23437      parsed.labelBack = true;
23438      // Clear the state after using it for navigation
23439      mainThreadLabelBack = false;
23440      window.safeSessionStorageRemove("aesthetic-labelBack");
23441      console.log(
23442        "🔗 Main thread: Cleared labelBack after using it for history navigation",
23443      );
23444    }
23445
23446    send({
23447      type: "history-load",
23448      content: parsed,
23449    });
23450  };
23451
23452  // Fullscreen
23453  // Note: This doesn't work in Safari because you can't fullscreen the body element.
23454  //       (Or anything other than a video element?) 22.2.13
23455
23456  const requestFullscreen =
23457    document.body.requestFullscreen || wrapper.webkitRequestFullscreen;
23458
23459  // const exitFullscreen =
23460  //   document.exitFullscreen || document.webkitExitFullscreen;
23461
23462  // Tries to toggle fullscreen. Must be called within a user interaction.
23463  function enableFullscreen() {
23464    const fullscreenElement =
23465      document.fullscreenElement || document.webkitFullscreenElement;
23466
23467    if (!fullscreenElement) {
23468      requestFullscreen.apply(document.body)?.catch((e) => console.error(e));
23469    } else {
23470      // exitFullscreen();
23471    }
23472  }
23473
23474  document.body.onfullscreenchange = (event) => {
23475    const fullscreenElement =
23476      document.fullscreenElement || document.webkitFullscreenElement;
23477
23478    if (fullscreenElement) {
23479      console.log("😱 Entered fullscreen mode!", fullscreenElement);
23480    } else {
23481      console.log("😱 Leaving fullscreen mode!");
23482    }
23483  };
23484
23485  // 🔘 Button Hitboxes
23486  // (Created for 📋 Clipboard Events)
23487  let hitboxes = {};
23488  window.addEventListener("pointerup", async (e) => {
23489    keys(hitboxes).forEach((key) => hitboxes[key]?.(e));
23490  });
23491
23492  window.addEventListener("pointerdown", async (e) => {
23493    keys(hitboxes).forEach((key) => hitboxes[key]?.(e));
23494  });
23495
23496  // BIOS overlays stay reachable when a piece hides the OS pointer in favor
23497  // of a painted cursor. Raise the native hand over system hitboxes, then
23498  // restore the piece's exact cursor request when it leaves.
23499  window.addEventListener("pointermove", (e) => {
23500    const overSystemControl = keys(hitboxes).some((key) =>
23501      hitboxes[key]?.contains?.(e));
23502    document.body.style.cursor = overSystemControl
23503      ? "pointer"
23504      : (window.acPieceCursorCss || "auto");
23505  });
23506
23507  // 📄 Drag and Drop File API
23508
23509  // Drag over...
23510  document.body.addEventListener("dragover", function (e) {
23511    e.stopPropagation();
23512    e.preventDefault();
23513    e.dataTransfer.dropEffect = "copy"; // Show as copy
23514    // copy, move, link, or none
23515  });
23516
23517  document.body.addEventListener("drop", async function (e) {
23518    e.stopPropagation();
23519    e.preventDefault();
23520    const files = e.dataTransfer.files; // Get the file(s).
23521    
23522    // Process multiple files if dropped together
23523    if (files.length > 0) {
23524      console.log(`💧 Dropped ${files.length} file(s)`);
23525      
23526      // Check for ALS + WAV combination
23527      let alsFile = null;
23528      let wavFile = null;
23529      
23530      // Scan all files to identify types
23531      for (let i = 0; i < files.length; i++) {
23532        const file = files[i];
23533        const ext = extension(file.name);
23534        console.log(`💧 File ${i + 1}: ${file.name} (${ext})`);
23535        
23536        if (ext === "als" && !alsFile) {
23537          alsFile = file;
23538        } else if (ext === "wav" && !wavFile) {
23539          wavFile = file;
23540        }
23541      }
23542      
23543      // If we have both ALS and WAV, process them as a pair
23544      if (alsFile && wavFile) {
23545        console.log("🎵🔊 Processing ALS + WAV combination:", alsFile.name, "+", wavFile.name);
23546        
23547        // Process ALS file first
23548        await processDroppedFile(alsFile);
23549        
23550        // Then process WAV file
23551        await processDroppedFile(wavFile);
23552        
23553        return; // Exit early - we've handled the multi-file drop
23554      }
23555      
23556      // Otherwise, process files individually (existing behavior)
23557      for (let i = 0; i < files.length; i++) {
23558        await processDroppedFile(files[i]);
23559      }
23560    } else {
23561      // No files — check for dropped text content.
23562      const text = e.dataTransfer.getData("text/plain")?.trim();
23563      if (text) {
23564        console.log("💧 Dropped text:", text.substring(0, 80));
23565        if (isKidlispSource(text)) {
23566          // KidLisp source code
23567          send({
23568            type: "dropped:piece",
23569            content: { name: "dropped", source: text, isKidLisp: true },
23570          });
23571        } else if (
23572          text.includes("function paint") ||
23573          text.includes("function boot") ||
23574          text.includes("function act") ||
23575          text.includes("function sim") ||
23576          text.includes("export {") ||
23577          text.includes("export default")
23578        ) {
23579          // JavaScript piece source code
23580          send({
23581            type: "dropped:piece",
23582            content: { name: "dropped", source: text },
23583          });
23584        } else {
23585          // Treat as a prompt command
23586          send({ type: "dropped:text", content: { text } });
23587        }
23588      }
23589    }
23590  });
23591
23592  // Extract file processing logic into reusable function
23593  async function processDroppedFile(file) {
23594    const ext = extension(file.name);
23595    console.log("💧 Processing:", file.name, ext);
23596    
23597    // 📦 KidLisp bundle (.lisp.html) - extract embedded source and run it
23598    if (file.name.endsWith('.lisp.html')) {
23599      console.log("📦 Detected KidLisp bundle:", file.name);
23600      const reader = new FileReader();
23601      reader.onload = async function (e) {
23602        try {
23603          let htmlContent = e.target.result;
23604          
23605          // Check if it's gzip compressed (has DecompressionStream)
23606          if (htmlContent.includes("DecompressionStream('gzip')")) {
23607            // Extract base64 gzip data and decompress
23608            const base64Match = htmlContent.match(/base64,([A-Za-z0-9+/=]+)/);
23609            if (base64Match) {
23610              const base64Data = base64Match[1];
23611              const binaryString = atob(base64Data);
23612              const bytes = new Uint8Array(binaryString.length);
23613              for (let i = 0; i < binaryString.length; i++) {
23614                bytes[i] = binaryString.charCodeAt(i);
23615              }
23616              // Decompress gzip
23617              const blob = new Blob([bytes]);
23618              const ds = new DecompressionStream('gzip');
23619              const decompressedStream = blob.stream().pipeThrough(ds);
23620              htmlContent = await new Response(decompressedStream).text();
23621            }
23622          }
23623          
23624          // Extract KidLisp source from the decompressed HTML
23625          // Look for: window.EMBEDDED_KIDLISP_SOURCE = "..."
23626          // or: window.acKIDLISP_SOURCE = "..."
23627          let source = null;
23628          const embeddedMatch = htmlContent.match(/window\.EMBEDDED_KIDLISP_SOURCE\s*=\s*"([^"]+)"/);
23629          const acMatch = htmlContent.match(/window\.acKIDLISP_SOURCE\s*=\s*"([^"]+)"/);
23630          
23631          if (embeddedMatch) {
23632            source = embeddedMatch[1].replace(/\\n/g, '\n').replace(/\\"/g, '"');
23633          } else if (acMatch) {
23634            source = acMatch[1].replace(/\\n/g, '\n').replace(/\\"/g, '"');
23635          }
23636          
23637          if (source) {
23638            // Extract piece name from filename: [email protected]
23639            // Keep the $ prefix for KidLisp pieces
23640            const nameMatch = file.name.match(/^(\$[^-]+)/);
23641            const pieceName = nameMatch ? nameMatch[1] : file.name.replace('.lisp.html', '');
23642            
23643            console.log("📦 Extracted KidLisp source from bundle:", pieceName, source.substring(0, 50) + "...");
23644            send({
23645              type: "dropped:piece",
23646              content: {
23647                name: pieceName,
23648                source: source,
23649                isKidLisp: true,
23650                fromBundle: true
23651              },
23652            });
23653          } else {
23654            console.warn("📦 Could not find KidLisp source in bundle");
23655          }
23656        } catch (err) {
23657          console.error("📦 Error processing KidLisp bundle:", err);
23658        }
23659      };
23660      reader.readAsText(file);
23661      return;
23662    }
23663    
23664    // 🗒️ Source code file. (.mjs / .lisp native AC pieces, .js p5 sketches)
23665    if (ext === "mjs" || ext === "lisp" || ext === "js") {
23666        const reader = new FileReader();
23667        reader.onload = function (e) {
23668          send({
23669            type: "dropped:piece",
23670            content: {
23671              name: file.name.replace(/\.(mjs|lisp|js)$/, ""),
23672              source: e.target.result,
23673              isP5: ext === "js", // Route .js sketches through the p5 worker.
23674            },
23675          });
23676        };
23677
23678        reader.readAsText(file);
23679        // 🖼️ Image file
23680      } else if (
23681        ext === "png" ||
23682        ext === "jpeg" ||
23683        ext === "jpg" ||
23684        ext === "gif" ||
23685        ext === "webp"
23686      ) {
23687        const bitmap = await toBitmap(file);
23688        send({
23689          type: "dropped:bitmap",
23690          content: {
23691            name: file.name.replace("." + ext, ""),
23692            source: bitmap,
23693          },
23694        });
23695        // 🖼️⌛ Recorded Painting (or other complex media) / 🎮 Game Boy ROMs in ZIP
23696      } else if (ext === "zip") {
23697        const reader = new FileReader();
23698        reader.onload = async function (e) {
23699          const data = e.target.result;
23700          if (!window.JSZip) await loadJSZip();
23701          
23702          try {
23703            const zip = new window.JSZip();
23704            const zipContent = await zip.loadAsync(data);
23705            
23706            // Look for Game Boy ROM files first
23707            const romFiles = [];
23708            zipContent.forEach((relativePath, file) => {
23709              const ext = extension(file.name);
23710              if (ext === "gb" || ext === "gbc") {
23711                romFiles.push(file);
23712              }
23713            });
23714            
23715            if (romFiles.length > 0) {
23716              // Handle Game Boy ROMs from zip
23717              console.log(`🎮 Found ${romFiles.length} Game Boy ROM(s) in zip:`, romFiles.map(f => f.name));
23718              
23719              // If multiple ROMs, let user choose (for now, just take the first one)
23720              const romFile = romFiles[0];
23721              const romData = await romFile.async("arraybuffer");
23722              const ext = extension(romFile.name);
23723              
23724              console.log("🎮 BIOS: Extracted Game Boy ROM from zip:", romFile.name, `(${romData.byteLength} bytes)`);
23725              
23726              const gameRomData = {
23727                name: romFile.name.replace(/\.(gb|gbc)$/, ""),
23728                originalName: romFile.name,
23729                romData: romData, // ArrayBuffer
23730                isGameBoyColor: ext === "gbc"
23731              };
23732              
23733              // Handle ROM loading directly in main thread
23734              loadGameboyROM(gameRomData);
23735              return; // Exit early, we found a ROM
23736            }
23737            
23738            // If no ROM files found, try processing as painting record
23739            const record = await unzip(data);
23740            if (record) {
23741              send({ type: "painting:record:dropped", content: record });
23742            }
23743            
23744          } catch (error) {
23745            console.error("❌ Failed to process ZIP file:", error);
23746          }
23747        };
23748        reader.readAsArrayBuffer(file);
23749      // 🔊 Audio file (.wav)
23750      } else if (ext === "wav") {
23751        const reader = new FileReader();
23752        reader.onload = async function (e) {
23753          try {
23754            console.log("🔊 BIOS: Dropped WAV file:", file.name, `(${e.target.result.byteLength} bytes)`);
23755            
23756            // Create a unique ID for this WAV file
23757            const wavId = "dropped-wav-" + file.name.replace(/\.wav$/, "") + "-" + performance.now();
23758            const arrayBuffer = e.target.result;
23759            const fileSize = arrayBuffer.byteLength; // Capture size before storing
23760            
23761            console.log("🔊 BIOS: Storing WAV in sfx cache with ID:", wavId);
23762            // Store the WAV data in the sfx cache for decoding and playback
23763            sfx[wavId] = arrayBuffer;
23764            
23765            console.log("🔊 BIOS: Starting immediate WAV decoding...");
23766            // Trigger immediate decoding
23767            await decodeSfx(wavId);
23768            console.log("🔊 BIOS: WAV decoding complete, sending to piece...");
23769            
23770            send({
23771              type: "dropped:wav",
23772              content: {
23773                name: file.name.replace(/\.wav$/, ""),
23774                originalName: file.name,
23775                size: fileSize,
23776                id: wavId // Include the audio ID for playback
23777              },
23778            });
23779            
23780            console.log("🔊 BIOS: WAV processing complete for:", wavId);
23781          } catch (error) {
23782            console.error("❌ BIOS: Failed to process WAV file:", error);
23783          }
23784        };
23785        reader.readAsArrayBuffer(file);
23786      // 🎵 Long-form audio (.mp3 etc.) — same `dropped:file` path the
23787      // ac-electron host uses, but with an object url, so the `dj` piece
23788      // streams it straight from memory.
23789      } else if (["mp3", "m4a", "aac", "ogg", "opus", "flac", "webm"].includes(ext)) {
23790        const url = URL.createObjectURL(file);
23791        console.log("🎵 BIOS: Dropped audio file:", file.name, url);
23792        send({
23793          type: "dropped:file",
23794          content: { url, name: file.name, mime: file.type },
23795        });
23796      // 🎵 Ableton Live Set file (ZIP archive containing XML)
23797      }
23797 else if (ext === "als") {
23798        
23799        // Parse XML and extract key project structure using robust regex patterns
23800        function parseAbletonProject(xmlString) {
23801          try {
23802            console.log("🎵 BIOS parsing ALS XML data:", xmlString.length, "characters");
23803            
23804            const structure = {
23805              version: "Unknown",
23806              creator: "Unknown", 
23807              tracks: [],
23808              tempo: "Unknown",
23809              sceneCount: 0,
23810              sampleRate: "Unknown",
23811              lastModDate: "Unknown"
23812            };
23813            
23814            // Extract Ableton version info
23815            const abletonMatch = xmlString.match(/<Ableton[^>]*MajorVersion="([^"]*)"[^>]*MinorVersion="([^"]*)"[^>]*Creator="([^"]*)"/);
23816            if (abletonMatch) {
23817              structure.version = `${abletonMatch[1]}.${abletonMatch[2]}`;
23818              structure.creator = abletonMatch[3];
23819            }
23820            
23821            // Extract sample rate for accurate timing calculations
23822            const sampleRateMatch = xmlString.match(/<SampleRate[^>]*Value="([^"]*)"/);
23823            if (sampleRateMatch) {
23824              structure.sampleRate = parseFloat(sampleRateMatch[1]) || 44100;
23825            }
23826            
23827            // Extract last modified date
23828            const lastModMatch = xmlString.match(/<LastModDate[^>]*Value="([^"]*)"/);
23829            if (lastModMatch) {
23830              structure.lastModDate = lastModMatch[1];
23831            }
23832            
23833            // Extract tempo - Enhanced with multiple strategies
23834            let tempo = "Unknown";
23835            
23836            // Strategy 1: Look for global tempo in LiveSet
23837            const globalTempoMatch = xmlString.match(/<LiveSet[^>]*>[\s\S]*?<MasterTrack[^>]*>[\s\S]*?<DeviceChain[^>]*>[\s\S]*?<Tempo[^>]*>[\s\S]*?<Manual[^>]*Value="([^"]*)"[\s\S]*?<\/Tempo>/);
23838            if (globalTempoMatch) {
23839              tempo = parseFloat(globalTempoMatch[1]) || "Unknown";
23840              console.log(`🎵 BIOS found global tempo: ${tempo} BPM`);
23841            }
23842            
23843            // Strategy 2: Manual tempo value (enhanced pattern)
23844            if (tempo === "Unknown") {
23845              const tempoMatch = xmlString.match(/<Manual[^>]*Value="([^"]*)"/);
23846              if (tempoMatch) {
23847                const tempoValue = parseFloat(tempoMatch[1]);
23848                // Only accept reasonable tempo values (between 60-300 BPM)
23849                if (tempoValue && tempoValue >= 60 && tempoValue <= 300) {
23850                  tempo = tempoValue;
23851                  console.log(`🎵 BIOS found manual tempo: ${tempo} BPM`);
23852                }
23853              }
23854            }
23855            
23856            // Strategy 3: MasterTrack tempo (enhanced)
23857            if (tempo === "Unknown") {
23858              const masterTempoMatch = xmlString.match(/<MasterTrack[^>]*>[\s\S]*?<Tempo[^>]*>[\s\S]*?<Manual[^>]*Value="([^"]*)"[\s\S]*?<\/Tempo>/);
23859              if (masterTempoMatch) {
23860                tempo = parseFloat(masterTempoMatch[1]) || "Unknown";
23861                console.log(`🎵 BIOS found MasterTrack tempo: ${tempo} BPM`);
23862              }
23863            }
23864            
23865            structure.tempo = tempo;
23866            
23867            // Count scenes
23868            const sceneMatches = xmlString.match(/<Scene[^>]*>/g);
23869            structure.sceneCount = sceneMatches ? sceneMatches.length : 0;
23870            
23871            // Extract tracks - simplified for now to avoid complexity
23872            const trackTypes = ['GroupTrack', 'MidiTrack', 'AudioTrack', 'ReturnTrack'];
23873            console.log("🎵 BIOS starting track detection...");
23874            
23875            trackTypes.forEach(trackType => {
23876              const trackRegex = new RegExp(`<${trackType}[^>]*>([\\s\\S]*?)</${trackType}>`, 'g');
23877              let match;
23878              
23879              while ((match = trackRegex.exec(xmlString)) !== null) {
23880                const trackContent = match[1];
23881                
23882                // Extract track name
23883                const nameMatch = trackContent.match(/<(?:EffectiveName|UserName)[^>]*Value="([^"]*)"/);
23884                const trackName = nameMatch ? nameMatch[1] : `${trackType} ${structure.tracks.length + 1}`;
23885                
23886                console.log(`🎵 BIOS found track: "${trackName}" (${trackType})`);
23887                
23888                // Extract MIDI notes for MIDI tracks
23889                let midiNotes = [];
23890                let clips = [];
23891                
23892                if (trackType === "MidiTrack") {
23893                  // Look for MIDI notes in clips
23894                  const clipMatches = trackContent.match(/<MidiClip[^>]*>[\s\S]*?<\/MidiClip>/g);
23895                  if (clipMatches) {
23896                    clipMatches.forEach((clipContent, clipIndex) => {
23897                      // Extract notes from this clip
23898                      const noteMatches = clipContent.match(/<KeyTrack[^>]*>[\s\S]*?<Notes>[\s\S]*?<\/Notes>/g);
23899                      if (noteMatches) {
23900                        noteMatches.forEach(noteSection => {
23901                          const individualNotes = noteSection.match(/<MidiNoteEvent[^>]*Time="([^"]*)"[^>]*Duration="([^"]*)"[^>]*Velocity="([^"]*)"[^>]*>/g);
23902                          if (individualNotes) {
23903                            individualNotes.forEach(noteEvent => {
23904                              const timeMatch = noteEvent.match(/Time="([^"]*)"/);
23905                              const durationMatch = noteEvent.match(/Duration="([^"]*)"/);
23906                              const velocityMatch = noteEvent.match(/Velocity="([^"]*)"/);
23907                              
23908                              if (timeMatch && durationMatch && velocityMatch) {
23909                                midiNotes.push({
23910                                  time: parseFloat(timeMatch[1]) || 0,
23911                                  duration: parseFloat(durationMatch[1]) || 0,
23912                                  velocity: parseFloat(velocityMat
23912ch[1]) || 0,
23913                                  clip: clipIndex
23914                                });
23915                              }
23916                            });
23917                          }
23918                        });
23919                      }
23920                      
23921                      // Extract clip info
23922                      const clipNameMatch = clipContent.match(/<Name[^>]*Value="([^"]*)"/);
23923                      const clipTimeMatch = clipContent.match(/<CurrentStart[^>]*Value="([^"]*)"/);
23924                      const clipLengthMatch = clipContent.match(/<Length[^>]*Value="([^"]*)"/);
23925                      
23926                      clips.push({
23927                        name: clipNameMatch ? clipNameMatch[1] : `Clip ${clipIndex + 1}`,
23928                        start: clipTimeMatch ? parseFloat(clipTimeMatch[1]) : 0,
23929                        length: clipLengthMatch ? parseFloat(clipLengthMatch[1]) : 0
23930                      });
23931                    });
23932                  }
23933                } else if (trackType === "AudioTrack") {
23934                  // Look for audio clips
23935                  const audioClipMatches = trackContent.match(/<AudioClip[^>]*>[\s\S]*?<\/AudioClip>/g);
23936                  if (audioClipMatches) {
23937                    audioClipMatches.forEach((clipContent, clipIndex) => {
23938                      const clipNameMatch = clipContent.match(/<Name[^>]*Value="([^"]*)"/);
23939                      const clipTimeMatch = clipContent.match(/<CurrentStart[^>]*Value="([^"]*)"/);
23940                      const clipLengthMatch = clipContent.match(/<Length[^>]*Value="([^"]*)"/);
23941                      
23942                      clips.push({
23943                        name: clipNameMatch ? clipNameMatch[1] : `Audio Clip ${clipIndex + 1}`,
23944                        start: clipTimeMatch ? parseFloat(clipTimeMatch[1]) : 0,
23945                        length: clipLengthMatch ? parseFloat(clipLengthMatch[1]) : 0
23946                      });
23947                    });
23948                  }
23949                }
23950                
23951                structure.tracks.push({
23952                  name: trackName.replace(/^[#\s]*/, ''), // Remove leading # and spaces
23953                  type: trackType,
23954                  clipCount: clips.length,
23955                  clips: clips,
23956                  midiNotes: midiNotes,
23957                  noteCount: midiNotes.length
23958                });
23959              }
23960            });
23961            
23962            console.log(`🎵 BIOS ✅ Parsing complete: ${structure.tracks.length} tracks found`);
23963            return structure;
23964          } catch (e) {
23965            console.warn("🎵 BIOS Failed to parse XML:", e);
23966            return null;
23967          }
23968        }
23969        
23970        const reader = new FileReader();
23971        reader.onload = async function (e) {
23972          try {
23973            const data = e.target.result;
23974            const uint8Data = new Uint8Array(data);
23975            
23976            console.log("🔍 ALS file signature:", Array.from(uint8Data.slice(0, 4)).map(b => b.toString(16).padStart(2, '0')).join(' '));
23977            
23978            // Check if it's a ZIP file (signature: 50 4b 03 04)
23979            if (uint8Data[0] === 0x50 && uint8Data[1] === 0x4b && uint8Data[2] === 0x03 && uint8Data[3] === 0x04) {
23980              console.log("📦 ALS file is a ZIP archive, loading JSZip...");
23981              
23982              // Load JSZip if not already loaded
23983              if (!window.JSZip) await loadJSZip();
23984              
23985              const zip = new window.JSZip();
23986              const zipData = await zip.loadAsync(data);
23987              
23988              console.log("📂 ZIP contents:", Object.keys(zipData.files));
23989              
23990              // Look for the main ALS XML file (usually named after the project)
23991              let xmlContent = null;
23992              let xmlFileName = null;
23993              
23994              // Try to find XML files in the ZIP
23995              for (const fileName of Object.keys(zipData.files)) {
23996                const file = zipData.files[fileName];
23997                if (!file.dir && (fileName.endsWith('.xml') || fileName.endsWith('.als') || fileName === 'Project.xml')) {
23998                  console.log(`📄 Found XML file: ${fileName}`);
23999                  try {
24000                    // Try different extraction methods
24001                    console.log("🔍 Trying string extraction...");
24002                    xmlContent = await file.async("string");
24003                    console.log(`🔍 String extraction result (first 200 chars): ${xmlContent.substring(0, 200)}`);
24004                    
24005                    // If the content looks binary/garbled, try extracting as uint8array and decompress
24006                    if (xmlContent.charCodeAt(0) > 127 || !xmlContent.includes('<')) {
24007                      console.log("🔍 Content appears binary, tr
24007ying binary extraction and decompression...");
24008                      const binaryData = await file.async("uint8array");
24009                      console.log(`🔍 Binary data length: ${binaryData.length}`);
24010                      
24011                      try {
24012                        // Try gzip decompression with pako
24013                        console.log("🔍 Trying gzip decompression...");
24014                        const decompressed = pako.inflate(binaryData, { to: 'string' });
24015                        console.log(`🔍 Gzip decompression result (first 200 chars): ${decompressed.substring(0, 200)}`);
24016                        if (decompressed.includes('<')) {
24017                          xmlContent = decompressed;
24018                        }
24019                      } catch (e) {
24020                        console.log("🔍 Gzip failed, trying deflate...");
24021                        try {
24022                          const decompressed = pako.inflateRaw(binaryData, { to: 'string' });
24023                          console.log(`🔍 Deflate decompression result (first 200 chars): ${decompressed.substring(0, 200)}`);
24024                          if (decompressed.includes('<')) {
24025                            xmlContent = decompressed;
24026                          }
24027                        } catch (e2) {
24028                          console.warn("❌ Both gzip and deflate decompression failed:", e2);
24029                        }
24030                      }
24031                    }
24032                    
24033                    xmlFileName = fileName;
24034                    break;
24035                  } catch (e) {
24036                    console.warn(`❌ Failed to extract ${fileName}:`, e);
24037                  }
24038                }
24039              }
24040              
24041              // If no XML found, try the first non-directory file
24042              if (!xmlContent) {
24043                for (const fileName of Object.keys(zipData.files)) {
24044                  const file = zipData.files[fileName];
24045                  if (!file.dir) {
24046                    console.log(`📄 Trying file: ${fileName}`);
24047                    const content = await file.async("string");
24048                    if (content.includes('<?xml') || content.includes('<Ableton')) {
24049                      xmlContent = content;
24050                      xmlFileName = fileName;
24051                      break;
24052                    }
24053                  }
24054                }
24055              }
24056              
24057              if (xmlContent) {
24058                console.log(`✅ Successfully extracted XML from ${xmlFileName} (${xmlContent.length} chars)`);
24059                console.log("🎵 🚀 BIOS sending dropped:als event with content:", {
24060                  name: file.name.replace(/\.als$/, ""),
24061                  xmlData: xmlContent.length + " chars"
24062                });
24063                send({
24064                  type: "dropped:als",
24065                  content: {
24066                    name: file.name.replace(/\.als$/, ""),
24067                    xmlData: xmlContent,
24068                  },
24069                });
24070              } else {
24071                console.error("❌ No XML content found in ALS ZIP file");
24072              }
24073            } else {
24074              // Fall back to compression methods for non-ZIP ALS files
24075              // Load pako if not already loaded
24076              if (!window.pako) {
24077                console.log("📦 Loading pako compression library for ALS file...");
24078                await new Promise((resolve, reject) => {
24079                  const script = document.createElement("script");
24080                  script.src =
24081                    "https://cdn.jsdelivr.net/npm/[email protected]/dist/pako.min.js";
24082                  script.onload = resolve;
24083                  script.onerror = reject;
24084                  document.head.appendChild(script);
24085                });
24086              }
24087              
24088              let decompressedData = null;
24089              
24090              // Try different decompression methods
24091              const methods = [
24092                { name: 'zlib (inflate)', fn: () => pako.inflate(uint8Data, { to: 'string' }) },
24093                { name: 'gzip (ungzip)', fn: () => pako.ungzip(uint8Data, { to: 'string' }) },
24094                { name: 'raw deflate (inflateRaw)', fn: () => pako.inflateRaw(uint8Data, { to: 'string' }) }
24095              ];
24096              
24097              for (const method of methods) {
24098                try {
24099                  console.log(`🧪 Trying ${method.name}...`);
24100                  decompressedData = method.fn();
24101                  console.log(`✅ Successfully decompressed using ${method.name}`);
24102                  break;
24103                } catch (err) {
24104                  console.log(`❌ ${method.name} failed:`, err.message);
24105                }
24106              }
24107              
24108              if (decompressedData) {
24109                // Parse the XML data into a structured object
24110                const parsedProject = parseAbletonProject(decompressedData);
24111                
24112                console.log("🎵 🚀 BIOS sending dropped:als event with parsed content:", {
24113                  name: file.name.replace(/\.als$/, ""),
24114                  project: parsedProject ? `${parsedProject.tracks.length} tracks, ${parsedProject.tempo} BPM` : "parsing failed"
24115                });
24116                
24117                send({
24118                  type: "dropped:als",
24119                  content: {
24120                    name: file.name.replace(/\.als$/, ""),
24121                    xmlData: decompressedData,
24122                    project: parsedProject, // Send parsed structure
24123                  },
24124                });
24125              } else {
24126                console.error("❌ All decompression methods failed for ALS file");
24127                // Try to read as plain text in case it's not compressed
24128                try {
24129                  const textData = new TextDecoder().decode(uint8Data);
24130                  if (textData.includes('<?xml') || textData.includes('<Ableton')) {
24131                    console.log("📄 File appears to be uncompressed XML");
24132                    
24133                    // Parse the XML data into a structured object
24134                    const parsedProject = parseAbletonProject(textData);
24135                    
24136                    console.log("🎵 🚀 BIOS sending dropped:als event with uncompressed parsed content:", {
24137                      name: file.name.replace(/\.als$/, ""),
24138                      project: parsedProject ? `${parsedProject.tracks.length} tracks, ${parsedProject.tempo} BPM` : "parsing failed"
24139                    });
24140                    
24141                    send({
24142                      type: "dropped:als",
24143                      content: {
24144                        name: file.name.replace(/\.als$/, ""),
24145                        xmlData: textData,
24146                        project: parsedProject, // Send parsed structure
24147                      },
24148                    });
24149                  } else {
24150                    console.error("File doesn't appear to be XML either");
24151                  }
24152                } catch (textErr) {
24153                  console.error("Failed to read as text:", textErr);
24154                }
24155              }
24156            }
24157          } catch (error) {
24158            console.error("Failed to process ALS file:", error);
24159          }
24160        };
24161        reader.readAsArrayBuffer(file);
24162      // 🎮 Game Boy ROM file (.gb, .gbc)
24163      } else if (ext === "gb" || ext === "gbc") {
24164        const reader = new FileReader();
24165        reader.onload = function (e) {
24166          console.log("🎮 BIOS: Dropped Game Boy ROM:", file.name, `(${e.target.result.byteLength} bytes)`);
24167          
24168          const romData = {
24169            name: file.name.replace(/\.(gb|gbc)$/, ""),
24170            originalName: file.name,
24171            romData: e.target.result, // ArrayBuffer
24172            isGameBoyColor: ext === "gbc"
24173          };
24174          
24175          // Handle ROM loading directly in main thread
24176          loadGameboyROM(romData);
24177        };
24178        reader.readAsArrayBuffer(file);
24179    }
24180  }
24181
24182  // Instantly decode the audio before playback if it hasn't been already.
24183  async function decodeSfx(sound) {
24184    // console.log("🎵 BIOS decodeSfx called for:", sound, "type:", typeof sfx[sound]);
24185    
24186    // If sound is already being decoded, wait for it to complete
24187    if (decodingInProgress.has(sound)) {
24188      // console.log("🎵 BIOS decodeSfx already in progress, waiting for:", sound);
24189      // Wait for decode to complete (poll until no longer in progress)
24190      while (decodingInProgress.has(sound)) {
24191        await new Promise((resolve) => setTimeout(resolve, 20));
24192      }
24193      // console.log("🎵 BIOS decodeSfx wait complete for:", sound, "type:", typeof sfx[sound]);
24194      return sfx[sound];
24195    }
24196
24197    // Handle raw audio object (from cached tape when audioContext wasn't ready)
24198    if (sfx[sound] && typeof sfx[sound] === 'object' && sfx[sound].left && sfx[sound].right && sfx[sound].totalSamples) {
24199      console.log("🎬 📼 Converting cached raw audio to AudioBuffer");
24200      decodingInProgress.add(sound);
24201      
24202      try {
24203        // Ensure audioContext is initialized
24204        if (!audioContext) {
24205          if (activateSound) {
24206            activateSound();
24207            await new Promise((resolve) => setTimeout(resolve, 100));
24208          }
24209        }
24210        
24211        if (audioContext) {
24212          const rawAudio = sfx[sound];
24213          const audioBuffer = audioContext.createBuffer(2, rawAudio.totalSamples, rawAudio.sampleRate);
24214          audioBuffer.getChannelData(0).set(rawAudio.left);
24215          audioBuffer.getChannelData(1).set(rawAudio.right);
24216          sfx[sound] = audioBuffer;
24217          console.log(`🎬 📼 Successfully converted raw audio to AudioBuffer: ${rawAudio.totalSamples} samples`);
24218          decodingInProgress.delete(sound);
24219          return sfx[sound];
24220        } else {
24221          console.error("🎬 📼 Cannot convert raw audio: AudioContext not available");
24222          decodingInProgress.delete(sound);
24223          return null;
24224        }
24225      } catch (error) {
24226        console.error("🎬 📼 Failed to convert raw audio:", error);
24227        decodingInProgress.delete(sound);
24228        return null;
24229      }
24230    }
24231
24232    if (sfx[sound] instanceof ArrayBuffer) {
24233      // console.log("🎵 BIOS decodeSfx starting decode for ArrayBuffer:", sound);
24234      // Mark as being decoded to prevent concurrent decode attempts
24235      decodingInProgress.add(sound);
24236
24237      // Ensure audioContext is initialized before trying to decode
24238      if (!audioContext) {
24239        // console.log("🔊 Initializing audio context for WAV decoding...");
24240        if (activateSound) {
24241          activateSound();
24242          // Wait a moment for audio context to initialize
24243          await new Promise((resolve) => setTimeout(resolve, 100));
24244        }
24245      }
24246
24247      let audioBuffer;
24248      let buf = null; // Declare outside try block for error logging
24249      try {
24250        buf = sfx[sound];
24251        sfx[sound] = null;
24252        
24253        if (buf && audioContext) {
24254          // console.log("🎵 BIOS decoding audio data for:", sound, "buffer size:", buf.byteLength);
24255          audioBuffer = await audioContext.decodeAudioData(buf);
24256          if (debug && logs.audio) console.log("🔈 Decoded:", sound);
24257          // console.log("🎵 BIOS decode successful:", sound, "audioBuffer:", !!audioBuffer);
24258          sfx[sound] = audioBuffer;
24259
24260          // Process any queued sounds that might be waiting for this file
24261          processPendingSfx();
24262
24263          return sfx[sound];
24264        } else {
24265          console.error("🎵 BIOS decode failed - missing buffer or audioContext:", !!buf, !!audioContext);
24266        }
24267      } catch (err) {
24268        console.error("🔉 [DECODE] Decode error:", {
24269          error: err,
24270          sound: sound,
24271          bufferType: typeof buf,
24272          bufferByteLength: buf?.byteLength,
24273          isArrayBuffer: buf instanceof ArrayBuffer
24274        });
24275        sfx[sound] = null; // Clear the failed audio data
24276      } finally {
24277        // Always remove from decoding set when done
24278        decodingInProgress.delete(sound);
24279      }
24280    } else {
24281      return sfx[sound];
24282    }
24283  }
24284
24285  // Queue for sounds that need to be played once audio context is available
24286  let pendingSfxQueue = [];
24287
24288  // Track sounds that are currently being decoded to prevent multiple decode attempts
24289  const decodingInProgress = new Set();
24290
24291  // Process any queued sound effects once audio context is ready
24292  function processPendingSfx() {
24293    if (audioContext && pendingSfxQueue.length > 0) {
24294      const remaining = [];
24295      const currentTime = Date.now();
24296
24297      pendingSfxQueue.forEach(
24298        ({ id, soundData, options, completed, queuedAt }) => {
24299          // Add timestamp if not present
24300          const queueTime = queuedAt || currentTime;
24301          const timeInQueue = currentTime - queueTime;
24302
24303          // Only play sounds that have been loaded into the sfx cache
24304          if (sfx[soundData] && !(sfx[soundData] instanceof ArrayBuffer)) {
24305            playSfx(id, soundData, options, completed);
24306          } else if (timeInQueue < 5000) {
24307            // Only retry for 5 seconds
24308            remaining.push({
24309              id,
24310              soundData,
24311              options,
24312              completed,
24313              queuedAt: queueTime,
24314            });
24315          }
24316        },
24317      );
24318
24319      pendingSfxQueue = remaining;
24320
24321      // If there are still sounds waiting, check again soon
24322      if (pendingSfxQueue.length > 0) {
24323        setTimeout(processPendingSfx, 100);
24324      }
24325    }
24326  }
24327
24328  // Utilities
24329
24330  // Convert an img or blob object to an ac formatted bitmap  / "painting".
24331  async function toBitmap(imgOrBlob) {
24332    const img = await createImageBitmap(imgOrBlob);
24333    const canvas = document.createElement("canvas");
24334    canvas.width = img.width;
24335    canvas.height = img.height;
24336    const ctx = canvas.getContext("2d");
24337    ctx.drawImage(img, 0, 0);
24338    const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
24339    return {
24340      width: imageData.width,
24341      height: imageData.height,
24342      pixels: imageData.data,
24343    };
24344  }
24345
24346  // Unzip a file.
24347  async function unzip(data) {
24348    try {
24349      const zip = await window.JSZip.loadAsync(data);
24350
24351      // Detect type of media based on presence of "steps" file...
24352      const steps = JSON.parse(await zip.file("painting.json")?.async("text"));
24353      const record = [];
24354
24355      if (steps) {
24356        // TODO: Parse the JSON from steps.
24357        const lines = steps; // Remove timestamp.
24358
24359        // Load `painting:recording` step text format.
24360        for (let i = 0; i < lines.length; i += 1) {
24361          const components = lines[i].step.split(" - ");
24362          const step = { timestamp: components[0], label: components[1] };
24363          if (lines[i].gesture?.length > 0) step.gesture = lines[i].gesture;
24364          const picture = zip.file(`${lines[i].step}.png`);
24365
24366          if (picture) {
24367            const blob = await picture.async("blob");
24368            step.painting = await toBitmap(blob);
24369          }
24370          record.push(step);
24371        }
24372
24373        return record;
24374      } else {
24375        console.warn("🤐 Could not detect ZIP media type.");
24376        return record;
24377      }
24378    } catch (err) {
24379      console.error("🤐 Error reading ZIP:", err);
24380      return record;
24381    }
24382  }
24383
24384  // Convert a blob oobject to an ArrayBuffer.
24385  function blobToArrayBuffer(blob) {
24386    return new Promise((resolve, reject) => {
24387      const reader = new FileReader();
24388      reader.onloadend = () => resolve(reader.result);
24389      reader.onerror = reject;
24390      reader.readAsArrayBuffer(blob);
24391    });
24392  }
24393
24394  window.iMessageExtensionResize = (mode) => {
24395    console.log("📱 iMessage Extension Resized:", mode);
24396    window.acSEND({ type: "imessage-extension:resized", content: { mode } });
24397  };
24398
24399  window.iOSAppSwitchPiece = (piece) => {
24400    console.log("📱 iOS Switch Piece:", piece);
24401    window.acSEND({
24402      type: "jump",
24403      content: { piece, ahistorical: false, alias: false },
24404    });
24405  };
24406
24407  // Permission is requested by native only after the user enters `notifs`.
24408  if (window.webkit?.messageHandlers?.iOSApp) {
24409    installIOSPushBridge(window);
24410    iOSAppSend({ type: "notifications:ready" });
24411  }
24412
24413} // End of boot function
24414
24415function iOSAppSend(message) {
24416  const packedMessage = JSON.stringify(message);
24417  console.log("📱 Sending to iOS App:", packedMessage);
24418  window.webkit?.messageHandlers?.iOSApp.postMessage(packedMessage);
24419}
24420
24421async function checkMicrophonePermission() {
24422  try {
24423    const permissionStatus = await navigator.permissions.query({
24424      name: "microphone",
24425    });
24426    // console.log('Microphone permission status:', permissionStatus.state);
24427    if (permissionStatus.state === "granted") {
24428      // console.log('Microphone access is granted.');
24429    } else if (permissionStatus.state === "denied") {
24430      // console.log('Microphone access is denied.');
24431    } else {
24432      // console.log(`Microphone access is in prompt state (user hasn't decided yet).`);
24433    }
24434    return permissionStatus.state; // 'granted', 'denied', or 'prompt'
24435  } catch (error) {
24436    console.error("Permission query error:", error);
24437    return null;
24438  }
24439}
24440
24441export { boot };

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.