← Files ModRetro Chromatic PluginARCHIVED FILE

dist/web-annotations/recording.js

34.3 KB · Oct 2, 2026 · 00:37 UTC

↓ Download file

/// <reference lib="dom" />
import { RecordingController } from "./recording-controller.js";
import { parseRecordingSaved } from "./recording-protocol.js";
import { mountPreviewErrorDialog } from "./error-dialog.js";
import { ICONS } from "./view.js";
const MAX_BYTES = 128 * 1024 * 1024;
const MAX_VISIBILITY_CHANGES = 256;
const stamp = () => ({ utc: new Date().toISOString(), monotonicMs: performance.now() });
/** Records live browser media only. It never drives or pauses the emulator. */
export class CanvasRecording {
    canvas;
    audio;
    callbacks;
    #state = "idle";
    #recorder;
    #release;
    #start;
    #end;
    #detach;
    #limitTimer;
    #releaseConfirmed = true;
    #audio = {};
    constructor(canvas, audio, callbacks) {
        this.canvas = canvas;
        this.audio = audio;
        this.callbacks = callbacks;
    }
    get releaseConfirmed() { return this.#releaseConfirmed; }
    get state() { return this.#state; }
    get audioState() { return { ...this.#audio }; }
    get elapsedMs() { return this.#start ? (this.#end?.monotonicMs ?? performance.now()) - this.#start.monotonicMs : 0; }
    #setState(state) { this.#state = state; this.callbacks.state?.(state); }
    start() {
        if (this.#state !== "idle")
            return;
        let videoStream;
        let audioCapture;
        let recorder;
        const chunks = [];
        const events = [];
        let bytes = 0;
        let failure;
        let startEvent;
        let stopRequested;
        let omittedBytes = 0;
        let stopVisibility = () => { };
        let released = false, releaseAttempted = false;
        this.#releaseConfirmed = false;
        this.#audio = {};
        const safeStamp = () => { try {
            const value = stamp();
            if (!Number.isFinite(value.monotonicMs))
                throw new Error();
            return value;
        }
        catch {
            failure ??= "Recording timing could not be measured.";
            return { utc: new Date(0).toISOString(), monotonicMs: 0 };
        } };
        const release = () => {
            if (releaseAttempted)
                return;
            releaseAttempted = true;
            if (this.#limitTimer) {
                try {
                    clearTimeout(this.#limitTimer);
                }
                catch {
                    failure ??= "Recording timer cleanup was unconfirmed.";
                }
            }
            this.#limitTimer = undefined;
            let confirmed = true;
            for (const track of videoStream?.getTracks() ?? []) {
                try {
                    track.stop();
                }
                catch {
                    confirmed = false;
                }
            }
            try {
                audioCapture?.release();
            }
            catch {
                confirmed = false;
            }
            for (const track of audioCapture?.stream?.getTracks() ?? [])
                if (track.readyState !== "ended") {
                    try {
                        track.stop();
                    }
                    catch {
                        confirmed = false;
                    }
                }
            released = confirmed;
            this.#releaseConfirmed = confirmed;
            this.#release = undefined;
        };
        try {
            if (typeof MediaRecorder === "undefined" || typeof MediaStream === "undefined" || typeof this.canvas.captureStream !== "function") {
                throw new Error("Video recording is unavailable in this browser.");
            }
            videoStream = this.canvas.captureStream(60);
            audioCapture = this.audio();
            const videoTracks = videoStream.getVideoTracks();
            const audioTracks = audioCapture.stream?.getAudioTracks() ?? [];
            if (videoTracks.length !== 1 || (audioCapture.unavailableReason ? audioTracks.length !== 0 : audioTracks.length !== 1) || [...videoTracks, ...audioTracks].some(track => track.readyState === "ended")) {
                throw new Error("Recording needs a live game video track and game audio track.");
            }
            this.#audio = { audioIncluded: audioTracks.length === 1, ...(audioCapture.unavailableReason ? { audioUnavailableReason: audioCapture.unavailableReason } : {}) };
            const stream = new MediaStream([...videoTracks, ...audioTracks]);
            const mimeType = ["video/webm;codecs=vp9,opus", "video/webm;codecs=vp8,opus", "video/mp4"].find(type => MediaRecorder.isTypeSupported(type));
            recorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined);
            this.#start = safeStamp();
            this.#end = undefined;
            const startRequested = this.#start;
            const ownerDocument = this.canvas.ownerDocument;
            const visibilityState = () => {
                const state = ownerDocument?.visibilityState;
                return state === "visible" || state === "hidden" ? state : "unavailable";
            };
            const visibility = {
                atStart: { ...startRequested, state: visibilityState() },
                changes: [],
                omittedChanges: 0,
            };
            if (typeof ownerDocument?.addEventListener === "function" && typeof ownerDocument?.removeEventListener === "function") {
                let observing = true;
                const changed = () => {
                    if (visibility.changes.length < MAX_VISIBILITY_CHANGES)
                        visibility.changes.push({ ...safeStamp(), state: visibilityState() });
                    else
                        visibility.omittedChanges++;
                };
                ownerDocument.addEventListener("visibilitychange", changed);
                stopVisibility = () => {
                    if (!observing)
                        return;
                    observing = false;
                    ownerDocument.removeEventListener("visibilitychange", changed);
                };
            }
            const width = this.canvas.width, height = this.canvas.height;
            recorder.onstart = () => { startEvent = safeStamp(); };
            recorder.ondataavailable = event => {
                if (this.#state === "disposed")
                    return;
                if (bytes + event.data.size > MAX_BYTES || events.length >= 10_000) {
                    failure ??= "Recording exceeded its local memory limit. Please record a shorter clip.";
                    omittedBytes += event.data.size;
                    this.stop();
                    return;
                }
                bytes += event.data.size;
                if (event.data.size)
                    chunks.push(event.data);
                events.push({ bytes: event.data.size, timecode: Number.isFinite(event.timecode) ? event.timecode : null, eventTimestamp: event.timeStamp, receivedAtMs: safeStamp().monotonicMs });
            };
            recorder.onerror = () => { stopVisibility(); failure ??= "The browser could not finish this recording."; this.stop(); };
            recorder.onstop = () => {
                const stopped = safeStamp();
                this.#end = stopped;
                const diagnostics = {
                    mimeType: recorder.mimeType, startEventObserved: !!startEvent, dataEvents: events.length,
                    zeroByteEvents: events.filter(event => event.bytes === 0).length, bytes, omittedBytes,
                    startRequestedAt: startRequested.utc, stoppedAt: stopped.utc, elapsedMs: Math.max(0, stopped.monotonicMs - startRequested.monotonicMs),
                    visibilityAtStart: visibility.atStart.state, visibilityAtStop: visibilityState(),
                    videoTrackAtStop: videoTracks[0].readyState, audioTrackAtStop: audioTracks[0]?.readyState ?? "not-included",
                };
                release();
                this.#detach?.();
                this.#recorder = undefined;
                this.#requestStop = undefined;
                this.#detach = undefined;
                if (this.#state === "disposed")
                    return;
                this.#setState("idle");
                if (failure)
                    this.callbacks.error?.(failure, diagnostics);
                if (!chunks.length) {
                    if (!failure)
                        this.callbacks.error?.("The recording was empty. No video file was saved; the encoding failure cause is unknown.", diagnostics);
                    return;
                }
                const video = new Blob(chunks, { type: recorder.mimeType });
                this.callbacks.complete?.({ video, metadata: {
                        schemaVersion: 1, source: audioTracks.length ? "live-game-canvas-and-game-audio" : "live-game-canvas", mimeType: video.type, ...this.#audio,
                        status: failure ? "partial" : "complete", error: failure ?? null, omittedBytes, releaseConfirmed: released,
                        bytes: video.size, width, height, maxVideoFramesPerSecond: 60,
                        startRequested, startEvent: startEvent ?? null, stopRequested: stopRequested ?? null, stopEvent: stopped,
                        elapsedMs: stopped.monotonicMs - startRequested.monotonicMs,
                        timing: "Original encoded media timestamps; event times use performance.now(). Not emulator frames.",
                        chunks: events, visibility,
                    } });
            };
            // Retain the actual stop-request event separately from encoded media time.
            const requestStop = () => {
                stopRequested ??= safeStamp();
                if (recorder.state !== "inactive")
                    recorder.stop();
            };
            this.#requestStop = requestStop;
            this.#detach = () => {
                stopVisibility();
                recorder.onstop = recorder.ondataavailable = recorder.onerror = recorder.onstart = null;
            };
            this.#release = release;
            this.#recorder = recorder;
            recorder.start(1000);
            this.#setState("recording");
            this.#limitTimer = setTimeout(() => {
                if (this.#recorder !== recorder)
                    return;
                // Classify the existing deadline before the hard stop can emit its final media event.
                try {
                    this.callbacks.beforeLimitStop?.();
                }
                catch {
                    failure ??= "Recording timing could not be confirmed.";
                }
                finally {
                    this.stop();
                }
            }, 600_000);
        }
        catch (error) {
            stopVisibility();
            if (recorder) {
                recorder.onstop = recorder.ondataavailable = recorder.onerror = recorder.onstart = null;
                try {
                    if (recorder.state !== "inactive")
                        recorder.stop();
                }
                catch { /* Release all acquired tracks below. */ }
            }
            release();
            this.#recorder = undefined;
            this.#requestStop = undefined;
            this.#detach = undefined;
            this.#setState("idle");
            this.callbacks.error?.(error instanceof Error ? error.message : "Recording could not start.");
        }
    }
    #requestStop;
    stop() {
        if (this.#state !== "recording")
            return;
        this.#setState("stopping");
        try {
            this.#requestStop?.();
        }
        catch (error) {
            this.#release?.();
            this.callbacks.error?.(error instanceof Error ? error.message : "Recording stop could not be confirmed.");
        }
    }
    dispose() {
        if (this.#state === "disposed")
            return;
        const active = this.#recorder;
        this.#setState("disposed");
        try {
            if (active && active.state !== "inactive")
                this.#requestStop?.();
        }
        finally {
            try {
                this.#release?.();
            }
            finally {
                this.#detach?.();
            }
        }
        this.#recorder = undefined;
        this.#requestStop = undefined;
        this.#detach = undefined;
    }
}
const MAX_SCREENSHOT_BYTES = 8 * 1024 * 1024;
/** Captures the game only; an optional callback saves to the existing local preview server. */
export function mountRecordingControl(canvas, audio, host, identity, saveCapture, lastCapture, onErrorOpen, isVisible = () => true, noticeHost) {
    const doc = host.ownerDocument;
    host.dataset.previewRecording = "";
    const element = (tag, className) => {
        const node = doc.createElement(tag);
        node.className = className;
        return node;
    };
    const action = (label, className = "") => {
        const control = element("button", ("cgv-action " + className).trim());
        control.type = "button";
        control.textContent = label;
        return control;
    };
    const icon = (path, filled = false) => {
        const svg = doc.createElementNS("http://www.w3.org/2000/svg", "svg");
        svg.setAttribute("viewBox", "0 0 24 24");
        svg.setAttribute("aria-hidden", "true");
        svg.setAttribute("focusable", "false");
        const shape = doc.createElementNS("http://www.w3.org/2000/svg", "path");
        shape.setAttribute("d", path);
        if (filled)
            shape.setAttribute("fill", "currentColor");
        svg.append(shape);
        return svg;
    };
    const actions = element("div", "cgv-capture-actions");
    actions.setAttribute("role", "group");
    actions.setAttribute("aria-label", "Game captures");
    const screenshot = action("", "cgv-capture-button cgv-screenshot-button");
    screenshot.dataset.action = "screenshot";
    screenshot.setAttribute("aria-label", "Screenshot");
    screenshot.dataset.tooltip = "Screenshot";
    screenshot.append(icon("M3 7h4l2-3h6l2 3h4v13H3ZM16 13a4 4 0 1 1-8 0 4 4 0 0 1 8 0Z"));
    const button = action("", "cgv-capture-button cgv-record-button");
    button.dataset.action = "record-video";
    button.setAttribute("aria-description", "Keep the preview visible while recording.");
    const recordIcon = element("span", "cgv-record-idle");
    recordIcon.append(icon(ICONS.record));
    const badge = element("span", "cgv-record-indicator");
    badge.setAttribute("aria-hidden", "true");
    const stopIcon = element("span", "cgv-record-stop");
    stopIcon.append(icon("M6 6h12v12H6Z", true));
    const elapsed = element("time", "cgv-record-time");
    elapsed.textContent = "0:00";
    badge.append(stopIcon, elapsed);
    badge.hidden = true;
    button.append(recordIcon, badge);
    actions.append(screenshot, button);
    const toast = element("div", "cgv-capture-toast");
    toast.setAttribute("role", "region");
    toast.setAttribute("aria-label", "Capture result");
    toast.tabIndex = -1;
    toast.hidden = true;
    const toastHeading = element("div", "cgv-toast-heading");
    const status = element("span", "cgv-capture-status");
    status.setAttribute("role", "status");
    status.setAttribute("aria-live", "polite");
    const dismiss = action("", "cgv-toast-dismiss");
    dismiss.setAttribute("aria-label", "Dismiss");
    dismiss.dataset.tooltip = "Dismiss capture notification";
    dismiss.append(icon("M6 6l12 12M18 6 6 18"));
    toastHeading.append(status, dismiss);
    const detail = element("p", "cgv-toast-detail");
    const links = element("div", "cgv-toast-actions");
    const copyInput = element("input", "cgv-copy-path");
    copyInput.readOnly = true;
    copyInput.setAttribute("aria-label", "Saved capture path");
    copyInput.hidden = true;
    toast.append(toastHeading, detail, links, copyInput);
    host.append(actions);
    (noticeHost ?? host).append(toast);
    const errorDialog = mountPreviewErrorDialog(host, identity, onErrorOpen);
    let viewId = "";
    let captureOrigin;
    let disposed = false;
    let takingScreenshot = false;
    let pending;
    let latest;
    let toastMode = "message";
    let hovered = false;
    let dismissTimer;
    const clearDismissTimer = () => {
        if (dismissTimer !== undefined)
            clearTimeout(dismissTimer);
        dismissTimer = undefined;
    };
    const hideToast = (restoreFocus = false) => {
        if (pending)
            return;
        clearDismissTimer();
        const hadFocus = toast.contains(doc.activeElement);
        toast.hidden = true;
        hovered = false;
        if (restoreFocus && hadFocus && !button.disabled)
            button.focus({ preventScroll: true });
    };
    const scheduleDismiss = () => {
        clearDismissTimer();
        if (disposed || toast.hidden || toastMode !== "saved" || pending || hovered || toast.contains(doc.activeElement))
            return;
        dismissTimer = setTimeout(() => {
            dismissTimer = undefined;
            if (!disposed && toastMode === "saved" && !pending && !hovered && !toast.contains(doc.activeElement))
                hideToast();
        }, 8000);
    };
    const showToast = (mode, message, description, canDismiss) => {
        clearDismissTimer();
        toastMode = mode;
        toast.dataset.state = mode;
        status.textContent = message;
        detail.textContent = description;
        detail.title = "";
        detail.hidden = !description;
        dismiss.hidden = !canDismiss;
        copyInput.hidden = true;
        copyInput.value = "";
        links.replaceChildren();
        toast.hidden = false;
    };
    const refreshElapsed = () => {
        const seconds = Math.max(0, Math.floor(recording.elapsedMs / 1000));
        elapsed.textContent = Math.floor(seconds / 60) + ":" + String(seconds % 60).padStart(2, "0");
        elapsed.dateTime = "PT" + seconds + "S";
        const description = "Elapsed time " + elapsed.textContent + (recording.audioState.audioIncluded === false ? ". Video only; game audio is unavailable." : "") + ". Keep the preview visible while recording.";
        if (button.getAttribute("aria-description") !== description)
            button.setAttribute("aria-description", description);
    };
    const refreshControls = (state = recording.state) => {
        const active = state === "recording";
        const label = active ? "Stop recording" : state === "stopping" ? "Finishing recording" : "Record video";
        button.setAttribute("aria-label", label);
        button.dataset.tooltip = label + ". Keep the preview visible while recording.";
        button.dataset.recording = String(active || state === "stopping");
        button.disabled = disposed || (controller.blocked && !active) || state === "disposed" || state === "stopping" || (!active && (takingScreenshot || !!pending));
        button.setAttribute("aria-pressed", String(active));
        screenshot.disabled = disposed || controller.blocked || state !== "idle" || takingScreenshot || !!pending;
        screenshot.setAttribute("aria-label", takingScreenshot ? "Capturing screenshot" : "Screenshot");
        screenshot.dataset.tooltip = takingScreenshot ? "Capturing screenshot" : "Screenshot";
        badge.hidden = !active && state !== "stopping";
        recordIcon.hidden = !badge.hidden;
        if (badge.hidden)
            button.setAttribute("aria-description", "Keep the preview visible while recording.");
        if (lastCapture)
            lastCapture.disabled = disposed || !latest || !!pending || takingScreenshot;
        if (!badge.hidden)
            refreshElapsed();
    };
    const releaseLocalUrls = (capture) => {
        if (capture.mediaUrl)
            URL.revokeObjectURL(capture.mediaUrl);
        if (capture.metadataUrl)
            URL.revokeObjectURL(capture.metadataUrl);
        capture.mediaUrl = capture.metadataUrl = undefined;
    };
    const link = (label, url, filename) => {
        const anchor = element("a", "cgv-capture-link");
        anchor.href = url;
        anchor.textContent = label;
        if (filename)
            anchor.download = filename;
        // The containing Codex built-in browser manages this link; never invoke an OS opener.
        else {
            anchor.target = "_blank";
            anchor.rel = "noopener";
        }
        return anchor;
    };
    const basename = (path) => path.split(/[\\/]/).pop() || "capture";
    const showSaved = () => {
        if (!latest || pending || disposed)
            return;
        const entry = latest;
        showToast("saved", "Saved", entry.partial ? "Partial video saved. Playback may be incomplete."
            : entry.file.kind === "screenshot" ? "Screenshot saved locally." : "Video saved locally.", true);
        let downloadUrl = entry.file.url;
        if (entry.file.kind === "video") {
            const url = new URL(downloadUrl, doc.baseURI);
            url.searchParams.set("download", "1");
            downloadUrl = url.href;
        }
        else
            links.append(link("Open", entry.file.url));
        links.append(link("Download", downloadUrl, basename(entry.file.path)), link(entry.file.kind === "video" ? "Timing" : "Details", entry.file.metadataUrl, basename(entry.file.metadataPath)));
        const copyPath = action("Copy path", "cgv-toast-action");
        copyPath.addEventListener("click", () => {
            const stillCurrent = () => !disposed && latest === entry && !pending && toastMode === "saved";
            const revealPath = () => {
                if (!stillCurrent())
                    return;
                copyInput.value = entry.file.path;
                copyInput.hidden = false;
                copyInput.focus({ preventScroll: true });
                copyInput.select();
                detail.textContent = "Copy the selected path.";
            };
            try {
                const clipboard = doc.defaultView?.navigator.clipboard;
                if (!clipboard) {
                    revealPath();
                    return;
                }
                void clipboard.writeText(entry.file.path).then(() => {
                    if (stillCurrent())
                        detail.textContent = "Path copied.";
                }, revealPath);
            }
            catch {
                revealPath();
            }
        });
        links.append(copyPath);
        scheduleDismiss();
    };
    const renderPending = () => {
        if (!pending || disposed)
            return;
        const capture = pending;
        try {
            capture.mediaUrl ??= URL.createObjectURL(capture.media);
        }
        catch { /* Keep the original Blob. */ }
        try {
            capture.metadataUrl ??= URL.createObjectURL(new Blob([JSON.stringify(capture.metadata, null, 2) + "\n"], { type: "application/json" }));
        }
        catch { /* A metadata download failure must not discard the media. */ }
        const partial = capture.metadata.status === "partial";
        showToast("pending", capture.saving ? "Saving…" : "Save not confirmed", (partial ? "Partial recording. " : "") + (capture.saving ? "Saving to this project on your device."
            : "Download this copy before leaving."), false);
        if (capture.mediaUrl) {
            if (capture.kind === "screenshot")
                links.append(link("Open", capture.mediaUrl));
            links.append(link("Download", capture.mediaUrl, capture.filename));
        }
        if (capture.metadataUrl)
            links.append(link(capture.kind === "video" ? "Timing" : "Details", capture.metadataUrl, capture.metadataFilename));
        if (!capture.mediaUrl || !capture.metadataUrl) {
            const prepare = action("Prepare downloads", "cgv-toast-action");
            prepare.addEventListener("click", () => { if (!disposed && pending === capture)
                renderPending(); });
            links.append(prepare);
        }
        const discard = action("Discard unsaved", "cgv-toast-action");
        discard.disabled = capture.saving || !!capture.managed;
        discard.addEventListener("click", () => {
            if (disposed || pending !== capture || capture.saving || capture.managed)
                return;
            releaseLocalUrls(capture);
            pending = undefined;
            refreshControls();
            hideToast(true);
        });
        links.append(discard);
    };
    const localAssetUrl = (value) => {
        if (typeof value !== "string" || !value || /^[a-z][a-z\d+.-]*:/i.test(value) || value.startsWith("//"))
            throw new Error("The saved file URL is not local to this preview.");
        const base = new URL(doc.baseURI);
        const url = new URL(value, base);
        if (url.origin !== base.origin || url.username || url.password)
            throw new Error("The saved file URL is not local to this preview.");
        return url.href;
    };
    const acceptCapture = async (capture) => {
        if (disposed)
            return;
        const stem = (capture.kind === "screenshot" ? "screenshot" : "gameplay") + "-" + new Date().toISOString().replace(/[:.]/g, "-");
        const extension = capture.kind === "screenshot" ? "png" : capture.media.type.startsWith("video/mp4") ? "mp4" : "webm";
        const result = { ...capture, origin: capture.origin ?? { viewId, generation: controller.captureGeneration() }, metadata: { ...capture.metadata, game: { ...identity } },
            filename: stem + "." + extension, metadataFilename: stem + "-" + (capture.kind === "video" ? "timing" : "details") + ".json", saving: !!saveCapture };
        pending = result;
        renderPending();
        refreshControls();
        if (!saveCapture)
            return;
        try {
            // One save attempt only. An uncertain reply keeps the original browser copy.
            const file = await saveCapture({ kind: result.kind, media: result.media, metadata: result.metadata, origin: result.origin, ...(result.managed ? { managed: result.managed } : {}) });
            if (disposed || pending !== result)
                return;
            if (file.kind !== result.kind || file.bytes !== result.media.size || typeof file.path !== "string" || !file.path
                || typeof file.metadataPath !== "string" || !file.metadataPath)
                throw new Error("The local save response did not confirm this capture.");
            if (result.managed)
                controller.saved(parseRecordingSaved(file));
            latest = { file: { ...file, url: localAssetUrl(file.url), metadataUrl: localAssetUrl(file.metadataUrl) },
                partial: result.metadata.status === "partial" };
            pending = undefined;
            try {
                showSaved();
            }
            finally {
                releaseLocalUrls(result);
            }
        }
        catch (error) {
            if (disposed || pending !== result)
                return;
            result.saving = false;
            renderPending();
            errorDialog.show("capture-save", error instanceof Error ? error.message : "The local save did not finish.");
        }
        finally {
            if (!disposed)
                refreshControls();
        }
    };
    const recording = new CanvasRecording(canvas, audio, {
        beforeLimitStop: () => controller.tick(),
        state(state) { refreshControls(state); },
        error(message, diagnostics) {
            if (recording.state === "idle")
                controller.failed(message, recording.releaseConfirmed, diagnostics);
            if (!disposed)
                errorDialog.show(diagnostics?.bytes === 0 ? "recording-empty" : "recording", diagnostics ? `${message}\n${JSON.stringify(diagnostics, null, 2)}` : message, diagnostics);
        },
        complete({ video, metadata }) {
            const owner = controller.ownership();
            if (owner)
                controller.captured(metadata.releaseConfirmed === true);
            void acceptCapture({ kind: "video", media: video, origin: captureOrigin, metadata: controller.metadata(metadata), ...(owner ? { managed: { ...owner, viewId } } : {}) });
        },
    });
    const controller = new RecordingController({
        busy: () => recording.state !== "idle" || takingScreenshot || !!pending,
        visible: () => doc.visibilityState === "visible" && isVisible(),
        available: () => !disposed && isVisible() && !!saveCapture && typeof MediaRecorder !== "undefined" && typeof canvas.captureStream === "function",
        start: () => { captureOrigin = { viewId, generation: controller.captureGeneration() }; hideToast(); recording.start(); return recording.state === "recording"; },
        stop: () => recording.stop(),
        acceptSaved: (file) => {
            const capture = pending;
            if (!capture || !capture.managed || file.bytes !== capture.media.size || file.mimeType !== capture.media.type)
                return false;
            if (file.url !== `./codex-captures/${file.id}/media` || file.metadataUrl !== `./codex-captures/${file.id}/metadata`)
                return false;
            latest = { file: { ...file, url: localAssetUrl(file.url), metadataUrl: localAssetUrl(file.metadataUrl) }, partial: capture.metadata.status === "partial" };
            pending = undefined;
            try {
                showSaved();
            }
            finally {
                releaseLocalUrls(capture);
            }
            return true;
        },
        changed: () => refreshControls(),
        error: message => { if (!disposed)
            errorDialog.show("recording", message); },
    });
    const recordClick = () => {
        if (!isVisible())
            return;
        if (recording.state === "recording") {
            controller.stop("Stopped by the user.");
            recording.stop();
        }
        else if (!disposed && !takingScreenshot && !pending && recording.state === "idle" && controller.humanStart()) {
            captureOrigin = { viewId, generation: controller.captureGeneration() };
            hideToast();
            recording.start();
        }
    };
    const screenshotClick = async () => {
        if (!isVisible() || disposed || takingScreenshot || pending || recording.state !== "idle" || !controller.humanStart())
            return;
        hideToast();
        takingScreenshot = true;
        refreshControls();
        try {
            const width = canvas.width, height = canvas.height;
            if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1 || width > 4096 || height > 4096 || width * height > 4_194_304) {
                throw new Error("Screenshot dimensions exceed the supported limit.");
            }
            if (typeof canvas.toBlob !== "function")
                throw new Error("Screenshots are unavailable in this browser.");
            const captureRequested = stamp();
            const media = await new Promise((resolve, reject) => canvas.toBlob(blob => {
                if (blob)
                    resolve(blob);
                else
                    reject(new Error("The game screenshot could not be captured."));
            }, "image/png"));
            if (disposed)
                return;
            if (!media.size || media.size > MAX_SCREENSHOT_BYTES || media.type !== "image/png")
                throw new Error("The screenshot must be a PNG no larger than 8 MiB.");
            await acceptCapture({ kind: "screenshot", media, metadata: {
                    schemaVersion: 1, source: "live-game-canvas", status: "complete", mimeType: media.type,
                    bytes: media.size, width, height, captureRequested, encodedAt: stamp(),
                } });
        }
        catch (error) {
            if (!disposed)
                errorDialog.show("screenshot", error instanceof Error ? error.message : "The screenshot could not be captured.");
        }
        finally {
            takingScreenshot = false;
            if (!disposed)
                refreshControls();
        }
    };
    const openLast = () => {
        if (disposed || pending || !latest)
            return;
        showSaved();
        links.querySelector("a")?.focus({ preventScroll: true });
    };
    const dismissClick = () => hideToast(true);
    const entered = () => { hovered = true; clearDismissTimer(); };
    const left = () => { hovered = false; scheduleDismiss(); };
    const focused = () => clearDismissTimer();
    const blurred = () => queueMicrotask(() => { if (!disposed)
        scheduleDismiss(); });
    button.addEventListener("click", recordClick);
    screenshot.addEventListener("click", screenshotClick);
    dismiss.addEventListener("click", dismissClick);
    lastCapture?.addEventListener("click", openLast);
    toast.addEventListener("pointerenter", entered);
    toast.addEventListener("pointerleave", left);
    toast.addEventListener("focusin", focused);
    toast.addEventListener("focusout", blurred);
    const timer = setInterval(() => { if (recording.state === "recording" || recording.state === "stopping")
        refreshElapsed(); }, 250);
    const beforeUnload = (event) => {
        if (recording.state === "recording" || recording.state === "stopping" || takingScreenshot || pending) {
            event.preventDefault();
            event.returnValue = "";
        }
    };
    doc.defaultView?.addEventListener("beforeunload", beforeUnload);
    refreshControls();
    return { recordingState: () => {
            const state = controller.state(), owner = controller.ownership();
            return { ...state, ...(owner && state.owner === "mcp" && captureOrigin?.generation === owner.generation ? recording.audioState : {}) };
        }, recordingReply: reply => controller.apply(reply), bindView: id => { viewId = id; }, beginRetirement: () => controller.beginRetirement(), dispose() {
            if (disposed)
                return;
            disposed = true;
            clearInterval(timer);
            clearDismissTimer();
            controller.dispose();
            recording.dispose();
            errorDialog.dispose();
            if (pending)
                releaseLocalUrls(pending);
            pending = undefined;
            latest = undefined;
            button.removeEventListener("click", recordClick);
            screenshot.removeEventListener("click", screenshotClick);
            dismiss.removeEventListener("click", dismissClick);
            lastCapture?.removeEventListener("click", openLast);
            if (lastCapture)
                lastCapture.disabled = true;
            toast.removeEventListener("pointerenter", entered);
            toast.removeEventListener("pointerleave", left);
            toast.removeEventListener("focusin", focused);
            toast.removeEventListener("focusout", blurred);
            toast.remove();
            doc.defaultView?.removeEventListener("beforeunload", beforeUnload);
            host.replaceChildren();
        } };
}
//# sourceMappingURL=recording.js.map

SHA-256: 090c9833f1503892214b64899ba729560f76c8814eda0677ecdc34814844ea9b