@1agh/maude 0.39.1 → 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 (82) hide show
  1. package/README.md +1 -1
  2. package/apps/studio/annotations-context-toolbar.tsx +44 -1
  3. package/apps/studio/annotations-layer.tsx +253 -3
  4. package/apps/studio/annotations-model.ts +107 -6
  5. package/apps/studio/api.ts +812 -64
  6. package/apps/studio/bin/_html-playwright.mjs +9 -1
  7. package/apps/studio/bin/_pdf-playwright.mjs +8 -1
  8. package/apps/studio/bin/_png-playwright.mjs +8 -1
  9. package/apps/studio/bin/_pw-launch.mjs +54 -0
  10. package/apps/studio/bin/_svg-playwright.mjs +8 -1
  11. package/apps/studio/bin/_video-playwright.mjs +452 -0
  12. package/apps/studio/bin/prep.sh +8 -1
  13. package/apps/studio/canvas-edit.ts +1885 -104
  14. package/apps/studio/canvas-lib.tsx +19 -0
  15. package/apps/studio/canvas-list-watch.ts +6 -2
  16. package/apps/studio/canvas-shell.tsx +27 -0
  17. package/apps/studio/client/app.jsx +1029 -30
  18. package/apps/studio/client/github.js +7 -0
  19. package/apps/studio/client/panels/TimelinePanel.jsx +860 -0
  20. package/apps/studio/client/panels/timeline-parse.js +229 -0
  21. package/apps/studio/client/panels/timeline-snap.js +55 -0
  22. package/apps/studio/client/styles/3-shell-maude.css +107 -0
  23. package/apps/studio/config.schema.json +14 -0
  24. package/apps/studio/context-menu.tsx +1 -1
  25. package/apps/studio/context.ts +113 -1
  26. package/apps/studio/dist/client.bundle.js +88 -16
  27. package/apps/studio/dist/comment-mount.js +1 -1
  28. package/apps/studio/dist/runtime/.min-sizes.json +11 -1
  29. package/apps/studio/dist/runtime/@remotion_media.js +491 -0
  30. package/apps/studio/dist/runtime/@remotion_player.js +56 -0
  31. package/apps/studio/dist/runtime/@remotion_transitions.js +300 -0
  32. package/apps/studio/dist/runtime/@remotion_transitions_clock-wipe.js +1 -0
  33. package/apps/studio/dist/runtime/@remotion_transitions_fade.js +1 -0
  34. package/apps/studio/dist/runtime/@remotion_transitions_flip.js +1 -0
  35. package/apps/studio/dist/runtime/@remotion_transitions_none.js +1 -0
  36. package/apps/studio/dist/runtime/@remotion_transitions_slide.js +1 -0
  37. package/apps/studio/dist/runtime/@remotion_transitions_wipe.js +1 -0
  38. package/apps/studio/dist/runtime/REMOTION-LICENSE.md +28 -0
  39. package/apps/studio/dist/runtime/remotion.js +42 -0
  40. package/apps/studio/dist/styles.css +1 -1
  41. package/apps/studio/exporters/_browser-bundles.ts +117 -0
  42. package/apps/studio/exporters/_runtime.ts +69 -0
  43. package/apps/studio/exporters/html.ts +4 -3
  44. package/apps/studio/exporters/index.ts +28 -2
  45. package/apps/studio/exporters/pdf.ts +4 -3
  46. package/apps/studio/exporters/png.ts +5 -3
  47. package/apps/studio/exporters/pptx.ts +6 -4
  48. package/apps/studio/exporters/svg.ts +9 -5
  49. package/apps/studio/exporters/video-encode-lib.ts +200 -0
  50. package/apps/studio/exporters/video-render-lib.ts +108 -0
  51. package/apps/studio/exporters/video.ts +184 -0
  52. package/apps/studio/http.ts +535 -27
  53. package/apps/studio/input-router.tsx +7 -1
  54. package/apps/studio/runtime-bundle.ts +30 -0
  55. package/apps/studio/server.ts +34 -9
  56. package/apps/studio/test/annotations-roundtrip.test.ts +47 -0
  57. package/apps/studio/test/canvas-create-api.test.ts +76 -0
  58. package/apps/studio/test/canvas-edit.test.ts +90 -0
  59. package/apps/studio/test/canvas-list-watch.test.ts +49 -0
  60. package/apps/studio/test/canvas-media-drop.test.ts +30 -1
  61. package/apps/studio/test/canvas-origin-gate.test.ts +36 -0
  62. package/apps/studio/test/clip-addressing.test.ts +732 -0
  63. package/apps/studio/test/config-reload.test.ts +230 -0
  64. package/apps/studio/test/dns-rebinding-guard.test.ts +74 -0
  65. package/apps/studio/test/edit-persistence.test.ts +91 -0
  66. package/apps/studio/test/exporters/runtime.test.ts +59 -0
  67. package/apps/studio/test/file-lock.test.ts +84 -0
  68. package/apps/studio/test/fixtures/video-comp-fixture.tsx +82 -0
  69. package/apps/studio/test/timeline-parse.test.ts +127 -0
  70. package/apps/studio/test/timeline-snap.test.ts +85 -0
  71. package/apps/studio/test/video-asset.test.ts +163 -0
  72. package/apps/studio/test/video-comp-fixture.test.ts +50 -0
  73. package/apps/studio/test/video-comp.test.ts +168 -0
  74. package/apps/studio/test/video-render-bridge.test.ts +149 -0
  75. package/apps/studio/use-annotation-resize.tsx +6 -3
  76. package/apps/studio/use-canvas-media-drop.tsx +66 -4
  77. package/apps/studio/video-comp.tsx +444 -0
  78. package/apps/studio/whats-new.json +27 -0
  79. package/apps/studio/ws.ts +5 -0
  80. package/package.json +8 -8
  81. package/plugins/design/templates/_shell.html +25 -2
  82. package/plugins/design/templates/design-system-inspiration/_MAPPING.md +1 -1
