A cut between two videos flashed white: the sprite switched on had no picture yet, because its player only opened then or had been dropped meanwhile. A driven video now waits muted at its time while its sprite is off, in the editor and in a run. - A disabled sprite with Video clock Driven asks for its player without drawing it. A Free video still opens when shown. - A run's draw list keeps disabled driven sprites, marked as off. Before, collectWorldItems dropped every disabled object, so the game view never held a player for them. - VideoLibrary.wear() counts as asking. The sweeper dropped a held player after 2 to 4 s and it was loading again at the cut. - The speed of the asked time is read over a window that grows to 500 ms. Over 100 ms one 1/60 s step more or less read as 17 % off, the playback rate jumped and the sound dropped out. - A stream feeds every packet it has loaded to the decoder at once and waits once. One round trip per 20 ms packet let the decoder fall behind on a busy main thread, which made e2e 34 flaky in CI. Tests: - Unit streaming, video-library, video-follow: green, 4 new - e2e 34 + 66 (atx): ALL PASSED, stream skipped 0, 66m new - e2e 66 (atx), two runs: ALL PASSED, 25 checks, 66n new - Gate (atx): CHECK EXIT 0, 297 files, 4557 tests - Counter-checks: without the draw list fix 66n finds no player, without the wear() fix and with the old speed reading the new unit tests turn red Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
111 lines
4.3 KiB
TypeScript
111 lines
4.3 KiB
TypeScript
/**
|
||
* What streaming (#349) needs of a browser, stubbed: a copy as Opus in Ogg served with or without
|
||
* `Range`, and an `AudioDecoder` whose every sample is where it lies in the stream – so a check sees
|
||
* to the sample whether what is played is what it should be.
|
||
*/
|
||
import { vi } from 'vitest';
|
||
import { oggOpus } from '../../src/core/music/ogg';
|
||
|
||
export const PRE_SKIP = 312;
|
||
/** Bytes of each packet: big enough that a copy of a few seconds comes in several pieces. */
|
||
const PACKET_BYTES = 4000;
|
||
|
||
/** A copy of `seconds` seconds: CELT packets of 20 ms. */
|
||
export function fakeCopy(seconds: number, channels = 2): { bytes: Uint8Array; frames: number } {
|
||
const count = Math.ceil((seconds * 48000 + PRE_SKIP) / 960);
|
||
const packets = Array.from({ length: count }, () => {
|
||
const data = new Uint8Array(PACKET_BYTES);
|
||
data[0] = 31 << 3;
|
||
return { data, samples: 960 };
|
||
});
|
||
const frames = Math.round(seconds * 48000);
|
||
return { bytes: oggOpus({ channels, preSkip: PRE_SKIP, frames, packets }), frames };
|
||
}
|
||
|
||
export interface FakeServer {
|
||
/** Every request, as the range it asked for ('' for the whole file). */
|
||
asked: string[];
|
||
}
|
||
|
||
/** Serves these files by URL ending; `ranges` false answers every request with the whole file. */
|
||
export function serveCopies(files: Record<string, Uint8Array>, ranges = true): FakeServer {
|
||
const server: FakeServer = { asked: [] };
|
||
vi.stubGlobal('fetch', (url: string, init?: { headers?: Record<string, string> }) => {
|
||
const name = Object.keys(files).find((file) => url.endsWith(file));
|
||
if (!name) return Promise.resolve({ ok: false, status: 404 });
|
||
const bytes = files[name]!;
|
||
const range = init?.headers?.Range ?? '';
|
||
server.asked.push(range);
|
||
const [from, to] = range ? range.replace('bytes=', '').split('-').map(Number) : [0, bytes.length - 1];
|
||
if (!ranges || !range) return Promise.resolve(answer(200, bytes, null));
|
||
if (from! >= bytes.length) return Promise.resolve(answer(416, new Uint8Array(0), null));
|
||
const part = bytes.slice(from, Math.min(bytes.length, to! + 1));
|
||
return Promise.resolve(answer(206, part, `bytes ${from}-${from! + part.length - 1}/${bytes.length}`));
|
||
});
|
||
return server;
|
||
}
|
||
|
||
function answer(status: number, bytes: Uint8Array, contentRange: string | null) {
|
||
return {
|
||
ok: status >= 200 && status < 300,
|
||
status,
|
||
headers: { get: (name: string) => (name.toLowerCase() === 'content-range' ? contentRange : null) },
|
||
arrayBuffer: () => Promise.resolve(bytes.slice().buffer),
|
||
};
|
||
}
|
||
|
||
/** Every decoder made, with the timestamps of what it was given and whether it was closed. */
|
||
export const decoders: { fed: number[]; closed: boolean; channels: number }[] = [];
|
||
|
||
/**
|
||
* An `AudioDecoder` that gives back, for each packet, 960 samples counting up from where it starts;
|
||
* with `delayMs`, each on a task that long after, as on a busy page.
|
||
*/
|
||
export function stubDecoder(delayMs = 0): void {
|
||
decoders.length = 0;
|
||
vi.stubGlobal(
|
||
'EncodedAudioChunk',
|
||
class {
|
||
readonly timestamp: number;
|
||
constructor(init: { timestamp: number }) {
|
||
this.timestamp = init.timestamp;
|
||
}
|
||
},
|
||
);
|
||
vi.stubGlobal(
|
||
'AudioDecoder',
|
||
class {
|
||
state = 'unconfigured';
|
||
private readonly made = { fed: [] as number[], closed: false, channels: 0 };
|
||
constructor(private readonly init: { output: (data: unknown) => void }) {
|
||
decoders.push(this.made);
|
||
}
|
||
configure(config: { numberOfChannels: number }) {
|
||
this.state = 'configured';
|
||
this.made.channels = config.numberOfChannels;
|
||
}
|
||
decode(chunk: { timestamp: number }) {
|
||
const at = Math.round((chunk.timestamp * 48000) / 1e6);
|
||
this.made.fed.push(at);
|
||
const give = () => {
|
||
if (this.made.closed) return;
|
||
this.init.output({
|
||
numberOfFrames: 960,
|
||
numberOfChannels: this.made.channels,
|
||
copyTo: (into: Float32Array, { planeIndex }: { planeIndex: number }) => {
|
||
for (let i = 0; i < into.length; i++) into[i] = at + i + planeIndex * 1e6;
|
||
},
|
||
close: () => {},
|
||
});
|
||
};
|
||
if (delayMs > 0) setTimeout(give, delayMs);
|
||
else queueMicrotask(give);
|
||
}
|
||
close() {
|
||
this.state = 'closed';
|
||
this.made.closed = true;
|
||
}
|
||
},
|
||
);
|
||
}
|