Files
feuerwerk/tests/core/matroska.test.ts
T
adminandClaude Opus 5.5 043c224cc3 Rename Game Maker to Feuerwerk and serve it at /feuerwerk/ (#422)
The editor gets a name of its own that fits beside Brandmauer,
Glutspeicher and Feuerwehr. This is stage 1, 2 and 4 of #422: the
name, the URL and the stored state in the browser. Repository, CI
runners, containers and the GAME_MAKER_* variables follow in stage 3.

- All paths come from SITE_PATH in src/core/site.ts: the API endpoint,
  the data folders, the play page and the base in vite.config.ts. A
  later move is one line instead of fifteen.
- The window title, the sign-in screen, the playground and the README
  say Feuerwerk. The package, __feuerwerkPlay, the take worklet and the
  writing app in Ogg and Matroska follow.
- The session cookie is now feuerwerk_session, so everyone signs in
  once more.
- localStorage keys are now feuerwerk.*. moveOldStoredKeys copies the
  old game-maker.* keys over on the first start, so settings, panel
  sizes and save games survive.
- tools/ci/serve.sh serves the build under /feuerwerk/. The variable
  GAME_MAKER_BASE_URL keeps its name until stage 3.

Outside the repository, nockal.com/feuerwerk/ is a second symlink to
the same tree. nginx sends the bare /game-maker/ folder there with a
301. Everything else under /game-maker/ is still served directly, so
shared play links and the API keep working.

Finding: when nginx redirects a path without a trailing slash or with
a query, the Brandmauer answers with its own 307 to the same URL plus
a slash, placed after the query. A play link would lose its token that
way, which is why only the bare folder is redirected.

Tests:
- Gate on melektaus-atx: CHECK EXIT 0, 307 files, 4756 tests
  (4 new in site.test.ts).
- Counter-check: when the migration misses the old prefix, 3 of the
  4 new tests fail.
- e2e 60 under /feuerwerk/: ALL PASSED, 20 checks.
- From outside: /feuerwerk/ 200, /game-maker/ 301, an old play
  link 200.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:27:43 +02:00

203 lines
8.0 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { AVC_CODEC, colourOf, type MatroskaHead, MatroskaWriter } from '../../src/core/recording/matroska';
/** One element as read back: its id in hex, its bytes, and what it holds where it is a master. */
interface Read {
id: string;
data: Uint8Array;
children: Read[];
}
const MASTERS = new Set([
'1a45dfa3',
'18538067',
'1549a966',
'1654ae6b',
'ae',
'e0',
'55b0',
'e1',
'1f43b675',
]);
function vint(bytes: Uint8Array, at: number, keepMarker: boolean): { value: number; length: number } {
const first = bytes[at]!;
let length = 1;
while (length <= 8 && !(first & (0x80 >> (length - 1)))) length++;
let value = keepMarker ? first : first & (0xff >> length);
for (let i = 1; i < length; i++) value = value * 256 + bytes[at + i]!;
return { value, length };
}
function readAll(bytes: Uint8Array, from = 0, to = bytes.length): Read[] {
const out: Read[] = [];
let at = from;
while (at < to) {
const id = vint(bytes, at, true);
const size = vint(bytes, at + id.length, false);
const start = at + id.length + size.length;
const data = bytes.subarray(start, start + size.value);
const hex = id.value.toString(16);
out.push({ id: hex, data, children: MASTERS.has(hex) ? readAll(bytes, start, start + size.value) : [] });
at = start + size.value;
}
return out;
}
const find = (list: Read[], id: string) => list.find((e) => e.id === id);
const uintOf = (e: Read | undefined) => (e ? e.data.reduce((v, b) => v * 256 + b, 0) : NaN);
const textOf = (e: Read | undefined) => new TextDecoder().decode(e?.data);
/** Track, time relative to its cluster, key flag and payload of each block, with the cluster's time. */
function blocksOf(
file: Uint8Array,
): { cluster: number; track: number; ms: number; key: boolean; data: number[] }[] {
const segment = find(readAll(file), '18538067')!;
return segment.children
.filter((e) => e.id === '1f43b675')
.flatMap((cluster) => {
const start = uintOf(find(cluster.children, 'e7'));
return cluster.children
.filter((e) => e.id === 'a3')
.map((block) => ({
cluster: start,
track: block.data[0]! & 0x7f,
ms: start + new DataView(block.data.buffer, block.data.byteOffset + 1, 2).getInt16(0),
key: (block.data[3]! & 0x80) !== 0,
data: [...block.data.subarray(4)],
}));
});
}
const HEAD: MatroskaHead = {
video: {
codec: AVC_CODEC,
width: 1280,
height: 720,
private: Uint8Array.from([1, 100, 0, 31]),
fps: 30,
colour: colourOf({ matrix: 'bt709', transfer: 'iec61966-2-1', primaries: 'bt709', fullRange: false }),
},
audio: { channels: 2, private: new TextEncoder().encode('OpusHead'), preSkip: 312 },
durationMs: 1234.5,
app: 'feuerwerk',
};
/** The whole file: the head, then the clusters handed out before (`earlier`) and at the end. */
function fileOf(writer: MatroskaWriter, head: MatroskaHead = HEAD, earlier: Uint8Array[] = []): Uint8Array {
const taken = writer.take();
const { head: bytes, rest } = writer.finish(head);
const parts = [bytes, ...earlier, ...taken, ...rest];
const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
let at = 0;
for (const part of parts) {
out.set(part, at);
at += part.length;
}
return out;
}
describe('MatroskaWriter (#405)', () => {
it('writes a head that says what the tracks are and how long the file is', () => {
const writer = new MatroskaWriter(2);
writer.add(1, 0, true, Uint8Array.from([9]));
const file = fileOf(writer);
const [ebml, segment] = readAll(file);
expect(textOf(find(ebml!.children, '4282'))).toBe('matroska');
// The segment's size covers exactly the rest of the file.
expect(segment!.data.byteOffset + segment!.data.length).toBe(file.length);
const info = find(segment!.children, '1549a966')!;
expect(uintOf(find(info.children, '2ad7b1'))).toBe(1_000_000);
const duration = find(info.children, '4489')!;
expect(new DataView(duration.data.buffer, duration.data.byteOffset, 8).getFloat64(0)).toBe(1234.5);
const [video, audio] = find(segment!.children, '1654ae6b')!.children;
expect(textOf(find(video!.children, '86'))).toBe('V_MPEG4/ISO/AVC');
expect([...find(video!.children, '63a2')!.data]).toEqual([1, 100, 0, 31]);
const picture = find(video!.children, 'e0')!;
expect([uintOf(find(picture.children, 'b0')), uintOf(find(picture.children, 'ba'))]).toEqual([1280, 720]);
// A frame lasts a 30th of a second, in nanoseconds, so nothing has to guess the rate.
expect(uintOf(find(video!.children, '23e383'))).toBe(33_333_333);
// BT.709 pixels in the broadcast range, sRGB's transfer.
const colour = find(picture.children, '55b0')!.children;
expect(['55b1', '55b9', '55ba', '55bb'].map((id) => uintOf(find(colour, id)))).toEqual([1, 1, 13, 1]);
expect(textOf(find(audio!.children, '86'))).toBe('A_OPUS');
expect(textOf(find(audio!.children, '63a2'))).toBe('OpusHead');
// 312 samples at 48 kHz, in nanoseconds.
expect(uintOf(find(audio!.children, '56aa'))).toBe(6_500_000);
expect(uintOf(find(find(audio!.children, 'e1')!.children, '9f'))).toBe(2);
});
it('takes the colour from what the encoder says, BT.709 where it says nothing', () => {
expect(colourOf(null)).toEqual({ matrix: 1, transfer: 1, primaries: 1, fullRange: false });
expect(colourOf({ matrix: 'smpte170m', fullRange: true })).toEqual({
matrix: 6,
transfer: 1,
primaries: 1,
fullRange: true,
});
});
it('writes the blocks of both tracks in the order of their time, whatever order they come in', () => {
const writer = new MatroskaWriter(2);
writer.add(1, 0, true, Uint8Array.from([1]));
writer.add(1, 16_667, false, Uint8Array.from([2]));
writer.add(1, 33_333, false, Uint8Array.from([3]));
writer.add(2, 0, true, Uint8Array.from([10]));
writer.add(2, 20_000, true, Uint8Array.from([11]));
writer.add(2, 40_000, true, Uint8Array.from([12]));
const blocks = blocksOf(fileOf(writer));
expect(blocks.map((b) => [b.track, b.ms])).toEqual([
[1, 0],
[2, 0],
[1, 17],
[2, 20],
[1, 33],
[2, 40],
]);
expect(blocks.map((b) => b.data[0])).toEqual([1, 10, 2, 11, 3, 12]);
});
it('hands out a cluster once the next key frame starts another', () => {
const writer = new MatroskaWriter(1);
writer.add(1, 0, true, Uint8Array.from([1]));
writer.add(1, 1_000_000, false, Uint8Array.from([2]));
expect(writer.take()).toEqual([]);
writer.add(1, 2_000_000, true, Uint8Array.from([3]));
const first = writer.take();
expect(first).toHaveLength(1);
const blocks = blocksOf(fileOf(writer, HEAD, first));
expect(blocks.map((b) => [b.cluster, b.ms, b.key])).toEqual([
[0, 0, true],
[0, 1000, false],
[2000, 2000, true],
]);
});
it('starts a new cluster before a block would be too far from its start to be told', () => {
const writer = new MatroskaWriter(1);
writer.add(1, 0, true, Uint8Array.from([1]));
writer.add(1, 40_000_000, false, Uint8Array.from([2]));
const blocks = blocksOf(fileOf(writer));
expect(blocks.map((b) => [b.cluster, b.ms])).toEqual([
[0, 0],
[40_000, 40_000],
]);
});
it('does not hold the video back for ever when no sound comes', () => {
const writer = new MatroskaWriter(2);
for (let i = 0; i < 90; i++) writer.add(1, i * 33_333, i % 30 === 0, Uint8Array.from([i]));
// Three seconds of video, no sound: what is more than a second behind has gone into clusters.
expect(writer.take().length).toBeGreaterThan(0);
});
it('counts its bytes, the open cluster included, and leaves out a missing sound track', () => {
const writer = new MatroskaWriter(1);
writer.add(1, 0, true, new Uint8Array(100));
expect(writer.bytes).toBeGreaterThan(100);
const file = fileOf(writer, { ...HEAD, audio: null });
const segment = find(readAll(file), '18538067')!;
expect(find(segment.children, '1654ae6b')!.children).toHaveLength(1);
});
});