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.