M12: cinematic export — camPath ride -> festival4d-cut.webm

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 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-17 17:53:34 +10:00
parent 52e891204d
commit ddb8dea6d8

View File

@ -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);
},
};