@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.
Files changed (48) hide show
  1. package/apps/studio/api.ts +389 -14
  2. package/apps/studio/bin/_video-playwright.mjs +15 -6
  3. package/apps/studio/canvas-edit.ts +141 -20
  4. package/apps/studio/canvas-lib.tsx +61 -0
  5. package/apps/studio/client/app.jsx +824 -121
  6. package/apps/studio/client/panels/ClipInspector.jsx +409 -0
  7. package/apps/studio/client/panels/CloudBar.jsx +12 -2
  8. package/apps/studio/client/panels/TimelinePanel.jsx +1775 -430
  9. package/apps/studio/client/panels/timeline-media-cache.js +274 -0
  10. package/apps/studio/client/panels/timeline-parse.js +46 -1
  11. package/apps/studio/client/panels/timeline-scale.js +64 -0
  12. package/apps/studio/client/report-bug.jsx +15 -15
  13. package/apps/studio/client/styles/3-shell-maude.css +446 -3
  14. package/apps/studio/client/styles/4-components-maude.css +55 -0
  15. package/apps/studio/client/styles/4-components.css +0 -83
  16. package/apps/studio/clip-ops.ts +1980 -0
  17. package/apps/studio/cloud/endpoints.ts +88 -17
  18. package/apps/studio/credential-grammar.ts +100 -0
  19. package/apps/studio/debug-bundle.ts +12 -0
  20. package/apps/studio/dist/client.bundle.js +2048 -2048
  21. package/apps/studio/dist/styles.css +1 -1
  22. package/apps/studio/exporters/video.ts +49 -10
  23. package/apps/studio/http.ts +113 -6
  24. package/apps/studio/ripple.ts +335 -0
  25. package/apps/studio/sync/hub-link.ts +3 -3
  26. package/apps/studio/sync/hubs-config.ts +32 -0
  27. package/apps/studio/sync/workspace-signin.ts +8 -4
  28. package/apps/studio/test/_helpers.ts +5 -3
  29. package/apps/studio/test/canvas-create-api.test.ts +8 -2
  30. package/apps/studio/test/canvas-origin-gate.test.ts +20 -0
  31. package/apps/studio/test/clip-addressing.test.ts +7 -6
  32. package/apps/studio/test/clip-ops.test.ts +836 -0
  33. package/apps/studio/test/cloud-endpoints.test.ts +37 -2
  34. package/apps/studio/test/comments-api.test.ts +49 -0
  35. package/apps/studio/test/credential-grammar.test.ts +107 -0
  36. package/apps/studio/test/read-only-role.test.ts +95 -0
  37. package/apps/studio/test/ripple.test.ts +197 -0
  38. package/apps/studio/test/timeline-scale.test.ts +65 -0
  39. package/apps/studio/test/workspace-signin.test.ts +4 -1
  40. package/apps/studio/whats-new.json +35 -0
  41. package/cli/commands/share.mjs +8 -4
  42. package/cli/lib/ddr-to-kgai.mjs +6 -1
  43. package/cli/lib/ddr-to-kgai.test.mjs +1 -2
  44. package/cli/lib/hubs-config.mjs +15 -1
  45. package/cli/lib/hubs-config.test.mjs +19 -0
  46. package/cli/lib/share-plan.mjs +5 -1
  47. package/cli/lib/share-plan.test.mjs +11 -2
  48. package/package.json +8 -8
