The game view could already record itself through nodes. Making a video of the editor itself, to show how something is done, needed an outside screen recorder. The main menu now starts and stops one, with no options to set. - "Start recording" sits in its own group before General settings, and reads "Stop recording" while one runs. The name is "Recording <time>", the size is the whole canvas in device pixels (an odd row or column is cut), 60 fps always. Sound is the master, saving goes the same way as for the game view. - The encoder, the sound and the Matroska writing of a take moved from GameRecorder.ts to RecordingTake.ts. A take now reads its pictures from a FrameSource, so the game view and the editor share it. - The editor's picture is copied from the canvas after each render, in the same frame, so nothing is drawn twice. A resting editor does not render, and the recording repeats its last picture. - The mouse pointer is drawn into the recording in the shape the editor shows (arrow, hand, resize arrows, I-beam, crosshair, move). - A red dot with the running time stands beside the menu button on screen only. It is drawn after the copy, and the toolbar makes room for it. - Drawing that dot made Pixi take it as the root for every hit test, and the menu would not open any more. The stage is set back as the last rendered object after it. Tests: - Gate on melektaus-atx green: 303 files, 4686 tests. - Unit recording: 20 green, 5 new. - e2e 67: 8 checks, 67e new. It records through the menu and decodes the MP4: 1280x800 at 60 fps, the pointer black at the mouse, the dot missing, 238 plain spots alike with the canvas. Without the pointer and with the dot drawn before the copy, 67e is red. - e2e 46: the main menu lists "Start recording". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
282 lines
9.1 KiB
TypeScript
282 lines
9.1 KiB
TypeScript
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<CursorShape, Path2D>();
|
|
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);
|
|
}
|
|
}
|