Keep a video's loading out of recordings and first frames (#417)
game-maker / gate (push) Successful in 4m21s
game-maker / suite (push) Failing after 17m16s

A Start recording right after Play composition still began with
empty frames. The composition's clock waited for its video, but the
recording went on and took every frame while the video loaded. A
clip that starts later in the video also slipped through, because
its player already had one picture while it still sought to the
clip's start.

- The view now counts every video sprite it leaves out as missing,
  not only one without any first picture.
- GameRecorder takes no frame while the game view misses a video,
  for at most VIDEO_WAIT_MS (3 s) at a time. The recorded time
  stands and the sound of that time is dropped, so it stays in sync.
- RecordingClock.start forgets a hold from before the first frame.
  Without that, the first real frames all got the time 0.
- A followed player starts at 1x until the first speed reading.
  Before, it paused and sought every frame for about 100 ms, so the
  first pictures after a wait stuck and then jumped. A step faster
  than 4x and a time running backwards do not count as a start.
- Test hooks: __gm.gameVideo(id) reads the game view's own player,
  TakeInfo.lastFrameAt shows when the last frame was taken.

Tests:
- Unit recording (clock after an early hold), video-follow (2 new,
  1 adapted): 449 green in tests/core/recording, tests/viewport and
  tests/core/composition*
