In the full suite e2e 66f saw the same purple #cc00f4 fourteen times instead of the clip's green and blue. Alone the module was green. The video sprite stood at (0, 0), and there earlier modules keep objects on purpose that later modules still need. Every other module has its own lane for that reason, only 66 had none. - scaffold.mjs: new lane `video` at y -7000. - 66-video.mjs: the sprite and lookAt use that lane, and the cleanup looks back at (0, 0) like the other lane modules do. Found with a diagnosis in a full run on melektaus-atx: under the sprite's centre lay o3, otex (z 900) and filler40 to filler1 (z 5) from e2e 10 to 12, only then vd66-object. Beside them the sprite showed green as it should. The purple is otex's texture in the state later modules leave it in. It has nothing to do with video seeking: task 645 saw the same colour while seeking was still broken. Tests: - e2e 10-12 + 66, old state (melektaus-atx): 66f FAIL, otex in front - e2e 10-12 + 66, with the fix: ALL PASSED, 81 checks, 66f sees #00ff01 and #0000ff - Prettier, eslint: clean Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
275 lines
11 KiB
JavaScript
275 lines
11 KiB
JavaScript
import { readFileSync } from 'node:fs';
|
||
import { uploadVideo } from '../lib/projects.mjs';
|
||
import { LANES, MAKE_OBJECT, pickAssetType } from './lib/scaffold.mjs';
|
||
|
||
/**
|
||
* Videos (#402). A WebM goes up and the worker turns it into an MP4 (in CI beside the PHP server,
|
||
* on stein2 in the container game-maker-video). "+ → Video" offers it with what it is; Use makes
|
||
* the asset and opens its tab, which shows the picture. A drag in the clip strip spans a clip, one
|
||
* undo step. A sprite wearing the clip plays only that stretch, over and over, without sound in the
|
||
* editor; Play starts it over from the clip's start and lets it be heard, Esc makes it quiet again.
|
||
*
|
||
* The test video is 2 s at 30 fps, a quarter each red, green, blue and white, with a tone. Colours
|
||
* are read from the video the tab holds and from the screen.
|
||
*
|
||
* Everything it adds carries a `vd66` id or is the asset it made, and goes again at the end.
|
||
*/
|
||
const SPRITE = 'vd66-sprite';
|
||
const OBJECT = 'vd66-object';
|
||
const LANE = LANES.video;
|
||
const COLORS = { red: '#fe0000', green: '#00ff01', blue: '#0000ff', white: '#ffffff' };
|
||
|
||
export async function video(t) {
|
||
const { check, sleep, json, mutate, click, clickText, drag, shot, showTab, play, stop } = t;
|
||
const waitFor = async (read, done, tries = 40) => {
|
||
let value = await read();
|
||
for (let i = 0; i < tries && !done(value); i++) {
|
||
await sleep(150);
|
||
value = await read();
|
||
}
|
||
return value;
|
||
};
|
||
|
||
// 66a. Converted on the server: the asked size, length and frame rate, as an MP4 with a poster.
|
||
const uploaded = await uploadVideo(
|
||
t.baseUrl,
|
||
readFileSync(new URL('./fixtures/colors.webm', import.meta.url)),
|
||
'colors.webm',
|
||
);
|
||
check(
|
||
'an uploaded WebM is converted to an MP4 with its size, length, frame rate and sound',
|
||
uploaded.status === 'ready' &&
|
||
/^[a-f0-9]{12}\/[a-f0-9]{64}\.mp4$/.test(uploaded.file) &&
|
||
uploaded.width === 320 &&
|
||
uploaded.height === 180 &&
|
||
Math.abs(uploaded.duration - 2) < 0.05 &&
|
||
uploaded.fps === 30 &&
|
||
uploaded.audio === true &&
|
||
uploaded.poster === uploaded.file.replace(/\.mp4$/, '.jpg'),
|
||
JSON.stringify(uploaded),
|
||
);
|
||
|
||
// 66b. "+ → Video" offers it with what it is, Use makes the asset and opens the tab.
|
||
await showTab('scene');
|
||
await clickText('+');
|
||
await pickAssetType(t, 'Video');
|
||
const picker = await waitFor(
|
||
() => json('__gm.videoPicker()'),
|
||
(p) => p?.files.includes(uploaded.file),
|
||
);
|
||
check(
|
||
'the + menu opens the video picker on what the server holds',
|
||
Boolean(picker?.files.includes(uploaded.file)),
|
||
JSON.stringify(picker),
|
||
);
|
||
const row = await json(`__gm.videoPickerRow(${JSON.stringify(uploaded.file)})`);
|
||
await click(row.x, row.y);
|
||
const picked = await json('__gm.videoPicker()');
|
||
check(
|
||
'a ready video says its size, length, frame rate and sound',
|
||
picked.detail.includes('320 × 180 · 2 s · 30 fps · with sound'),
|
||
picked.detail,
|
||
);
|
||
await shot('66-video-picker');
|
||
await clickText('Use video');
|
||
const assetId = await waitFor(
|
||
() => json('__gm.videoTab.current()'),
|
||
(id) => Boolean(id),
|
||
);
|
||
const asset = await json(`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(assetId)})`);
|
||
check(
|
||
'Use makes a video asset of it and opens its tab',
|
||
asset?.type === 'video' &&
|
||
asset.file === uploaded.file &&
|
||
asset.width === 320 &&
|
||
asset.fps === 30 &&
|
||
asset.name === 'colors',
|
||
JSON.stringify(asset),
|
||
);
|
||
|
||
// 66c. The tab shows the picture: red at the start, read from the video and from the screen.
|
||
const preview = await waitFor(
|
||
() => json('__gm.videoTab.preview()'),
|
||
(p) => p?.shown && p.pixel?.[0] > 200,
|
||
);
|
||
const shownColor = (p) => json(`__gm.pixelAt(${Math.round(p.x + p.w / 2)}, ${Math.round(p.y + p.h / 2)})`);
|
||
check(
|
||
'the tab shows the video, its first frame red',
|
||
preview?.shown &&
|
||
preview.pixel[0] > 200 &&
|
||
preview.pixel[1] < 50 &&
|
||
(await shownColor(preview)) === COLORS.red,
|
||
JSON.stringify({ preview, screen: await shownColor(preview) }),
|
||
);
|
||
|
||
// 66d. A drag in the strip spans a clip from 0.6 s to 1.4 s, which is then picked; one undo step.
|
||
const from = await json('__gm.videoTab.stripAt(0.6)');
|
||
const to = await json('__gm.videoTab.stripAt(1.4)');
|
||
await drag(from.x, from.y, to.x, to.y);
|
||
await sleep(300);
|
||
const clips = await json('__gm.videoTab.clips()');
|
||
const clip = clips[0];
|
||
check(
|
||
'a drag in the strip spans a clip, which is picked',
|
||
clips.length === 1 &&
|
||
Math.abs(clip.props.start - 0.6) < 0.02 &&
|
||
Math.abs(clip.props.end - 1.4) < 0.02 &&
|
||
(await json('__gm.videoTab.pickedClip()')) === clip.id,
|
||
JSON.stringify({ clips, picked: await json('__gm.videoTab.pickedClip()') }),
|
||
);
|
||
const atClip = await waitFor(
|
||
() => json('__gm.videoTab.preview()'),
|
||
(p) => p?.pixel?.[1] > 200 && p.pixel[0] < 50,
|
||
);
|
||
check(
|
||
'the tab stands at the picked clip’s start, green',
|
||
atClip.pixel[1] > 200 && atClip.pixel[0] < 50,
|
||
JSON.stringify(atClip),
|
||
);
|
||
await t.undo();
|
||
const undone = await json('__gm.videoTab.clips()');
|
||
await t.redo();
|
||
check(
|
||
'the clip is one undo step',
|
||
undone.length === 0 && (await json('__gm.videoTab.clips()')).length === 1,
|
||
JSON.stringify(undone),
|
||
);
|
||
|
||
// 66e. The undo left the video picked; a click on the band picks the clip again. Play in the tab
|
||
// runs the clip and moves the playhead; Stop holds it.
|
||
const band = await json('__gm.videoTab.stripAt(1)');
|
||
await click(band.x, band.y);
|
||
check('a click on its band picks the clip', (await json('__gm.videoTab.pickedClip()')) === clip.id);
|
||
const playButton = await json('__gm.videoTab.playButton()');
|
||
await click(playButton.x, playButton.y);
|
||
await sleep(500);
|
||
const running = await json(
|
||
'({ playing: __gm.videoTab.playing(), head: __gm.videoTab.playhead(), button: __gm.videoTab.playButton().text })',
|
||
);
|
||
check(
|
||
'Play in the tab plays the picked clip from its start',
|
||
running.playing && running.head > 0.6 && running.head < 1.4 && running.button === 'Stop',
|
||
JSON.stringify(running),
|
||
);
|
||
await click(playButton.x, playButton.y);
|
||
check('Stop holds it', (await json('__gm.videoTab.playing()')) === false);
|
||
|
||
// 66e-ii. With the clip picked (#403), a drag in the track off its handles or in the strip beside
|
||
// its band changes nothing; one on its band moves it whole, one undo step. A click in the
|
||
// track beside it picks the video, on it the clip again.
|
||
const track = (s) => json(`__gm.videoTab.trackAt(${s})`);
|
||
const strip = (s) => json(`__gm.videoTab.stripAt(${s})`);
|
||
const clipsNow = async () => JSON.stringify(await json('__gm.videoTab.clips()'));
|
||
const spanBefore = await clipsNow();
|
||
for (const [at, from, to] of [
|
||
[track, 1, 1.8],
|
||
[strip, 1.6, 1.9],
|
||
]) {
|
||
const a = await at(from);
|
||
const b = await at(to);
|
||
await drag(a.x, a.y, b.x, b.y);
|
||
}
|
||
const untouched = await clipsNow();
|
||
const bandFrom = await strip(1);
|
||
const bandTo = await strip(1.2);
|
||
await drag(bandFrom.x, bandFrom.y, bandTo.x, bandTo.y);
|
||
await sleep(150);
|
||
const moved = (await json('__gm.videoTab.clips()'))[0];
|
||
await t.undo();
|
||
const undoneMove = await clipsNow();
|
||
const besideTrack = await track(1.8);
|
||
await click(besideTrack.x, besideTrack.y);
|
||
const pickedBeside = await json('__gm.videoTab.pickedClip()');
|
||
const onTrack = await track(1);
|
||
await click(onTrack.x, onTrack.y);
|
||
check(
|
||
'with the clip picked a drag off its handles changes nothing, on its band it moves the clip whole; a click in the track picks',
|
||
untouched === spanBefore &&
|
||
Math.abs(moved?.props.start - 0.8) < 0.02 &&
|
||
Math.abs(moved?.props.end - 1.6) < 0.02 &&
|
||
undoneMove === spanBefore &&
|
||
pickedBeside === null &&
|
||
(await json('__gm.videoTab.pickedClip()')) === clip.id,
|
||
JSON.stringify({ spanBefore, untouched, moved, undoneMove, pickedBeside }),
|
||
);
|
||
await shot('66-video-tab');
|
||
|
||
// 66f. A sprite wearing the clip shows only its stretch, over and over: green and blue, never red
|
||
// or white, without sound in the editor.
|
||
await showTab('scene');
|
||
await mutate(`
|
||
const p = __gm.state.project;
|
||
const scene = __gm.state.activeScene;
|
||
${MAKE_OBJECT}
|
||
p.assets.unshift({ id: '${SPRITE}', type: 'sprite', name: '${SPRITE}', props: { width: 320, height: 180, color: 0xffffff, texture: ${JSON.stringify(clip.id)}, shader: '' } });
|
||
scene.objects.push(make('${OBJECT}', '${SPRITE}', '', ${LANE}, 'video'));
|
||
__gm.state.select(null);
|
||
__gm.lookAt(0, ${LANE});`);
|
||
await waitFor(
|
||
() => json('__gm.videos()'),
|
||
(players) => players.some((p) => p.ready),
|
||
);
|
||
const seen = new Set();
|
||
for (let i = 0; i < 14; i++) {
|
||
const r = await json(`__gm.objectRect('${OBJECT}')`);
|
||
seen.add(await json(`__gm.pixelAt(${Math.round(r.x)}, ${Math.round(r.y)})`));
|
||
await sleep(120);
|
||
}
|
||
const players = await json('__gm.videos()');
|
||
check(
|
||
'a sprite wearing the clip plays only its stretch, round and round',
|
||
seen.has(COLORS.green) && seen.has(COLORS.blue) && !seen.has(COLORS.red) && !seen.has(COLORS.white),
|
||
JSON.stringify([...seen]),
|
||
);
|
||
check(
|
||
'the editor plays it without sound',
|
||
players.length === 1 && players[0].muted && !players[0].paused && players[0].key.includes('#0.6~1.4~1~1'),
|
||
JSON.stringify(players),
|
||
);
|
||
await shot('66-video-sprite');
|
||
|
||
// 66g. Play starts it over from the clip's start and lets it be heard; Esc makes it quiet again.
|
||
await play();
|
||
await sleep(200);
|
||
const heard = (await json('__gm.videos()'))[0];
|
||
check(
|
||
'Play starts the clip over and lets it be heard',
|
||
heard && !heard.muted && !heard.paused && heard.time >= 0.6 && heard.time < 1.1,
|
||
JSON.stringify(heard),
|
||
);
|
||
await stop();
|
||
await sleep(200);
|
||
const quiet = (await json('__gm.videos()'))[0];
|
||
check('Esc makes it a quiet preview again', quiet && quiet.muted, JSON.stringify(quiet));
|
||
|
||
// 66h. The sprite picked but not in the scene: only the inspector's preview wears the video. It
|
||
// asks for it every frame, so the player is not let go after 2 s; a let-go texture used to
|
||
// leave the preview drawing a destroyed one, and the batcher threw on every frame.
|
||
await mutate(`
|
||
const p = __gm.state.project;
|
||
for (const holder of p.assets) if (holder.objects) holder.objects = holder.objects.filter((o) => o.id !== '${OBJECT}');
|
||
__gm.state.select({ kind: 'asset', assetId: '${SPRITE}' });`);
|
||
const logFrom = t.consoleLog.length;
|
||
await sleep(4500);
|
||
const inspected = await json('__gm.inspectorSpritePreview()');
|
||
const thrown = t.consoleLog.slice(logFrom).filter((line) => /EXCEPTION|alphaMode/.test(line));
|
||
const kept = await json('__gm.videos()');
|
||
const inPreview = inspected && (await shownColor(inspected));
|
||
check(
|
||
'a sprite shown only in the inspector keeps its video playing there',
|
||
thrown.length === 0 &&
|
||
inspected?.animates === true &&
|
||
kept.length === 1 &&
|
||
kept[0].ready &&
|
||
(inPreview === COLORS.green || inPreview === COLORS.blue),
|
||
JSON.stringify({ thrown, inspected, kept, inPreview }),
|
||
);
|
||
|
||
await mutate(`
|
||
const p = __gm.state.project;
|
||
for (const holder of p.assets) if (holder.objects) holder.objects = holder.objects.filter((o) => o.id !== '${OBJECT}');
|
||
p.assets = p.assets.filter((a) => a.id !== '${SPRITE}' && a.id !== ${JSON.stringify(assetId)});
|
||
__gm.state.select(null);
|
||
__gm.lookAt(0, 0);`);
|
||
}
|