Record the game view from a behavior into a video asset (#405)
A game can now film itself. A behavior starts and stops a take, and the editor turns it into a video asset with the run's picture and the sound the player hears. The exported game hands the file over as a download, since there is no project to keep it in. - New action nodes `Start recording` (Name, Resolution, FPS) and `Stop recording`. An empty name becomes `Recording <timestamp>`. - Resolution is `Game view`, `720p` or `1080p`. For the fixed sizes the height is set and the width follows the camera. - The picture is the camera only, without hints, overlays or the letterbox. It is drawn into a render texture of the target size, so a smaller window still gives full 1080p. - `PictureReader` reads the texture back through pixel buffers with a fence. The editor never waits on the GPU, and a frame with all three buffers busy is dropped and counted. - Nothing of the take reaches the visible canvas. A first version drew tiles into it and copied them out, and with the low-latency canvas that midway state flickered on screen. - The sound is tapped behind the master limiter in the FX worklet and encoded as Opus. - H.264 is chosen when the browser has it, VP9 otherwise. A small own Matroska writer packs both tracks, no new dependency. It names the frame rate and BT.709 so ffmpeg keeps both. - A second `Start recording` is ignored with a warning in the log. A take goes on through `Load scene` and a restart, pauses with the editor and is saved when the run ends. It stops at 2 GB. - The editor uploads the take on the existing video route and makes the asset once the run is over. The video tab gets a Download button for the converted MP4. - `downloadBlob` in `src/ui` replaces the song editor's own copy. Findings: - `On start` runs before the game screen knows the world. A take therefore fixes its size on the first frame that has a camera. - Without a frame rate in the file, ffmpeg turned 30 fps into 59.94. - The worker's poster JPEG is off in colour for every upload (red 255 comes out as 229, BT.601). Not touched here. Tests: - Unit recording + matroska: 21 green, plus the node catalog cases. The Matroska test reads the file back with its own EBML reader. - e2e 67 (new) on melektaus-atx: ALL PASSED. 1080p30 from a 1280x800 window gives a 1920x1080 MP4 with sound and no dropped frame. Every 4th pixel is checked, a green quarter catches an upside-down picture. A take ended by Esc is saved with the timestamp name. - e2e 60j: the exported game downloads an `.mkv` with EBML magic. - The flicker is gone on the user's machine without `?vsync`. - Gate on melektaus-atx: CHECK EXIT 0, 299 files, 4586 tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
36ffd9a61a
commit
e191dc4b2e
35 files changed
+2211
-26
No files matched your search
@@ -13,7 +13,7 @@ const BACKGROUND = '#22252c';
|
||||
|
||||
/**
|
||||
* A small game of its own: a red crate in the middle of the camera that the arrow keys move, a
|
||||
* `Log` on start, and S stopping the game.
|
||||
* `Log` and a recording on start, and S stopping the game.
|
||||
*/
|
||||
function game(id) {
|
||||
const mover = {
|
||||
@@ -37,10 +37,15 @@ function game(id) {
|
||||
nodes: [
|
||||
node('c-start', 'OnStart', 0, 0),
|
||||
node('c-log', 'Log', 260, 0, { message: 'released game started' }),
|
||||
node('c-record', 'StartRecording', 520, 0, { name: 'Released 60', fps: '30' }),
|
||||
node('c-key', 'OnKeyDown', 0, 160, { key: 's' }),
|
||||
node('c-stop', 'StopGame', 260, 160),
|
||||
],
|
||||
edges: [edge('c1', 'c-start', 'exec', 'c-log', 'exec'), edge('c2', 'c-key', 'exec', 'c-stop', 'exec')],
|
||||
edges: [
|
||||
edge('c1', 'c-start', 'exec', 'c-log', 'exec'),
|
||||
edge('c2', 'c-key', 'exec', 'c-stop', 'exec'),
|
||||
edge('c3', 'c-log', 'exec', 'c-record', 'exec'),
|
||||
],
|
||||
};
|
||||
return {
|
||||
formatVersion: 2,
|
||||
@@ -226,6 +231,9 @@ export async function release(t) {
|
||||
);
|
||||
|
||||
// 60d. A click starts it in the start scene, zoomed to Auto: a whole number of pixels per unit.
|
||||
// What the page hands the browser to download is caught here instead (60j).
|
||||
await t.evaluate(`window.__downloads = [];
|
||||
HTMLAnchorElement.prototype.click = function () { window.__downloads.push({ name: this.download, href: this.href }); };`);
|
||||
const size = { w: await json('innerWidth'), h: await json('innerHeight') };
|
||||
await click(size.w / 2, size.h / 2);
|
||||
await waitUntil('__gameMakerPlay.running()', { label: 'the game runs' });
|
||||
@@ -286,6 +294,24 @@ export async function release(t) {
|
||||
JSON.stringify({ moved, screen: await player('screen()') }),
|
||||
);
|
||||
|
||||
// 60j (#405). The game recorded itself from its start: ending the run saved the recording, and a
|
||||
// released game, with no project to put it in, hands it to the browser as a Matroska file.
|
||||
await waitUntil('window.__downloads.length > 0', { label: 'the recording is downloaded' });
|
||||
const downloads = await json('window.__downloads');
|
||||
const file = JSON.parse(
|
||||
await t.evaluate(`fetch(${JSON.stringify(downloads[0]?.href ?? '')})
|
||||
.then((r) => r.arrayBuffer())
|
||||
.then((b) => JSON.stringify({ bytes: b.byteLength, head: [...new Uint8Array(b.slice(0, 4))] }))`),
|
||||
);
|
||||
check(
|
||||
'a released game downloads its recording as an MKV file once the run ends',
|
||||
downloads.length === 1 &&
|
||||
downloads[0].name === 'Released 60.mkv' &&
|
||||
file.bytes > 1000 &&
|
||||
JSON.stringify(file.head) === JSON.stringify([0x1a, 0x45, 0xdf, 0xa3]),
|
||||
JSON.stringify({ downloads: downloads.map((d) => d.name), file }),
|
||||
);
|
||||
|
||||
// 60h (#267). A click on the corner button makes the page fullscreen and does not reach the
|
||||
// game – it would start it again; a second click leaves fullscreen.
|
||||
const corner = (await player('fullscreen()')).button;
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import { MAKE_OBJECT } from './lib/scaffold.mjs';
|
||||
|
||||
/**
|
||||
* Recording the game view (#405). A behavior starts a recording at 1080p and 30 fps when the run
|
||||
* starts, and V stops it: the server converts the file like any upload, and once the run is over a
|
||||
* video asset of that name is in the project, 1920×1080 with sound, its picture the red sprite that
|
||||
* covers the camera. G starts another at the size of the game view; a second G is ignored and says
|
||||
* so in the log, and Esc ends the run, which saves that one too, named after when it started.
|
||||
*
|
||||
* Everything it adds carries an `rc67` id or is one of the two video assets, and goes again at the end.
|
||||
*/
|
||||
const CONTROL = 'rc67-control';
|
||||
const COVER = 'rc67-cover';
|
||||
const NAME = 'rc67 take';
|
||||
|
||||
const node = (id, type, x, y, params = {}) => ({ id: `rc67-${id}`, type, x, y, params });
|
||||
const edge = (id, from, to) => ({
|
||||
id: `rc67-${id}`,
|
||||
from: { node: `rc67-${from}`, port: 'exec' },
|
||||
to: { node: `rc67-${to}`, port: 'exec' },
|
||||
});
|
||||
|
||||
export async function recording(t) {
|
||||
const { check, sleep, json, evaluate, mutate, press, play, stop, showTab, waitUntil } = t;
|
||||
await showTab('scene');
|
||||
|
||||
const graph = {
|
||||
nodes: [
|
||||
node('start', 'OnStart', 0, 0),
|
||||
node('record', 'StartRecording', 300, 0, { name: NAME, resolution: '1080p', fps: '30' }),
|
||||
node('v', 'OnKeyDown', 0, 200, { key: 'v' }),
|
||||
node('stop', 'StopRecording', 300, 200),
|
||||
node('g', 'OnKeyDown', 0, 400, { key: 'g' }),
|
||||
node('again', 'StartRecording', 300, 400),
|
||||
],
|
||||
edges: [edge('e1', 'start', 'record'), edge('e2', 'v', 'stop'), edge('e3', 'g', 'again')],
|
||||
};
|
||||
// Red over the whole camera picture, blue over its right half and green over its lower left
|
||||
// quarter, in front of what earlier steps left there: a frame drawn at the wrong place, or read
|
||||
// back upside down, shows the wrong colour.
|
||||
await mutate(`
|
||||
const p = __gm.state.project;
|
||||
const scene = __gm.state.activeScene;
|
||||
const cam = scene.objects.find((o) => o.id === 'o3').position;
|
||||
p.assets.push({ id: '${CONTROL}', type: 'behavior', name: 'Recorder 67', props: { enabled: true, runInEditor: false }, variables: [], graph: ${JSON.stringify(graph)} });
|
||||
${MAKE_OBJECT}
|
||||
const sprite = p.assets.find((a) => a.type === 'sprite');
|
||||
const cover = make('${COVER}', sprite.id, '', 0, 'Cover 67');
|
||||
Object.assign(cover.position, { x: cam.x, y: cam.y, z: 1000 });
|
||||
cover.overrides = { width: 900, height: 600, color: 0xff0000, texture: '' };
|
||||
const right = make('rc67-right', sprite.id, '', 0, 'Right 67');
|
||||
Object.assign(right.position, { x: cam.x + 225, y: cam.y, z: 1001 });
|
||||
right.overrides = { width: 450, height: 600, color: 0x0000ff, texture: '' };
|
||||
const low = make('rc67-low', sprite.id, '', 0, 'Low 67');
|
||||
Object.assign(low.position, { x: cam.x - 225, y: cam.y + 150, z: 1001 });
|
||||
low.overrides = { width: 450, height: 300, color: 0x00ff00, texture: '' };
|
||||
const logic = make('rc67-logic', '${CONTROL}', '', 0, 'Recorder 67');
|
||||
Object.assign(logic.position, { x: cam.x + 400, y: cam.y });
|
||||
scene.objects.push(cover, right, low, logic);
|
||||
`);
|
||||
const recordings = () => json('__gm.recording()');
|
||||
|
||||
// 67a. Recorded from the start of the run, at 1080p and 30 fps, with the master's sound. The window
|
||||
// is smaller than 1080p: the frame is drawn into a texture of its own (`PictureReader`).
|
||||
await play();
|
||||
await sleep(1200);
|
||||
await press('v');
|
||||
await waitUntil(`__gm.recording().takes[0]?.state === 'done'`, { label: 'the first take is saved' });
|
||||
const first = (await recordings()).takes[0];
|
||||
check(
|
||||
'Start recording records the game view at the size asked, with sound, and Stop recording saves it',
|
||||
first.name === NAME &&
|
||||
first.width === 1920 &&
|
||||
first.height === 1080 &&
|
||||
first.fps === 30 &&
|
||||
first.sound &&
|
||||
first.soundFrames > 0 &&
|
||||
first.frames >= 25 &&
|
||||
first.frames <= 45 &&
|
||||
first.failure === null,
|
||||
JSON.stringify(first),
|
||||
);
|
||||
|
||||
// 67b. A second start while one records is ignored, and says so in the log.
|
||||
await press('g');
|
||||
await sleep(300);
|
||||
await press('g');
|
||||
await sleep(900);
|
||||
const log = (await json('__gm.world()'))?.log ?? [];
|
||||
const during = await recordings();
|
||||
check(
|
||||
'a second Start recording while one records is ignored and says so in the log',
|
||||
during.takes.length === 2 &&
|
||||
log.some((line) => line.includes('Start recording is ignored') && line.includes(during.takes[1].name)),
|
||||
JSON.stringify({ log, takes: during.takes.map((take) => take.name) }),
|
||||
);
|
||||
await stop();
|
||||
|
||||
// 67c. Ending the run saved the second take; both became video assets once the server had them.
|
||||
await waitUntil(
|
||||
`__gm.recording().uploads.length === 2 && __gm.recording().uploads.every((u) => u.state === 'added' || u.state === 'failed')`,
|
||||
{
|
||||
timeout: 60_000,
|
||||
label: 'both recordings are in the project',
|
||||
},
|
||||
);
|
||||
const { takes, uploads } = await recordings();
|
||||
const second = takes[1];
|
||||
const assets = await json(`__gm.state.project.assets
|
||||
.filter((a) => a.type === 'video' && ${JSON.stringify(uploads.map((u) => u.assetId))}.includes(a.id))
|
||||
.map((a) => ({ id: a.id, name: a.name, file: a.file, width: a.width, height: a.height, duration: a.duration, fps: a.fps, audio: a.audio }))`);
|
||||
const named = assets.find((a) => a.name === NAME);
|
||||
check(
|
||||
'the recording becomes a video asset of its name: 1920×1080, its length, with sound',
|
||||
named !== undefined &&
|
||||
named.width === 1920 &&
|
||||
named.height === 1080 &&
|
||||
named.audio === true &&
|
||||
Math.abs(named.duration - first.durationMs / 1000) < 0.1 &&
|
||||
named.fps === 30,
|
||||
JSON.stringify({ assets, uploads }),
|
||||
);
|
||||
const timed = assets.find((a) => a.name !== NAME);
|
||||
check(
|
||||
'ending the run saves a recording still going, named after when it started, at the game view size',
|
||||
second.state === 'done' &&
|
||||
/^Recording \d{4}-\d\d-\d\d \d\d:\d\d:\d\d$/.test(second.name) &&
|
||||
timed?.name === second.name &&
|
||||
timed.width === second.width &&
|
||||
timed.height === second.height &&
|
||||
second.width % 2 === 0 &&
|
||||
second.fps === 60,
|
||||
JSON.stringify({ second, timed }),
|
||||
);
|
||||
|
||||
// 67d. The picture is the camera's: red at the top left, green below it and blue on the right,
|
||||
// none of what the view lays over it, the right way up, and as red as it was drawn; every
|
||||
// 4th pixel is read.
|
||||
// From the MP4 as Chrome decodes it: the poster is no witness, ffmpeg cuts it with BT.601
|
||||
// whatever the file says (a red of 255 comes out 229).
|
||||
const colorsOf = (file) =>
|
||||
evaluate(`new Promise((resolve) => {
|
||||
const video = document.createElement('video');
|
||||
video.muted = true;
|
||||
video.onerror = () => resolve('null');
|
||||
video.src = '/game-maker/data/videos/${file}';
|
||||
video.play().then(() => video.requestVideoFrameCallback(() => {
|
||||
video.pause();
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = video.videoWidth;
|
||||
canvas.height = video.videoHeight;
|
||||
const pen = canvas.getContext('2d');
|
||||
pen.drawImage(video, 0, 0);
|
||||
const { data } = pen.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const off = [];
|
||||
for (let y = 2; y < canvas.height; y += 4)
|
||||
for (let x = 2; x < canvas.width; x += 4) {
|
||||
const i = (y * canvas.width + x) * 4;
|
||||
const edge = canvas.width / 2;
|
||||
const middle = canvas.height / 2;
|
||||
if (Math.abs(x - edge) < canvas.width / 100 || Math.abs(y - middle) < canvas.height / 100) continue;
|
||||
const hot = x >= edge ? 2 : y >= middle ? 1 : 0;
|
||||
const rgb = [data[i], data[i + 1], data[i + 2]];
|
||||
if (rgb.some((v, c) => (c === hot ? v < 245 : v > 12))) off.push([x, y, ...rgb]);
|
||||
}
|
||||
resolve(JSON.stringify({ w: canvas.width, h: canvas.height, off: off.length, first: off.slice(0, 5) }));
|
||||
}), () => resolve('null'));
|
||||
})`);
|
||||
const colors = `[${await colorsOf(named?.file ?? '')},${await colorsOf(timed?.file ?? '')}]`;
|
||||
const red = JSON.parse(colors);
|
||||
check(
|
||||
'the recorded picture is the camera picture, the right way up, as red as it was drawn',
|
||||
red.every((frame) => frame !== null && frame.w > 0 && frame.off === 0),
|
||||
colors,
|
||||
);
|
||||
|
||||
await mutate(`
|
||||
const p = __gm.state.project;
|
||||
const made = ${JSON.stringify(assets.map((a) => a.id))};
|
||||
p.assets = p.assets.filter((a) => a.id !== '${CONTROL}' && !made.includes(a.id));
|
||||
const scene = __gm.state.activeScene;
|
||||
scene.objects = scene.objects.filter((o) => !o.id.startsWith('rc67-'));`);
|
||||
}
|
||||
@@ -69,6 +69,7 @@ import { renderLayers } from './63-render-layers.mjs';
|
||||
import { pattern } from './64-pattern.mjs';
|
||||
import { song } from './65-song.mjs';
|
||||
import { video } from './66-video.mjs';
|
||||
import { recording } from './67-recording.mjs';
|
||||
|
||||
export const STEPS = [
|
||||
hierarchy,
|
||||
@@ -137,4 +138,5 @@ export const STEPS = [
|
||||
pattern,
|
||||
song,
|
||||
video,
|
||||
recording,
|
||||
];
|
||||
Reference in new issue
Block a user