@@ -0,0 +1,732 @@
1
+ // clip-addressing.test.ts — DDR-150 P2. The single AST clip tokenizer that
2
+ // replaces document-order indexing. The headline guard: on a MULTI-COMP canvas,
3
+ // enumerateClips scoped to one artboard returns THAT comp's clips only — so the
4
+ // UI (which addresses ops by stableId) can never mis-hit a clip in another comp
5
+ // (the defect that made destructive ops corrupt the wrong clip). Plus: comments
6
+ // are skipped (AST, not regex), TransitionSeries semantics are gated, and the
7
+ // content-hash fingerprint refuses a stale/raced target.
8
+
9
+ import { describe, expect, test } from 'bun:test';
10
+
11
+ import {
12
+ applyEditArrayElementString,
13
+ applyInsertClip,
14
+ applyRemoveClip,
15
+ applyReorderClip,
16
+ applyRetimeSequenceByClip,
17
+ applyToggleClipHidden,
18
+ assembleCompSource,
19
+ assertCompSemantics,
20
+ CanvasEditError,
21
+ enumerateClips,
22
+ resolveClip,
23
+ } from '../canvas-edit.ts';
24
+
25
+ const CANVAS = '/abs/Canvas.tsx';
26
+
27
+ describe('enumerateClips — multi-comp scoping (the headline defect)', () => {
28
+ const SRC = [
29
+ 'const A = 40;',
30
+ 'const B = 50;',
31
+ 'const Intro = () => (',
32
+ ' <Sequence durationInFrames={A}><Video src="intro.mp4" /></Sequence>',
33
+ ');',
34
+ 'const Outro = () => (',
35
+ ' <>',
36
+ ' <Sequence durationInFrames={B}><Video src="a.mp4" /></Sequence>',
37
+ ' <Sequence from={10} durationInFrames={60}><Video src="b.mp4" /></Sequence>',
38
+ ' </>',
39
+ ');',
40
+ 'function Canvas() {',
41
+ ' return (',
42
+ ' <DesignCanvas>',
43
+ ' <DCArtboard id="intro"><VideoComp component={Intro} durationInFrames={A} fps={30} /></DCArtboard>',
44
+ ' <DCArtboard id="outro"><VideoComp component={Outro} durationInFrames={B} fps={30} /></DCArtboard>',
45
+ ' </DesignCanvas>',
46
+ ' );',
47
+ '}',
48
+ ].join('\n');
49
+
50
+ test('scopes to the selected artboard’s comp — not the whole file', () => {
51
+ const intro = enumerateClips(CANVAS, SRC, 'intro');
52
+ expect(intro.compName).toBe('Intro');
53
+ expect(intro.clips.map((c) => c.stableId)).toEqual(['Intro#0']);
54
+ expect(intro.clips[0]?.mediaSrc).toBe('intro.mp4');
55
+ expect(intro.clips[0]?.durationInFrames).toBe(40);
56
+
57
+ const outro = enumerateClips(CANVAS, SRC, 'outro');
58
+ expect(outro.compName).toBe('Outro');
59
+ // TWO clips, comp-scoped ids — NOT file-wide indices 1 & 2.
60
+ expect(outro.clips.map((c) => c.stableId)).toEqual(['Outro#0', 'Outro#1']);
61
+ // The headline assertion: outro clip 0 is the OUTRO clip (a.mp4), never the
62
+ // intro clip. A whole-file document-order index would have mis-hit here.
63
+ expect(outro.clips[0]?.mediaSrc).toBe('a.mp4');
64
+ expect(outro.clips[1]?.mediaSrc).toBe('b.mp4');
65
+ expect(outro.clips[1]?.from).toBe(10);
66
+ expect(outro.clips[1]?.durationInFrames).toBe(60);
67
+ });
68
+
69
+ test('the same stableId means the same clip across UI/engine (distinct comp-scoped ids)', () => {
70
+ const intro = enumerateClips(CANVAS, SRC, 'intro');
71
+ const outro = enumerateClips(CANVAS, SRC, 'outro');
72
+ const ids = [...intro.clips, ...outro.clips].map((c) => c.stableId);
73
+ expect(new Set(ids).size).toBe(ids.length); // no collisions across comps
74
+ });
75
+ });
76
+
77
+ describe('enumerateClips — stable identity precedence', () => {
78
+ test('<Sequence name> wins; sentinel next; else comp#index', () => {
79
+ const src = [
80
+ 'const Comp = () => (',
81
+ ' <>',
82
+ ' <Sequence name="hero" durationInFrames={30}><A /></Sequence>',
83
+ ' {/* @mclip lower-third */}',
84
+ ' <Sequence durationInFrames={20}><B /></Sequence>',
85
+ ' <Sequence durationInFrames={20}><C /></Sequence>',
86
+ ' </>',
87
+ ');',
88
+ 'function Canvas() { return <DCArtboard id="x"><VideoComp component={Comp} durationInFrames={70} fps={30} /></DCArtboard>; }',
89
+ ].join('\n');
90
+ const { clips } = enumerateClips(CANVAS, src, 'x');
91
+ expect(clips.map((c) => c.stableId)).toEqual(['name:hero', 'mclip:lower-third', 'Comp#2']);
92
+ });
93
+ });
94
+
95
+ describe('enumerateClips — AST skips commented + tolerates .map', () => {
96
+ test('a commented-out <Sequence> is NOT enumerated (regex would have counted it)', () => {
97
+ const src = [
98
+ 'const Comp = () => (',
99
+ ' <>',
100
+ ' <Sequence durationInFrames={30}><A /></Sequence>',
101
+ ' {/* <Sequence durationInFrames={99}><Ghost /></Sequence> */}',
102
+ ' <Sequence durationInFrames={20}><B /></Sequence>',
103
+ ' </>',
104
+ ');',
105
+ 'function Canvas() { return <DCArtboard id="x"><VideoComp component={Comp} durationInFrames={50} fps={30} /></DCArtboard>; }',
106
+ ].join('\n');
107
+ const { clips } = enumerateClips(CANVAS, src, 'x');
108
+ expect(clips.length).toBe(2);
109
+ expect(clips.map((c) => c.stableId)).toEqual(['Comp#0', 'Comp#1']);
110
+ });
111
+
112
+ test('a .map() renders one addressable template clip', () => {
113
+ const src = [
114
+ 'const Comp = ({ shots }) => (',
115
+ ' <>{shots.map((s) => <Sequence key={s.id} from={s.from} durationInFrames={s.dur}><Video src={s.src} /></Sequence>)}</>',
116
+ ');',
117
+ 'function Canvas() { return <DCArtboard id="x"><VideoComp component={Comp} durationInFrames={90} fps={30} /></DCArtboard>; }',
118
+ ].join('\n');
119
+ const { clips } = enumerateClips(CANVAS, src, 'x');
120
+ expect(clips.length).toBe(1);
121
+ expect(clips[0]?.stableId).toBe('Comp#0');
122
+ expect(clips[0]?.mediaTag).toBe('Video'); // media detected even though src is dynamic
123
+ });
124
+ });
125
+
126
+ describe('resolveClip — content-hash fingerprint (optimistic concurrency)', () => {
127
+ const src = [
128
+ 'const Comp = () => <Sequence durationInFrames={30}><Video src="a.mp4" /></Sequence>;',
129
+ 'function Canvas() { return <DCArtboard id="x"><VideoComp component={Comp} durationInFrames={30} fps={30} /></DCArtboard>; }',
130
+ ].join('\n');
131
+
132
+ test('resolves a clip by stableId + matching hash', () => {
133
+ const { clips } = enumerateClips(CANVAS, src, 'x');
134
+ const c = clips[0] as { stableId: string; contentHash: string };
135
+ const r = resolveClip(CANVAS, src, 'x', c.stableId, c.contentHash);
136
+ expect(r.stableId).toBe(c.stableId);
137
+ });
138
+
139
+ test('refuses when the clip content changed since it was read', () => {
140
+ const { clips } = enumerateClips(CANVAS, src, 'x');
141
+ const c = clips[0] as { stableId: string };
142
+ expect(() => resolveClip(CANVAS, src, 'x', c.stableId, 'deadbeef')).toThrow(CanvasEditError);
143
+ });
144
+
145
+ test('throws on an unknown stableId', () => {
146
+ expect(() => resolveClip(CANVAS, src, 'x', 'Comp#99')).toThrow(CanvasEditError);
147
+ });
148
+ });
149
+
150
+ describe('assertCompSemantics — TransitionSeries alternation (parse-clean ≠ correct)', () => {
151
+ const wrap = (inner: string) =>
152
+ `const Comp = () => (<TransitionSeries>${inner}</TransitionSeries>);`;
153
+ const SEQ = '<TransitionSeries.Sequence durationInFrames={30}><A /></TransitionSeries.Sequence>';
154
+ const TR = '<TransitionSeries.Transition timing={t} />';
155
+
156
+ test('accepts a valid alternating series', () => {
157
+ expect(assertCompSemantics(CANVAS, wrap(`${SEQ}${TR}${SEQ}`)).ok).toBe(true);
158
+ expect(assertCompSemantics(CANVAS, wrap(SEQ)).ok).toBe(true);
159
+ });
160
+
161
+ test('rejects a leading transition', () => {
162
+ expect(() => assertCompSemantics(CANVAS, wrap(`${TR}${SEQ}`))).toThrow(CanvasEditError);
163
+ });
164
+
165
+ test('rejects a trailing transition (the orphaned-transition after a remove)', () => {
166
+ expect(() => assertCompSemantics(CANVAS, wrap(`${SEQ}${TR}`))).toThrow(CanvasEditError);
167
+ });
168
+
169
+ test('rejects a doubled transition', () => {
170
+ expect(() => assertCompSemantics(CANVAS, wrap(`${SEQ}${TR}${TR}${SEQ}`))).toThrow(
171
+ CanvasEditError
172
+ );
173
+ });
174
+ });
175
+
176
+ describe('applyRetimeSequenceByClip — stableId retime (multi-comp safe)', () => {
177
+ const SRC = [
178
+ 'const Intro = () => <Sequence durationInFrames={40}><Video src="intro.mp4" /></Sequence>;',
179
+ 'const Outro = () => (',
180
+ ' <>',
181
+ ' <Sequence durationInFrames={50}><Video src="a.mp4" /></Sequence>',
182
+ ' <Sequence from={10} durationInFrames={60}><Video src="b.mp4" /></Sequence>',
183
+ ' </>',
184
+ ');',
185
+ 'function Canvas() {',
186
+ ' return (<DesignCanvas>',
187
+ ' <DCArtboard id="intro"><VideoComp component={Intro} durationInFrames={40} fps={30} /></DCArtboard>',
188
+ ' <DCArtboard id="outro"><VideoComp component={Outro} durationInFrames={110} fps={30} /></DCArtboard>',
189
+ ' </DesignCanvas>);',
190
+ '}',
191
+ ].join('\n');
192
+
193
+ test('retimes the addressed clip in comp B without touching comp A', () => {
194
+ const out = applyRetimeSequenceByClip(CANVAS, SRC, 'outro', 'Outro#1', undefined, {
195
+ durationInFrames: 90,
196
+ });
197
+ // Outro#1 (the b.mp4 clip, from={10}) is retimed to 90…
198
+ expect(out.source).toContain('from={10} durationInFrames={90}');
199
+ // …and the Intro clip (a whole-file index of 0) is untouched.
200
+ expect(out.source).toContain('const Intro = () => <Sequence durationInFrames={40}>');
201
+ // …as is Outro#0.
202
+ expect(out.source).toContain('<Sequence durationInFrames={50}><Video src="a.mp4"');
203
+ });
204
+
205
+ test('refuses a stale content-hash (concurrent edit)', () => {
206
+ expect(() =>
207
+ applyRetimeSequenceByClip(CANVAS, SRC, 'outro', 'Outro#1', 'deadbeef', {
208
+ durationInFrames: 90,
209
+ })
210
+ ).toThrow(CanvasEditError);
211
+ });
212
+
213
+ test('inserts `from` when moving a cursor-implicit clip (P3 Task 6)', () => {
214
+ const src = [
215
+ 'const Comp = () => <Sequence durationInFrames={40}><A /></Sequence>;',
216
+ 'function Canvas() { return <DCArtboard id="x"><VideoComp component={Comp} durationInFrames={40} fps={30} /></DCArtboard>; }',
217
+ ].join('\n');
218
+ const out = applyRetimeSequenceByClip(CANVAS, src, 'x', 'Comp#0', undefined, { from: 25 });
219
+ expect(out.source).toContain('<Sequence from={25} durationInFrames={40}>');
220
+ });
221
+ });
222
+
223
+ describe('applyRetimeSequenceByClip — refuses a no-op `from` on series clips (dogfood fix)', () => {
224
+ const src = [
225
+ 'const Comp = () => (<TransitionSeries>',
226
+ ' <TransitionSeries.Sequence durationInFrames={30}><A /></TransitionSeries.Sequence>',
227
+ ' <TransitionSeries.Transition timing={t} />',
228
+ ' <TransitionSeries.Sequence durationInFrames={40}><B /></TransitionSeries.Sequence>',
229
+ '</TransitionSeries>);',
230
+ 'function Canvas() { return <DCArtboard id="x"><VideoComp component={Comp} durationInFrames={70} fps={30} /></DCArtboard>; }',
231
+ ].join('\n');
232
+
233
+ test('from-move on a TransitionSeries.Sequence throws (Remotion ignores the prop)', () => {
234
+ const seqs = enumerateClips(CANVAS, src, 'x').clips.filter((c) => c.kind === 'sequence');
235
+ expect(() =>
236
+ applyRetimeSequenceByClip(CANVAS, src, 'x', seqs[0]!.stableId, undefined, { from: 20 })
237
+ ).toThrow(/series computes its position/);
238
+ });
239
+
240
+ test('trim (durationInFrames) on a TransitionSeries.Sequence still works', () => {
241
+ const seqs = enumerateClips(CANVAS, src, 'x').clips.filter((c) => c.kind === 'sequence');
242
+ const out = applyRetimeSequenceByClip(CANVAS, src, 'x', seqs[1]!.stableId, undefined, {
243
+ durationInFrames: 25,
244
+ });
245
+ expect(out.source).toContain('durationInFrames={25}');
246
+ });
247
+ });
248
+
249
+ describe('applyRemoveClip — clip removal (DDR-150 P3)', () => {
250
+ const outro = () =>
251
+ [
252
+ `const Intro = () => <Sequence durationInFrames={40}><Video src="i.mp4" /></Sequence>;`,
253
+ `const Outro = () => (`,
254
+ ` <>`,
255
+ ` <Sequence durationInFrames={50}><Video src="a.mp4" /></Sequence>`,
256
+ ` <Sequence from={10} durationInFrames={60}><Video src="b.mp4" /></Sequence>`,
257
+ ` </>`,
258
+ `);`,
259
+ `function Canvas() {`,
260
+ ` return (<DesignCanvas>`,
261
+ ` <DCArtboard id="intro"><VideoComp component={Intro} durationInFrames={40} fps={30} /></DCArtboard>`,
262
+ ` <DCArtboard id="outro"><VideoComp component={Outro} durationInFrames={110} fps={30} /></DCArtboard>`,
263
+ ` </DesignCanvas>);`,
264
+ `}`,
265
+ ].join('\n');
266
+
267
+ test('removes a standalone clip in comp B, leaving comp A + the sibling clip', () => {
268
+ const src = outro();
269
+ const out = applyRemoveClip(CANVAS, src, 'outro', 'Outro#0', undefined);
270
+ // Outro#0 (a.mp4) gone; Outro#1 (b.mp4) + Intro (i.mp4) intact.
271
+ expect(out.source).not.toContain('a.mp4');
272
+ expect(out.source).toContain('b.mp4');
273
+ expect(out.source).toContain('i.mp4');
274
+ });
275
+
276
+ test('refuses removing the only clip in a comp', () => {
277
+ const src = [
278
+ 'const Comp = () => <Sequence durationInFrames={30}><A /></Sequence>;',
279
+ 'function Canvas() { return <DCArtboard id="x"><VideoComp component={Comp} durationInFrames={30} fps={30} /></DCArtboard>; }',
280
+ ].join('\n');
281
+ const only = enumerateClips(CANVAS, src, 'x').clips[0] as { stableId: string };
282
+ expect(() => applyRemoveClip(CANVAS, src, 'x', only.stableId, undefined)).toThrow(
283
+ CanvasEditError
284
+ );
285
+ });
286
+
287
+ test('refuses a stale content-hash', () => {
288
+ const src = outro();
289
+ expect(() => applyRemoveClip(CANVAS, src, 'outro', 'Outro#0', 'deadbeef')).toThrow(
290
+ CanvasEditError
291
+ );
292
+ });
293
+
294
+ test('inserts a new <Sequence> with media after the last clip (P4 Task 11)', () => {
295
+ const src = [
296
+ 'const Comp = () => (',
297
+ ' <>',
298
+ ' <Sequence durationInFrames={50}><Video src="assets/a.mp4" /></Sequence>',
299
+ ' </>',
300
+ ');',
301
+ 'function Canvas() { return <DCArtboard id="x"><VideoComp component={Comp} durationInFrames={50} fps={30} /></DCArtboard>; }',
302
+ ].join('\n');
303
+ const out = applyInsertClip(CANVAS, src, 'x', {
304
+ from: 50,
305
+ durationInFrames: 40,
306
+ mediaTag: 'Video',
307
+ src: 'assets/new.mp4',
308
+ });
309
+ expect(out.source).toContain('<Sequence from={50} durationInFrames={40}>');
310
+ expect(out.source).toContain('<Video src="assets/new.mp4" />');
311
+ expect(out.stableId).toBe('Comp#1'); // new clip is the 2nd
312
+ // both clips enumerate cleanly
313
+ expect(enumerateClips(CANVAS, out.source, 'x').clips.length).toBe(2);
314
+ });
315
+
316
+ test('refuses insert with a traversal src', () => {
317
+ const src = [
318
+ 'const Comp = () => <Sequence durationInFrames={30}><Video src="assets/a.mp4" /></Sequence>;',
319
+ 'function Canvas() { return <DCArtboard id="x"><VideoComp component={Comp} durationInFrames={30} fps={30} /></DCArtboard>; }',
320
+ ].join('\n');
321
+ expect(() =>
322
+ applyInsertClip(CANVAS, src, 'x', {
323
+ from: 0,
324
+ durationInFrames: 30,
325
+ mediaTag: 'Video',
326
+ src: '../../etc/passwd',
327
+ })
328
+ ).toThrow(CanvasEditError);
329
+ });
330
+
331
+ test('refuses appending into a single-sequence <TransitionSeries> (no transition to clone)', () => {
332
+ const src = [
333
+ 'const Comp = () => (<TransitionSeries>',
334
+ ' <TransitionSeries.Sequence durationInFrames={30}><A /></TransitionSeries.Sequence>',
335
+ '</TransitionSeries>);',
336
+ 'function Canvas() { return <DCArtboard id="x"><VideoComp component={Comp} durationInFrames={30} fps={30} /></DCArtboard>; }',
337
+ ].join('\n');
338
+ expect(() => applyInsertClip(CANVAS, src, 'x', { from: 30, durationInFrames: 30 })).toThrow(
339
+ CanvasEditError
340
+ );
341
+ });
342
+
343
+ test('appends a transition + sequence into a <TransitionSeries> (dogfood — cloned transition)', () => {
344
+ const src = [
345
+ 'const Comp = () => (<TransitionSeries>',
346
+ ' <TransitionSeries.Sequence durationInFrames={30}><A /></TransitionSeries.Sequence>',
347
+ ' <TransitionSeries.Transition presentation={fade()} timing={linearTiming({ durationInFrames: 8 })} />',
348
+ ' <TransitionSeries.Sequence durationInFrames={40}><B /></TransitionSeries.Sequence>',
349
+ '</TransitionSeries>);',
350
+ 'function Canvas() { return <DCArtboard id="x"><VideoComp component={Comp} durationInFrames={70} fps={30} /></DCArtboard>; }',
351
+ ].join('\n');
352
+ const out = applyInsertClip(CANVAS, src, 'x', {
353
+ from: 0,
354
+ durationInFrames: 50,
355
+ mediaTag: 'Video',
356
+ src: 'assets/new.mp4',
357
+ });
358
+ // A new sequence with the media + a cloned transition; alternation intact.
359
+ expect(out.source).toContain('<Video src="assets/new.mp4" />');
360
+ expect((out.source.match(/<\/TransitionSeries\.Sequence>/g) || []).length).toBe(3); // 3 sequences
361
+ expect((out.source.match(/TransitionSeries\.Transition/g) || []).length).toBe(2);
362
+ expect(() => assertCompSemantics(CANVAS, out.source)).not.toThrow();
363
+ const seqs = enumerateClips(CANVAS, out.source, 'x').clips.filter((c) => c.kind === 'sequence');
364
+ expect(seqs.length).toBe(3);
365
+ });
366
+
367
+ test('drops one adjacent transition when removing a TransitionSeries clip (series stays valid)', () => {
368
+ const src = [
369
+ 'const Comp = () => (<TransitionSeries>',
370
+ ' <TransitionSeries.Sequence durationInFrames={30}><A /></TransitionSeries.Sequence>',
371
+ ' <TransitionSeries.Transition timing={t} />',
372
+ ' <TransitionSeries.Sequence durationInFrames={40}><B /></TransitionSeries.Sequence>',
373
+ ' <TransitionSeries.Transition timing={t} />',
374
+ ' <TransitionSeries.Sequence durationInFrames={50}><C /></TransitionSeries.Sequence>',
375
+ '</TransitionSeries>);',
376
+ 'function Canvas() { return <DCArtboard id="x"><VideoComp component={Comp} durationInFrames={120} fps={30} /></DCArtboard>; }',
377
+ ].join('\n');
378
+ // Remove the middle sequence (Comp#2 in flat order: S,T,S,T,S → index 2).
379
+ const clips = enumerateClips(CANVAS, src, 'x').clips;
380
+ const mid = clips.filter((c) => c.kind === 'sequence')[1] as { stableId: string };
381
+ const out = applyRemoveClip(CANVAS, src, 'x', mid.stableId, undefined);
382
+ // <B/> gone; the result still parses AND passes the alternation gate.
383
+ expect(out.source).not.toContain('<B />');
384
+ expect(() => assertCompSemantics(CANVAS, out.source)).not.toThrow();
385
+ // exactly one transition left (was 2, one dropped with the removed sequence).
386
+ expect((out.source.match(/TransitionSeries\.Transition/g) || []).length).toBe(1);
387
+ });
388
+ });
389
+
390
+ describe('applyReorderClip — z-order reorder (DDR-150 P5)', () => {
391
+ // Three standalone <Sequence> siblings whose stacking order (document order)
392
+ // is the render z-order — footage under a lower-third under a logo.
393
+ const stack = () =>
394
+ [
395
+ 'const Reel = () => (',
396
+ ' <>',
397
+ ' <Sequence durationInFrames={90}><Video src="footage.mp4" /></Sequence>',
398
+ ' <Sequence durationInFrames={90}><Img src="lower-third.png" /></Sequence>',
399
+ ' <Sequence durationInFrames={90}><Img src="logo.png" /></Sequence>',
400
+ ' </>',
401
+ ');',
402
+ 'function Canvas() { return <DCArtboard id="reel"><VideoComp component={Reel} durationInFrames={90} fps={30} /></DCArtboard>; }',
403
+ ].join('\n');
404
+
405
+ test('moves a clip after a sibling — stacking order changes, content preserved', () => {
406
+ const src = stack();
407
+ const clips = enumerateClips(CANVAS, src, 'reel').clips;
408
+ const footage = clips[0]!; // Reel#0
409
+ const logo = clips[2]!; // Reel#2
410
+ // Bring footage to the top: move it AFTER the logo.
411
+ const out = applyReorderClip(
412
+ CANVAS,
413
+ src,
414
+ 'reel',
415
+ footage.stableId,
416
+ footage.contentHash,
417
+ logo.stableId,
418
+ logo.contentHash,
419
+ 'after'
420
+ );
421
+ // New document order: lower-third, logo, footage (footage now paints last).
422
+ const after = enumerateClips(CANVAS, out.source, 'reel').clips;
423
+ expect(after.map((c) => c.mediaSrc)).toEqual(['lower-third.png', 'logo.png', 'footage.mp4']);
424
+ // All three clips survive; nothing was dropped or duplicated.
425
+ expect(after.length).toBe(3);
426
+ // The moved clip is re-addressed by its (unchanged) content hash.
427
+ expect(out.stableId).toBe(after[2]!.stableId);
428
+ });
429
+
430
+ test('refuses a stale content-hash on either clip', () => {
431
+ const src = stack();
432
+ const clips = enumerateClips(CANVAS, src, 'reel').clips;
433
+ expect(() =>
434
+ applyReorderClip(
435
+ CANVAS,
436
+ src,
437
+ 'reel',
438
+ clips[0]!.stableId,
439
+ 'deadbeef',
440
+ clips[2]!.stableId,
441
+ clips[2]!.contentHash,
442
+ 'after'
443
+ )
444
+ ).toThrow(CanvasEditError);
445
+ });
446
+
447
+ test('refuses a self-move', () => {
448
+ const src = stack();
449
+ const c0 = enumerateClips(CANVAS, src, 'reel').clips[0]!;
450
+ expect(() =>
451
+ applyReorderClip(
452
+ CANVAS,
453
+ src,
454
+ 'reel',
455
+ c0.stableId,
456
+ c0.contentHash,
457
+ c0.stableId,
458
+ c0.contentHash,
459
+ 'after'
460
+ )
461
+ ).toThrow(CanvasEditError);
462
+ });
463
+
464
+ test('reorders TransitionSeries beats by SWAPPING sequences (dogfood — transitions stay put)', () => {
465
+ const src = [
466
+ 'const Comp = () => (<TransitionSeries>',
467
+ ' <TransitionSeries.Sequence durationInFrames={30}><A /></TransitionSeries.Sequence>',
468
+ ' <TransitionSeries.Transition timing={t} />',
469
+ ' <TransitionSeries.Sequence durationInFrames={40}><B /></TransitionSeries.Sequence>',
470
+ ' <TransitionSeries.Transition timing={t} />',
471
+ ' <TransitionSeries.Sequence durationInFrames={50}><C /></TransitionSeries.Sequence>',
472
+ '</TransitionSeries>);',
473
+ 'function Canvas() { return <DCArtboard id="x"><VideoComp component={Comp} durationInFrames={120} fps={30} /></DCArtboard>; }',
474
+ ].join('\n');
475
+ const seqs = enumerateClips(CANVAS, src, 'x').clips.filter((c) => c.kind === 'sequence');
476
+ // Move A (clip 0) later — swap with B (clip 1).
477
+ const out = applyReorderClip(
478
+ CANVAS,
479
+ src,
480
+ 'x',
481
+ seqs[0]!.stableId,
482
+ seqs[0]!.contentHash,
483
+ seqs[1]!.stableId,
484
+ seqs[1]!.contentHash,
485
+ 'after'
486
+ );
487
+ // B now plays first (with its 40f), A second (30f) — alternation intact.
488
+ const order = (out.source.match(/<([ABC]) \//g) || []).map((m) => m[1]);
489
+ expect(order).toEqual(['B', 'A', 'C']);
490
+ expect(() => assertCompSemantics(CANVAS, out.source)).not.toThrow();
491
+ expect((out.source.match(/TransitionSeries\.Transition/g) || []).length).toBe(2); // both kept
492
+ });
493
+
494
+ test('still refuses reordering a series clip against a STANDALONE one (mixed kinds)', () => {
495
+ const src = [
496
+ 'const Comp = () => (<><TransitionSeries>',
497
+ ' <TransitionSeries.Sequence durationInFrames={30}><A /></TransitionSeries.Sequence>',
498
+ ' <TransitionSeries.Transition timing={t} />',
499
+ ' <TransitionSeries.Sequence durationInFrames={40}><B /></TransitionSeries.Sequence>',
500
+ '</TransitionSeries>',
501
+ '<Sequence durationInFrames={20}><D /></Sequence></>);',
502
+ 'function Canvas() { return <DCArtboard id="x"><VideoComp component={Comp} durationInFrames={90} fps={30} /></DCArtboard>; }',
503
+ ].join('\n');
504
+ const seqs = enumerateClips(CANVAS, src, 'x').clips.filter((c) => c.kind === 'sequence');
505
+ const seriesClip = seqs.find((c) => c.tag === 'TransitionSeries.Sequence')!;
506
+ const standalone = seqs.find((c) => c.tag === 'Sequence')!;
507
+ expect(() =>
508
+ applyReorderClip(
509
+ CANVAS,
510
+ src,
511
+ 'x',
512
+ seriesClip.stableId,
513
+ seriesClip.contentHash,
514
+ standalone.stableId,
515
+ standalone.contentHash,
516
+ 'after'
517
+ )
518
+ ).toThrow(CanvasEditError);
519
+ });
520
+ });
521
+
522
+ describe('applyToggleClipHidden — hide/show a clip (dogfood)', () => {
523
+ const src = [
524
+ 'const Comp = () => (<TransitionSeries>',
525
+ ' <TransitionSeries.Sequence durationInFrames={40}><A /></TransitionSeries.Sequence>',
526
+ ' <TransitionSeries.Transition timing={t} />',
527
+ ' <TransitionSeries.Sequence durationInFrames={50}><B /></TransitionSeries.Sequence>',
528
+ '</TransitionSeries>);',
529
+ 'function Canvas() { return <DCArtboard id="x"><VideoComp component={Comp} durationInFrames={90} fps={30} /></DCArtboard>; }',
530
+ ].join('\n');
531
+
532
+ test('hide gates the body behind {false && (…)} keeping the tag + alternation, then shows reversibly', () => {
533
+ const s0 = enumerateClips(CANVAS, src, 'x').clips.filter((c) => c.kind === 'sequence');
534
+ expect(s0.every((c) => c.hidden === false)).toBe(true);
535
+ const hid = applyToggleClipHidden(CANVAS, src, 'x', s0[0]!.stableId, s0[0]!.contentHash);
536
+ expect(hid.hidden).toBe(true);
537
+ expect(hid.source).toContain('{false && (<A />)}');
538
+ expect(() => assertCompSemantics(CANVAS, hid.source)).not.toThrow(); // alternation intact
539
+ const s1 = enumerateClips(CANVAS, hid.source, 'x').clips.filter((c) => c.kind === 'sequence');
540
+ expect(s1[0]!.hidden).toBe(true);
541
+ const shown = applyToggleClipHidden(
542
+ CANVAS,
543
+ hid.source,
544
+ 'x',
545
+ s1[0]!.stableId,
546
+ s1[0]!.contentHash
547
+ );
548
+ expect(shown.hidden).toBe(false);
549
+ expect(shown.source).toBe(src); // fully reversible
550
+ });
551
+ });
552
+
553
+ describe('enumerateClips — clip layer decomposition (dogfood — mp4 background + title)', () => {
554
+ const src = [
555
+ "const CLIPS = [{ src: 'assets/a.mp4', label: 'Aerial' }];",
556
+ 'const LowerThird = ({ label }) => <AbsoluteFill><div>{label}</div></AbsoluteFill>;',
557
+ 'const ClipShot = ({ clip }) => (',
558
+ ' <AbsoluteFill>',
559
+ ' <AbsoluteFill><Video src={clip.src} /></AbsoluteFill>',
560
+ ' <AbsoluteFill style={{ background: "grad" }} />',
561
+ ' <LowerThird label={clip.label} />',
562
+ ' </AbsoluteFill>',
563
+ ');',
564
+ 'const TitleCard = () => <AbsoluteFill>hi</AbsoluteFill>;',
565
+ 'const Reel = () => (<TransitionSeries>',
566
+ ' <TransitionSeries.Sequence durationInFrames={60}><TitleCard /></TransitionSeries.Sequence>',
567
+ ' <TransitionSeries.Transition timing={t} />',
568
+ ' <TransitionSeries.Sequence durationInFrames={60}><ClipShot clip={CLIPS[0]} /></TransitionSeries.Sequence>',
569
+ '</TransitionSeries>);',
570
+ 'function Canvas() { return <DCArtboard id="reel"><VideoComp component={Reel} durationInFrames={120} fps={30} /></DCArtboard>; }',
571
+ ].join('\n');
572
+
573
+ test('a ClipShot decomposes into a video layer (array-fed, replaceable) + its title component', () => {
574
+ const seqs = enumerateClips(CANVAS, src, 'reel').clips.filter((c) => c.kind === 'sequence');
575
+ expect(seqs[0]!.layers).toEqual([]); // pure TitleCard → no layers
576
+ const layers = seqs[1]!.layers;
577
+ expect(layers.map((l) => l.kind)).toEqual(['video', 'component']);
578
+ expect(layers[0]!.mediaTag).toBe('Video');
579
+ expect(layers[0]!.mediaArrayRef).toEqual({ arrayName: 'CLIPS', index: 0, field: 'src' });
580
+ expect(layers[1]!.label).toBe('LowerThird');
581
+ });
582
+ });
583
+
584
+ describe('enumerateClips — nested/wrapper-component media (showreel granularity + replace)', () => {
585
+ const showreel = [
586
+ 'const CLIPS = [',
587
+ " { src: 'assets/a.mp4', label: 'Aerial' },",
588
+ " { src: 'assets/b.mp4', label: 'Nature' },",
589
+ '];',
590
+ 'const ClipShot = ({ clip }) => <AbsoluteFill><Video src={clip.src} /></AbsoluteFill>;',
591
+ 'const TitleCard = () => <AbsoluteFill>hi</AbsoluteFill>;',
592
+ 'const Comp = () => (<><TransitionSeries>',
593
+ ' <TransitionSeries.Sequence durationInFrames={60}><TitleCard /></TransitionSeries.Sequence>',
594
+ ' <TransitionSeries.Transition timing={t} />',
595
+ ' <TransitionSeries.Sequence durationInFrames={60}><ClipShot clip={CLIPS[0]} /></TransitionSeries.Sequence>',
596
+ ' <TransitionSeries.Transition timing={t} />',
597
+ ' <TransitionSeries.Sequence durationInFrames={60}><ClipShot clip={CLIPS[1]} /></TransitionSeries.Sequence>',
598
+ '</TransitionSeries><Audio src="assets/music.mp3" /></>);',
599
+ 'function Canvas() { return <DCArtboard id="reel"><VideoComp component={Comp} durationInFrames={180} fps={30} /></DCArtboard>; }',
600
+ ].join('\n');
601
+
602
+ test('resolves media through a wrapper component fed by an array element', () => {
603
+ const seqs = enumerateClips(CANVAS, showreel, 'reel').clips.filter(
604
+ (c) => c.kind === 'sequence'
605
+ );
606
+ // TitleCard = pure function, no media.
607
+ expect(seqs[0]!.mediaTag).toBeNull();
608
+ // ClipShot clips → Video, src resolved through CLIPS[i].src, array-ref for replace.
609
+ expect(seqs[1]!.mediaTag).toBe('Video');
610
+ expect(seqs[1]!.mediaSrc).toBe('assets/a.mp4');
611
+ expect(seqs[1]!.mediaArrayRef).toEqual({ arrayName: 'CLIPS', index: 0, field: 'src' });
612
+ expect(seqs[2]!.mediaArrayRef).toEqual({ arrayName: 'CLIPS', index: 1, field: 'src' });
613
+ });
614
+
615
+ test('the loose <Audio> is NOT mis-attributed to the last sequence', () => {
616
+ const { clips, media } = enumerateClips(CANVAS, showreel, 'reel');
617
+ const seqs = clips.filter((c) => c.kind === 'sequence');
618
+ expect(seqs.every((s) => s.mediaTag !== 'Audio')).toBe(true);
619
+ expect(media.length).toBe(1);
620
+ expect(media[0]!.tag).toBe('Audio');
621
+ });
622
+ });
623
+
624
+ describe('applyEditArrayElementString — array-fed src replace (showreel)', () => {
625
+ const src = [
626
+ 'const CLIPS = [',
627
+ " { src: 'assets/a.mp4', label: 'Aerial' },",
628
+ " { src: 'assets/b.mp4', label: 'Nature' },",
629
+ '];',
630
+ ].join('\n');
631
+
632
+ test('rewrites the indexed element string', () => {
633
+ const out = applyEditArrayElementString(CANVAS, src, 'CLIPS', 1, 'src', 'assets/new.mp4');
634
+ expect(out.source).toContain('"assets/new.mp4"');
635
+ expect(out.source).toContain("'assets/a.mp4'"); // sibling untouched
636
+ });
637
+
638
+ test('rejects a traversal / scheme value', () => {
639
+ expect(() => applyEditArrayElementString(CANVAS, src, 'CLIPS', 0, 'src', '../x')).toThrow(
640
+ CanvasEditError
641
+ );
642
+ });
643
+
644
+ test('throws on a missing array / element / field', () => {
645
+ expect(() =>
646
+ applyEditArrayElementString(CANVAS, src, 'NOPE', 0, 'src', 'assets/x.mp4')
647
+ ).toThrow(CanvasEditError);
648
+ expect(() =>
649
+ applyEditArrayElementString(CANVAS, src, 'CLIPS', 9, 'src', 'assets/x.mp4')
650
+ ).toThrow(CanvasEditError);
651
+ });
652
+ });
653
+
654
+ describe('enumerateClips — loose media beds (DDR-150 dogfood #5, audio replace)', () => {
655
+ test('an <Audio> bed OUTSIDE clips is listed in media[] with a cd-id; one inside a clip is not', () => {
656
+ const src = [
657
+ 'const Comp = () => (',
658
+ ' <>',
659
+ ' <Sequence durationInFrames={60}><Video src="assets/a.mp4" /></Sequence>',
660
+ ' <Sequence durationInFrames={30}><Audio src="assets/voice.mp3" /></Sequence>',
661
+ ' <Audio src="assets/music.mp3" volume={0.6} />',
662
+ ' </>',
663
+ ');',
664
+ 'function Canvas() { return <DCArtboard id="x"><VideoComp component={Comp} durationInFrames={60} fps={30} /></DCArtboard>; }',
665
+ ].join('\n');
666
+ const { clips, media } = enumerateClips(CANVAS, src, 'x');
667
+ expect(clips.length).toBe(2);
668
+ // ONLY the loose bed — the in-clip <Audio>/<Video> stay clip-scoped media.
669
+ expect(media.length).toBe(1);
670
+ expect(media[0]!.tag).toBe('Audio');
671
+ expect(media[0]!.src).toBe('assets/music.mp3');
672
+ expect(typeof media[0]!.cdId === 'string' || media[0]!.cdId === null).toBe(true);
673
+ });
674
+ });
675
+
676
+ describe('assembleCompSource — refs → comp (DDR-150 P4 Task 12)', () => {
677
+ test('lays video clips back-to-back as named sequences + parses + enumerates', () => {
678
+ const tsx = assembleCompSource(
679
+ 'MyReel',
680
+ [
681
+ { src: 'assets/a.mp4', mediaKind: 'video', durationInFrames: 60 },
682
+ { src: 'assets/b.mp4', mediaKind: 'video', durationInFrames: 40 },
683
+ ],
684
+ { fps: 30 }
685
+ );
686
+ // Generated source is parseable + the enumerator sees exactly two clips…
687
+ const { clips, compName, durationInFrames } = enumerateClips('/abs/MyReel.tsx', tsx, 'reel');
688
+ expect(compName).toBe('Comp');
689
+ expect(clips.map((c) => c.mediaSrc)).toEqual(['assets/a.mp4', 'assets/b.mp4']);
690
+ // …laid back-to-back (cursor advances by each duration)…
691
+ expect(clips[0]!.from).toBe(0);
692
+ expect(clips[1]!.from).toBe(60);
693
+ expect(clips[1]!.durationInFrames).toBe(40);
694
+ // …with durable <Sequence name> identity so hand-edits land right.
695
+ expect(clips.map((c) => c.stableId)).toEqual(['name:clip-1', 'name:clip-2']);
696
+ // total duration = sum of clip durations.
697
+ expect(durationInFrames).toBe(100);
698
+ // and it passes the semantic gate.
699
+ expect(() => assertCompSemantics('/abs/MyReel.tsx', tsx)).not.toThrow();
700
+ });
701
+
702
+ test('audio clips become <Audio> beds under the reel (not sequenced)', () => {
703
+ const tsx = assembleCompSource('R', [
704
+ { src: 'assets/v.mp4', mediaKind: 'video', durationInFrames: 90 },
705
+ { src: 'assets/music.mp3', mediaKind: 'audio' },
706
+ ]);
707
+ expect(tsx).toContain('<Audio src="assets/music.mp3" />');
708
+ expect(tsx).toContain(
709
+ `import { AbsoluteFill, Sequence, OffthreadVideo, Audio } from 'remotion';`
710
+ );
711
+ // total driven by the video clip's duration (audio doesn't extend it).
712
+ expect(enumerateClips('/abs/R.tsx', tsx, 'reel').durationInFrames).toBe(90);
713
+ });
714
+
715
+ test('defaults an unknown duration to fps*3', () => {
716
+ const tsx = assembleCompSource('R', [{ src: 'assets/v.mp4', mediaKind: 'video' }], { fps: 24 });
717
+ expect(enumerateClips('/abs/R.tsx', tsx, 'reel').clips[0]!.durationInFrames).toBe(72);
718
+ });
719
+
720
+ test('rejects a traversal / scheme src', () => {
721
+ expect(() =>
722
+ assembleCompSource('R', [{ src: '../../etc/passwd', mediaKind: 'video' }])
723
+ ).toThrow(CanvasEditError);
724
+ expect(() =>
725
+ assembleCompSource('R', [{ src: 'https://evil.example/x.mp4', mediaKind: 'video' }])
726
+ ).toThrow(CanvasEditError);
727
+ });
728
+
729
+ test('refuses an empty clip set', () => {
730
+ expect(() => assembleCompSource('R', [])).toThrow(CanvasEditError);
731
+ });
732
+ });