@@ -0,0 +1,1980 @@
1
+ // clip-ops.ts — composed timeline clip operations (feature-enhanced-video-editing).
2
+ //
3
+ // The verbs the iMovie-style editor speaks: each is a pure `applyX(source, …)
4
+ // → { source', … }` built from the canvas-edit primitives + the Phase-0 ripple
5
+ // engine, plus a disk wrapper (atomic write + cross-process lock). Lives in its
6
+ // own module because ripple.ts already imports canvas-edit.ts — composing here
7
+ // keeps the import graph acyclic.
8
+
9
+ import MagicString from 'magic-string';
10
+ import { parseSync } from 'oxc-parser';
11
+
12
+ import {
13
+ applyRemoveClip,
14
+ assertCompSemantics,
15
+ CanvasEditError,
16
+ type ClipInfo,
17
+ enumerateClips,
18
+ escapeAttr,
19
+ lineStartInfo,
20
+ spanWithFraming,
21
+ withLock,
22
+ } from './canvas-edit.ts';
23
+ import { applyRippleAfterClip, collectConsts, resolveNum } from './ripple.ts';
24
+
25
+ /** Parse a `<TransitionSeries.Transition>`'s duration out of its source span
26
+ * (`timing={linearTiming({ durationInFrames: XF })}` — the attr the AST-level
27
+ * numAttr can't see because it nests inside the timing call). */
28
+ export function transitionDurationFrames(
29
+ source: string,
30
+ transition: ClipInfo,
31
+ consts?: Record<string, number>
32
+ ): number | null {
33
+ const text = source.slice(transition.start, transition.end);
34
+ const m = text.match(/durationInFrames:\s*([^,}\s)]+)/);
35
+ if (!m) return null;
36
+ return resolveNum(m[1] as string, consts ?? collectConsts(source));
37
+ }
38
+
39
+ /** The transition applyRemoveClip will drop alongside a series clip (its NEXT
40
+ * transition, else its PREVIOUS — the same preference order). */
41
+ function adjacentTransition(clips: ClipInfo[], idx: number): ClipInfo | null {
42
+ const next = clips[idx + 1];
43
+ const prev = clips[idx - 1];
44
+ if (next?.kind === 'transition') return next;
45
+ if (prev?.kind === 'transition') return prev;
46
+ return null;
47
+ }
48
+
49
+ /**
50
+ * Remove a clip WITH ripple (the iMovie Delete): a series beat's removal
51
+ * shrinks the comp TOTAL by (clip duration − the dropped transition's overlap)
52
+ * so the cut stays truthful; a standalone `<Sequence>` (overlay/audio band) is
53
+ * removed WITHOUT ripple — overlays are absolutely positioned by design and
54
+ * deleting one must not move the rest of the cut. Pure.
55
+ */
56
+ export function applyRemoveClipRippled(
57
+ canvasAbsPath: string,
58
+ source: string,
59
+ artboardId: string | undefined,
60
+ stableId: string,
61
+ expectedHash: string | undefined
62
+ ): { source: string; rippled: boolean } {
63
+ const { clips } = enumerateClips(canvasAbsPath, source, artboardId);
64
+ const idx = clips.findIndex((c) => c.stableId === stableId);
65
+ const clip = idx >= 0 ? (clips[idx] as ClipInfo) : null;
66
+ let mid = source;
67
+ let rippled = false;
68
+ if (
69
+ clip &&
70
+ clip.kind === 'sequence' &&
71
+ (clip.tag.startsWith('TransitionSeries.') || clip.tag.startsWith('Series.')) &&
72
+ clip.durationInFrames != null
73
+ ) {
74
+ const t = adjacentTransition(clips, idx);
75
+ const overlap = t ? (transitionDurationFrames(source, t) ?? 0) : 0;
76
+ const delta = -(clip.durationInFrames - overlap);
77
+ if (delta !== 0) {
78
+ // TOTAL only — series siblings position themselves, and overlay-band
79
+ // standalone clips deliberately stay put on a storyline delete.
80
+ const r = applyRippleAfterClip(canvasAbsPath, mid, artboardId, stableId, delta, {
81
+ shiftFroms: false,
82
+ });
83
+ mid = r.source;
84
+ rippled = r.totalEdited;
85
+ }
86
+ }
87
+ // Ripple never touches the clip's own span text, so the caller's contentHash
88
+ // still fingerprints it — applyRemoveClip re-verifies on the rippled source.
89
+ const out = applyRemoveClip(canvasAbsPath, mid, artboardId, stableId, expectedHash);
90
+ return { source: out.source, rippled };
91
+ }
92
+
93
+ /** Remove-with-ripple on disk (atomic write + cross-process lock). */
94
+ export async function removeClipRippled(
95
+ canvasAbsPath: string,
96
+ artboardId: string | undefined,
97
+ stableId: string,
98
+ expectedHash: string | undefined
99
+ ): Promise<{ source: string; rippled: boolean }> {
100
+ return withLock(canvasAbsPath, async () => {
101
+ const file = Bun.file(canvasAbsPath);
102
+ if (!(await file.exists())) {
103
+ throw new CanvasEditError(`Canvas not found: ${canvasAbsPath}`, {
104
+ canvas: canvasAbsPath,
105
+ id: stableId,
106
+ });
107
+ }
108
+ const source = await file.text();
109
+ const next = applyRemoveClipRippled(canvasAbsPath, source, artboardId, stableId, expectedHash);
110
+ if (next.source === source) return next;
111
+ const tmp = `${canvasAbsPath}.tmp.${Math.random().toString(36).slice(2, 10)}`;
112
+ await Bun.write(tmp, next.source);
113
+ const { rename } = await import('node:fs/promises');
114
+ await rename(tmp, canvasAbsPath);
115
+ return next;
116
+ });
117
+ }
118
+
119
+ // ---------------------------------------------------------------------------
120
+ // Task 6 — magnetic storyline reorder (a real MOVE, not the ▲▼ adjacent swap).
121
+
122
+ /** Resolve a clip by stableId + optional fingerprint (local mirror of
123
+ * canvas-edit's resolveClip, against an already-enumerated list). */
124
+ function mustResolve(
125
+ canvasAbsPath: string,
126
+ clips: ClipInfo[],
127
+ stableId: string,
128
+ expectedHash: string | undefined
129
+ ): { clip: ClipInfo; idx: number } {
130
+ const idx = clips.findIndex((c) => c.stableId === stableId);
131
+ if (idx < 0) {
132
+ throw new CanvasEditError(`clip "${stableId}" not found`, {
133
+ canvas: canvasAbsPath,
134
+ id: stableId,
135
+ });
136
+ }
137
+ const clip = clips[idx] as ClipInfo;
138
+ if (expectedHash != null && expectedHash !== clip.contentHash) {
139
+ throw new CanvasEditError(
140
+ `clip "${stableId}" changed since it was read (concurrent edit); reload and retry`,
141
+ { canvas: canvasAbsPath, id: stableId }
142
+ );
143
+ }
144
+ return { clip, idx };
145
+ }
146
+
147
+ const isSeriesSeq = (c: ClipInfo) =>
148
+ c.kind === 'sequence' && (c.tag.startsWith('TransitionSeries.') || c.tag.startsWith('Series.'));
149
+
150
+ /**
151
+ * Move a series beat before/after another beat — the drag-to-reorder commit.
152
+ * Cuts the beat plus ONE companion transition (its preceding one when it has
153
+ * one, else the following) and reinserts the pair at the target so the
154
+ * S/T/S/T alternation survives any distance: inserting BEFORE a beat emits
155
+ * `B T`, inserting AFTER emits `T B`. Pure; semantic-gated.
156
+ */
157
+ export function applySeriesMove(
158
+ canvasAbsPath: string,
159
+ source: string,
160
+ artboardId: string | undefined,
161
+ movedStableId: string,
162
+ movedHash: string | undefined,
163
+ refStableId: string,
164
+ refHash: string | undefined,
165
+ position: 'before' | 'after'
166
+ ): { source: string; stableId: string } {
167
+ if (movedStableId === refStableId) {
168
+ throw new CanvasEditError('cannot move a clip relative to itself', {
169
+ canvas: canvasAbsPath,
170
+ id: movedStableId,
171
+ });
172
+ }
173
+ const { clips } = enumerateClips(canvasAbsPath, source, artboardId);
174
+ const { clip: moved, idx } = mustResolve(canvasAbsPath, clips, movedStableId, movedHash);
175
+ const { clip: ref } = mustResolve(canvasAbsPath, clips, refStableId, refHash);
176
+ if (!isSeriesSeq(moved) || !isSeriesSeq(ref)) {
177
+ throw new CanvasEditError('series move needs two series beats', {
178
+ canvas: canvasAbsPath,
179
+ id: movedStableId,
180
+ });
181
+ }
182
+ const prev = clips[idx - 1];
183
+ const next = clips[idx + 1];
184
+ const companion = prev?.kind === 'transition' ? prev : next?.kind === 'transition' ? next : null;
185
+
186
+ const movedSpan = spanWithFraming(source, moved.start, moved.end);
187
+ const companionSpan = companion ? spanWithFraming(source, companion.start, companion.end) : null;
188
+ const beatText = source.slice(movedSpan[0], movedSpan[1]);
189
+ const transitionText = companionSpan ? source.slice(companionSpan[0], companionSpan[1]) : '';
190
+
191
+ const s = new MagicString(source);
192
+ s.remove(movedSpan[0], movedSpan[1]);
193
+ if (companionSpan) s.remove(companionSpan[0], companionSpan[1]);
194
+ const insertText =
195
+ position === 'before' ? `${beatText}${transitionText}` : `${transitionText}${beatText}`;
196
+ if (position === 'before') {
197
+ const [rs] = spanWithFraming(source, ref.start, ref.end);
198
+ s.appendLeft(rs, insertText);
199
+ } else {
200
+ s.appendRight(ref.end, insertText);
201
+ }
202
+ const out = s.toString();
203
+ assertParses(canvasAbsPath, out, movedStableId);
204
+ assertCompSemantics(canvasAbsPath, out);
205
+ const after = enumerateClips(canvasAbsPath, out, artboardId);
206
+ const settled = after.clips.find((c) => c.contentHash === moved.contentHash);
207
+ return { source: out, stableId: settled?.stableId ?? movedStableId };
208
+ }
209
+
210
+ /** Series move on disk (atomic write + cross-process lock). */
211
+ export async function seriesMove(
212
+ canvasAbsPath: string,
213
+ artboardId: string | undefined,
214
+ movedStableId: string,
215
+ movedHash: string | undefined,
216
+ refStableId: string,
217
+ refHash: string | undefined,
218
+ position: 'before' | 'after'
219
+ ): Promise<{ source: string; stableId: string }> {
220
+ return withLock(canvasAbsPath, async () => {
221
+ const source = await Bun.file(canvasAbsPath).text();
222
+ const next = applySeriesMove(
223
+ canvasAbsPath,
224
+ source,
225
+ artboardId,
226
+ movedStableId,
227
+ movedHash,
228
+ refStableId,
229
+ refHash,
230
+ position
231
+ );
232
+ if (next.source === source) return next;
233
+ const tmp = `${canvasAbsPath}.tmp.${Math.random().toString(36).slice(2, 10)}`;
234
+ await Bun.write(tmp, next.source);
235
+ const { rename } = await import('node:fs/promises');
236
+ await rename(tmp, canvasAbsPath);
237
+ return next;
238
+ });
239
+ }
240
+
241
+ // ---------------------------------------------------------------------------
242
+ // Task 6 — positional insert (the drop caret): storyline index-aware insert,
243
+ // or a standalone overlay/audio clip that lands OUTSIDE the series.
244
+
245
+ const INSERTABLE_MEDIA = new Set(['Video', 'Audio', 'Img', 'OffthreadVideo']);
246
+
247
+ function assertContainedSrc(canvasAbsPath: string, src: string, id: string): void {
248
+ // Allowlist, not blocklist (security review 2026-07-30): the src the export
249
+ // renderer fetches must be a contained asset — a relative path or a `/`-rooted
250
+ // path under the design root — never a scheme (`javascript:`/`data:`/`http(s):`)
251
+ // and never a protocol-relative `//host/...` (which the blocklist let slip →
252
+ // SSRF residue in the server-side render). `/\...` (a single leading slash) is
253
+ // allowed; `//...` is not.
254
+ const t = src.trim();
255
+ const contained =
256
+ t.length > 0 &&
257
+ !/\.\./.test(t) &&
258
+ !/^[a-z][a-z0-9+.-]*:/i.test(t) && // any URI scheme
259
+ !t.startsWith('//'); // protocol-relative
260
+ if (!contained) {
261
+ throw new CanvasEditError(
262
+ 'media src must be a contained asset path (no ../, schemes, or //host)',
263
+ {
264
+ canvas: canvasAbsPath,
265
+ id,
266
+ }
267
+ );
268
+ }
269
+ }
270
+
271
+ export interface InsertAtOptions {
272
+ lane: 'storyline' | 'overlay' | 'audio';
273
+ /** storyline only: beat position 0..N (N = append at the end). */
274
+ index?: number;
275
+ /** overlay/audio only: start frame. */
276
+ from?: number;
277
+ durationInFrames: number;
278
+ mediaTag?: string | null;
279
+ src?: string | null;
280
+ /** Task 22 — a prompt-carrying AI slate instead of media. Prompt is USER
281
+ * TEXT → JSON.stringify into the JSX (DDR-150 P1). */
282
+ placeholder?: { prompt: string; kind: string } | null;
283
+ }
284
+
285
+ const PLACEHOLDER_KINDS = new Set(['veo', 'motion', 'image']);
286
+
287
+ function placeholderChildText(
288
+ canvasAbsPath: string,
289
+ artboardId: string | undefined,
290
+ p: { prompt: string; kind: string },
291
+ indent: string,
292
+ dur: number
293
+ ): string {
294
+ const kind = PLACEHOLDER_KINDS.has(p.kind) ? p.kind : 'veo';
295
+ const prompt = String(p.prompt ?? '').slice(0, 2000);
296
+ if (!prompt.trim()) {
297
+ throw new CanvasEditError('an AI placeholder needs a prompt', {
298
+ canvas: canvasAbsPath,
299
+ id: artboardId ?? '',
300
+ });
301
+ }
302
+ // Paired-tag form: the prompt lives in a plain <span> child (a stamped,
303
+ // data-cd-editable leaf — double-click in the artboard edits it in place;
304
+ // the modal-prompt flow is gone). JSON.stringify keeps DDR-150 P1 discipline.
305
+ return (
306
+ `\n${indent} <AIPlaceholder kind="${kind}" durationInFrames={${dur}}>` +
307
+ `\n${indent} <span>{${JSON.stringify(prompt)}}</span>` +
308
+ `\n${indent} </AIPlaceholder>\n${indent}`
309
+ );
310
+ }
311
+
312
+ /**
313
+ * Insert a clip at a POSITION (unlike canvas-edit's append-only insert):
314
+ * • `lane: 'storyline'` inserts a series beat at `index`, cloning an existing
315
+ * transition to keep the alternation (B T at the head, T B elsewhere) and
316
+ * bumping the comp TOTAL by (duration − transition overlap);
317
+ * • `lane: 'overlay' | 'audio'` inserts a standalone `<Sequence from>` as a
318
+ * SIBLING after the series (never inside it), no TOTAL change.
319
+ * Pure; returns the new clip's stableId.
320
+ */
321
+ export function applyInsertClipAt(
322
+ canvasAbsPath: string,
323
+ source: string,
324
+ artboardId: string | undefined,
325
+ opts: InsertAtOptions
326
+ ): { source: string; stableId: string | null } {
327
+ const cc = enumerateClips(canvasAbsPath, source, artboardId);
328
+ if (!cc.compName) {
329
+ // A `kind="video"` artboard without a comp is the user saying "this is a
330
+ // video" — upgrade it in place and retry, instead of refusing.
331
+ if (opts.lane === 'storyline') {
332
+ const ensured = applyEnsureVideoComp(canvasAbsPath, source, artboardId);
333
+ if (ensured) {
334
+ return applyInsertClipAt(canvasAbsPath, ensured.source, ensured.artboardId, opts);
335
+ }
336
+ }
337
+ throw new CanvasEditError(
338
+ 'no video-comp for this artboard — set an artboard’s Kind to "Video" in the Inspector and drop again, or use ⌘K → New video…',
339
+ { canvas: canvasAbsPath, id: artboardId ?? '' }
340
+ );
341
+ }
342
+ const dur = Math.max(1, Math.round(opts.durationInFrames));
343
+ const srcTrim = (opts.src ?? '').trim();
344
+ const media =
345
+ opts.mediaTag && INSERTABLE_MEDIA.has(opts.mediaTag) && srcTrim
346
+ ? { tag: opts.mediaTag, src: srcTrim }
347
+ : null;
348
+ if (media) {
349
+ assertContainedSrc(canvasAbsPath, media.src, artboardId ?? '');
350
+ // House spelling: <OffthreadVideo> for video (frame-accurate export, and it
351
+ // can never collide with a canvas component named `Video` — the brief-board
352
+ // upgrade bug where the emitted tag resolved to the canvas itself).
353
+ if (media.tag === 'Video') media.tag = 'OffthreadVideo';
354
+ }
355
+
356
+ const seqs = cc.clips.filter((c) => c.kind === 'sequence');
357
+ const s = new MagicString(source);
358
+
359
+ if (opts.lane === 'storyline') {
360
+ const beats = seqs.filter(isSeriesSeq);
361
+ if (beats.length === 0) {
362
+ // Greenfield first drop (Task 20, default-container rule): author the
363
+ // <TransitionSeries> storyline around the first beat, inside the comp's
364
+ // root <AbsoluteFill>.
365
+ if (!media && !opts.placeholder) {
366
+ throw new CanvasEditError('the first storyline insert needs media', {
367
+ canvas: canvasAbsPath,
368
+ id: artboardId ?? '',
369
+ });
370
+ }
371
+ const bodyM = source.match(
372
+ new RegExp(`(?:const\\s+${cc.compName}\\s*=|function\\s+${cc.compName}\\b)`)
373
+ );
374
+ if (!bodyM || bodyM.index == null) {
375
+ throw new CanvasEditError(`could not locate component ${cc.compName}`, {
376
+ canvas: canvasAbsPath,
377
+ id: artboardId ?? '',
378
+ });
379
+ }
380
+ const rootClose = source.indexOf('</AbsoluteFill>', bodyM.index);
381
+ if (rootClose < 0) {
382
+ throw new CanvasEditError(
383
+ 'greenfield insert needs a root <AbsoluteFill> in the comp — add one, then drop again',
384
+ { canvas: canvasAbsPath, id: artboardId ?? '' }
385
+ );
386
+ }
387
+ const indent = `${lineStartInfo(source, rootClose).indent} `;
388
+ const s2 = new MagicString(source);
389
+ const gfChild = opts.placeholder
390
+ ? placeholderChildText(
391
+ canvasAbsPath,
392
+ artboardId,
393
+ opts.placeholder,
394
+ `${indent} `,
395
+ dur
396
+ ).trimEnd()
397
+ : `\n${indent} <${media.tag} src="${escapeAttr(media.src)}" />`;
398
+ s2.appendLeft(
399
+ rootClose,
400
+ ` <TransitionSeries>\n${indent} <TransitionSeries.Sequence durationInFrames={${dur}}>` +
401
+ `${gfChild}\n` +
402
+ `${indent} </TransitionSeries.Sequence>\n${indent}</TransitionSeries>\n${indent.slice(2)}`
403
+ );
404
+ ensureImportPublic(s2, source, 'TransitionSeries', '@remotion/transitions');
405
+ if (opts.placeholder) ensureNamedImport(s2, source, 'AIPlaceholder', '@maude/canvas-lib');
406
+ if (media) ensureNamedImport(s2, source, media.tag, 'remotion');
407
+ const out2 = s2.toString();
408
+ assertParses(canvasAbsPath, out2, artboardId ?? '');
409
+ assertCompSemantics(canvasAbsPath, out2);
410
+ const after2 = enumerateClips(canvasAbsPath, out2, artboardId);
411
+ const beat = after2.clips.find((c) => isSeriesSeq(c));
412
+ return { source: out2, stableId: beat?.stableId ?? null };
413
+ }
414
+ // A transition proto is cloned when the series has one; a series of hard
415
+ // cuts (no transitions — valid Remotion) inserts a bare beat.
416
+ const proto = cc.clips.find((c) => c.kind === 'transition') ?? null;
417
+ const protoOverlap = proto ? (transitionDurationFrames(source, proto) ?? 0) : 0;
418
+ const index = Math.max(0, Math.min(beats.length, Math.round(opts.index ?? beats.length)));
419
+ const seriesPrefix = (beats[0] as ClipInfo).tag.split('.')[0]; // TransitionSeries | Series
420
+ const anchor = (index === 0 ? beats[0] : beats[index - 1]) as ClipInfo;
421
+ const indent = lineStartInfo(source, anchor.start).indent;
422
+ const transitionText = proto ? `\n${indent}${source.slice(proto.start, proto.end)}` : '';
423
+ const mediaText = opts.placeholder
424
+ ? placeholderChildText(canvasAbsPath, artboardId, opts.placeholder, indent, dur)
425
+ : media
426
+ ? `\n${indent} <${media.tag} src="${escapeAttr(media.src)}" />\n${indent}`
427
+ : '';
428
+ if (opts.placeholder) ensureNamedImport(s, source, 'AIPlaceholder', '@maude/canvas-lib');
429
+ if (media) ensureNamedImport(s, source, media.tag, 'remotion');
430
+ const beatText = `<${seriesPrefix}.Sequence durationInFrames={${dur}}>${mediaText}</${seriesPrefix}.Sequence>`;
431
+ if (index === 0) {
432
+ // Head insert: B (T) before the first beat.
433
+ const [rs] = spanWithFraming(source, anchor.start, anchor.end);
434
+ s.appendLeft(rs, `\n${indent}${beatText}${transitionText}`);
435
+ } else {
436
+ // Insert after the anchor beat: (T) B.
437
+ s.appendRight(anchor.end, `${transitionText}\n${indent}${beatText}`);
438
+ }
439
+ let out = s.toString();
440
+ assertParses(canvasAbsPath, out, artboardId ?? '');
441
+ assertCompSemantics(canvasAbsPath, out);
442
+ // The cut grew — bump the comp TOTAL (const-preferring) by dur − overlap.
443
+ const mid = enumerateClips(canvasAbsPath, out, artboardId);
444
+ const midBeats = mid.clips.filter((c) => c.kind === 'sequence' && isSeriesSeq(c));
445
+ const inserted = midBeats[index] as ClipInfo | undefined;
446
+ if (inserted && dur - protoOverlap !== 0) {
447
+ out = applyRippleAfterClip(
448
+ canvasAbsPath,
449
+ out,
450
+ artboardId,
451
+ inserted.stableId,
452
+ dur - protoOverlap,
453
+ {
454
+ shiftFroms: false,
455
+ }
456
+ ).source;
457
+ }
458
+ const after = enumerateClips(canvasAbsPath, out, artboardId);
459
+ const afterBeats = after.clips.filter((c) => c.kind === 'sequence' && isSeriesSeq(c));
460
+ return { source: out, stableId: (afterBeats[index] as ClipInfo | undefined)?.stableId ?? null };
461
+ }
462
+
463
+ // overlay / audio — a standalone <Sequence from> sibling, never inside the series.
464
+ const from = Math.max(0, Math.round(opts.from ?? 0));
465
+ const lastSeries = [...cc.clips].reverse().find((c) => isSeriesSeq(c) || c.kind === 'transition');
466
+ let insertAt: number;
467
+ let indent: string;
468
+ if (lastSeries) {
469
+ const closeTag = lastSeries.tag.startsWith('Series.') ? '</Series>' : '</TransitionSeries>';
470
+ const closeIdx = source.indexOf(closeTag, lastSeries.end);
471
+ if (closeIdx < 0) {
472
+ throw new CanvasEditError('could not locate the series close tag', {
473
+ canvas: canvasAbsPath,
474
+ id: artboardId ?? '',
475
+ });
476
+ }
477
+ insertAt = closeIdx + closeTag.length;
478
+ indent = lineStartInfo(source, closeIdx).indent;
479
+ } else {
480
+ const last = seqs[seqs.length - 1];
481
+ if (last) {
482
+ insertAt = last.end;
483
+ indent = lineStartInfo(source, last.start).indent;
484
+ } else {
485
+ // Empty comp (greenfield) — anchor inside the comp's root <AbsoluteFill>,
486
+ // exactly like the storyline greenfield insert.
487
+ const bodyM = source.match(
488
+ new RegExp(`(?:const\\s+${cc.compName}\\s*=|function\\s+${cc.compName}\\b)`)
489
+ );
490
+ const rootClose = bodyM ? source.indexOf('</AbsoluteFill>', bodyM.index) : -1;
491
+ if (rootClose < 0) {
492
+ throw new CanvasEditError(
493
+ 'comp has no clip and no root <AbsoluteFill> to anchor the insert — drop onto the storyline instead',
494
+ { canvas: canvasAbsPath, id: artboardId ?? '' }
495
+ );
496
+ }
497
+ insertAt = rootClose;
498
+ indent = `${lineStartInfo(source, rootClose).indent} `;
499
+ }
500
+ }
501
+ // Task 19 — "+ Title": a literal text block instead of a media child. The
502
+ // caption is USER TEXT → JSON.stringify (DDR-150 P1 quoted-string discipline).
503
+ const titleText =
504
+ opts.mediaTag === 'Title'
505
+ ? `\n${indent} <AbsoluteFill style={{ display: 'grid', placeItems: 'center', pointerEvents: 'none' }}>` +
506
+ `\n${indent} <div style={{ color: 'white', fontSize: 64, fontWeight: 700, textShadow: '0 2px 24px rgba(0,0,0,0.55)', pointerEvents: 'auto' }}>{${JSON.stringify(String(opts.src || 'Title'))}}</div>` +
507
+ `\n${indent} </AbsoluteFill>\n${indent}`
508
+ : null;
509
+ const mediaText = opts.placeholder
510
+ ? placeholderChildText(canvasAbsPath, artboardId, opts.placeholder, indent, dur)
511
+ : titleText
512
+ ? titleText
513
+ : media
514
+ ? `\n${indent} <${media.tag} src="${escapeAttr(media.src)}" />\n${indent}`
515
+ : '';
516
+ if (opts.placeholder) ensureNamedImport(s, source, 'AIPlaceholder', '@maude/canvas-lib');
517
+ if (media) ensureNamedImport(s, source, media.tag, 'remotion');
518
+ if (titleText) ensureNamedImport(s, source, 'AbsoluteFill', 'remotion');
519
+ ensureNamedImport(s, source, 'Sequence', 'remotion');
520
+ const clipText = `\n${indent}<Sequence from={${from}} durationInFrames={${dur}}>${mediaText}</Sequence>`;
521
+ s.appendRight(insertAt, clipText);
522
+ let out = s.toString();
523
+ try {
524
+ assertParses(canvasAbsPath, out, artboardId ?? '');
525
+ } catch {
526
+ // A comp whose body is the bare series (`() => (<TransitionSeries>…`)
527
+ // can't take a sibling — wrap series + new clip in a fragment.
528
+ if (!lastSeries)
529
+ throw new CanvasEditError('insert produced invalid source', {
530
+ canvas: canvasAbsPath,
531
+ id: artboardId ?? '',
532
+ });
533
+ // The container's OWN open tag — never a `<TransitionSeries.Sequence>`
534
+ // member tag (a bare lastIndexOf prefix-matched those).
535
+ const openRe = lastSeries.tag.startsWith('Series.')
536
+ ? /<Series(?![.\w])/g
537
+ : /<TransitionSeries(?![.\w])/g;
538
+ let openStart = -1;
539
+ for (const m of source.matchAll(openRe)) {
540
+ if ((m.index as number) < lastSeries.start) openStart = m.index as number;
541
+ }
542
+ if (openStart < 0) {
543
+ throw new CanvasEditError('insert produced invalid source', {
544
+ canvas: canvasAbsPath,
545
+ id: artboardId ?? '',
546
+ });
547
+ }
548
+ const s2 = new MagicString(source);
549
+ s2.appendLeft(openStart, '<>');
550
+ s2.appendRight(insertAt, `${clipText}\n${indent}</>`);
551
+ if (opts.placeholder) ensureNamedImport(s2, source, 'AIPlaceholder', '@maude/canvas-lib');
552
+ if (media) ensureNamedImport(s2, source, media.tag, 'remotion');
553
+ if (titleText) ensureNamedImport(s2, source, 'AbsoluteFill', 'remotion');
554
+ ensureNamedImport(s2, source, 'Sequence', 'remotion');
555
+ out = s2.toString();
556
+ assertParses(canvasAbsPath, out, artboardId ?? '');
557
+ }
558
+ assertCompSemantics(canvasAbsPath, out);
559
+ // A clip landing past the current comp end stretches the timeline to it.
560
+ out = applyFitTotalToContent(canvasAbsPath, out, artboardId).source;
561
+ const after = enumerateClips(canvasAbsPath, out, artboardId);
562
+ const standalone = after.clips.filter((c) => c.kind === 'sequence' && c.tag === 'Sequence');
563
+ return {
564
+ source: out,
565
+ stableId: (standalone[standalone.length - 1] as ClipInfo | undefined)?.stableId ?? null,
566
+ };
567
+ }
568
+
569
+ /** Positional insert on disk (atomic write + cross-process lock). */
570
+ export async function insertClipAt(
571
+ canvasAbsPath: string,
572
+ artboardId: string | undefined,
573
+ opts: InsertAtOptions
574
+ ): Promise<{ source: string; stableId: string | null }> {
575
+ return withLock(canvasAbsPath, async () => {
576
+ const file = Bun.file(canvasAbsPath);
577
+ if (!(await file.exists())) {
578
+ throw new CanvasEditError(`Canvas not found: ${canvasAbsPath}`, {
579
+ canvas: canvasAbsPath,
580
+ id: artboardId ?? '',
581
+ });
582
+ }
583
+ const source = await file.text();
584
+ const next = applyInsertClipAt(canvasAbsPath, source, artboardId, opts);
585
+ if (next.source === source) return next;
586
+ const tmp = `${canvasAbsPath}.tmp.${Math.random().toString(36).slice(2, 10)}`;
587
+ await Bun.write(tmp, next.source);
588
+ const { rename } = await import('node:fs/promises');
589
+ await rename(tmp, canvasAbsPath);
590
+ return next;
591
+ });
592
+ }
593
+
594
+ // ---------------------------------------------------------------------------
595
+ // Phase 2 (Tasks 10–15) — parametric clip verbs. All text-surgical on ONE
596
+ // opening tag (media element / clip tag / transition), reparse + semantic
597
+ // gated, composed with the ripple engine for anything that changes length.
598
+
599
+ /** End index (exclusive) of the opening tag starting at `tagStart` — brace-aware
600
+ * so `style={{ … }}` doesn't fool the `>` scan. */
601
+ function openTagEnd(source: string, tagStart: number): number {
602
+ let depth = 0;
603
+ for (let i = tagStart; i < source.length; i += 1) {
604
+ const ch = source[i];
605
+ if (ch === '{') depth += 1;
606
+ else if (ch === '}') depth -= 1;
607
+ else if (ch === '>' && depth <= 0) return i + 1;
608
+ }
609
+ return source.length;
610
+ }
611
+
612
+ /** Span of `name=<value>` (incl. leading space) inside the opening tag at
613
+ * `tagStart`, brace-matching the value. Null when absent. */
614
+ function attrSpanInTag(
615
+ source: string,
616
+ tagStart: number,
617
+ name: string
618
+ ): { start: number; end: number; valueText: string | null } | null {
619
+ const end = openTagEnd(source, tagStart);
620
+ const tag = source.slice(tagStart, end);
621
+ const re = new RegExp(`\\s${name}(?=[\\s/>=])`);
622
+ const m = tag.match(re);
623
+ if (!m || m.index == null) return null;
624
+ const attrStart = tagStart + m.index;
625
+ let i = attrStart + m[0].length;
626
+ if (source[i] !== '=') return { start: attrStart, end: i, valueText: null }; // bare attr
627
+ i += 1;
628
+ if (source[i] === '"' || source[i] === "'") {
629
+ const q = source[i];
630
+ const close = source.indexOf(q, i + 1);
631
+ const vEnd = close < 0 ? end : close + 1;
632
+ return { start: attrStart, end: vEnd, valueText: source.slice(i, vEnd) };
633
+ }
634
+ if (source[i] === '{') {
635
+ let depth = 0;
636
+ for (let k = i; k < end; k += 1) {
637
+ if (source[k] === '{') depth += 1;
638
+ else if (source[k] === '}') {
639
+ depth -= 1;
640
+ if (depth === 0) {
641
+ return { start: attrStart, end: k + 1, valueText: source.slice(i, k + 1) };
642
+ }
643
+ }
644
+ }
645
+ }
646
+ return null;
647
+ }
648
+
649
+ /** Set (`next` = full JSX value text like `{2}` or `"x"` or '' for bare) or
650
+ * remove (`next` = null) an attribute on the opening tag at `tagStart`. */
651
+ function setTagAttr(
652
+ s: MagicString,
653
+ source: string,
654
+ tagStart: number,
655
+ name: string,
656
+ next: string | null
657
+ ): void {
658
+ const existing = attrSpanInTag(source, tagStart, name);
659
+ if (next == null) {
660
+ if (existing) s.remove(existing.start, existing.end);
661
+ return;
662
+ }
663
+ const text = next === '' ? ` ${name}` : ` ${name}=${next}`;
664
+ if (existing) {
665
+ s.overwrite(existing.start, existing.end, text);
666
+ } else {
667
+ const nameM = source.slice(tagStart).match(/^<[A-Za-z][\w.]*/);
668
+ if (!nameM) return;
669
+ s.appendLeft(tagStart + nameM[0].length, text);
670
+ }
671
+ }
672
+
673
+ /** The clip's DIRECT media element open-tag start, or null (wrapper/array-fed
674
+ * media is refused by the callers — editing a shared component would leak). */
675
+ function directMediaTagStart(
676
+ source: string,
677
+ clip: ClipInfo,
678
+ tags = ['Video', 'OffthreadVideo', 'Audio', 'Img']
679
+ ): number | null {
680
+ const span = source.slice(clip.start, clip.end);
681
+ const m = span.match(new RegExp(`<(${tags.join('|')})\\b`));
682
+ if (!m || m.index == null) return null;
683
+ return clip.start + m.index;
684
+ }
685
+
686
+ function numAttrText(source: string, tagStart: number, name: string): number | null {
687
+ const a = attrSpanInTag(source, tagStart, name);
688
+ if (!a?.valueText) return null;
689
+ const inner = a.valueText.replace(/^\{|\}$/g, '').trim();
690
+ return resolveNum(inner, collectConsts(source));
691
+ }
692
+
693
+ /** Overwrite a clip tag's durationInFrames with a literal (a shared const must
694
+ * not absorb a per-clip change). Throws when the attr is missing. */
695
+ function setClipDurationLiteral(
696
+ s: MagicString,
697
+ source: string,
698
+ canvasAbsPath: string,
699
+ clip: ClipInfo,
700
+ frames: number
701
+ ): void {
702
+ const a = attrSpanInTag(source, clip.start, 'durationInFrames');
703
+ if (!a) {
704
+ throw new CanvasEditError(`clip "${clip.stableId}" has no durationInFrames to rewrite`, {
705
+ canvas: canvasAbsPath,
706
+ id: clip.stableId,
707
+ });
708
+ }
709
+ s.overwrite(a.start, a.end, ` durationInFrames={${Math.max(1, Math.round(frames))}}`);
710
+ }
711
+
712
+ interface SpeedResult {
713
+ source: string;
714
+ rate: number;
715
+ newDuration: number | null;
716
+ }
717
+
718
+ /**
719
+ * Task 10 — set/remove a clip's playback speed. Writes `playbackRate` on the
720
+ * clip's DIRECT media element, recomputes the clip's `durationInFrames`
721
+ * (source frames consumed stay constant: d1 = d0 × r0 / r1), and ripples the
722
+ * length delta (downstream `from`s + TOTAL). `rate === 1` removes the attr.
723
+ */
724
+ export function applySetPlaybackRate(
725
+ canvasAbsPath: string,
726
+ source: string,
727
+ artboardId: string | undefined,
728
+ stableId: string,
729
+ expectedHash: string | undefined,
730
+ rate: number
731
+ ): SpeedResult {
732
+ if (!(rate > 0.01 && rate <= 16)) {
733
+ throw new CanvasEditError('playbackRate must be between 0.01 and 16', {
734
+ canvas: canvasAbsPath,
735
+ id: stableId,
736
+ });
737
+ }
738
+ const { clips } = enumerateClips(canvasAbsPath, source, artboardId);
739
+ const { clip } = mustResolve(canvasAbsPath, clips, stableId, expectedHash);
740
+ const mediaStart = directMediaTagStart(source, clip, ['Video', 'OffthreadVideo', 'Audio']);
741
+ if (mediaStart == null) {
742
+ throw new CanvasEditError(
743
+ `clip "${stableId}" has no direct media element — speed applies to <Video>/<Audio> clips (shared/wrapper media would change every user)`,
744
+ { canvas: canvasAbsPath, id: stableId }
745
+ );
746
+ }
747
+ const oldRate = numAttrText(source, mediaStart, 'playbackRate') ?? 1;
748
+ const oldDur = clip.durationInFrames;
749
+ const s = new MagicString(source);
750
+ setTagAttr(s, source, mediaStart, 'playbackRate', rate === 1 ? null : `{${rate}}`);
751
+ let newDur: number | null = null;
752
+ if (oldDur != null) {
753
+ newDur = Math.max(1, Math.round((oldDur * oldRate) / rate));
754
+ setClipDurationLiteral(s, source, canvasAbsPath, clip, newDur);
755
+ }
756
+ let out = s.toString();
757
+ assertParses(canvasAbsPath, out, stableId);
758
+ assertCompSemantics(canvasAbsPath, out);
759
+ if (oldDur != null && newDur != null && newDur !== oldDur) {
760
+ out = applyRippleAfterClip(canvasAbsPath, out, artboardId, stableId, newDur - oldDur).source;
761
+ }
762
+ return { source: out, rate, newDuration: newDur };
763
+ }
764
+
765
+ /**
766
+ * Task 11 — in-point trim (the left-edge drag). `deltaFrames > 0` trims INTO
767
+ * the clip (later in-point, shorter clip); negative restores. Writes
768
+ * `trimBefore` on the media element (reads legacy `startFrom` too), shortens
769
+ * the clip, moves a standalone clip's `from` (the left edge follows), and
770
+ * ripples downstream.
771
+ */
772
+ export function applyTrimIn(
773
+ canvasAbsPath: string,
774
+ source: string,
775
+ artboardId: string | undefined,
776
+ stableId: string,
777
+ expectedHash: string | undefined,
778
+ deltaFrames: number
779
+ ): { source: string; trimBefore: number; newDuration: number } {
780
+ const delta = Math.round(deltaFrames);
781
+ const { clips } = enumerateClips(canvasAbsPath, source, artboardId);
782
+ const { clip } = mustResolve(canvasAbsPath, clips, stableId, expectedHash);
783
+ const mediaStart = directMediaTagStart(source, clip, ['Video', 'OffthreadVideo', 'Audio']);
784
+ if (mediaStart == null) {
785
+ throw new CanvasEditError(
786
+ `clip "${stableId}" has no direct media element — in-point trim applies to media clips`,
787
+ { canvas: canvasAbsPath, id: stableId }
788
+ );
789
+ }
790
+ const oldDur = clip.durationInFrames;
791
+ if (oldDur == null) {
792
+ throw new CanvasEditError(`clip "${stableId}" has no resolvable durationInFrames`, {
793
+ canvas: canvasAbsPath,
794
+ id: stableId,
795
+ });
796
+ }
797
+ const rate = numAttrText(source, mediaStart, 'playbackRate') ?? 1;
798
+ const oldTrim =
799
+ numAttrText(source, mediaStart, 'trimBefore') ??
800
+ numAttrText(source, mediaStart, 'startFrom') ??
801
+ 0;
802
+ const newTrim = Math.max(0, Math.round(oldTrim + delta * rate));
803
+ const effDelta = Math.round((newTrim - oldTrim) / rate); // clamped at trim 0
804
+ const newDur = oldDur - effDelta;
805
+ if (newDur < 1) {
806
+ throw new CanvasEditError('trim would leave the clip shorter than 1 frame', {
807
+ canvas: canvasAbsPath,
808
+ id: stableId,
809
+ });
810
+ }
811
+ const s = new MagicString(source);
812
+ setTagAttr(s, source, mediaStart, 'trimBefore', newTrim === 0 ? null : `{${newTrim}}`);
813
+ // Emit trimBefore, retire the legacy spelling if present.
814
+ if (attrSpanInTag(source, mediaStart, 'startFrom')) {
815
+ setTagAttr(s, source, mediaStart, 'startFrom', null);
816
+ }
817
+ setClipDurationLiteral(s, source, canvasAbsPath, clip, newDur);
818
+ if (clip.tag === 'Sequence' && clip.from != null) {
819
+ const a = attrSpanInTag(source, clip.start, 'from');
820
+ if (a) s.overwrite(a.start, a.end, ` from={${Math.max(0, clip.from + effDelta)}}`);
821
+ }
822
+ let out = s.toString();
823
+ assertParses(canvasAbsPath, out, stableId);
824
+ assertCompSemantics(canvasAbsPath, out);
825
+ if (effDelta !== 0) {
826
+ out = applyRippleAfterClip(canvasAbsPath, out, artboardId, stableId, -effDelta).source;
827
+ }
828
+ return { source: out, trimBefore: newTrim, newDuration: newDur };
829
+ }
830
+
831
+ /**
832
+ * Task 12 — per-clip audio: mute toggle + constant volume on the clip's direct
833
+ * media element. `muted: true` writes the bare attr; `volume` writes
834
+ * `volume={n}` (0–1); null/1 removes it.
835
+ */
836
+ export function applyClipAudio(
837
+ canvasAbsPath: string,
838
+ source: string,
839
+ artboardId: string | undefined,
840
+ stableId: string,
841
+ expectedHash: string | undefined,
842
+ opts: { muted?: boolean; volume?: number | null }
843
+ ): { source: string } {
844
+ const { clips } = enumerateClips(canvasAbsPath, source, artboardId);
845
+ const { clip } = mustResolve(canvasAbsPath, clips, stableId, expectedHash);
846
+ const mediaStart = directMediaTagStart(source, clip, ['Video', 'OffthreadVideo', 'Audio']);
847
+ if (mediaStart == null) {
848
+ throw new CanvasEditError(`clip "${stableId}" has no direct media element to set audio on`, {
849
+ canvas: canvasAbsPath,
850
+ id: stableId,
851
+ });
852
+ }
853
+ const s = new MagicString(source);
854
+ if (opts.muted != null) setTagAttr(s, source, mediaStart, 'muted', opts.muted ? '' : null);
855
+ if (opts.volume !== undefined) {
856
+ const v = opts.volume;
857
+ if (v != null && !(v >= 0 && v <= 1)) {
858
+ throw new CanvasEditError('volume must be between 0 and 1', {
859
+ canvas: canvasAbsPath,
860
+ id: stableId,
861
+ });
862
+ }
863
+ setTagAttr(s, source, mediaStart, 'volume', v == null || v === 1 ? null : `{${v}}`);
864
+ }
865
+ const out = s.toString();
866
+ assertParses(canvasAbsPath, out, stableId);
867
+ assertCompSemantics(canvasAbsPath, out);
868
+ return { source: out };
869
+ }
870
+
871
+ /**
872
+ * Task 12 — detach audio: mute the video clip AND add an audio-band
873
+ * `<Sequence from><Audio src={same} trimBefore={same} playbackRate={same}>`
874
+ * (same file, two elements, zero preprocessing).
875
+ */
876
+ export function applyDetachAudio(
877
+ canvasAbsPath: string,
878
+ source: string,
879
+ artboardId: string | undefined,
880
+ stableId: string,
881
+ expectedHash: string | undefined
882
+ ): { source: string; audioStableId: string | null } {
883
+ const cc = enumerateClips(canvasAbsPath, source, artboardId);
884
+ const { clip } = mustResolve(canvasAbsPath, cc.clips, stableId, expectedHash);
885
+ const mediaStart = directMediaTagStart(source, clip, ['Video', 'OffthreadVideo']);
886
+ if (mediaStart == null || !clip.mediaSrc) {
887
+ throw new CanvasEditError(
888
+ `clip "${stableId}" has no direct video media with a literal src to detach`,
889
+ { canvas: canvasAbsPath, id: stableId }
890
+ );
891
+ }
892
+ if (clip.durationInFrames == null) {
893
+ throw new CanvasEditError(`clip "${stableId}" has no resolvable duration`, {
894
+ canvas: canvasAbsPath,
895
+ id: stableId,
896
+ });
897
+ }
898
+ const trim = numAttrText(source, mediaStart, 'trimBefore') ?? 0;
899
+ const rate = numAttrText(source, mediaStart, 'playbackRate') ?? 1;
900
+ // Mute the video in place…
901
+ const s = new MagicString(source);
902
+ setTagAttr(s, source, mediaStart, 'muted', '');
903
+ const muted = s.toString();
904
+ // …then insert the audio clip at the clip's absolute start.
905
+ const startFrame = clipAbsoluteStart(muted, canvasAbsPath, artboardId, stableId);
906
+ const inserted = applyInsertClipAt(canvasAbsPath, muted, artboardId, {
907
+ lane: 'audio',
908
+ from: startFrame,
909
+ durationInFrames: clip.durationInFrames,
910
+ mediaTag: 'Audio',
911
+ src: clip.mediaSrc,
912
+ });
913
+ // applyInsertClipAt writes a plain `<Audio src>`; graft trim/rate onto it.
914
+ let out = inserted.source;
915
+ if (trim || rate !== 1) {
916
+ const after = enumerateClips(canvasAbsPath, out, artboardId);
917
+ const audioClip = after.clips.find((c) => c.stableId === inserted.stableId);
918
+ const audioMedia = audioClip ? directMediaTagStart(out, audioClip, ['Audio']) : null;
919
+ if (audioMedia != null) {
920
+ const s2 = new MagicString(out);
921
+ if (trim) setTagAttr(s2, out, audioMedia, 'trimBefore', `{${trim}}`);
922
+ if (rate !== 1) setTagAttr(s2, out, audioMedia, 'playbackRate', `{${rate}}`);
923
+ out = s2.toString();
924
+ assertParses(canvasAbsPath, out, stableId);
925
+ }
926
+ }
927
+ assertCompSemantics(canvasAbsPath, out);
928
+ return { source: out, audioStableId: inserted.stableId };
929
+ }
930
+
931
+ /** Absolute start frame of a clip: explicit `from`, or the series cursor walk
932
+ * (sum of prior beat durations minus transition overlaps). */
933
+ export function clipAbsoluteStart(
934
+ source: string,
935
+ canvasAbsPath: string,
936
+ artboardId: string | undefined,
937
+ stableId: string
938
+ ): number {
939
+ const { clips } = enumerateClips(canvasAbsPath, source, artboardId);
940
+ const consts = collectConsts(source);
941
+ let cursor = 0;
942
+ for (const c of clips) {
943
+ if (c.kind === 'transition') {
944
+ cursor -= transitionDurationFrames(source, c, consts) ?? 0;
945
+ continue;
946
+ }
947
+ const start = c.tag === 'Sequence' && c.from != null ? c.from : cursor;
948
+ if (c.stableId === stableId) return Math.max(0, Math.round(start));
949
+ cursor = start + (c.durationInFrames ?? 0);
950
+ }
951
+ return 0;
952
+ }
953
+
954
+ /** Task 14 — grade parameter vocabulary (the PhotoEdit subset that compiles to
955
+ * a CSS filter chain — deterministic in Player + both export paths). */
956
+ export interface GradeParams {
957
+ brightness?: number; // 1 = neutral
958
+ contrast?: number; // 1 = neutral
959
+ saturation?: number; // 1 = neutral
960
+ hue?: number; // deg, 0 = neutral
961
+ sepia?: number; // 0 = neutral
962
+ grayscale?: number; // 0 = neutral
963
+ invert?: number; // 0 = neutral
964
+ }
965
+
966
+ export function gradeToFilter(p: GradeParams): string {
967
+ const parts: string[] = [];
968
+ const num = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
969
+ const b = num(p.brightness);
970
+ if (b != null && b !== 1) parts.push(`brightness(${b})`);
971
+ const c = num(p.contrast);
972
+ if (c != null && c !== 1) parts.push(`contrast(${c})`);
973
+ const s = num(p.saturation);
974
+ if (s != null && s !== 1) parts.push(`saturate(${s})`);
975
+ const h = num(p.hue);
976
+ if (h != null && h !== 0) parts.push(`hue-rotate(${h}deg)`);
977
+ const se = num(p.sepia);
978
+ if (se != null && se !== 0) parts.push(`sepia(${se})`);
979
+ const g = num(p.grayscale);
980
+ if (g != null && g !== 0) parts.push(`grayscale(${g})`);
981
+ const i = num(p.invert);
982
+ if (i != null && i !== 0) parts.push(`invert(${i})`);
983
+ return parts.join(' ');
984
+ }
985
+
986
+ /** Round-trip: parse a filter chain back to params. Unrecognized functions →
987
+ * null (the UI shows a read-only badge instead of clobbering hand-written CSS). */
988
+ export function filterToGrade(filter: string): GradeParams | null {
989
+ const out: GradeParams = {};
990
+ let rest = String(filter).trim();
991
+ const fnRe = /^([a-z-]+)\(([^)]*)\)\s*/;
992
+ while (rest.length) {
993
+ const m = rest.match(fnRe);
994
+ if (!m) return null;
995
+ const val = Number.parseFloat(m[2] as string);
996
+ if (!Number.isFinite(val)) return null;
997
+ switch (m[1]) {
998
+ case 'brightness':
999
+ out.brightness = val;
1000
+ break;
1001
+ case 'contrast':
1002
+ out.contrast = val;
1003
+ break;
1004
+ case 'saturate':
1005
+ out.saturation = val;
1006
+ break;
1007
+ case 'hue-rotate':
1008
+ out.hue = val;
1009
+ break;
1010
+ case 'sepia':
1011
+ out.sepia = val;
1012
+ break;
1013
+ case 'grayscale':
1014
+ out.grayscale = val;
1015
+ break;
1016
+ case 'invert':
1017
+ out.invert = val;
1018
+ break;
1019
+ default:
1020
+ return null;
1021
+ }
1022
+ rest = rest.slice(m[0].length);
1023
+ }
1024
+ return out;
1025
+ }
1026
+
1027
+ /**
1028
+ * Task 14 — set/clear a clip's color grade: ONE CSS `filter` string on the
1029
+ * media element's `style` (literal object only). `params === null` clears it.
1030
+ */
1031
+ export function applyClipGrade(
1032
+ canvasAbsPath: string,
1033
+ source: string,
1034
+ artboardId: string | undefined,
1035
+ stableId: string,
1036
+ expectedHash: string | undefined,
1037
+ params: GradeParams | null
1038
+ ): { source: string; filter: string } {
1039
+ const { clips } = enumerateClips(canvasAbsPath, source, artboardId);
1040
+ const { clip } = mustResolve(canvasAbsPath, clips, stableId, expectedHash);
1041
+ const mediaStart = directMediaTagStart(source, clip, ['Video', 'OffthreadVideo', 'Img']);
1042
+ if (mediaStart == null) {
1043
+ throw new CanvasEditError(`clip "${stableId}" has no direct media element to grade`, {
1044
+ canvas: canvasAbsPath,
1045
+ id: stableId,
1046
+ });
1047
+ }
1048
+ const filter = params ? gradeToFilter(params) : '';
1049
+ const styleAttr = attrSpanInTag(source, mediaStart, 'style');
1050
+ const s = new MagicString(source);
1051
+ if (!styleAttr) {
1052
+ if (filter) setTagAttr(s, source, mediaStart, 'style', `{{ filter: '${filter}' }}`);
1053
+ } else {
1054
+ const v = styleAttr.valueText ?? '';
1055
+ if (!v.startsWith('{{')) {
1056
+ throw new CanvasEditError(
1057
+ `clip "${stableId}"'s media style is not a literal object — edit it by hand`,
1058
+ { canvas: canvasAbsPath, id: stableId }
1059
+ );
1060
+ }
1061
+ const filterProp = v.match(/(['"]?)filter\1\s*:\s*(['"])((?:\\.|(?!\2).)*)\2\s*,?\s*/);
1062
+ if (filterProp && filterProp.index != null) {
1063
+ const abs = styleAttr.start + (styleAttr.end - styleAttr.start - v.length) + filterProp.index;
1064
+ if (filter) {
1065
+ s.overwrite(
1066
+ abs,
1067
+ abs + filterProp[0].length,
1068
+ `filter: '${filter}'${filterProp[0].trimEnd().endsWith(',') ? ', ' : ' '}`
1069
+ );
1070
+ } else {
1071
+ s.remove(abs, abs + filterProp[0].length);
1072
+ }
1073
+ } else if (filter) {
1074
+ const openIdx = styleAttr.end - v.length + 2; // after `{{`
1075
+ s.appendLeft(openIdx, ` filter: '${filter}',`);
1076
+ }
1077
+ }
1078
+ const out = s.toString();
1079
+ assertParses(canvasAbsPath, out, stableId);
1080
+ assertCompSemantics(canvasAbsPath, out);
1081
+ return { source: out, filter };
1082
+ }
1083
+
1084
+ /**
1085
+ * Task 13 — crop/reposition: an idempotent framing wrapper around the media
1086
+ * element (`overflow:hidden` outer + scaled/translated inner), marked with
1087
+ * `data-mframe` for lossless round-trip. `framing === null` unwraps.
1088
+ */
1089
+ export function applyClipFraming(
1090
+ canvasAbsPath: string,
1091
+ source: string,
1092
+ artboardId: string | undefined,
1093
+ stableId: string,
1094
+ expectedHash: string | undefined,
1095
+ framing: { scale: number; x: number; y: number } | null
1096
+ ): { source: string } {
1097
+ const { clips } = enumerateClips(canvasAbsPath, source, artboardId);
1098
+ const { clip } = mustResolve(canvasAbsPath, clips, stableId, expectedHash);
1099
+ const mediaStart = directMediaTagStart(source, clip, ['Video', 'OffthreadVideo', 'Img']);
1100
+ if (mediaStart == null) {
1101
+ throw new CanvasEditError(`clip "${stableId}" has no direct media element to frame`, {
1102
+ canvas: canvasAbsPath,
1103
+ id: stableId,
1104
+ });
1105
+ }
1106
+ // Existing wrapper? (marker on the OUTER div, inside this clip's span)
1107
+ const span = source.slice(clip.start, clip.end);
1108
+ const marker = span.match(/<div data-mframe="[^"]*"[^>]*>/);
1109
+ const s = new MagicString(source);
1110
+ if (marker && marker.index != null) {
1111
+ const outerStart = clip.start + marker.index;
1112
+ // The wrapper is exactly: <div data-mframe …><div …>MEDIA</div></div>
1113
+ const outerEnd = matchTagEnd(source, outerStart);
1114
+ if (outerEnd == null) {
1115
+ throw new CanvasEditError('framing wrapper is malformed — fix the JSX by hand', {
1116
+ canvas: canvasAbsPath,
1117
+ id: stableId,
1118
+ });
1119
+ }
1120
+ const inner = source.slice(outerStart, outerEnd);
1121
+ const mediaM = inner.match(/<(?:Video|OffthreadVideo|Img)\b[\s\S]*?\/>/);
1122
+ if (!mediaM || mediaM.index == null) {
1123
+ throw new CanvasEditError('framing wrapper holds no media — fix the JSX by hand', {
1124
+ canvas: canvasAbsPath,
1125
+ id: stableId,
1126
+ });
1127
+ }
1128
+ const mediaText = mediaM[0];
1129
+ if (framing == null) {
1130
+ s.overwrite(outerStart, outerEnd, mediaText);
1131
+ } else {
1132
+ s.overwrite(outerStart, outerEnd, framingWrapper(mediaText, framing));
1133
+ }
1134
+ } else if (framing != null) {
1135
+ const mEnd = matchSelfClosing(source, mediaStart);
1136
+ if (mEnd == null) {
1137
+ throw new CanvasEditError(
1138
+ 'framing needs a self-closing media element (`<Video … />`) — adjust the JSX',
1139
+ { canvas: canvasAbsPath, id: stableId }
1140
+ );
1141
+ }
1142
+ s.overwrite(mediaStart, mEnd, framingWrapper(source.slice(mediaStart, mEnd), framing));
1143
+ }
1144
+ const out = s.toString();
1145
+ assertParses(canvasAbsPath, out, stableId);
1146
+ assertCompSemantics(canvasAbsPath, out);
1147
+ return { source: out };
1148
+ }
1149
+
1150
+ function framingWrapper(mediaText: string, f: { scale: number; x: number; y: number }): string {
1151
+ const scale = Math.max(1, Math.min(8, f.scale || 1));
1152
+ const x = Math.max(-100, Math.min(100, f.x || 0));
1153
+ const y = Math.max(-100, Math.min(100, f.y || 0));
1154
+ return (
1155
+ `<div data-mframe="${scale},${x},${y}" style={{ overflow: 'hidden', width: '100%', height: '100%' }}>` +
1156
+ `<div style={{ width: '100%', height: '100%', transform: 'scale(${scale}) translate(${x}%, ${y}%)' }}>` +
1157
+ mediaText +
1158
+ `</div></div>`
1159
+ );
1160
+ }
1161
+
1162
+ /** End index (exclusive) of the `<div …>…</div>` element starting at `start`
1163
+ * (depth-counted on <div/</div> only — the wrapper contains no other divs
1164
+ * except its own inner). */
1165
+ function matchTagEnd(source: string, start: number): number | null {
1166
+ const re = /<div\b|<\/div>/g;
1167
+ re.lastIndex = start;
1168
+ let depth = 0;
1169
+ let m: RegExpExecArray | null = re.exec(source);
1170
+ while (m) {
1171
+ if (m[0] === '<div') depth += 1;
1172
+ else {
1173
+ depth -= 1;
1174
+ if (depth === 0) return m.index + m[0].length;
1175
+ }
1176
+ m = re.exec(source);
1177
+ }
1178
+ return null;
1179
+ }
1180
+
1181
+ /** End (exclusive) of a self-closing tag starting at `start`, or null. */
1182
+ function matchSelfClosing(source: string, start: number): number | null {
1183
+ const end = openTagEnd(source, start);
1184
+ return source.slice(end - 2, end) === '/>' ? end : null;
1185
+ }
1186
+
1187
+ // Task 15 / 17 — the 6 bundled transition presentations.
1188
+ const PRESENTATIONS: Record<string, { factory: string; specifier: string; importName: string }> = {
1189
+ fade: { factory: 'fade()', specifier: '@remotion/transitions/fade', importName: 'fade' },
1190
+ slide: { factory: 'slide()', specifier: '@remotion/transitions/slide', importName: 'slide' },
1191
+ wipe: { factory: 'wipe()', specifier: '@remotion/transitions/wipe', importName: 'wipe' },
1192
+ flip: { factory: 'flip()', specifier: '@remotion/transitions/flip', importName: 'flip' },
1193
+ 'clock-wipe': {
1194
+ factory: 'clockWipe({ width: 1920, height: 1080 })',
1195
+ specifier: '@remotion/transitions/clock-wipe',
1196
+ importName: 'clockWipe',
1197
+ },
1198
+ none: { factory: 'none()', specifier: '@remotion/transitions/none', importName: 'none' },
1199
+ };
1200
+
1201
+ export const PRESENTATION_KEYS = Object.keys(PRESENTATIONS);
1202
+
1203
+ /** Ensure `import { name } from 'specifier';` exists (inserted after the last
1204
+ * import when absent). */
1205
+ function ensureImport(s: MagicString, source: string, importName: string, specifier: string): void {
1206
+ if (new RegExp(`from ['"]${specifier.replace(/[/]/g, '\\/')}['"]`).test(source)) return;
1207
+ const imports = [...source.matchAll(/^import [^\n]*\n/gm)];
1208
+ const last = imports[imports.length - 1];
1209
+ const at = last ? (last.index as number) + last[0].length : 0;
1210
+ s.appendLeft(at, `import { ${importName} } from '${specifier}';\n`);
1211
+ }
1212
+
1213
+ /**
1214
+ * Task 15 — edit an existing seam: presentation (among the 6 bundled) and/or
1215
+ * duration (TOTAL-rippled). The transition is addressed by ITS stableId from
1216
+ * the enumerator.
1217
+ */
1218
+ export function applyEditTransition(
1219
+ canvasAbsPath: string,
1220
+ source: string,
1221
+ artboardId: string | undefined,
1222
+ transitionId: string,
1223
+ expectedHash: string | undefined,
1224
+ opts: { presentation?: string; durationInFrames?: number }
1225
+ ): { source: string } {
1226
+ const { clips } = enumerateClips(canvasAbsPath, source, artboardId);
1227
+ const { clip: t } = mustResolve(canvasAbsPath, clips, transitionId, expectedHash);
1228
+ if (t.kind !== 'transition') {
1229
+ throw new CanvasEditError(`"${transitionId}" is not a transition`, {
1230
+ canvas: canvasAbsPath,
1231
+ id: transitionId,
1232
+ });
1233
+ }
1234
+ const s = new MagicString(source);
1235
+ if (opts.presentation != null) {
1236
+ const p = PRESENTATIONS[opts.presentation];
1237
+ if (!p) {
1238
+ throw new CanvasEditError(
1239
+ `unknown presentation "${opts.presentation}" — one of: ${PRESENTATION_KEYS.join(', ')}`,
1240
+ { canvas: canvasAbsPath, id: transitionId }
1241
+ );
1242
+ }
1243
+ const attr = attrSpanInTag(source, t.start, 'presentation');
1244
+ if (!attr) {
1245
+ throw new CanvasEditError('transition has no presentation attr to rewrite', {
1246
+ canvas: canvasAbsPath,
1247
+ id: transitionId,
1248
+ });
1249
+ }
1250
+ // clockWipe sizes itself to the comp when we can read it.
1251
+ let factory = p.factory;
1252
+ if (opts.presentation === 'clock-wipe') {
1253
+ const wh = compDimensions(source);
1254
+ if (wh) factory = `clockWipe({ width: ${wh.width}, height: ${wh.height} })`;
1255
+ }
1256
+ s.overwrite(attr.start, attr.end, ` presentation={${factory}}`);
1257
+ ensureImport(s, source, p.importName, p.specifier);
1258
+ }
1259
+ let out: string;
1260
+ let delta = 0;
1261
+ if (opts.durationInFrames != null) {
1262
+ const next = Math.max(1, Math.round(opts.durationInFrames));
1263
+ const span = source.slice(t.start, t.end);
1264
+ const m = span.match(/durationInFrames:\s*([^,}\s)]+)/);
1265
+ if (!m || m.index == null) {
1266
+ throw new CanvasEditError('transition has no timing durationInFrames to rewrite', {
1267
+ canvas: canvasAbsPath,
1268
+ id: transitionId,
1269
+ });
1270
+ }
1271
+ const old = resolveNum(m[1] as string, collectConsts(source));
1272
+ const valStart = t.start + m.index + m[0].indexOf(m[1] as string);
1273
+ s.overwrite(valStart, valStart + (m[1] as string).length, String(next));
1274
+ if (old != null) delta = -(next - old); // longer overlap ⇒ SHORTER comp
1275
+ out = s.toString();
1276
+ } else {
1277
+ out = s.toString();
1278
+ }
1279
+ assertParses(canvasAbsPath, out, transitionId);
1280
+ assertCompSemantics(canvasAbsPath, out);
1281
+ if (delta !== 0) {
1282
+ out = applyRippleAfterClip(canvasAbsPath, out, artboardId, transitionId, delta, {
1283
+ shiftFroms: false,
1284
+ }).source;
1285
+ }
1286
+ return { source: out };
1287
+ }
1288
+
1289
+ /** The comp's VideoComp width/height (for clockWipe), when resolvable. */
1290
+ function compDimensions(source: string): { width: number; height: number } | null {
1291
+ const m = source.match(/<VideoComp\b[^>]*>/);
1292
+ if (!m || m.index == null) return null;
1293
+ const w = numAttrText(source, m.index, 'width');
1294
+ const h = numAttrText(source, m.index, 'height');
1295
+ return w && h ? { width: w, height: h } : null;
1296
+ }
1297
+
1298
+ // ---------------------------------------------------------------------------
1299
+ // Phase 3 — structural ops: split at playhead, transition insert/remove.
1300
+
1301
+ /** Rewrite/insert/remove an attr on the FIRST tag of a detached text fragment. */
1302
+ function textWithAttr(text: string, name: string, next: string | null): string {
1303
+ const s = new MagicString(text);
1304
+ setTagAttr(s, text, 0, name, next);
1305
+ return s.toString();
1306
+ }
1307
+
1308
+ /** Rewrite an attr on the first MEDIA tag inside a detached clip text. */
1309
+ function textWithMediaAttr(text: string, name: string, next: string | null): string {
1310
+ const m = text.match(/<(?:Video|OffthreadVideo|Audio|Img)\b/);
1311
+ if (!m || m.index == null) return text;
1312
+ const s = new MagicString(text);
1313
+ setTagAttr(s, text, m.index, name, next);
1314
+ return s.toString();
1315
+ }
1316
+
1317
+ /**
1318
+ * Task 16 — split a clip at an absolute comp frame. Two literal halves: the
1319
+ * first keeps the in-point and takes `offset` frames; the second starts at the
1320
+ * cut (`trimBefore += offset × rate`) and takes the remainder. Both inherit
1321
+ * every other attr (grade, crop wrapper, audio props ride along verbatim).
1322
+ *
1323
+ * Scope fence (DDR-150 deferral): standalone `<Sequence>` clips, and series
1324
+ * beats NOT adjacent to a transition — at a seam the overlap math makes the
1325
+ * halves lie, so the engine refuses with guidance instead.
1326
+ */
1327
+ export function applySplitClip(
1328
+ canvasAbsPath: string,
1329
+ source: string,
1330
+ artboardId: string | undefined,
1331
+ stableId: string,
1332
+ expectedHash: string | undefined,
1333
+ atFrame: number
1334
+ ): { source: string; firstStableId: string | null; secondStableId: string | null } {
1335
+ const { clips } = enumerateClips(canvasAbsPath, source, artboardId);
1336
+ const { clip, idx } = mustResolve(canvasAbsPath, clips, stableId, expectedHash);
1337
+ if (clip.kind !== 'sequence') {
1338
+ throw new CanvasEditError(`"${stableId}" is a transition, not a splittable clip`, {
1339
+ canvas: canvasAbsPath,
1340
+ id: stableId,
1341
+ });
1342
+ }
1343
+ if (isSeriesSeq(clip)) {
1344
+ const prev = clips[idx - 1];
1345
+ const next = clips[idx + 1];
1346
+ if (prev?.kind === 'transition' || next?.kind === 'transition') {
1347
+ throw new CanvasEditError(
1348
+ 'this beat touches a transition — remove the transition first (or move the clip out of it), then split',
1349
+ { canvas: canvasAbsPath, id: stableId }
1350
+ );
1351
+ }
1352
+ }
1353
+ const dur = clip.durationInFrames;
1354
+ if (dur == null) {
1355
+ throw new CanvasEditError(`clip "${stableId}" has no resolvable durationInFrames`, {
1356
+ canvas: canvasAbsPath,
1357
+ id: stableId,
1358
+ });
1359
+ }
1360
+ const start = clipAbsoluteStart(source, canvasAbsPath, artboardId, stableId);
1361
+ const offset = Math.round(atFrame - start);
1362
+ if (offset < 1 || offset > dur - 1) {
1363
+ throw new CanvasEditError(
1364
+ `split point is ${offset <= 0 ? 'at/before the clip start' : 'at/after the clip end'} — scrub inside the clip first`,
1365
+ { canvas: canvasAbsPath, id: stableId }
1366
+ );
1367
+ }
1368
+ const clipText = source.slice(clip.start, clip.end);
1369
+ const mediaM = clipText.match(/<(?:Video|OffthreadVideo|Audio|Img)\b/);
1370
+ const rate = mediaM ? (numAttrText(clipText, mediaM.index as number, 'playbackRate') ?? 1) : 1;
1371
+ const oldTrim = mediaM
1372
+ ? (numAttrText(clipText, mediaM.index as number, 'trimBefore') ??
1373
+ numAttrText(clipText, mediaM.index as number, 'startFrom') ??
1374
+ 0)
1375
+ : 0;
1376
+ const nameM = clipText.match(/^<[A-Za-z][\w.]*\s[^>]*?name="([^"]*)"/);
1377
+
1378
+ // First half — duration only.
1379
+ const first = textWithAttr(clipText, 'durationInFrames', `{${offset}}`);
1380
+ // Second half — remainder + shifted in-point (+ shifted `from` when standalone).
1381
+ let second = textWithAttr(clipText, 'durationInFrames', `{${dur - offset}}`);
1382
+ if (mediaM) {
1383
+ const newTrim = Math.round(oldTrim + offset * rate);
1384
+ second = textWithMediaAttr(second, 'startFrom', null);
1385
+ second = textWithMediaAttr(second, 'trimBefore', newTrim === 0 ? null : `{${newTrim}}`);
1386
+ }
1387
+ if (clip.tag === 'Sequence' && clip.from != null) {
1388
+ second = textWithAttr(second, 'from', `{${clip.from + offset}}`);
1389
+ }
1390
+ if (nameM) {
1391
+ // Duplicate names would collide the stableId — suffix the second half.
1392
+ second = second.replace(`name="${nameM[1]}"`, `name="${nameM[1]}-${atFrame}"`);
1393
+ }
1394
+ const indent = lineStartInfo(source, clip.start).indent;
1395
+ const s = new MagicString(source);
1396
+ s.overwrite(clip.start, clip.end, `${first}\n${indent}${second}`);
1397
+ const out = s.toString();
1398
+ assertParses(canvasAbsPath, out, stableId);
1399
+ assertCompSemantics(canvasAbsPath, out);
1400
+ // No ripple: the halves sum to the original (a hard cut between them).
1401
+ const after = enumerateClips(canvasAbsPath, out, artboardId);
1402
+ const seqIdxBefore = clips.slice(0, idx).filter((c) => c.kind === 'sequence').length;
1403
+ const seqs = after.clips.filter((c) => c.kind === 'sequence');
1404
+ return {
1405
+ source: out,
1406
+ firstStableId: seqs[seqIdxBefore]?.stableId ?? null,
1407
+ secondStableId: seqs[seqIdxBefore + 1]?.stableId ?? null,
1408
+ };
1409
+ }
1410
+
1411
+ /** Ensure `name` is among the named imports from `specifier` (extends an
1412
+ * existing import line, else adds one after the last import). */
1413
+ function ensureNamedImport(s: MagicString, source: string, name: string, specifier: string): void {
1414
+ const esc = specifier.replace(/[/]/g, '\\/');
1415
+ const line = source.match(new RegExp(`import \\{([^}]*)\\} from ['"]${esc}['"]`));
1416
+ if (line && line.index != null) {
1417
+ if (new RegExp(`\\b${name}\\b`).test(line[1] as string)) return;
1418
+ const braceOpen = line.index + (line[0] as string).indexOf('{') + 1;
1419
+ s.appendLeft(braceOpen, ` ${name},`);
1420
+ return;
1421
+ }
1422
+ const imports = [...source.matchAll(/^import [^\n]*\n/gm)];
1423
+ const last = imports[imports.length - 1];
1424
+ const at = last ? (last.index as number) + last[0].length : 0;
1425
+ s.appendLeft(at, `import { ${name} } from '${specifier}';\n`);
1426
+ }
1427
+
1428
+ /**
1429
+ * Task 17 — insert a transition into a hard cut AFTER `afterStableId` (a
1430
+ * series beat whose next sibling is another beat). TOTAL shrinks by the new
1431
+ * overlap (ripple). Refuses standalone pairs and already-transitioned seams.
1432
+ */
1433
+ export function applyInsertTransition(
1434
+ canvasAbsPath: string,
1435
+ source: string,
1436
+ artboardId: string | undefined,
1437
+ afterStableId: string,
1438
+ expectedHash: string | undefined,
1439
+ presentation: string,
1440
+ frames: number
1441
+ ): { source: string; transitionStableId: string | null } {
1442
+ const p = PRESENTATIONS[presentation];
1443
+ if (!p) {
1444
+ throw new CanvasEditError(
1445
+ `unknown presentation "${presentation}" — one of: ${PRESENTATION_KEYS.join(', ')}`,
1446
+ { canvas: canvasAbsPath, id: afterStableId }
1447
+ );
1448
+ }
1449
+ const dur = Math.max(1, Math.round(frames));
1450
+ const { clips } = enumerateClips(canvasAbsPath, source, artboardId);
1451
+ const { clip, idx } = mustResolve(canvasAbsPath, clips, afterStableId, expectedHash);
1452
+ if (!isSeriesSeq(clip)) {
1453
+ throw new CanvasEditError(
1454
+ 'transitions live between series beats — standalone clips take a hard cut (convert to a series first)',
1455
+ { canvas: canvasAbsPath, id: afterStableId }
1456
+ );
1457
+ }
1458
+ const next = clips[idx + 1];
1459
+ if (!next || !isSeriesSeq(next)) {
1460
+ throw new CanvasEditError(
1461
+ next?.kind === 'transition'
1462
+ ? 'this seam already has a transition — click its ⧓ chip to edit it'
1463
+ : 'no following beat — a transition needs a clip on both sides',
1464
+ { canvas: canvasAbsPath, id: afterStableId }
1465
+ );
1466
+ }
1467
+ let factory = p.factory;
1468
+ if (presentation === 'clock-wipe') {
1469
+ const wh = compDimensionsPublic(source);
1470
+ if (wh) factory = `clockWipe({ width: ${wh.width}, height: ${wh.height} })`;
1471
+ }
1472
+ const indent = lineStartInfo(source, clip.start).indent;
1473
+ const s = new MagicString(source);
1474
+ s.appendRight(
1475
+ clip.end,
1476
+ `\n${indent}<${clip.tag.split('.')[0]}.Transition presentation={${factory}} timing={linearTiming({ durationInFrames: ${dur} })} />`
1477
+ );
1478
+ ensureNamedImport(s, source, 'linearTiming', '@remotion/transitions');
1479
+ ensureImportPublic(s, source, p.importName, p.specifier);
1480
+ let out = s.toString();
1481
+ assertParses(canvasAbsPath, out, afterStableId);
1482
+ assertCompSemantics(canvasAbsPath, out);
1483
+ // New overlap ⇒ the comp SHRINKS by `dur`.
1484
+ out = applyRippleAfterClip(canvasAbsPath, out, artboardId, afterStableId, -dur, {
1485
+ shiftFroms: false,
1486
+ }).source;
1487
+ const after = enumerateClips(canvasAbsPath, out, artboardId);
1488
+ const afterIdx = after.clips.findIndex((c) => c.stableId === afterStableId);
1489
+ const t = after.clips[afterIdx + 1];
1490
+ return { source: out, transitionStableId: t?.kind === 'transition' ? t.stableId : null };
1491
+ }
1492
+
1493
+ /**
1494
+ * Task 17 — remove a transition (the seam becomes a hard cut). TOTAL grows by
1495
+ * the removed overlap (ripple).
1496
+ */
1497
+ export function applyRemoveTransition(
1498
+ canvasAbsPath: string,
1499
+ source: string,
1500
+ artboardId: string | undefined,
1501
+ transitionId: string,
1502
+ expectedHash: string | undefined
1503
+ ): { source: string } {
1504
+ const { clips } = enumerateClips(canvasAbsPath, source, artboardId);
1505
+ const { clip: t, idx } = mustResolve(canvasAbsPath, clips, transitionId, expectedHash);
1506
+ if (t.kind !== 'transition') {
1507
+ throw new CanvasEditError(`"${transitionId}" is not a transition`, {
1508
+ canvas: canvasAbsPath,
1509
+ id: transitionId,
1510
+ });
1511
+ }
1512
+ const overlap = transitionDurationFrames(source, t) ?? 0;
1513
+ const prevBeat = clips[idx - 1];
1514
+ const span = spanWithFraming(source, t.start, t.end);
1515
+ const s = new MagicString(source);
1516
+ s.remove(span[0], span[1]);
1517
+ let out = s.toString();
1518
+ assertParses(canvasAbsPath, out, transitionId);
1519
+ assertCompSemantics(canvasAbsPath, out);
1520
+ if (overlap !== 0 && prevBeat) {
1521
+ out = applyRippleAfterClip(canvasAbsPath, out, artboardId, prevBeat.stableId, overlap, {
1522
+ shiftFroms: false,
1523
+ }).source;
1524
+ }
1525
+ return { source: out };
1526
+ }
1527
+
1528
+ // Small public shims so Task-17 code can reuse the Task-15 internals.
1529
+ function compDimensionsPublic(source: string): { width: number; height: number } | null {
1530
+ const m = source.match(/<VideoComp\b[^>]*>/);
1531
+ if (!m || m.index == null) return null;
1532
+ const w = numAttrText(source, m.index, 'width');
1533
+ const h = numAttrText(source, m.index, 'height');
1534
+ return w && h ? { width: w, height: h } : null;
1535
+ }
1536
+ function ensureImportPublic(
1537
+ s: MagicString,
1538
+ source: string,
1539
+ importName: string,
1540
+ specifier: string
1541
+ ): void {
1542
+ if (new RegExp(`from ['"]${specifier.replace(/[/]/g, '\\/')}['"]`).test(source)) return;
1543
+ const imports = [...source.matchAll(/^import [^\n]*\n/gm)];
1544
+ const last = imports[imports.length - 1];
1545
+ const at = last ? (last.index as number) + last[0].length : 0;
1546
+ s.appendLeft(at, `import { ${importName} } from '${specifier}';\n`);
1547
+ }
1548
+
1549
+ /**
1550
+ * Task 22 — resolve an AI placeholder: swap the `<AIPlaceholder …/>` slate for
1551
+ * the generated media IN PLACE (the clip tag — and with it the stableId slot,
1552
+ * timing, and any name — survives).
1553
+ */
1554
+ export function applyResolvePlaceholder(
1555
+ canvasAbsPath: string,
1556
+ source: string,
1557
+ artboardId: string | undefined,
1558
+ stableId: string,
1559
+ expectedHash: string | undefined,
1560
+ src: string,
1561
+ mediaKind: 'video' | 'image'
1562
+ ): { source: string } {
1563
+ const { clips } = enumerateClips(canvasAbsPath, source, artboardId);
1564
+ const { clip } = mustResolve(canvasAbsPath, clips, stableId, expectedHash);
1565
+ const srcTrim = String(src ?? '').trim();
1566
+ if (!srcTrim) {
1567
+ throw new CanvasEditError('resolve needs a generated asset src', {
1568
+ canvas: canvasAbsPath,
1569
+ id: stableId,
1570
+ });
1571
+ }
1572
+ assertContainedSrc(canvasAbsPath, srcTrim, stableId);
1573
+ const span = source.slice(clip.start, clip.end);
1574
+ const m = span.match(/<AIPlaceholder\b/);
1575
+ if (!m || m.index == null) {
1576
+ throw new CanvasEditError(`clip "${stableId}" has no <AIPlaceholder> to resolve`, {
1577
+ canvas: canvasAbsPath,
1578
+ id: stableId,
1579
+ });
1580
+ }
1581
+ const phStart = clip.start + m.index;
1582
+ let phEnd = openTagEnd(source, phStart);
1583
+ if (source.slice(phEnd - 2, phEnd) !== '/>') {
1584
+ // Paired-tag form (`<AIPlaceholder …><span>{"…"}</span></AIPlaceholder>`,
1585
+ // the inline-editable emit) — swap the WHOLE element, close tag included.
1586
+ // Security (adversarial review 2026-07-30): a prompt of `foo</AIPlaceholder>`
1587
+ // emits a FAKE close tag inside the `{"…"}` string literal. `indexOf` would
1588
+ // match that fake first and overwrite through it (assertParses then fails
1589
+ // closed, but the placeholder becomes permanently un-resolvable). One
1590
+ // placeholder per clip span ⇒ the REAL closer is the LAST `</AIPlaceholder>`
1591
+ // before clip.end; a string-embedded fake always precedes it. Scan backwards.
1592
+ const closeTag = '</AIPlaceholder>';
1593
+ const close = source.lastIndexOf(closeTag, clip.end - 1);
1594
+ if (close < phEnd || close + closeTag.length > clip.end) {
1595
+ throw new CanvasEditError(
1596
+ 'placeholder element has no matching close tag — fix the JSX by hand',
1597
+ {
1598
+ canvas: canvasAbsPath,
1599
+ id: stableId,
1600
+ }
1601
+ );
1602
+ }
1603
+ phEnd = close + closeTag.length;
1604
+ }
1605
+ const tag = mediaKind === 'image' ? 'Img' : 'OffthreadVideo';
1606
+ const s = new MagicString(source);
1607
+ s.overwrite(phStart, phEnd, `<${tag} src="${escapeAttr(srcTrim)}" />`);
1608
+ const out = s.toString();
1609
+ assertParses(canvasAbsPath, out, stableId);
1610
+ assertCompSemantics(canvasAbsPath, out);
1611
+ return { source: out };
1612
+ }
1613
+
1614
+ /**
1615
+ * Dogfood fix — an artboard marked `kind="video"` in the Inspector IS the
1616
+ * user's "this is a video" intent, but without a `<VideoComp>` inside it the
1617
+ * timeline can't see it and every drop dead-ended in a new canvas. This
1618
+ * injects the missing comp IN PLACE: a minimal component + a `<VideoComp>`
1619
+ * mounted as the artboard's last child (paints over the brief content), sized
1620
+ * to the artboard. Returns null when the canvas has no eligible artboard
1621
+ * (an explicit `artboardId`, else the first `kind="video"` one).
1622
+ */
1623
+ export function applyEnsureVideoComp(
1624
+ canvasAbsPath: string,
1625
+ source: string,
1626
+ artboardId: string | undefined
1627
+ ): { source: string; artboardId: string } | null {
1628
+ const re = /<DCArtboard\b[^>]*>/g;
1629
+ let target: { start: number; tag: string } | null = null;
1630
+ for (const m of source.matchAll(re)) {
1631
+ const start = m.index as number;
1632
+ const idAttr = attrSpanInTag(source, start, 'id');
1633
+ const id = idAttr?.valueText?.replace(/^["']|["']$/g, '') ?? null;
1634
+ const kindAttr = attrSpanInTag(source, start, 'kind');
1635
+ const kind = kindAttr?.valueText?.replace(/^["']|["']$/g, '') ?? null;
1636
+ if ((artboardId && id === artboardId) || (!artboardId && kind === 'video')) {
1637
+ if (artboardId && kind !== 'video') return null; // explicit target must opt in
1638
+ target = { start, tag: m[0] as string };
1639
+ break;
1640
+ }
1641
+ }
1642
+ if (!target) return null;
1643
+ const idAttr = attrSpanInTag(source, target.start, 'id');
1644
+ const targetId = idAttr?.valueText?.replace(/^["']|["']$/g, '') ?? '';
1645
+ const width = numAttrText(source, target.start, 'width') ?? 1280;
1646
+ const height = numAttrText(source, target.start, 'height') ?? 720;
1647
+ const closeIdx = source.indexOf('</DCArtboard>', target.start);
1648
+ if (closeIdx < 0) return null;
1649
+ // Unique component name.
1650
+ let compName = 'VideoCut';
1651
+ for (let i = 2; new RegExp(`\\b${compName}\\b`).test(source); i += 1) compName = `VideoCut${i}`;
1652
+ const indent = lineStartInfo(source, closeIdx).indent;
1653
+ const s = new MagicString(source);
1654
+ s.appendLeft(
1655
+ closeIdx,
1656
+ ` <VideoComp component={${compName}} durationInFrames={150} fps={30} width={${width}} height={${height}} />\n${indent}`
1657
+ );
1658
+ // The comp body — an AbsoluteFill root so the first storyline drop can
1659
+ // author its <TransitionSeries> inside (the greenfield insert contract).
1660
+ const exportM = source.match(/^export default function\b/m) ?? source.match(/^export default\b/m);
1661
+ const at = exportM?.index ?? 0;
1662
+ s.appendLeft(
1663
+ at,
1664
+ `const ${compName} = () => (\n <AbsoluteFill style={{ background: '#000' }}>\n </AbsoluteFill>\n);\n\n`
1665
+ );
1666
+ ensureNamedImport(s, source, 'VideoComp', '@maude/canvas-lib');
1667
+ ensureNamedImport(s, source, 'AbsoluteFill', 'remotion');
1668
+ const out = s.toString();
1669
+ assertParses(canvasAbsPath, out, targetId);
1670
+ return { source: out, artboardId: targetId };
1671
+ }
1672
+
1673
+ /**
1674
+ * Dogfood — edit the text a clip carries: a Title overlay's literal child or an
1675
+ * AIPlaceholder's `prompt`. Both are authored as a JSON-stringified expression
1676
+ * (`{"…"}`, DDR-150 P1), so ONE rewrite covers them: replace the clip's first
1677
+ * quoted-string expression with the new JSON literal.
1678
+ */
1679
+ export function applySetClipText(
1680
+ canvasAbsPath: string,
1681
+ source: string,
1682
+ artboardId: string | undefined,
1683
+ stableId: string,
1684
+ expectedHash: string | undefined,
1685
+ text: string
1686
+ ): { source: string } {
1687
+ const { clips } = enumerateClips(canvasAbsPath, source, artboardId);
1688
+ const { clip } = mustResolve(canvasAbsPath, clips, stableId, expectedHash);
1689
+ const t = String(text ?? '').slice(0, 2000);
1690
+ if (!t.trim()) {
1691
+ throw new CanvasEditError('text must not be empty', { canvas: canvasAbsPath, id: stableId });
1692
+ }
1693
+ const span = source.slice(clip.start, clip.end);
1694
+ const m = span.match(/\{"(?:[^"\\]|\\.)*"\}/);
1695
+ if (!m || m.index == null) {
1696
+ throw new CanvasEditError(
1697
+ 'this clip has no editable text literal (Title overlays and AI placeholders do)',
1698
+ { canvas: canvasAbsPath, id: stableId }
1699
+ );
1700
+ }
1701
+ const start = clip.start + m.index;
1702
+ const s = new MagicString(source);
1703
+ s.overwrite(start, start + m[0].length, `{${JSON.stringify(t)}}`);
1704
+ const out = s.toString();
1705
+ assertParses(canvasAbsPath, out, stableId);
1706
+ assertCompSemantics(canvasAbsPath, out);
1707
+ return { source: out };
1708
+ }
1709
+
1710
+ /**
1711
+ * Dogfood (2026-07-30) — vertical layer reorder. Standalone overlay clips are
1712
+ * SIBLINGS whose document order IS their paint order (later paints on top).
1713
+ * Move one to `toIndex` in that ladder (audio-kind standalones are excluded —
1714
+ * they have no z-order). One-pass span permutation, then reparse + gate.
1715
+ */
1716
+ export function applyReorderOverlayLayer(
1717
+ canvasAbsPath: string,
1718
+ source: string,
1719
+ artboardId: string | undefined,
1720
+ stableId: string,
1721
+ expectedHash: string | undefined,
1722
+ toIndex: number
1723
+ ): { source: string } {
1724
+ const { clips } = enumerateClips(canvasAbsPath, source, artboardId);
1725
+ const { clip } = mustResolve(canvasAbsPath, clips, stableId, expectedHash);
1726
+ if (isSeriesSeq(clip)) {
1727
+ throw new CanvasEditError('storyline beats reorder horizontally — drag the beat instead', {
1728
+ canvas: canvasAbsPath,
1729
+ id: stableId,
1730
+ });
1731
+ }
1732
+ const ladder = clips.filter(
1733
+ (c) => c.kind === 'sequence' && !isSeriesSeq(c) && c.mediaTag !== 'Audio'
1734
+ ) as ClipInfo[];
1735
+ const from = ladder.findIndex((c) => c.stableId === clip.stableId);
1736
+ if (from < 0) {
1737
+ throw new CanvasEditError('this clip has no layer order (audio clips stack freely)', {
1738
+ canvas: canvasAbsPath,
1739
+ id: stableId,
1740
+ });
1741
+ }
1742
+ const to = Math.max(0, Math.min(ladder.length - 1, Math.round(toIndex)));
1743
+ if (to === from) return { source };
1744
+ const order = ladder.map((_, k) => k);
1745
+ order.splice(from, 1);
1746
+ order.splice(to, 0, from);
1747
+ const texts = ladder.map((c) => source.slice(c.start, c.end));
1748
+ const s = new MagicString(source);
1749
+ order.forEach((srcIdx, slot) => {
1750
+ const at = ladder[slot] as ClipInfo;
1751
+ if (srcIdx !== slot) s.overwrite(at.start, at.end, texts[srcIdx] as string);
1752
+ });
1753
+ const out = s.toString();
1754
+ assertParses(canvasAbsPath, out, stableId);
1755
+ assertCompSemantics(canvasAbsPath, out);
1756
+ return { source: out };
1757
+ }
1758
+
1759
+ /** Graft trim/rate onto a freshly-inserted clip's media element. */
1760
+ function graftMediaAttrs(
1761
+ canvasAbsPath: string,
1762
+ source: string,
1763
+ artboardId: string | undefined,
1764
+ stableId: string | null,
1765
+ trim: number,
1766
+ rate: number
1767
+ ): string {
1768
+ if (!stableId || (trim === 0 && rate === 1)) return source;
1769
+ const after = enumerateClips(canvasAbsPath, source, artboardId);
1770
+ const clip = after.clips.find((c) => c.stableId === stableId);
1771
+ const mediaStart = clip ? directMediaTagStart(source, clip) : null;
1772
+ if (mediaStart == null) return source;
1773
+ const s = new MagicString(source);
1774
+ if (trim) setTagAttr(s, source, mediaStart, 'trimBefore', `{${trim}}`);
1775
+ if (rate !== 1) setTagAttr(s, source, mediaStart, 'playbackRate', `{${rate}}`);
1776
+ const out = s.toString();
1777
+ assertParses(canvasAbsPath, out, stableId);
1778
+ return out;
1779
+ }
1780
+
1781
+ /**
1782
+ * Layers model (dogfood) — move a storyline beat OUT into its own overlay
1783
+ * layer (a standalone `<Sequence from>` at the beat's absolute start), or a
1784
+ * standalone overlay clip INTO the storyline (appended as a beat). Media-
1785
+ * carrying clips only; timing (trimBefore/playbackRate) rides along.
1786
+ * Insert-first, then remove the original (found again by content hash), so
1787
+ * the "cannot remove the only clip" guard never blocks the move.
1788
+ */
1789
+ export function applyMoveClipToOverlay(
1790
+ canvasAbsPath: string,
1791
+ source: string,
1792
+ artboardId: string | undefined,
1793
+ stableId: string,
1794
+ expectedHash: string | undefined
1795
+ ): { source: string; stableId: string | null } {
1796
+ const { clips } = enumerateClips(canvasAbsPath, source, artboardId);
1797
+ const { clip } = mustResolve(canvasAbsPath, clips, stableId, expectedHash);
1798
+ if (!isSeriesSeq(clip)) {
1799
+ throw new CanvasEditError('already an overlay/standalone clip', {
1800
+ canvas: canvasAbsPath,
1801
+ id: stableId,
1802
+ });
1803
+ }
1804
+ const mediaStart = directMediaTagStart(source, clip);
1805
+ if (mediaStart == null || !clip.mediaSrc || clip.durationInFrames == null) {
1806
+ throw new CanvasEditError('only a media clip with a direct source can move between layers', {
1807
+ canvas: canvasAbsPath,
1808
+ id: stableId,
1809
+ });
1810
+ }
1811
+ const start = clipAbsoluteStart(source, canvasAbsPath, artboardId, stableId);
1812
+ const trim =
1813
+ numAttrText(source, mediaStart, 'trimBefore') ??
1814
+ numAttrText(source, mediaStart, 'startFrom') ??
1815
+ 0;
1816
+ const rate = numAttrText(source, mediaStart, 'playbackRate') ?? 1;
1817
+ const ins = applyInsertClipAt(canvasAbsPath, source, artboardId, {
1818
+ lane: 'overlay',
1819
+ from: start,
1820
+ durationInFrames: clip.durationInFrames,
1821
+ mediaTag: clip.mediaTag === 'Audio' ? 'Audio' : 'Video',
1822
+ src: clip.mediaSrc,
1823
+ });
1824
+ let out = graftMediaAttrs(canvasAbsPath, ins.source, artboardId, ins.stableId, trim, rate);
1825
+ const again = enumerateClips(canvasAbsPath, out, artboardId);
1826
+ const insertedHash = again.clips.find((c) => c.stableId === ins.stableId)?.contentHash ?? null;
1827
+ const original = again.clips.find(
1828
+ (c) => c.contentHash === clip.contentHash && c.stableId !== ins.stableId
1829
+ );
1830
+ if (original) {
1831
+ out = applyRemoveClipRippled(
1832
+ canvasAbsPath,
1833
+ out,
1834
+ artboardId,
1835
+ original.stableId,
1836
+ undefined
1837
+ ).source;
1838
+ }
1839
+ // The storyline shrank under the overlay — stretch the comp back to it.
1840
+ out = applyFitTotalToContent(canvasAbsPath, out, artboardId).source;
1841
+ assertCompSemantics(canvasAbsPath, out);
1842
+ // Positional ids shift when the original leaves — re-address the moved clip.
1843
+ const settled = insertedHash
1844
+ ? enumerateClips(canvasAbsPath, out, artboardId).clips.find(
1845
+ (c) => c.contentHash === insertedHash
1846
+ )
1847
+ : null;
1848
+ return { source: out, stableId: settled?.stableId ?? ins.stableId };
1849
+ }
1850
+
1851
+ export function applyMoveClipToStoryline(
1852
+ canvasAbsPath: string,
1853
+ source: string,
1854
+ artboardId: string | undefined,
1855
+ stableId: string,
1856
+ expectedHash: string | undefined
1857
+ ): { source: string; stableId: string | null } {
1858
+ const { clips } = enumerateClips(canvasAbsPath, source, artboardId);
1859
+ const { clip } = mustResolve(canvasAbsPath, clips, stableId, expectedHash);
1860
+ if (clip.tag !== 'Sequence') {
1861
+ throw new CanvasEditError('already a storyline beat', { canvas: canvasAbsPath, id: stableId });
1862
+ }
1863
+ const mediaStart = directMediaTagStart(source, clip);
1864
+ if (mediaStart == null || !clip.mediaSrc || clip.durationInFrames == null) {
1865
+ throw new CanvasEditError('only a media clip with a direct source can move between layers', {
1866
+ canvas: canvasAbsPath,
1867
+ id: stableId,
1868
+ });
1869
+ }
1870
+ const trim =
1871
+ numAttrText(source, mediaStart, 'trimBefore') ??
1872
+ numAttrText(source, mediaStart, 'startFrom') ??
1873
+ 0;
1874
+ const rate = numAttrText(source, mediaStart, 'playbackRate') ?? 1;
1875
+ const ins = applyInsertClipAt(canvasAbsPath, source, artboardId, {
1876
+ lane: 'storyline',
1877
+ durationInFrames: clip.durationInFrames,
1878
+ mediaTag: clip.mediaTag === 'Audio' ? 'Audio' : 'Video',
1879
+ src: clip.mediaSrc,
1880
+ });
1881
+ let out = graftMediaAttrs(canvasAbsPath, ins.source, artboardId, ins.stableId, trim, rate);
1882
+ const again = enumerateClips(canvasAbsPath, out, artboardId);
1883
+ const insertedHash = again.clips.find((c) => c.stableId === ins.stableId)?.contentHash ?? null;
1884
+ const original = again.clips.find(
1885
+ (c) => c.contentHash === clip.contentHash && c.stableId !== ins.stableId
1886
+ );
1887
+ if (original) {
1888
+ out = applyRemoveClipRippled(
1889
+ canvasAbsPath,
1890
+ out,
1891
+ artboardId,
1892
+ original.stableId,
1893
+ undefined
1894
+ ).source;
1895
+ }
1896
+ // Exact-fit the comp end (the storyline insert over-bumped while the overlay
1897
+ // still existed).
1898
+ out = applyFitTotalToContent(canvasAbsPath, out, artboardId).source;
1899
+ assertCompSemantics(canvasAbsPath, out);
1900
+ // Positional ids shift when the original leaves — re-address the moved clip.
1901
+ const settled = insertedHash
1902
+ ? enumerateClips(canvasAbsPath, out, artboardId).clips.find(
1903
+ (c) => c.contentHash === insertedHash
1904
+ )
1905
+ : null;
1906
+ return { source: out, stableId: settled?.stableId ?? ins.stableId };
1907
+ }
1908
+
1909
+ /**
1910
+ * "The timeline always reaches the last clip" (dogfood rule): compute the real
1911
+ * content end — storyline cursor end, every standalone clip's from+duration —
1912
+ * and set the comp duration to it exactly — the timeline always ends where
1913
+ * the last clip ends (grows AND shrinks with the content).
1914
+ */
1915
+ export function applyFitTotalToContent(
1916
+ canvasAbsPath: string,
1917
+ source: string,
1918
+ artboardId: string | undefined
1919
+ ): { source: string; total: number | null } {
1920
+ const cc = enumerateClips(canvasAbsPath, source, artboardId);
1921
+ if (!cc.compName || cc.durationInFrames == null) return { source, total: cc.durationInFrames };
1922
+ const consts = collectConsts(source);
1923
+ let cursor = 0;
1924
+ let maxEnd = 0;
1925
+ for (const c of cc.clips) {
1926
+ if (c.kind === 'transition') {
1927
+ cursor -= transitionDurationFrames(source, c, consts) ?? 0;
1928
+ continue;
1929
+ }
1930
+ const start = c.tag === 'Sequence' && c.from != null ? c.from : cursor;
1931
+ const end = start + (c.durationInFrames ?? 0);
1932
+ if (c.tag !== 'Sequence') cursor = end;
1933
+ if (end > maxEnd) maxEnd = end;
1934
+ }
1935
+ const delta = maxEnd - cc.durationInFrames;
1936
+ if (delta === 0 || maxEnd <= 0 || cc.clips.length === 0) {
1937
+ return { source, total: cc.durationInFrames };
1938
+ }
1939
+ const anchor = cc.clips.find((c) => c.kind === 'sequence');
1940
+ if (!anchor) return { source, total: cc.durationInFrames };
1941
+ const out = applyRippleAfterClip(canvasAbsPath, source, artboardId, anchor.stableId, delta, {
1942
+ shiftFroms: false,
1943
+ }).source;
1944
+ return { source: out, total: maxEnd };
1945
+ }
1946
+
1947
+ /** Run a pure source-op on disk (atomic write + cross-process lock). */
1948
+ export async function applyOnDisk<T extends { source: string }>(
1949
+ canvasAbsPath: string,
1950
+ fn: (source: string) => T
1951
+ ): Promise<T> {
1952
+ return withLock(canvasAbsPath, async () => {
1953
+ const file = Bun.file(canvasAbsPath);
1954
+ if (!(await file.exists())) {
1955
+ throw new CanvasEditError(`Canvas not found: ${canvasAbsPath}`, {
1956
+ canvas: canvasAbsPath,
1957
+ id: '',
1958
+ });
1959
+ }
1960
+ const source = await file.text();
1961
+ const next = fn(source);
1962
+ if (next.source === source) return next;
1963
+ const tmp = `${canvasAbsPath}.tmp.${Math.random().toString(36).slice(2, 10)}`;
1964
+ await Bun.write(tmp, next.source);
1965
+ const { rename } = await import('node:fs/promises');
1966
+ await rename(tmp, canvasAbsPath);
1967
+ return next;
1968
+ });
1969
+ }
1970
+
1971
+ /** Re-parse gate shared by ops in this module (parse-clean before semantics). */
1972
+ export function assertParses(canvasAbsPath: string, source: string, id: string): void {
1973
+ const parsed = parseSync(canvasAbsPath, source, { sourceType: 'module' });
1974
+ if (parsed.errors && parsed.errors.length > 0) {
1975
+ throw new CanvasEditError(
1976
+ `edit produced invalid source: ${parsed.errors[0]?.message ?? 'parse error'}`,
1977
+ { canvas: canvasAbsPath, id }
1978
+ );
1979
+ }
1980
+ }