Files
feuerwerk/tests/viewport/fakeStream.ts
T
adminandClaude Opus 5.5 43fd22a763 Keep a driven video ready while its sprite is off (#404)
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>
2026-10-11 15:37:16 +02:00

111 lines
4.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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;
}
},
);
}