diff --git a/src/client/components/GameRecapViewer.ts b/src/client/components/GameRecapViewer.ts new file mode 100644 index 0000000000..c39d0655c9 --- /dev/null +++ b/src/client/components/GameRecapViewer.ts @@ -0,0 +1,263 @@ +import { html, LitElement } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; +import { + RecapFrame, + RecapFrameStore, +} from "../graphics/recapCapture/RecapFrameStore"; + +@customElement("game-recap-viewer") +export class GameRecapViewer extends LitElement { + @property({ attribute: false }) + frameStore: RecapFrameStore | null = null; + + @property({ type: Boolean }) + autoplay: boolean = true; + + @state() + private frames: readonly RecapFrame[] = []; + + @state() + private currentIndex = 0; + + @state() + private isPlaying = false; + + private unsubscribe: (() => void) | null = null; + private playbackHandle: number | null = null; + private lastFrameTime = 0; + private canvas: HTMLCanvasElement | null = null; + private readonly frameIntervalMs = 60; + private loopPauseMs = 0; + private loopHoldActive = false; + private loopHoldConsumed = false; + private loopHoldStart = 0; + + createRenderRoot() { + return this; + } + + connectedCallback() { + super.connectedCallback(); + this.attachStore(); + } + + disconnectedCallback() { + super.disconnectedCallback(); + this.detachStore(); + this.stopPlayback(); + } + + updated(changed: Map) { + if (changed.has("frameStore")) { + this.attachStore(); + } + if (changed.has("frames")) { + this.onFramesUpdated(); + } + } + + render() { + const hasFrames = this.frames.length > 0; + const firstFrame = this.frames[0]; + const aspect = firstFrame + ? `${firstFrame.width} / ${firstFrame.height}` + : "16 / 9"; + const containerStyle = `width: 100%; aspect-ratio: ${aspect}; max-height: min(60vh, 420px);`; + const showDownloadButton = hasFrames; + return html` +
+ + ${showDownloadButton + ? html`` + : null} + ${!hasFrames + ? html`
+ ${"Recap capture warming up"} +
` + : null} +
+ `; + } + + private attachStore() { + this.detachStore(); + if (!this.frameStore) { + return; + } + this.unsubscribe = this.frameStore.subscribe((frames) => { + this.loopPauseMs = this.frameStore?.getLoopPauseMs() ?? 0; + this.frames = [...frames]; + }); + } + + private detachStore() { + this.unsubscribe?.(); + this.unsubscribe = null; + this.loopPauseMs = 0; + } + + private onFramesUpdated() { + if (this.frames.length === 0) { + this.currentIndex = 0; + this.stopPlayback(); + this.paintFrame(); + return; + } + + if (this.currentIndex >= this.frames.length) { + this.currentIndex = Math.max(0, this.frames.length - 1); + } + + const nextPause = this.frameStore?.getLoopPauseMs() ?? this.loopPauseMs; + if (nextPause !== this.loopPauseMs) { + this.loopPauseMs = nextPause; + this.loopHoldConsumed = nextPause <= 0 ? true : false; + if (nextPause <= 0) { + this.loopHoldActive = false; + } + } + + this.paintFrame(); + + if (this.autoplay && !this.isPlaying) { + this.startPlayback(); + } + } + + private startPlayback() { + if (this.frames.length === 0) { + return; + } + this.isPlaying = true; + this.lastFrameTime = performance.now(); + this.loopHoldActive = false; + this.loopHoldConsumed = this.loopPauseMs <= 0; + this.playbackHandle = requestAnimationFrame((time) => + this.advanceLoop(time), + ); + } + + private stopPlayback() { + if (this.playbackHandle !== null) { + cancelAnimationFrame(this.playbackHandle); + this.playbackHandle = null; + } + this.isPlaying = false; + } + + private advanceLoop(time: number) { + if (!this.isPlaying || this.frames.length === 0) { + return; + } + + if (this.loopHoldActive) { + if (time - this.loopHoldStart < this.loopPauseMs) { + this.playbackHandle = requestAnimationFrame((nextTime) => + this.advanceLoop(nextTime), + ); + return; + } + this.loopHoldActive = false; + this.lastFrameTime = time - this.frameIntervalMs; + } + + if (time - this.lastFrameTime >= this.frameIntervalMs) { + const atLastFrame = this.currentIndex === this.frames.length - 1; + if ( + atLastFrame && + this.loopPauseMs > 0 && + !this.loopHoldActive && + !this.loopHoldConsumed + ) { + this.loopHoldActive = true; + this.loopHoldConsumed = true; + this.loopHoldStart = time; + this.playbackHandle = requestAnimationFrame((nextTime) => + this.advanceLoop(nextTime), + ); + return; + } + + const wrapped = atLastFrame; + this.stepFrame(1); + this.lastFrameTime = time; + if (wrapped) { + this.handleLoopCompleted(); + } + } + this.playbackHandle = requestAnimationFrame((nextTime) => + this.advanceLoop(nextTime), + ); + } + + private stepFrame(delta: number) { + if (this.frames.length === 0) { + return; + } + const nextIndex = + (this.currentIndex + delta + this.frames.length) % this.frames.length; + this.currentIndex = nextIndex; + this.paintFrame(); + } + + private paintFrame() { + this.canvas = + this.canvas ?? (this.querySelector("canvas") as HTMLCanvasElement | null); + if (!this.canvas) { + return; + } + const context = this.canvas.getContext("2d"); + if (!context) { + return; + } + + if (this.frames.length === 0) { + context.clearRect(0, 0, this.canvas.width, this.canvas.height); + return; + } + + const frame = this.frames[this.currentIndex]; + if ( + this.canvas.width !== frame.width || + this.canvas.height !== frame.height + ) { + this.canvas.width = frame.width; + this.canvas.height = frame.height; + } + this.canvas.style.width = "100%"; + this.canvas.style.height = "100%"; + context.clearRect(0, 0, this.canvas.width, this.canvas.height); + + if (frame.imageBitmap) { + context.drawImage(frame.imageBitmap, 0, 0, frame.width, frame.height); + return; + } + + const image = new Image(); + image.onload = () => { + context.drawImage(image, 0, 0, frame.width, frame.height); + }; + image.src = frame.objectUrl; + } + + private handleLoopCompleted() { + this.loopHoldConsumed = this.loopPauseMs <= 0; + } + + private downloadAutoRecording = () => { + this.dispatchEvent( + new CustomEvent("recap-request-export", { bubbles: true }), + ); + }; +} diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts index 8a3c1576d0..6aadedc50f 100644 --- a/src/client/graphics/GameRenderer.ts +++ b/src/client/graphics/GameRenderer.ts @@ -39,6 +39,7 @@ import { UILayer } from "./layers/UILayer"; import { UnitDisplay } from "./layers/UnitDisplay"; import { UnitLayer } from "./layers/UnitLayer"; import { WinModal } from "./layers/WinModal"; +import { GameRecapCapture } from "./recapCapture/GameRecapCapture"; export function createRenderer( canvas: HTMLCanvasElement, @@ -274,6 +275,23 @@ export function createRenderer( fpsDisplay, ]; + const captureLayers = layers.filter( + (layer) => + layer instanceof TerrainLayer || + layer instanceof TerritoryLayer || + layer instanceof RailroadLayer || + layer instanceof StructureIconsLayer || + layer instanceof UnitLayer, + ); + + const recapCapture = new GameRecapCapture( + game, + transformHandler, + captureLayers, + ); + + winModal.attachRecapCapture(recapCapture); + return new GameRenderer( game, eventBus, @@ -282,6 +300,7 @@ export function createRenderer( uiState, layers, fpsDisplay, + recapCapture, ); } @@ -296,6 +315,7 @@ export class GameRenderer { public uiState: UIState, private layers: Layer[], private fpsDisplay: FPSDisplay, + private recapCapture?: GameRecapCapture, ) { const context = canvas.getContext("2d"); if (context === null) throw new Error("2d context not supported"); @@ -305,6 +325,7 @@ export class GameRenderer { initialize() { this.eventBus.on(RedrawGraphicsEvent, () => this.redraw()); this.layers.forEach((l) => l.init?.()); + this.recapCapture?.start(); document.body.appendChild(this.canvas); window.addEventListener("resize", () => this.resizeCanvas()); @@ -327,6 +348,7 @@ export class GameRenderer { this.canvas.width = window.innerWidth; this.canvas.height = window.innerHeight; this.transformHandler.updateCanvasBoundingRect(); + this.recapCapture?.onViewportResize(); //this.redraw() } @@ -340,6 +362,10 @@ export class GameRenderer { renderGame() { const start = performance.now(); + if (this.recapCapture?.isCapturing()) { + requestAnimationFrame(() => this.renderGame()); + return; + } // Set background this.context.fillStyle = this.game .config() @@ -390,6 +416,7 @@ export class GameRenderer { tick() { this.layers.forEach((l) => l.tick?.()); + this.recapCapture?.tick(); } resize(width: number, height: number): void { diff --git a/src/client/graphics/layers/WinModal.ts b/src/client/graphics/layers/WinModal.ts index 3e042afe1e..ee7018e0bc 100644 --- a/src/client/graphics/layers/WinModal.ts +++ b/src/client/graphics/layers/WinModal.ts @@ -5,6 +5,7 @@ import { ColorPalette, Pattern } from "../../../core/CosmeticSchemas"; import { EventBus } from "../../../core/EventBus"; import { GameUpdateType } from "../../../core/game/GameUpdates"; import { GameView } from "../../../core/game/GameView"; +import "../../components/GameRecapViewer"; import "../../components/PatternButton"; import { fetchCosmetics, @@ -13,6 +14,7 @@ import { } from "../../Cosmetics"; import { getUserMe } from "../../jwt"; import { SendWinnerEvent } from "../../Transport"; +import { GameRecapCapture } from "../recapCapture/GameRecapCapture"; import { Layer } from "./Layer"; @customElement("win-modal") @@ -34,10 +36,15 @@ export class WinModal extends LitElement implements Layer { @state() private patternContent: TemplateResult | null = null; + @state() + private exportingRecap = false; + private _title: string; private rand = Math.random(); + private recapCapture: GameRecapCapture | null = null; + // Override to prevent shadow DOM creation createRenderRoot() { return this; @@ -47,6 +54,18 @@ export class WinModal extends LitElement implements Layer { super(); } + disconnectedCallback(): void { + super.disconnectedCallback(); + } + + attachRecapCapture(recapCapture: GameRecapCapture) { + if (this.recapCapture === recapCapture) { + return; + } + this.recapCapture = recapCapture; + this.requestUpdate(); + } + render() { return html`
${this._title || ""} - ${this.innerHtml()} + ${this.innerHtml()} ${this.renderRecapSection()}
+ +
+ `; + } + + private get canExportRecap(): boolean { + return ( + typeof MediaRecorder !== "undefined" && + typeof HTMLCanvasElement !== "undefined" && + "captureStream" in HTMLCanvasElement.prototype + ); + } + + private async onExportRecapWebM() { + if (!this.recapCapture || this.exportingRecap) { + return; + } + if (!this.canExportRecap) { + console.warn( + "Recap export requested but MediaRecorder support is unavailable", + ); + return; + } + this.exportingRecap = true; + console.info("[RecapCapture] Manual export requested"); + try { + const { blob, filename } = await this.recapCapture.exportAsWebM(); + if (typeof document === "undefined") { + console.warn("Recap export is unavailable in this environment"); + return; + } + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = filename; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + URL.revokeObjectURL(url); + console.info("[RecapCapture] Manual export completed", { + sizeBytes: blob.size, + filename, + }); + } catch (error) { + console.error("Failed to export recap capture", error); + } finally { + this.exportingRecap = false; + } + } + renderPatternButton() { return html`
@@ -191,6 +271,7 @@ export class WinModal extends LitElement implements Layer { } async show() { + this.recapCapture?.stopCapturing(); await this.loadPatternContent(); this.isVisible = true; this.requestUpdate(); diff --git a/src/client/graphics/recapCapture/GameRecapCapture.ts b/src/client/graphics/recapCapture/GameRecapCapture.ts new file mode 100644 index 0000000000..e45140d07b --- /dev/null +++ b/src/client/graphics/recapCapture/GameRecapCapture.ts @@ -0,0 +1,436 @@ +import { GameView } from "../../../core/game/GameView"; +import { TransformHandler } from "../TransformHandler"; +import { Layer } from "../layers/Layer"; +import { + defaultRecapCaptureConfig, + RecapCaptureConfig, +} from "./RecapCaptureConfig"; +import { RecapCaptureSurface } from "./RecapCaptureSurface"; +import { RecapFrame, RecapFrameStore } from "./RecapFrameStore"; + +interface TransformSnapshot { + scale: number; + offsetX: number; + offsetY: number; +} + +export interface RecapCaptureStats { + frameCount: number; + approximateDurationMs: number; + lastTickCaptured: number | null; +} + +export class GameRecapCapture { + private readonly config: RecapCaptureConfig; + private readonly surface: RecapCaptureSurface; + private readonly frameStore: RecapFrameStore; + private lastCaptureTick: number | null = null; + private captureInProgress = false; + private viewport: { width: number; height: number } | null = null; + private stopped = false; + private resolvedMimeType: string | null = null; + private pendingFinalCapture = false; + private memoryUsageLogged = false; + + constructor( + private readonly game: GameView, + private readonly transformHandler: TransformHandler, + private readonly layers: Layer[], + config?: Partial, + ) { + this.config = { ...defaultRecapCaptureConfig, ...config }; + this.surface = new RecapCaptureSurface(); + this.frameStore = new RecapFrameStore(this.config.maxFrames); + } + + start() { + this.stopped = false; + this.resolvedMimeType = null; + this.lastCaptureTick = null; + this.frameStore.setLoopPauseMs(0); + this.pendingFinalCapture = false; + this.memoryUsageLogged = false; + this.refreshViewportSize(); + this.frameStore.clear(); + } + + dispose() { + this.stopped = true; + this.pendingFinalCapture = false; + this.surface.dispose(); + this.frameStore.clear(); + this.memoryUsageLogged = false; + } + + onViewportResize() { + this.refreshViewportSize(); + } + + getFrameStore(): RecapFrameStore { + return this.frameStore; + } + + getStats(): RecapCaptureStats { + return { + frameCount: this.frameStore.getFrameCount(), + approximateDurationMs: this.frameStore.approximateDurationMs(), + lastTickCaptured: this.lastCaptureTick, + }; + } + + tick() { + if (this.stopped) { + return; + } + if (!this.viewport) { + this.refreshViewportSize(); + } + + const tick = this.game.ticks(); + if (this.lastCaptureTick !== null && tick <= this.lastCaptureTick) { + return; + } + + if (tick % this.config.captureEveryNTicks !== 0) { + return; + } + + if (this.captureInProgress) { + return; + } + this.queueCapture(tick); + } + + isCapturing(): boolean { + return this.captureInProgress; + } + + stopCapturing(): void { + if (this.stopped) { + return; + } + this.stopped = true; + this.frameStore.setLoopPauseMs(this.config.loopTailHoldMs); + if (this.captureInProgress) { + this.pendingFinalCapture = true; + } else { + this.queueCapture(this.game.ticks()); + } + } + + async exportAsWebM(targetFps: number = this.config.exportFps): Promise<{ + blob: Blob; + filename: string; + }> { + const fpsInput = Number.isFinite(targetFps) + ? targetFps + : this.config.exportFps; + const fps = Math.min(60, Math.max(1, Math.round(fpsInput))); + const frames = this.frameStore.getFrames(); + if (frames.length === 0) { + throw new Error("No recap frames available for export"); + } + + const mimeType = this.resolveExportMimeType(); + if (!mimeType) { + throw new Error("No supported MediaRecorder mime type for WebM export"); + } + + const canvas = document.createElement("canvas"); + canvas.width = frames[0].width; + canvas.height = frames[0].height; + const context = canvas.getContext("2d"); + if (!context) { + throw new Error("Unable to acquire 2D context for export"); + } + + if ( + typeof canvas.captureStream !== "function" || + typeof MediaRecorder === "undefined" + ) { + throw new Error( + "MediaRecorder canvas captureStream is not supported in this environment", + ); + } + + const stream = canvas.captureStream(fps); + const recorderOptions: MediaRecorderOptions = { mimeType }; + if (this.config.exportVideoBitsPerSecond) { + recorderOptions.videoBitsPerSecond = this.config.exportVideoBitsPerSecond; + } + const recorder = new MediaRecorder(stream, recorderOptions); + const chunks: BlobPart[] = []; + recorder.addEventListener("dataavailable", (event) => { + if (event.data && event.data.size > 0) { + chunks.push(event.data); + } + }); + + const stopPromise = new Promise((resolve, reject) => { + const handleError = (event: Event) => { + recorder.removeEventListener("error", handleError); + recorder.removeEventListener("stop", handleStop); + const error = (event as { error?: DOMException }).error; + reject(error ?? new Error("MediaRecorder error")); + }; + const handleStop = () => { + recorder.removeEventListener("error", handleError); + recorder.removeEventListener("stop", handleStop); + resolve(new Blob(chunks, { type: mimeType })); + }; + recorder.addEventListener("error", handleError); + recorder.addEventListener("stop", handleStop); + }); + + const startPromise = new Promise((resolve) => { + const handleStart = () => { + recorder.removeEventListener("start", handleStart); + resolve(); + }; + recorder.addEventListener("start", handleStart); + }); + + const drawFrame = async (frame: RecapFrame) => { + if (!frame.imageBitmap && typeof createImageBitmap === "function") { + try { + frame.imageBitmap = await createImageBitmap(frame.blob); + } catch (error) { + console.warn("Failed to create ImageBitmap for export frame", error); + } + } + + context.clearRect(0, 0, canvas.width, canvas.height); + if (frame.imageBitmap) { + context.drawImage(frame.imageBitmap, 0, 0, canvas.width, canvas.height); + } else { + const image = await this.blobToImage(frame.blob); + context.drawImage(image, 0, 0, canvas.width, canvas.height); + } + }; + + const framesToEncode = + frames.length === 1 ? [...frames, frames[0]] : [...frames]; + const frameInterval = Math.max(1000 / fps, 16); + + await drawFrame(framesToEncode[0]); + + recorder.start(); + await startPromise; + + const canvasTrack = stream.getVideoTracks()[0] as + | (MediaStreamTrack & { requestFrame?: () => void }) + | undefined; + + canvasTrack?.requestFrame?.(); + await this.wait(frameInterval); + + for (let index = 1; index < framesToEncode.length; index += 1) { + const frame = framesToEncode[index]; + await drawFrame(frame); + canvasTrack?.requestFrame?.(); + await this.wait(frameInterval); + } + + if (typeof recorder.requestData === "function") { + try { + recorder.requestData(); + } catch (error) { + console.warn("MediaRecorder requestData failed", error); + } + } + + await this.wait(frameInterval); + recorder.stop(); + stream.getTracks().forEach((track) => track.stop()); + + const blob = await stopPromise; + if (blob.size < 2048) { + throw new Error("Recap export produced an unexpectedly small recording"); + } + const filename = `openfront-recap-${Date.now()}.webm`; + return { blob, filename }; + } + + private async performCapture(tick: number) { + this.refreshViewportSize(); + if (!this.viewport) { + return; + } + + const handlerInternals = this + .transformHandler as unknown as TransformSnapshot & { + _boundingRect?: DOMRect; + centerAll(fit?: number): void; + override(x?: number, y?: number, scale?: number): void; + }; + + const originalTransform = this.snapshotTransform(); + const originalBoundingRect = handlerInternals._boundingRect; + + const captureRect = + typeof DOMRect === "function" + ? new DOMRect(0, 0, this.viewport.width, this.viewport.height) + : ({ + x: 0, + y: 0, + width: this.viewport.width, + height: this.viewport.height, + top: 0, + right: this.viewport.width, + bottom: this.viewport.height, + left: 0, + } as DOMRect); + handlerInternals._boundingRect = captureRect; + this.transformHandler.centerAll(1); + + try { + const result = await this.surface.capture({ + layers: this.layers, + game: this.game, + transformHandler: this.transformHandler, + viewport: this.viewport, + backgroundColor: null, + mimeType: this.config.imageMimeType, + imageQuality: this.config.imageQuality, + }); + + if (!result.imageBitmap && typeof createImageBitmap === "function") { + try { + result.imageBitmap = await createImageBitmap(result.blob); + } catch (error) { + console.warn( + "Failed to create ImageBitmap for captured frame", + error, + ); + } + } + + this.frameStore.addFrame({ + tick, + capturedAt: Date.now(), + blob: result.blob, + width: result.width, + height: result.height, + imageBitmap: result.imageBitmap, + }); + if (!this.memoryUsageLogged) { + const bytes = this.frameStore.getApproximateBlobBytes(); + const mebibytes = bytes / (1024 * 1024); + console.info("[RecapCapture] First frame stored", { + frames: this.frameStore.getFrameCount(), + resolution: `${result.width}x${result.height}`, + approxBlobMiB: Number(mebibytes.toFixed(2)), + }); + this.memoryUsageLogged = true; + } + this.lastCaptureTick = tick; + } catch (error) { + console.error("GameRecapCapture failed to capture frame", error); + } finally { + if (originalBoundingRect) { + handlerInternals._boundingRect = originalBoundingRect; + } else { + delete handlerInternals._boundingRect; + } + handlerInternals.override( + originalTransform.offsetX, + originalTransform.offsetY, + originalTransform.scale, + ); + } + } + + private queueCapture(tick: number) { + this.captureInProgress = true; + const runCapture = () => { + void this.performCapture(tick).finally(() => { + this.captureInProgress = false; + if (this.pendingFinalCapture) { + this.pendingFinalCapture = false; + this.queueCapture(this.game.ticks()); + } + }); + }; + + if (typeof requestAnimationFrame === "function") { + requestAnimationFrame(() => runCapture()); + } else { + setTimeout(runCapture, 0); + } + } + + private snapshotTransform(): TransformSnapshot { + const handler = this.transformHandler as unknown as TransformSnapshot; + return { + scale: handler.scale, + offsetX: handler.offsetX, + offsetY: handler.offsetY, + }; + } + + private async blobToImage(blob: Blob): Promise { + return new Promise((resolve, reject) => { + const url = URL.createObjectURL(blob); + const image = new Image(); + image.onload = () => { + URL.revokeObjectURL(url); + resolve(image); + }; + image.onerror = (error) => { + URL.revokeObjectURL(url); + reject(error); + }; + image.src = url; + }); + } + + private wait(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + } + + private resolveExportMimeType(): string | null { + if (this.resolvedMimeType !== null) { + return this.resolvedMimeType; + } + if (typeof MediaRecorder === "undefined") { + return null; + } + const mimeType = this.config.exportMimeTypes.find((type) => + MediaRecorder.isTypeSupported(type), + ); + if (!mimeType) { + return null; + } + this.resolvedMimeType = mimeType; + return mimeType; + } + + private refreshViewportSize() { + const rect = this.transformHandler.boundingRect(); + const mapWidth = this.game.width(); + const mapHeight = this.game.height(); + if (!rect || mapWidth === 0 || mapHeight === 0) { + return; + } + + const targetWidth = this.config.targetWidth ?? rect.width; + const targetHeight = this.config.targetHeight ?? rect.height; + + const scale = Math.min(targetWidth / mapWidth, targetHeight / mapHeight); + if (!Number.isFinite(scale) || scale <= 0) { + return; + } + + const width = Math.max(1, Math.round(mapWidth * scale)); + const height = Math.max(1, Math.round(mapHeight * scale)); + if ( + !this.viewport || + this.viewport.width !== width || + this.viewport.height !== height + ) { + this.viewport = { width, height }; + } + } +} diff --git a/src/client/graphics/recapCapture/RecapCaptureConfig.ts b/src/client/graphics/recapCapture/RecapCaptureConfig.ts new file mode 100644 index 0000000000..f1c48e950a --- /dev/null +++ b/src/client/graphics/recapCapture/RecapCaptureConfig.ts @@ -0,0 +1,29 @@ +export interface RecapCaptureConfig { + captureEveryNTicks: number; + maxFrames: number; + targetWidth?: number; + targetHeight?: number; + imageMimeType: string; + imageQuality?: number; + loopTailHoldMs: number; + exportFps: number; + exportVideoBitsPerSecond?: number; + exportMimeTypes: string[]; +} + +export const defaultRecapCaptureConfig: RecapCaptureConfig = { + captureEveryNTicks: 50, + maxFrames: 900, + targetWidth: 1920, + targetHeight: 1080, + imageMimeType: "image/webp", + imageQuality: 0.92, + loopTailHoldMs: 1000, + exportFps: 10, + exportVideoBitsPerSecond: 8_000_000, + exportMimeTypes: [ + "video/webm;codecs=vp9", + "video/webm;codecs=vp8", + "video/webm", + ], +}; diff --git a/src/client/graphics/recapCapture/RecapCaptureSurface.ts b/src/client/graphics/recapCapture/RecapCaptureSurface.ts new file mode 100644 index 0000000000..d15954b543 --- /dev/null +++ b/src/client/graphics/recapCapture/RecapCaptureSurface.ts @@ -0,0 +1,163 @@ +import { GameView } from "../../../core/game/GameView"; +import { TransformHandler } from "../TransformHandler"; +import { Layer } from "../layers/Layer"; + +export interface CaptureViewport { + width: number; + height: number; +} + +export interface CaptureResult { + blob: Blob; + imageBitmap?: ImageBitmap; + width: number; + height: number; +} + +export interface CaptureOptions { + layers: Layer[]; + game: GameView; + transformHandler: TransformHandler; + viewport: CaptureViewport; + backgroundColor?: string | null; + mimeType: string; + imageQuality?: number; + afterDraw?: ( + context: CanvasRenderingContext2D, + worldTransform: DOMMatrix, + ) => void; +} + +export class RecapCaptureSurface { + private canvas: HTMLCanvasElement; + private context: CanvasRenderingContext2D; + private captureInFlight = false; + + constructor() { + this.canvas = document.createElement("canvas"); + this.canvas.style.position = "fixed"; + this.canvas.style.pointerEvents = "none"; + this.canvas.style.opacity = "0"; + this.canvas.style.width = "0px"; + this.canvas.style.height = "0px"; + if (typeof document !== "undefined" && document.body) { + document.body.appendChild(this.canvas); + } + const ctx = this.canvas.getContext("2d"); + if (!ctx) { + throw new Error("RecapCaptureSurface failed to get 2D context"); + } + this.context = ctx; + } + + async capture(options: CaptureOptions): Promise { + if (this.captureInFlight) { + throw new Error("capture already in progress"); + } + this.captureInFlight = true; + try { + this.ensureSize(options.viewport.width, options.viewport.height); + this.context.setTransform(1, 0, 0, 1, 0, 0); + this.context.imageSmoothingEnabled = false; + this.context.clearRect(0, 0, this.canvas.width, this.canvas.height); + if (options.backgroundColor) { + this.context.fillStyle = options.backgroundColor; + this.context.fillRect(0, 0, this.canvas.width, this.canvas.height); + } + + const restoreTransformIfNeeded = ( + needsTransform: boolean, + active: boolean, + ) => { + if (needsTransform && !active) { + this.context.save(); + options.transformHandler.handleTransform(this.context); + return true; + } + if (!needsTransform && active) { + this.context.restore(); + return false; + } + return active; + }; + + let transformActive = false; + for (const layer of options.layers) { + if (typeof layer.renderLayer !== "function") { + continue; + } + const needsTransform = layer.shouldTransform?.() ?? false; + transformActive = restoreTransformIfNeeded( + needsTransform, + transformActive, + ); + try { + layer.renderLayer(this.context); + } catch (error) { + console.error( + "RecapCaptureSurface failed to render layer", + layer, + error, + ); + } + } + const worldTransform = this.context.getTransform(); + if (transformActive) { + this.context.restore(); + } + + options.afterDraw?.(this.context, worldTransform); + + const blob = await this.toBlob(options.mimeType, options.imageQuality); + let imageBitmap: ImageBitmap | undefined; + if (typeof createImageBitmap === "function") { + try { + imageBitmap = await createImageBitmap(blob); + } catch (error) { + console.warn( + "RecapCaptureSurface could not create ImageBitmap", + error, + ); + } + } + + return { + blob, + imageBitmap, + width: this.canvas.width, + height: this.canvas.height, + }; + } finally { + this.captureInFlight = false; + } + } + + dispose() { + this.canvas.remove(); + } + + private ensureSize(width: number, height: number) { + const safeWidth = Math.max(1, Math.round(width)); + const safeHeight = Math.max(1, Math.round(height)); + if (this.canvas.width !== safeWidth || this.canvas.height !== safeHeight) { + this.canvas.width = safeWidth; + this.canvas.height = safeHeight; + } + } + + private toBlob(mimeType: string, quality?: number): Promise { + return new Promise((resolve, reject) => { + this.canvas.toBlob( + (blob) => { + if (!blob) { + reject(new Error("Failed to encode recap frame")); + return; + } + resolve(blob); + }, + mimeType, + quality, + ); + }); + } +} diff --git a/src/client/graphics/recapCapture/RecapFrameStore.ts b/src/client/graphics/recapCapture/RecapFrameStore.ts new file mode 100644 index 0000000000..137e810074 --- /dev/null +++ b/src/client/graphics/recapCapture/RecapFrameStore.ts @@ -0,0 +1,149 @@ +export interface RecapFrame { + tick: number; + capturedAt: number; + blob: Blob; + width: number; + height: number; + objectUrl: string; + imageBitmap?: ImageBitmap; +} + +export interface SerializableRecapFrame { + tick: number; + capturedAt: number; + mimeType: string; + dataUrl: string; +} + +type Subscriber = (frames: readonly RecapFrame[]) => void; + +export class RecapFrameStore { + private frames: RecapFrame[] = []; + private subscribers: Set = new Set(); + private loopPauseMs = 0; + private totalBlobBytes = 0; + + constructor(private readonly maxFrames: number) {} + + addFrame(frame: Omit) { + const objectUrl = URL.createObjectURL(frame.blob); + const nextFrame: RecapFrame = { ...frame, objectUrl }; + this.frames.push(nextFrame); + this.totalBlobBytes += frame.blob.size; + this.compactIfNeeded(); + this.notify(); + } + + getFrames(): readonly RecapFrame[] { + return this.frames; + } + + private compactIfNeeded() { + if (!Number.isFinite(this.maxFrames) || this.maxFrames <= 0) { + return; + } + while (this.frames.length > this.maxFrames) { + const next: RecapFrame[] = []; + const removed: RecapFrame[] = []; + for (let index = 0; index < this.frames.length; index += 1) { + if (index % 2 === 0) { + next.push(this.frames[index]); + } else { + removed.push(this.frames[index]); + } + } + if (next.length === this.frames.length) { + break; + } + removed.forEach((frame) => { + this.totalBlobBytes -= frame.blob.size; + URL.revokeObjectURL(frame.objectUrl); + try { + frame.imageBitmap?.close(); + } catch { + /* ignore */ + } + }); + this.frames = next; + } + } + + getFrameCount(): number { + return this.frames.length; + } + + getLoopPauseMs(): number { + return this.loopPauseMs; + } + + setLoopPauseMs(durationMs: number) { + this.loopPauseMs = Math.max(0, Math.round(durationMs)); + this.notify(); + } + + clear() { + for (const frame of this.frames) { + URL.revokeObjectURL(frame.objectUrl); + try { + frame.imageBitmap?.close(); + } catch { + /* ignore */ + } + } + this.frames = []; + this.loopPauseMs = 0; + this.totalBlobBytes = 0; + this.notify(); + } + + subscribe(subscriber: Subscriber): () => void { + this.subscribers.add(subscriber); + subscriber(this.frames); + return () => { + this.subscribers.delete(subscriber); + }; + } + + private notify() { + for (const subscriber of this.subscribers) { + subscriber(this.frames); + } + } + + async serializeFrames(mimeType: string): Promise { + const serialized: SerializableRecapFrame[] = []; + for (const frame of this.frames) { + const dataUrl = await this.blobToDataUrl(frame.blob); + serialized.push({ + tick: frame.tick, + capturedAt: frame.capturedAt, + mimeType, + dataUrl, + }); + } + return serialized; + } + + approximateDurationMs(): number { + if (this.frames.length < 2) { + return 0; + } + + const first = this.frames[0]; + const last = this.frames[this.frames.length - 1]; + return last.capturedAt - first.capturedAt; + } + + getApproximateBlobBytes(): number { + return this.totalBlobBytes; + } + + private blobToDataUrl(blob: Blob): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onerror = () => reject(reader.error); + reader.onload = () => resolve(reader.result as string); + reader.readAsDataURL(blob); + }); + } +}