diff --git a/system/public/aesthetic.computer/bios.mjs b/system/public/aesthetic.computer/bios.mjs index c13deffa7..ad834dff7 100644 --- a/system/public/aesthetic.computer/bios.mjs +++ b/system/public/aesthetic.computer/bios.mjs @@ -32,7 +32,7 @@ import { Safari, Aesthetic, AestheticExtension, - AestheticIOSApp, + AestheticIOSApp, } from "./lib/platform.mjs"; import { headers } from "./lib/headers.mjs"; import { logs, log } from "./lib/logs.mjs"; @@ -43,17 +43,17 @@ import { timestamp, radians } from "./lib/num.mjs"; import * as graph from "./lib/graph.mjs"; import * as WebGPU from "./lib/webgpu.mjs"; import { initGPU, switchBackend } from "./lib/gpu/index.mjs"; // 🎨 New backend system (auto-registers backends) -import { createWebGLBlitter } from "./lib/webgl-blit.mjs"; -import { handleFightRtcMessage } from "./lib/fight/rtc-main.mjs"; -import { - chooseCompactVideoMime, - measureCaptureAVSync, - shouldResumeCaptureRecorder, -} from "./lib/capture-session.mjs"; -import { - attachSoundtrackToFrames, - frameIndexForSoundtrackProgress, -} from "./lib/sound-on-film.mjs"; +import { createWebGLBlitter } from "./lib/webgl-blit.mjs"; +import { handleFightRtcMessage } from "./lib/fight/rtc-main.mjs"; +import { + chooseCompactVideoMime, + measureCaptureAVSync, + shouldResumeCaptureRecorder, +} from "./lib/capture-session.mjs"; +import { + attachSoundtrackToFrames, + frameIndexForSoundtrackProgress, +} from "./lib/sound-on-film.mjs"; // import * as TwoD from "./lib/2d.mjs"; // 🆕 2D GPU Renderer. const TwoD = undefined; @@ -831,40 +831,40 @@ function consumeDiskSends(send) { USB.initialize(); // 💾 Boot the system and load a disk. -async function boot(parsed, bpm = 60, resolution, debug) { - const bootStartTime = performance.now(); - perf.markBootStart(); - headers(); // Print console headers with auto-detected theme. - - const workerBundleParam = new URLSearchParams(window.location.search).get("workerbundle"); - const workerBundleRequested = - !window.acPACK_MODE && - (workerBundleParam === "1" || workerBundleParam === "true"); - const workerBundleState = (window.acWORKER_BUNDLE = { - requested: workerBundleRequested, - active: false, - ready: false, - filename: null, - fallback: null, - }); - const workerBundlePathPromise = workerBundleRequested - ? fetch("/aesthetic.computer/lib/disk-worker-manifest.json", { - cache: "no-cache", - }) - .then(async (response) => { - if (!response.ok) throw new Error(`manifest HTTP ${response.status}`); - const manifest = await response.json(); - if (!/^disk\.worker\.[a-f0-9]{12}\.mjs$/.test(manifest?.filename || "")) { - throw new Error("invalid worker bundle manifest"); - } - workerBundleState.filename = manifest.filename; - return `/aesthetic.computer/lib/${manifest.filename}`; - }) - .catch((error) => { - workerBundleState.fallback = `manifest: ${error.message}`; - return null; - }) - : Promise.resolve(null); +async function boot(parsed, bpm = 60, resolution, debug) { + const bootStartTime = performance.now(); + perf.markBootStart(); + headers(); // Print console headers with auto-detected theme. + + const workerBundleParam = new URLSearchParams(window.location.search).get("workerbundle"); + const workerBundleRequested = + !window.acPACK_MODE && + (workerBundleParam === "1" || workerBundleParam === "true"); + const workerBundleState = (window.acWORKER_BUNDLE = { + requested: workerBundleRequested, + active: false, + ready: false, + filename: null, + fallback: null, + }); + const workerBundlePathPromise = workerBundleRequested + ? fetch("/aesthetic.computer/lib/disk-worker-manifest.json", { + cache: "no-cache", + }) + .then(async (response) => { + if (!response.ok) throw new Error(`manifest HTTP ${response.status}`); + const manifest = await response.json(); + if (!/^disk\.worker\.[a-f0-9]{12}\.mjs$/.test(manifest?.filename || "")) { + throw new Error("invalid worker bundle manifest"); + } + workerBundleState.filename = manifest.filename; + return `/aesthetic.computer/lib/${manifest.filename}`; + }) + .catch((error) => { + workerBundleState.fallback = `manifest: ${error.message}`; + return null; + }) + : Promise.resolve(null); // Expose Loop control to window for boot.mjs // Track pause state for kidlisp console snapshots @@ -947,62 +947,62 @@ async function boot(parsed, bpm = 60, resolution, debug) { let recordedFrames = []; let recordedPieceChanges = []; // Track piece changes during recording const mediaRecorderChunks = []; - let mediaRecorderDuration = 0, - mediaRecorderStartTime, - mediaRecorderFrameCount = 0; // Frame counter for performance optimization - let recorderGeneration = 0; - let compactVideoRecording = false; - let compactRecorderStream = null; - let recordedVideoBlob = null; - let recordedVideoMime = ""; - let compactPlaybackUrl = null; - let needs$creenshot = false; // Flag when a capture is requested. + let mediaRecorderDuration = 0, + mediaRecorderStartTime, + mediaRecorderFrameCount = 0; // Frame counter for performance optimization + let recorderGeneration = 0; + let compactVideoRecording = false; + let compactRecorderStream = null; + let recordedVideoBlob = null; + let recordedVideoMime = ""; + let compactPlaybackUrl = null; + let needs$creenshot = false; // Flag when a capture is requested. // Raw audio capture for tape playback - let rawAudioProcessor = null; - let rawAudioData = []; - let rawAudioSampleRate = 44100; - let rawAudioConnected = false; - let rawAudioStartInterval = null; - let rawAudioStartTimeout = null; - let captureSession = null; - - function clearRawAudioCapture(clearData = true) { - if (rawAudioStartInterval) clearInterval(rawAudioStartInterval); - if (rawAudioStartTimeout) clearTimeout(rawAudioStartTimeout); - rawAudioStartInterval = null; - rawAudioStartTimeout = null; - if (rawAudioProcessor) { - try { sfxStreamGain?.disconnect(rawAudioProcessor); } catch {} - try { micStreamGain?.disconnect(rawAudioProcessor); } catch {} - try { rawAudioProcessor.disconnect(); } catch {} - } - rawAudioProcessor = null; - rawAudioConnected = false; - if (clearData) rawAudioData = []; - } - - function clearCompactRecorderStream() { - compactRecorderStream?.getTracks?.().forEach((track) => { - try { track.stop(); } catch {} - }); - compactRecorderStream = null; - compactVideoRecording = false; - } - - function reportCaptureAVSync() { - if (!captureSession || captureSession.reported) return; - const result = measureCaptureAVSync( - captureSession.firstVideoMs, - captureSession.firstAudioMs, - ); - if (!result) return; - captureSession.reported = true; - send({ - type: "recorder:av-sync", - content: { sessionId: captureSession.id, ...result }, - }); - } + let rawAudioProcessor = null; + let rawAudioData = []; + let rawAudioSampleRate = 44100; + let rawAudioConnected = false; + let rawAudioStartInterval = null; + let rawAudioStartTimeout = null; + let captureSession = null; + + function clearRawAudioCapture(clearData = true) { + if (rawAudioStartInterval) clearInterval(rawAudioStartInterval); + if (rawAudioStartTimeout) clearTimeout(rawAudioStartTimeout); + rawAudioStartInterval = null; + rawAudioStartTimeout = null; + if (rawAudioProcessor) { + try { sfxStreamGain?.disconnect(rawAudioProcessor); } catch {} + try { micStreamGain?.disconnect(rawAudioProcessor); } catch {} + try { rawAudioProcessor.disconnect(); } catch {} + } + rawAudioProcessor = null; + rawAudioConnected = false; + if (clearData) rawAudioData = []; + } + + function clearCompactRecorderStream() { + compactRecorderStream?.getTracks?.().forEach((track) => { + try { track.stop(); } catch {} + }); + compactRecorderStream = null; + compactVideoRecording = false; + } + + function reportCaptureAVSync() { + if (!captureSession || captureSession.reported) return; + const result = measureCaptureAVSync( + captureSession.firstVideoMs, + captureSession.firstAudioMs, + ); + if (!result) return; + captureSession.reported = true; + send({ + type: "recorder:av-sync", + content: { sessionId: captureSession.id, ...result }, + }); + } // Dynamic FPS detection for display-rate independent recording let detectedDisplayFPS = 60; // Default fallback @@ -1024,9 +1024,9 @@ async function boot(parsed, bpm = 60, resolution, debug) { let whens = {}; // Register core signal handlers - whens["recorder:cut"] = async function() { - let rollingEndedSent = false; - const cutGeneration = recorderGeneration; + whens["recorder:cut"] = async function() { + let rollingEndedSent = false; + const cutGeneration = recorderGeneration; if (!mediaRecorder) { console.warn(`No mediaRecorder available during cut - sending rolling:ended anyway`); @@ -1038,70 +1038,70 @@ async function boot(parsed, bpm = 60, resolution, debug) { try { // Safety check to prevent NaN duration - if (mediaRecorderStartTime !== undefined) { - mediaRecorderDuration += performance.now() - mediaRecorderStartTime; + if (mediaRecorderStartTime !== undefined) { + mediaRecorderDuration += performance.now() - mediaRecorderStartTime; } else { console.warn("Warning: mediaRecorderStartTime is undefined during cut, cannot calculate duration"); // Set a minimal duration to prevent NaN if (mediaRecorderDuration === undefined || isNaN(mediaRecorderDuration)) { - mediaRecorderDuration = 100; // Fallback to 100ms - } - } - - // Camera clips can stay compressed from capture through review and - // upload. MediaRecorder guarantees the final dataavailable event before - // stop, so wait for stop instead of building a partial Blob after pause. - if (compactVideoRecording) { - const recorder = mediaRecorder; - const mime = recorder.mimeType || recordedVideoMime || "video/mp4"; - - await new Promise((resolve, reject) => { - recorder.onstop = null; - recorder.addEventListener("stop", resolve, { once: true }); - recorder.addEventListener( - "error", - (event) => reject(event.error || new Error("Video recorder failed")), - { once: true }, - ); - recorder.stop(); - }); - - recordedVideoBlob = new Blob(mediaRecorderChunks, { type: mime }); - recordedVideoMime = mime; - clearCompactRecorderStream(); - mediaRecorder = undefined; - mediaRecorderStartTime = undefined; - mediaRecorderChunks.length = 0; - - send({ type: "recorder:rolling:ended" }); - rollingEndedSent = true; - - try { - await receivedChange({ - data: { - type: "store:persist", - content: { - key: "tape", - method: "local:db", - data: { - kind: "video", - blob: recordedVideoBlob, - mime: recordedVideoMime, - duration: mediaRecorderDuration, - timestamp: Date.now(), - }, - }, - }, - }); - if (cutGeneration !== recorderGeneration) await Store.del("tape"); - } catch (storageError) { - console.error("Error storing compact video tape:", storageError); - } - return; - } - - // mediaRecorder?.stop(); - mediaRecorder?.pause(); // Single clips for now. + mediaRecorderDuration = 100; // Fallback to 100ms + } + } + + // Camera clips can stay compressed from capture through review and + // upload. MediaRecorder guarantees the final dataavailable event before + // stop, so wait for stop instead of building a partial Blob after pause. + if (compactVideoRecording) { + const recorder = mediaRecorder; + const mime = recorder.mimeType || recordedVideoMime || "video/mp4"; + + await new Promise((resolve, reject) => { + recorder.onstop = null; + recorder.addEventListener("stop", resolve, { once: true }); + recorder.addEventListener( + "error", + (event) => reject(event.error || new Error("Video recorder failed")), + { once: true }, + ); + recorder.stop(); + }); + + recordedVideoBlob = new Blob(mediaRecorderChunks, { type: mime }); + recordedVideoMime = mime; + clearCompactRecorderStream(); + mediaRecorder = undefined; + mediaRecorderStartTime = undefined; + mediaRecorderChunks.length = 0; + + send({ type: "recorder:rolling:ended" }); + rollingEndedSent = true; + + try { + await receivedChange({ + data: { + type: "store:persist", + content: { + key: "tape", + method: "local:db", + data: { + kind: "video", + blob: recordedVideoBlob, + mime: recordedVideoMime, + duration: mediaRecorderDuration, + timestamp: Date.now(), + }, + }, + }, + }); + if (cutGeneration !== recorderGeneration) await Store.del("tape"); + } catch (storageError) { + console.error("Error storing compact video tape:", storageError); + } + return; + } + + // mediaRecorder?.stop(); + mediaRecorder?.pause(); // Single clips for now. hdTapeStop(); // 🖼️📼 Finalize the hd recording (blob lands in onstop). // Store the tape data to IndexedDB for persistence across page refreshes @@ -1111,7 +1111,7 @@ async function boot(parsed, bpm = 60, resolution, debug) { // Convert raw audio data to serializable format for storage let rawAudioArrays = null; - if (rawAudioData.length > 0 && audioContext) { + if (rawAudioData.length > 0 && audioContext) { try { const totalSamples = rawAudioData.length * 4096; // 4096 samples per chunk const leftChannelData = new Float32Array(totalSamples); @@ -1129,30 +1129,30 @@ async function boot(parsed, bpm = 60, resolution, debug) { } } - rawAudioArrays = { - left: leftChannelData, - right: rightChannelData, - sampleRate: rawAudioSampleRate, - totalSamples: totalSamples - }; - attachSoundtrackToFrames(recordedFrames, totalSamples); - - console.log(`🎞️ Sound-on-film attached: ${recordedFrames.length} frames, ${totalSamples} audio samples`); + rawAudioArrays = { + left: leftChannelData, + right: rightChannelData, + sampleRate: rawAudioSampleRate, + totalSamples: totalSamples + }; + attachSoundtrackToFrames(recordedFrames, totalSamples); + + console.log(`🎞️ Sound-on-film attached: ${recordedFrames.length} frames, ${totalSamples} audio samples`); } catch (error) { console.error("Error creating raw audio arrays:", error); - } - } - clearRawAudioCapture(false); - - // The live frames and soundtrack are ready now. Hand cap off to video - // immediately; IndexedDB persistence can finish without holding the UI. - send({ type: "recorder:rolling:ended" }); - rollingEndedSent = true; - - try { + } + } + clearRawAudioCapture(false); + + // The live frames and soundtrack are ready now. Hand cap off to video + // immediately; IndexedDB persistence can finish without holding the UI. + send({ type: "recorder:rolling:ended" }); + rollingEndedSent = true; + + try { // Don't store frames in IndexedDB to avoid memory issues with long recordings // Frames are kept in memory (recordedFrames) for the current session only - await receivedChange({ + await receivedChange({ data: { type: "store:persist", content: { @@ -1166,9 +1166,9 @@ async function boot(parsed, bpm = 60, resolution, debug) { rawAudio: rawAudioArrays, // Add raw audio arrays for playback }, }, - }, - }); - if (cutGeneration !== recorderGeneration) await Store.del("tape"); + }, + }); + if (cutGeneration !== recorderGeneration) await Store.del("tape"); if (debug && logs.recorder) console.log("📼 Stored tape to IndexedDB (without frame data)"); } catch (storageError) { @@ -1176,10 +1176,10 @@ async function boot(parsed, bpm = 60, resolution, debug) { // Continue despite storage error } - } catch (error) { - console.error("Error in cut operation:", error); - // Still send the ended signal even if something fails - if (!rollingEndedSent) send({ type: "recorder:rolling:ended" }); + } catch (error) { + console.error("Error in cut operation:", error); + // Still send the ended signal even if something fails + if (!rollingEndedSent) send({ type: "recorder:rolling:ended" }); } }; @@ -2904,8 +2904,8 @@ async function boot(parsed, bpm = 60, resolution, debug) { }; const sfx = {}; // Buffers of sound effects that have been loaded. - const sfxPlaying = {}; // Sound sources that are currently playing. - const sfxProgress = {}; // Latest AudioWorklet read-head report by sound id. + const sfxPlaying = {}; // Sound sources that are currently playing. + const sfxProgress = {}; // Latest AudioWorklet read-head report by sound id. const sfxLoaded = {}; // Sound sources that have been buffered and loaded. const sfxCompletionCallbacks = {}; // Completion callbacks for sound effects. // NOTE: streamAudio is now at module scope for master volume control @@ -3599,12 +3599,12 @@ async function boot(parsed, bpm = 60, resolution, debug) { speakerProcessor.port.postMessage({ type: "sound", data: sound }); return { - progress: () => { - speakerProcessor.port.postMessage({ - type: "get-progress", - content: sound.id, - }); - return sfxProgress[sound.id]?.progress; + progress: () => { + speakerProcessor.port.postMessage({ + type: "get-progress", + content: sound.id, + }); + return sfxProgress[sound.id]?.progress; }, kill: (fade) => { killSound(sound.id, fade); @@ -3741,9 +3741,9 @@ async function boot(parsed, bpm = 60, resolution, debug) { return; } - if (msg.type === "progress") { - sfxProgress[msg.content.id] = msg.content; - // Send sound progress to the disk. + if (msg.type === "progress") { + sfxProgress[msg.content.id] = msg.content; + // Send sound progress to the disk. // console.log("Received progress for:", msg); send({ type: "sfx:progress:report", @@ -3752,8 +3752,8 @@ async function boot(parsed, bpm = 60, resolution, debug) { return; } - if (msg.type === "killed") { - delete sfxProgress[msg.content.id]; + if (msg.type === "killed") { + delete sfxProgress[msg.content.id]; // Call the completion callback if it exists const completionCallback = sfxCompletionCallbacks[msg.content.id]; if (completionCallback) { @@ -4366,17 +4366,17 @@ async function boot(parsed, bpm = 60, resolution, debug) { //const worker = new Worker("./aesthetic.computer/lib/disk.js", { // type: "module", //}); - const standardWorkerPath = - (window.acPACK_MODE ? "./aesthetic.computer/lib/disk.mjs" : "/aesthetic.computer/lib/disk.mjs") + - window.location.search + - "#" + - Date.now(); // bust the cache. This prevents an error related to Safari loading workers from memory. - const workerBundlePath = await workerBundlePathPromise; - let usingBundledWorker = Boolean(workerBundlePath); - let activeWorkerPath = workerBundlePath - ? workerBundlePath + window.location.search + "#" + Date.now() - : standardWorkerPath; - workerBundleState.active = usingBundledWorker; + const standardWorkerPath = + (window.acPACK_MODE ? "./aesthetic.computer/lib/disk.mjs" : "/aesthetic.computer/lib/disk.mjs") + + window.location.search + + "#" + + Date.now(); // bust the cache. This prevents an error related to Safari loading workers from memory. + const workerBundlePath = await workerBundlePathPromise; + let usingBundledWorker = Boolean(workerBundlePath); + let activeWorkerPath = workerBundlePath + ? workerBundlePath + window.location.search + "#" + Date.now() + : standardWorkerPath; + workerBundleState.active = usingBundledWorker; const sandboxed = (window.origin === "null" || !window.origin || window.acPACK_MODE || window.acSPIDER) && !window.acVSCODE; @@ -4391,7 +4391,7 @@ async function boot(parsed, bpm = 60, resolution, debug) { }); } - // Extract embedded source if available + // Extract embedded source if available let embeddedSource = null; try { const embeddedScript = document.getElementById("embedded-source"); @@ -4418,7 +4418,7 @@ async function boot(parsed, bpm = 60, resolution, debug) { vscode: window.acVSCODE, objktMode: window.acPACK_MODE || false, objktKidlispCodes: window.objktKidlispCodes || globalThis.objktKidlispCodes || {}, - microphonePermission: null, + microphonePermission: null, resolution, embeddedSource, noauth: window.acNOAUTH || false, @@ -4438,17 +4438,17 @@ async function boot(parsed, bpm = 60, resolution, debug) { receivedChange(m); }; - let send = (msg) => { - console.warn("Send has not been wired yet!", msg); - }; - - let firstMessageSent = false; - const sendFirstMessage = () => { - if (firstMessageSent) return; - firstMessageSent = true; - send(firstMessage); - consumeDiskSends(send); - }; + let send = (msg) => { + console.warn("Send has not been wired yet!", msg); + }; + + let firstMessageSent = false; + const sendFirstMessage = () => { + if (firstMessageSent) return; + firstMessageSent = true; + send(firstMessage); + consumeDiskSends(send); + }; const TAPE_PREVIEW_MAX_FRAMES = 90; const TAPE_PREVIEW_WIDTH = 256; @@ -4540,10 +4540,10 @@ async function boot(parsed, bpm = 60, resolution, debug) { perf.markBoot("worker-create-start"); let workerFailed = false; // Guard to prevent double-initialization - let workerReady = false; // Track if worker has responded - let workerInitialized = false; // Guard to prevent sending firstMessage multiple times - let retryCount = 0; - let activeWorker = null; + let workerReady = false; // Track if worker has responded + let workerInitialized = false; // Guard to prevent sending firstMessage multiple times + let retryCount = 0; + let activeWorker = null; const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'; // On localhost, fail fast (1 retry) since HTTP proxy is flaky - noWorker fallback works fine // In production, retry more since network issues are usually transient @@ -4551,28 +4551,28 @@ async function boot(parsed, bpm = 60, resolution, debug) { // Note: Can't use blob URLs for workers because dynamic imports inside the worker // (like loading pieces) would resolve relative to blob: origin which fails. - // Workers need to load via HTTP so their dynamic imports work correctly. - - const createWorker = () => { - const worker = new Worker(new URL(activeWorkerPath, window.location.href), { - type: "module", - }); - activeWorker = worker; - - // Rewire things a bit if workers with modules are not supported (Firefox). - worker.onerror = async (err) => { - if (workerFailed) return; // Already handling fallback - if (usingBundledWorker && !workerInitialized) { - worker.terminate(); - usingBundledWorker = false; - workerBundleState.active = false; - workerBundleState.fallback = `worker error: ${err.message || "unknown"}`; - activeWorkerPath = standardWorkerPath; - retryCount = 0; - createWorker(); - return; - } - if (workerInitialized) { + // Workers need to load via HTTP so their dynamic imports work correctly. + + const createWorker = () => { + const worker = new Worker(new URL(activeWorkerPath, window.location.href), { + type: "module", + }); + activeWorker = worker; + + // Rewire things a bit if workers with modules are not supported (Firefox). + worker.onerror = async (err) => { + if (workerFailed) return; // Already handling fallback + if (usingBundledWorker && !workerInitialized) { + worker.terminate(); + usingBundledWorker = false; + workerBundleState.active = false; + workerBundleState.fallback = `worker error: ${err.message || "unknown"}`; + activeWorkerPath = standardWorkerPath; + retryCount = 0; + createWorker(); + return; + } + if (workerInitialized) { // Worker already started successfully - this is a late error, don't double-init console.warn("🟡 Worker error after init:", err.message || "(no message)"); return; @@ -4630,7 +4630,7 @@ async function boot(parsed, bpm = 60, resolution, debug) { module.noWorker.postMessage = (e) => onMessage(e); send = (e) => module.noWorker.onMessage(e); window.acSEND = send; - sendFirstMessage(); + sendFirstMessage(); }; if (worker.postMessage) { @@ -4653,10 +4653,10 @@ async function boot(parsed, bpm = 60, resolution, debug) { // Handle worker-ready signal from disk.mjs if (e.data?.type === "worker-ready") { if (workerInitialized) return; // Already initialized (shouldn't happen) - workerInitialized = true; - workerReady = true; - workerBundleState.ready = usingBundledWorker; - perf.markBoot("worker-connected"); + workerInitialized = true; + workerReady = true; + workerBundleState.ready = usingBundledWorker; + perf.markBoot("worker-connected"); // Notify parent that worker is connected const workerConnectTime = performance.now(); @@ -4672,7 +4672,7 @@ async function boot(parsed, bpm = 60, resolution, debug) { // NOW send the initial message - disk.mjs is ready to receive it perf.markBoot("first-message-sent"); - sendFirstMessage(); + sendFirstMessage(); return; } @@ -4682,23 +4682,23 @@ async function boot(parsed, bpm = 60, resolution, debug) { } }; - // Start the first worker attempt - createWorker(); - - if (usingBundledWorker) { - setTimeout(() => { - if (workerReady || workerFailed || !usingBundledWorker) return; - activeWorker?.terminate(); - usingBundledWorker = false; - workerBundleState.active = false; - workerBundleState.fallback = "worker ready timeout"; - activeWorkerPath = standardWorkerPath; - retryCount = 0; - createWorker(); - }, 4000); - } - - // Timeout: fall back to noWorker mode if worker never responds + // Start the first worker attempt + createWorker(); + + if (usingBundledWorker) { + setTimeout(() => { + if (workerReady || workerFailed || !usingBundledWorker) return; + activeWorker?.terminate(); + usingBundledWorker = false; + workerBundleState.active = false; + workerBundleState.fallback = "worker ready timeout"; + activeWorkerPath = standardWorkerPath; + retryCount = 0; + createWorker(); + }, 4000); + } + + // Timeout: fall back to noWorker mode if worker never responds const workerTimeoutMs = isLocalhost ? 3000 : 10000; setTimeout(async () => { if (workerReady || workerFailed) return; // Already resolved @@ -4722,7 +4722,7 @@ async function boot(parsed, bpm = 60, resolution, debug) { module.noWorker.postMessage = (e) => onMessage(e); send = (e) => module.noWorker.onMessage(e); window.acSEND = send; - sendFirstMessage(); + sendFirstMessage(); }, workerTimeoutMs); } else { // B. No Worker Mode @@ -4744,9 +4744,9 @@ async function boot(parsed, bpm = 60, resolution, debug) { // The initial message sends the path and host to load the disk. // Note: In worker mode, this is now handled inside the if(worker.postMessage) block // to ensure proper timing. In no-worker mode, it's handled here. - if (!workersEnabled) { - sendFirstMessage(); - } + if (!workersEnabled) { + sendFirstMessage(); + } // 🛑 HALT Detection - Watchdog for unresponsive disk worker let lastPongTime = Date.now(); @@ -6332,22 +6332,22 @@ async function boot(parsed, bpm = 60, resolution, debug) { } // Post a message to a potential iframe parent, like in the VSCode extension. - if (type === "post-to-parent") { + if (type === "post-to-parent") { // Note: kidlisp-code-created and setCode contain cached $identifiers (like "nece"), // not actual source code. The actual source is set via kidlisp-reload in boot.mjs. // if (debug) console.log("🏃‍♂️ Posting up to parent...", content); if (window.parent) window.parent.postMessage(content, "*"); return; - } - - // Menu Fighter's pieces may execute in the disk worker. Keep WebRTC in the - // main Window and expose only a narrow packet/signaling bridge. - if (type?.startsWith("fight:rtc:")) { - await handleFightRtcMessage(type, content, send); - return; - } - - // Connect to a UDP server, + } + + // Menu Fighter's pieces may execute in the disk worker. Keep WebRTC in the + // main Window and expose only a narrow packet/signaling bridge. + if (type?.startsWith("fight:rtc:")) { + await handleFightRtcMessage(type, content, send); + return; + } + + // Connect to a UDP server, // which will pass messages to the disk runner. if (type === "udp:connect") { const udp = await loadUDP(); @@ -12343,11 +12343,11 @@ async function boot(parsed, bpm = 60, resolution, debug) { // 📼 Get tape information (duration, frame count, etc.) if (type === "tape:get-info") { - const info = { - frameCount: recordedFrames.length, - totalDuration: 0, - hasAudio: !!recordedVideoBlob || !!window.tapeAudioArrayBuffer, - kind: recordedVideoBlob ? "video" : "frames", + const info = { + frameCount: recordedFrames.length, + totalDuration: 0, + hasAudio: !!recordedVideoBlob || !!window.tapeAudioArrayBuffer, + kind: recordedVideoBlob ? "video" : "frames", // Source of the recording, so the video piece can tell KidLisp // $code tapes (which get the GIF/MP4/ZIP export trio) from others. pieceName: window.currentRecordingOptions?.pieceName || null, @@ -12378,18 +12378,18 @@ async function boot(parsed, bpm = 60, resolution, debug) { tapeManager?.cleanup(); tapeManager = null; // Tear down MP4-tape playback if it was a video-backed tape. - if (mp4PlaybackVideo) { - try { - mp4PlaybackVideo.pause(); - mp4PlaybackVideo.removeAttribute("src"); - mp4PlaybackVideo.load(); - } catch {} - mp4PlaybackVideo = undefined; - if (compactPlaybackUrl) { - URL.revokeObjectURL(compactPlaybackUrl); - compactPlaybackUrl = null; - } - underlayFrame?.remove(); + if (mp4PlaybackVideo) { + try { + mp4PlaybackVideo.pause(); + mp4PlaybackVideo.removeAttribute("src"); + mp4PlaybackVideo.load(); + } catch {} + mp4PlaybackVideo = undefined; + if (compactPlaybackUrl) { + URL.revokeObjectURL(compactPlaybackUrl); + compactPlaybackUrl = null; + } + underlayFrame?.remove(); underlayFrame = undefined; } return; @@ -12760,14 +12760,14 @@ async function boot(parsed, bpm = 60, resolution, debug) { const tapeAudioId = Object.keys(sfxPlaying).find((id) => id.startsWith("tape:audio_"), ); - if (tapeAudioId && sfxPlaying[tapeAudioId]) { - const v = - typeof content === "number" ? Math.max(0, Math.min(1, content)) : 1; - sfxPlaying[tapeAudioId].update({ volume: v, duration: 0.08 }); - } else if (mp4PlaybackVideo) { - mp4PlaybackVideo.volume = - typeof content === "number" ? Math.max(0, Math.min(1, content)) : 1; - } + if (tapeAudioId && sfxPlaying[tapeAudioId]) { + const v = + typeof content === "number" ? Math.max(0, Math.min(1, content)) : 1; + sfxPlaying[tapeAudioId].update({ volume: v, duration: 0.08 }); + } else if (mp4PlaybackVideo) { + mp4PlaybackVideo.volume = + typeof content === "number" ? Math.max(0, Math.min(1, content)) : 1; + } return; } @@ -12791,17 +12791,17 @@ async function boot(parsed, bpm = 60, resolution, debug) { const tapeAudioId = Object.keys(sfxPlaying).find((id) => id.startsWith("tape:audio_"), ); - if (tapeAudioId && sfxPlaying[tapeAudioId]) { - const p = typeof content === "number" ? Math.max(0, Math.min(1, content)) : 0; - sfxPlaying[tapeAudioId].update({ samplePosition: p }); - } else if ( - mp4PlaybackVideo && - Number.isFinite(mp4PlaybackVideo.duration) && - mp4PlaybackVideo.duration > 0 - ) { - const p = typeof content === "number" ? Math.max(0, Math.min(1, content)) : 0; - mp4PlaybackVideo.currentTime = p * mp4PlaybackVideo.duration; - } + if (tapeAudioId && sfxPlaying[tapeAudioId]) { + const p = typeof content === "number" ? Math.max(0, Math.min(1, content)) : 0; + sfxPlaying[tapeAudioId].update({ samplePosition: p }); + } else if ( + mp4PlaybackVideo && + Number.isFinite(mp4PlaybackVideo.duration) && + mp4PlaybackVideo.duration > 0 + ) { + const p = typeof content === "number" ? Math.max(0, Math.min(1, content)) : 0; + mp4PlaybackVideo.currentTime = p * mp4PlaybackVideo.duration; + } return; } @@ -12811,22 +12811,22 @@ async function boot(parsed, bpm = 60, resolution, debug) { const tapeAudioId = Object.keys(sfxPlaying).find((id) => id.startsWith("tape:audio_"), ); - if (tapeAudioId && sfxPlaying[tapeAudioId]) { - const rate = typeof content === "number" ? content : 1; - sfxPlaying[tapeAudioId].update({ sampleSpeed: rate }); - } else if (mp4PlaybackVideo) { - const rate = typeof content === "number" ? content : 1; - if (rate > 0) { - mp4PlaybackVideo.preservesPitch = false; - mp4PlaybackVideo.playbackRate = Math.max(0.25, Math.min(4, rate)); - mp4PlaybackVideo.play().catch(() => {}); - } else { - // HTMLMediaElement has no reverse playback. The video piece still - // seeks frames explicitly while dragging backward; pause native - // audio here instead of letting it run in the wrong direction. - mp4PlaybackVideo.pause(); - } - } + if (tapeAudioId && sfxPlaying[tapeAudioId]) { + const rate = typeof content === "number" ? content : 1; + sfxPlaying[tapeAudioId].update({ sampleSpeed: rate }); + } else if (mp4PlaybackVideo) { + const rate = typeof content === "number" ? content : 1; + if (rate > 0) { + mp4PlaybackVideo.preservesPitch = false; + mp4PlaybackVideo.playbackRate = Math.max(0.25, Math.min(4, rate)); + mp4PlaybackVideo.play().catch(() => {}); + } else { + // HTMLMediaElement has no reverse playback. The video piece still + // seeks frames explicitly while dragging backward; pause native + // audio here instead of letting it run in the wrong direction. + mp4PlaybackVideo.pause(); + } + } return; } @@ -13102,18 +13102,18 @@ async function boot(parsed, bpm = 60, resolution, debug) { handler.state = "up"; } }; - handler.contains = (e) => { - const frame = canvas.getBoundingClientRect(); - const xscale = projectedWidth / canvas.width; - const yscale = projectedHeight / canvas.height; - return Box.from({ - x: frame.left + content.box.x * xscale, - y: frame.top + content.box.y * yscale, - w: content.box.w * xscale, - h: content.box.h * yscale, - }).contains({ x: e.x, y: e.y }); - }; - handler.state = hitboxes[content.label]?.state || "up"; + handler.contains = (e) => { + const frame = canvas.getBoundingClientRect(); + const xscale = projectedWidth / canvas.width; + const yscale = projectedHeight / canvas.height; + return Box.from({ + x: frame.left + content.box.x * xscale, + y: frame.top + content.box.y * yscale, + w: content.box.w * xscale, + h: content.box.h * yscale, + }).contains({ x: e.x, y: e.y }); + }; + handler.state = hitboxes[content.label]?.state || "up"; hitboxes[content.label] = handler; return; @@ -13201,11 +13201,11 @@ async function boot(parsed, bpm = 60, resolution, debug) { tiny: "none", dot: "none", }; - if (code === "native") document.body.classList.add("native-cursor"); - else document.body.classList.remove("native-cursor"); - const cursorCss = code in CURSOR_CSS ? CURSOR_CSS[code] : code; - window.acPieceCursorCss = cursorCss; - document.body.style.cursor = cursorCss; + if (code === "native") document.body.classList.add("native-cursor"); + else document.body.classList.remove("native-cursor"); + const cursorCss = code in CURSOR_CSS ? CURSOR_CSS[code] : code; + window.acPieceCursorCss = cursorCss; + document.body.style.cursor = cursorCss; return; } @@ -15071,7 +15071,7 @@ async function boot(parsed, bpm = 60, resolution, debug) { // metadata)) but the data is the already-encoded clip bytes, so there's // no frame/zip step. track-tape mints a !code and marks kind "mp4". if (type === "upload-video-tape") { - const { data, mime, duration, callback, source } = content || {}; + const { data, mime, duration, callback, source } = content || {}; // Logged-in users get a dotted-timestamp filename (no "-" so the // presigned server sorts it under {sub}/.mp4, mirroring zip tapes); // guests fall back to a generic name and the server mints a nanoid. @@ -15089,10 +15089,10 @@ async function boot(parsed, bpm = 60, resolution, debug) { filename = "tape.mp4"; } const blob = new Blob([data], { type: mime || "video/mp4" }); - const metadata = { - totalDuration: duration || 0, - source: source || "camera-roll", - }; + const metadata = { + totalDuration: duration || 0, + source: source || "camera-roll", + }; receivedUpload( { filename, data: blob }, callback || "tape:posted", @@ -15106,16 +15106,16 @@ async function boot(parsed, bpm = 60, resolution, debug) { return; } - if (type === "microphone") { - receivedMicrophone(content); - return; - } - - if (type === "microphone-permission-request") { - const permission = await checkMicrophonePermission(); - send({ type: "microphone-permission", content: permission }); - return; - } + if (type === "microphone") { + receivedMicrophone(content); + return; + } + + if (type === "microphone-permission-request") { + const permission = await checkMicrophonePermission(); + send({ type: "microphone-permission", content: permission }); + return; + } if (type === "notifications:web") { window.acRequestNotifications?.(content); @@ -15181,8 +15181,8 @@ async function boot(parsed, bpm = 60, resolution, debug) { } // Audio-visual recording of the main audio track and microphone. - if (type === "recorder:rolling") { - recorderGeneration += 1; + if (type === "recorder:rolling") { + recorderGeneration += 1; // mediaRecorderBlob = null; // Clear the current blob when we start recording. // Store recording metadata for filename generation @@ -15201,10 +15201,10 @@ async function boot(parsed, bpm = 60, resolution, debug) { frameCount: content.frameCount || null, // Store target frame count kidlispFps: content.kidlispFps || null, // Store KidLisp framerate from fps function cleanMode: content.cleanMode || false, // Store clean mode flag (no overlays, no progress bar) - showTezosStamp: content.showTezosStamp || false, // Store Tezos stamp visibility - freshSession: content.freshSession === true, - avSync: content.avSync === true, - compactVideo: content.compactVideo === true, + showTezosStamp: content.showTezosStamp || false, // Store Tezos stamp visibility + freshSession: content.freshSession === true, + avSync: content.avSync === true, + compactVideo: content.compactVideo === true, }; actualContent = content.type || "video"; } else { @@ -15216,10 +15216,10 @@ async function boot(parsed, bpm = 60, resolution, debug) { intendedDuration: null, mystery: false, cleanMode: false, // Default to false for legacy recordings - kidlispFps: null, // Initialize for KidLisp framerate updates - freshSession: false, - avSync: false, - compactVideo: false, + kidlispFps: null, // Initialize for KidLisp framerate updates + freshSession: false, + avSync: false, + compactVideo: false, }; actualContent = content; } @@ -15234,16 +15234,16 @@ async function boot(parsed, bpm = 60, resolution, debug) { // Always clear frames and underlay when starting new recording // (Must happen before the paused check to avoid early return skipping cleanup) - recordedFrames.length = 0; - recordedPieceChanges.length = 0; // Clear piece changes tracking - recordedVideoBlob = null; - recordedVideoMime = ""; - if (compactPlaybackUrl) { - URL.revokeObjectURL(compactPlaybackUrl); - compactPlaybackUrl = null; - } - clearCompactRecorderStream(); - startTapePlayback = undefined; + recordedFrames.length = 0; + recordedPieceChanges.length = 0; // Clear piece changes tracking + recordedVideoBlob = null; + recordedVideoMime = ""; + if (compactPlaybackUrl) { + URL.revokeObjectURL(compactPlaybackUrl); + compactPlaybackUrl = null; + } + clearCompactRecorderStream(); + startTapePlayback = undefined; hdTapeReset(); // 🖼️📼 Fresh hd-tape state; arms itself on the first hd frame. console.log("🎬 Initialized recording state"); @@ -15256,10 +15256,10 @@ async function boot(parsed, bpm = 60, resolution, debug) { console.log("🎬 Cleared underlay from previous playback"); } - if (mediaRecorder && shouldResumeCaptureRecorder( - mediaRecorder.state, - recordingOptions.freshSession, - )) { + if (mediaRecorder && shouldResumeCaptureRecorder( + mediaRecorder.state, + recordingOptions.freshSession, + )) { mediaRecorder.resume(); mediaRecorderStartTime = performance.now(); // Initialize recording start timestamp for frame recording if not already set @@ -15278,25 +15278,25 @@ async function boot(parsed, bpm = 60, resolution, debug) { return; } - if (mediaRecorder) { - stop(); - } + if (mediaRecorder) { + stop(); + } function stop() { - // Properly stop and clean up MediaRecorder before trashing it - if (mediaRecorder && mediaRecorder.state !== "inactive") { - mediaRecorder.onstop = null; - mediaRecorder.stop(); - } + // Properly stop and clean up MediaRecorder before trashing it + if (mediaRecorder && mediaRecorder.state !== "inactive") { + mediaRecorder.onstop = null; + mediaRecorder.stop(); + } mediaRecorder = undefined; // ❌ Trash the recorder. mediaRecorderStartTime = undefined; mediaRecorderDuration = undefined; // Reset to undefined for clean initialization mediaRecorderChunks.length = 0; - clearRawAudioCapture(); - clearCompactRecorderStream(); - captureSession = null; + clearRawAudioCapture(); + clearCompactRecorderStream(); + captureSession = null; // Clear recording timestamp for next recording if (window.recordingStartTimestamp) { @@ -15339,76 +15339,76 @@ async function boot(parsed, bpm = 60, resolution, debug) { mimeType = "audio/aac"; } - let options = { mimeType }; - - if (actualContent === "video") { - // Camera recordings use the browser encoder over the already-rendered - // AC canvas. This preserves cap's crop/mirror/zoom while avoiding a - // second getImageData plus an unbounded array of RGBA frames. - if ( - recordingOptions.compactVideo && - typeof canvas.captureStream === "function" && - typeof MediaStream === "function" - ) { - const compactMime = chooseCompactVideoMime( - MediaRecorder.isTypeSupported.bind(MediaRecorder), - ); - - if (compactMime) { - try { - const canvasStream = canvas.captureStream(30); - compactRecorderStream = new MediaStream(); - canvasStream.getVideoTracks().forEach((track) => - compactRecorderStream.addTrack(track), - ); - audioStreamDest?.stream?.getAudioTracks().forEach((track) => - compactRecorderStream.addTrack(track.clone()), - ); - - const pixelsPerSecond = canvas.width * canvas.height * 30; - const videoBitsPerSecond = Math.min( - 8_000_000, - Math.max(1_500_000, Math.round(pixelsPerSecond * 0.12)), - ); - mediaRecorder = new MediaRecorder(compactRecorderStream, { - mimeType: compactMime, - videoBitsPerSecond, - }); - compactVideoRecording = true; - recordedVideoMime = compactMime; - console.log("🎬 Compact video recorder ready:", { - mime: compactMime, - videoBitsPerSecond, - }); - } catch (error) { - console.warn("Compact video recording unavailable; using frame tape:", error); - clearCompactRecorderStream(); - mediaRecorder = undefined; - } - } - } - - // Feature-detection fallback: retain the existing audio + frame tape. - if (!mediaRecorder) { - try { - mediaRecorder = new MediaRecorder(audioStreamDest.stream, options); - console.log("🎬 MediaRecorder created successfully:", mediaRecorder.state); - } catch (error) { - console.error("MediaRecorder creation failed:", error); - return; - } - } - - // Frame tapes need raw PCM for their custom scrub player. Compact - // videos carry their audio in the encoded media stream. - if (!compactVideoRecording && audioContext && sfxStreamGain) { + let options = { mimeType }; + + if (actualContent === "video") { + // Camera recordings use the browser encoder over the already-rendered + // AC canvas. This preserves cap's crop/mirror/zoom while avoiding a + // second getImageData plus an unbounded array of RGBA frames. + if ( + recordingOptions.compactVideo && + typeof canvas.captureStream === "function" && + typeof MediaStream === "function" + ) { + const compactMime = chooseCompactVideoMime( + MediaRecorder.isTypeSupported.bind(MediaRecorder), + ); + + if (compactMime) { + try { + const canvasStream = canvas.captureStream(30); + compactRecorderStream = new MediaStream(); + canvasStream.getVideoTracks().forEach((track) => + compactRecorderStream.addTrack(track), + ); + audioStreamDest?.stream?.getAudioTracks().forEach((track) => + compactRecorderStream.addTrack(track.clone()), + ); + + const pixelsPerSecond = canvas.width * canvas.height * 30; + const videoBitsPerSecond = Math.min( + 8_000_000, + Math.max(1_500_000, Math.round(pixelsPerSecond * 0.12)), + ); + mediaRecorder = new MediaRecorder(compactRecorderStream, { + mimeType: compactMime, + videoBitsPerSecond, + }); + compactVideoRecording = true; + recordedVideoMime = compactMime; + console.log("🎬 Compact video recorder ready:", { + mime: compactMime, + videoBitsPerSecond, + }); + } catch (error) { + console.warn("Compact video recording unavailable; using frame tape:", error); + clearCompactRecorderStream(); + mediaRecorder = undefined; + } + } + } + + // Feature-detection fallback: retain the existing audio + frame tape. + if (!mediaRecorder) { + try { + mediaRecorder = new MediaRecorder(audioStreamDest.stream, options); + console.log("🎬 MediaRecorder created successfully:", mediaRecorder.state); + } catch (error) { + console.error("MediaRecorder creation failed:", error); + return; + } + } + + // Frame tapes need raw PCM for their custom scrub player. Compact + // videos carry their audio in the encoded media stream. + if (!compactVideoRecording && audioContext && sfxStreamGain) { try { rawAudioData = []; rawAudioSampleRate = audioContext.sampleRate; // Create a script processor node to capture raw audio rawAudioProcessor = audioContext.createScriptProcessor(4096, 2, 2); - rawAudioProcessor.onaudioprocess = function(event) { + rawAudioProcessor.onaudioprocess = function(event) { // Only start capturing after MediaRecorder has started if (mediaRecorderStartTime === undefined) return; @@ -15416,19 +15416,19 @@ async function boot(parsed, bpm = 60, resolution, debug) { const leftChannel = inputBuffer.getChannelData(0); const rightChannel = inputBuffer.getChannelData(1); - const callbackMs = performance.now(); - const chunkStartMs = callbackMs - inputBuffer.duration * 1000; - if (captureSession && captureSession.firstAudioMs === null) { - captureSession.firstAudioMs = chunkStartMs; - reportCaptureAVSync(); - } - - // Store the audio data with timing information - rawAudioData.push({ - left: new Float32Array(leftChannel), - right: new Float32Array(rightChannel), - timestamp: chunkStartMs - mediaRecorderStartTime, - }); + const callbackMs = performance.now(); + const chunkStartMs = callbackMs - inputBuffer.duration * 1000; + if (captureSession && captureSession.firstAudioMs === null) { + captureSession.firstAudioMs = chunkStartMs; + reportCaptureAVSync(); + } + + // Store the audio data with timing information + rawAudioData.push({ + left: new Float32Array(leftChannel), + right: new Float32Array(rightChannel), + timestamp: chunkStartMs - mediaRecorderStartTime, + }); }; console.log("🎵 Raw audio capture prepared (will connect when recording starts)"); @@ -15443,59 +15443,59 @@ async function boot(parsed, bpm = 60, resolution, debug) { console.log("🎬 Setting up MediaRecorder callbacks"); mediaRecorder.onstart = function () { // mediaRecorderResized = false; - mediaRecorderStartTime = performance.now(); - captureSession = { - id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, - firstVideoMs: null, - firstAudioMs: null, - reported: false, - }; + mediaRecorderStartTime = performance.now(); + captureSession = { + id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + firstVideoMs: null, + firstAudioMs: null, + reported: false, + }; // Initialize recording start timestamp for frame recording window.recordingStartTimestamp = Date.now(); window.currentTapeProgress = 0; window.lastVideoProgress = 0; - if (rawAudioProcessor && sfxStreamGain) { - const connectRawAudio = (reason) => { - if (rawAudioConnected || !rawAudioProcessor) return; - try { - sfxStreamGain.connect(rawAudioProcessor); - micStreamGain?.connect(rawAudioProcessor); - rawAudioProcessor.connect(audioContext.destination); - rawAudioConnected = true; - if (rawAudioStartInterval) clearInterval(rawAudioStartInterval); - if (rawAudioStartTimeout) clearTimeout(rawAudioStartTimeout); - rawAudioStartInterval = null; - rawAudioStartTimeout = null; - console.log(`🎵 Raw audio capture connected (${reason})`); - } catch (error) { - console.warn("Raw audio capture connection failed:", error); - } - }; - - if (recordingOptions.avSync) { - // Camera takes capture from the recorder epoch. Waiting 100ms here - // used to shift its entire soundtrack ahead of the film frames. - connectRawAudio("camera recorder epoch"); - } else { - const detectionStartTime = performance.now(); - rawAudioStartInterval = setInterval(() => { - const progressStarted = window.currentTapeProgress > 0; - const audioRouted = sfxStreamGain.numberOfOutputs > 0; - if ( - progressStarted || - audioRouted || - performance.now() - detectionStartTime > 100 - ) { - connectRawAudio("audio detected"); - } - }, 16); - rawAudioStartTimeout = setTimeout( - () => connectRawAudio("timeout"), - 1000, - ); - } - } + if (rawAudioProcessor && sfxStreamGain) { + const connectRawAudio = (reason) => { + if (rawAudioConnected || !rawAudioProcessor) return; + try { + sfxStreamGain.connect(rawAudioProcessor); + micStreamGain?.connect(rawAudioProcessor); + rawAudioProcessor.connect(audioContext.destination); + rawAudioConnected = true; + if (rawAudioStartInterval) clearInterval(rawAudioStartInterval); + if (rawAudioStartTimeout) clearTimeout(rawAudioStartTimeout); + rawAudioStartInterval = null; + rawAudioStartTimeout = null; + console.log(`🎵 Raw audio capture connected (${reason})`); + } catch (error) { + console.warn("Raw audio capture connection failed:", error); + } + }; + + if (recordingOptions.avSync) { + // Camera takes capture from the recorder epoch. Waiting 100ms here + // used to shift its entire soundtrack ahead of the film frames. + connectRawAudio("camera recorder epoch"); + } else { + const detectionStartTime = performance.now(); + rawAudioStartInterval = setInterval(() => { + const progressStarted = window.currentTapeProgress > 0; + const audioRouted = sfxStreamGain.numberOfOutputs > 0; + if ( + progressStarted || + audioRouted || + performance.now() - detectionStartTime > 100 + ) { + connectRawAudio("audio detected"); + } + }, 16); + rawAudioStartTimeout = setTimeout( + () => connectRawAudio("timeout"), + 1000, + ); + } + } // Clear KidLisp FPS timeline for new recording window.kidlispFpsTimeline = []; @@ -15729,127 +15729,127 @@ async function boot(parsed, bpm = 60, resolution, debug) { mediaRecorderFrameCount = 0; // Reset frame capture counter for optimization console.log("🎬 Starting MediaRecorder, state before start:", mediaRecorder.state); - // One-second encoded chunks keep event overhead low for compact camera - // clips. Frame tapes retain their existing cadence for compatibility. - mediaRecorder.start(compactVideoRecording ? 1000 : 100); + // One-second encoded chunks keep event overhead low for compact camera + // clips. Frame tapes retain their existing cadence for compatibility. + mediaRecorder.start(compactVideoRecording ? 1000 : 100); console.log("🎬 MediaRecorder.start() called, state after start:", mediaRecorder.state); //} return; } - if (type === "recorder:present") { - // Compact camera tapes are already playable media. Present the Blob - // directly instead of rebuilding a timeline from retained ImageData. - if (!recordedVideoBlob) { - try { - const cachedTape = await Store.get("tape"); - if (cachedTape?.kind === "video" && cachedTape.blob) { - recordedVideoBlob = cachedTape.blob; - recordedVideoMime = cachedTape.mime || cachedTape.blob.type || "video/mp4"; - mediaRecorderDuration = cachedTape.duration || 0; - } - } catch (error) { - console.warn("Could not restore compact video tape:", error); - } - } - - if (recordedVideoBlob) { - stopTapePlayback?.(); - underlayFrame?.remove(); - underlayFrame = document.createElement("div"); - underlayFrame.id = "underlay"; - underlayFrame.style.cssText = - "position: fixed; top: 0; left: 0; width: 100%; height: 100%; " + - "z-index: -1; pointer-events: none; background: black;"; - - const video = document.createElement("video"); - mp4PlaybackVideo = video; - if (compactPlaybackUrl) URL.revokeObjectURL(compactPlaybackUrl); - compactPlaybackUrl = URL.createObjectURL(recordedVideoBlob); - video.src = compactPlaybackUrl; - video.playsInline = true; - video.setAttribute("playsinline", ""); - video.loop = true; - video.muted = false; - video.controls = false; - video.preload = "auto"; - video.style.cssText = - "position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain;"; - - let playbackFrame = 0; - const transmitProgress = () => { - if (mp4PlaybackVideo !== video || !video.isConnected) return; - if (Number.isFinite(video.duration) && video.duration > 0) { - send({ - type: "recorder:present-progress", - content: video.currentTime / video.duration, - }); - } - playbackFrame = requestAnimationFrame(transmitProgress); - }; - - stopTapePlayback = () => { - cancelAnimationFrame(playbackFrame); - try { video.pause(); } catch {} - }; - pauseTapePlayback = () => video.pause(); - resumeTapePlayback = () => video.play().catch(() => {}); - seekTapePlayback = (progress) => { - if (!Number.isFinite(video.duration) || video.duration <= 0) return; - video.currentTime = Math.max(0, Math.min(1, progress)) * video.duration; - }; - - video.onloadedmetadata = () => { - const duration = Number.isFinite(video.duration) - ? video.duration - : mediaRecorderDuration / 1000; - send({ - type: "tape:info-reply", - content: { - frameCount: 0, - totalDuration: duration, - hasAudio: true, - kind: "video", - pieceName: window.currentRecordingOptions?.pieceName || "cap", - }, - }); - }; - video.onplay = () => send({ type: "recorder:present-playing" }); - video.onpause = () => send({ type: "recorder:present-paused" }); - video.onerror = () => { - console.error("Compact video tape failed to load"); - send({ type: "recorder:presented:failure" }); - }; - - underlayFrame.appendChild(video); - document.body.insertBefore(underlayFrame, document.body.firstChild); - - if (freezeFrame || freezeFrameFrozen || wrapper.contains(freezeFrameCan)) { - freezeFrameCan.remove(); - freezeFrame = false; - freezeFrameGlaze = false; - freezeFrameFrozen = false; - } - const glazeCan = Glaze.getCan(); - if (glazeCan) glazeCan.style.display = "none"; - if (webglCompositeCanvas) webglCompositeCanvas.style.display = "none"; - if (overlayCan) overlayCan.style.display = "none"; - ctx.clearRect(0, 0, canvas.width, canvas.height); - canvas.style.visibility = "visible"; - document.body.style.background = "transparent"; - document.body.style.backgroundImage = "none"; - wrapper.style.background = "transparent"; - - send({ type: "recorder:presented" }); - playbackFrame = requestAnimationFrame(transmitProgress); - if (!content?.noplay) video.play().catch(() => { - send({ type: "recorder:present-paused" }); - }); - else send({ type: "recorder:present-paused" }); - return; - } - - // Check for cached video if no active recording AND no recorded frames + if (type === "recorder:present") { + // Compact camera tapes are already playable media. Present the Blob + // directly instead of rebuilding a timeline from retained ImageData. + if (!recordedVideoBlob) { + try { + const cachedTape = await Store.get("tape"); + if (cachedTape?.kind === "video" && cachedTape.blob) { + recordedVideoBlob = cachedTape.blob; + recordedVideoMime = cachedTape.mime || cachedTape.blob.type || "video/mp4"; + mediaRecorderDuration = cachedTape.duration || 0; + } + } catch (error) { + console.warn("Could not restore compact video tape:", error); + } + } + + if (recordedVideoBlob) { + stopTapePlayback?.(); + underlayFrame?.remove(); + underlayFrame = document.createElement("div"); + underlayFrame.id = "underlay"; + underlayFrame.style.cssText = + "position: fixed; top: 0; left: 0; width: 100%; height: 100%; " + + "z-index: -1; pointer-events: none; background: black;"; + + const video = document.createElement("video"); + mp4PlaybackVideo = video; + if (compactPlaybackUrl) URL.revokeObjectURL(compactPlaybackUrl); + compactPlaybackUrl = URL.createObjectURL(recordedVideoBlob); + video.src = compactPlaybackUrl; + video.playsInline = true; + video.setAttribute("playsinline", ""); + video.loop = true; + video.muted = false; + video.controls = false; + video.preload = "auto"; + video.style.cssText = + "position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain;"; + + let playbackFrame = 0; + const transmitProgress = () => { + if (mp4PlaybackVideo !== video || !video.isConnected) return; + if (Number.isFinite(video.duration) && video.duration > 0) { + send({ + type: "recorder:present-progress", + content: video.currentTime / video.duration, + }); + } + playbackFrame = requestAnimationFrame(transmitProgress); + }; + + stopTapePlayback = () => { + cancelAnimationFrame(playbackFrame); + try { video.pause(); } catch {} + }; + pauseTapePlayback = () => video.pause(); + resumeTapePlayback = () => video.play().catch(() => {}); + seekTapePlayback = (progress) => { + if (!Number.isFinite(video.duration) || video.duration <= 0) return; + video.currentTime = Math.max(0, Math.min(1, progress)) * video.duration; + }; + + video.onloadedmetadata = () => { + const duration = Number.isFinite(video.duration) + ? video.duration + : mediaRecorderDuration / 1000; + send({ + type: "tape:info-reply", + content: { + frameCount: 0, + totalDuration: duration, + hasAudio: true, + kind: "video", + pieceName: window.currentRecordingOptions?.pieceName || "cap", + }, + }); + }; + video.onplay = () => send({ type: "recorder:present-playing" }); + video.onpause = () => send({ type: "recorder:present-paused" }); + video.onerror = () => { + console.error("Compact video tape failed to load"); + send({ type: "recorder:presented:failure" }); + }; + + underlayFrame.appendChild(video); + document.body.insertBefore(underlayFrame, document.body.firstChild); + + if (freezeFrame || freezeFrameFrozen || wrapper.contains(freezeFrameCan)) { + freezeFrameCan.remove(); + freezeFrame = false; + freezeFrameGlaze = false; + freezeFrameFrozen = false; + } + const glazeCan = Glaze.getCan(); + if (glazeCan) glazeCan.style.display = "none"; + if (webglCompositeCanvas) webglCompositeCanvas.style.display = "none"; + if (overlayCan) overlayCan.style.display = "none"; + ctx.clearRect(0, 0, canvas.width, canvas.height); + canvas.style.visibility = "visible"; + document.body.style.background = "transparent"; + document.body.style.backgroundImage = "none"; + wrapper.style.background = "transparent"; + + send({ type: "recorder:presented" }); + playbackFrame = requestAnimationFrame(transmitProgress); + if (!content?.noplay) video.play().catch(() => { + send({ type: "recorder:present-paused" }); + }); + else send({ type: "recorder:present-paused" }); + return; + } + + // Check for cached video if no active recording AND no recorded frames if ( (!mediaRecorder || mediaRecorder.state !== "paused") && recordedFrames.length === 0 @@ -16234,33 +16234,33 @@ async function boot(parsed, bpm = 60, resolution, debug) { } }); - tapeSoundId = startTapeAudioLoop(audioPosition); - } - - // 🎞️ Sound-on-film: once the worklet reports its sample read head, - // that sample range owns the displayed frame. Audio is the single - // clock, so RAF jitter and repeated loops cannot drift picture away. - let soundOnFilmProgress = null; - if (!render && !isScrubbing && recordedFrames[0]?.[3]) { - const activeTapeSoundId = - tapeSoundId || - Object.keys(sfxPlaying).find((id) => id.startsWith("tape:audio_")); - const audioProgress = activeTapeSoundId - ? sfxPlaying[activeTapeSoundId]?.progress?.() - : null; - if (Number.isFinite(audioProgress)) { - soundOnFilmProgress = ((audioProgress % 1) + 1) % 1; - const filmFrame = frameIndexForSoundtrackProgress( - recordedFrames, - soundOnFilmProgress, - ); - if (filmFrame >= 0) f = filmFrame; - playbackProgress = soundOnFilmProgress * playbackDurationMs; - playbackStart = performance.now() - playbackProgress; - } - } - - // Resize fctx here if the width and + tapeSoundId = startTapeAudioLoop(audioPosition); + } + + // 🎞️ Sound-on-film: once the worklet reports its sample read head, + // that sample range owns the displayed frame. Audio is the single + // clock, so RAF jitter and repeated loops cannot drift picture away. + let soundOnFilmProgress = null; + if (!render && !isScrubbing && recordedFrames[0]?.[3]) { + const activeTapeSoundId = + tapeSoundId || + Object.keys(sfxPlaying).find((id) => id.startsWith("tape:audio_")); + const audioProgress = activeTapeSoundId + ? sfxPlaying[activeTapeSoundId]?.progress?.() + : null; + if (Number.isFinite(audioProgress)) { + soundOnFilmProgress = ((audioProgress % 1) + 1) % 1; + const filmFrame = frameIndexForSoundtrackProgress( + recordedFrames, + soundOnFilmProgress, + ); + if (filmFrame >= 0) f = filmFrame; + playbackProgress = soundOnFilmProgress * playbackDurationMs; + playbackStart = performance.now() - playbackProgress; + } + } + + // Resize fctx here if the width and // height is different. const pic = recordedFrames[f][1]; if ( @@ -16339,9 +16339,9 @@ async function boot(parsed, bpm = 60, resolution, debug) { } // Advance frames while playback has progressed past the current frame's time - if (soundOnFilmProgress !== null && !render) { - // The soundtrack read head selected `f` above. - } else if (isScrubbing && !render) { + if (soundOnFilmProgress !== null && !render) { + // The soundtrack read head selected `f` above. + } else if (isScrubbing && !render) { // While the piece is speed-scrubbing, its seeks own the frame // index — the RAF clock must not advance frames at 1× between // seeks (at slow rates that read as jitter and inaccuracy). @@ -16512,8 +16512,8 @@ async function boot(parsed, bpm = 60, resolution, debug) { window.lastVideoProgress = currentProgress; } else { // For normal playback, use time-based progress - currentProgress = - soundOnFilmProgress ?? playbackProgress / playbackDurationMs; + currentProgress = + soundOnFilmProgress ?? playbackProgress / playbackDurationMs; } // Store global progress for overlay breathing pattern @@ -16559,7 +16559,7 @@ async function boot(parsed, bpm = 60, resolution, debug) { update(); }; - // CRITICAL: Clear freeze frame that would layer above the underlay video (z-index 3 > 0) + // CRITICAL: Clear freeze frame that would layer above the underlay video (z-index 3 > 0) if (freezeFrame || freezeFrameFrozen || wrapper.contains(freezeFrameCan)) { freezeFrameCan.remove(); freezeFrame = false; @@ -16571,12 +16571,12 @@ async function boot(parsed, bpm = 60, resolution, debug) { // z-index: -1 places it below the main canvas (1), glaze (2), and UI (6) underlayFrame.style.cssText = "position: fixed; top: 0; left: 0; width: 100%; height: 100%; z-index: -1; pointer-events: none;"; - underlayFrame.appendChild(frameCan); - // Insert at the very beginning of body to ensure it's below the wrapper - document.body.insertBefore(underlayFrame, document.body.firstChild); - // Start only after the first frame canvas is in the document. This makes - // cap → video reveal frame zero in the same presentation turn. - startTapePlayback(); + underlayFrame.appendChild(frameCan); + // Insert at the very beginning of body to ensure it's below the wrapper + document.body.insertBefore(underlayFrame, document.body.firstChild); + // Start only after the first frame canvas is in the document. This makes + // cap → video reveal frame zero in the same presentation turn. + startTapePlayback(); // Hide only the opaque compositor layers during presentation // Keep the main canvas visible - the piece uses wipe(0,0,0,0) for transparency @@ -16638,15 +16638,15 @@ async function boot(parsed, bpm = 60, resolution, debug) { } if (type === "recorder:unpresent") { - if (underlayFrame) { - const media = underlayFrame.querySelector("video, audio"); - if (media?.src) URL.revokeObjectURL(media.src); - if (media === mp4PlaybackVideo) mp4PlaybackVideo = undefined; - if (compactPlaybackUrl) { - URL.revokeObjectURL(compactPlaybackUrl); - compactPlaybackUrl = null; - } - underlayFrame?.remove(); + if (underlayFrame) { + const media = underlayFrame.querySelector("video, audio"); + if (media?.src) URL.revokeObjectURL(media.src); + if (media === mp4PlaybackVideo) mp4PlaybackVideo = undefined; + if (compactPlaybackUrl) { + URL.revokeObjectURL(compactPlaybackUrl); + compactPlaybackUrl = null; + } + underlayFrame?.remove(); underlayFrame = undefined; // Restore compositor layers visibility @@ -17775,70 +17775,70 @@ async function boot(parsed, bpm = 60, resolution, debug) { return; } - if (type === "recorder:slate") { - recorderGeneration += 1; - stopTapePlayback?.(); - if (mediaRecorder && mediaRecorder.state !== "inactive") { - mediaRecorder.onstop = null; - mediaRecorder.stop(); - } - mediaRecorder = undefined; - mediaRecorderStartTime = undefined; - mediaRecorderDuration = 0; - mediaRecorderFrameCount = 0; - mediaRecorderChunks.length = 0; - recordedFrames.length = 0; - recordedPieceChanges.length = 0; - clearRawAudioCapture(); - clearCompactRecorderStream(); - recordedVideoBlob = null; - recordedVideoMime = ""; - if (compactPlaybackUrl) { - URL.revokeObjectURL(compactPlaybackUrl); - compactPlaybackUrl = null; - } - captureSession = null; - delete sfx["tape:audio"]; - delete window.recordingStartTimestamp; - delete window.currentTapeProgress; - delete window.lastVideoProgress; - underlayFrame?.remove(); - underlayFrame = undefined; - await Store.del("tape"); - return; - } - - // Request recorded frames for export - if (type === "recorder:request-frames") { - if (!recordedVideoBlob) { - try { - const cachedTape = await Store.get("tape"); - if (cachedTape?.kind === "video" && cachedTape.blob) { - recordedVideoBlob = cachedTape.blob; - recordedVideoMime = cachedTape.mime || cachedTape.blob.type || "video/mp4"; - mediaRecorderDuration = cachedTape.duration || 0; - } - } catch (error) { - console.warn("Could not retrieve compact video tape:", error); - } - } - - if (recordedVideoBlob) { - send({ - type: "recorder:frames-response", - content: { - frames: [], - video: { - blob: recordedVideoBlob, - mime: recordedVideoMime || recordedVideoBlob.type || "video/mp4", - duration: mediaRecorderDuration / 1000, - }, - }, - }); - return; - } - - if (recordedFrames.length > 0) { + if (type === "recorder:slate") { + recorderGeneration += 1; + stopTapePlayback?.(); + if (mediaRecorder && mediaRecorder.state !== "inactive") { + mediaRecorder.onstop = null; + mediaRecorder.stop(); + } + mediaRecorder = undefined; + mediaRecorderStartTime = undefined; + mediaRecorderDuration = 0; + mediaRecorderFrameCount = 0; + mediaRecorderChunks.length = 0; + recordedFrames.length = 0; + recordedPieceChanges.length = 0; + clearRawAudioCapture(); + clearCompactRecorderStream(); + recordedVideoBlob = null; + recordedVideoMime = ""; + if (compactPlaybackUrl) { + URL.revokeObjectURL(compactPlaybackUrl); + compactPlaybackUrl = null; + } + captureSession = null; + delete sfx["tape:audio"]; + delete window.recordingStartTimestamp; + delete window.currentTapeProgress; + delete window.lastVideoProgress; + underlayFrame?.remove(); + underlayFrame = undefined; + await Store.del("tape"); + return; + } + + // Request recorded frames for export + if (type === "recorder:request-frames") { + if (!recordedVideoBlob) { + try { + const cachedTape = await Store.get("tape"); + if (cachedTape?.kind === "video" && cachedTape.blob) { + recordedVideoBlob = cachedTape.blob; + recordedVideoMime = cachedTape.mime || cachedTape.blob.type || "video/mp4"; + mediaRecorderDuration = cachedTape.duration || 0; + } + } catch (error) { + console.warn("Could not retrieve compact video tape:", error); + } + } + + if (recordedVideoBlob) { + send({ + type: "recorder:frames-response", + content: { + frames: [], + video: { + blob: recordedVideoBlob, + mime: recordedVideoMime || recordedVideoBlob.type || "video/mp4", + duration: mediaRecorderDuration / 1000, + }, + }, + }); + return; + } + + if (recordedFrames.length > 0) { // Try to get raw audio from cached tape let rawAudio = null; try { @@ -20039,17 +20039,17 @@ async function boot(parsed, bpm = 60, resolution, debug) { } // 📼 Capture frame data AFTER HUD overlays but BEFORE tape progress bar (including HUD in recording) - if (isRecording && !compactVideoRecording) { - // 🎯 Capture EVERY frame - no time-based throttling for maximum quality + if (isRecording && !compactVideoRecording) { + // 🎯 Capture EVERY frame - no time-based throttling for maximum quality // GIF export will downsample to 30fps, MP4 will use all frames mediaRecorderFrameCount = (mediaRecorderFrameCount || 0) + 1; - // Update recording progress for tape progress bar breathing pattern - const relativeTimestamp = performance.now() - mediaRecorderStartTime; - if (captureSession && captureSession.firstVideoMs === null) { - captureSession.firstVideoMs = mediaRecorderStartTime + relativeTimestamp; - reportCaptureAVSync(); - } + // Update recording progress for tape progress bar breathing pattern + const relativeTimestamp = performance.now() - mediaRecorderStartTime; + if (captureSession && captureSession.firstVideoMs === null) { + captureSession.firstVideoMs = mediaRecorderStartTime + relativeTimestamp; + reportCaptureAVSync(); + } const targetDuration = window.currentRecordingOptions?.duration || 3000; // Default 3 seconds if not set window.currentTapeProgress = Math.min(1, relativeTimestamp / targetDuration); @@ -21791,14 +21791,14 @@ async function boot(parsed, bpm = 60, resolution, debug) { // for multiple videos can be routed simultaneously. const video = document.createElement("video"); - // The native iPhone WKWebView replaces its UA with the exact string - // "Aesthetic", so the ordinary iOS detector cannot see it. Camera - // capture must still use the mobile constraint/restart/mirroring path; - // otherwise WKWebView reports portrait dimensions around sideways - // sensor pixels and our dimension-based rotation check cannot recover. - const cameraIOS = iOS || AestheticIOSApp; - const mobileCamera = cameraIOS || Android; - + // The native iPhone WKWebView replaces its UA with the exact string + // "Aesthetic", so the ordinary iOS detector cannot see it. Camera + // capture must still use the mobile constraint/restart/mirroring path; + // otherwise WKWebView reports portrait dimensions around sideways + // sensor pixels and our dimension-based rotation check cannot recover. + const cameraIOS = iOS || AestheticIOSApp; + const mobileCamera = cameraIOS || Android; + // Camera properties. let facingMode = options.facing || "user", zoom = 1; @@ -21843,10 +21843,10 @@ async function boot(parsed, bpm = 60, resolution, debug) { buffer.style = `position: absolute; opacity: 0;`; - let settings, stream, videoTrack; - let lastProcessedVideoTime = -1; - let facingModeChange = false; - let torchEnabled = false; + let settings, stream, videoTrack; + let lastProcessedVideoTime = -1; + let facingModeChange = false; + let torchEnabled = false; try { // Grab video from the user using a requested width and height based @@ -21862,7 +21862,9 @@ async function boot(parsed, bpm = 60, resolution, debug) { const constraints = { facingMode: facingModeChoice, - frameRate: { ideal: 30 }, + // Prefer a high-rate preview — filming feels tighter at 60+. + // `ideal` is best-effort, so 30fps-only sensors still open. + frameRate: { ideal: 60 }, }; // Mobile camera sensors are physically landscape. Per Snap and @@ -21872,7 +21874,7 @@ async function boot(parsed, bpm = 60, resolution, debug) { // getUserMedia time. We then rotate the canvas in process() // below to fit the portrait buffer. if ( - mobileCamera && + mobileCamera && typeof window !== "undefined" && window.matchMedia?.("(orientation: portrait)")?.matches && (facingModeChoice === "environment" || @@ -21896,24 +21898,40 @@ async function boot(parsed, bpm = 60, resolution, debug) { audio: false, }); - video.srcObject = stream; - lastProcessedVideoTime = -1; - videoTrack = stream.getVideoTracks()[0]; - const capabilities = videoTrack.getCapabilities?.() || {}; - settings = videoTrack.getSettings(); + video.srcObject = stream; + lastProcessedVideoTime = -1; + videoTrack = stream.getVideoTracks()[0]; + const capabilities = videoTrack.getCapabilities?.() || {}; + settings = videoTrack.getSettings(); + + // 🎞️ Chase the sensor's max rate so the preview tracks the world + // with less delay. The encoded tape keeps its own rate — this is + // feel while filming, not format. + const maxFps = capabilities.frameRate?.max; + if (maxFps && (!settings.frameRate || maxFps > settings.frameRate)) { + try { + await videoTrack.applyConstraints({ frameRate: { ideal: maxFps } }); + settings = videoTrack.getSettings(); + } catch (fpsError) { + console.warn("🎥 Max frame rate constraint failed:", fpsError); + } + } + console.log( + `🎥 Camera preview: ${settings.frameRate || "?"}fps (sensor max ${maxFps || "?"})`, + ); // Update global facingMode in case different from requested. - facingMode = videoTrack.getConstraints().facingMode; - torchEnabled = false; - const actualFacing = settings?.facingMode || facingModeChoice; - send({ - type: "camera:capabilities", - content: { - torch: actualFacing === "environment" && capabilities.torch === true, - enabled: false, - facingMode: actualFacing, - }, - }); + facingMode = videoTrack.getConstraints().facingMode; + torchEnabled = false; + const actualFacing = settings?.facingMode || facingModeChoice; + send({ + type: "camera:capabilities", + content: { + torch: actualFacing === "environment" && capabilities.torch === true, + enabled: false, + facingMode: actualFacing, + }, + }); // 📊 Send camera debug telemetry to the worker so pieces (e.g. cap) // can console.log() it and the piece-runs silo captures it. @@ -21937,13 +21955,13 @@ async function boot(parsed, bpm = 60, resolution, debug) { height: settings?.height ?? null, aspectRatio: settings?.aspectRatio ?? null, facingMode: settings?.facingMode ?? null, - frameRate: settings?.frameRate ?? null, - torch: capabilities.torch === true, + frameRate: settings?.frameRate ?? null, + torch: capabilities.torch === true, }, facingMode, iOS, - cameraIOS, - AestheticIOSApp, + cameraIOS, + AestheticIOSApp, Android, isPortrait, orientationAngle, @@ -21989,55 +22007,55 @@ async function boot(parsed, bpm = 60, resolution, debug) { ); // Resizing the video after creation. (Window resize or device rotate.) - videoResize = async function ({ width, height, facing, torch }) { - cancelAnimationFrame(getAnimationRequest()); - - try { - if (typeof torch === "boolean") { - const capabilities = videoTrack?.getCapabilities?.() || {}; - const actualFacing = settings?.facingMode || facingMode; - if (actualFacing !== "environment" || capabilities.torch !== true) { - send({ - type: "camera:torch", - content: { - available: false, - enabled: false, - facingMode: actualFacing, - }, - }); - process(); - return; - } - try { - await videoTrack.applyConstraints({ advanced: [{ torch }] }); - torchEnabled = torch; - settings = videoTrack.getSettings(); - send({ - type: "camera:torch", - content: { - available: true, - enabled: torchEnabled, - facingMode: actualFacing, - }, - }); - } catch (torchError) { - send({ - type: "camera:torch", - content: { - available: true, - enabled: torchEnabled, - facingMode: actualFacing, - error: torchError?.message || String(torchError), - }, - }); - } - if (width === undefined && height === undefined && !facing) { - process(); - return; - } - } - - const sizeChange = !isNaN(width) && !isNaN(height); + videoResize = async function ({ width, height, facing, torch }) { + cancelAnimationFrame(getAnimationRequest()); + + try { + if (typeof torch === "boolean") { + const capabilities = videoTrack?.getCapabilities?.() || {}; + const actualFacing = settings?.facingMode || facingMode; + if (actualFacing !== "environment" || capabilities.torch !== true) { + send({ + type: "camera:torch", + content: { + available: false, + enabled: false, + facingMode: actualFacing, + }, + }); + process(); + return; + } + try { + await videoTrack.applyConstraints({ advanced: [{ torch }] }); + torchEnabled = torch; + settings = videoTrack.getSettings(); + send({ + type: "camera:torch", + content: { + available: true, + enabled: torchEnabled, + facingMode: actualFacing, + }, + }); + } catch (torchError) { + send({ + type: "camera:torch", + content: { + available: true, + enabled: torchEnabled, + facingMode: actualFacing, + error: torchError?.message || String(torchError), + }, + }); + } + if (width === undefined && height === undefined && !facing) { + process(); + return; + } + } + + const sizeChange = !isNaN(width) && !isNaN(height); if (sizeChange) { // Update outer dimensions from the new size. @@ -22059,7 +22077,7 @@ async function boot(parsed, bpm = 60, resolution, debug) { { once: true }, ); - if (mobileCamera) { + if (mobileCamera) { await getDevice(facing); } else { video.srcObject = null; // Refresh the video `srcObject`. @@ -22070,14 +22088,14 @@ async function boot(parsed, bpm = 60, resolution, debug) { } } - if (facing && settings?.facingMode !== facing) { - if (torchEnabled) { - try { - await videoTrack.applyConstraints({ advanced: [{ torch: false }] }); - } catch {} - torchEnabled = false; - } - facingModeChange = true; + if (facing && settings?.facingMode !== facing) { + if (torchEnabled) { + try { + await videoTrack.applyConstraints({ advanced: [{ torch: false }] }); + } catch {} + torchEnabled = false; + } + facingModeChange = true; await getDevice(facing); facingModeChange = false; @@ -22190,22 +22208,22 @@ async function boot(parsed, bpm = 60, resolution, debug) { handData = hand; } - function process() { - cancelAnimationFrame(getAnimationRequest()); - if (facingModeChange) return; - // Camera tracks commonly deliver 30fps while display RAF runs at 60 - // or 120Hz. Avoid redrawing, reading, and transferring the same camera - // frame two to four times. - if (video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) { - animationRequest = requestAnimationFrame(process); - return; - } - if (video.currentTime === lastProcessedVideoTime) { - animationRequest = requestAnimationFrame(process); - return; - } - lastProcessedVideoTime = video.currentTime; - // cancelAnimationFrame(getAnimationRequest()); + function process() { + cancelAnimationFrame(getAnimationRequest()); + if (facingModeChange) return; + // Camera tracks commonly deliver 30fps while display RAF runs at 60 + // or 120Hz. Avoid redrawing, reading, and transferring the same camera + // frame two to four times. + if (video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) { + animationRequest = requestAnimationFrame(process); + return; + } + if (video.currentTime === lastProcessedVideoTime) { + animationRequest = requestAnimationFrame(process); + return; + } + lastProcessedVideoTime = video.currentTime; + // cancelAnimationFrame(getAnimationRequest()); // TODO: Video effects / filter kernels could be added here... // 💡 For GPU backed visuals. 23.04.29.20.47 @@ -22240,7 +22258,7 @@ async function boot(parsed, bpm = 60, resolution, debug) { } // Mirror the front camera for selfie framing. Desktop webcams are // conventionally mirrored too. Mobile rear camera stays unmirrored. - const needsMirror = facingMode === "user" || !mobileCamera; + const needsMirror = facingMode === "user" || !mobileCamera; // EXIF orientation 6 — the convention iPhone (and most Android // devices) tag portrait camera captures with — means "rotate 90° // CW for display". The raw landscape sensor buffer has the @@ -22633,22 +22651,22 @@ async function boot(parsed, bpm = 60, resolution, debug) { keys(hitboxes).forEach((key) => hitboxes[key]?.(e)); }); - window.addEventListener("pointerdown", async (e) => { - keys(hitboxes).forEach((key) => hitboxes[key]?.(e)); - }); - - // BIOS overlays stay reachable when a piece hides the OS pointer in favor - // of a painted cursor. Raise the native hand over system hitboxes, then - // restore the piece's exact cursor request when it leaves. - window.addEventListener("pointermove", (e) => { - const overSystemControl = keys(hitboxes).some((key) => - hitboxes[key]?.contains?.(e)); - document.body.style.cursor = overSystemControl - ? "pointer" - : (window.acPieceCursorCss || "auto"); - }); - - // 📄 Drag and Drop File API + window.addEventListener("pointerdown", async (e) => { + keys(hitboxes).forEach((key) => hitboxes[key]?.(e)); + }); + + // BIOS overlays stay reachable when a piece hides the OS pointer in favor + // of a painted cursor. Raise the native hand over system hitboxes, then + // restore the piece's exact cursor request when it leaves. + window.addEventListener("pointermove", (e) => { + const overSystemControl = keys(hitboxes).some((key) => + hitboxes[key]?.contains?.(e)); + document.body.style.cursor = overSystemControl + ? "pointer" + : (window.acPieceCursorCss || "auto"); + }); + + // 📄 Drag and Drop File API // Drag over... document.body.addEventListener("dragover", function (e) { diff --git a/system/public/aesthetic.computer/disks/cap.mjs b/system/public/aesthetic.computer/disks/cap.mjs index 1602e1641..15c97b05b 100644 --- a/system/public/aesthetic.computer/disks/cap.mjs +++ b/system/public/aesthetic.computer/disks/cap.mjs @@ -58,6 +58,14 @@ const zoomMax = 4; const zoomMin = 1; const zoomSensitivity = 240; +// Blit tracking: paint() runs at display rate (up to 120Hz) but camera +// frames arrive at 30–60fps, so the wipe + paste only need to happen when +// the frame object, zoom, or screen size actually changed. +let pastedFrame = null, + pastedZoom = 0, + pastedW = 0, + pastedH = 0; + // 🥾 Boot function boot({ ui, params, colon, system, rec, notice }) { // Piece modules persist between visits. A re-shoot must never inherit the @@ -79,6 +87,7 @@ function boot({ ui, params, colon, system, rec, notice }) { torchPending = false; zoom = 1; zoomStartY = null; + pastedFrame = null; // Parse parameters if (params[0] === "me" || params[0] === "selfie") facing = "user"; @@ -133,17 +142,42 @@ function paint({ frame = vid(); } - // Paste the video centered on screen, scaled by the current zoom factor. - // paste(frame, x, y, scale) draws the frame upscaled by `scale`; we - // recenter so the camera image stays anchored to the screen center as - // the user slides up to zoom in. Off-screen pixels clip naturally. + // Draw the camera frame, zoomed around the screen center. Zoom crops the + // frame's middle 1/zoom region and scales the crop up to the full screen — + // the crop path blits per destination pixel, so a fractional zoom stays + // realtime (a fractional `scale` paste walks every source pixel through + // color()+box() and slideshows). if (frame) { - const drawW = frame.width * zoom; - const drawH = frame.height * zoom; - const offsetX = floor((screen.width - drawW) / 2); - const offsetY = floor((screen.height - drawH) / 2); - wipe(0); // Clear first - paste(frame, offsetX, offsetY, zoom); + const stale = + frame !== pastedFrame || + zoom !== pastedZoom || + screen.width !== pastedW || + screen.height !== pastedH; + if (stale) { + pastedFrame = frame; + pastedZoom = zoom; + pastedW = screen.width; + pastedH = screen.height; + wipe(0); // Clear first — the contain fit can letterbox. + if (zoom > 1.001) { + const cropW = frame.width / zoom; + const cropH = frame.height / zoom; + paste(frame, 0, 0, { + crop: { + x: (frame.width - cropW) / 2, + y: (frame.height - cropH) / 2, + w: cropW, + h: cropH, + }, + width: screen.width, + height: screen.height, + }); + } else { + const offsetX = floor((screen.width - frame.width) / 2); + const offsetY = floor((screen.height - frame.height) / 2); + paste(frame, offsetX, offsetY); + } + } } // 🎬 Draw UI elements to a recording UI overlay (NOT captured in tape). diff --git a/system/public/aesthetic.computer/disks/video.mjs b/system/public/aesthetic.computer/disks/video.mjs index 6da92cc88..bbd989110 100644 --- a/system/public/aesthetic.computer/disks/video.mjs +++ b/system/public/aesthetic.computer/disks/video.mjs @@ -136,6 +136,11 @@ let sustained = false; let resumeTarget = 1; // The pre-gesture rate: brake, wheel, and dip return here const PARK_SNAP = 0.05; // Within this of 1×, a release ends the scrub cleanly +// 🐢 Slow park: letting go below 1× HOLDS that slow rate — it's a setting +// the gesture made, and friction leaves it alone until the next touch. +// Letting go above 1× still glides home to 1× on the wheel's friction. +let slowParked = false; + // 🎰 Wheel: a FLICK release lets the platter run free, then it eases down // like a prize wheel to the pre-flick rate. A gentle release parks instead. let wheelActive = false; @@ -437,6 +442,7 @@ function boot({ wipe, rec, gizmo, jump, notice, store, params, send, hud }) { lastScrollAt = 0; wheelActive = false; sustained = false; + slowParked = false; chopActive = 0; flickVel = 0; scratchVelocity = 0; @@ -1595,6 +1601,7 @@ function sim({ needsPaint, rec, send, clock, sound }) { tapDipTime = -1; scrubSpeed = dipBase; sustained = true; + slowParked = Math.abs(dipBase) < 1; } } @@ -1662,6 +1669,7 @@ function sim({ needsPaint, rec, send, clock, sound }) { tapDipTime = -1; isScrubbing = false; if (Math.abs(scrubSpeed - 1) < PARK_SNAP) scrubSpeed = 1; + slowParked = Math.abs(scrubSpeed) < 1; // 🐢 Sub-1× release stays slow driveDirection = directionalCruiseTarget(scrubSpeed); sustained = true; } @@ -1679,6 +1687,7 @@ function sim({ needsPaint, rec, send, clock, sound }) { wheelActive = false; scrubSpeed = resumeTarget; sustained = true; + slowParked = false; // A flick always lands at cruise (±1×) } } @@ -1689,7 +1698,9 @@ function sim({ needsPaint, rec, send, clock, sound }) { // it's always the same gesture: let go, the wheel finds the groove and // locks. Never a jump. (A held chop owns position — it opts out.) if (sustained && !isScrubbing && !chopActive) { - if (driveDirection < 0) { + if (slowParked) { + // 🐢 A sub-1× park holds its rate — no bearing glide, no PLL. + } else if (driveDirection < 0) { // A reverse throw settles at reverse 1× and never crosses zero. scrubSpeed += (-1 - scrubSpeed) * (1 - Math.pow(0.94, rate)); if (Math.abs(scrubSpeed + 1) < 0.004) scrubSpeed = -1; @@ -1740,6 +1751,7 @@ function sim({ needsPaint, rec, send, clock, sound }) { scrubSpeed = resumeTarget; driveDirection = directionalCruiseTarget(resumeTarget); sustained = true; + slowParked = Math.abs(resumeTarget) < 1; // Braking from a slow park returns to it } } @@ -1831,6 +1843,7 @@ function act({ brakeResume = false; wheelActive = false; sustained = false; + slowParked = false; holdTime = 0; flickVel = 0; scratchVelocity = 0; @@ -2823,6 +2836,7 @@ function act({ brakeResume = false; wheelActive = false; sustained = false; + slowParked = false; scrollScrubbing = false; tapDipTime = -1; chopActive = 0; @@ -3079,27 +3093,34 @@ function act({ isScrubbing = false; elasticAnchorX = null; elasticBase = 1; + const thrown = Math.max( + -24, + Math.min(24, scrubSpeed + flickVel * FLICK_KICK), + ); if (brakeHolding) { // 🖐️ Brake release: spin back up to the pre-gesture rate. brakeHolding = false; brakeResume = true; - } else if (Math.abs(flickVel) > FLICK_THRESHOLD) { + } else if ( + Math.abs(flickVel) > FLICK_THRESHOLD && + Math.abs(thrown) >= 1 + ) { // 🎰 Flick: the platter runs free with the throw's momentum, // then eases to 1× without changing the throw's direction. - scrubSpeed = Math.max( - -24, - Math.min(24, scrubSpeed + flickVel * FLICK_KICK), - ); + scrubSpeed = thrown; resumeTarget = directionalCruiseTarget(scrubSpeed, flickVel); driveDirection = resumeTarget; nudgeTapeAudioSpeed(send, scrubSpeed); wheelActive = true; sustained = false; + slowParked = false; } else { // 🅿️ Release parks where you left it. Near 1× it pins to // exactly 1 — the scrub drive at 1.0 IS normal playback, so - // there's no handoff and no jump, ever. + // there's no handoff and no jump, ever. 🐢 Below 1× the rate + // HOLDS; above 1× the bearing glide brings it home to 1×. if (Math.abs(scrubSpeed - 1) < PARK_SNAP) scrubSpeed = 1; + slowParked = Math.abs(scrubSpeed) < 1; driveDirection = directionalCruiseTarget(scrubSpeed); sustained = true; nudgeTapeAudioSpeed(send, scrubSpeed); @@ -3115,6 +3136,7 @@ function act({ brakeResume = false; wheelActive = false; sustained = false; + slowParked = false; elasticAnchorX = null; scrubSpeed = 0; nudgeTapeAudioSpeed(send, 1); @@ -3762,6 +3784,7 @@ function leave({ send, rec }) { tapDipTime = -1; wheelActive = false; sustained = false; + slowParked = false; flickVel = 0; scratchVelocity = 0; lastScratchEventAt = 0; @@ -3868,6 +3891,7 @@ function autopilot(rec, send, simDt) { if (!isScrubbing) { inertiaActive = false; sustained = false; + slowParked = false; wheelActive = false; // Position must be captured BEFORE isScrubbing flips — after the // flip autopilotProgress() returns the stale scrub position. @@ -3901,6 +3925,7 @@ function autopilot(rec, send, simDt) { inertiaActive = false; brakeResume = false; sustained = false; + slowParked = false; wheelActive = false; resumeTarget = 1; scrubCurrentProgress = autoSegStartProgress; @@ -3923,6 +3948,7 @@ function autopilot(rec, send, simDt) { inertiaActive = false; brakeResume = false; sustained = false; + slowParked = false; wheelActive = false; isScrubbing = false; dipBase = 1; diff --git a/system/public/aesthetic.computer/lib/disk.mjs b/system/public/aesthetic.computer/lib/disk.mjs index e3de500ca..4af42bf04 100644 --- a/system/public/aesthetic.computer/lib/disk.mjs +++ b/system/public/aesthetic.computer/lib/disk.mjs @@ -9404,16 +9404,9 @@ async function load( shader(p, c); } } - } else if (lastActiveVideo) { - // Make it all red... - const { pixels } = lastActiveVideo; - for (let i = 0; i < pixels.length; i += 4) { - pixels[i] = 255; - pixels[i + 1] = 0; - pixels[i + 2] = 0; - pixels[i + 3] = 255; - } } + // With no fresh frame (e.g. mid camera switch), hand back the last real + // frame untouched so the preview holds instead of flashing a placeholder. return activeVideo || lastActiveVideo; } diff --git a/system/public/aesthetic.computer/lib/graph.mjs b/system/public/aesthetic.computer/lib/graph.mjs index 0930ed89d..6342c13ca 100644 --- a/system/public/aesthetic.computer/lib/graph.mjs +++ b/system/public/aesthetic.computer/lib/graph.mjs @@ -2313,11 +2313,56 @@ function paste(from, destX = 0, destY = 0, scale = 1, blit = false) { const cY = Math.floor(crop.y); const cW = Math.floor(crop.w); const cH = Math.floor(crop.h); + const srcWidth = from.width; + const srcHeight = from.height; + + // Destination-based scaling for cropped buffers (fixes downscaling gaps) + const targetW = tWidth || (scale && typeof scale === 'number' ? Math.floor(cW * scale) : cW); + const targetH = tHeight || (scale && typeof scale === 'number' ? Math.floor(cH * scale) : cH); + + // 🚀 Fast crop+scale: nearest-neighbor straight from the source + // buffer — no intermediate crop copy. The mask is rectangular, so + // it clamps the loop bounds instead of branching per pixel, and + // fully opaque pixels move as single 32-bit words. This is the + // realtime path for full-screen video zoom (e.g. cap). + if ( + (targetW !== cW || targetH !== cH) && + !angle && + (sourcePixels.byteOffset & 3) === 0 && + (pixels.byteOffset & 3) === 0 + ) { + const scaleX = cW / targetW; + const scaleY = cH / targetH; + + // Loop bounds clamped to the screen... + let startY = Math.max(0, -destY); + let endY = Math.min(targetH, height - destY); + let startX = Math.max(0, -destX); + let endX = Math.min(targetW, width - destX); + + // ...and to the mask rect (pan-translated like every other blit). + if (activeMask) { + const maskX = activeMask.x + panTranslation.x; + const maskY = activeMask.y + panTranslation.y; + startX = Math.max(startX, maskX - destX); + endX = Math.min(endX, maskX + activeMask.width - destX); + startY = Math.max(startY, maskY - destY); + endY = Math.min(endY, maskY + activeMask.height - destY); + } + + if (endX > startX && endY > startY) { + blitCropScale( + sourcePixels, srcWidth, srcHeight, cX, cY, + scaleX, scaleY, destX, destY, + pixels, width, startX, startY, endX, endY, + ); + } + return; + } // Create a new buffer for the cropped area // We must copy the pixels because grid() expects a packed buffer matching width/height const croppedPixels = new Uint8ClampedArray(cW * cH * 4); - const srcWidth = from.width; for (let y = 0; y < cH; y++) { const srcY = cY + y; @@ -2356,11 +2401,9 @@ function paste(from, destX = 0, destY = 0, scale = 1, blit = false) { height: cH, pixels: croppedPixels }; - - // Destination-based scaling for cropped buffers (fixes downscaling gaps) - const targetW = tWidth || (scale && typeof scale === 'number' ? Math.floor(cW * scale) : cW); - const targetH = tHeight || (scale && typeof scale === 'number' ? Math.floor(cH * scale) : cH); - + + // Misaligned-buffer fallback for the scaled case (the aligned fast + // path above returned already). if ((targetW !== cW || targetH !== cH) && !angle) { const scaleX = cW / targetW; const scaleY = cH / targetH;