From ddb8dea6d889cb5afa2d043f10f5f6d5ee5b3c5f Mon Sep 17 00:00:00 2001 From: type-two Date: Fri, 17 Jul 2026 17:53:34 +1000 Subject: [PATCH] =?UTF-8?q?M12:=20cinematic=20export=20=E2=80=94=20camPath?= =?UTF-8?q?=20ride=20->=20festival4d-cut.webm?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit frontend/src/exportVideo.js fills the foundation2 stub: the ⏺ button records the scene3d canvas captureStream(30) muxed with the live WebAudio mix via the transport.captureAudioStream() hook (spatial included, silent tap when no track — degrade, don't block), MediaRecorder vp9/opus webm (vp8/webm fallbacks), spanning first -> last camPath keyframe. Playback is driven through the existing #btn-path flow at forced rate 1 (file duration must equal the path span), stops at the last keyframe (or early on a second click / anything that halts the ride), downloads festival4d-cut.webm, then restores ALL prior transport/UI state (playhead, rate, play state, path state, followed camera) and releases the audio tap + canvas tracks. Button disabled under 2 keyframes by chaining main.js's camPath.onChange (frozen files untouched). Requires live-browser verification (focused tab — captureStream delivers no frames hidden): see plan/status/lane-E.md. Frontend build clean. Co-Authored-By: Claude Fable 5 --- frontend/src/exportVideo.js | 215 ++++++++++++++++++++++++++++++++++-- 1 file changed, 204 insertions(+), 11 deletions(-) diff --git a/frontend/src/exportVideo.js b/frontend/src/exportVideo.js index 3530b5e..060e8ce 100644 --- a/frontend/src/exportVideo.js +++ b/frontend/src/exportVideo.js @@ -1,18 +1,211 @@ -// Cinematic export (spec M12, lane E). STUB — lane E fills THIS FILE ONLY. +// Cinematic export (spec M12, lane E). // // Pre-wired by foundation2: the hidden ⏺ button (#btn-export in index.html), this module's -// import + init() call in main.js, and the transport hooks -// `transport.captureAudioStream()` -> MediaStream tapping the live WebAudio mix (post-gain, -// spatial included) and `transport.releaseAudioStream()` when done. +// import + init() call in main.js, and the transport hooks transport.captureAudioStream() +// (a MediaStream tapping the live WebAudio mix — post-gain, spatial included) + +// transport.releaseAudioStream() when done. // -// When implementing: unhide the button, set `ready = true`; on click seek to the first -// camPath keyframe, play the path, mux scene3d canvas `captureStream(30)` + the audio stream -// with MediaRecorder (video/webm;codecs=vp9,opus), stop at the last keyframe, download -// `festival4d-cut.webm`, then RESTORE all prior UI/transport state. The canvas is -// `scene3d.renderer.domElement`. MediaRecorder needs a *focused* tab to capture real frames -// (pitfall #2) — say so in your evidence if you couldn't. +// Flow: seek to the first camPath keyframe, play the path via the existing #btn-path flow +// (so its UI state stays truthful), record scene3d's canvas captureStream(30) muxed with the +// audio tap through MediaRecorder (vp9/opus webm, fallbacks below), stop at the last +// keyframe, download `festival4d-cut.webm`, then restore ALL prior UI/transport state. +// Export runs at rate 1 regardless of the user's speed (the recording is wall-clock, so the +// file duration must equal the path's time span); the prior rate is restored afterwards. +// Degradation: fewer than 2 keyframes -> button disabled; no decoded WebAudio track -> the +// tap is silent but the export still works (pitfall #5); clicking ⏺ mid-export stops early +// and still produces a valid file. NOTE (pitfall #2): canvas.captureStream only delivers +// real frames in a FOCUSED tab — a hidden/backgrounded pane records a frozen image. + +import { state } from "./state.js"; +import { transport } from "./transport.js"; +import { camPath } from "./camPath.js"; +import { scene3d } from "./scene3d.js"; + +const BTN_LABEL = "⏺ Export"; +const MIME_CANDIDATES = [ + "video/webm;codecs=vp9,opus", // the spec'd target + "video/webm;codecs=vp8,opus", + "video/webm", +]; + +function pickMime() { + if (typeof MediaRecorder === "undefined") return null; + return MIME_CANDIDATES.find((m) => MediaRecorder.isTypeSupported(m)) ?? null; +} + +function download(blob, filename) { + const a = document.createElement("a"); + a.href = URL.createObjectURL(blob); + a.download = filename; + a.click(); + URL.revokeObjectURL(a.href); +} + +/** Toggle the shared #btn-path flow (main.js owns its UI state) into the desired state. */ +function setPathPlaying(playing) { + if (camPath.playing === playing) return; + document.getElementById("btn-path")?.click(); +} export const exportVideo = { ready: false, - init() {}, // ponytail: stub — lane E replaces the body + exporting: false, + + _btn: null, + _recorder: null, + _chunks: [], + _canvasStream: null, + _endT: 0, + _prior: null, + _finishing: false, + + init() { + const btn = document.getElementById("btn-export"); + if (!btn) return; // markup missing: stay hidden, degrade silently + this._btn = btn; + + btn.addEventListener("click", () => { + if (this.exporting) this._finish("stopped early"); + else this._start(); + }); + + // Enabled only with a playable path (>= 2 keyframes). main.js assigned camPath.onChange + // before lane modules init, so chain it rather than replace it (camPath.js is frozen). + const chained = camPath.onChange; + camPath.onChange = (n) => { + chained?.(n); + if (!this.exporting) btn.disabled = n < 2; + }; + btn.disabled = camPath.count() < 2; + + btn.style.display = ""; // unhide: this lane has landed + this.ready = true; + }, + + _start() { + if (this.exporting || camPath.keyframes.length < 2) return; + const mime = pickMime(); + if (!mime) { + console.error("[export] MediaRecorder/webm unsupported in this browser"); + this._flash("⏺ unsupported"); + return; + } + + const kf = camPath.keyframes; // sorted by t_global (camPath invariant) + const startT = kf[0].t_global; + this._endT = kf[kf.length - 1].t_global; + + // Snapshot everything we mutate, so _finish can restore it exactly. + this._prior = { + tGlobal: state.tGlobal, + playing: state.playing, + rate: state.rate, + pathPlaying: camPath.playing, + followCameraId: state.followCameraId, + }; + + // Park the world at the first keyframe, real-time rate, path engaged. + setPathPlaying(false); + if (state.playing) transport.pause(); + transport.setRate(1); + transport.seek(startT); + + // Canvas frames + the live WebAudio mix tap (foundation2 hook; silent when no track). + const canvas = scene3d.renderer.domElement; + this._canvasStream = canvas.captureStream(30); + const audioStream = transport.captureAudioStream(); + const mixed = new MediaStream([ + ...this._canvasStream.getVideoTracks(), + ...audioStream.getAudioTracks(), + ]); + + this._chunks = []; + this._recorder = new MediaRecorder(mixed, { + mimeType: mime, + videoBitsPerSecond: 8_000_000, + }); + this._recorder.addEventListener("dataavailable", (e) => { + if (e.data && e.data.size > 0) this._chunks.push(e.data); + }); + this._recorder.addEventListener("stop", () => this._deliver(mime)); + + this.exporting = true; + this._finishing = false; + this._btn.classList.add("active"); + this._btn.textContent = "⏺ REC — click to stop"; + + // Roll: recorder + playback in the same tick so file duration ~= the path span. + setPathPlaying(true); // #btn-path flow: camPath.play() + transport starts the clock + this._recorder.start(250); + this._tick = this._tick.bind(this); + requestAnimationFrame(this._tick); + console.info(`[export] recording ${startT.toFixed(2)}s -> ${this._endT.toFixed(2)}s (${mime})`); + }, + + _tick() { + if (!this.exporting || this._finishing) return; + // Done when the playhead crosses the last keyframe — or when anything halts the ride + // (end of timeline pauses the transport; snap-to-camera exits path mode). + if (state.tGlobal >= this._endT - 1e-3 || !state.playing || !camPath.playing) { + this._finish("end of path"); + return; + } + requestAnimationFrame(this._tick); + }, + + _finish(reason) { + if (!this.exporting || this._finishing) return; + this._finishing = true; + console.info(`[export] finishing (${reason})`); + try { + if (this._recorder && this._recorder.state !== "inactive") this._recorder.stop(); + else this._deliver(null); // recorder never really started: still restore the UI + } catch (err) { + console.error("[export] recorder stop failed", err); + this._deliver(null); + } + }, + + /** MediaRecorder "stop" handler: build + download the file, then restore prior state. */ + _deliver(mime) { + if (this._chunks.length > 0) { + const blob = new Blob(this._chunks, { type: mime ?? "video/webm" }); + download(blob, "festival4d-cut.webm"); + console.info(`[export] festival4d-cut.webm — ${(blob.size / 1e6).toFixed(2)} MB`); + } else { + console.warn("[export] no data recorded (hidden tab? — captureStream needs focus)"); + } + this._chunks = []; + this._recorder = null; + + // Release the shared hooks. + transport.releaseAudioStream(); + this._canvasStream?.getTracks().forEach((t) => t.stop()); + this._canvasStream = null; + + // Restore ALL prior UI/transport state (spec M12). + const prior = this._prior ?? {}; + this._prior = null; + setPathPlaying(false); // resets #btn-path label via main.js's own flow + if (state.playing) transport.pause(); + transport.setRate(prior.rate ?? 1); + transport.seek(prior.tGlobal ?? 0); + if (prior.followCameraId != null) scene3d.snapTo(prior.followCameraId); + if (prior.pathPlaying) setPathPlaying(true); + if (prior.playing && !state.playing) transport.play(); + + this.exporting = false; + this._finishing = false; + this._btn.classList.remove("active"); + this._btn.textContent = BTN_LABEL; + this._btn.disabled = camPath.count() < 2; + }, + + _flash(text, ms = 2200) { + const btn = this._btn; + btn.textContent = text; + setTimeout(() => { + if (!this.exporting) btn.textContent = BTN_LABEL; + }, ms); + }, };