import { Matrix, type RenderTexture } from 'pixi.js'; import { pictureTransform, recordingName, type RecordingSettings, type RecordingSink, recordingSize, type RecordingUploadOptions, } from '../core/recording/recording'; import { ui } from '../ui'; import { PictureReader } from './PictureReader'; import { type FrameSource, type MadeRecording, Take, type TakeHost, type TakeInfo } from './RecordingTake'; import type { PictureArea } from './WorldViewport'; /** The view a recording takes its frames from (`WorldViewport`). */ export interface RecordingView { picture(): { shown: PictureArea; whole: PictureArea } | null; drawPicture(transform: Matrix, target: RenderTexture): void; /** Whether its last redraw left out a video sprite, the video still without its picture. */ missesVideo(): boolean; } /** Where the recorder looks, and what it tells; `saved` gets what the node asked to become of it. */ export interface RecordingHost { view(): RecordingView | null; log(line: string): void; saved(recording: MadeRecording, options: RecordingUploadOptions): void; } /** * Records the game view (#405): `Start recording` and `Stop recording` reach it through the run, the * host hands it the view and draws a frame for it each tick (`capture`). One recording at a time; it * outlives the world, so a `Load scene` or a restart goes on in the same file. */ export class GameRecorder implements RecordingSink { private take: Take | null = null; private paused = false; /** Every recording of this page, newest last (test hook). */ private readonly takes: Take[] = []; constructor(private readonly host: RecordingHost) {} get isRecording(): boolean { return this.take !== null; } /** * Whether a recording still sets up its encoders: the run waits for it, or the frames meanwhile * are lost – the first of a game that starts recording in `On start` among them. */ get isStarting(): boolean { return this.take?.isStarting ?? false; } /** The view shows the world now: a recording started in `On start` takes its size already. */ prepare(): void { if (this.host.view()) this.take?.prepare(); } start(settings: RecordingSettings): string { if (this.take) return `Start recording is ignored: "${this.take.name}" is recording already – Stop recording ends it`; const name = recordingName(settings.name, new Date()); const source = new ViewSource(name, settings, this.host); const upload = { convert: settings.convert, adopt: settings.convert && settings.adopt }; const host: TakeHost = { log: (line) => this.host.log(line), saved: (recording) => this.host.saved(recording, upload), }; const take = new Take( name, settings.fps, source, host, () => { if (this.take === take) this.take = null; }, { sound: settings.sound }, ); this.take = take; this.takes.push(take); if (this.paused) take.setPaused(true); return ''; } stop(): string { if (!this.take) return 'Stop recording: nothing is recording'; this.take.finish(''); this.take = null; return ''; } /** The editor holds the run: the recording holds with it, and its time leaves the pause out. */ setPaused(paused: boolean): void { this.paused = paused; this.take?.setPaused(paused); } /** The view has drawn this frame: the recording takes it, if it is due. */ capture(): void { if (this.host.view()) this.take?.capture(); } /** The run is over: a recording still going is saved, not lost. */ end(): void { this.stop(); } info(): { recording: boolean; takes: TakeInfo[] } { return { recording: this.isRecording, takes: this.takes.map((take) => take.info()) }; } } /** * What `PictureReader` hands back: limited range BT.709, its values sRGB as drawn, the same as * Chrome's encoder says of a recording of the whole editor. */ const PICTURE_COLOUR: VideoColorSpaceInit = { primaries: 'bt709', transfer: 'iec61966-2-1', matrix: 'bt709', fullRange: false, }; /** * The game view, drawn into a texture of the recording's size and read back from the GPU * (`PictureReader`) as I420: the frames come out a tick or two after they are taken. */ class ViewSource implements FrameSource { private reader: PictureReader | null = null; private size = { w: 0, h: 0 }; private toldNoCamera = false; constructor( private readonly name: string, private readonly settings: RecordingSettings, private readonly host: RecordingHost, ) {} open(): { w: number; h: number } | null { const picture = this.host.view()?.picture(); if (!picture) { if (!this.toldNoCamera) { this.toldNoCamera = true; this.host.log( `Recording "${this.name}" waits: no camera is switched on, so there is nothing to record`, ); } return null; } const ratio = ui().app.renderer.resolution; const device = (area: PictureArea) => ({ w: area.w * ratio, h: area.h * ratio }); this.size = recordingSize(this.settings.resolution, device(picture.shown), device(picture.whole)); this.reader = new PictureReader(this.size.w, this.size.h); return this.size; } holds(): boolean { return this.host.view()?.missesVideo() ?? false; } get busy(): boolean { return this.reader?.busy ?? true; } get pending(): number { return this.reader?.pending ?? 0; } take(ms: number): boolean { const view = this.host.view(); const picture = view?.picture(); if (!view || !picture || !this.reader) return false; const m = pictureTransform( this.settings.resolution === 'view' ? picture.shown : picture.whole, this.size, ); view.drawPicture(new Matrix(m.a, 0, 0, m.d, m.tx, m.ty), this.reader.texture); this.reader.read(ms); return true; } collect(encode: (frame: VideoFrame) => void): void { const layout = this.reader?.layout.layout; this.reader?.collect((bytes, ms) => encode( new VideoFrame(bytes, { format: 'I420', codedWidth: this.size.w, codedHeight: this.size.h, layout, colorSpace: PICTURE_COLOUR, timestamp: Math.round(ms * 1000), duration: Math.round(1e6 / this.settings.fps), }), ), ); } close(): void { this.reader?.destroy(); this.reader = null; } }