import { Container, Graphics, UPDATE_PRIORITY } from 'pixi.js'; import { cursorShape, type CursorShape } from '../core/recording/cursors'; import { EDITOR_RECORDING_FPS, editorRecordingSize, elapsedText, fitPicture, recordingName, } from '../core/recording/recording'; import { crispText, renderCount, requestRender, textStyle, Theme, ui } from '../ui'; import { type FrameSource, Take, type TakeHost, type TakeInfo } from '../viewport/RecordingTake'; /** What the editor recorder needs of the editor. */ export interface EditorRecorderHost extends TakeHost { /** Makes room in the top bar for the badge while recording, or takes it away. */ showBadge(on: boolean): void; /** Where the badge goes, in CSS pixels. */ badgeRoom(): { x: number; y: number; w: number; h: number }; } /** * Records the whole editor (main menu → Start recording): the canvas as it is on screen, at its size * in device pixels and 60 fps, with the mouse pointer drawn in, since the system draws that over the * canvas. What the editor draws is copied from the canvas right after each render, so recording costs * no second drawing. The badge beside the menu is drawn onto the screen after that copy, so it is * never in the recording. */ export class EditorRecorder { private take: Take | null = null; private source: CanvasSource | null = null; /** Every recording of this page, newest last (test hook). */ private readonly takes: Take[] = []; private seen = -1; private startedAt = 0; private shownText = ''; private readonly badge = new RecordingBadge(); constructor(private readonly host: EditorRecorderHost) {} get isRecording(): boolean { return this.take !== null; } start(): void { if (this.take) return; const app = ui().app; const source = new CanvasSource(app.canvas); const take = new Take(recordingName('', new Date()), EDITOR_RECORDING_FPS, source, this.host, () => { if (this.take === take) this.end(); }); this.take = take; this.source = source; this.takes.push(take); this.startedAt = performance.now(); this.shownText = ''; this.seen = -1; this.host.showBadge(true); // After the render loop (`UPDATE_PRIORITY.LOW`): the canvas holds this tick's picture then. app.ticker.add(this.tick, this, UPDATE_PRIORITY.UTILITY); requestRender(); } stop(): void { const take = this.take; if (!take) return; this.end(); take.finish(''); } private end(): void { this.take = null; this.source?.detach(); this.source = null; ui().app.ticker.remove(this.tick, this); this.host.showBadge(false); requestRender(); } private tick(): void { const take = this.take; const source = this.source; if (!take || !source) return; const drawn = renderCount(); if (drawn !== this.seen) { this.seen = drawn; source.copy(); this.drawBadge(); } take.capture(); // An idle editor draws nothing, and the time beside the menu has to move on. if (elapsedText(performance.now() - this.startedAt) !== this.shownText) requestRender(); } private drawBadge(): void { this.shownText = elapsedText(performance.now() - this.startedAt); this.badge.show(this.host.badgeRoom(), this.shownText); const app = ui().app; app.renderer.render({ container: this.badge, clear: false }); // Pixi hit-tests the pointer against what it drew on screen last: that has to be the stage. (app.renderer as unknown as { _lastObjectRendered: Container })._lastObjectRendered = app.stage; } info(): { recording: boolean; takes: TakeInfo[]; badge: { x: number; y: number; w: number; h: number } | null; } { return { recording: this.isRecording, takes: this.takes.map((take) => take.info()), badge: this.isRecording ? this.host.badgeRoom() : null, }; } } /** The red dot and the time beside the menu while the editor records. */ class RecordingBadge extends Container { private readonly dot = new Graphics(); private readonly time = crispText({ text: '', style: textStyle('body') }); constructor() { super(); this.addChild(this.dot, this.time); } show(room: { x: number; y: number; w: number; h: number }, text: string): void { const radius = 5; this.position.set(room.x, room.y); this.dot .clear() .circle(radius + 2, room.h / 2, radius) .fill(Theme.colors.danger); this.time.text = text; this.time.position.set(radius * 2 + 7, Math.round((room.h - this.time.height) / 2)); } } /** * The canvas, copied after each render into a picture of the recording's size; a frame is that * picture with the mouse pointer drawn over it. */ class CanvasSource implements FrameSource { private readonly size: { w: number; h: number }; private readonly picture: OffscreenCanvas; private readonly picturePen: OffscreenCanvasRenderingContext2D; private readonly frame: OffscreenCanvas; private readonly framePen: OffscreenCanvasRenderingContext2D; private readonly pointer = new PointerTrack(); private readonly paths = new Map(); private copied = false; private ready: VideoFrame[] = []; constructor(private readonly canvas: HTMLCanvasElement) { this.size = editorRecordingSize({ w: canvas.width, h: canvas.height }); this.picture = new OffscreenCanvas(this.size.w, this.size.h); this.picturePen = this.picture.getContext('2d', { alpha: false })!; this.frame = new OffscreenCanvas(this.size.w, this.size.h); this.framePen = this.frame.getContext('2d', { alpha: false })!; this.pointer.attach(canvas); } open(): { w: number; h: number } { return this.size; } /** The canvas holds what the editor just drew: it becomes the picture. */ copy(): void { const { canvas, picturePen: pen } = this; const at = fitPicture({ w: canvas.width, h: canvas.height }, this.size); pen.fillStyle = '#000'; pen.fillRect(0, 0, this.size.w, this.size.h); pen.drawImage(canvas, 0, 0, canvas.width, canvas.height, at.x, at.y, at.w, at.h); this.copied = true; } holds(): boolean { return !this.copied; } get busy(): boolean { return false; } get pending(): number { return this.ready.length; } take(ms: number): boolean { if (!this.copied) return false; this.framePen.drawImage(this.picture, 0, 0); this.drawPointer(); this.ready.push( new VideoFrame(this.frame, { timestamp: Math.round(ms * 1000), duration: Math.round(1e6 / EDITOR_RECORDING_FPS), }), ); return true; } private drawPointer(): void { const { canvas, pointer } = this; const shape = cursorShape(canvas.style.cursor); if (!pointer.inside || !shape) return; const rect = canvas.getBoundingClientRect(); if (rect.width === 0 || rect.height === 0) return; const at = fitPicture({ w: canvas.width, h: canvas.height }, this.size); const x = at.x + ((pointer.x - rect.left) / rect.width) * at.w; const y = at.y + ((pointer.y - rect.top) / rect.height) * at.h; const scale = at.w / rect.width; let path = this.paths.get(shape); if (!path) this.paths.set(shape, (path = new Path2D(shape.path))); const pen = this.framePen; pen.save(); pen.translate(x, y); pen.scale(scale, scale); pen.rotate((shape.turn * Math.PI) / 180); pen.translate(-shape.hot.x, -shape.hot.y); pen.lineJoin = 'round'; pen.lineWidth = 2; pen.strokeStyle = '#fff'; pen.stroke(path); pen.fillStyle = '#000'; pen.fill(path); pen.restore(); } collect(encode: (frame: VideoFrame) => void): void { for (const frame of this.ready.splice(0)) encode(frame); } /** The recording ends: the pointer is no longer followed, the frames taken still go out. */ detach(): void { this.pointer.detach(); } close(): void { this.pointer.detach(); for (const frame of this.ready.splice(0)) frame.close(); } } /** Where the mouse is, in client pixels, and whether it is over the page at all. */ class PointerTrack { x = 0; y = 0; inside = false; private attached = false; private readonly moved = (event: PointerEvent): void => { this.x = event.clientX; this.y = event.clientY; this.inside = true; }; private readonly left = (): void => { this.inside = false; }; /** Starts where Pixi last saw the pointer: the click on Start recording put it there. */ attach(canvas: HTMLCanvasElement): void { if (this.attached) return; this.attached = true; const rect = canvas.getBoundingClientRect(); const at = ui().app.renderer.events.pointer.global; this.x = rect.left + at.x; this.y = rect.top + at.y; this.inside = at.x > 0 || at.y > 0; window.addEventListener('pointermove', this.moved, { capture: true, passive: true }); window.addEventListener('pointerdown', this.moved, { capture: true, passive: true }); document.documentElement.addEventListener('pointerleave', this.left); } detach(): void { if (!this.attached) return; this.attached = false; window.removeEventListener('pointermove', this.moved, { capture: true }); window.removeEventListener('pointerdown', this.moved, { capture: true }); document.documentElement.removeEventListener('pointerleave', this.left); } }