- e2e 68e (a clip from 0.8 s shows that picture first and then
  plays), 68f (a recording that already runs takes no frame while
  the composition's video loads)
- 68e is red without the start at 1x, 68f without the hold
- README: 2038 editor checks

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-12 11:38:37 +02:00
1 parent 6e6b6a4d9a
commit b2b4cbad28
10 files changed
+213 -25

No files matched your search

+105 -4
View File
@@ -8,7 +8,8 @@ import { MAKE_OBJECT } from './lib/scaffold.mjs';
* it: each item becomes an object below the placement while it lasts, the first track in front, a
* clip moves the item it overlaps, and `On composition finished` hears the end, by which time no
* item is left. Nothing of the run stays in the scene. An item's video is waited for: the clock
* stands until the Game tab has its first picture.
* stands until the Game tab has its first picture, for a clip that starts later the picture there,
* and then the video plays at once. A recording that runs meanwhile takes no frame.
*
* Everything it adds carries a `cp68` id and goes again at the end.
*/
@@ -23,7 +24,7 @@ const link = (id, from, fromPort, to, toPort) => ({
});
export async function composition(t) {
const { check, sleep, json, evaluate, mutate, play, stop, showTab } = t;
const { check, sleep, json, evaluate, mutate, press, play, stop, showTab, waitUntil } = t;
await showTab('scene');
const graph = {
@@ -192,7 +193,7 @@ export async function composition(t) {
const loading = [];
const step = () => {
const time = ${clock};
const player = __gm.videos().find((v) => v.key.endsWith(':${id('v')}'));
const player = __gm.gameVideo('${id('v')}');
if (player?.ready) return done(JSON.stringify({ loading, ready: time }));
if (player) loading.push(time);
if (!__gm.world() || loading.length > 300) return done(JSON.stringify({ loading, ready: null }));
@@ -219,9 +220,109 @@ export async function composition(t) {
);
await stop();
// 68e. The item wears a clip that starts at 0.8 s: its player loads, then seeks back to 0.8. The
// clock stands until the Game tab draws that picture, and from its first step on the video
// plays – it was held and sought frame by frame for 0.1 s before, its first pictures stuck.
await mutate(`
const p = __gm.state.project;
p.assets = p.assets.filter((a) => !a.id.startsWith('cp68-'));
p.assets.find((a) => a.id === 'cp68-video').clips = [{ id: 'cp68-late', name: 'Late', props: { start: 0.8, end: 0 } }];
p.assets.find((a) => a.id === 'cp68-screen').props.texture = 'cp68-late';`);
await play();
const rows = JSON.parse(
await evaluate(`new Promise((done) => {
const rows = [];
const step = () => {
const player = __gm.gameVideo('${id('v')}');
rows.push({ time: ${clock}, drawn: __gm.gameDrawnRect('${id('v')}') !== null,
at: player?.time ?? null, paused: player?.paused ?? null });
if (rows.length >= 240 || !__gm.world()) return done(JSON.stringify(rows));
requestAnimationFrame(step);
};
step();
})`),
);
const first = rows.findIndex((row) => row.drawn);
const moving = rows.filter((row) => row.drawn && row.time > 0).slice(0, 3);
check(
'a clip that starts later is waited for, then plays from its first step on',
first >= 0 &&
rows.slice(0, first).every((row) => row.time === 0) &&
Math.abs(rows[first].at - 0.8) < 0.02 &&
moving.length === 3 &&
moving.every((row) => row.paused === false),
JSON.stringify({ first, shown: rows[first], moving }),
);
await stop();
// 68f. A recording runs when Play composition starts the cut: no frame is taken while the Game
// tab leaves the item's video out – it recorded the empty frames of the wait before. A clip
// that starts elsewhere, so a new player loads: the one of 68e is still there, at its picture.
await mutate(`
const video = __gm.state.project.assets.find((a) => a.id === 'cp68-video');
video.clips.push({ id: 'cp68-later', name: 'Later', props: { start: 1.2, end: 0 } });
__gm.state.project.assets.find((a) => a.id === 'cp68-screen').props.texture = 'cp68-later';`);
const flow = structuredClone(graph);
flow.nodes.push(
node('record', 'StartRecording', 300, -150, { name: 'cp68 take', resolution: 'view', fps: '60' }),
node('key', 'OnKeyDown', 0, 450, { key: 'p' }),
);
flow.edges = flow.edges.filter((e) => e.id !== 'cp68-e1');
flow.edges.push(link('e4', 'start', 'exec', 'record', 'exec'), link('e5', 'key', 'exec', 'play', 'exec'));
await mutate(
`__gm.state.project.assets.find((a) => a.id === 'cp68-director').graph = ${JSON.stringify(flow)};`,
);
const { uploads, takes } = await json('__gm.recording()');
const take = `__gm.recording().takes[${takes.length}]`;
await play();
await waitUntil(`${take}?.state === 'recording' && ${take}.lastFrameAt !== null`, {
label: 'the recording takes frames',
});
// Sampled every frame in the page, from before the key on.
await evaluate(`{
const rows = (window.cp68Rows = []);
const step = () => {
const exists = (__gm.world()?.entities ?? []).some((e) => e.objectId === '${id('v')}');
rows.push({ exists, drawn: __gm.gameDrawnRect('${id('v')}') !== null, taken: ${take}?.lastFrameAt ?? null });
const drawn = rows.findIndex((row) => row.drawn);
if ((drawn < 0 || rows.length <= drawn + 20) && rows.length < 400) requestAnimationFrame(step);
};
requestAnimationFrame(step);
}`);
await press('p');
await waitUntil(
`window.cp68Rows.length >= 400 || (window.cp68Rows.some((row) => row.drawn) && window.cp68Rows.length > window.cp68Rows.findIndex((row) => row.drawn) + 20)`,
{ label: 'the item is drawn' },
);
const takeRows = await json('window.cp68Rows');
await stop();
// The frames in which the item was there and its video still left out.
const missing = takeRows.filter((row) => row.exists && !row.drawn);
const shown = takeRows.findIndex((row) => row.drawn);
check(
'a recording takes no frame while a composition waits for its video, and goes on after',
missing.length >= 2 &&
missing.every((row) => row.taken === missing[0].taken) &&
shown >= 0 &&
takeRows.at(-1).taken > missing.at(-1).taken,
JSON.stringify({
missing: missing.length,
takenWhileMissing: new Set(missing.map((row) => row.taken)).size - 1,
shown,
after:
takeRows.at(-1).taken !== null && missing.length > 0
? Math.round(takeRows.at(-1).taken - missing.at(-1).taken)
: null,
}),
);
await waitUntil(
`__gm.recording().uploads.length > ${uploads.length} && ['added', 'failed'].includes(__gm.recording().uploads.at(-1).state)`,
{ timeout: 60_000, label: 'the recording is in the project' },
);
const madeId = (await json('__gm.recording()')).uploads.at(-1).assetId ?? '';
await mutate(`
const p = __gm.state.project;
p.assets = p.assets.filter((a) => !a.id.startsWith('cp68-') && a.id !== ${JSON.stringify(madeId)});
const scene = __gm.state.activeScene;
scene.objects = scene.objects.filter((o) => !o.id.startsWith('cp68-'));`);
}