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:
parent
52e891204d
commit
ddb8dea6d8
@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
Loading…
Reference in New Issue
Block a user