diff --git a/system/public/aesthetic.computer/bios.mjs b/system/public/aesthetic.computer/bios.mjs index 2782ae3fbe..492548db62 100644 --- a/system/public/aesthetic.computer/bios.mjs +++ b/system/public/aesthetic.computer/bios.mjs @@ -45,6 +45,7 @@ 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 { + chooseCompactVideoMime, measureCaptureAVSync, shouldResumeCaptureRecorder, } from "./lib/capture-session.mjs"; @@ -921,7 +922,12 @@ async function boot(parsed, bpm = 60, resolution, debug) { mediaRecorderStartTime, mediaRecorderFrameCount = 0; // Frame counter for performance optimization let recorderGeneration = 0; - let needs$creenshot = false; // Flag when a capture is requested. + 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; @@ -947,6 +953,14 @@ async function boot(parsed, bpm = 60, resolution, debug) { 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( @@ -995,18 +1009,79 @@ 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 - } - } - - // 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; + send({ + type: "recorder:compact", + content: { + phase: "cut", + mime, + bytes: recordedVideoBlob.size, + duration: mediaRecorderDuration / 1000, + }, + }); + 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 @@ -12219,10 +12294,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: !!window.tapeAudioArrayBuffer, + 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, @@ -12253,14 +12329,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; - 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; @@ -12631,11 +12711,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 }); - } + 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; } @@ -12659,10 +12742,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 }); - } + 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; } @@ -12672,10 +12762,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 }); - } + 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; } @@ -14924,7 +15026,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 } = 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. @@ -14942,7 +15044,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: "camera-roll" }; + const metadata = { + totalDuration: duration || 0, + source: source || "camera-roll", + }; receivedUpload( { filename, data: blob }, callback || "tape:posted", @@ -15048,6 +15153,7 @@ async function boot(parsed, bpm = 60, resolution, debug) { 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 { @@ -15062,6 +15168,7 @@ async function boot(parsed, bpm = 60, resolution, debug) { kidlispFps: null, // Initialize for KidLisp framerate updates freshSession: false, avSync: false, + compactVideo: false, }; actualContent = content; } @@ -15076,9 +15183,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 - 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"); @@ -15130,6 +15244,7 @@ async function boot(parsed, bpm = 60, resolution, debug) { mediaRecorderChunks.length = 0; clearRawAudioCapture(); + clearCompactRecorderStream(); captureSession = null; // Clear recording timestamp for next recording @@ -15173,20 +15288,89 @@ async function boot(parsed, bpm = 60, resolution, debug) { mimeType = "audio/aac"; } - let options = { mimeType }; - - if (actualContent === "video") { - // Start recording audio. - try { - mediaRecorder = new MediaRecorder(audioStreamDest.stream, options); - console.log("🎬 MediaRecorder created successfully:", mediaRecorder.state); - } catch (error) { - console.error("MediaRecorder creation failed:", error); - return; - } - - // Set up raw audio capture for playback - but don't connect yet - if (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; + send({ + type: "recorder:compact", + content: { + phase: "ready", + mime: compactMime, + videoBitsPerSecond, + width: canvas.width, + height: canvas.height, + fps: 30, + }, + }); + console.log("🎬 Compact video recorder ready:", { + mime: compactMime, + videoBitsPerSecond, + }); + } catch (error) { + console.warn("Compact video recording unavailable; using frame tape:", error); + send({ + type: "recorder:compact", + content: { phase: "fallback", reason: error?.message || String(error) }, + }); + clearCompactRecorderStream(); + mediaRecorder = undefined; + } + } else { + send({ + type: "recorder:compact", + content: { phase: "fallback", reason: "mp4-media-recorder-unsupported" }, + }); + } + } + + // 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; @@ -15514,14 +15698,136 @@ 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); - mediaRecorder.start(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") { - // 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: "recorder:compact", + content: { + phase: "preview", + mime: recordedVideoMime || recordedVideoBlob.type || "video/mp4", + bytes: recordedVideoBlob.size, + duration, + }, + }); + 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 @@ -16310,10 +16616,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); - 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 @@ -17457,6 +17768,13 @@ async function boot(parsed, bpm = 60, resolution, debug) { 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; @@ -17507,9 +17825,37 @@ async function boot(parsed, bpm = 60, resolution, debug) { return; } - // Request recorded frames for export - if (type === "recorder:request-frames") { - if (recordedFrames.length > 0) { + // 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 { @@ -19710,8 +20056,8 @@ 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) { - // 🎯 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; @@ -21575,6 +21921,7 @@ async function boot(parsed, bpm = 60, resolution, debug) { opacity: 0;`; let settings, stream, videoTrack; + let lastProcessedVideoTime = -1; let facingModeChange = false; let torchEnabled = false; @@ -21627,6 +21974,7 @@ async function boot(parsed, bpm = 60, resolution, debug) { }); video.srcObject = stream; + lastProcessedVideoTime = -1; videoTrack = stream.getVideoTracks()[0]; const capabilities = videoTrack.getCapabilities?.() || {}; settings = videoTrack.getSettings(); @@ -21919,10 +22267,22 @@ async function boot(parsed, bpm = 60, resolution, debug) { handData = hand; } - function process() { - cancelAnimationFrame(getAnimationRequest()); - if (facingModeChange) return; - // 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 diff --git a/system/public/aesthetic.computer/disks/cap.mjs b/system/public/aesthetic.computer/disks/cap.mjs index e533b9266c..c71d5ba1ca 100644 --- a/system/public/aesthetic.computer/disks/cap.mjs +++ b/system/public/aesthetic.computer/disks/cap.mjs @@ -21,7 +21,7 @@ import { RecordingTimer, MicLevel } from "./common/cap-ui.mjs"; -const { floor, min, max, sqrt } = Math; +const { floor, min, max } = Math; // Recording state let vid, @@ -114,7 +114,6 @@ function paint({ painting, recordingUI, ui, - num: { randIntRange, clamp, rand }, }) { // Initialize video feed to match screen dimensions (not painting) if (!vid) { @@ -130,18 +129,9 @@ function paint({ // Draw the video on each frame if (capturing) { - frame = vid(function shader({ x, y }, c) { - // Light vignette effect based on frame dimensions - const cx = screen.width / 2; - const cy = screen.height / 2; - const maxDist = sqrt(cx * cx + cy * cy); - const dist = sqrt((x - cx) ** 2 + (y - cy) ** 2); - const vignette = 1 - (dist / maxDist) * 0.1; - - c[0] = clamp(floor(c[0] * vignette), 0, 255); - c[1] = clamp(floor(c[1] * vignette), 0, 255); - c[2] = clamp(floor(c[2] * vignette), 0, 255); - }); + // Keep preview pixels unchanged. The former per-pixel vignette walked the + // entire camera frame in the worker for every update. + frame = vid(); } // Paste the video centered on screen, scaled by the current zoom factor. @@ -343,6 +333,10 @@ function startRecording(rec, notice) { mystery: false, freshSession: true, avSync: true, + // Prefer a browser-native encoded stream for camera clips. BIOS falls + // back to the frame tape when canvas capture or MP4 MediaRecorder is + // unavailable, so the rest of AC's recording API stays unchanged. + compactVideo: true, }, (time) => { // Recording started callback @@ -394,6 +388,9 @@ function act({ event: e, jump, video, cameras, rec, notice, leaving, hud }) { `${Number.isFinite(offset) ? offset.toFixed(1) : "?"}ms`, ); } + if (e.is("recorder:compact")) { + console.log("🎥 compact:", JSON.stringify(e.content)); + } // Handle microphone connection events if (e.is("microphone-connect:success")) { diff --git a/system/public/aesthetic.computer/disks/video.mjs b/system/public/aesthetic.computer/disks/video.mjs index 060b587762..5d82d53185 100644 --- a/system/public/aesthetic.computer/disks/video.mjs +++ b/system/public/aesthetic.computer/disks/video.mjs @@ -2403,7 +2403,23 @@ function act({ try { // Request frames from the recording system for tape posting rec.requestFrames(async (frameData) => { - if (frameData.frames && frameData.frames.length > 0) { + if (frameData.video?.blob) { + // Camera clips are already browser-encoded MP4s. Upload the + // same compressed bytes used for review; no frame ZIP or + // client-side transcode is needed. + exportStatusMessage = "UPLOADING TAPE"; + currentExportPhase = "uploading"; + send({ + type: "upload-video-tape", + content: { + data: frameData.video.blob, + mime: frameData.video.mime, + duration: frameData.video.duration, + source: "web-camera", + callback: "tape:posted", + }, + }); + } else if (frameData.frames && frameData.frames.length > 0) { // Convert frames to format expected by create-and-post-tape const frameRecord = []; @@ -3308,6 +3324,12 @@ function handleSystemMessage({ event: e, rec, needsPaint, jump }) { rec.tapeProgress = 0; } }; + + if (e.is("recorder:compact")) { + console.log("🎥 compact:", JSON.stringify(e.content)); + requestPaint(); + return true; + } // Handle upload progress (from 90% to 100% as file uploads) if (e.is("upload:progress")) { diff --git a/system/public/aesthetic.computer/lib/capture-session.mjs b/system/public/aesthetic.computer/lib/capture-session.mjs index b787146ba4..4f230b0a28 100644 --- a/system/public/aesthetic.computer/lib/capture-session.mjs +++ b/system/public/aesthetic.computer/lib/capture-session.mjs @@ -2,6 +2,17 @@ export function shouldResumeCaptureRecorder(state, freshSession = false) { return state === "paused" && !freshSession; } +const COMPACT_VIDEO_MIMES = [ + "video/mp4;codecs=avc1.42E01E,mp4a.40.2", + "video/mp4;codecs=avc1.42E01E", + "video/mp4", +]; + +export function chooseCompactVideoMime(isTypeSupported) { + if (typeof isTypeSupported !== "function") return null; + return COMPACT_VIDEO_MIMES.find((mime) => isTypeSupported(mime)) || null; +} + export function measureCaptureAVSync(videoStartMs, audioStartMs, toleranceMs = 80) { if (!Number.isFinite(videoStartMs) || !Number.isFinite(audioStartMs)) { return null; diff --git a/system/public/aesthetic.computer/lib/disk.mjs b/system/public/aesthetic.computer/lib/disk.mjs index 5dda2aa373..0554fe5c10 100644 --- a/system/public/aesthetic.computer/lib/disk.mjs +++ b/system/public/aesthetic.computer/lib/disk.mjs @@ -11835,6 +11835,11 @@ async function makeFrame({ data: { type, content } }) { return; } + if (type === "recorder:compact") { + actAlerts.push({ name: type, content }); + return; + } + // 📊 Camera diagnostics from bios — log directly here so they land in // the piece-runs silo via the patched console. Avoids needing to wire // every diagnostic type into the actAlerts queue (which only carries diff --git a/tests/capture-session.test.mjs b/tests/capture-session.test.mjs index a9775ef25a..cbd2935956 100644 --- a/tests/capture-session.test.mjs +++ b/tests/capture-session.test.mjs @@ -1,6 +1,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import { + chooseCompactVideoMime, measureCaptureAVSync, shouldResumeCaptureRecorder, } from "../system/public/aesthetic.computer/lib/capture-session.mjs"; @@ -17,3 +18,23 @@ test("capture A/V alignment reports signed offset and tolerance", () => { assert.equal(measureCaptureAVSync(10, 120, 40).aligned, false); assert.equal(measureCaptureAVSync(undefined, 20), null); }); + +test("compact camera capture requires an MP4 MediaRecorder", () => { + const supported = new Set(["video/mp4", "video/webm"]); + assert.equal( + chooseCompactVideoMime((mime) => supported.has(mime)), + "video/mp4", + ); + assert.equal( + chooseCompactVideoMime((mime) => mime === "video/webm"), + null, + ); + assert.equal(chooseCompactVideoMime(undefined), null); +}); + +test("compact camera capture prefers explicit H264 and AAC", () => { + assert.equal( + chooseCompactVideoMime(() => true), + "video/mp4;codecs=avc1.42E01E,mp4a.40.2", + ); +});