@1agh/maude 0.51.1 → 0.52.1
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.
- package/apps/studio/api.ts +389 -14
- package/apps/studio/bin/_video-playwright.mjs +15 -6
- package/apps/studio/canvas-edit.ts +141 -20
- package/apps/studio/canvas-lib.tsx +61 -0
- package/apps/studio/client/app.jsx +824 -121
- package/apps/studio/client/panels/ClipInspector.jsx +409 -0
- package/apps/studio/client/panels/CloudBar.jsx +12 -2
- package/apps/studio/client/panels/TimelinePanel.jsx +1775 -430
- package/apps/studio/client/panels/timeline-media-cache.js +274 -0
- package/apps/studio/client/panels/timeline-parse.js +46 -1
- package/apps/studio/client/panels/timeline-scale.js +64 -0
- package/apps/studio/client/report-bug.jsx +15 -15
- package/apps/studio/client/styles/3-shell-maude.css +446 -3
- package/apps/studio/client/styles/4-components-maude.css +55 -0
- package/apps/studio/client/styles/4-components.css +0 -83
- package/apps/studio/clip-ops.ts +1980 -0
- package/apps/studio/cloud/endpoints.ts +88 -17
- package/apps/studio/credential-grammar.ts +100 -0
- package/apps/studio/debug-bundle.ts +12 -0
- package/apps/studio/dist/client.bundle.js +2048 -2048
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/exporters/video.ts +49 -10
- package/apps/studio/http.ts +113 -6
- package/apps/studio/ripple.ts +335 -0
- package/apps/studio/sync/hub-link.ts +3 -3
- package/apps/studio/sync/hubs-config.ts +32 -0
- package/apps/studio/sync/workspace-signin.ts +8 -4
- package/apps/studio/test/_helpers.ts +5 -3
- package/apps/studio/test/canvas-create-api.test.ts +8 -2
- package/apps/studio/test/canvas-origin-gate.test.ts +20 -0
- package/apps/studio/test/clip-addressing.test.ts +7 -6
- package/apps/studio/test/clip-ops.test.ts +836 -0
- package/apps/studio/test/cloud-endpoints.test.ts +37 -2
- package/apps/studio/test/comments-api.test.ts +49 -0
- package/apps/studio/test/credential-grammar.test.ts +107 -0
- package/apps/studio/test/read-only-role.test.ts +95 -0
- package/apps/studio/test/ripple.test.ts +197 -0
- package/apps/studio/test/timeline-scale.test.ts +65 -0
- package/apps/studio/test/workspace-signin.test.ts +4 -1
- package/apps/studio/whats-new.json +35 -0
- package/cli/commands/share.mjs +8 -4
- package/cli/lib/ddr-to-kgai.mjs +6 -1
- package/cli/lib/ddr-to-kgai.test.mjs +1 -2
- package/cli/lib/hubs-config.mjs +15 -1
- package/cli/lib/hubs-config.test.mjs +19 -0
- package/cli/lib/share-plan.mjs +5 -1
- package/cli/lib/share-plan.test.mjs +11 -2
- package/package.json +8 -8
|
@@ -0,0 +1,836 @@
|
|
|
1
|
+
// clip-ops.test.ts — feature-enhanced-video-editing. Golden-file fixtures for
|
|
2
|
+
// the composed clip operations (canvas-edit primitives + the ripple engine).
|
|
3
|
+
|
|
4
|
+
import { describe, expect, test } from 'bun:test';
|
|
5
|
+
|
|
6
|
+
import { enumerateClips } from '../canvas-edit.ts';
|
|
7
|
+
import {
|
|
8
|
+
applyClipAudio,
|
|
9
|
+
applyClipFraming,
|
|
10
|
+
applyClipGrade,
|
|
11
|
+
applyDetachAudio,
|
|
12
|
+
applyEditTransition,
|
|
13
|
+
applyInsertClipAt,
|
|
14
|
+
applyInsertTransition,
|
|
15
|
+
applyMoveClipToOverlay,
|
|
16
|
+
applyMoveClipToStoryline,
|
|
17
|
+
applyRemoveClipRippled,
|
|
18
|
+
applyRemoveTransition,
|
|
19
|
+
applyReorderOverlayLayer,
|
|
20
|
+
applyResolvePlaceholder,
|
|
21
|
+
applySeriesMove,
|
|
22
|
+
applySetClipText,
|
|
23
|
+
applySetPlaybackRate,
|
|
24
|
+
applySplitClip,
|
|
25
|
+
applyTrimIn,
|
|
26
|
+
clipAbsoluteStart,
|
|
27
|
+
filterToGrade,
|
|
28
|
+
gradeToFilter,
|
|
29
|
+
transitionDurationFrames,
|
|
30
|
+
} from '../clip-ops.ts';
|
|
31
|
+
|
|
32
|
+
const CANVAS = '/abs/Canvas.tsx';
|
|
33
|
+
|
|
34
|
+
const SERIES = [
|
|
35
|
+
"import { TransitionSeries } from '@remotion/transitions';",
|
|
36
|
+
'const S1 = 90;',
|
|
37
|
+
'const S2 = 120;',
|
|
38
|
+
'const S3 = 60;',
|
|
39
|
+
'const XF = 15;',
|
|
40
|
+
'const TOTAL = S1 + S2 + S3 - XF - XF;',
|
|
41
|
+
'const Comp = () => (',
|
|
42
|
+
' <TransitionSeries>',
|
|
43
|
+
' <TransitionSeries.Sequence name="s1" durationInFrames={S1}><Video src="a.mp4" /></TransitionSeries.Sequence>',
|
|
44
|
+
' <TransitionSeries.Transition presentation={fade()} timing={linearTiming({ durationInFrames: XF })} />',
|
|
45
|
+
' <TransitionSeries.Sequence name="s2" durationInFrames={S2}><Video src="b.mp4" /></TransitionSeries.Sequence>',
|
|
46
|
+
' <TransitionSeries.Transition presentation={fade()} timing={linearTiming({ durationInFrames: XF })} />',
|
|
47
|
+
' <TransitionSeries.Sequence name="s3" durationInFrames={S3}><Video src="c.mp4" /></TransitionSeries.Sequence>',
|
|
48
|
+
' </TransitionSeries>',
|
|
49
|
+
');',
|
|
50
|
+
'function Canvas() {',
|
|
51
|
+
' return (',
|
|
52
|
+
' <DCArtboard id="a"><VideoComp component={Comp} durationInFrames={TOTAL} fps={30} /></DCArtboard>',
|
|
53
|
+
' );',
|
|
54
|
+
'}',
|
|
55
|
+
].join('\n');
|
|
56
|
+
|
|
57
|
+
const STANDALONE = [
|
|
58
|
+
'const TOTAL = 300;',
|
|
59
|
+
'const Comp = () => (',
|
|
60
|
+
' <>',
|
|
61
|
+
' <Sequence name="base" from={0} durationInFrames={300}><Video src="a.mp4" /></Sequence>',
|
|
62
|
+
' <Sequence name="title" from={30} durationInFrames={90}><Title /></Sequence>',
|
|
63
|
+
' </>',
|
|
64
|
+
');',
|
|
65
|
+
'const Title = () => <AbsoluteFill />;',
|
|
66
|
+
'function Canvas() {',
|
|
67
|
+
' return (',
|
|
68
|
+
' <DCArtboard id="a"><VideoComp component={Comp} durationInFrames={TOTAL} fps={30} /></DCArtboard>',
|
|
69
|
+
' );',
|
|
70
|
+
'}',
|
|
71
|
+
].join('\n');
|
|
72
|
+
|
|
73
|
+
describe('transitionDurationFrames', () => {
|
|
74
|
+
test('parses the const-fed timing duration', () => {
|
|
75
|
+
const { clips } = enumerateClips(CANVAS, SERIES, 'a');
|
|
76
|
+
const t = clips.find((c) => c.kind === 'transition');
|
|
77
|
+
expect(t).toBeTruthy();
|
|
78
|
+
expect(transitionDurationFrames(SERIES, t!)).toBe(15);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe('applyRemoveClipRippled — series beat (the magnetic Delete)', () => {
|
|
83
|
+
test('removes the beat + one transition and shrinks TOTAL by dur − overlap', () => {
|
|
84
|
+
const r = applyRemoveClipRippled(CANVAS, SERIES, 'a', 'name:s2', undefined);
|
|
85
|
+
expect(r.rippled).toBe(true);
|
|
86
|
+
// S2 (120) leaves, one XF (15) transition leaves with it → TOTAL −105.
|
|
87
|
+
expect(r.source).toContain('const TOTAL = S1 + S2 + S3 - XF - XF - 105;');
|
|
88
|
+
expect(r.source).not.toContain('name="s2"');
|
|
89
|
+
// still a valid alternation: s1 ⧓ s3
|
|
90
|
+
const after = enumerateClips(CANVAS, r.source, 'a');
|
|
91
|
+
expect(after.clips.map((c) => c.kind)).toEqual(['sequence', 'transition', 'sequence']);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('removing the LAST beat drops its preceding transition', () => {
|
|
95
|
+
const r = applyRemoveClipRippled(CANVAS, SERIES, 'a', 'name:s3', undefined);
|
|
96
|
+
expect(r.source).not.toContain('name="s3"');
|
|
97
|
+
expect(r.source).toContain('- 45;'); // 60 − 15
|
|
98
|
+
const after = enumerateClips(CANVAS, r.source, 'a');
|
|
99
|
+
expect(after.clips.map((c) => c.kind)).toEqual(['sequence', 'transition', 'sequence']);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe('applySeriesMove — magnetic drag-to-reorder', () => {
|
|
104
|
+
const order = (src) =>
|
|
105
|
+
enumerateClips(CANVAS, src, 'a')
|
|
106
|
+
.clips.filter((c) => c.kind === 'sequence')
|
|
107
|
+
.map((c) => c.stableId);
|
|
108
|
+
|
|
109
|
+
test('move the last beat before the first (multi-slot, not a swap)', () => {
|
|
110
|
+
const r = applySeriesMove(
|
|
111
|
+
CANVAS,
|
|
112
|
+
SERIES,
|
|
113
|
+
'a',
|
|
114
|
+
'name:s3',
|
|
115
|
+
undefined,
|
|
116
|
+
'name:s1',
|
|
117
|
+
undefined,
|
|
118
|
+
'before'
|
|
119
|
+
);
|
|
120
|
+
expect(order(r.source)).toEqual(['name:s3', 'name:s1', 'name:s2']);
|
|
121
|
+
expect(r.stableId).toBe('name:s3');
|
|
122
|
+
const kinds = enumerateClips(CANVAS, r.source, 'a').clips.map((c) => c.kind);
|
|
123
|
+
expect(kinds).toEqual(['sequence', 'transition', 'sequence', 'transition', 'sequence']);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('move the first beat after the last', () => {
|
|
127
|
+
const r = applySeriesMove(
|
|
128
|
+
CANVAS,
|
|
129
|
+
SERIES,
|
|
130
|
+
'a',
|
|
131
|
+
'name:s1',
|
|
132
|
+
undefined,
|
|
133
|
+
'name:s3',
|
|
134
|
+
undefined,
|
|
135
|
+
'after'
|
|
136
|
+
);
|
|
137
|
+
expect(order(r.source)).toEqual(['name:s2', 'name:s3', 'name:s1']);
|
|
138
|
+
const kinds = enumerateClips(CANVAS, r.source, 'a').clips.map((c) => c.kind);
|
|
139
|
+
expect(kinds).toEqual(['sequence', 'transition', 'sequence', 'transition', 'sequence']);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('adjacent move behaves like the ▲ swap', () => {
|
|
143
|
+
const r = applySeriesMove(
|
|
144
|
+
CANVAS,
|
|
145
|
+
SERIES,
|
|
146
|
+
'a',
|
|
147
|
+
'name:s2',
|
|
148
|
+
undefined,
|
|
149
|
+
'name:s1',
|
|
150
|
+
undefined,
|
|
151
|
+
'before'
|
|
152
|
+
);
|
|
153
|
+
expect(order(r.source)).toEqual(['name:s2', 'name:s1', 'name:s3']);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('refuses a self-move and a standalone target', () => {
|
|
157
|
+
expect(() =>
|
|
158
|
+
applySeriesMove(CANVAS, SERIES, 'a', 'name:s1', undefined, 'name:s1', undefined, 'after')
|
|
159
|
+
).toThrow();
|
|
160
|
+
expect(() =>
|
|
161
|
+
applySeriesMove(
|
|
162
|
+
CANVAS,
|
|
163
|
+
STANDALONE,
|
|
164
|
+
'a',
|
|
165
|
+
'name:base',
|
|
166
|
+
undefined,
|
|
167
|
+
'name:title',
|
|
168
|
+
undefined,
|
|
169
|
+
'after'
|
|
170
|
+
)
|
|
171
|
+
).toThrow();
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
describe('applyInsertClipAt — the drop caret', () => {
|
|
176
|
+
test('storyline insert at index 1 lands between s1 and s2, TOTAL grows', () => {
|
|
177
|
+
const r = applyInsertClipAt(CANVAS, SERIES, 'a', {
|
|
178
|
+
lane: 'storyline',
|
|
179
|
+
index: 1,
|
|
180
|
+
durationInFrames: 45,
|
|
181
|
+
mediaTag: 'Video',
|
|
182
|
+
src: 'assets/new.mp4',
|
|
183
|
+
});
|
|
184
|
+
const cc = enumerateClips(CANVAS, r.source, 'a');
|
|
185
|
+
const beats = cc.clips.filter((c) => c.kind === 'sequence');
|
|
186
|
+
expect(beats.length).toBe(4);
|
|
187
|
+
expect(beats[1]?.mediaSrc).toBe('assets/new.mp4');
|
|
188
|
+
expect(beats[1]?.stableId).toBe(r.stableId);
|
|
189
|
+
// alternation survived
|
|
190
|
+
const kinds = cc.clips.map((c) => c.kind);
|
|
191
|
+
expect(kinds).toEqual([
|
|
192
|
+
'sequence',
|
|
193
|
+
'transition',
|
|
194
|
+
'sequence',
|
|
195
|
+
'transition',
|
|
196
|
+
'sequence',
|
|
197
|
+
'transition',
|
|
198
|
+
'sequence',
|
|
199
|
+
]);
|
|
200
|
+
// TOTAL bumped by 45 − 15 (cloned transition overlap)
|
|
201
|
+
expect(r.source).toContain('const TOTAL = S1 + S2 + S3 - XF - XF + 30;');
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test('storyline insert at index 0 keeps the series starting with a Sequence', () => {
|
|
205
|
+
const r = applyInsertClipAt(CANVAS, SERIES, 'a', {
|
|
206
|
+
lane: 'storyline',
|
|
207
|
+
index: 0,
|
|
208
|
+
durationInFrames: 30,
|
|
209
|
+
mediaTag: 'Video',
|
|
210
|
+
src: 'assets/head.mp4',
|
|
211
|
+
});
|
|
212
|
+
const cc = enumerateClips(CANVAS, r.source, 'a');
|
|
213
|
+
expect(cc.clips[0]?.kind).toBe('sequence');
|
|
214
|
+
expect(cc.clips[0]?.mediaSrc).toBe('assets/head.mp4');
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test('audio lane inserts a standalone <Sequence from> OUTSIDE the series', () => {
|
|
218
|
+
const r = applyInsertClipAt(CANVAS, SERIES, 'a', {
|
|
219
|
+
lane: 'audio',
|
|
220
|
+
from: 60,
|
|
221
|
+
durationInFrames: 240,
|
|
222
|
+
mediaTag: 'Audio',
|
|
223
|
+
src: 'assets/music.mp3',
|
|
224
|
+
});
|
|
225
|
+
const cc = enumerateClips(CANVAS, r.source, 'a');
|
|
226
|
+
const standalone = cc.clips.filter((c) => c.tag === 'Sequence');
|
|
227
|
+
expect(standalone.length).toBe(1);
|
|
228
|
+
expect(standalone[0]?.from).toBe(60);
|
|
229
|
+
expect(standalone[0]?.mediaSrc).toBe('assets/music.mp3');
|
|
230
|
+
// alternation of the series untouched
|
|
231
|
+
expect(() => enumerateClips(CANVAS, r.source, 'a')).not.toThrow();
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test('refuses a traversal/scheme src', () => {
|
|
235
|
+
expect(() =>
|
|
236
|
+
applyInsertClipAt(CANVAS, SERIES, 'a', {
|
|
237
|
+
lane: 'storyline',
|
|
238
|
+
index: 1,
|
|
239
|
+
durationInFrames: 30,
|
|
240
|
+
mediaTag: 'Video',
|
|
241
|
+
src: '../evil.mp4',
|
|
242
|
+
})
|
|
243
|
+
).toThrow();
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
describe('applyRemoveClipRippled — standalone overlay clip', () => {
|
|
248
|
+
test('removes WITHOUT ripple (overlays must not move the cut)', () => {
|
|
249
|
+
const r = applyRemoveClipRippled(CANVAS, STANDALONE, 'a', 'name:title', undefined);
|
|
250
|
+
expect(r.rippled).toBe(false);
|
|
251
|
+
expect(r.source).toContain('const TOTAL = 300;');
|
|
252
|
+
expect(r.source).not.toContain('name="title"');
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
// ---------------------------------------------------------------------------
|
|
257
|
+
// Phase 2 — parametric verbs.
|
|
258
|
+
|
|
259
|
+
describe('applySetPlaybackRate (Task 10)', () => {
|
|
260
|
+
test('2× halves the duration, writes playbackRate, ripples TOTAL', () => {
|
|
261
|
+
const r = applySetPlaybackRate(CANVAS, SERIES, 'a', 'name:s2', undefined, 2);
|
|
262
|
+
expect(r.newDuration).toBe(60); // 120 / 2
|
|
263
|
+
expect(r.source).toContain('playbackRate={2}');
|
|
264
|
+
expect(r.source).toMatch(/name="s2" durationInFrames=\{60\}/);
|
|
265
|
+
expect(r.source).toContain('const TOTAL = S1 + S2 + S3 - XF - XF - 60;');
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test('rate 1 removes the attr and restores the duration', () => {
|
|
269
|
+
const twice = applySetPlaybackRate(CANVAS, SERIES, 'a', 'name:s2', undefined, 2).source;
|
|
270
|
+
const back = applySetPlaybackRate(CANVAS, twice, 'a', 'name:s2', undefined, 1);
|
|
271
|
+
expect(back.source).not.toContain('playbackRate');
|
|
272
|
+
expect(back.newDuration).toBe(120);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test('refuses a wrapper-media clip and absurd rates', () => {
|
|
276
|
+
expect(() => applySetPlaybackRate(CANVAS, SERIES, 'a', 'name:s1', undefined, 0)).toThrow();
|
|
277
|
+
const NOMEDIA = SERIES.replace('<Video src="b.mp4" />', '<Card />');
|
|
278
|
+
expect(() => applySetPlaybackRate(CANVAS, NOMEDIA, 'a', 'name:s2', undefined, 2)).toThrow();
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
describe('applyTrimIn (Task 11)', () => {
|
|
283
|
+
test('trims into the clip: trimBefore + shorter duration + ripple', () => {
|
|
284
|
+
const r = applyTrimIn(CANVAS, SERIES, 'a', 'name:s2', undefined, 20);
|
|
285
|
+
expect(r.trimBefore).toBe(20);
|
|
286
|
+
expect(r.newDuration).toBe(100);
|
|
287
|
+
expect(r.source).toContain('trimBefore={20}');
|
|
288
|
+
expect(r.source).toContain('- 20;'); // TOTAL shrank
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test('standalone clip: from moves right with the in-point', () => {
|
|
292
|
+
const r = applyTrimIn(CANVAS, STANDALONE, 'a', 'name:base', undefined, 10);
|
|
293
|
+
expect(r.source).toMatch(/name="base" from=\{10\} durationInFrames=\{290\}/);
|
|
294
|
+
// a JSX-only clip (no media) refuses
|
|
295
|
+
expect(() => applyTrimIn(CANVAS, STANDALONE, 'a', 'name:title', undefined, 10)).toThrow();
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test('legacy startFrom is read and retired', () => {
|
|
299
|
+
const LEGACY = SERIES.replace('<Video src="b.mp4" />', '<Video src="b.mp4" startFrom={30} />');
|
|
300
|
+
const r = applyTrimIn(CANVAS, LEGACY, 'a', 'name:s2', undefined, 10);
|
|
301
|
+
expect(r.trimBefore).toBe(40);
|
|
302
|
+
expect(r.source).toContain('trimBefore={40}');
|
|
303
|
+
expect(r.source).not.toContain('startFrom');
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
test('clamps at trim 0 and refuses a <1-frame result', () => {
|
|
307
|
+
expect(() => applyTrimIn(CANVAS, SERIES, 'a', 'name:s2', undefined, 120)).toThrow();
|
|
308
|
+
});
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
describe('applyClipAudio + applyDetachAudio (Task 12)', () => {
|
|
312
|
+
test('mute + volume write onto the media element', () => {
|
|
313
|
+
const r = applyClipAudio(CANVAS, SERIES, 'a', 'name:s2', undefined, {
|
|
314
|
+
muted: true,
|
|
315
|
+
volume: 0.4,
|
|
316
|
+
});
|
|
317
|
+
expect(r.source).toMatch(/<Video muted volume=\{0.4\} src="b.mp4" \/>/);
|
|
318
|
+
const off = applyClipAudio(CANVAS, r.source, 'a', 'name:s2', undefined, {
|
|
319
|
+
muted: false,
|
|
320
|
+
volume: 1,
|
|
321
|
+
});
|
|
322
|
+
expect(off.source).toContain('<Video src="b.mp4" />');
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
test('detach: video muted + audio-band <Sequence><Audio same-src> at the beat start', () => {
|
|
326
|
+
const r = applyDetachAudio(CANVAS, SERIES, 'a', 'name:s2', undefined);
|
|
327
|
+
expect(r.source).toMatch(/<Video muted src="b.mp4" \/>/);
|
|
328
|
+
const cc = enumerateClips(CANVAS, r.source, 'a');
|
|
329
|
+
const audioClip = cc.clips.find((c) => c.stableId === r.audioStableId);
|
|
330
|
+
expect(audioClip?.mediaTag).toBe('Audio');
|
|
331
|
+
expect(audioClip?.mediaSrc).toBe('b.mp4');
|
|
332
|
+
// s2 starts at S1 − XF = 75
|
|
333
|
+
expect(audioClip?.from).toBe(75);
|
|
334
|
+
expect(audioClip?.durationInFrames).toBe(120);
|
|
335
|
+
});
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
describe('clipAbsoluteStart', () => {
|
|
339
|
+
test('series cursor walk subtracts transition overlaps', () => {
|
|
340
|
+
expect(clipAbsoluteStart(SERIES, CANVAS, 'a', 'name:s1')).toBe(0);
|
|
341
|
+
expect(clipAbsoluteStart(SERIES, CANVAS, 'a', 'name:s2')).toBe(75);
|
|
342
|
+
expect(clipAbsoluteStart(SERIES, CANVAS, 'a', 'name:s3')).toBe(180);
|
|
343
|
+
});
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
describe('grade (Task 14)', () => {
|
|
347
|
+
test('params ↔ filter round-trip is lossless', () => {
|
|
348
|
+
const p = { brightness: 1.2, contrast: 0.9, saturation: 1.5, hue: 30, sepia: 0.2 };
|
|
349
|
+
expect(filterToGrade(gradeToFilter(p))).toEqual(p);
|
|
350
|
+
expect(filterToGrade('blur(4px)')).toBe(null); // unrecognized → read-only badge
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
test('sets ONE filter string on the media style, clears on null', () => {
|
|
354
|
+
const r = applyClipGrade(CANVAS, SERIES, 'a', 'name:s2', undefined, {
|
|
355
|
+
brightness: 1.1,
|
|
356
|
+
grayscale: 1,
|
|
357
|
+
});
|
|
358
|
+
expect(r.source).toContain("style={{ filter: 'brightness(1.1) grayscale(1)' }}");
|
|
359
|
+
const cleared = applyClipGrade(CANVAS, r.source, 'a', 'name:s2', undefined, null);
|
|
360
|
+
expect(cleared.source).not.toContain('filter:');
|
|
361
|
+
});
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
describe('applyClipFraming (Task 13)', () => {
|
|
365
|
+
test('wrap → rewrap → unwrap round-trips to the original media', () => {
|
|
366
|
+
const r1 = applyClipFraming(CANVAS, SERIES, 'a', 'name:s2', undefined, {
|
|
367
|
+
scale: 1.5,
|
|
368
|
+
x: 10,
|
|
369
|
+
y: -5,
|
|
370
|
+
});
|
|
371
|
+
expect(r1.source).toContain('data-mframe="1.5,10,-5"');
|
|
372
|
+
expect(r1.source).toContain("transform: 'scale(1.5) translate(10%, -5%)'");
|
|
373
|
+
const r2 = applyClipFraming(CANVAS, r1.source, 'a', 'name:s2', undefined, {
|
|
374
|
+
scale: 2,
|
|
375
|
+
x: 0,
|
|
376
|
+
y: 0,
|
|
377
|
+
});
|
|
378
|
+
expect(r2.source).toContain('data-mframe="2,0,0"');
|
|
379
|
+
expect(r2.source).not.toContain('data-mframe="1.5');
|
|
380
|
+
const r3 = applyClipFraming(CANVAS, r2.source, 'a', 'name:s2', undefined, null);
|
|
381
|
+
expect(r3.source).not.toContain('data-mframe');
|
|
382
|
+
expect(r3.source).toContain('<Video src="b.mp4" />');
|
|
383
|
+
});
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
// Hard-cut series (no transitions — valid Remotion), the split playground.
|
|
387
|
+
const HARDCUT = [
|
|
388
|
+
"import { TransitionSeries } from '@remotion/transitions';",
|
|
389
|
+
'const TOTAL = 270;',
|
|
390
|
+
'const Comp = () => (',
|
|
391
|
+
' <TransitionSeries>',
|
|
392
|
+
' <TransitionSeries.Sequence name="one" durationInFrames={90}><Video src="a.mp4" /></TransitionSeries.Sequence>',
|
|
393
|
+
' <TransitionSeries.Sequence name="two" durationInFrames={120}><Video src="b.mp4" trimBefore={30} /></TransitionSeries.Sequence>',
|
|
394
|
+
' <TransitionSeries.Sequence name="three" durationInFrames={60}><Video src="c.mp4" /></TransitionSeries.Sequence>',
|
|
395
|
+
' </TransitionSeries>',
|
|
396
|
+
');',
|
|
397
|
+
'function Canvas() {',
|
|
398
|
+
' return (',
|
|
399
|
+
' <DCArtboard id="a"><VideoComp component={Comp} durationInFrames={TOTAL} fps={30} /></DCArtboard>',
|
|
400
|
+
' );',
|
|
401
|
+
'}',
|
|
402
|
+
].join('\n');
|
|
403
|
+
|
|
404
|
+
describe('applySplitClip (Task 16)', () => {
|
|
405
|
+
test('splits a hard-cut beat: two halves, trimBefore shifted, no TOTAL change', () => {
|
|
406
|
+
// "two" spans 90–210; split at 150 → halves 60 + 60, second trim 30+60=90.
|
|
407
|
+
const r = applySplitClip(CANVAS, HARDCUT, 'a', 'name:two', undefined, 150);
|
|
408
|
+
const cc = enumerateClips(CANVAS, r.source, 'a');
|
|
409
|
+
const seqs = cc.clips.filter((c) => c.kind === 'sequence');
|
|
410
|
+
expect(seqs.length).toBe(4);
|
|
411
|
+
expect(r.firstStableId).toBe('name:two');
|
|
412
|
+
expect(r.secondStableId).toBe('name:two-150');
|
|
413
|
+
expect(seqs[1]?.durationInFrames).toBe(60);
|
|
414
|
+
expect(seqs[2]?.durationInFrames).toBe(60);
|
|
415
|
+
expect(r.source).toContain('trimBefore={90}');
|
|
416
|
+
expect(r.source).toContain('const TOTAL = 270;'); // halves sum — no ripple
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
test('standalone split: second half gets from + trimBefore', () => {
|
|
420
|
+
const r = applySplitClip(CANVAS, STANDALONE, 'a', 'name:base', undefined, 120);
|
|
421
|
+
expect(r.source).toMatch(/from=\{120\} durationInFrames=\{180\}/);
|
|
422
|
+
expect(r.source).toContain('trimBefore={120}');
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
test('corruption fixtures: off-by-one at the cut frame refuses', () => {
|
|
426
|
+
expect(() => applySplitClip(CANVAS, HARDCUT, 'a', 'name:two', undefined, 90)).toThrow(); // at start
|
|
427
|
+
expect(() => applySplitClip(CANVAS, HARDCUT, 'a', 'name:two', undefined, 210)).toThrow(); // at end
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
test('corruption fixture: a beat touching a transition refuses with guidance', () => {
|
|
431
|
+
let err: unknown;
|
|
432
|
+
try {
|
|
433
|
+
applySplitClip(CANVAS, SERIES, 'a', 'name:s2', undefined, 100);
|
|
434
|
+
} catch (e) {
|
|
435
|
+
err = e;
|
|
436
|
+
}
|
|
437
|
+
expect(String(err?.message)).toContain('remove the transition first');
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
test('speed-carrying clip: second-half trim accounts for the rate', () => {
|
|
441
|
+
const fast = applySetPlaybackRate(CANVAS, HARDCUT, 'a', 'name:two', undefined, 2).source;
|
|
442
|
+
// two now 60f (90–150). split at 120 → offset 30 → trim 30 + 30×2 = 90.
|
|
443
|
+
const r = applySplitClip(CANVAS, fast, 'a', 'name:two', undefined, 120);
|
|
444
|
+
expect(r.source).toContain('trimBefore={90}');
|
|
445
|
+
});
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
describe('applyInsertTransition + applyRemoveTransition (Task 17)', () => {
|
|
449
|
+
test('insert into a hard cut: fade + linearTiming + TOTAL shrinks', () => {
|
|
450
|
+
const r = applyInsertTransition(CANVAS, HARDCUT, 'a', 'name:one', undefined, 'fade', 12);
|
|
451
|
+
expect(r.source).toContain(
|
|
452
|
+
'<TransitionSeries.Transition presentation={fade()} timing={linearTiming({ durationInFrames: 12 })} />'
|
|
453
|
+
);
|
|
454
|
+
expect(r.source).toContain("import { fade } from '@remotion/transitions/fade';");
|
|
455
|
+
expect(r.source).toMatch(
|
|
456
|
+
/import \{ linearTiming, TransitionSeries \} from '@remotion\/transitions';/
|
|
457
|
+
);
|
|
458
|
+
expect(r.source).toContain('const TOTAL = 258;');
|
|
459
|
+
expect(r.transitionStableId).toBeTruthy();
|
|
460
|
+
const kinds = enumerateClips(CANVAS, r.source, 'a').clips.map((c) => c.kind);
|
|
461
|
+
expect(kinds).toEqual(['sequence', 'transition', 'sequence', 'sequence']);
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
test('refuses an already-transitioned seam + a standalone pair', () => {
|
|
465
|
+
expect(() =>
|
|
466
|
+
applyInsertTransition(CANVAS, SERIES, 'a', 'name:s1', undefined, 'fade', 12)
|
|
467
|
+
).toThrow(/already has a transition/);
|
|
468
|
+
expect(() =>
|
|
469
|
+
applyInsertTransition(CANVAS, STANDALONE, 'a', 'name:base', undefined, 'fade', 12)
|
|
470
|
+
).toThrow(/standalone/);
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
test('remove: seam becomes a hard cut, TOTAL grows back', () => {
|
|
474
|
+
const cc = enumerateClips(CANVAS, SERIES, 'a');
|
|
475
|
+
const t = cc.clips.find((c) => c.kind === 'transition');
|
|
476
|
+
const r = applyRemoveTransition(CANVAS, SERIES, 'a', t.stableId, undefined);
|
|
477
|
+
const kinds = enumerateClips(CANVAS, r.source, 'a').clips.map((c) => c.kind);
|
|
478
|
+
expect(kinds).toEqual(['sequence', 'sequence', 'transition', 'sequence']);
|
|
479
|
+
expect(r.source).toContain('+ 15;'); // TOTAL grew by the removed overlap
|
|
480
|
+
});
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
describe('applyInsertClipAt — Title overlay (Task 19)', () => {
|
|
484
|
+
test('title block is a quoted literal (injection-safe) and lands in the overlay', () => {
|
|
485
|
+
const evil = `*/ backtick \` \${process.env.HOME} "quote"`;
|
|
486
|
+
const r = applyInsertClipAt(CANVAS, SERIES, 'a', {
|
|
487
|
+
lane: 'overlay',
|
|
488
|
+
from: 10,
|
|
489
|
+
durationInFrames: 60,
|
|
490
|
+
mediaTag: 'Title',
|
|
491
|
+
src: evil,
|
|
492
|
+
});
|
|
493
|
+
expect(r.source).toContain(JSON.stringify(evil));
|
|
494
|
+
const cc = enumerateClips(CANVAS, r.source, 'a');
|
|
495
|
+
expect(cc.clips.some((c) => c.tag === 'Sequence')).toBe(true);
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
test('overlapping overlays are ALLOWED — each is its own layer (z = document order)', () => {
|
|
499
|
+
const first = applyInsertClipAt(CANVAS, SERIES, 'a', {
|
|
500
|
+
lane: 'overlay',
|
|
501
|
+
from: 10,
|
|
502
|
+
durationInFrames: 60,
|
|
503
|
+
mediaTag: 'Title',
|
|
504
|
+
src: 'Hello',
|
|
505
|
+
}).source;
|
|
506
|
+
const second = applyInsertClipAt(CANVAS, first, 'a', {
|
|
507
|
+
lane: 'overlay',
|
|
508
|
+
from: 30,
|
|
509
|
+
durationInFrames: 60,
|
|
510
|
+
mediaTag: 'Title',
|
|
511
|
+
src: 'World',
|
|
512
|
+
});
|
|
513
|
+
expect(second.stableId).toBeTruthy();
|
|
514
|
+
const cc = enumerateClips(CANVAS, second.source, 'a');
|
|
515
|
+
expect(cc.clips.filter((c) => c.tag === 'Sequence').length).toBe(2);
|
|
516
|
+
});
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
describe('greenfield first drop (Task 20)', () => {
|
|
520
|
+
const EMPTY = [
|
|
521
|
+
"import { DesignCanvas, DCSection, DCArtboard, VideoComp } from '@maude/canvas-lib';",
|
|
522
|
+
"import { AbsoluteFill } from 'remotion';",
|
|
523
|
+
'const Comp = () => (',
|
|
524
|
+
" <AbsoluteFill style={{ background: 'var(--bg-0)' }}>",
|
|
525
|
+
" <AbsoluteFill style={{ display: 'grid', placeItems: 'center' }}>",
|
|
526
|
+
' <div>Drop clips on the timeline to start the cut</div>',
|
|
527
|
+
' </AbsoluteFill>',
|
|
528
|
+
' </AbsoluteFill>',
|
|
529
|
+
');',
|
|
530
|
+
'export default function NewVideo() {',
|
|
531
|
+
' return (',
|
|
532
|
+
' <DesignCanvas>',
|
|
533
|
+
' <DCSection title="Assembled reel">',
|
|
534
|
+
' <DCArtboard id="reel" label="Reel" width={1920} height={1080}>',
|
|
535
|
+
' <VideoComp component={Comp} durationInFrames={150} fps={30} width={1920} height={1080} />',
|
|
536
|
+
' </DCArtboard>',
|
|
537
|
+
' </DCSection>',
|
|
538
|
+
' </DesignCanvas>',
|
|
539
|
+
' );',
|
|
540
|
+
'}',
|
|
541
|
+
].join('\n');
|
|
542
|
+
|
|
543
|
+
test('first storyline drop authors the <TransitionSeries> around the beat', () => {
|
|
544
|
+
const r = applyInsertClipAt('/abs/NewVideo.tsx', EMPTY, 'reel', {
|
|
545
|
+
lane: 'storyline',
|
|
546
|
+
index: 0,
|
|
547
|
+
durationInFrames: 90,
|
|
548
|
+
mediaTag: 'Video',
|
|
549
|
+
src: 'assets/first.mp4',
|
|
550
|
+
});
|
|
551
|
+
expect(r.source).toContain("import { TransitionSeries } from '@remotion/transitions';");
|
|
552
|
+
const cc = enumerateClips('/abs/NewVideo.tsx', r.source, 'reel');
|
|
553
|
+
expect(cc.clips.length).toBe(1);
|
|
554
|
+
expect(cc.clips[0]?.mediaSrc).toBe('assets/first.mp4');
|
|
555
|
+
expect(cc.clips[0]?.tag).toBe('TransitionSeries.Sequence');
|
|
556
|
+
expect(r.stableId).toBe(cc.clips[0]?.stableId);
|
|
557
|
+
// a SECOND drop inserts a sibling beat normally
|
|
558
|
+
const r2 = applyInsertClipAt('/abs/NewVideo.tsx', r.source, 'reel', {
|
|
559
|
+
lane: 'storyline',
|
|
560
|
+
index: 1,
|
|
561
|
+
durationInFrames: 60,
|
|
562
|
+
mediaTag: 'Video',
|
|
563
|
+
src: 'assets/second.mp4',
|
|
564
|
+
});
|
|
565
|
+
const cc2 = enumerateClips('/abs/NewVideo.tsx', r2.source, 'reel');
|
|
566
|
+
expect(cc2.clips.filter((c) => c.kind === 'sequence').length).toBe(2);
|
|
567
|
+
});
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
describe('AIPlaceholder insert + resolve (Tasks 21–22)', () => {
|
|
571
|
+
const EVIL = '*/ `backtick` ${process.env.HOME} </AIPlaceholder> "q" \\';
|
|
572
|
+
|
|
573
|
+
test('prompt is JSON.stringified into the JSX (injection fixtures) and enumerates back', () => {
|
|
574
|
+
const r = applyInsertClipAt(CANVAS, SERIES, 'a', {
|
|
575
|
+
lane: 'storyline',
|
|
576
|
+
index: 3,
|
|
577
|
+
durationInFrames: 150,
|
|
578
|
+
placeholder: { prompt: EVIL, kind: 'veo' },
|
|
579
|
+
});
|
|
580
|
+
// Paired-tag emit (inline-editable): the prompt is a JSON.stringified
|
|
581
|
+
// literal inside the <span> child, never raw text in the JSX.
|
|
582
|
+
expect(r.source).toContain(`<span>{${JSON.stringify(EVIL)}}</span>`);
|
|
583
|
+
expect(r.source).toContain("import { AIPlaceholder } from '@maude/canvas-lib';");
|
|
584
|
+
const cc = enumerateClips(CANVAS, r.source, 'a');
|
|
585
|
+
const ph = cc.clips.find((c) => c.stableId === r.stableId);
|
|
586
|
+
expect(ph?.placeholder?.prompt).toBe(EVIL);
|
|
587
|
+
expect(ph?.placeholder?.kind).toBe('veo');
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
test('resolve swaps the slate for media in place — identity survives', () => {
|
|
591
|
+
const withPh = applyInsertClipAt(CANVAS, SERIES, 'a', {
|
|
592
|
+
lane: 'storyline',
|
|
593
|
+
index: 3,
|
|
594
|
+
durationInFrames: 150,
|
|
595
|
+
placeholder: { prompt: 'sunset over Brno', kind: 'veo' },
|
|
596
|
+
});
|
|
597
|
+
const id = withPh.stableId;
|
|
598
|
+
const r = applyResolvePlaceholder(
|
|
599
|
+
CANVAS,
|
|
600
|
+
withPh.source,
|
|
601
|
+
'a',
|
|
602
|
+
id,
|
|
603
|
+
undefined,
|
|
604
|
+
'assets/deadbeef.mp4',
|
|
605
|
+
'video'
|
|
606
|
+
);
|
|
607
|
+
expect(r.source).not.toContain('AIPlaceholder prompt');
|
|
608
|
+
const cc = enumerateClips(CANVAS, r.source, 'a');
|
|
609
|
+
const clip = cc.clips.find((c) => c.stableId === id);
|
|
610
|
+
expect(clip?.mediaSrc).toBe('assets/deadbeef.mp4');
|
|
611
|
+
expect(clip?.placeholder).toBe(null);
|
|
612
|
+
// traversal src refused
|
|
613
|
+
expect(() =>
|
|
614
|
+
applyResolvePlaceholder(CANVAS, withPh.source, 'a', id, undefined, '../x.mp4', 'video')
|
|
615
|
+
).toThrow();
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
test('resolve is not fooled by a fake </AIPlaceholder> inside the prompt (F-A3)', () => {
|
|
619
|
+
// Adversarial: the prompt embeds a literal close tag. The emit keeps it
|
|
620
|
+
// inside the `{"…"}` string; resolve must find the REAL closer (the last
|
|
621
|
+
// one), overwrite the whole element, and produce valid source.
|
|
622
|
+
const withPh = applyInsertClipAt(CANVAS, SERIES, 'a', {
|
|
623
|
+
lane: 'storyline',
|
|
624
|
+
index: 3,
|
|
625
|
+
durationInFrames: 150,
|
|
626
|
+
placeholder: { prompt: 'foo</AIPlaceholder>bar', kind: 'veo' },
|
|
627
|
+
});
|
|
628
|
+
const id = withPh.stableId;
|
|
629
|
+
const r = applyResolvePlaceholder(
|
|
630
|
+
CANVAS,
|
|
631
|
+
withPh.source,
|
|
632
|
+
'a',
|
|
633
|
+
id,
|
|
634
|
+
undefined,
|
|
635
|
+
'assets/deadbeef.mp4',
|
|
636
|
+
'video'
|
|
637
|
+
);
|
|
638
|
+
expect(r.source).not.toContain('<AIPlaceholder'); // element gone (import may remain)
|
|
639
|
+
expect(r.source).not.toContain('</AIPlaceholder>');
|
|
640
|
+
const cc = enumerateClips(CANVAS, r.source, 'a');
|
|
641
|
+
expect(cc.clips.find((c) => c.stableId === id)?.mediaSrc).toBe('assets/deadbeef.mp4');
|
|
642
|
+
});
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
describe('applyReorderOverlayLayer — vertical z-order (dogfood 2026-07-30)', () => {
|
|
646
|
+
const LAYERED = [
|
|
647
|
+
'const TOTAL = 300;',
|
|
648
|
+
'const Comp = () => (',
|
|
649
|
+
' <>',
|
|
650
|
+
' <Sequence name="base" from={0} durationInFrames={300}><Video src="a.mp4" /></Sequence>',
|
|
651
|
+
' <Sequence name="mid" from={10} durationInFrames={90}><Img src="b.png" /></Sequence>',
|
|
652
|
+
' <Sequence name="top" from={20} durationInFrames={60}><Title /></Sequence>',
|
|
653
|
+
' <Sequence name="bed" from={0} durationInFrames={300}><Audio src="m.mp3" /></Sequence>',
|
|
654
|
+
' </>',
|
|
655
|
+
');',
|
|
656
|
+
'const Title = () => <AbsoluteFill />;',
|
|
657
|
+
'function Canvas() {',
|
|
658
|
+
' return (',
|
|
659
|
+
' <DCArtboard id="a"><VideoComp component={Comp} durationInFrames={TOTAL} fps={30} /></DCArtboard>',
|
|
660
|
+
' );',
|
|
661
|
+
'}',
|
|
662
|
+
].join('\n');
|
|
663
|
+
|
|
664
|
+
const names = (src: string) => [...src.matchAll(/<Sequence name="([^"]+)"/g)].map((m) => m[1]);
|
|
665
|
+
|
|
666
|
+
test('moves a layer to the top of the paint order (audio excluded from the ladder)', () => {
|
|
667
|
+
const cc = enumerateClips(CANVAS, LAYERED, 'a');
|
|
668
|
+
const base = cc.clips.find(
|
|
669
|
+
(c) => c.kind === 'sequence' && /name="base"/.test(LAYERED.slice(c.start, c.end))
|
|
670
|
+
);
|
|
671
|
+
const r = applyReorderOverlayLayer(CANVAS, LAYERED, 'a', base!.stableId, undefined, 2);
|
|
672
|
+
expect(names(r.source)).toEqual(['mid', 'top', 'base', 'bed']);
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
test('one-step down + same-index no-op + audio refused', () => {
|
|
676
|
+
const cc = enumerateClips(CANVAS, LAYERED, 'a');
|
|
677
|
+
const spanOf = (n: string) =>
|
|
678
|
+
cc.clips.find(
|
|
679
|
+
(c) =>
|
|
680
|
+
c.kind === 'sequence' && new RegExp(`name="${n}"`).test(LAYERED.slice(c.start, c.end))
|
|
681
|
+
);
|
|
682
|
+
const top = spanOf('top');
|
|
683
|
+
const down = applyReorderOverlayLayer(CANVAS, LAYERED, 'a', top!.stableId, undefined, 1);
|
|
684
|
+
expect(names(down.source)).toEqual(['base', 'top', 'mid', 'bed']);
|
|
685
|
+
const noop = applyReorderOverlayLayer(CANVAS, LAYERED, 'a', top!.stableId, undefined, 2);
|
|
686
|
+
expect(noop.source).toBe(LAYERED);
|
|
687
|
+
const bed = spanOf('bed');
|
|
688
|
+
expect(() =>
|
|
689
|
+
applyReorderOverlayLayer(CANVAS, LAYERED, 'a', bed!.stableId, undefined, 0)
|
|
690
|
+
).toThrow();
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
test('a series beat is refused (horizontal reorder owns it)', () => {
|
|
694
|
+
const cc = enumerateClips(CANVAS, SERIES, 'a');
|
|
695
|
+
const beat = cc.clips.find((c) => c.kind === 'sequence');
|
|
696
|
+
expect(() =>
|
|
697
|
+
applyReorderOverlayLayer(CANVAS, SERIES, 'a', beat!.stableId, undefined, 1)
|
|
698
|
+
).toThrow();
|
|
699
|
+
});
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
describe('applyEditTransition (Task 15)', () => {
|
|
703
|
+
test('duration change rewrites the timing int + shrinks TOTAL', () => {
|
|
704
|
+
const cc = enumerateClips(CANVAS, SERIES, 'a');
|
|
705
|
+
const t = cc.clips.find((c) => c.kind === 'transition');
|
|
706
|
+
const r = applyEditTransition(CANVAS, SERIES, 'a', t.stableId, undefined, {
|
|
707
|
+
durationInFrames: 30,
|
|
708
|
+
});
|
|
709
|
+
expect(r.source).toContain('durationInFrames: 30');
|
|
710
|
+
// overlap grew 15 → 30 ⇒ comp 15 shorter
|
|
711
|
+
expect(r.source).toContain('- 15;');
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
test('presentation swap rewrites the factory + adds the import', () => {
|
|
715
|
+
const cc = enumerateClips(CANVAS, SERIES, 'a');
|
|
716
|
+
const t = cc.clips.find((c) => c.kind === 'transition');
|
|
717
|
+
const r = applyEditTransition(CANVAS, SERIES, 'a', t.stableId, undefined, {
|
|
718
|
+
presentation: 'wipe',
|
|
719
|
+
});
|
|
720
|
+
expect(r.source).toContain('presentation={wipe()}');
|
|
721
|
+
expect(r.source).toContain("import { wipe } from '@remotion/transitions/wipe';");
|
|
722
|
+
expect(() =>
|
|
723
|
+
applyEditTransition(CANVAS, SERIES, 'a', t.stableId, undefined, { presentation: 'zoom' })
|
|
724
|
+
).toThrow();
|
|
725
|
+
});
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
describe('applyEnsureVideoComp — kind="video" artboard upgrade', () => {
|
|
729
|
+
const BRIEF = [
|
|
730
|
+
'import { DCArtboard, DCSection, DesignCanvas } from "@maude/canvas-lib";',
|
|
731
|
+
'',
|
|
732
|
+
'export default function Video() {',
|
|
733
|
+
' return (',
|
|
734
|
+
' <DesignCanvas>',
|
|
735
|
+
' <DCSection id="brief-board" title="Brief">',
|
|
736
|
+
' <DCArtboard kind="video" id="brief" label="Brief" width={1280} height={800}>',
|
|
737
|
+
' <div>annotate me</div>',
|
|
738
|
+
' </DCArtboard>',
|
|
739
|
+
' </DCSection>',
|
|
740
|
+
' </DesignCanvas>',
|
|
741
|
+
' );',
|
|
742
|
+
'}',
|
|
743
|
+
].join('\n');
|
|
744
|
+
|
|
745
|
+
test('storyline drop injects a VideoComp in place, then inserts the beat', () => {
|
|
746
|
+
const r = applyInsertClipAt('/abs/Video.tsx', BRIEF, undefined, {
|
|
747
|
+
lane: 'storyline',
|
|
748
|
+
durationInFrames: 90,
|
|
749
|
+
mediaTag: 'Video',
|
|
750
|
+
src: 'assets/x.mp4',
|
|
751
|
+
});
|
|
752
|
+
expect(r.source).toContain('<VideoComp component={VideoCut}');
|
|
753
|
+
expect(r.source).toContain('width={1280} height={800}');
|
|
754
|
+
expect(r.source).toMatch(/import \{[^}]*AbsoluteFill[^}]*\} from 'remotion';/);
|
|
755
|
+
expect(r.source).toContain('<OffthreadVideo src="assets/x.mp4" />');
|
|
756
|
+
const cc = enumerateClips('/abs/Video.tsx', r.source, 'brief');
|
|
757
|
+
expect(cc.compName).toBe('VideoCut');
|
|
758
|
+
expect(cc.clips.length).toBe(1);
|
|
759
|
+
expect(cc.clips[0]?.mediaSrc).toBe('assets/x.mp4');
|
|
760
|
+
expect(r.stableId).toBe(cc.clips[0]?.stableId);
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
test('a canvas with NO kind="video" artboard still refuses with guidance', () => {
|
|
764
|
+
const PLAIN = BRIEF.replace(' kind="video"', '');
|
|
765
|
+
expect(() =>
|
|
766
|
+
applyInsertClipAt('/abs/Video.tsx', PLAIN, undefined, {
|
|
767
|
+
lane: 'storyline',
|
|
768
|
+
durationInFrames: 90,
|
|
769
|
+
mediaTag: 'Video',
|
|
770
|
+
src: 'assets/x.mp4',
|
|
771
|
+
})
|
|
772
|
+
).toThrow(/Kind/);
|
|
773
|
+
});
|
|
774
|
+
});
|
|
775
|
+
|
|
776
|
+
describe('layers model — set-text + move between layers', () => {
|
|
777
|
+
test('set-text rewrites a Title literal and an AIPlaceholder prompt', () => {
|
|
778
|
+
const withTitle = applyInsertClipAt(CANVAS, SERIES, 'a', {
|
|
779
|
+
lane: 'overlay',
|
|
780
|
+
from: 5,
|
|
781
|
+
durationInFrames: 40,
|
|
782
|
+
mediaTag: 'Title',
|
|
783
|
+
src: 'Hello',
|
|
784
|
+
});
|
|
785
|
+
const r = applySetClipText(
|
|
786
|
+
CANVAS,
|
|
787
|
+
withTitle.source,
|
|
788
|
+
'a',
|
|
789
|
+
withTitle.stableId,
|
|
790
|
+
undefined,
|
|
791
|
+
'Nový titulek "s uvozovkami"'
|
|
792
|
+
);
|
|
793
|
+
expect(r.source).toContain(JSON.stringify('Nový titulek "s uvozovkami"'));
|
|
794
|
+
const withPh = applyInsertClipAt(CANVAS, SERIES, 'a', {
|
|
795
|
+
lane: 'storyline',
|
|
796
|
+
index: 3,
|
|
797
|
+
durationInFrames: 60,
|
|
798
|
+
placeholder: { prompt: 'stary prompt', kind: 'veo' },
|
|
799
|
+
});
|
|
800
|
+
const r2 = applySetClipText(
|
|
801
|
+
CANVAS,
|
|
802
|
+
withPh.source,
|
|
803
|
+
'a',
|
|
804
|
+
withPh.stableId,
|
|
805
|
+
undefined,
|
|
806
|
+
'novy prompt'
|
|
807
|
+
);
|
|
808
|
+
const cc = enumerateClips(CANVAS, r2.source, 'a');
|
|
809
|
+
expect(cc.clips.find((c) => c.stableId === withPh.stableId)?.placeholder?.prompt).toBe(
|
|
810
|
+
'novy prompt'
|
|
811
|
+
);
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
test('beat → overlay layer keeps timing and ripples the storyline', () => {
|
|
815
|
+
const r = applyMoveClipToOverlay(CANVAS, HARDCUT, 'a', 'name:two', undefined);
|
|
816
|
+
const cc = enumerateClips(CANVAS, r.source, 'a');
|
|
817
|
+
const beats = cc.clips.filter((c) => c.tag !== 'Sequence' && c.kind === 'sequence');
|
|
818
|
+
const overlays = cc.clips.filter((c) => c.tag === 'Sequence');
|
|
819
|
+
expect(beats.length).toBe(2);
|
|
820
|
+
expect(overlays.length).toBe(1);
|
|
821
|
+
expect(overlays[0]?.from).toBe(90); // the beat started at 90
|
|
822
|
+
expect(overlays[0]?.mediaProps?.trimBefore).toBe(30); // rode along
|
|
823
|
+
expect(r.source).toContain('const TOTAL = 210;'); // comp ends where the overlay ends (90+120)
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
test('overlay → storyline round-trips: the moved-out beat comes back as a beat', () => {
|
|
827
|
+
const out = applyMoveClipToOverlay(CANVAS, HARDCUT, 'a', 'name:two', undefined);
|
|
828
|
+
const back = applyMoveClipToStoryline(CANVAS, out.source, 'a', out.stableId, undefined);
|
|
829
|
+
const cc = enumerateClips(CANVAS, back.source, 'a');
|
|
830
|
+
const beats = cc.clips.filter((c) => c.kind === 'sequence' && c.tag !== 'Sequence');
|
|
831
|
+
expect(beats.length).toBe(3);
|
|
832
|
+
expect(beats.some((c) => c.mediaSrc === 'b.mp4')).toBe(true);
|
|
833
|
+
expect(cc.clips.filter((c) => c.tag === 'Sequence').length).toBe(0); // overlay row gone
|
|
834
|
+
expect(back.source).toContain('const TOTAL = 270;'); // exact content end again
|
|
835
|
+
});
|
|
836
|
+
});
|