@1agh/maude 0.39.0 → 0.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/README.md +1 -1
  2. package/apps/studio/acp/bridge.ts +144 -5
  3. package/apps/studio/acp/index.ts +11 -1
  4. package/apps/studio/annotations-context-toolbar.tsx +44 -1
  5. package/apps/studio/annotations-layer.tsx +253 -3
  6. package/apps/studio/annotations-model.ts +107 -6
  7. package/apps/studio/api.ts +812 -64
  8. package/apps/studio/bin/_html-playwright.mjs +9 -1
  9. package/apps/studio/bin/_pdf-playwright.mjs +8 -1
  10. package/apps/studio/bin/_png-playwright.mjs +8 -1
  11. package/apps/studio/bin/_pw-launch.mjs +54 -0
  12. package/apps/studio/bin/_svg-playwright.mjs +8 -1
  13. package/apps/studio/bin/_video-playwright.mjs +452 -0
  14. package/apps/studio/bin/prep.sh +8 -1
  15. package/apps/studio/canvas-edit.ts +1885 -104
  16. package/apps/studio/canvas-lib.tsx +19 -0
  17. package/apps/studio/canvas-list-watch.ts +6 -2
  18. package/apps/studio/canvas-shell.tsx +27 -0
  19. package/apps/studio/client/app.jsx +1029 -30
  20. package/apps/studio/client/github.js +7 -0
  21. package/apps/studio/client/panels/TimelinePanel.jsx +860 -0
  22. package/apps/studio/client/panels/timeline-parse.js +229 -0
  23. package/apps/studio/client/panels/timeline-snap.js +55 -0
  24. package/apps/studio/client/styles/3-shell-maude.css +107 -0
  25. package/apps/studio/config.schema.json +14 -0
  26. package/apps/studio/context-menu.tsx +1 -1
  27. package/apps/studio/context.ts +113 -1
  28. package/apps/studio/dist/client.bundle.js +88 -16
  29. package/apps/studio/dist/comment-mount.js +1 -1
  30. package/apps/studio/dist/runtime/.min-sizes.json +11 -1
  31. package/apps/studio/dist/runtime/@remotion_media.js +491 -0
  32. package/apps/studio/dist/runtime/@remotion_player.js +56 -0
  33. package/apps/studio/dist/runtime/@remotion_transitions.js +300 -0
  34. package/apps/studio/dist/runtime/@remotion_transitions_clock-wipe.js +1 -0
  35. package/apps/studio/dist/runtime/@remotion_transitions_fade.js +1 -0
  36. package/apps/studio/dist/runtime/@remotion_transitions_flip.js +1 -0
  37. package/apps/studio/dist/runtime/@remotion_transitions_none.js +1 -0
  38. package/apps/studio/dist/runtime/@remotion_transitions_slide.js +1 -0
  39. package/apps/studio/dist/runtime/@remotion_transitions_wipe.js +1 -0
  40. package/apps/studio/dist/runtime/REMOTION-LICENSE.md +28 -0
  41. package/apps/studio/dist/runtime/remotion.js +42 -0
  42. package/apps/studio/dist/styles.css +1 -1
  43. package/apps/studio/exporters/_browser-bundles.ts +117 -0
  44. package/apps/studio/exporters/_runtime.ts +69 -0
  45. package/apps/studio/exporters/html.ts +4 -3
  46. package/apps/studio/exporters/index.ts +28 -2
  47. package/apps/studio/exporters/pdf.ts +4 -3
  48. package/apps/studio/exporters/png.ts +5 -3
  49. package/apps/studio/exporters/pptx.ts +6 -4
  50. package/apps/studio/exporters/svg.ts +9 -5
  51. package/apps/studio/exporters/video-encode-lib.ts +200 -0
  52. package/apps/studio/exporters/video-render-lib.ts +108 -0
  53. package/apps/studio/exporters/video.ts +184 -0
  54. package/apps/studio/http.ts +535 -27
  55. package/apps/studio/input-router.tsx +7 -1
  56. package/apps/studio/runtime-bundle.ts +30 -0
  57. package/apps/studio/server.ts +34 -9
  58. package/apps/studio/test/acp-bridge.test.ts +132 -0
  59. package/apps/studio/test/acp-commands.test.ts +8 -3
  60. package/apps/studio/test/annotations-roundtrip.test.ts +47 -0
  61. package/apps/studio/test/canvas-create-api.test.ts +76 -0
  62. package/apps/studio/test/canvas-edit.test.ts +90 -0
  63. package/apps/studio/test/canvas-list-watch.test.ts +49 -0
  64. package/apps/studio/test/canvas-media-drop.test.ts +30 -1
  65. package/apps/studio/test/canvas-origin-gate.test.ts +36 -0
  66. package/apps/studio/test/clip-addressing.test.ts +732 -0
  67. package/apps/studio/test/config-reload.test.ts +230 -0
  68. package/apps/studio/test/dns-rebinding-guard.test.ts +74 -0
  69. package/apps/studio/test/edit-persistence.test.ts +91 -0
  70. package/apps/studio/test/exporters/runtime.test.ts +59 -0
  71. package/apps/studio/test/file-lock.test.ts +84 -0
  72. package/apps/studio/test/fixtures/mock-acp-agent.mjs +24 -1
  73. package/apps/studio/test/fixtures/video-comp-fixture.tsx +82 -0
  74. package/apps/studio/test/timeline-parse.test.ts +127 -0
  75. package/apps/studio/test/timeline-snap.test.ts +85 -0
  76. package/apps/studio/test/video-asset.test.ts +163 -0
  77. package/apps/studio/test/video-comp-fixture.test.ts +50 -0
  78. package/apps/studio/test/video-comp.test.ts +168 -0
  79. package/apps/studio/test/video-render-bridge.test.ts +149 -0
  80. package/apps/studio/use-annotation-resize.tsx +6 -3
  81. package/apps/studio/use-canvas-media-drop.tsx +66 -4
  82. package/apps/studio/video-comp.tsx +444 -0
  83. package/apps/studio/whats-new.json +36 -0
  84. package/apps/studio/ws.ts +5 -0
  85. package/package.json +8 -8
  86. package/plugins/design/templates/_shell.html +25 -2
  87. package/plugins/design/templates/design-system-inspiration/_MAPPING.md +1 -1
