diff --git a/system/public/aesthetic.computer/bios.mjs b/system/public/aesthetic.computer/bios.mjs index 10e13d8228..c2f0a089a8 100644 --- a/system/public/aesthetic.computer/bios.mjs +++ b/system/public/aesthetic.computer/bios.mjs @@ -22193,7 +22193,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 @@ -22246,6 +22248,22 @@ async function boot(parsed, bpm = 60, resolution, debug) { 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; diff --git a/system/public/aesthetic.computer/disks/cap.mjs b/system/public/aesthetic.computer/disks/cap.mjs index cdb89781fb..ad20c38b75 100644 --- a/system/public/aesthetic.computer/disks/cap.mjs +++ b/system/public/aesthetic.computer/disks/cap.mjs @@ -60,6 +60,14 @@ const zoomMax = 6; const zoomMin = 1; const zoomSensitivity = 120; +// 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 @@ -82,6 +90,7 @@ function boot({ ui, params, colon, system, rec, notice }) { zoom = 1; lastSentZoom = 1; zoomStartY = null; + pastedFrame = null; // Parse parameters if (params[0] === "me" || params[0] === "selfie") facing = "user"; @@ -137,21 +146,45 @@ 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) { // Keep the live preview independent from iOS hardware constraints. The // recorder clone receives hardware zoom; AC renders the matching digital // crop here so zoom cannot corrupt or stall the source camera track. - const displayZoom = zoom; - const drawW = frame.width * displayZoom; - const drawH = frame.height * displayZoom; - const offsetX = floor((screen.width - drawW) / 2); - const offsetY = floor((screen.height - drawH) / 2); - wipe(0); // Clear first - paste(frame, offsetX, offsetY, displayZoom); + 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 5d82d53185..78a0e4033b 100644 --- a/system/public/aesthetic.computer/disks/video.mjs +++ b/system/public/aesthetic.computer/disks/video.mjs @@ -139,6 +139,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; @@ -443,6 +448,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; @@ -1656,6 +1662,7 @@ function sim({ needsPaint, rec, send, clock, sound }) { tapDipTime = -1; scrubSpeed = dipBase; sustained = true; + slowParked = Math.abs(dipBase) < 1; } } @@ -1723,6 +1730,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; } @@ -1740,6 +1748,7 @@ function sim({ needsPaint, rec, send, clock, sound }) { wheelActive = false; scrubSpeed = resumeTarget; sustained = true; + slowParked = false; // A flick always lands at cruise (±1×) } } @@ -1750,7 +1759,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; @@ -1803,6 +1814,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 } } @@ -1894,6 +1906,7 @@ function act({ brakeResume = false; wheelActive = false; sustained = false; + slowParked = false; holdTime = 0; flickVel = 0; scratchVelocity = 0; @@ -2911,6 +2924,7 @@ function act({ brakeResume = false; wheelActive = false; sustained = false; + slowParked = false; scrollScrubbing = false; tapDipTime = -1; chopActive = 0; @@ -3168,27 +3182,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); @@ -3204,6 +3225,7 @@ function act({ brakeResume = false; wheelActive = false; sustained = false; + slowParked = false; elasticAnchorX = null; scrubSpeed = 0; nudgeTapeAudioSpeed(send, 1); @@ -3897,6 +3919,7 @@ function leave({ send, rec }) { tapDipTime = -1; wheelActive = false; sustained = false; + slowParked = false; flickVel = 0; scratchVelocity = 0; lastScratchEventAt = 0; @@ -4003,6 +4026,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. @@ -4036,6 +4060,7 @@ function autopilot(rec, send, simDt) { inertiaActive = false; brakeResume = false; sustained = false; + slowParked = false; wheelActive = false; resumeTarget = 1; scrubCurrentProgress = autoSegStartProgress; @@ -4058,6 +4083,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 099dadc213..8e1bcfb3c5 100644 --- a/system/public/aesthetic.computer/lib/disk.mjs +++ b/system/public/aesthetic.computer/lib/disk.mjs @@ -9406,16 +9406,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 0930ed89db..6342c13cac 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;