Files
adminandClaude Opus 5.5 87b2ee9402 Record every frame with Real time off on Start recording (#423)
At 4K and 60 fps the user's computer reaches about 39 fps in real time.
Reading each frame back from the GPU takes longer than a frame, and
neither reading back later nor the canvas route helped. With Real time
off the run moves on by one frame of the recording at a time, however
long the computer takes, so the file has every frame.

- Start recording has a new check Real time, on by default. Only the
  node records so, the editor recording stays in real time.
- SteadyPace in recording.ts is the pace as a pure class. A step is
  1/fps once the frame before is taken and the sound up to it is made,
  otherwise 0 and the run holds. A frame's time is its number.
- The sound is made apart, as decided in the issue. AudioSwitch routes
  what the run plays to the live library, or to the offline library of
  a SteadySound while such a recording lasts. That library has the same
  mix, buses, FX and limiter and is rendered up to each next frame.
- A driven video holds at every picture asked for, and the frame waits
  for it. Its sound is decoded from the MP4 and played in step through
  its bus.
- Two Chrome quirks measured and worked around. A held video hands
  over the picture of a seek before seeked, so the frame callback is
  asked for when the seek begins. A seek right onto where a picture
  begins shows that one or the one before, so such a seek lands half a
  millisecond further. Together 30 s became 10.7 s for 2 s of video.
- The notice tells how far the recording has got, and the line at the
  end how long it took.

Deviations and limits:

- Input is not ignored, it still reaches the game, which runs slower.
- At most ten minutes per recording. Chrome holds the whole buffer of
  the OfflineAudioContext while rendering, 230 MB for ten minutes.
- A free video plays on in real time and does not fit. A sound playing
  before the start stays at the speakers.

Found along the way:

- A check in e2e 60 was red since 77365f1. It expected one log line,
  the start line of the recording came on top. CI was off, so nobody
  saw it. The expectation is updated.

Tests:

- First gate run red: 1 test in node-catalog.test.ts still expected
  the settings of Start recording without realTime. Updated.
- Second gate run on melektaus-atx green: 310 files,
  4818 tests.
- e2e 66 + 68 all passed, 50 checks, 68j new with 3 checks. ffmpeg
  reads all 120 frames 1/60 s apart, the colour changes at frames
  30/60/90, the video's sound from the first frame, the blip 8 ms off.
- e2e 60 (20 checks) and 67 green.
- Counter-check without the pace: all three 68j checks red, 32 of 119
  frames, colour changes at 33/62/96, no sound. AudioSwitch's unit test
  goes red when the ids are mixed up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:57:15 +02:00

517 lines
20 KiB
TypeScript
Raw Permalink 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.
import { describe, expect, it } from 'vitest';
import {
CURSOR_SIZE,
cursorCss,
cursorKeyword,
cursorKeywords,
cursorShape,
cursorShapes,
cursorSvgAt,
readCursor,
} from '../../src/core/recording/cursors';
import { editorRecordingPlan, editorRecordingSettingsOf } from '../../src/core/recording/editorRecording';
import {
avcCodec,
editorRecordingSize,
elapsedText,
fitPicture,
FrameGate,
FramePace,
i420Layout,
isKeyFrame,
pictureTransform,
RecordingClock,
recordingName,
recordingSettingsOf,
recordingSize,
SteadyPace,
} from '../../src/core/recording/recording';
import { requireNodeDef } from '../../src/core/commands/require';
import { runDef, fakeContext } from '../helpers';
describe('recording settings (#405)', () => {
it('names a recording after its start when no name is given', () => {
const at = new Date(2026, 9, 11, 16, 42, 5);
expect(recordingName('', at)).toBe('Recording 2026-10-11 16:42:05');
expect(recordingName(' ', at)).toBe('Recording 2026-10-11 16:42:05');
expect(recordingName(' Boss fight ', at)).toBe('Boss fight');
});
it('reads what the node hands over, an unknown choice as the default', () => {
expect(recordingSettingsOf(' Run ', '1080p', '30', { sound: false, adopt: false })).toEqual({
name: 'Run',
resolution: '1080p',
fps: 30,
sound: false,
convert: true,
adopt: false,
realTime: true,
});
// The flags left out are on, as in "Start recording…".
expect(recordingSettingsOf('', 'huge', 25)).toEqual({
name: '',
resolution: 'view',
fps: 60,
sound: true,
convert: true,
adopt: true,
realTime: true,
});
expect(recordingSettingsOf('', '4k', '60', { realTime: false }).realTime).toBe(false);
});
it('records the game view at its size on screen, even on both sides', () => {
expect(recordingSize('view', { w: 1279, h: 721 }, { w: 1279, h: 721 })).toEqual({ w: 1280, h: 722 });
// A 5K screen is held to 4K.
expect(recordingSize('view', { w: 5120, h: 2880 }, { w: 5120, h: 2880 })).toEqual({ w: 3840, h: 2160 });
});
it('holds the height of 720p to 4K and takes the width from the camera', () => {
expect(recordingSize('1080p', { w: 800, h: 450 }, { w: 800, h: 450 })).toEqual({ w: 1920, h: 1080 });
expect(recordingSize('720p', { w: 400, h: 400 }, { w: 400, h: 400 })).toEqual({ w: 720, h: 720 });
// 4:3 at 720p, and the camera's whole picture rules where the screen cuts it.
expect(recordingSize('720p', { w: 300, h: 400 }, { w: 640, h: 480 })).toEqual({ w: 960, h: 720 });
expect(recordingSize('1440p', { w: 800, h: 450 }, { w: 800, h: 450 })).toEqual({ w: 2560, h: 1440 });
expect(recordingSize('4k', { w: 800, h: 450 }, { w: 800, h: 450 })).toEqual({ w: 3840, h: 2160 });
expect(recordingSize('4k', { w: 400, h: 400 }, { w: 400, h: 400 })).toEqual({ w: 2160, h: 2160 });
});
it('scales a camera wider than 16:9 down to fit 4K', () => {
// 21:9 at 4K would be 5040 wide.
expect(recordingSize('4k', { w: 2100, h: 900 }, { w: 2100, h: 900 })).toEqual({ w: 3840, h: 1646 });
// At 1440p it still fits: 3360 × 1440.
expect(recordingSize('1440p', { w: 2100, h: 900 }, { w: 2100, h: 900 })).toEqual({ w: 3360, h: 1440 });
});
it('lays the I420 planes of a frame read back from the GPU out on whole texels', () => {
// 1920 is a multiple of 8, 1080 / 2 rows of chroma fill 270 texel rows a plane.
expect(i420Layout(1920, 1080)).toEqual({
stride: 1920,
chromaRows: 270,
texels: { w: 480, h: 1620 },
bytes: 1920 * 1620,
layout: [
{ offset: 0, stride: 1920 },
{ offset: 1920 * 1080, stride: 960 },
{ offset: 1920 * 1350, stride: 960 },
],
});
// 1646 is not: a luma row takes 1648 bytes. 722 / 2 = 361 chroma rows, odd, so the last texel
// row of each plane is half used.
const odd = i420Layout(1646, 722);
expect(odd.stride).toBe(1648);
expect(odd.chromaRows).toBe(181);
expect(odd.texels).toEqual({ w: 412, h: 722 + 362 });
expect(odd.layout[2]!.offset - odd.layout[1]!.offset).toBe(1648 * 181);
expect(odd.layout[1]!.stride * 361).toBeLessThanOrEqual(1648 * 181);
expect(odd.bytes).toBe(odd.layout[2]!.offset + 1648 * 181);
});
it('picks the lowest H.264 level that takes the frames', () => {
expect(avcCodec(1280, 720, 30)).toBe('avc1.64001f');
expect(avcCodec(1920, 1080, 30)).toBe('avc1.640028');
expect(avcCodec(1920, 1080, 60)).toBe('avc1.64002a');
expect(avcCodec(2560, 1440, 60)).toBe('avc1.640033');
expect(avcCodec(3840, 2160, 60)).toBe('avc1.640034');
});
it('starts a group every two seconds', () => {
expect([0, 1, 119, 120, 240].map((i) => isKeyFrame(i, 60))).toEqual([true, false, false, true, true]);
expect(isKeyFrame(60, 30)).toBe(true);
});
});
describe('RecordingClock', () => {
it('counts from the start and leaves the editor pause out', () => {
const clock = new RecordingClock();
expect(clock.at(500)).toBe(0);
clock.start(1000);
expect(clock.at(1500)).toBe(500);
clock.pause(2000);
expect(clock.at(5000)).toBe(1000);
clock.resume(6000);
expect(clock.at(6500)).toBe(1500);
// Pausing twice and resuming once still takes out the whole pause.
clock.pause(7000);
clock.pause(7500);
clock.resume(8000);
expect(clock.at(8000)).toBe(2000);
});
it('leaves a hold before the first frame out of the time, not off it', () => {
const clock = new RecordingClock();
clock.pause(0);
clock.resume(2000);
clock.start(2000);
expect(clock.at(2500)).toBe(500);
});
});
describe('FrameGate', () => {
const taken = (fps: number, frameMs: number, seconds: number, jitter = 0) => {
const gate = new FrameGate(fps);
let count = 0;
for (let i = 0; i * frameMs < seconds * 1000; i++) {
const wobble = jitter ? ((i * 7919) % 11) / 10 - 0.5 : 0;
if (gate.take(Math.max(0, i * frameMs + wobble * jitter))) count++;
}
return count;
};
it('takes every frame at 60 on a 60 Hz screen, however unevenly they come', () => {
expect(taken(60, 1000 / 60, 10)).toBe(600);
expect(taken(60, 1000 / 60, 10, 6)).toBe(600);
});
it('takes every other at 30', () => {
expect(taken(30, 1000 / 60, 10)).toBe(300);
});
it('takes about 60 a second from a 144 Hz screen', () => {
expect(Math.abs(taken(60, 1000 / 144, 10) - 600)).toBeLessThanOrEqual(6);
});
const gateAfter = (fps: number, gaps: number[], seconds: number) => {
const gate = new FrameGate(fps);
for (let i = 0, ms = 0; ms < seconds * 1000; ms += gaps[i++ % gaps.length]!) gate.take(ms);
return gate;
};
it('counts no frame skipped while the screen keeps up', () => {
expect(gateAfter(60, [1000 / 60], 10).skipped).toBe(0);
expect(gateAfter(30, [1000 / 60], 10).skipped).toBe(0);
expect(gateAfter(60, [1000 / 144], 10).skipped).toBeLessThanOrEqual(1);
});
it('counts the frames a slow screen skips, as many as the recording misses', () => {
// As in a 4K recording on the user's machine: 18 and 35 ms in turn, about 38 a second.
const slow = gateAfter(60, [18, 35], 10);
expect(slow.slots).toBeGreaterThanOrEqual(599);
expect(slow.slots).toBeLessThanOrEqual(601);
expect(slow.skipped).toBe(slow.slots - 378);
// A 50 Hz screen leaves out one in six of 60, however evenly the frames come.
const fifty = gateAfter(60, [20], 10);
expect(Math.abs(fifty.skipped - 100)).toBeLessThanOrEqual(2);
});
});
describe('FramePace', () => {
/** What `behind` said, frame by frame, of a recording that misses `missedPerSecond`. */
const said = (fps: number, missedPerSecond: (s: number) => number, seconds: number) => {
const pace = new FramePace(fps);
const told: { at: number; got: number }[] = [];
let missed = 0;
for (let ms = 0; ms <= seconds * 1000; ms += 1000 / fps) {
missed += missedPerSecond(ms / 1000) / fps;
const got = pace.behind(ms, Math.round(missed));
if (got !== null) told.push({ at: Math.round(ms), got });
}
return told;
};
it('says once that a recording falls behind, with the frames a second it gets', () => {
const told = said(60, () => 22, 20);
expect(told).toHaveLength(1);
expect(told[0]!.got).toBe(38);
// It has settled for a second and then judged two.
expect(told[0]!.at).toBeGreaterThanOrEqual(3000);
expect(told[0]!.at).toBeLessThan(3100);
});
it('lets a stall while it starts and a few frames missed pass', () => {
// Half a second lost at the start, then 5 % missed: never more than a tenth over a span.
expect(said(60, (s) => (s < 0.5 ? 60 : 3), 20)).toEqual([]);
});
it('says at the end that a recording stutters only when it missed frames after it settled', () => {
const after = (missedPerSecond: (s: number) => number, seconds: number) => {
const pace = new FramePace(60);
let missed = 0;
for (let ms = 0; ms <= seconds * 1000; ms += 1000 / 60) {
missed += missedPerSecond(ms / 1000) / 60;
pace.behind(ms, Math.round(missed));
}
return pace.stutters;
};
// A stall while it starts, in a short recording too: e2e 67 missed 3 of 37 frames so.
expect(after((s) => (s < 0.3 ? 60 : 0), 1.2)).toBe(false);
expect(after((s) => (s < 0.5 ? 60 : 1), 20)).toBe(false);
expect(after(() => 22, 13.8)).toBe(true);
expect(after(() => 6, 20)).toBe(true);
// Shorter than its first second: nothing to judge.
expect(after(() => 30, 0.9)).toBe(false);
});
it('says it when a recording falls behind only later', () => {
const told = said(30, (s) => (s < 7 ? 0 : 12), 20);
expect(told).toHaveLength(1);
expect(told[0]!.got).toBe(18);
expect(told[0]!.at).toBeGreaterThan(7000);
});
});
describe('SteadyPace (#423)', () => {
it('moves the run one frame at a time, each frame timed by its number', () => {
const pace = new SteadyPace(60);
const times: number[] = [];
for (let frame = 0; frame < 120; frame++) {
expect(pace.step(true)).toBeCloseTo(1 / 60, 12);
times.push(pace.ms);
pace.soundMade(pace.soundTo);
pace.took();
}
expect(pace.frames).toBe(120);
expect(pace.seconds).toBe(2);
times.forEach((ms, i) => expect(ms).toBeCloseTo((i * 1000) / 60, 9));
});
it('holds the run while a frame is owed, its sound not made, or the host is not ready', () => {
const pace = new SteadyPace(30);
expect(pace.step(false)).toBe(0);
expect(pace.step(true)).toBeCloseTo(1 / 30, 12);
expect(pace.owes).toBe(true);
// The frame drawn is not taken yet.
expect(pace.step(true)).toBe(0);
expect(pace.soundTo).toBeCloseTo(1 / 30, 12);
pace.took();
expect(pace.owes).toBe(false);
// Its sound is not made up to the next frame yet.
expect(pace.step(true)).toBe(0);
pace.soundMade(0.02);
expect(pace.step(true)).toBe(0);
// A suspend lands a little after the frame, on the next block of 128.
pace.soundMade(1664 / 48000);
expect(pace.step(true)).toBeCloseTo(1 / 30, 12);
expect(pace.ms).toBeCloseTo(1000 / 30, 9);
});
it('takes a frame owed only once', () => {
const pace = new SteadyPace(60);
pace.took();
expect(pace.frames).toBe(0);
pace.step(true);
pace.took();
pace.took();
expect(pace.frames).toBe(1);
});
});
describe('pictureTransform', () => {
it('maps the picture onto the whole frame', () => {
// A picture of 640x360 CSS pixels centred at (400, 300), recorded at 1080p.
const area = { x: 400, y: 300, w: 640, h: 360 };
const m = pictureTransform(area, { w: 1920, h: 1080 });
const at = (x: number, y: number) => ({ x: m.a * x + m.tx, y: m.d * y + m.ty });
expect(at(80, 120)).toEqual({ x: 0, y: 0 });
expect(at(720, 480)).toEqual({ x: 1920, y: 1080 });
expect(at(400, 300)).toEqual({ x: 960, y: 540 });
});
});
describe('the recording nodes', () => {
it('Start recording hands its settings over and flows on', () => {
const ctx = fakeContext();
const result = runDef('StartRecording', ctx, { name: 'Intro', resolution: '720p', fps: '30' }, {});
expect(result.next).toBe('exec');
expect(ctx.recordings).toEqual([
{ name: 'Intro', resolution: '720p', fps: 30, sound: true, convert: true, adopt: true, realTime: true },
]);
});
it('Start recording asks for the game view at 60, with sound, converted and imported by default', () => {
const ctx = fakeContext();
runDef('StartRecording', ctx, {}, {});
expect(ctx.recordings).toEqual([
{ name: '', resolution: 'view', fps: 60, sound: true, convert: true, adopt: true, realTime: true },
]);
});
it('Start recording hands its flags over', () => {
const ctx = fakeContext();
runDef('StartRecording', ctx, { sound: false, convert: true, adopt: false, realTime: false }, {});
expect(ctx.recordings).toEqual([
{ name: '', resolution: 'view', fps: 60, sound: false, convert: true, adopt: false, realTime: false },
]);
});
it('Start recording offers the flags of "Start recording…", all on', () => {
const fields = requireNodeDef('StartRecording').inputs.flatMap((port) =>
port.field ? [port.field] : [],
);
expect(fields.slice(3).map((f) => [f.label, f.default])).toEqual([
['Sound', true],
['Convert', true],
['Import after conversion', true],
['Real time', true],
]);
});
it('Stop recording asks for the stop and flows on', () => {
const ctx = fakeContext();
expect(runDef('StopRecording', ctx, {}, {}).next).toBe('exec');
expect(ctx.recordings).toEqual(['stop']);
});
});
describe('recording the editor', () => {
it('records the whole canvas, an odd row or column cut off', () => {
expect(editorRecordingSize({ w: 2560, h: 1369 })).toEqual({ w: 2560, h: 1368 });
expect(editorRecordingSize({ w: 1281, h: 721 })).toEqual({ w: 1280, h: 720 });
});
it('puts the canvas where it is while its size is the one it started with', () => {
expect(fitPicture({ w: 1281, h: 720 }, { w: 1280, h: 720 })).toEqual({ x: 0, y: 0, w: 1281, h: 720 });
});
it('fits a resized window in whole and centres it', () => {
expect(fitPicture({ w: 1000, h: 720 }, { w: 1280, h: 720 })).toEqual({ x: 140, y: 0, w: 1000, h: 720 });
expect(fitPicture({ w: 2560, h: 720 }, { w: 1280, h: 720 })).toEqual({ x: 0, y: 180, w: 1280, h: 360 });
});
it('records the screen as it is, at the resolution it is drawn at', () => {
expect(editorRecordingPlan({ w: 1280, h: 685 }, 1.25, 'screen')).toEqual({
size: { w: 1600, h: 856 },
resolution: 1.25,
});
});
it('draws sharper for a larger frame, fitted into it with the window’s shape', () => {
expect(editorRecordingPlan({ w: 1920, h: 1080 }, 1, '4k')).toEqual({
size: { w: 3840, h: 2160 },
resolution: 2,
});
expect(editorRecordingPlan({ w: 2560, h: 1305 }, 1, '4k')).toEqual({
size: { w: 3840, h: 1958 },
resolution: 1.5,
});
expect(editorRecordingPlan({ w: 1280, h: 800 }, 1, '1440p')).toEqual({
size: { w: 2304, h: 1440 },
resolution: 1.8,
});
});
it('keeps the screen’s resolution for a smaller frame, and draws at most four times as fine', () => {
expect(editorRecordingPlan({ w: 2560, h: 1440 }, 1, '1080p')).toEqual({
size: { w: 1920, h: 1080 },
resolution: 1,
});
expect(editorRecordingPlan({ w: 640, h: 360 }, 1, '4k')).toEqual({
size: { w: 3840, h: 2160 },
resolution: 4,
});
});
it('starts on the defaults, keeps what was chosen and never a name', () => {
expect(editorRecordingSettingsOf(null)).toEqual({
name: '',
resolution: 'screen',
fps: 60,
pointer: true,
sound: true,
convert: true,
adopt: true,
});
expect(
editorRecordingSettingsOf({
name: 'Old',
resolution: '4k',
fps: 30,
pointer: false,
sound: false,
convert: false,
adopt: 'no',
}),
).toEqual({
name: '',
resolution: '4k',
fps: 30,
pointer: false,
sound: false,
convert: false,
adopt: true,
});
expect(editorRecordingSettingsOf({ resolution: '8k', fps: 25 })).toMatchObject({
resolution: 'screen',
fps: 60,
});
});
it('shows the time it runs in minutes and seconds, hours once there are any', () => {
expect(elapsedText(0)).toBe('0:00');
expect(elapsedText(7999)).toBe('0:07');
expect(elapsedText(765_000)).toBe('12:45');
expect(elapsedText(3_723_000)).toBe('1:02:03');
});
it('draws the pointer the canvas shows, the arrow for what it does not know', () => {
expect(cursorShape('')).toBe(cursorShape('default'));
expect(cursorShape('inherit')).toBe(cursorShape('default'));
expect(cursorShape('ew-resize')).toEqual(cursorShape('col-resize'));
expect(cursorShape('ns-resize')!.turn).toBe(90);
expect(cursorShape('url(a.png) 4 4, grab')).toBe(cursorShape('grab'));
expect(cursorShape('grab')).not.toBe(cursorShape('default'));
expect(cursorShape('none')).toBeNull();
});
it('reads a cursor file: its viewBox is the picture, data-hot the point on the pointer', () => {
expect(cursorShape('pointer')).toMatchObject({
box: { x: -2, y: -2, w: 18, h: 22 },
hot: { x: 5, y: 0 },
});
expect(cursorShapes()).toHaveLength(11);
// As an editor might save it: other attributes first, commas, size in millimetres.
const edited = readCursor(
'<?xml version="1.0"?>\n<svg width="10mm" height="12mm" data-hot=" 3,4 " viewBox="1, 2, 30, 40" ' +
'xmlns="http://www.w3.org/2000/svg"><path d="M0 0" /></svg>',
45,
);
expect(edited).toMatchObject({ box: { x: 1, y: 2, w: 30, h: 40 }, hot: { x: 3, y: 4 }, turn: 45 });
expect(readCursor('<svg><path /></svg>')).toMatchObject({
box: { x: 0, y: 0, w: 24, h: 24 },
hot: { x: 0, y: 0 },
});
// Drawn at the size it is needed, whatever size the file names.
const sized = cursorSvgAt(edited, 2.5);
expect(sized).toContain('<svg width="75" height="100" data-hot=" 3,4 " viewBox="1, 2, 30, 40"');
expect(sized).not.toContain('mm"');
expect(sized).toContain('<path d="M0 0" />');
});
it('gives the editor each cursor file as a CSS cursor, turned where the shape is turned', () => {
const svgOf = (css: string): string =>
decodeURIComponent(/^url\("data:image\/svg\+xml,([^"]*)"\)/.exec(css)![1]!);
// The arrow at a unit per CSS pixel: its own picture, the hotspot counted from the picture's corner.
const arrow = cursorCss('default', undefined, 1);
const { box, hot } = cursorShape('default')!;
expect(arrow).toMatch(new RegExp(` ${hot.x - box.x} ${hot.y - box.y}, default$`));
expect(cursorKeyword(arrow)).toBe('default');
expect(cursorKeywords()).toEqual(expect.arrayContaining(['default', 'pointer', 'grab', 'nesw-resize']));
// The resize arrow, 26 × 14 around its middle, turned upright: 14 × 26, the hotspot in the middle.
const upright = cursorCss('ns-resize', undefined, 1);
expect(upright).toMatch(/ 7 13, ns-resize$/);
expect(svgOf(upright)).toContain('width="14" height="26" viewBox="-7 -13 14 26"');
expect(svgOf(upright)).toContain(
'<g transform="rotate(90 0 0)"><svg x="-13" y="-7" width="26" height="14"',
);
// Turned by 45°, the picture grows to hold the corners.
expect(cursorCss('nwse-resize', undefined, 1)).toMatch(/ 15 15, nwse-resize$/);
// Shown at CURSOR_SIZE: the arrow's 16 × 23 units are 12.8 × 18.4 pixels, rounded outwards from
// the corner at -1.6 to 14 × 19, and the viewBox still counts in the file's units.
expect(CURSOR_SIZE).toBe(0.8);
expect(cursorCss('default')).toMatch(/ 2 2, default$/);
expect(svgOf(cursorCss('default'))).toContain('width="14" height="19" viewBox="-2.5 -2.5 17.5 23.75"');
expect(cursorCss('ns-resize')).toMatch(/ 6 11, ns-resize$/);
// An edited file: what stands before its root is left out, the root is placed where its viewBox says.
const edited = readCursor(
'<?xml version="1.0"?>\n<!-- drawn by hand -->\n<svg x="5" viewBox="1 2 30 40" data-hot="3 4" ' +
'xmlns="http://www.w3.org/2000/svg"><path d="M0 0" /></svg>',
);
const css = cursorCss('grab', edited, 1);
expect(css).toMatch(/ 2 2, grab$/);
const svg = svgOf(css);
expect(svg).not.toContain('<?xml');
expect(svg).not.toContain('drawn by hand');
expect(svg).toContain('<svg x="1" y="2" width="30" height="40" viewBox="1 2 30 40"');
});
});