@@ -0,0 +1,127 @@
1
+ // timeline-parse — DDR-148 Timeline sequence/keyframe parser.
2
+ // Pure regex parse of a video-comp's sequence structure + interpolate windows.
3
+
4
+ import { describe, expect, test } from 'bun:test';
5
+
6
+ import { parseCompTimeline } from '../client/panels/timeline-parse.js';
7
+
8
+ const COMP = `
9
+ const A = 45;
10
+ const XF = 15;
11
+ const B = 60;
12
+ const Intro = () => {
13
+ const frame = useCurrentFrame();
14
+ const o = interpolate(frame, [0, 14], [0, 1]);
15
+ const y = interpolate(frame, [10, 30], [24, 0]);
16
+ return <AbsoluteFill style={{ opacity: o }} />;
17
+ };
18
+ const Payoff = () => {
19
+ const frame = useCurrentFrame();
20
+ const s = interpolate(frame, [4, 20], [0.8, 1]);
21
+ return <AbsoluteFill style={{ transform: 's' }} />;
22
+ };
23
+ function Movie() {
24
+ return (
25
+ <TransitionSeries>
26
+ <TransitionSeries.Sequence durationInFrames={A}><Intro /></TransitionSeries.Sequence>
27
+ <TransitionSeries.Transition presentation={fade()} timing={linearTiming({ durationInFrames: XF })} />
28
+ <TransitionSeries.Sequence durationInFrames={B}><Payoff /></TransitionSeries.Sequence>
29
+ </TransitionSeries>
30
+ );
31
+ }
32
+ `;
33
+
34
+ describe('parseCompTimeline', () => {
35
+ test('parses TransitionSeries sequences with const durations + overlap math', () => {
36
+ const r = parseCompTimeline(COMP, 90);
37
+ expect(r.total).toBe(90);
38
+ expect(r.sequences).toHaveLength(2);
39
+ expect(r.sequences[0]).toMatchObject({ label: 'Intro', from: 0, duration: 45 });
40
+ // Payoff starts at 45 - 15 (the transition overlaps back) = 30.
41
+ expect(r.sequences[1]).toMatchObject({ label: 'Payoff', from: 30, duration: 60 });
42
+ });
43
+
44
+ test('attributes interpolate windows to their sequence, offset to absolute frames', () => {
45
+ const r = parseCompTimeline(COMP, 90);
46
+ // Intro's [0,14] + [10,30] stay at from=0.
47
+ expect(r.sequences[0].keyframes).toEqual([
48
+ { from: 0, to: 14 },
49
+ { from: 10, to: 30 },
50
+ ]);
51
+ // Payoff's [4,20] is offset by its from=30 → [34,50].
52
+ expect(r.sequences[1].keyframes).toEqual([{ from: 34, to: 50 }]);
53
+ });
54
+
55
+ test('plain <Sequence from={M} durationInFrames={N}> uses absolute from', () => {
56
+ const src =
57
+ 'function C(){ return <><Sequence from={20} durationInFrames={40}><Foo/></Sequence></>; }';
58
+ const r = parseCompTimeline(src, 60);
59
+ expect(r.sequences[0]).toMatchObject({ label: 'Foo', from: 20, duration: 40 });
60
+ });
61
+
62
+ test('a non-comp / unparseable source yields no sequences (scrub-only)', () => {
63
+ expect(parseCompTimeline('function C(){ return <div/>; }', 30).sequences).toEqual([]);
64
+ expect(parseCompTimeline('', 0).sequences).toEqual([]);
65
+ });
66
+
67
+ test('the comp meta total wins over the derived span', () => {
68
+ const r = parseCompTimeline(COMP, 200);
69
+ expect(r.total).toBe(200);
70
+ });
71
+
72
+ test('an <Audio> bed becomes its own row spanning the comp (DDR-148 music layer)', () => {
73
+ const src = `${COMP.replace(
74
+ '</TransitionSeries>',
75
+ '</TransitionSeries><Audio src="/.design/assets/music.mp3" />'
76
+ )}`;
77
+ const r = parseCompTimeline(src, 90);
78
+ expect(r.audio).toHaveLength(1);
79
+ expect(r.audio[0]).toMatchObject({ kind: 'audio', label: 'music.mp3', from: 0, duration: 90 });
80
+ });
81
+
82
+ test('a multi-comp canvas scopes the timeline to the media comp + its total', () => {
83
+ // Two VideoComps on one canvas: a plain intro (no media) + a reel (has
84
+ // <Audio>). The parser must show the REEL — its sequences, total, audio.
85
+ const src = `
86
+ const IT = 40; const RT = 120;
87
+ const Intro = () => <AbsoluteFill/>;
88
+ function Movie(){ return <TransitionSeries><TransitionSeries.Sequence durationInFrames={IT}><Intro/></TransitionSeries.Sequence></TransitionSeries>; }
89
+ const Clip = () => <AbsoluteFill/>;
90
+ function Reel(){ return (<AbsoluteFill><TransitionSeries><TransitionSeries.Sequence durationInFrames={RT}><Clip/></TransitionSeries.Sequence></TransitionSeries><Audio src="/x/song.mp3"/></AbsoluteFill>); }
91
+ export default function Canvas(){ return (<DesignCanvas>
92
+ <VideoComp component={Movie} durationInFrames={IT} fps={30} width={960} height={540} />
93
+ <VideoComp component={Reel} durationInFrames={RT} fps={30} width={960} height={540} />
94
+ </DesignCanvas>); }
95
+ `;
96
+ // The app passes the FIRST comp's duration (the intro, 40) — the parser must
97
+ // still pick the reel (media) and report its total (120) + audio.
98
+ const r = parseCompTimeline(src, 40);
99
+ expect(r.total).toBe(120);
100
+ expect(r.sequences).toEqual([expect.objectContaining({ label: 'Clip', duration: 120 })]);
101
+ expect(r.audio).toHaveLength(1);
102
+ expect(r.audio[0].label).toBe('song.mp3');
103
+ });
104
+
105
+ test('the Timeline scopes to the SELECTED artboard (follows the canvas)', () => {
106
+ const src = `
107
+ const IT = 40; const RT = 120;
108
+ const Intro = () => <AbsoluteFill/>;
109
+ function Movie(){ return <TransitionSeries><TransitionSeries.Sequence durationInFrames={IT}><Intro/></TransitionSeries.Sequence></TransitionSeries>; }
110
+ const Clip = () => <AbsoluteFill/>;
111
+ function Reel(){ return (<AbsoluteFill><TransitionSeries><TransitionSeries.Sequence durationInFrames={RT}><Clip/></TransitionSeries.Sequence></TransitionSeries><Audio src="/x/song.mp3"/></AbsoluteFill>); }
112
+ export default function Canvas(){ return (<DesignCanvas>
113
+ <DCArtboard id="intro" width={960} height={540}><VideoComp component={Movie} durationInFrames={IT} fps={30} width={960} height={540} /></DCArtboard>
114
+ <DCArtboard id="reel" width={960} height={540}><VideoComp component={Reel} durationInFrames={RT} fps={30} width={960} height={540} /></DCArtboard>
115
+ </DesignCanvas>); }
116
+ `;
117
+ // Selecting the intro artboard shows the INTRO — even though the reel has media.
118
+ const intro = parseCompTimeline(src, 40, 'intro');
119
+ expect(intro.total).toBe(40);
120
+ expect(intro.sequences).toEqual([expect.objectContaining({ label: 'Intro', duration: 40 })]);
121
+ expect(intro.audio).toHaveLength(0);
122
+ // Selecting the reel shows the reel + its music.
123
+ const reel = parseCompTimeline(src, 40, 'reel');
124
+ expect(reel.total).toBe(120);
125
+ expect(reel.audio).toHaveLength(1);
126
+ });
127
+ });
@@ -0,0 +1,85 @@
1
+ // timeline-snap.test.ts — DDR-150 Task 15 (Polish). The pure snap helpers behind
2
+ // clip move/trim: a dragged edge lands on the nearest second-tick / neighbor edge
3
+ // / playhead within a threshold, and Alt (threshold 0) turns it off.
4
+
5
+ import { describe, expect, test } from 'bun:test';
6
+
7
+ import {
8
+ computeSnapTargets,
9
+ snapFrame,
10
+ snapThresholdFrames,
11
+ } from '../client/panels/timeline-snap.js';
12
+
13
+ describe('computeSnapTargets', () => {
14
+ test('includes 0, comp end, second ticks, neighbor edges, playhead — excludes the moving clip', () => {
15
+ const targets = computeSnapTargets({
16
+ fps: 30,
17
+ totalFrames: 120,
18
+ clips: [
19
+ { from: 0, duration: 30 }, // clip 0 — the one being moved (excluded)
20
+ { from: 45, duration: 30 }, // clip 1 — edges 45 + 75 should appear
21
+ ],
22
+ movingIndex: 0,
23
+ playhead: 88,
24
+ });
25
+ expect(targets).toContain(0); // start
26
+ expect(targets).toContain(120); // comp end
27
+ expect(targets).toContain(30); // second tick (1s @ 30fps)
28
+ expect(targets).toContain(60); // second tick (2s)
29
+ expect(targets).toContain(90); // second tick (3s)
30
+ expect(targets).toContain(45); // neighbor clip start
31
+ expect(targets).toContain(75); // neighbor clip end
32
+ expect(targets).toContain(88); // playhead
33
+ // the MOVING clip's own edges (0 already a tick; its end 30 is also a tick,
34
+ // but must not be added as a *clip edge* — dedup keeps it single + sorted)
35
+ expect(targets).toEqual([...targets].sort((a, b) => a - b));
36
+ expect(new Set(targets).size).toBe(targets.length); // deduped
37
+ });
38
+
39
+ test('clamps targets to [0, totalFrames]', () => {
40
+ const targets = computeSnapTargets({
41
+ fps: 30,
42
+ totalFrames: 50,
43
+ clips: [{ from: 40, duration: 40 }], // end 80 is out of range → dropped
44
+ movingIndex: -1,
45
+ playhead: 200, // out of range → dropped
46
+ });
47
+ expect(Math.max(...targets)).toBeLessThanOrEqual(50);
48
+ expect(targets).not.toContain(80);
49
+ expect(targets).not.toContain(200);
50
+ });
51
+ });
52
+
53
+ describe('snapFrame', () => {
54
+ const targets = [0, 30, 45, 60, 90, 120];
55
+
56
+ test('snaps to the nearest target within threshold', () => {
57
+ expect(snapFrame(32, targets, 4)).toBe(30); // 2 away
58
+ expect(snapFrame(43, targets, 4)).toBe(45); // 2 away
59
+ });
60
+
61
+ test('leaves the candidate unchanged when nothing is in range', () => {
62
+ expect(snapFrame(52, targets, 4)).toBe(52); // nearest (45/60) is 7/8 away
63
+ });
64
+
65
+ test('picks the CLOSER of two in-range targets', () => {
66
+ // 47 is 2 from 45 and 13 from 60 → 45.
67
+ expect(snapFrame(47, targets, 20)).toBe(45);
68
+ });
69
+
70
+ test('threshold 0 (Alt override) never snaps', () => {
71
+ expect(snapFrame(30, targets, 0)).toBe(30);
72
+ expect(snapFrame(31, targets, 0)).toBe(31);
73
+ });
74
+ });
75
+
76
+ describe('snapThresholdFrames', () => {
77
+ test('converts an ~8px radius into frames by the row width', () => {
78
+ // 8px on a 800px row spanning 400 frames → ~4 frames.
79
+ expect(Math.round(snapThresholdFrames(800, 401, 8))).toBe(4);
80
+ });
81
+
82
+ test('degrades safely on a zero-width row', () => {
83
+ expect(Number.isFinite(snapThresholdFrames(0, 100))).toBe(true);
84
+ });
85
+ });
@@ -0,0 +1,163 @@
1
+ // POST /_api/asset — DDR-148 video/audio widening of the Phase 23 asset route.
2
+ //
3
+ // Two layers (mirroring asset-api.test.ts):
4
+ // (1) the pure magic-byte sniff (`sniffAssetType`) — the ONE gate that decides
5
+ // the stored type AND category (→ which cap applies); a header lie / SVG /
6
+ // script is caught here;
7
+ // (2) the streaming POST endpoint (boots a real server) — video/audio happy
8
+ // path, per-category cap (image 10 MB stays tight; video honors the
9
+ // MAUDE_ASSET_MAX_VIDEO_BYTES override), content-addressed dedupe.
10
+
11
+ import { describe, expect, test } from 'bun:test';
12
+ import { existsSync, readdirSync } from 'node:fs';
13
+ import { join } from 'node:path';
14
+
15
+ import { assetCapForCategory, sniffAssetType } from '../api.ts';
16
+ import { bootServer, killProc, makeSandbox, nextPort } from './_helpers.ts';
17
+
18
+ // ── Minimal valid magic-byte headers (padded so the ≥12-byte sniff has data). ──
19
+ const pad = (header: number[], len = 64): Uint8Array => {
20
+ const out = new Uint8Array(len);
21
+ out.set(header, 0);
22
+ return out;
23
+ };
24
+ const ascii = (s: string): number[] => [...s].map((c) => c.charCodeAt(0));
25
+
26
+ const MP4 = pad([0, 0, 0, 0x18, ...ascii('ftyp'), ...ascii('isom')]);
27
+ const MOV = pad([0, 0, 0, 0x14, ...ascii('ftyp'), ...ascii('qt ')]);
28
+ const M4A = pad([0, 0, 0, 0x18, ...ascii('ftyp'), ...ascii('M4A ')]);
29
+ const WEBM = pad([0x1a, 0x45, 0xdf, 0xa3]);
30
+ const MP3_ID3 = pad([...ascii('ID3'), 0x03, 0x00]);
31
+ const MP3_SYNC = pad([0xff, 0xfb, 0x90, 0x00]);
32
+ const WAV = pad([...ascii('RIFF'), 0, 0, 0, 0, ...ascii('WAVE')]);
33
+ const PNG = pad([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
34
+ const SVG = new TextEncoder().encode('<svg xmlns="http://www.w3.org/2000/svg"><script/></svg>');
35
+
36
+ describe('video-asset / sniffAssetType (the widened type gate)', () => {
37
+ test('recognizes video containers by magic bytes', () => {
38
+ expect(sniffAssetType(MP4)).toEqual({ ext: 'mp4', category: 'video' });
39
+ expect(sniffAssetType(MOV)).toEqual({ ext: 'mov', category: 'video' });
40
+ expect(sniffAssetType(WEBM)).toEqual({ ext: 'webm', category: 'video' });
41
+ });
42
+
43
+ test('recognizes audio containers by magic bytes', () => {
44
+ expect(sniffAssetType(M4A)).toEqual({ ext: 'm4a', category: 'audio' });
45
+ expect(sniffAssetType(MP3_ID3)).toEqual({ ext: 'mp3', category: 'audio' });
46
+ expect(sniffAssetType(MP3_SYNC)).toEqual({ ext: 'mp3', category: 'audio' });
47
+ expect(sniffAssetType(WAV)).toEqual({ ext: 'wav', category: 'audio' });
48
+ });
49
+
50
+ test('images still resolve through the same superset sniff', () => {
51
+ expect(sniffAssetType(PNG)).toEqual({ ext: 'png', category: 'image' });
52
+ });
53
+
54
+ test('rejects SVG / script / text / empty (no script-bearing vector rides in)', () => {
55
+ expect(sniffAssetType(SVG)).toBeNull();
56
+ expect(sniffAssetType(new TextEncoder().encode('not media at all'))).toBeNull();
57
+ expect(sniffAssetType(new Uint8Array(0))).toBeNull();
58
+ // "RIFF" without "WAVE" (or "WEBP") must NOT match a media/image type.
59
+ expect(sniffAssetType(pad([...ascii('RIFF'), 0, 0, 0, 0, ...ascii('AVI ')]))).toBeNull();
60
+ });
61
+
62
+ test('caps: image 10 MB tight, video/audio share the larger cap', () => {
63
+ expect(assetCapForCategory('image')).toBe(10 * 1024 * 1024);
64
+ expect(assetCapForCategory('video')).toBeGreaterThan(assetCapForCategory('image'));
65
+ expect(assetCapForCategory('audio')).toBe(assetCapForCategory('video'));
66
+ });
67
+ });
68
+
69
+ describe('video-asset / POST /_api/asset (streaming endpoint)', () => {
70
+ async function withServer(
71
+ fn: (port: number, designRoot: string) => Promise<void>,
72
+ extraEnv?: Record<string, string>
73
+ ) {
74
+ const sandbox = makeSandbox();
75
+ const port = nextPort();
76
+ const proc = await bootServer(sandbox.root, port, extraEnv);
77
+ try {
78
+ await fn(port, sandbox.designRoot);
79
+ } finally {
80
+ await killProc(proc);
81
+ }
82
+ }
83
+
84
+ const postAsset = (port: number, body: Uint8Array) =>
85
+ fetch(`http://localhost:${port}/_api/asset`, {
86
+ method: 'POST',
87
+ headers: { 'Content-Type': 'application/octet-stream' },
88
+ body,
89
+ });
90
+
91
+ test('writes valid mp4/mov/webm/mp3/wav/m4a with the sniffed ext', async () => {
92
+ await withServer(async (port, designRoot) => {
93
+ for (const [bytes, ext] of [
94
+ [MP4, 'mp4'],
95
+ [MOV, 'mov'],
96
+ [WEBM, 'webm'],
97
+ [MP3_ID3, 'mp3'],
98
+ [WAV, 'wav'],
99
+ [M4A, 'm4a'],
100
+ ] as const) {
101
+ const res = await postAsset(port, bytes);
102
+ expect(res.status).toBe(201);
103
+ const json = (await res.json()) as { path: string };
104
+ expect(json.path).toMatch(new RegExp(`^assets/[0-9a-f]{8}\\.${ext}$`));
105
+ expect(existsSync(join(designRoot, json.path))).toBe(true);
106
+ }
107
+ });
108
+ });
109
+
110
+ test('bytes decide the ext, never the (octet-stream) upload header', async () => {
111
+ await withServer(async (port) => {
112
+ const res = await postAsset(port, MP4);
113
+ expect(res.status).toBe(201);
114
+ const json = (await res.json()) as { path: string };
115
+ expect(json.path.endsWith('.mp4')).toBe(true);
116
+ });
117
+ });
118
+
119
+ test('rejects an SVG upload (script-bearing) with 415 even on the media route', async () => {
120
+ await withServer(async (port) => {
121
+ expect((await postAsset(port, SVG)).status).toBe(415);
122
+ });
123
+ });
124
+
125
+ test('honors the MAUDE_ASSET_MAX_VIDEO_BYTES per-file cap (streamed → 413)', async () => {
126
+ await withServer(
127
+ async (port) => {
128
+ const big = new Uint8Array(2048);
129
+ big.set([0, 0, 0, 0x18, ...ascii('ftyp'), ...ascii('isom')], 0); // valid mp4 head
130
+ expect((await postAsset(port, big)).status).toBe(413);
131
+ // A small mp4 under the cap still writes.
132
+ expect((await postAsset(port, MP4)).status).toBe(201);
133
+ },
134
+ { MAUDE_ASSET_MAX_VIDEO_BYTES: '1024' }
135
+ );
136
+ });
137
+
138
+ test('identical media bytes dedupe to ONE content-addressed file', async () => {
139
+ await withServer(async (port, designRoot) => {
140
+ const a = await (await postAsset(port, MP4)).json();
141
+ const b = await (await postAsset(port, MP4)).json();
142
+ expect(a.path).toBe(b.path);
143
+ const files = readdirSync(join(designRoot, 'assets')).filter((f) => !f.startsWith('.tmp-'));
144
+ expect(files.length).toBe(1);
145
+ });
146
+ });
147
+
148
+ test('a rejected oversize upload leaves NO temp file behind (clean disk)', async () => {
149
+ await withServer(
150
+ async (port, designRoot) => {
151
+ const big = new Uint8Array(2048);
152
+ big.set([0, 0, 0, 0x18, ...ascii('ftyp'), ...ascii('isom')], 0);
153
+ expect((await postAsset(port, big)).status).toBe(413);
154
+ const assetsDir = join(designRoot, 'assets');
155
+ const leftover = existsSync(assetsDir)
156
+ ? readdirSync(assetsDir).filter((f) => f.startsWith('.tmp-'))
157
+ : [];
158
+ expect(leftover).toEqual([]);
159
+ },
160
+ { MAUDE_ASSET_MAX_VIDEO_BYTES: '1024' }
161
+ );
162
+ });
163
+ });
@@ -0,0 +1,50 @@
1
+ // video-comp-fixture — DDR-148 end-to-end build guard (CI-safe, no browser).
2
+ //
3
+ // The heavy render/scrub/export path is verified live via agent-browser + the
4
+ // determinism smoke; here we lock the CI-cheap invariant the whole feature
5
+ // rests on: a real video-comp canvas (the canonical fixture) BUILDS through the
6
+ // production canvas pipeline (buildCanvasModule) with `remotion`,
7
+ // `@remotion/transitions`, and the `fade` presentation externalised to the
8
+ // importmap (RUNTIME_PACKAGES) — never inlined (which would resolve against a
9
+ // non-existent user node_modules on an npm/marketplace install) — and with the
10
+ // <VideoComp> wrapper inlined from @maude/canvas-lib.
11
+
12
+ import { describe, expect, test } from 'bun:test';
13
+ import { readFileSync } from 'node:fs';
14
+
15
+ import { buildCanvasModule } from '../canvas-build.ts';
16
+
17
+ const FIXTURE = new URL('./fixtures/video-comp-fixture.tsx', import.meta.url).pathname;
18
+
19
+ describe('video-comp fixture — production build guard', () => {
20
+ test('builds through buildCanvasModule with the Remotion runtime externalised', async () => {
21
+ const src = readFileSync(FIXTURE, 'utf8');
22
+ const r = await buildCanvasModule(FIXTURE, src);
23
+ expect(r.js.length).toBeGreaterThan(1000);
24
+
25
+ const externals = new Set(
26
+ [...r.js.matchAll(/from\s*["']([^"'.][^"']*)["']/g)].map((m) => m[1])
27
+ );
28
+ // The comp's Remotion imports MUST stay external (importmap → pre-built
29
+ // /_canvas-runtime bundles), or an end-user install can't resolve them.
30
+ expect(externals.has('remotion')).toBe(true);
31
+ expect(externals.has('@remotion/transitions')).toBe(true);
32
+ expect(externals.has('@remotion/transitions/fade')).toBe(true);
33
+ expect(externals.has('react')).toBe(true);
34
+
35
+ // <VideoComp> (+ the seek-bridge) is inlined from @maude/canvas-lib.
36
+ expect(r.js).toContain('VideoComp');
37
+ expect(r.js).toContain('__maude_seek__');
38
+ });
39
+
40
+ test('every Remotion specifier the fixture imports is a known RUNTIME_PACKAGE', async () => {
41
+ const { RUNTIME_PACKAGES } = await import('../runtime-bundle.ts');
42
+ const known = new Set<string>(RUNTIME_PACKAGES);
43
+ const src = readFileSync(FIXTURE, 'utf8');
44
+ const imported = [...src.matchAll(/from\s*["'](@?remotion[^"']*)["']/g)].map((m) => m[1]);
45
+ expect(imported.length).toBeGreaterThan(0);
46
+ for (const spec of imported) {
47
+ expect(known.has(spec)).toBe(true);
48
+ }
49
+ });
50
+ });
@@ -0,0 +1,168 @@
1
+ // video-comp — DDR-148 seek-bridge unit coverage.
2
+ //
3
+ // The Remotion Player's actual frame render needs a real browser (rAF, layout,
4
+ // media) — that determinism ("two seeks to frame N → identical pixels") is an
5
+ // agent-browser live check (Task 11). What IS pure + testable here is the seek
6
+ // BRIDGE the capture spine + Timeline panel drive: the comp registry, the
7
+ // clamped seek routing to each Player, and the serializable snapshot. We stand
8
+ // up happy-dom, install the bridge without mounting a Player, register a fake
9
+ // Player handle (a seekTo/pause spy), and assert the routing contract.
10
+
11
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from 'bun:test';
12
+ import { GlobalRegistrator } from '@happy-dom/global-registrator';
13
+
14
+ import { installMaudeSeekBridge } from '../video-comp.tsx';
15
+
16
+ interface FakePlayer {
17
+ seekTo: (frame: number) => void;
18
+ pause: () => void;
19
+ seeks: number[];
20
+ pauses: number;
21
+ }
22
+
23
+ interface SeekWindow {
24
+ __maudeVideoComps?: Map<
25
+ string,
26
+ {
27
+ id: string;
28
+ fps: number;
29
+ durationInFrames: number;
30
+ width: number;
31
+ height: number;
32
+ ref: { current: FakePlayer | null };
33
+ }
34
+ >;
35
+ __maude_seek__?: (frame: number, opts?: { fps?: number }) => Promise<void>;
36
+ __maude_comps__?: () => Array<{
37
+ id: string;
38
+ fps: number;
39
+ durationInFrames: number;
40
+ width: number;
41
+ height: number;
42
+ }>;
43
+ }
44
+
45
+ function makeFakePlayer(): FakePlayer {
46
+ const p: FakePlayer = {
47
+ seeks: [],
48
+ pauses: 0,
49
+ seekTo(frame) {
50
+ p.seeks.push(frame);
51
+ },
52
+ pause() {
53
+ p.pauses += 1;
54
+ },
55
+ };
56
+ return p;
57
+ }
58
+
59
+ function registerComp(
60
+ id: string,
61
+ meta: { fps: number; durationInFrames: number; width: number; height: number },
62
+ player: FakePlayer
63
+ ): void {
64
+ const w = window as unknown as SeekWindow;
65
+ if (!w.__maudeVideoComps) w.__maudeVideoComps = new Map();
66
+ w.__maudeVideoComps.set(id, { id, ...meta, ref: { current: player } });
67
+ }
68
+
69
+ beforeAll(() => {
70
+ GlobalRegistrator.register();
71
+ });
72
+
73
+ afterAll(async () => {
74
+ await GlobalRegistrator.unregister();
75
+ });
76
+
77
+ beforeEach(() => {
78
+ const w = window as unknown as SeekWindow;
79
+ w.__maudeVideoComps = undefined;
80
+ w.__maude_seek__ = undefined;
81
+ w.__maude_comps__ = undefined;
82
+ });
83
+
84
+ describe('video-comp seek bridge', () => {
85
+ test('installs window.__maude_seek__ + __maude_comps__ idempotently', () => {
86
+ const w = window as unknown as SeekWindow;
87
+ installMaudeSeekBridge();
88
+ const seek1 = w.__maude_seek__;
89
+ const comps1 = w.__maude_comps__;
90
+ expect(typeof seek1).toBe('function');
91
+ expect(typeof comps1).toBe('function');
92
+ installMaudeSeekBridge();
93
+ // Second install must NOT replace the functions (idempotent).
94
+ expect(w.__maude_seek__).toBe(seek1);
95
+ expect(w.__maude_comps__).toBe(comps1);
96
+ });
97
+
98
+ test('__maude_comps__ reflects the live registry (serializable, no functions)', () => {
99
+ const w = window as unknown as SeekWindow;
100
+ installMaudeSeekBridge();
101
+ registerComp(
102
+ 'intro',
103
+ { fps: 30, durationInFrames: 90, width: 640, height: 360 },
104
+ makeFakePlayer()
105
+ );
106
+ const comps = w.__maude_comps__?.() ?? [];
107
+ expect(comps).toHaveLength(1);
108
+ expect(comps[0]).toEqual({
109
+ id: 'intro',
110
+ fps: 30,
111
+ durationInFrames: 90,
112
+ width: 640,
113
+ height: 360,
114
+ });
115
+ // No ref/functions leak into the snapshot.
116
+ expect(Object.keys(comps[0])).not.toContain('ref');
117
+ });
118
+
119
+ test('seek routes to every registered Player, paused first', async () => {
120
+ const w = window as unknown as SeekWindow;
121
+ installMaudeSeekBridge();
122
+ const a = makeFakePlayer();
123
+ const b = makeFakePlayer();
124
+ registerComp('a', { fps: 30, durationInFrames: 90, width: 640, height: 360 }, a);
125
+ registerComp('b', { fps: 24, durationInFrames: 48, width: 320, height: 240 }, b);
126
+ await w.__maude_seek__?.(30);
127
+ expect(a.seeks).toEqual([30]);
128
+ expect(b.seeks).toEqual([30]);
129
+ expect(a.pauses).toBe(1);
130
+ expect(b.pauses).toBe(1);
131
+ });
132
+
133
+ test('seek clamps into [0, durationInFrames-1] per comp + rounds', async () => {
134
+ const w = window as unknown as SeekWindow;
135
+ installMaudeSeekBridge();
136
+ const a = makeFakePlayer();
137
+ registerComp('a', { fps: 30, durationInFrames: 90, width: 640, height: 360 }, a);
138
+ await w.__maude_seek__?.(999); // past the end → clamp to 89
139
+ await w.__maude_seek__?.(-5); // before the start → clamp to 0
140
+ await w.__maude_seek__?.(12.6); // fractional → round to 13
141
+ expect(a.seeks).toEqual([89, 0, 13]);
142
+ });
143
+
144
+ test('two seeks to the same frame issue identical Player calls (determinism contract)', async () => {
145
+ const w = window as unknown as SeekWindow;
146
+ installMaudeSeekBridge();
147
+ const a = makeFakePlayer();
148
+ registerComp('a', { fps: 30, durationInFrames: 90, width: 640, height: 360 }, a);
149
+ await w.__maude_seek__?.(42);
150
+ await w.__maude_seek__?.(42);
151
+ expect(a.seeks).toEqual([42, 42]);
152
+ });
153
+
154
+ test('a Player ref that is not yet ready (null) does not throw', async () => {
155
+ const w = window as unknown as SeekWindow;
156
+ installMaudeSeekBridge();
157
+ if (!w.__maudeVideoComps) w.__maudeVideoComps = new Map();
158
+ w.__maudeVideoComps.set('pending', {
159
+ id: 'pending',
160
+ fps: 30,
161
+ durationInFrames: 90,
162
+ width: 640,
163
+ height: 360,
164
+ ref: { current: null },
165
+ });
166
+ await expect(w.__maude_seek__?.(10)).resolves.toBeUndefined();
167
+ });
168
+ });