@1agh/maude 0.39.1 → 0.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/README.md +1 -1
  2. package/apps/studio/ai-banner.tsx +2 -2
  3. package/apps/studio/annotations-context-toolbar.tsx +44 -1
  4. package/apps/studio/annotations-layer.tsx +306 -5
  5. package/apps/studio/annotations-model.ts +107 -6
  6. package/apps/studio/api.ts +1316 -67
  7. package/apps/studio/artboard-marquee.tsx +1 -1
  8. package/apps/studio/bin/_canvas-rects-playwright.mjs +55 -0
  9. package/apps/studio/bin/_canvas-rects-static.mjs +166 -0
  10. package/apps/studio/bin/_fetch-asset.mjs +556 -0
  11. package/apps/studio/bin/_html-playwright.mjs +9 -1
  12. package/apps/studio/bin/_pdf-playwright.mjs +8 -1
  13. package/apps/studio/bin/_png-playwright.mjs +8 -1
  14. package/apps/studio/bin/_pw-launch.mjs +54 -0
  15. package/apps/studio/bin/_svg-playwright.mjs +8 -1
  16. package/apps/studio/bin/_video-playwright.mjs +452 -0
  17. package/apps/studio/bin/annotate.mjs +576 -34
  18. package/apps/studio/bin/canvas-rects.sh +152 -0
  19. package/apps/studio/bin/fetch-asset.sh +34 -0
  20. package/apps/studio/bin/prep.sh +8 -1
  21. package/apps/studio/bin/read-annotations.mjs +138 -7
  22. package/apps/studio/bin/smoke.sh +42 -6
  23. package/apps/studio/build.ts +21 -0
  24. package/apps/studio/canvas-comment-mount.tsx +138 -4
  25. package/apps/studio/canvas-edit.ts +2625 -111
  26. package/apps/studio/canvas-lib.tsx +238 -2
  27. package/apps/studio/canvas-list-watch.ts +6 -2
  28. package/apps/studio/canvas-shell.tsx +514 -20
  29. package/apps/studio/client/app.jsx +2480 -52
  30. package/apps/studio/client/comments-overlay.css +130 -126
  31. package/apps/studio/client/github.js +15 -0
  32. package/apps/studio/client/panels/TimelinePanel.jsx +860 -0
  33. package/apps/studio/client/panels/timeline-parse.js +229 -0
  34. package/apps/studio/client/panels/timeline-snap.js +55 -0
  35. package/apps/studio/client/styles/3-shell-maude.css +155 -0
  36. package/apps/studio/comments-overlay.tsx +148 -41
  37. package/apps/studio/config.schema.json +14 -0
  38. package/apps/studio/context-menu.tsx +16 -6
  39. package/apps/studio/context.ts +113 -1
  40. package/apps/studio/contextual-toolbar.tsx +262 -4
  41. package/apps/studio/cursors-overlay.tsx +4 -4
  42. package/apps/studio/dist/client.bundle.js +89 -17
  43. package/apps/studio/dist/comment-mount.js +59 -1
  44. package/apps/studio/dist/runtime/.min-sizes.json +11 -1
  45. package/apps/studio/dist/runtime/@remotion_media.js +491 -0
  46. package/apps/studio/dist/runtime/@remotion_player.js +56 -0
  47. package/apps/studio/dist/runtime/@remotion_transitions.js +300 -0
  48. package/apps/studio/dist/runtime/@remotion_transitions_clock-wipe.js +1 -0
  49. package/apps/studio/dist/runtime/@remotion_transitions_fade.js +1 -0
  50. package/apps/studio/dist/runtime/@remotion_transitions_flip.js +1 -0
  51. package/apps/studio/dist/runtime/@remotion_transitions_none.js +1 -0
  52. package/apps/studio/dist/runtime/@remotion_transitions_slide.js +1 -0
  53. package/apps/studio/dist/runtime/@remotion_transitions_wipe.js +1 -0
  54. package/apps/studio/dist/runtime/REMOTION-LICENSE.md +28 -0
  55. package/apps/studio/dist/runtime/remotion.js +42 -0
  56. package/apps/studio/dist/styles.css +1 -1
  57. package/apps/studio/dom-selection.ts +127 -1
  58. package/apps/studio/drag-state.ts +24 -0
  59. package/apps/studio/equal-spacing-detector.ts +205 -0
  60. package/apps/studio/export-dialog.tsx +1 -1
  61. package/apps/studio/exporters/_browser-bundles.ts +117 -0
  62. package/apps/studio/exporters/_runtime.ts +69 -0
  63. package/apps/studio/exporters/html.ts +4 -3
  64. package/apps/studio/exporters/index.ts +28 -2
  65. package/apps/studio/exporters/pdf.ts +4 -3
  66. package/apps/studio/exporters/png.ts +5 -3
  67. package/apps/studio/exporters/pptx.ts +6 -4
  68. package/apps/studio/exporters/svg.ts +9 -5
  69. package/apps/studio/exporters/video-encode-lib.ts +200 -0
  70. package/apps/studio/exporters/video-render-lib.ts +108 -0
  71. package/apps/studio/exporters/video.ts +184 -0
  72. package/apps/studio/history.ts +47 -1
  73. package/apps/studio/http.ts +758 -27
  74. package/apps/studio/input-router.tsx +19 -1
  75. package/apps/studio/marquee-overlay.tsx +1 -1
  76. package/apps/studio/measure-overlay.tsx +241 -0
  77. package/apps/studio/participants-chrome.tsx +3 -3
  78. package/apps/studio/runtime-bundle.ts +30 -0
  79. package/apps/studio/server.ts +34 -9
  80. package/apps/studio/sizing-mode.ts +117 -0
  81. package/apps/studio/spacing-handles.ts +166 -0
  82. package/apps/studio/test/annotate-write.test.ts +890 -0
  83. package/apps/studio/test/annotations-roundtrip.test.ts +47 -0
  84. package/apps/studio/test/camera-reveal.test.tsx +173 -0
  85. package/apps/studio/test/canvas-create-api.test.ts +76 -0
  86. package/apps/studio/test/canvas-edit.test.ts +140 -0
  87. package/apps/studio/test/canvas-list-watch.test.ts +49 -0
  88. package/apps/studio/test/canvas-media-drop.test.ts +30 -1
  89. package/apps/studio/test/canvas-origin-gate.test.ts +54 -0
  90. package/apps/studio/test/canvas-rects.test.ts +198 -0
  91. package/apps/studio/test/clip-addressing.test.ts +732 -0
  92. package/apps/studio/test/comments-overlay.test.ts +117 -0
  93. package/apps/studio/test/config-reload.test.ts +230 -0
  94. package/apps/studio/test/dns-rebinding-guard.test.ts +74 -0
  95. package/apps/studio/test/dom-selection.test.ts +130 -0
  96. package/apps/studio/test/edit-css-occurrence.test.ts +81 -0
  97. package/apps/studio/test/edit-persistence.test.ts +91 -0
  98. package/apps/studio/test/edit-scope-api.test.ts +115 -0
  99. package/apps/studio/test/element-resize.test.ts +136 -0
  100. package/apps/studio/test/element-structural-api.test.ts +360 -0
  101. package/apps/studio/test/element-structural-edit.test.ts +233 -0
  102. package/apps/studio/test/equal-spacing-detector.test.ts +165 -1
  103. package/apps/studio/test/exporters/runtime.test.ts +59 -0
  104. package/apps/studio/test/file-lock.test.ts +84 -0
  105. package/apps/studio/test/fixtures/video-comp-fixture.tsx +82 -0
  106. package/apps/studio/test/history-rollback.test.ts +26 -0
  107. package/apps/studio/test/knob-props-authored.test.ts +87 -0
  108. package/apps/studio/test/read-annotations.test.ts +154 -0
  109. package/apps/studio/test/sizing-mode.test.ts +102 -0
  110. package/apps/studio/test/spacing-handles.test.ts +138 -0
  111. package/apps/studio/test/specimen-select.test.ts +88 -0
  112. package/apps/studio/test/timeline-parse.test.ts +127 -0
  113. package/apps/studio/test/timeline-snap.test.ts +85 -0
  114. package/apps/studio/test/tool-palette-insert-anchor.test.ts +84 -0
  115. package/apps/studio/test/undo-sequence-byte-compare.test.ts +211 -0
  116. package/apps/studio/test/video-asset.test.ts +163 -0
  117. package/apps/studio/test/video-comp-fixture.test.ts +50 -0
  118. package/apps/studio/test/video-comp.test.ts +168 -0
  119. package/apps/studio/test/video-render-bridge.test.ts +149 -0
  120. package/apps/studio/tool-palette.tsx +122 -2
  121. package/apps/studio/undo-hud.tsx +2 -2
  122. package/apps/studio/use-annotation-resize.tsx +6 -3
  123. package/apps/studio/use-canvas-media-drop.tsx +66 -4
  124. package/apps/studio/use-element-resize.tsx +732 -0
  125. package/apps/studio/use-keyboard-discipline.tsx +205 -15
  126. package/apps/studio/use-selection-set.tsx +14 -0
  127. package/apps/studio/use-spacing-handles.tsx +388 -0
  128. package/apps/studio/video-comp.tsx +444 -0
  129. package/apps/studio/whats-new.json +55 -0
  130. package/apps/studio/ws.ts +5 -0
  131. package/cli/commands/design.mjs +6 -1
  132. package/cli/commands/design.test.mjs +49 -1
  133. package/cli/lib/fetch-asset.test.mjs +213 -0
  134. package/package.json +8 -8
  135. package/plugins/design/dependencies.json +10 -2
  136. package/plugins/design/templates/_shell.html +25 -2
  137. package/plugins/design/templates/design-system-inspiration/_MAPPING.md +1 -1
@@ -27,6 +27,10 @@
27
27
  // (matches the locator.ts pattern). Two parallel edits against different
28
28
  // canvases run in parallel.
29
29
 
30
+ import { mkdir, open, stat, unlink } from 'node:fs/promises';
31
+ import { tmpdir } from 'node:os';
32
+ import path from 'node:path';
33
+
30
34
  import MagicString from 'magic-string';
31
35
  import { parseSync } from 'oxc-parser';
32
36
 
@@ -144,8 +148,67 @@ function findAttribute(opening: AnyNode, name: string): AnyNode | null {
144
148
  }
145
149
 
146
150
  // ---------------------------------------------------------------------------
147
- // Per-canvas mutex. Mirrors locator.ts; two concurrent edits against the same
148
- // .tsx file serialise so they can't race read-modify-write.
151
+ // Per-canvas mutex TWO layers (DDR-150 P2). (1) An in-process Promise chain
152
+ // serialises edits within THIS process (fast). (2) A cross-process advisory
153
+ // lockfile serialises against edits from ANOTHER process — the `/design:edit`
154
+ // CLI (`import.meta.main` below) or the HMR file-watcher — so their
155
+ // read-modify-write can't interleave with ours and lose an update (the in-
156
+ // process `locks` Map alone couldn't see them). The lockfile lives in the OS
157
+ // temp dir (NOT the versioned design root — never touches the gitignore
158
+ // taxonomy) keyed by the canvas absolute path. A crashed holder leaves a STALE
159
+ // lock, stolen after LOCK_STALE_MS; if it stays contended past LOCK_MAX_WAIT_MS
160
+ // we proceed anyway rather than deadlock — the atomic tmp-rename write + the
161
+ // content-hash fingerprint are the backstop against a truly simultaneous writer.
162
+
163
+ const LOCK_DIR = path.join(tmpdir(), 'maude-locks');
164
+ const LOCK_STALE_MS = 15_000;
165
+ const LOCK_POLL_MS = 25;
166
+ const LOCK_MAX_WAIT_MS = 10_000;
167
+
168
+ /** OS-temp lockfile path for a canvas (shared across processes; keyed by abs path). */
169
+ export function lockPathFor(filePath: string): string {
170
+ return path.join(LOCK_DIR, `${Bun.hash(filePath).toString(16).padStart(16, '0')}.lock`);
171
+ }
172
+
173
+ /**
174
+ * Acquire the cross-process advisory lock for `filePath`; resolves to an
175
+ * idempotent release fn. Exported for tests. Degrades to a no-op lock (rather
176
+ * than breaking every edit) if the temp dir is unwritable or contention outlasts
177
+ * LOCK_MAX_WAIT_MS.
178
+ */
179
+ export async function acquireFileLock(filePath: string): Promise<() => Promise<void>> {
180
+ const lp = lockPathFor(filePath);
181
+ await mkdir(LOCK_DIR, { recursive: true }).catch(() => {});
182
+ const start = Date.now();
183
+ for (;;) {
184
+ try {
185
+ const fh = await open(lp, 'wx'); // O_CREAT | O_EXCL — fails if already held
186
+ await fh.writeFile(`${process.pid} ${Date.now()}`);
187
+ await fh.close();
188
+ let released = false;
189
+ return async () => {
190
+ if (released) return;
191
+ released = true;
192
+ await unlink(lp).catch(() => {});
193
+ };
194
+ } catch (err) {
195
+ if ((err as NodeJS.ErrnoException).code !== 'EEXIST') {
196
+ return async () => {}; // can't create a lockfile at all → in-process-only
197
+ }
198
+ try {
199
+ const st = await stat(lp);
200
+ if (Date.now() - st.mtimeMs > LOCK_STALE_MS) {
201
+ await unlink(lp).catch(() => {}); // holder crashed — steal
202
+ continue;
203
+ }
204
+ } catch {
205
+ continue; // vanished between EEXIST and stat — retry the create
206
+ }
207
+ if (Date.now() - start > LOCK_MAX_WAIT_MS) return async () => {}; // don't deadlock
208
+ await new Promise((r) => setTimeout(r, LOCK_POLL_MS));
209
+ }
210
+ }
211
+ }
149
212
 
150
213
  const locks = new Map<string, Promise<void>>();
151
214
  function withLock<T>(filePath: string, fn: () => Promise<T>): Promise<T> {
@@ -156,10 +219,21 @@ function withLock<T>(filePath: string, fn: () => Promise<T>): Promise<T> {
156
219
  });
157
220
  const next = prev.then(() => gate);
158
221
  locks.set(filePath, next);
159
- return prev.then(fn).finally(() => {
160
- release();
161
- if (locks.get(filePath) === next) locks.delete(filePath);
162
- });
222
+ return prev
223
+ .then(async () => {
224
+ // In-process calls are already serialised by the chain above, so only ONE
225
+ // local call ever contends the lockfile — cross-process is its only job.
226
+ const releaseFile = await acquireFileLock(filePath);
227
+ try {
228
+ return await fn();
229
+ } finally {
230
+ await releaseFile();
231
+ }
232
+ })
233
+ .finally(() => {
234
+ release();
235
+ if (locks.get(filePath) === next) locks.delete(filePath);
236
+ });
163
237
  }
164
238
 
165
239
  // ---------------------------------------------------------------------------
@@ -188,7 +262,8 @@ export async function editAttribute(
188
262
  canvasAbsPath: string,
189
263
  id: string,
190
264
  attr: string,
191
- value: string
265
+ value: string,
266
+ occurrence?: number
192
267
  ): Promise<EditResult> {
193
268
  return withLock(canvasAbsPath, async () => {
194
269
  const file = Bun.file(canvasAbsPath);
@@ -199,7 +274,7 @@ export async function editAttribute(
199
274
  });
200
275
  }
201
276
  const source = await file.text();
202
- const next = applyEdit(canvasAbsPath, source, id, attr, value);
277
+ const next = applyEdit(canvasAbsPath, source, id, attr, value, occurrence);
203
278
  if (next.source === source) return { source, delta: 0, changed: false };
204
279
  const tmp = `${canvasAbsPath}.tmp.${Math.random().toString(36).slice(2, 10)}`;
205
280
  await Bun.write(tmp, next.source);
@@ -220,7 +295,8 @@ export async function editAttribute(
220
295
  export async function removeAttribute(
221
296
  canvasAbsPath: string,
222
297
  id: string,
223
- attr: string
298
+ attr: string,
299
+ occurrence?: number
224
300
  ): Promise<EditResult> {
225
301
  return withLock(canvasAbsPath, async () => {
226
302
  const file = Bun.file(canvasAbsPath);
@@ -231,7 +307,7 @@ export async function removeAttribute(
231
307
  });
232
308
  }
233
309
  const source = await file.text();
234
- const next = applyRemove(canvasAbsPath, source, id, attr);
310
+ const next = applyRemove(canvasAbsPath, source, id, attr, occurrence);
235
311
  if (next.source === source) return { source, delta: 0, changed: false };
236
312
  const tmp = `${canvasAbsPath}.tmp.${Math.random().toString(36).slice(2, 10)}`;
237
313
  await Bun.write(tmp, next.source);
@@ -246,7 +322,8 @@ export function applyRemove(
246
322
  canvasAbsPath: string,
247
323
  source: string,
248
324
  id: string,
249
- attr: string
325
+ attr: string,
326
+ occurrence?: number
250
327
  ): EditResult {
251
328
  const parsed = parseSync(canvasAbsPath, source, { sourceType: 'module' });
252
329
  if (parsed.errors && parsed.errors.length > 0) {
@@ -259,6 +336,11 @@ export function applyRemove(
259
336
  }
260
337
  );
261
338
  }
339
+ // Stage H3 — mirror applyEdit: a whole-instance reset routes to the dragged
340
+ // occurrence's `<Component/>` usage (no-op for a normal / single-usage element).
341
+ if (typeof occurrence === 'number' && Number.isFinite(occurrence)) {
342
+ id = resolveUsageId(parsed.program, id, occurrence);
343
+ }
262
344
  const hit = findOpening(parsed.program, id);
263
345
  if (!hit) {
264
346
  throw new CanvasEditError(`data-cd-id "${id}" not found in ${canvasAbsPath}`, {
@@ -324,7 +406,8 @@ export function applyEdit(
324
406
  source: string,
325
407
  id: string,
326
408
  attr: string,
327
- value: string
409
+ value: string,
410
+ occurrence?: number
328
411
  ): EditResult {
329
412
  const parsed = parseSync(canvasAbsPath, source, { sourceType: 'module' });
330
413
  if (parsed.errors && parsed.errors.length > 0) {
@@ -335,6 +418,19 @@ export function applyEdit(
335
418
  );
336
419
  }
337
420
 
421
+ // feature-element-editing-robustness Stage H3 — when the caller passes an
422
+ // explicit DOM-occurrence index (a whole-component-instance move/resize), route
423
+ // the write to that occurrence's parent `<Component/>` USAGE so the edit stays
424
+ // LOCAL to the dragged instance (its own left/top/width/height) instead of
425
+ // mutating the shared inner definition (which would move every instance). A
426
+ // no-op for a normal element or a `.map()`ed single-usage one (resolveUsageId
427
+ // returns `id`). Deliberately gated on `occurrence` being present: the CssKnobs
428
+ // / paste-style paths pass NO occurrence, so styling an INNER shared element
429
+ // stays global-and-labeled — the H2 badge is the answer there (the chosen model).
430
+ if (typeof occurrence === 'number' && Number.isFinite(occurrence)) {
431
+ id = resolveUsageId(parsed.program, id, occurrence);
432
+ }
433
+
338
434
  const hit = findOpening(parsed.program, id);
339
435
  if (!hit) {
340
436
  throw new CanvasEditError(`data-cd-id "${id}" not found in ${canvasAbsPath}`, {
@@ -352,7 +448,7 @@ export function applyEdit(
352
448
  id,
353
449
  });
354
450
  } else {
355
- editStringAttr(s, hit.opening, attr, value);
451
+ editStringAttr(s, hit.opening, attr, value, canvasAbsPath, id);
356
452
  }
357
453
 
358
454
  const out = s.toString();
@@ -402,6 +498,30 @@ export function applyTextEdit(
402
498
  });
403
499
  }
404
500
  const only = meaningful[0];
501
+ // A single `{'string literal'}` expression child — `<h1>{'Title'}</h1>` — is
502
+ // editable (DDR-150 P1): rewrite the literal in place. Written back via
503
+ // JSON.stringify so the result is an inert, correctly-escaped quoted string —
504
+ // the value never leaves the `{...}`, so (unlike JSXText) there is no markup /
505
+ // entity injection surface to guard. Any OTHER expression (identifier,
506
+ // template, call, member — `{title}`, `` {`${n} items`} ``) is genuinely
507
+ // dynamic: refuse and route to /design:edit rather than delete the binding.
508
+ if (meaningful.length === 1 && only?.type === 'JSXExpressionContainer') {
509
+ const expr = (only as AnyNode).expression;
510
+ if (
511
+ expr &&
512
+ (expr.type === 'Literal' || expr.type === 'StringLiteral') &&
513
+ typeof expr.value === 'string'
514
+ ) {
515
+ const s = new MagicString(source);
516
+ s.overwrite(expr.start as number, expr.end as number, JSON.stringify(text));
517
+ const out = s.toString();
518
+ return { source: out, delta: out.length - source.length };
519
+ }
520
+ throw new CanvasEditError(`element "${id}" has dynamic content — edit it via /design:edit`, {
521
+ canvas: canvasAbsPath,
522
+ id,
523
+ });
524
+ }
405
525
  if (meaningful.length > 1 || only?.type !== 'JSXText') {
406
526
  throw new CanvasEditError(
407
527
  `element "${id}" has mixed or expression content — edit it via /design:edit`,
@@ -648,6 +768,74 @@ function resolveUsageId(
648
768
  return usages[i]?.id ?? domId;
649
769
  }
650
770
 
771
+ /**
772
+ * Edit-scope verdict for the INV-3 predictability badge (feature-element-editing-
773
+ * robustness Stage H). Tells the Inspector whether a style/attr edit to `domId`
774
+ * stays LOCAL (this one rendered place) or is SHARED (changes N places).
775
+ */
776
+ export interface EditScope {
777
+ scope: 'local' | 'shared';
778
+ /** Enclosing reused-component name when the element lives inside one, else null. */
779
+ componentName: string | null;
780
+ /** How many rendered places an edit to this element touches. */
781
+ affects: number;
782
+ /** single = a lone element · component = inside an N-usage component · mapped =
783
+ * one source element rendered N× via `.map()` (DDR-139 §1). */
784
+ reason: 'single' | 'component' | 'mapped';
785
+ }
786
+
787
+ /**
788
+ * Resolve whether an edit to `domId` is local or shared — composing the SAME
789
+ * primitives `resolveUsageId` ships: the element's enclosing `componentName` plus
790
+ * the source usage count of that component. `renderedCount` is the number of DOM
791
+ * nodes carrying this cd-id (the client knows it): a single source element
792
+ * rendered N× through `.map()` is `shared` ('mapped') even with one source usage,
793
+ * so the badge never lies. A parse failure degrades to `local` (a badge must
794
+ * never crash selection). Pure — unit-tested without a DOM.
795
+ */
796
+ export function resolveEditScope(
797
+ canvasAbsPath: string,
798
+ source: string,
799
+ domId: string,
800
+ renderedCount = 1
801
+ ): EditScope {
802
+ const rendered =
803
+ Number.isFinite(renderedCount) && renderedCount > 0 ? Math.floor(renderedCount) : 1;
804
+ let program: AnyNode;
805
+ try {
806
+ const parsed = parseSync(canvasAbsPath, source, { sourceType: 'module' });
807
+ if (parsed.errors && parsed.errors.length > 0) {
808
+ return { scope: 'local', componentName: null, affects: 1, reason: 'single' };
809
+ }
810
+ program = parsed.program;
811
+ } catch {
812
+ return { scope: 'local', componentName: null, affects: 1, reason: 'single' };
813
+ }
814
+ const all = collectElementsFull(program);
815
+ const target = all.find((e) => e.id === domId);
816
+ const compName = target?.componentName || '';
817
+ // Usages of the enclosing component = distinct `<Component/>` tags in the tree.
818
+ // 0 for a top-level artboard element (the Canvas fn is never `<Canvas/>`'d).
819
+ const usages = compName ? all.filter((e) => e.tag === compName).length : 0;
820
+ if (usages > 1) {
821
+ return {
822
+ scope: 'shared',
823
+ componentName: compName,
824
+ affects: Math.max(usages, rendered),
825
+ reason: 'component',
826
+ };
827
+ }
828
+ if (rendered > 1) {
829
+ return {
830
+ scope: 'shared',
831
+ componentName: compName || null,
832
+ affects: rendered,
833
+ reason: 'mapped',
834
+ };
835
+ }
836
+ return { scope: 'local', componentName: null, affects: 1, reason: 'single' };
837
+ }
838
+
651
839
  /**
652
840
  * Move the element with `data-cd-id === id` to a position relative to the element
653
841
  * with `data-cd-id === refId`. Async wrapper: read, apply, atomic write under the
@@ -831,131 +1019,2457 @@ export function applyMove(
831
1019
  }
832
1020
 
833
1021
  // ---------------------------------------------------------------------------
834
- // Edit shapes.
1022
+ // DDR-148 — Timeline drag-to-retime. Rewrites a `<...Sequence>`'s
1023
+ // `durationInFrames` / `from` to a new frame count. Sequences are addressed by
1024
+ // their document ORDER (the same order timeline-parse.js tokenizes them), not a
1025
+ // data-cd-id — a member-expression element (`TransitionSeries.Sequence`) has no
1026
+ // stable cd-id, and the order is what the Timeline UI already knows.
1027
+
1028
+ export interface RetimePatch {
1029
+ durationInFrames?: number;
1030
+ from?: number;
1031
+ }
835
1032
 
836
- function editStringAttr(s: MagicString, opening: AnyNode, name: string, value: string): void {
837
- const attr = findAttribute(opening, name);
838
- if (attr) {
839
- // Replace existing value. JSX attribute value forms we handle:
840
- // - <Tag name="literal" /> replace inside the quotes
841
- // - <Tag name={'literal'} /> → wrap quotes around new value
842
- // - <Tag name={expr} /> → replace the whole expression text
843
- // - <Tag name /> → no value node; add `="..."`
844
- const v = attr.value;
845
- if (!v) {
846
- // <Tag name /> → <Tag name="value" />
847
- s.appendLeft(attr.end, `="${escapeAttr(value)}"`);
848
- return;
1033
+ const SEQ_TAG_RE = /<(?:TransitionSeries\.Sequence|Series\.Sequence|Sequence)\b[^>]*>/g;
1034
+
1035
+ /**
1036
+ * Rewrite one attribute on a sequence tag. Prefers editing a referenced const
1037
+ * (`durationInFrames={A}`bump `const A = …`) so a derived total
1038
+ * (`const TOTAL = A + B - XF`) updates in lock-step; falls back to editing a
1039
+ * literal in place; refuses a non-trivial expression (returns false).
1040
+ */
1041
+ function retimeAttr(
1042
+ s: MagicString,
1043
+ source: string,
1044
+ tag: string,
1045
+ tagStart: number,
1046
+ key: 'durationInFrames' | 'from',
1047
+ newVal: number
1048
+ ): boolean {
1049
+ const am = tag.match(new RegExp(`\\b${key}=\\{\\s*([^}]*?)\\s*\\}`));
1050
+ if (!am || am.index == null) {
1051
+ // Attr absent. For `from`, INSERT it — moving a cursor-implicit clip
1052
+ // (`<Sequence durationInFrames={…}>`) to a new start needs an explicit
1053
+ // `from` (DDR-150 P3 Task 6). durationInFrames is required on a clip, so
1054
+ // never auto-insert it.
1055
+ if (key === 'from') {
1056
+ const nameMatch = tag.match(/^<([A-Za-z][\w.]*)/);
1057
+ if (nameMatch) {
1058
+ s.appendLeft(tagStart + nameMatch[0].length, ` from={${newVal}}`);
1059
+ return true;
1060
+ }
849
1061
  }
850
- if (v.type === 'Literal' || v.type === 'StringLiteral') {
851
- // Replace the whole `"value"` (quotes included) so we control the escaping.
852
- // The value lands in a JSX *attribute*, where `"` must become `&quot;` and
853
- // `<`/`>` their entities — NOT JS backslash escaping. `JSON.stringify` would
854
- // emit `\"`, which is invalid in a JSX attribute and corrupts the source on
855
- // any value containing a double quote. Use the same `escapeAttr` as the two
856
- // insert branches so all four paths agree. See DDR-103 / DDR-105.
857
- s.overwrite(v.start, v.end, `"${escapeAttr(value)}"`);
858
- return;
1062
+ return false;
1063
+ }
1064
+ const inner = am[1].trim();
1065
+ if (/^[A-Za-z_$][\w$]*$/.test(inner)) {
1066
+ const cm = source.match(new RegExp(`\\bconst\\s+${inner}\\s*=\\s*(-?\\d+)`));
1067
+ if (cm && cm.index != null && cm[1]) {
1068
+ const numStart = cm.index + cm[0].lastIndexOf(cm[1]);
1069
+ s.overwrite(numStart, numStart + cm[1].length, String(newVal));
1070
+ return true;
859
1071
  }
860
- if (v.type === 'JSXExpressionContainer') {
861
- // Replace the whole `{...}` with a plain quoted literal — keeps the
862
- // resulting JSX readable. Same JSX-attribute escaping as above (NOT
863
- // `JSON.stringify`, which would JS-escape a `"` and corrupt the source).
864
- s.overwrite(v.start, v.end, `"${escapeAttr(value)}"`);
865
- return;
1072
+ }
1073
+ if (/^-?\d+$/.test(inner)) {
1074
+ const innerRel = am[0].indexOf(inner, am[0].indexOf('{'));
1075
+ const valStart = tagStart + am.index + innerRel;
1076
+ s.overwrite(valStart, valStart + inner.length, String(newVal));
1077
+ return true;
1078
+ }
1079
+ return false;
1080
+ }
1081
+
1082
+ /** Pure retime — exposed for tests. Never mutates disk. */
1083
+ export function applyRetimeSequence(
1084
+ canvasAbsPath: string,
1085
+ source: string,
1086
+ seqIndex: number,
1087
+ patch: RetimePatch
1088
+ ): { source: string } {
1089
+ const s = new MagicString(source);
1090
+ SEQ_TAG_RE.lastIndex = 0;
1091
+ let i = 0;
1092
+ let touched = false;
1093
+ let m: RegExpExecArray | null = SEQ_TAG_RE.exec(source);
1094
+ while (m) {
1095
+ if (i === seqIndex) {
1096
+ const tag = m[0];
1097
+ const tagStart = m.index;
1098
+ for (const [key, val] of [
1099
+ ['durationInFrames', patch.durationInFrames],
1100
+ ['from', patch.from],
1101
+ ] as const) {
1102
+ if (val == null || !Number.isFinite(val)) continue;
1103
+ if (retimeAttr(s, source, tag, tagStart, key, Math.max(0, Math.round(val)))) touched = true;
1104
+ }
1105
+ break;
866
1106
  }
867
- // Unknown shape — refuse rather than corrupt.
868
- throw new Error(`Unsupported JSX attribute value shape: ${v.type}`);
1107
+ i += 1;
1108
+ m = SEQ_TAG_RE.exec(source);
869
1109
  }
870
- // Attribute missing — insert right after the tag name (mirrors pipeline's
871
- // injection point so attribute order stays predictable).
872
- const insertAt: number | undefined = opening?.name?.end;
873
- if (typeof insertAt !== 'number') {
874
- throw new Error('Opening element has no resolvable name range');
1110
+ if (!touched) {
1111
+ throw new CanvasEditError(`no retimable sequence at index ${seqIndex}`, {
1112
+ canvas: canvasAbsPath,
1113
+ id: String(seqIndex),
1114
+ });
875
1115
  }
876
- s.appendLeft(insertAt, ` ${name}="${escapeAttr(value)}"`);
1116
+ const next = s.toString();
1117
+ const parsed = parseSync(canvasAbsPath, next, { sourceType: 'module' });
1118
+ if (parsed.errors && parsed.errors.length > 0) {
1119
+ throw new CanvasEditError(
1120
+ `retime produced invalid source: ${parsed.errors[0]?.message ?? 'parse error'}`,
1121
+ { canvas: canvasAbsPath, id: String(seqIndex) }
1122
+ );
1123
+ }
1124
+ return { source: next };
877
1125
  }
878
1126
 
879
- function escapeAttr(value: string): string {
880
- return value.replace(/"/g, '&quot;').replace(/[<>]/g, (c) => (c === '<' ? '&lt;' : '&gt;'));
1127
+ /** Retime a sequence on disk (atomic write + per-file lock, like moveElement). */
1128
+ export async function retimeSequence(
1129
+ canvasAbsPath: string,
1130
+ seqIndex: number,
1131
+ patch: RetimePatch
1132
+ ): Promise<{ source: string }> {
1133
+ return withLock(canvasAbsPath, async () => {
1134
+ const file = Bun.file(canvasAbsPath);
1135
+ if (!(await file.exists())) {
1136
+ throw new CanvasEditError(`Canvas not found: ${canvasAbsPath}`, {
1137
+ canvas: canvasAbsPath,
1138
+ id: String(seqIndex),
1139
+ });
1140
+ }
1141
+ const source = await file.text();
1142
+ const next = applyRetimeSequence(canvasAbsPath, source, seqIndex, patch);
1143
+ if (next.source === source) return next;
1144
+ const tmp = `${canvasAbsPath}.tmp.${Math.random().toString(36).slice(2, 10)}`;
1145
+ await Bun.write(tmp, next.source);
1146
+ const { rename } = await import('node:fs/promises');
1147
+ await rename(tmp, canvasAbsPath);
1148
+ return next;
1149
+ });
881
1150
  }
882
1151
 
883
1152
  /**
884
- * Escape JSX-significant characters so user-typed text lands in source `.tsx`
885
- * as inert TEXT, never as markup or an expression. The text is written between
886
- * an element's tags (`<button>HERE</button>`), where `<`/`>` would start a tag
887
- * and `{`/`}` would open a JSX expression — so all four (plus a bare `&`, which
888
- * begins an entity) are encoded. This is the load-bearing guard that keeps the
889
- * inline text editor (Phase 12) from being a source-injection vector. See
890
- * DDR-103.
1153
+ * Retime a clip addressed by the enumerator's `stableId` (comp-scoped) instead of
1154
+ * a whole-file index the DDR-150 P2 fix for the multi-comp mis-hit. Verifies
1155
+ * the content-hash fingerprint (refuse a stale/raced target), patches the clip's
1156
+ * own tag via `retimeAttr` (const-preferring), then reparse + semantic gate.
891
1157
  */
892
- function escapeJsxText(value: string): string {
893
- return value
894
- .replace(/&/g, '&amp;')
895
- .replace(/</g, '&lt;')
896
- .replace(/>/g, '&gt;')
897
- .replace(/\{/g, '&#123;')
898
- .replace(/\}/g, '&#125;');
899
- }
900
-
901
- function editStyleProp(
902
- s: MagicString,
903
- opening: AnyNode,
904
- prop: string,
905
- value: string,
1158
+ export function applyRetimeSequenceByClip(
906
1159
  canvasAbsPath: string,
907
- id: string
908
- ): void {
909
- const attr = findAttribute(opening, 'style');
910
- if (!attr) {
911
- // No style prop yet — insert one with a single key.
912
- const insertAt: number | undefined = opening?.name?.end;
913
- if (typeof insertAt !== 'number') {
914
- throw new Error('Opening element has no resolvable name range');
1160
+ source: string,
1161
+ artboardId: string | undefined,
1162
+ stableId: string,
1163
+ expectedHash: string | undefined,
1164
+ patch: RetimePatch
1165
+ ): { source: string } {
1166
+ const clip = resolveClip(canvasAbsPath, source, artboardId, stableId, expectedHash);
1167
+ // A `from` move is STANDALONE-<Sequence>-only. `<TransitionSeries.Sequence>` /
1168
+ // `<Series.Sequence>` compute their own offsets — Remotion silently IGNORES a
1169
+ // `from` prop on them, so patching/inserting one writes a lie: the timeline
1170
+ // draws a gap while the rendered video never changes (the dogfood bug —
1171
+ // "video je furt stejné, ať klipem pohnu jakkoliv"). Refuse loudly instead.
1172
+ if (patch.from != null && clip.tag !== 'Sequence') {
1173
+ throw new CanvasEditError(
1174
+ `"${stableId}" is a ${clip.tag} — the series computes its position, so moving it has no effect. Trim its duration instead, or reorder the beats.`,
1175
+ { canvas: canvasAbsPath, id: stableId }
1176
+ );
1177
+ }
1178
+ const s = new MagicString(source);
1179
+ SEQ_TAG_RE.lastIndex = 0;
1180
+ let touched = false;
1181
+ let m: RegExpExecArray | null = SEQ_TAG_RE.exec(source);
1182
+ while (m) {
1183
+ if (m.index === clip.start) {
1184
+ for (const [key, val] of [
1185
+ ['durationInFrames', patch.durationInFrames],
1186
+ ['from', patch.from],
1187
+ ] as const) {
1188
+ if (val == null || !Number.isFinite(val)) continue;
1189
+ if (retimeAttr(s, source, m[0], m.index, key, Math.max(0, Math.round(val)))) touched = true;
1190
+ }
1191
+ break;
915
1192
  }
916
- s.appendLeft(insertAt, ` style={{ ${jsKey(prop)}: ${value} }}`);
917
- return;
1193
+ m = SEQ_TAG_RE.exec(source);
918
1194
  }
919
- const v = attr.value;
920
- if (v?.type !== 'JSXExpressionContainer') {
1195
+ if (!touched) {
1196
+ throw new CanvasEditError(`clip "${stableId}" has no retimable from/durationInFrames`, {
1197
+ canvas: canvasAbsPath,
1198
+ id: stableId,
1199
+ });
1200
+ }
1201
+ const next = s.toString();
1202
+ const parsed = parseSync(canvasAbsPath, next, { sourceType: 'module' });
1203
+ if (parsed.errors && parsed.errors.length > 0) {
921
1204
  throw new CanvasEditError(
922
- `style attribute on ${id} is not a {{...}} expression — refusing to edit`,
923
- { canvas: canvasAbsPath, id }
1205
+ `retime produced invalid source: ${parsed.errors[0]?.message ?? 'parse error'}`,
1206
+ { canvas: canvasAbsPath, id: stableId }
924
1207
  );
925
1208
  }
926
- const obj = v.expression;
927
- if (obj?.type !== 'ObjectExpression') {
928
- throw new CanvasEditError(
929
- `style={...} on ${id} is not an inline ObjectExpression — refusing to edit`,
930
- { canvas: canvasAbsPath, id }
1209
+ assertCompSemantics(canvasAbsPath, next);
1210
+ return { source: next };
1211
+ }
1212
+
1213
+ /** Retime a clip by stableId on disk (atomic write + cross-process lock). */
1214
+ export async function retimeSequenceByClip(
1215
+ canvasAbsPath: string,
1216
+ artboardId: string | undefined,
1217
+ stableId: string,
1218
+ expectedHash: string | undefined,
1219
+ patch: RetimePatch
1220
+ ): Promise<{ source: string }> {
1221
+ return withLock(canvasAbsPath, async () => {
1222
+ const file = Bun.file(canvasAbsPath);
1223
+ if (!(await file.exists())) {
1224
+ throw new CanvasEditError(`Canvas not found: ${canvasAbsPath}`, {
1225
+ canvas: canvasAbsPath,
1226
+ id: stableId,
1227
+ });
1228
+ }
1229
+ const source = await file.text();
1230
+ const next = applyRetimeSequenceByClip(
1231
+ canvasAbsPath,
1232
+ source,
1233
+ artboardId,
1234
+ stableId,
1235
+ expectedHash,
1236
+ patch
931
1237
  );
1238
+ if (next.source === source) return next;
1239
+ const tmp = `${canvasAbsPath}.tmp.${Math.random().toString(36).slice(2, 10)}`;
1240
+ await Bun.write(tmp, next.source);
1241
+ const { rename } = await import('node:fs/promises');
1242
+ await rename(tmp, canvasAbsPath);
1243
+ return next;
1244
+ });
1245
+ }
1246
+
1247
+ // ---------------------------------------------------------------------------
1248
+ // Clip addressing (DDR-150 P2). The SINGLE authoritative tokenizer for a
1249
+ // video-comp's clips. The Timeline UI addresses every op through the `stableId`
1250
+ // this returns, NOT its own regex position — killing the two-tokenizer
1251
+ // document-order disagreement that made destructive ops corrupt the wrong clip
1252
+ // on a multi-comp canvas (the debate's headline defect). AST-based, so it skips
1253
+ // tags inside comments/strings (the regex parser didn't) and scopes cleanly to
1254
+ // one comp's body even when several comps share a file.
1255
+
1256
+ /** Sequence-family "clip" tags (the timeline rows). */
1257
+ const CLIP_TAGS = new Set(['Sequence', 'Series.Sequence', 'TransitionSeries.Sequence']);
1258
+ /** Transition tags (occupy a slot between clips inside a TransitionSeries). */
1259
+ const TRANSITION_TAGS = new Set(['Series.Transition', 'TransitionSeries.Transition']);
1260
+ /** Media tags that carry a `src` (the replace-media + drop targets). */
1261
+ const MEDIA_TAGS = new Set(['Video', 'OffthreadVideo', 'Audio', 'Img', 'Image']);
1262
+ /** Remotion/layout primitives that are structural, not their own timeline layer. */
1263
+ const LAYER_SKIP_TAGS = new Set([
1264
+ 'AbsoluteFill',
1265
+ 'Sequence',
1266
+ 'Series',
1267
+ 'Series.Sequence',
1268
+ 'Series.Transition',
1269
+ 'TransitionSeries',
1270
+ 'TransitionSeries.Sequence',
1271
+ 'TransitionSeries.Transition',
1272
+ 'Loop',
1273
+ 'Freeze',
1274
+ 'Fragment',
1275
+ ]);
1276
+
1277
+ /** Full tag string of a JSXElement: `Sequence` | `TransitionSeries.Sequence` | … */
1278
+ function jsxTagName(node: AnyNode): string | null {
1279
+ const n = node?.openingElement?.name;
1280
+ if (!n) return null;
1281
+ if (n.type === 'JSXIdentifier') return typeof n.name === 'string' ? n.name : null;
1282
+ if (n.type === 'JSXMemberExpression') {
1283
+ const obj = n.object?.type === 'JSXIdentifier' ? n.object.name : null;
1284
+ const prop = n.property?.type === 'JSXIdentifier' ? n.property.name : null;
1285
+ if (obj && prop) return `${obj}.${prop}`;
932
1286
  }
1287
+ return null;
1288
+ }
933
1289
 
934
- // Search for an existing property with the same key. JSX styles permit
935
- // camelCase identifiers (paddingTop) AND quoted strings ("padding-top").
936
- // Compare both forms.
937
- const propCamel = prop.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
938
- for (const p of obj.properties as AnyNode[]) {
939
- if (p?.type !== 'Property' && p?.type !== 'ObjectProperty') continue;
940
- const k = p.key;
941
- if (!k) continue;
942
- const kname = k.type === 'Identifier' ? k.name : k.type === 'Literal' ? String(k.value) : null;
943
- if (kname === prop || kname === propCamel) {
944
- s.overwrite(p.value.start, p.value.end, value);
1290
+ /** Evaluate a numeric AST expression against a resolved const map (literal,
1291
+ * negation, const identifier, or simple arithmetic of them). null if not
1292
+ * resolvable from/duration are best-effort labels, never load-bearing for
1293
+ * addressing (that's stableId + contentHash). */
1294
+ function evalNum(n: AnyNode, consts: Record<string, number>): number | null {
1295
+ if (!n || typeof n !== 'object') return null;
1296
+ const t = n.type;
1297
+ if ((t === 'Literal' || t === 'NumericLiteral') && typeof n.value === 'number') return n.value;
1298
+ if (t === 'UnaryExpression' && n.operator === '-') {
1299
+ const v = evalNum(n.argument, consts);
1300
+ return v == null ? null : -v;
1301
+ }
1302
+ if (t === 'Identifier') return Object.hasOwn(consts, n.name) ? (consts[n.name] as number) : null;
1303
+ if (t === 'BinaryExpression') {
1304
+ const l = evalNum(n.left, consts);
1305
+ const r = evalNum(n.right, consts);
1306
+ if (l == null || r == null) return null;
1307
+ switch (n.operator) {
1308
+ case '+':
1309
+ return l + r;
1310
+ case '-':
1311
+ return l - r;
1312
+ case '*':
1313
+ return l * r;
1314
+ case '/':
1315
+ return r ? l / r : null;
1316
+ default:
1317
+ return null;
1318
+ }
1319
+ }
1320
+ return null;
1321
+ }
1322
+
1323
+ /** Top-level numeric const bindings (3 passes so a derived `TOTAL = A + B` resolves). */
1324
+ function collectNumericConsts(program: AnyNode): Record<string, number> {
1325
+ const consts: Record<string, number> = {};
1326
+ const decls: Array<{ name: string; init: AnyNode }> = [];
1327
+ for (const node of program?.body ?? []) {
1328
+ if (node?.type !== 'VariableDeclaration') continue;
1329
+ for (const d of node.declarations ?? []) {
1330
+ if (d?.id?.type === 'Identifier' && d.init) decls.push({ name: d.id.name, init: d.init });
1331
+ }
1332
+ }
1333
+ for (let pass = 0; pass < 3; pass += 1) {
1334
+ for (const { name, init } of decls) {
1335
+ if (Object.hasOwn(consts, name)) continue;
1336
+ const v = evalNum(init, consts);
1337
+ if (v != null && Number.isFinite(v)) consts[name] = Math.round(v);
1338
+ }
1339
+ }
1340
+ return consts;
1341
+ }
1342
+
1343
+ /** Resolve a numeric JSX attribute (`from={20}` / `durationInFrames={A}`). */
1344
+ function numAttr(opening: AnyNode, name: string, consts: Record<string, number>): number | null {
1345
+ const a = findAttribute(opening, name);
1346
+ let v = a?.value;
1347
+ if (!v) return null;
1348
+ if (v.type === 'JSXExpressionContainer') v = v.expression;
1349
+ const n = evalNum(v, consts);
1350
+ return n == null ? null : Math.round(n);
1351
+ }
1352
+
1353
+ /** First media descendant of a clip (its `<Video>`/`<Audio>`/`<Img>` — the replace target). */
1354
+ function firstMediaDescendant(clip: AnyNode): AnyNode | null {
1355
+ let found: AnyNode | null = null;
1356
+ function walk(n: AnyNode): void {
1357
+ if (found || !n || typeof n !== 'object') return;
1358
+ if (Array.isArray(n)) {
1359
+ for (const c of n) {
1360
+ if (found) return;
1361
+ walk(c);
1362
+ }
945
1363
  return;
946
1364
  }
1365
+ if (n.type === 'JSXElement' && MEDIA_TAGS.has(jsxTagName(n) ?? '')) {
1366
+ found = n;
1367
+ return;
1368
+ }
1369
+ for (const k of Object.keys(n)) {
1370
+ if (k === 'loc' || k === 'range' || k === 'start' || k === 'end' || k === 'type') continue;
1371
+ walk(n[k]);
1372
+ }
947
1373
  }
1374
+ walk(clip.children ?? []);
1375
+ return found;
1376
+ }
948
1377
 
949
- // Key missing append before the closing `}`. ObjectExpression's last char
950
- // is the `}`; magic-string appendLeft at end keeps the brace in place.
951
- const props = obj.properties as AnyNode[];
952
- const tail = props.length > 0 ? (props[props.length - 1].end as number) : obj.start + 1;
953
- const sep = props.length > 0 ? ', ' : ' ';
1378
+ /** Map every top-level component name its render body root (for nested-media resolution). */
1379
+ function collectComponentBodies(program: AnyNode): Map<string, AnyNode> {
1380
+ const out = new Map<string, AnyNode>();
1381
+ const body = (program?.body ?? []) as AnyNode[];
1382
+ for (const stmt of body) {
1383
+ if (stmt?.type === 'FunctionDeclaration' && isPascalIdent(stmt.id?.name)) {
1384
+ out.set(stmt.id.name, stmt.body);
1385
+ } else if (stmt?.type === 'VariableDeclaration') {
1386
+ for (const d of stmt.declarations ?? []) {
1387
+ const name = componentNameOf(d);
1388
+ if (name && d.init?.body) out.set(name, d.init.body);
1389
+ }
1390
+ }
1391
+ }
1392
+ return out;
1393
+ }
1394
+
1395
+ /** Map every top-level `const NAME = [ {…}, … ]` → its ArrayExpression (for `CLIPS[i].src`). */
1396
+ function collectArrayLiterals(program: AnyNode): Map<string, AnyNode> {
1397
+ const out = new Map<string, AnyNode>();
1398
+ for (const stmt of (program?.body ?? []) as AnyNode[]) {
1399
+ if (stmt?.type !== 'VariableDeclaration') continue;
1400
+ for (const d of stmt.declarations ?? []) {
1401
+ if (d?.id?.type === 'Identifier' && d.init?.type === 'ArrayExpression') {
1402
+ out.set(d.id.name, d.init);
1403
+ }
1404
+ }
1405
+ }
1406
+ return out;
1407
+ }
1408
+
1409
+ /** The first JSXElement child of `clip` whose tag is a PascalCase component (not a primitive). */
1410
+ function firstComponentChild(clip: AnyNode): AnyNode | null {
1411
+ for (const child of (clip.children ?? []) as AnyNode[]) {
1412
+ if (child?.type === 'JSXElement') {
1413
+ const tag = jsxTagName(child);
1414
+ if (tag && isPascalIdent(tag) && !MEDIA_TAGS.has(tag)) return child;
1415
+ }
1416
+ }
1417
+ return null;
1418
+ }
1419
+
1420
+ /** A media element's `src` attr node (JSX value), or null. */
1421
+ function srcAttrValue(mediaEl: AnyNode): AnyNode | null {
1422
+ const attr = findAttribute(mediaEl.openingElement, 'src');
1423
+ return attr?.value ?? null;
1424
+ }
1425
+
1426
+ /**
1427
+ * Resolve a clip's media even when it's nested one level inside a wrapper
1428
+ * component whose `src` is fed by an array element (the showreel
1429
+ * `<ClipShot clip={CLIPS[2]} />` → `<Video src={clip.src} />` pattern). Returns
1430
+ * the media tag + a replace target: a direct `cdId` (literal src), an
1431
+ * `arrayRef` (edit `NAME[i].field`), or `shared` (literal src in a reused comp).
1432
+ */
1433
+ function resolveNestedMedia(
1434
+ clip: AnyNode,
1435
+ componentBodies: Map<string, AnyNode>,
1436
+ arrays: Map<string, AnyNode>,
1437
+ cdIdOf: Map<AnyNode, string>
1438
+ ): {
1439
+ el: AnyNode;
1440
+ tag: string;
1441
+ src: string | null;
1442
+ cdId: string | null;
1443
+ arrayRef: { arrayName: string; index: number; field: string } | null;
1444
+ shared: boolean;
1445
+ } | null {
1446
+ // 1. Direct media descendant (literal or prop src).
1447
+ const direct = firstMediaDescendant(clip);
1448
+ if (direct) {
1449
+ return {
1450
+ el: direct,
1451
+ tag: jsxTagName(direct) ?? 'Video',
1452
+ src: getStringAttr(direct.openingElement, 'src'),
1453
+ cdId: cdIdOf.get(direct) ?? null,
1454
+ arrayRef: null,
1455
+ shared: false,
1456
+ };
1457
+ }
1458
+ // 2. Wrapper component: <Comp propName={ARR[i]} /> → Comp body → media <X src={param.field}>.
1459
+ const wrapper = firstComponentChild(clip);
1460
+ if (!wrapper) return null;
1461
+ const compName = jsxTagName(wrapper);
1462
+ const body = compName ? componentBodies.get(compName) : null;
1463
+ if (!body) return null;
1464
+ const media = firstMediaDescendant({ children: [body] });
1465
+ if (!media) return null;
1466
+ const tag = jsxTagName(media) ?? 'Video';
1467
+ const srcVal = srcAttrValue(media);
1468
+ // Literal src inside the shared component → replaceable, but shared.
1469
+ if (srcVal?.type === 'Literal' || srcVal?.type === 'StringLiteral') {
1470
+ return {
1471
+ el: media,
1472
+ tag,
1473
+ src: String(srcVal.value),
1474
+ cdId: cdIdOf.get(media) ?? null,
1475
+ arrayRef: null,
1476
+ shared: true,
1477
+ };
1478
+ }
1479
+ // Prop-bound src: `src={param.field}` where `param` is the component's first
1480
+ // destructured/parameter name, bound at the call site to `ARR[i]`.
1481
+ if (srcVal?.type === 'JSXExpressionContainer') {
1482
+ const expr = srcVal.expression;
1483
+ // member: param.field
1484
+ if (
1485
+ expr?.type === 'MemberExpression' &&
1486
+ expr.object?.type === 'Identifier' &&
1487
+ expr.property?.type === 'Identifier'
1488
+ ) {
1489
+ const field = expr.property.name;
1490
+ // Find the call-site prop bound to this component's param: <Comp X={ARR[i]}>.
1491
+ // The wrapper's first attribute value that is `ARR[i]` gives the array + index.
1492
+ for (const attr of (wrapper.openingElement?.attributes ?? []) as AnyNode[]) {
1493
+ const v = attr?.value;
1494
+ if (v?.type !== 'JSXExpressionContainer') continue;
1495
+ const e = v.expression;
1496
+ if (
1497
+ e?.type === 'MemberExpression' &&
1498
+ e.object?.type === 'Identifier' &&
1499
+ arrays.has(e.object.name) &&
1500
+ e.property?.type === 'Literal' &&
1501
+ typeof e.property.value === 'number'
1502
+ ) {
1503
+ const arr = arrays.get(e.object.name);
1504
+ const el = (arr?.elements ?? [])[e.property.value] as AnyNode | undefined;
1505
+ const prop =
1506
+ el?.type === 'ObjectExpression'
1507
+ ? (el.properties ?? []).find(
1508
+ (p: AnyNode) => p?.key?.name === field || p?.key?.value === field
1509
+ )
1510
+ : null;
1511
+ const litSrc =
1512
+ prop?.value && (prop.value.type === 'Literal' || prop.value.type === 'StringLiteral')
1513
+ ? String(prop.value.value)
1514
+ : null;
1515
+ return {
1516
+ el: media,
1517
+ tag,
1518
+ src: litSrc,
1519
+ cdId: null,
1520
+ arrayRef: { arrayName: e.object.name, index: e.property.value, field },
1521
+ shared: false,
1522
+ };
1523
+ }
1524
+ }
1525
+ }
1526
+ }
1527
+ // Media exists but src isn't resolvable to a replaceable target.
1528
+ return { el: media, tag, src: null, cdId: null, arrayRef: null, shared: false };
1529
+ }
1530
+
1531
+ /** One visually-meaningful layer inside a clip (a media element or a named
1532
+ * sub-component) — so the Timeline can show a ClipShot's mp4 background and its
1533
+ * title/lower-third as SEPARATE rows instead of one opaque "ClipShot". */
1534
+ export interface ClipLayer {
1535
+ kind: 'video' | 'image' | 'audio' | 'component';
1536
+ /** Human label — the media filename, or the sub-component's tag. */
1537
+ label: string;
1538
+ mediaTag: string | null;
1539
+ mediaSrc: string | null;
1540
+ mediaCdId: string | null;
1541
+ mediaArrayRef: { arrayName: string; index: number; field: string } | null;
1542
+ mediaShared: boolean;
1543
+ }
1544
+
1545
+ /**
1546
+ * Decompose a clip into its stacked layers (media elements + named sub-components),
1547
+ * resolving through a wrapper component (the showreel `<ClipShot clip={CLIPS[i]}>`
1548
+ * → its `<Video>` + `<LowerThird>`). Ordered by source position. Skips Remotion/
1549
+ * layout primitives (AbsoluteFill, Sequence, …). Returns [] when the clip has no
1550
+ * decomposable structure (a pure inline card).
1551
+ */
1552
+ function collectClipLayers(
1553
+ clip: AnyNode,
1554
+ componentBodies: Map<string, AnyNode>,
1555
+ arrays: Map<string, AnyNode>,
1556
+ cdIdOf: Map<AnyNode, string>
1557
+ ): ClipLayer[] {
1558
+ // Resolve the root to walk: the wrapper component's body if the clip wraps one,
1559
+ // else the clip's own children. Carry the wrapper element so prop-fed src (the
1560
+ // component's `clip` param → `CLIPS[i]`) resolves against the call site.
1561
+ const wrapper = firstComponentChild(clip);
1562
+ const wrapperTag = wrapper ? jsxTagName(wrapper) : null;
1563
+ const body = wrapperTag ? componentBodies.get(wrapperTag) : null;
1564
+ const root: AnyNode = body ? { children: [body] } : clip;
1565
+
1566
+ const layers: ClipLayer[] = [];
1567
+ (function walk(n: AnyNode): void {
1568
+ if (!n || typeof n !== 'object') return;
1569
+ if (Array.isArray(n)) {
1570
+ for (const c of n) walk(c);
1571
+ return;
1572
+ }
1573
+ if (n.type === 'JSXElement') {
1574
+ const tag = jsxTagName(n) ?? '';
1575
+ if (MEDIA_TAGS.has(tag)) {
1576
+ // Media layer — resolve src the same way replace does (literal / prop / array).
1577
+ const m = wrapper
1578
+ ? resolveMediaSrcThroughWrapper(n, wrapper, arrays, cdIdOf)
1579
+ : {
1580
+ src: getStringAttr(n.openingElement, 'src'),
1581
+ cdId: cdIdOf.get(n) ?? null,
1582
+ arrayRef: null as ClipLayer['mediaArrayRef'],
1583
+ shared: false,
1584
+ };
1585
+ const kind: ClipLayer['kind'] =
1586
+ tag === 'Audio' ? 'audio' : tag === 'Img' || tag === 'Image' ? 'image' : 'video';
1587
+ layers.push({
1588
+ kind,
1589
+ label: m.src ? String(m.src).split('/').pop() || tag : tag,
1590
+ mediaTag: tag,
1591
+ mediaSrc: m.src,
1592
+ mediaCdId: m.cdId,
1593
+ mediaArrayRef: m.arrayRef,
1594
+ mediaShared: m.shared,
1595
+ });
1596
+ } else if (
1597
+ /^[A-Z]/.test(tag) &&
1598
+ !LAYER_SKIP_TAGS.has(tag) &&
1599
+ tag !== wrapperTag &&
1600
+ componentBodies.has(tag) // a KNOWN locally-defined component → a real layer
1601
+ ) {
1602
+ // A named sub-component (LowerThird, TitleBadge, …) → a component layer.
1603
+ // Only surface locally-defined components (skip unknown/library tags to
1604
+ // avoid noise); its media (if any) is walked below and attributed to it.
1605
+ layers.push({
1606
+ kind: 'component',
1607
+ label: tag,
1608
+ mediaTag: null,
1609
+ mediaSrc: null,
1610
+ mediaCdId: null,
1611
+ mediaArrayRef: null,
1612
+ mediaShared: false,
1613
+ });
1614
+ }
1615
+ }
1616
+ // Generic recursion (mirrors firstMediaDescendant) so wrapped/parenthesized
1617
+ // arrow bodies + all child positions are traversed, not just `children`.
1618
+ for (const k of Object.keys(n)) {
1619
+ if (k === 'loc' || k === 'range' || k === 'start' || k === 'end' || k === 'type') continue;
1620
+ walk(n[k]);
1621
+ }
1622
+ })(root);
1623
+ return layers;
1624
+ }
1625
+
1626
+ /** Resolve a media element's src (literal / prop-fed-from-array) via its wrapper's call site. */
1627
+ function resolveMediaSrcThroughWrapper(
1628
+ media: AnyNode,
1629
+ wrapper: AnyNode,
1630
+ arrays: Map<string, AnyNode>,
1631
+ cdIdOf: Map<AnyNode, string>
1632
+ ): {
1633
+ src: string | null;
1634
+ cdId: string | null;
1635
+ arrayRef: ClipLayer['mediaArrayRef'];
1636
+ shared: boolean;
1637
+ } {
1638
+ const srcVal = srcAttrValue(media);
1639
+ if (srcVal?.type === 'Literal' || srcVal?.type === 'StringLiteral') {
1640
+ return {
1641
+ src: String(srcVal.value),
1642
+ cdId: cdIdOf.get(media) ?? null,
1643
+ arrayRef: null,
1644
+ shared: true,
1645
+ };
1646
+ }
1647
+ if (
1648
+ srcVal?.type === 'JSXExpressionContainer' &&
1649
+ srcVal.expression?.type === 'MemberExpression' &&
1650
+ srcVal.expression.property?.type === 'Identifier'
1651
+ ) {
1652
+ const field = srcVal.expression.property.name;
1653
+ for (const attr of (wrapper.openingElement?.attributes ?? []) as AnyNode[]) {
1654
+ const v = attr?.value;
1655
+ const e = v?.type === 'JSXExpressionContainer' ? v.expression : null;
1656
+ if (
1657
+ e?.type === 'MemberExpression' &&
1658
+ e.object?.type === 'Identifier' &&
1659
+ arrays.has(e.object.name) &&
1660
+ e.property?.type === 'Literal' &&
1661
+ typeof e.property.value === 'number'
1662
+ ) {
1663
+ const arr = arrays.get(e.object.name);
1664
+ const el = (arr?.elements ?? [])[e.property.value] as AnyNode | undefined;
1665
+ const prop =
1666
+ el?.type === 'ObjectExpression'
1667
+ ? (el.properties ?? []).find(
1668
+ (p: AnyNode) => p?.key?.name === field || p?.key?.value === field
1669
+ )
1670
+ : null;
1671
+ const lit =
1672
+ prop?.value && (prop.value.type === 'Literal' || prop.value.type === 'StringLiteral')
1673
+ ? String(prop.value.value)
1674
+ : null;
1675
+ return {
1676
+ src: lit,
1677
+ cdId: null,
1678
+ arrayRef: { arrayName: e.object.name, index: e.property.value, field },
1679
+ shared: false,
1680
+ };
1681
+ }
1682
+ }
1683
+ }
1684
+ return { src: null, cdId: cdIdOf.get(media) ?? null, arrayRef: null, shared: false };
1685
+ }
1686
+
1687
+ /** The child span [start, end] of a clip element (between its `>` and `</`), or null (self-closing). */
1688
+ function clipChildrenSpan(node: AnyNode): { start: number; end: number } | null {
1689
+ const oe = node.openingElement;
1690
+ const ce = node.closingElement;
1691
+ if (!oe || !ce || typeof oe.end !== 'number' || typeof ce.start !== 'number') return null;
1692
+ return { start: oe.end as number, end: ce.start as number };
1693
+ }
1694
+
1695
+ /** True when a clip's children are gated behind `{false && (…)}` (the hide marker). */
1696
+ function clipChildrenHidden(node: AnyNode, source: string): boolean {
1697
+ const span = clipChildrenSpan(node);
1698
+ if (!span) return false;
1699
+ return /^\s*\{false && \(/.test(source.slice(span.start, span.end));
1700
+ }
1701
+
1702
+ /** Stable content fingerprint of a clip's exact source span (optimistic-concurrency check). */
1703
+ function hashSpan(source: string, start: number, end: number): string {
1704
+ return Bun.hash(source.slice(start, end)).toString(16).padStart(16, '0').slice(0, 12);
1705
+ }
1706
+
1707
+ /** One clip (timeline row) + everything an op needs to address + label it. */
1708
+ export interface ClipInfo {
1709
+ /** Durable identity: `name:<Sequence name>` → `mclip:<sentinel>` → `<comp>#<indexInComp>`. */
1710
+ stableId: string;
1711
+ kind: 'sequence' | 'transition';
1712
+ tag: string;
1713
+ from: number | null;
1714
+ durationInFrames: number | null;
1715
+ mediaTag: string | null;
1716
+ mediaSrc: string | null;
1717
+ /** Positional cd-id of the media element (for `editAttribute` src-replace). */
1718
+ mediaCdId: string | null;
1719
+ /**
1720
+ * When the clip's media lives inside a wrapper component whose `src` is fed by
1721
+ * an array element (`<ClipShot clip={CLIPS[2]} />` → `<Video src={clip.src}>`),
1722
+ * this points at the array-literal string to edit for a replace (the showreel
1723
+ * pattern). `mediaCdId` is then null (the element's src is a prop, not literal).
1724
+ */
1725
+ mediaArrayRef: { arrayName: string; index: number; field: string } | null;
1726
+ /**
1727
+ * True when the media's literal `src` lives in a SHARED wrapper component (every
1728
+ * instance renders the same element) — replacing it changes every clip using
1729
+ * that component. The client warns before applying.
1730
+ */
1731
+ mediaShared: boolean;
1732
+ /** Positional cd-id of the clip's OWN `<Sequence>` node (for `moveElement` z-order reorder). */
1733
+ clipCdId: string | null;
1734
+ /** The clip's stacked layers (mp4 background + title/lower-third + …) for the
1735
+ * expandable timeline rows. Empty for a pure inline card. */
1736
+ layers: ClipLayer[];
1737
+ /** True when the clip's body is gated behind `{false && (…)}` (hidden — renders
1738
+ * nothing but keeps its time slot + the TransitionSeries alternation). */
1739
+ hidden: boolean;
1740
+ /** Fingerprint of the clip's source span — refuse an op if it no longer matches. */
1741
+ contentHash: string;
1742
+ start: number;
1743
+ end: number;
1744
+ }
1745
+
1746
+ /** A media element sitting DIRECTLY in the comp body (not inside a clip) — an
1747
+ * `<Audio>` music bed under the reel, a full-length `<Video>` background, … .
1748
+ * Not a clip (no from/duration semantics of its own), but it IS addressable:
1749
+ * the Timeline's audio rows use `cdId` for a replace (`editAttribute` on src). */
1750
+ export interface LooseMediaInfo {
1751
+ tag: string;
1752
+ src: string | null;
1753
+ cdId: string | null;
1754
+ contentHash: string;
1755
+ }
1756
+
1757
+ export interface CompClips {
1758
+ compName: string | null;
1759
+ artboardId: string | null;
1760
+ fps: number | null;
1761
+ durationInFrames: number | null;
1762
+ clips: ClipInfo[];
1763
+ /** DDR-150 dogfood #5 — loose media beds (document order), for audio replace. */
1764
+ media: LooseMediaInfo[];
1765
+ }
1766
+
1767
+ /**
1768
+ * Enumerate the clips of ONE video-comp (scoped by `artboardId` → its
1769
+ * `<VideoComp component={X}>` → component X's body). The Timeline UI renders
1770
+ * rows from this and addresses every op by `clip.stableId` — so UI and engine
1771
+ * can never disagree about which clip is which (the multi-comp defect). Throws
1772
+ * `CanvasEditError` on unparseable source.
1773
+ */
1774
+ export function enumerateClips(
1775
+ canvasAbsPath: string,
1776
+ source: string,
1777
+ artboardId?: string
1778
+ ): CompClips {
1779
+ const parsed = parseSync(canvasAbsPath, source, { sourceType: 'module' });
1780
+ if (parsed.errors && parsed.errors.length > 0) {
1781
+ throw new CanvasEditError(
1782
+ `oxc-parser failed on ${canvasAbsPath}: ${parsed.errors[0]?.message ?? 'unknown'}`,
1783
+ { canvas: canvasAbsPath, id: artboardId ?? '' }
1784
+ );
1785
+ }
1786
+ const program = parsed.program;
1787
+ const consts = collectNumericConsts(program);
1788
+ const cdIdOf = new Map<AnyNode, string>();
1789
+ for (const { id, node } of collectElements(program)) cdIdOf.set(node, id);
1790
+
1791
+ interface Usage {
1792
+ compName: string | null;
1793
+ artboardId: string | null;
1794
+ fps: number | null;
1795
+ duration: number | null;
1796
+ }
1797
+ interface RawClip {
1798
+ tag: string;
1799
+ kind: 'sequence' | 'transition';
1800
+ node: AnyNode;
1801
+ comp: string;
1802
+ indexInComp: number;
1803
+ start: number;
1804
+ end: number;
1805
+ }
1806
+ interface RawMedia {
1807
+ tag: string;
1808
+ node: AnyNode;
1809
+ comp: string;
1810
+ start: number;
1811
+ end: number;
1812
+ }
1813
+ const usages: Usage[] = [];
1814
+ const clips: RawClip[] = [];
1815
+ const mediaEls: RawMedia[] = [];
1816
+ const compClipCount: Record<string, number> = {};
1817
+ const compStack: string[] = [''];
1818
+ const artboardStack: Array<string | null> = [];
1819
+
1820
+ function visit(node: AnyNode): void {
1821
+ if (!node || typeof node !== 'object') return;
1822
+ if (Array.isArray(node)) {
1823
+ for (const c of node) visit(c);
1824
+ return;
1825
+ }
1826
+ if (typeof node.type !== 'string') return;
1827
+ const newComp = componentNameOf(node);
1828
+ let pushedComp = false;
1829
+ if (newComp !== null) {
1830
+ compStack.push(newComp);
1831
+ pushedComp = true;
1832
+ }
1833
+ if (node.type === 'JSXElement') {
1834
+ const tag = jsxTagName(node);
1835
+ let pushedArt = false;
1836
+ if (tag === 'DCArtboard') {
1837
+ artboardStack.push(getStringAttr(node.openingElement, 'id'));
1838
+ pushedArt = true;
1839
+ }
1840
+ if (tag === 'VideoComp') {
1841
+ const compAttr = findAttribute(node.openingElement, 'component');
1842
+ const cv = compAttr?.value;
1843
+ const cn =
1844
+ cv?.type === 'JSXExpressionContainer' && cv.expression?.type === 'Identifier'
1845
+ ? cv.expression.name
1846
+ : null;
1847
+ usages.push({
1848
+ compName: cn,
1849
+ artboardId: artboardStack.length ? artboardStack[artboardStack.length - 1] : null,
1850
+ fps: numAttr(node.openingElement, 'fps', consts),
1851
+ duration: numAttr(node.openingElement, 'durationInFrames', consts),
1852
+ });
1853
+ }
1854
+ if (tag && (CLIP_TAGS.has(tag) || TRANSITION_TAGS.has(tag))) {
1855
+ const comp = compStack[compStack.length - 1] as string;
1856
+ const idx = compClipCount[comp] ?? 0;
1857
+ compClipCount[comp] = idx + 1;
1858
+ clips.push({
1859
+ tag,
1860
+ kind: TRANSITION_TAGS.has(tag) ? 'transition' : 'sequence',
1861
+ node,
1862
+ comp,
1863
+ indexInComp: idx,
1864
+ start: node.start as number,
1865
+ end: node.end as number,
1866
+ });
1867
+ }
1868
+ if (tag && MEDIA_TAGS.has(tag)) {
1869
+ // Every media element, with its owning comp — loose beds are filtered
1870
+ // out of clip spans below (an <Audio> under the reel vs inside a clip).
1871
+ mediaEls.push({
1872
+ tag,
1873
+ node,
1874
+ comp: compStack[compStack.length - 1] as string,
1875
+ start: node.start as number,
1876
+ end: node.end as number,
1877
+ });
1878
+ }
1879
+ if (node.openingElement) visit(node.openingElement.attributes);
1880
+ visit(node.children);
1881
+ if (pushedArt) artboardStack.pop();
1882
+ if (pushedComp) compStack.pop();
1883
+ return;
1884
+ }
1885
+ for (const k of Object.keys(node)) {
1886
+ if (k === 'loc' || k === 'range' || k === 'start' || k === 'end' || k === 'type') continue;
1887
+ visit(node[k]);
1888
+ }
1889
+ if (pushedComp) compStack.pop();
1890
+ }
1891
+ visit(program);
1892
+
1893
+ // Resolve which comp to scope to. Preference mirrors timeline-parse.js:
1894
+ // the selected artboard's comp → a comp with clips → the first usage.
1895
+ const target =
1896
+ (artboardId && usages.find((u) => u.artboardId === artboardId && u.compName)) ||
1897
+ usages.find((u) => u.compName && clips.some((c) => c.comp === u.compName)) ||
1898
+ usages.find((u) => u.compName) ||
1899
+ null;
1900
+ const targetComp = target?.compName ?? clips[0]?.comp ?? null;
1901
+
1902
+ const componentBodies = collectComponentBodies(program);
1903
+ const arrays = collectArrayLiterals(program);
1904
+ const scoped = targetComp == null ? [] : clips.filter((c) => c.comp === targetComp);
1905
+ const clipInfos: ClipInfo[] = scoped.map((c) => {
1906
+ const opening = c.node.openingElement;
1907
+ const nameAttr = getStringAttr(opening, 'name');
1908
+ const before = source.slice(Math.max(0, c.start - 160), c.start);
1909
+ const sentinel = before.match(/\{\/\*\s*@mclip\s+([A-Za-z0-9_-]+)\s*\*\/\}\s*$/);
1910
+ const stableId =
1911
+ nameAttr != null && nameAttr !== ''
1912
+ ? `name:${nameAttr}`
1913
+ : sentinel
1914
+ ? `mclip:${sentinel[1]}`
1915
+ : `${targetComp}#${c.indexInComp}`;
1916
+ // Resolve media even through a wrapper component (the showreel pattern) so
1917
+ // the Timeline can badge the clip video/image/audio AND replace its source.
1918
+ const m = resolveNestedMedia(c.node, componentBodies, arrays, cdIdOf);
1919
+ return {
1920
+ stableId,
1921
+ kind: c.kind,
1922
+ tag: c.tag,
1923
+ from: numAttr(opening, 'from', consts),
1924
+ durationInFrames: numAttr(opening, 'durationInFrames', consts),
1925
+ mediaTag: m ? m.tag : null,
1926
+ mediaSrc: m ? m.src : null,
1927
+ mediaCdId: m ? m.cdId : null,
1928
+ mediaArrayRef: m ? m.arrayRef : null,
1929
+ mediaShared: m ? m.shared : false,
1930
+ clipCdId: cdIdOf.get(c.node) ?? null,
1931
+ layers: collectClipLayers(c.node, componentBodies, arrays, cdIdOf),
1932
+ hidden: clipChildrenHidden(c.node, source),
1933
+ contentHash: hashSpan(source, c.start, c.end),
1934
+ start: c.start,
1935
+ end: c.end,
1936
+ };
1937
+ });
1938
+
1939
+ // Loose media beds — media in the target comp OUTSIDE every clip span
1940
+ // (an <Audio> music bed, a background <Video>). Document order.
1941
+ const media: LooseMediaInfo[] = mediaEls
1942
+ .filter(
1943
+ (mel) =>
1944
+ mel.comp === targetComp && !scoped.some((c) => mel.start >= c.start && mel.end <= c.end)
1945
+ )
1946
+ .map((mel) => ({
1947
+ tag: mel.tag,
1948
+ src: getStringAttr(mel.node.openingElement, 'src'),
1949
+ cdId: cdIdOf.get(mel.node) ?? null,
1950
+ contentHash: hashSpan(source, mel.start, mel.end),
1951
+ }));
1952
+
1953
+ return {
1954
+ compName: targetComp,
1955
+ artboardId: target?.artboardId ?? null,
1956
+ fps: target?.fps ?? null,
1957
+ durationInFrames: target?.duration ?? null,
1958
+ clips: clipInfos,
1959
+ media,
1960
+ };
1961
+ }
1962
+
1963
+ /**
1964
+ * Resolve a `stableId` (from `enumerateClips`) to its live clip in `source`,
1965
+ * verifying the caller's `expectedHash` still matches (optimistic concurrency —
1966
+ * a stale UI index or a concurrent edit is refused, not silently mis-applied).
1967
+ * Returns the ClipInfo (with current start/end for the patch). Throws on a
1968
+ * missing id or a hash mismatch.
1969
+ */
1970
+ export function resolveClip(
1971
+ canvasAbsPath: string,
1972
+ source: string,
1973
+ artboardId: string | undefined,
1974
+ stableId: string,
1975
+ expectedHash?: string
1976
+ ): ClipInfo {
1977
+ const { clips } = enumerateClips(canvasAbsPath, source, artboardId);
1978
+ const hit = clips.find((c) => c.stableId === stableId);
1979
+ if (!hit) {
1980
+ throw new CanvasEditError(`clip "${stableId}" not found`, {
1981
+ canvas: canvasAbsPath,
1982
+ id: stableId,
1983
+ });
1984
+ }
1985
+ if (expectedHash != null && expectedHash !== hit.contentHash) {
1986
+ throw new CanvasEditError(
1987
+ `clip "${stableId}" changed since it was read (concurrent edit); reload and retry`,
1988
+ { canvas: canvasAbsPath, id: stableId }
1989
+ );
1990
+ }
1991
+ return hit;
1992
+ }
1993
+
1994
+ /**
1995
+ * The semantic gate (DDR-150 P2). Parse-clean is NOT correct: a wrong-clip
1996
+ * delete, an orphaned/leading/double `<TransitionSeries.Transition>`, all parse
1997
+ * fine yet render wrong. After any structural clip edit, assert every
1998
+ * `<TransitionSeries>` strictly alternates Sequence/Transition and begins + ends
1999
+ * with a Sequence. Throws `CanvasEditError` on a violation.
2000
+ */
2001
+ export function assertCompSemantics(canvasAbsPath: string, source: string): { ok: true } {
2002
+ const parsed = parseSync(canvasAbsPath, source, { sourceType: 'module' });
2003
+ if (parsed.errors && parsed.errors.length > 0) {
2004
+ throw new CanvasEditError(
2005
+ `oxc-parser failed on ${canvasAbsPath}: ${parsed.errors[0]?.message ?? 'unknown'}`,
2006
+ { canvas: canvasAbsPath, id: '' }
2007
+ );
2008
+ }
2009
+ const series: AnyNode[] = [];
2010
+ (function find(node: AnyNode): void {
2011
+ if (!node || typeof node !== 'object') return;
2012
+ if (Array.isArray(node)) {
2013
+ for (const c of node) find(c);
2014
+ return;
2015
+ }
2016
+ if (node.type === 'JSXElement' && jsxTagName(node) === 'TransitionSeries') series.push(node);
2017
+ for (const k of Object.keys(node)) {
2018
+ if (k === 'loc' || k === 'range' || k === 'start' || k === 'end' || k === 'type') continue;
2019
+ find(node[k]);
2020
+ }
2021
+ })(parsed.program);
2022
+
2023
+ for (const s of series) {
2024
+ const kinds: Array<'sequence' | 'transition'> = [];
2025
+ for (const child of s.children ?? []) {
2026
+ if (child?.type !== 'JSXElement') continue;
2027
+ const tag = jsxTagName(child);
2028
+ if (tag && TRANSITION_TAGS.has(tag)) kinds.push('transition');
2029
+ else if (tag && CLIP_TAGS.has(tag)) kinds.push('sequence');
2030
+ }
2031
+ if (kinds.length === 0) continue;
2032
+ const bad =
2033
+ kinds[0] !== 'sequence' ||
2034
+ kinds[kinds.length - 1] !== 'sequence' ||
2035
+ kinds.some((k, i) => i > 0 && k === kinds[i - 1]);
2036
+ if (bad) {
2037
+ throw new CanvasEditError(
2038
+ 'TransitionSeries must alternate Sequence/Transition and begin + end with a Sequence (a leading, trailing, or doubled Transition is invalid Remotion)',
2039
+ { canvas: canvasAbsPath, id: '' }
2040
+ );
2041
+ }
2042
+ }
2043
+ return { ok: true };
2044
+ }
2045
+
2046
+ /** Source span of an element extended back over its leading indent + newline
2047
+ * (so removing it doesn't leave a blank line) — mirrors moveElement's removeStart. */
2048
+ function spanWithFraming(source: string, start: number, end: number): [number, number] {
2049
+ const line = lineStartInfo(source, start);
2050
+ const rs = line.newlineBefore ? line.indentStart - 1 : line.indentStart;
2051
+ return [rs, end];
2052
+ }
2053
+
2054
+ /**
2055
+ * Remove a clip (DDR-150 P3), addressed by the enumerator's stableId. Verifies
2056
+ * the content-hash fingerprint; refuses removing the ONLY clip; when the clip
2057
+ * lives in a `<TransitionSeries>` also removes ONE adjacent transition so the
2058
+ * series stays valid; then reparse + semantic gate (the backstop that refuses a
2059
+ * remove which would leave a dangling/doubled transition, rather than corrupt).
2060
+ */
2061
+ export function applyRemoveClip(
2062
+ canvasAbsPath: string,
2063
+ source: string,
2064
+ artboardId: string | undefined,
2065
+ stableId: string,
2066
+ expectedHash: string | undefined
2067
+ ): { source: string } {
2068
+ const { clips } = enumerateClips(canvasAbsPath, source, artboardId);
2069
+ const idx = clips.findIndex((c) => c.stableId === stableId);
2070
+ if (idx < 0) {
2071
+ throw new CanvasEditError(`clip "${stableId}" not found`, {
2072
+ canvas: canvasAbsPath,
2073
+ id: stableId,
2074
+ });
2075
+ }
2076
+ const clip = clips[idx] as ClipInfo;
2077
+ if (expectedHash != null && expectedHash !== clip.contentHash) {
2078
+ throw new CanvasEditError(
2079
+ `clip "${stableId}" changed since it was read (concurrent edit); reload and retry`,
2080
+ { canvas: canvasAbsPath, id: stableId }
2081
+ );
2082
+ }
2083
+ if (clip.kind !== 'sequence') {
2084
+ throw new CanvasEditError(`"${stableId}" is a transition, not a removable clip`, {
2085
+ canvas: canvasAbsPath,
2086
+ id: stableId,
2087
+ });
2088
+ }
2089
+ if (clips.filter((c) => c.kind === 'sequence').length <= 1) {
2090
+ throw new CanvasEditError('cannot remove the only clip — delete the comp instead', {
2091
+ canvas: canvasAbsPath,
2092
+ id: stableId,
2093
+ });
2094
+ }
2095
+ const spans: Array<[number, number]> = [spanWithFraming(source, clip.start, clip.end)];
2096
+ if (clip.tag.startsWith('TransitionSeries.')) {
2097
+ const next = clips[idx + 1];
2098
+ const prev = clips[idx - 1];
2099
+ const t = next?.kind === 'transition' ? next : prev?.kind === 'transition' ? prev : null;
2100
+ if (t) spans.push(spanWithFraming(source, t.start, t.end));
2101
+ }
2102
+ const s = new MagicString(source);
2103
+ for (const [a, b] of spans) s.remove(a, b);
2104
+ const out = s.toString();
2105
+ const parsed = parseSync(canvasAbsPath, out, { sourceType: 'module' });
2106
+ if (parsed.errors && parsed.errors.length > 0) {
2107
+ throw new CanvasEditError(
2108
+ `remove produced invalid source: ${parsed.errors[0]?.message ?? 'parse error'}`,
2109
+ { canvas: canvasAbsPath, id: stableId }
2110
+ );
2111
+ }
2112
+ assertCompSemantics(canvasAbsPath, out); // refuse a dangling/doubled transition
2113
+ return { source: out };
2114
+ }
2115
+
2116
+ /** Remove a clip on disk (atomic write + cross-process lock). */
2117
+ export async function removeClip(
2118
+ canvasAbsPath: string,
2119
+ artboardId: string | undefined,
2120
+ stableId: string,
2121
+ expectedHash: string | undefined
2122
+ ): Promise<{ source: string }> {
2123
+ return withLock(canvasAbsPath, async () => {
2124
+ const file = Bun.file(canvasAbsPath);
2125
+ if (!(await file.exists())) {
2126
+ throw new CanvasEditError(`Canvas not found: ${canvasAbsPath}`, {
2127
+ canvas: canvasAbsPath,
2128
+ id: stableId,
2129
+ });
2130
+ }
2131
+ const source = await file.text();
2132
+ const nextSrc = applyRemoveClip(canvasAbsPath, source, artboardId, stableId, expectedHash);
2133
+ if (nextSrc.source === source) return nextSrc;
2134
+ const tmp = `${canvasAbsPath}.tmp.${Math.random().toString(36).slice(2, 10)}`;
2135
+ await Bun.write(tmp, nextSrc.source);
2136
+ const { rename } = await import('node:fs/promises');
2137
+ await rename(tmp, canvasAbsPath);
2138
+ return nextSrc;
2139
+ });
2140
+ }
2141
+
2142
+ /**
2143
+ * Reorder a clip among its STANDALONE `<Sequence>` siblings (DDR-150 P5) — the
2144
+ * z-order / render-stacking gesture (later sibling paints on top), distinct from
2145
+ * the horizontal `from`-move. Reuses the shipped `applyMove` (re-indent + reparse
2146
+ * + re-settle-through-id-churn) addressed by each clip's own cd-id, then runs the
2147
+ * semantic gate. Refuses a `<TransitionSeries.Sequence>`/`<Series.Sequence>`
2148
+ * (reordering those breaks the transition/back-to-back timing — not a stacking
2149
+ * op) and a self-move. Both `stableId`s are fingerprint-checked. Returns the moved
2150
+ * clip's stableId (recomputed from disk — a pure reorder leaves its content hash
2151
+ * unchanged, so it's found even when addressed by scoped index).
2152
+ */
2153
+ export function applyReorderClip(
2154
+ canvasAbsPath: string,
2155
+ source: string,
2156
+ artboardId: string | undefined,
2157
+ movedStableId: string,
2158
+ movedHash: string | undefined,
2159
+ refStableId: string,
2160
+ refHash: string | undefined,
2161
+ position: MovePosition
2162
+ ): { source: string; stableId: string } {
2163
+ if (movedStableId === refStableId) {
2164
+ throw new CanvasEditError('cannot reorder a clip relative to itself', {
2165
+ canvas: canvasAbsPath,
2166
+ id: movedStableId,
2167
+ });
2168
+ }
2169
+ const moved = resolveClip(canvasAbsPath, source, artboardId, movedStableId, movedHash);
2170
+ const ref = resolveClip(canvasAbsPath, source, artboardId, refStableId, refHash);
2171
+ const bothSeries =
2172
+ (moved.tag === 'TransitionSeries.Sequence' || moved.tag === 'Series.Sequence') &&
2173
+ (ref.tag === 'TransitionSeries.Sequence' || ref.tag === 'Series.Sequence');
2174
+ // TransitionSeries/Series clips play in ORDER (not z-stacked) — "reorder" means
2175
+ // change PLAY order. Physically moving the tag would break the S/T/S/T
2176
+ // alternation, so instead SWAP the two sequences' spans in place: the
2177
+ // transitions between them stay put, alternation is preserved, and the beats
2178
+ // change order (DDR-150 dogfood — "reorder klip vrstvy" on a showreel).
2179
+ if (bothSeries) {
2180
+ const a = moved.start <= ref.start ? moved : ref;
2181
+ const b = moved.start <= ref.start ? ref : moved;
2182
+ const s = new MagicString(source);
2183
+ s.overwrite(a.start, a.end, source.slice(b.start, b.end));
2184
+ s.overwrite(b.start, b.end, source.slice(a.start, a.end));
2185
+ const out = s.toString();
2186
+ const parsed = parseSync(canvasAbsPath, out, { sourceType: 'module' });
2187
+ if (parsed.errors && parsed.errors.length > 0) {
2188
+ throw new CanvasEditError(
2189
+ `reorder produced invalid source: ${parsed.errors[0]?.message ?? 'parse error'}`,
2190
+ { canvas: canvasAbsPath, id: movedStableId }
2191
+ );
2192
+ }
2193
+ assertCompSemantics(canvasAbsPath, out);
2194
+ const after = enumerateClips(canvasAbsPath, out, artboardId);
2195
+ const settled = after.clips.find((c) => c.contentHash === moved.contentHash);
2196
+ return { source: out, stableId: settled?.stableId ?? movedStableId };
2197
+ }
2198
+ if (moved.tag !== 'Sequence' || ref.tag !== 'Sequence') {
2199
+ throw new CanvasEditError(
2200
+ 'reorder needs two sequences of the same kind (standalone ↔ standalone, or series ↔ series)',
2201
+ { canvas: canvasAbsPath, id: movedStableId }
2202
+ );
2203
+ }
2204
+ if (!moved.clipCdId || !ref.clipCdId) {
2205
+ throw new CanvasEditError('clip has no addressable node to reorder', {
2206
+ canvas: canvasAbsPath,
2207
+ id: movedStableId,
2208
+ });
2209
+ }
2210
+ const res = applyMove(canvasAbsPath, source, moved.clipCdId, ref.clipCdId, position);
2211
+ assertCompSemantics(canvasAbsPath, res.source);
2212
+ // A pure reorder never touches the clip body → its content hash is preserved.
2213
+ // Re-address by that hash so a scoped-index stableId still resolves post-move.
2214
+ const after = enumerateClips(canvasAbsPath, res.source, artboardId);
2215
+ const settled = after.clips.find((c) => c.contentHash === moved.contentHash);
2216
+ return { source: res.source, stableId: settled?.stableId ?? movedStableId };
2217
+ }
2218
+
2219
+ /** Reorder a clip on disk (atomic write + cross-process lock). */
2220
+ export async function reorderClip(
2221
+ canvasAbsPath: string,
2222
+ artboardId: string | undefined,
2223
+ movedStableId: string,
2224
+ movedHash: string | undefined,
2225
+ refStableId: string,
2226
+ refHash: string | undefined,
2227
+ position: MovePosition
2228
+ ): Promise<{ source: string; stableId: string }> {
2229
+ return withLock(canvasAbsPath, async () => {
2230
+ const file = Bun.file(canvasAbsPath);
2231
+ if (!(await file.exists())) {
2232
+ throw new CanvasEditError(`Canvas not found: ${canvasAbsPath}`, {
2233
+ canvas: canvasAbsPath,
2234
+ id: movedStableId,
2235
+ });
2236
+ }
2237
+ const source = await file.text();
2238
+ const next = applyReorderClip(
2239
+ canvasAbsPath,
2240
+ source,
2241
+ artboardId,
2242
+ movedStableId,
2243
+ movedHash,
2244
+ refStableId,
2245
+ refHash,
2246
+ position
2247
+ );
2248
+ if (next.source === source) return next;
2249
+ const tmp = `${canvasAbsPath}.tmp.${Math.random().toString(36).slice(2, 10)}`;
2250
+ await Bun.write(tmp, next.source);
2251
+ const { rename } = await import('node:fs/promises');
2252
+ await rename(tmp, canvasAbsPath);
2253
+ return next;
2254
+ });
2255
+ }
2256
+
2257
+ /**
2258
+ * Toggle a clip's visibility (DDR-150 dogfood — "hide clip"). Gates the clip's
2259
+ * children behind `{false && (…)}` (hide) or strips it (show). The clip TAG stays
2260
+ * — so it keeps its time slot and, inside a `<TransitionSeries>`, the S/T/S
2261
+ * alternation stays valid — only its content stops rendering. Reversible.
2262
+ * Fingerprint-checked. Returns the new hidden state.
2263
+ */
2264
+ export function applyToggleClipHidden(
2265
+ canvasAbsPath: string,
2266
+ source: string,
2267
+ artboardId: string | undefined,
2268
+ stableId: string,
2269
+ expectedHash: string | undefined
2270
+ ): { source: string; hidden: boolean } {
2271
+ resolveClip(canvasAbsPath, source, artboardId, stableId, expectedHash); // validate + fingerprint
2272
+ // Re-parse to locate the clip's node (need its opening/closing tag spans).
2273
+ const parsed = parseSync(canvasAbsPath, source, { sourceType: 'module' });
2274
+ if (parsed.errors && parsed.errors.length > 0) {
2275
+ throw new CanvasEditError(`oxc-parser failed: ${parsed.errors[0]?.message ?? 'unknown'}`, {
2276
+ canvas: canvasAbsPath,
2277
+ id: stableId,
2278
+ });
2279
+ }
2280
+ const { clips } = enumerateClips(canvasAbsPath, source, artboardId);
2281
+ const info = clips.find((c) => c.stableId === stableId);
2282
+ if (!info)
2283
+ throw new CanvasEditError(`clip "${stableId}" not found`, {
2284
+ canvas: canvasAbsPath,
2285
+ id: stableId,
2286
+ });
2287
+ // Walk for the JSXElement whose span matches the resolved clip.
2288
+ let node: AnyNode | null = null;
2289
+ (function find(n: AnyNode): void {
2290
+ if (node || !n || typeof n !== 'object') return;
2291
+ if (Array.isArray(n)) {
2292
+ for (const c of n) find(c);
2293
+ return;
2294
+ }
2295
+ if (n.type === 'JSXElement' && n.start === info.start && n.end === info.end) {
2296
+ node = n;
2297
+ return;
2298
+ }
2299
+ for (const k of Object.keys(n)) {
2300
+ if (k === 'loc' || k === 'range' || k === 'start' || k === 'end' || k === 'type') continue;
2301
+ find(n[k]);
2302
+ }
2303
+ })(parsed.program);
2304
+ if (!node)
2305
+ throw new CanvasEditError(`clip "${stableId}" node not found`, {
2306
+ canvas: canvasAbsPath,
2307
+ id: stableId,
2308
+ });
2309
+ const span = clipChildrenSpan(node);
2310
+ if (!span) {
2311
+ throw new CanvasEditError('this clip has no body to hide (self-closing)', {
2312
+ canvas: canvasAbsPath,
2313
+ id: stableId,
2314
+ });
2315
+ }
2316
+ const children = source.slice(span.start, span.end);
2317
+ const s = new MagicString(source);
2318
+ let hidden: boolean;
2319
+ if (/^\s*\{false && \(/.test(children)) {
2320
+ // Unhide — strip the exact wrapper we added.
2321
+ const inner = children.replace(/^(\s*)\{false && \(/, '$1').replace(/\)\}(\s*)$/, '$1');
2322
+ s.overwrite(span.start, span.end, inner);
2323
+ hidden = false;
2324
+ } else {
2325
+ s.overwrite(span.start, span.end, `{false && (${children})}`);
2326
+ hidden = true;
2327
+ }
2328
+ const out = s.toString();
2329
+ const re = parseSync(canvasAbsPath, out, { sourceType: 'module' });
2330
+ if (re.errors && re.errors.length > 0) {
2331
+ throw new CanvasEditError(
2332
+ `hide produced invalid source: ${re.errors[0]?.message ?? 'parse error'}`,
2333
+ {
2334
+ canvas: canvasAbsPath,
2335
+ id: stableId,
2336
+ }
2337
+ );
2338
+ }
2339
+ assertCompSemantics(canvasAbsPath, out);
2340
+ return { source: out, hidden };
2341
+ }
2342
+
2343
+ /** Toggle a clip's hidden state on disk (atomic write + cross-process lock). */
2344
+ export async function toggleClipHidden(
2345
+ canvasAbsPath: string,
2346
+ artboardId: string | undefined,
2347
+ stableId: string,
2348
+ expectedHash: string | undefined
2349
+ ): Promise<{ source: string; hidden: boolean }> {
2350
+ return withLock(canvasAbsPath, async () => {
2351
+ const file = Bun.file(canvasAbsPath);
2352
+ if (!(await file.exists())) {
2353
+ throw new CanvasEditError(`Canvas not found: ${canvasAbsPath}`, {
2354
+ canvas: canvasAbsPath,
2355
+ id: stableId,
2356
+ });
2357
+ }
2358
+ const source = await file.text();
2359
+ const next = applyToggleClipHidden(canvasAbsPath, source, artboardId, stableId, expectedHash);
2360
+ if (next.source === source) return next;
2361
+ const tmp = `${canvasAbsPath}.tmp.${Math.random().toString(36).slice(2, 10)}`;
2362
+ await Bun.write(tmp, next.source);
2363
+ const { rename } = await import('node:fs/promises');
2364
+ await rename(tmp, canvasAbsPath);
2365
+ return next;
2366
+ });
2367
+ }
2368
+
2369
+ /** Media tags a caller may insert as a clip's child (also gated at the api layer). */
2370
+ const INSERTABLE_MEDIA = new Set(['Video', 'Audio', 'Img', 'OffthreadVideo']);
2371
+
2372
+ /**
2373
+ * Insert a new `<Sequence>` clip into a comp (DDR-150 P4), addressed by
2374
+ * artboardId. Appends after the comp's LAST clip (matching its indentation),
2375
+ * optionally with a media child. Scoped to STANDALONE `<Sequence>` comps —
2376
+ * refuses a `<TransitionSeries>` (its overlap/transition math isn't a clean
2377
+ * append) and a comp with no existing clip to anchor to. Reparse + semantic
2378
+ * gate; `src` contained (no ../ or script schemes). Returns the new stableId.
2379
+ */
2380
+ export function applyInsertClip(
2381
+ canvasAbsPath: string,
2382
+ source: string,
2383
+ artboardId: string | undefined,
2384
+ opts: { from: number; durationInFrames: number; mediaTag?: string | null; src?: string | null }
2385
+ ): { source: string; stableId: string | null } {
2386
+ const { clips, compName } = enumerateClips(canvasAbsPath, source, artboardId);
2387
+ if (!compName) {
2388
+ throw new CanvasEditError('no video-comp for this artboard', {
2389
+ canvas: canvasAbsPath,
2390
+ id: artboardId ?? '',
2391
+ });
2392
+ }
2393
+ if (clips.length === 0) {
2394
+ throw new CanvasEditError('comp has no existing clip to anchor the insert', {
2395
+ canvas: canvasAbsPath,
2396
+ id: artboardId ?? '',
2397
+ });
2398
+ }
2399
+ const last = clips[clips.length - 1] as ClipInfo;
2400
+ const from = Math.max(0, Math.round(opts.from));
2401
+ const dur = Math.max(1, Math.round(opts.durationInFrames));
2402
+ const indent = lineStartInfo(source, last.start).indent;
2403
+ const srcTrim = (opts.src ?? '').trim();
2404
+ if (
2405
+ opts.mediaTag &&
2406
+ INSERTABLE_MEDIA.has(opts.mediaTag) &&
2407
+ srcTrim &&
2408
+ (/\.\./.test(srcTrim) || /^\s*(javascript|vbscript|file|data):/i.test(srcTrim))
2409
+ ) {
2410
+ throw new CanvasEditError(
2411
+ 'media src must be a contained asset path (no ../ or script schemes)',
2412
+ {
2413
+ canvas: canvasAbsPath,
2414
+ id: artboardId ?? '',
2415
+ }
2416
+ );
2417
+ }
2418
+ const s = new MagicString(source);
2419
+ if (last.tag.startsWith('TransitionSeries.') || last.tag.startsWith('Series.')) {
2420
+ // DDR-150 dogfood — append INTO a TransitionSeries: add a transition + a new
2421
+ // TransitionSeries.Sequence, keeping the S/T/S alternation. The transition is
2422
+ // CLONED verbatim from an existing one in the series so its
2423
+ // presentation/timing imports are already satisfied (we can't invent a
2424
+ // `fade()` the comp may not import). Series sequences carry no `from`.
2425
+ const seriesPrefix = last.tag.split('.')[0]; // 'TransitionSeries' | 'Series'
2426
+ const proto = clips.find(
2427
+ (c) => c.kind === 'transition' && c.tag.startsWith(`${seriesPrefix}.`)
2428
+ );
2429
+ if (!proto) {
2430
+ throw new CanvasEditError(
2431
+ `cannot append into a <${seriesPrefix}> with no existing transition to clone — add a beat via chat`,
2432
+ { canvas: canvasAbsPath, id: artboardId ?? '' }
2433
+ );
2434
+ }
2435
+ const transitionText = source.slice(proto.start, proto.end);
2436
+ const media =
2437
+ opts.mediaTag && INSERTABLE_MEDIA.has(opts.mediaTag)
2438
+ ? `\n${indent} <${opts.mediaTag} src="${escapeAttr(srcTrim)}" />\n${indent}`
2439
+ : '';
2440
+ const clipText =
2441
+ `\n${indent}${transitionText}` +
2442
+ `\n${indent}<${seriesPrefix}.Sequence durationInFrames={${dur}}>${media}</${seriesPrefix}.Sequence>`;
2443
+ s.appendLeft(last.end, clipText);
2444
+ } else {
2445
+ const media =
2446
+ opts.mediaTag && INSERTABLE_MEDIA.has(opts.mediaTag)
2447
+ ? `\n${indent} <${opts.mediaTag} src="${escapeAttr(srcTrim)}" />\n${indent}`
2448
+ : '';
2449
+ const clipText = `\n${indent}<Sequence from={${from}} durationInFrames={${dur}}>${media}</Sequence>`;
2450
+ s.appendLeft(last.end, clipText);
2451
+ }
2452
+ const out = s.toString();
2453
+ const parsed = parseSync(canvasAbsPath, out, { sourceType: 'module' });
2454
+ if (parsed.errors && parsed.errors.length > 0) {
2455
+ throw new CanvasEditError(
2456
+ `insert produced invalid source: ${parsed.errors[0]?.message ?? 'parse error'}`,
2457
+ { canvas: canvasAbsPath, id: artboardId ?? '' }
2458
+ );
2459
+ }
2460
+ assertCompSemantics(canvasAbsPath, out);
2461
+ const after = enumerateClips(canvasAbsPath, out, artboardId);
2462
+ const newStableId = after.clips.length
2463
+ ? (after.clips[after.clips.length - 1]?.stableId ?? null)
2464
+ : null;
2465
+ return { source: out, stableId: newStableId };
2466
+ }
2467
+
2468
+ /** Insert a clip on disk (atomic write + cross-process lock). */
2469
+ export async function insertClip(
2470
+ canvasAbsPath: string,
2471
+ artboardId: string | undefined,
2472
+ opts: { from: number; durationInFrames: number; mediaTag?: string | null; src?: string | null }
2473
+ ): Promise<{ source: string; stableId: string | null }> {
2474
+ return withLock(canvasAbsPath, async () => {
2475
+ const file = Bun.file(canvasAbsPath);
2476
+ if (!(await file.exists())) {
2477
+ throw new CanvasEditError(`Canvas not found: ${canvasAbsPath}`, {
2478
+ canvas: canvasAbsPath,
2479
+ id: artboardId ?? '',
2480
+ });
2481
+ }
2482
+ const source = await file.text();
2483
+ const next = applyInsertClip(canvasAbsPath, source, artboardId, opts);
2484
+ if (next.source === source) return next;
2485
+ const tmp = `${canvasAbsPath}.tmp.${Math.random().toString(36).slice(2, 10)}`;
2486
+ await Bun.write(tmp, next.source);
2487
+ const { rename } = await import('node:fs/promises');
2488
+ await rename(tmp, canvasAbsPath);
2489
+ return next;
2490
+ });
2491
+ }
2492
+
2493
+ /**
2494
+ * Replace a media `src` that lives in an array-of-objects literal
2495
+ * (`const CLIPS = [{ src: 'assets/a.mp4', … }, …]`) — the showreel pattern where
2496
+ * a clip's `<Video src={clip.src}>` is fed by `<ClipShot clip={CLIPS[i]} />`.
2497
+ * Pure. Rewrites `NAME[index].field`'s string to `value` (contained asset path).
2498
+ * Throws `CanvasEditError` on a bad path / missing target.
2499
+ */
2500
+ export function applyEditArrayElementString(
2501
+ canvasAbsPath: string,
2502
+ source: string,
2503
+ arrayName: string,
2504
+ index: number,
2505
+ field: string,
2506
+ value: string
2507
+ ): { source: string } {
2508
+ const v = value.trim();
2509
+ if (!v || /\.\./.test(v) || /^\s*(javascript|vbscript|file|data|https?):/i.test(v)) {
2510
+ throw new CanvasEditError('src must be a contained asset path (no ../ or scheme)', {
2511
+ canvas: canvasAbsPath,
2512
+ id: `${arrayName}[${index}].${field}`,
2513
+ });
2514
+ }
2515
+ const parsed = parseSync(canvasAbsPath, source, { sourceType: 'module' });
2516
+ if (parsed.errors && parsed.errors.length > 0) {
2517
+ throw new CanvasEditError(`oxc-parser failed: ${parsed.errors[0]?.message ?? 'unknown'}`, {
2518
+ canvas: canvasAbsPath,
2519
+ id: arrayName,
2520
+ });
2521
+ }
2522
+ let arr: AnyNode | null = null;
2523
+ for (const stmt of (parsed.program?.body ?? []) as AnyNode[]) {
2524
+ if (stmt?.type !== 'VariableDeclaration') continue;
2525
+ for (const d of stmt.declarations ?? []) {
2526
+ if (
2527
+ d?.id?.type === 'Identifier' &&
2528
+ d.id.name === arrayName &&
2529
+ d.init?.type === 'ArrayExpression'
2530
+ ) {
2531
+ arr = d.init;
2532
+ }
2533
+ }
2534
+ }
2535
+ if (!arr)
2536
+ throw new CanvasEditError(`array "${arrayName}" not found`, {
2537
+ canvas: canvasAbsPath,
2538
+ id: arrayName,
2539
+ });
2540
+ const el = (arr.elements ?? [])[index] as AnyNode | undefined;
2541
+ if (!el || el.type !== 'ObjectExpression') {
2542
+ throw new CanvasEditError(`${arrayName}[${index}] is not an object literal`, {
2543
+ canvas: canvasAbsPath,
2544
+ id: `${arrayName}[${index}]`,
2545
+ });
2546
+ }
2547
+ const prop = (el.properties ?? []).find(
2548
+ (p: AnyNode) => p?.key?.name === field || p?.key?.value === field
2549
+ );
2550
+ const valNode = prop?.value;
2551
+ if (!valNode || (valNode.type !== 'Literal' && valNode.type !== 'StringLiteral')) {
2552
+ throw new CanvasEditError(`${arrayName}[${index}].${field} is not a string literal`, {
2553
+ canvas: canvasAbsPath,
2554
+ id: `${arrayName}[${index}].${field}`,
2555
+ });
2556
+ }
2557
+ const s = new MagicString(source);
2558
+ s.overwrite(valNode.start as number, valNode.end as number, JSON.stringify(v));
2559
+ const out = s.toString();
2560
+ const re = parseSync(canvasAbsPath, out, { sourceType: 'module' });
2561
+ if (re.errors && re.errors.length > 0) {
2562
+ throw new CanvasEditError(
2563
+ `edit produced invalid source: ${re.errors[0]?.message ?? 'parse error'}`,
2564
+ {
2565
+ canvas: canvasAbsPath,
2566
+ id: arrayName,
2567
+ }
2568
+ );
2569
+ }
2570
+ return { source: out };
2571
+ }
2572
+
2573
+ /** Edit an array-element src on disk (atomic write + cross-process lock). */
2574
+ export async function editArrayElementString(
2575
+ canvasAbsPath: string,
2576
+ arrayName: string,
2577
+ index: number,
2578
+ field: string,
2579
+ value: string
2580
+ ): Promise<{ source: string }> {
2581
+ return withLock(canvasAbsPath, async () => {
2582
+ const file = Bun.file(canvasAbsPath);
2583
+ if (!(await file.exists())) {
2584
+ throw new CanvasEditError(`Canvas not found: ${canvasAbsPath}`, {
2585
+ canvas: canvasAbsPath,
2586
+ id: arrayName,
2587
+ });
2588
+ }
2589
+ const source = await file.text();
2590
+ const next = applyEditArrayElementString(canvasAbsPath, source, arrayName, index, field, value);
2591
+ if (next.source === source) return next;
2592
+ const tmp = `${canvasAbsPath}.tmp.${Math.random().toString(36).slice(2, 10)}`;
2593
+ await Bun.write(tmp, next.source);
2594
+ const { rename } = await import('node:fs/promises');
2595
+ await rename(tmp, canvasAbsPath);
2596
+ return next;
2597
+ });
2598
+ }
2599
+
2600
+ // ---------------------------------------------------------------------------
2601
+ // General element structural edits — delete / insert / new-artboard (Stage I,
2602
+ // feature-element-editing-robustness). These extend the DDR-138/139 move/reorder
2603
+ // model (and the DDR-150 clip delete/insert) from video-comp <Sequence> clips to
2604
+ // ARBITRARY canvas elements + whole artboards. Every op is a whole-file
2605
+ // {before, after} write (a structural edit renumbers positional data-cd-ids, so
2606
+ // an inverse *descriptor* goes stale — the caller logs before/after under a seq
2607
+ // and Cmd+Z reverts by whole-file swap through /_api/reorder-revert). Each runs
2608
+ // the same reparse gate as `applyMove`: never write source that doesn't parse.
2609
+
2610
+ /** Kind of element the insert palette can synthesize. */
2611
+ export type InsertKind = 'div' | 'text' | 'image';
2612
+
2613
+ /** Synthesize a minimal, self-styled JSX element for an insert. No `data-cd-id`
2614
+ * — the pipeline stamps it on the next transpile (canvas-pipeline stamping is
2615
+ * unconditional). The new element lands selectable + immediately styleable. */
2616
+ function synthInsertElement(kind: InsertKind, src?: string): string {
2617
+ if (kind === 'text') return `<p style={{ margin: 0 }}>Text</p>`;
2618
+ if (kind === 'image') {
2619
+ const s = (src ?? '').trim();
2620
+ return `<img src="${escapeAttr(s)}" alt="" style={{ width: 160, height: 120, objectFit: 'cover', borderRadius: 8 }} />`;
2621
+ }
2622
+ // div — a visible neutral placeholder box the user can immediately restyle.
2623
+ return `<div style={{ width: 120, height: 80, background: 'var(--bg-2)', borderRadius: 8 }} />`;
2624
+ }
2625
+
2626
+ /**
2627
+ * Delete the element with `data-cd-id === id` from a canvas. Pure; never mutates
2628
+ * disk (the async `deleteElement` wraps this under the per-file lock). For a
2629
+ * reused-component INSTANCE the id names an element inside the component
2630
+ * definition (shared across N usages); `occurrence` maps it to the specific
2631
+ * `<Component/>` USAGE so deleting one instance is artboard-local — deleting the
2632
+ * shared internal element is inherently global (there is only one). Mirrors
2633
+ * `applyRemoveClip`: remove the framed span (leading indent + newline), then the
2634
+ * reparse gate refuses a delete that would leave invalid JSX (e.g. an element
2635
+ * that was the sole child of a `{expr}` that now dangles).
2636
+ */
2637
+ export function applyDeleteElement(
2638
+ canvasAbsPath: string,
2639
+ source: string,
2640
+ id: string,
2641
+ occurrence?: number
2642
+ ): { source: string; deletedId: string } {
2643
+ const parsed = parseSync(canvasAbsPath, source, { sourceType: 'module' });
2644
+ if (parsed.errors && parsed.errors.length > 0) {
2645
+ throw new CanvasEditError(
2646
+ `oxc-parser failed on ${canvasAbsPath}: ${parsed.errors[0]?.message ?? 'unknown'}`,
2647
+ { canvas: canvasAbsPath, id }
2648
+ );
2649
+ }
2650
+ const targetId = resolveUsageId(parsed.program, id, occurrence);
2651
+ const hit = findOpening(parsed.program, targetId);
2652
+ if (!hit) {
2653
+ throw new CanvasEditError(`data-cd-id "${targetId}" not found in ${canvasAbsPath}`, {
2654
+ canvas: canvasAbsPath,
2655
+ id: targetId,
2656
+ });
2657
+ }
2658
+ const el = hit.element;
2659
+ const [rs, re] = spanWithFraming(source, el.start as number, el.end as number);
2660
+ const s = new MagicString(source);
2661
+ s.remove(rs, re);
2662
+ const out = s.toString();
2663
+ const check = parseSync(canvasAbsPath, out, { sourceType: 'module' });
2664
+ if (check.errors && check.errors.length > 0) {
2665
+ throw new CanvasEditError(
2666
+ `delete would produce invalid source (${check.errors[0]?.message ?? 'parse error'}); aborted — the element may be the sole child a parent requires`,
2667
+ { canvas: canvasAbsPath, id: targetId }
2668
+ );
2669
+ }
2670
+ return { source: out, deletedId: targetId };
2671
+ }
2672
+
2673
+ /** Delete an element on disk (atomic write + cross-process lock). */
2674
+ export async function deleteElement(
2675
+ canvasAbsPath: string,
2676
+ id: string,
2677
+ occurrence?: number
2678
+ ): Promise<{ source: string; deletedId: string }> {
2679
+ return withLock(canvasAbsPath, async () => {
2680
+ const file = Bun.file(canvasAbsPath);
2681
+ if (!(await file.exists())) {
2682
+ throw new CanvasEditError(`Canvas not found: ${canvasAbsPath}`, {
2683
+ canvas: canvasAbsPath,
2684
+ id,
2685
+ });
2686
+ }
2687
+ const source = await file.text();
2688
+ const next = applyDeleteElement(canvasAbsPath, source, id, occurrence);
2689
+ if (next.source === source) return next;
2690
+ const tmp = `${canvasAbsPath}.tmp.${Math.random().toString(36).slice(2, 10)}`;
2691
+ await Bun.write(tmp, next.source);
2692
+ const { rename } = await import('node:fs/promises');
2693
+ await rename(tmp, canvasAbsPath);
2694
+ return next;
2695
+ });
2696
+ }
2697
+
2698
+ /**
2699
+ * Insert a synthesized element (`div` / `text` / `image`) relative to the
2700
+ * element with `data-cd-id === refId`. Pure. Reuses `applyMove`'s per-position
2701
+ * anchor + indentation logic (the new element is a single line, so no
2702
+ * continuation re-indent is needed). The inserted element carries NO
2703
+ * `data-cd-id`; after the reparse gate we recompute its post-transpile
2704
+ * positional id (the id the DOM will carry once the pipeline stamps it) by
2705
+ * locating the freshly-inserted node, and return it so the caller can select the
2706
+ * new element. An `image` needs a contained asset `src` (the AssetPicker
2707
+ * supplies one — never a remote hotlink, which the CSP split origin blocks).
2708
+ */
2709
+ export function applyInsertElement(
2710
+ canvasAbsPath: string,
2711
+ source: string,
2712
+ refId: string,
2713
+ position: MovePosition,
2714
+ kind: InsertKind,
2715
+ opts?: { src?: string; occurrence?: number }
2716
+ ): { source: string; newId: string | null } {
2717
+ if (kind === 'image') {
2718
+ if (!opts?.src?.trim()) {
2719
+ throw new CanvasEditError('insert image requires a contained asset src (assets/…)', {
2720
+ canvas: canvasAbsPath,
2721
+ id: refId,
2722
+ });
2723
+ }
2724
+ assertContainedAssetSrc(opts.src, canvasAbsPath);
2725
+ }
2726
+ const parsed = parseSync(canvasAbsPath, source, { sourceType: 'module' });
2727
+ if (parsed.errors && parsed.errors.length > 0) {
2728
+ throw new CanvasEditError(
2729
+ `oxc-parser failed on ${canvasAbsPath}: ${parsed.errors[0]?.message ?? 'unknown'}`,
2730
+ { canvas: canvasAbsPath, id: refId }
2731
+ );
2732
+ }
2733
+ const targetRef = resolveUsageId(parsed.program, refId, opts?.occurrence);
2734
+ const ref = findOpening(parsed.program, targetRef);
2735
+ if (!ref) {
2736
+ throw new CanvasEditError(`reference data-cd-id "${targetRef}" not found in ${canvasAbsPath}`, {
2737
+ canvas: canvasAbsPath,
2738
+ id: targetRef,
2739
+ });
2740
+ }
2741
+ const refEl = ref.element;
2742
+ const rStart = refEl.start as number;
2743
+ const rEnd = refEl.end as number;
2744
+ const inside = position === 'inside-start' || position === 'inside-end';
2745
+ if (inside && (refEl.openingElement?.selfClosing || !refEl.closingElement)) {
2746
+ throw new CanvasEditError(
2747
+ `target "${targetRef}" is self-closing — cannot nest an element inside it`,
2748
+ { canvas: canvasAbsPath, id: targetRef }
2749
+ );
2750
+ }
2751
+
2752
+ const indentUnit = detectIndentUnit(source);
2753
+ const newText = synthInsertElement(kind, opts?.src);
2754
+
2755
+ let targetIndent: string;
2756
+ let anchor: number;
2757
+ let insertText: string;
2758
+ if (position === 'after') {
2759
+ targetIndent = lineStartInfo(source, rStart).indent;
2760
+ anchor = rEnd;
2761
+ insertText = `\n${targetIndent}${newText}`;
2762
+ } else if (position === 'before') {
2763
+ const rLine = lineStartInfo(source, rStart);
2764
+ targetIndent = rLine.indent;
2765
+ if (rLine.newlineBefore) {
2766
+ anchor = rLine.indentStart - 1;
2767
+ insertText = `\n${targetIndent}${newText}`;
2768
+ } else {
2769
+ anchor = rLine.indentStart;
2770
+ insertText = `${targetIndent}${newText}\n`;
2771
+ }
2772
+ } else if (position === 'inside-start') {
2773
+ targetIndent = lineStartInfo(source, rStart).indent + indentUnit;
2774
+ anchor = refEl.openingElement.end as number;
2775
+ insertText = `\n${targetIndent}${newText}`;
2776
+ } else {
2777
+ // inside-end — last child, before the closing tag's own line.
2778
+ targetIndent = lineStartInfo(source, rStart).indent + indentUnit;
2779
+ const cStart = refEl.closingElement.start as number;
2780
+ const cLine = lineStartInfo(source, cStart);
2781
+ if (cLine.newlineBefore) {
2782
+ anchor = cLine.indentStart - 1;
2783
+ insertText = `\n${targetIndent}${newText}`;
2784
+ } else {
2785
+ anchor = cStart;
2786
+ insertText = `${newText}`;
2787
+ }
2788
+ }
2789
+
2790
+ const s = new MagicString(source);
2791
+ s.appendLeft(anchor, insertText);
2792
+ const out = s.toString();
2793
+ const check = parseSync(canvasAbsPath, out, { sourceType: 'module' });
2794
+ if (check.errors && check.errors.length > 0) {
2795
+ throw new CanvasEditError(
2796
+ `insert would produce invalid source (${check.errors[0]?.message ?? 'parse error'}); aborted`,
2797
+ { canvas: canvasAbsPath, id: targetRef }
2798
+ );
2799
+ }
2800
+ // Recompute the new element's post-insert positional id. There's a single
2801
+ // append (no removes), so the new element's `<` lands at `anchor + prefixLen`
2802
+ // in `out`; match by that exact offset (robust when the inserted text is a
2803
+ // duplicate of a sibling), falling back to a text match.
2804
+ const prefixLen = insertText.indexOf(newText);
2805
+ const elemStart = anchor + prefixLen;
2806
+ let newId: string | null = null;
2807
+ let fallback: string | null = null;
2808
+ for (const { id: eid, node } of collectElements(check.program)) {
2809
+ if ((node.start as number) === elemStart) {
2810
+ newId = eid;
2811
+ break;
2812
+ }
2813
+ if (out.slice(node.start as number, node.end as number) === newText) fallback = eid;
2814
+ }
2815
+ return { source: out, newId: newId ?? fallback };
2816
+ }
2817
+
2818
+ /** Insert an element on disk (atomic write + cross-process lock). */
2819
+ export async function insertElement(
2820
+ canvasAbsPath: string,
2821
+ refId: string,
2822
+ position: MovePosition,
2823
+ kind: InsertKind,
2824
+ opts?: { src?: string; occurrence?: number }
2825
+ ): Promise<{ source: string; newId: string | null }> {
2826
+ return withLock(canvasAbsPath, async () => {
2827
+ const file = Bun.file(canvasAbsPath);
2828
+ if (!(await file.exists())) {
2829
+ throw new CanvasEditError(`Canvas not found: ${canvasAbsPath}`, {
2830
+ canvas: canvasAbsPath,
2831
+ id: refId,
2832
+ });
2833
+ }
2834
+ const source = await file.text();
2835
+ const next = applyInsertElement(canvasAbsPath, source, refId, position, kind, opts);
2836
+ if (next.source === source) return next;
2837
+ const tmp = `${canvasAbsPath}.tmp.${Math.random().toString(36).slice(2, 10)}`;
2838
+ await Bun.write(tmp, next.source);
2839
+ const { rename } = await import('node:fs/promises');
2840
+ await rename(tmp, canvasAbsPath);
2841
+ return next;
2842
+ });
2843
+ }
2844
+
2845
+ /**
2846
+ * Duplicate the element with `data-cd-id === id` — insert a verbatim copy of its
2847
+ * source as the next sibling (Cmd+D, Task L3). Since the SOURCE carries no
2848
+ * data-cd-id (they're injected at transpile, not stored on disk), the clone gets
2849
+ * a fresh positional id on the next transpile — no id collision. Reused-component
2850
+ * instances resolve via resolveUsageId so the copy is the `<Card/>` USAGE
2851
+ * (artboard-local), consistent with the Stage-H scope model.
2852
+ */
2853
+ export function applyDuplicateElement(
2854
+ canvasAbsPath: string,
2855
+ source: string,
2856
+ id: string,
2857
+ occurrence?: number
2858
+ ): { source: string; newId: string | null } {
2859
+ const parsed = parseSync(canvasAbsPath, source, { sourceType: 'module' });
2860
+ if (parsed.errors && parsed.errors.length > 0) {
2861
+ throw new CanvasEditError(
2862
+ `oxc-parser failed on ${canvasAbsPath}: ${parsed.errors[0]?.message ?? 'unknown'}`,
2863
+ { canvas: canvasAbsPath, id }
2864
+ );
2865
+ }
2866
+ const targetId = resolveUsageId(parsed.program, id, occurrence);
2867
+ const hit = findOpening(parsed.program, targetId);
2868
+ if (!hit) {
2869
+ throw new CanvasEditError(`data-cd-id "${targetId}" not found in ${canvasAbsPath}`, {
2870
+ canvas: canvasAbsPath,
2871
+ id: targetId,
2872
+ });
2873
+ }
2874
+ const el = hit.element;
2875
+ const elStart = el.start as number;
2876
+ const elEnd = el.end as number;
2877
+ // The clone is the element's own source, placed at the same indent as the next
2878
+ // sibling (its internal lines are already indented relative to that level).
2879
+ const cloneText = source.slice(elStart, elEnd);
2880
+ const targetIndent = lineStartInfo(source, elStart).indent;
2881
+ const insertText = `\n${targetIndent}${cloneText}`;
2882
+ const s = new MagicString(source);
2883
+ s.appendLeft(elEnd, insertText);
2884
+ const out = s.toString();
2885
+ const check = parseSync(canvasAbsPath, out, { sourceType: 'module' });
2886
+ if (check.errors && check.errors.length > 0) {
2887
+ throw new CanvasEditError(
2888
+ `duplicate would produce invalid source (${check.errors[0]?.message ?? 'parse error'}); aborted`,
2889
+ { canvas: canvasAbsPath, id: targetId }
2890
+ );
2891
+ }
2892
+ // Single append (no removes) → the copy's `<` lands at this exact offset.
2893
+ const elemStart = elEnd + insertText.indexOf(cloneText);
2894
+ let newId: string | null = null;
2895
+ let fallback: string | null = null;
2896
+ for (const { id: eid, node } of collectElements(check.program)) {
2897
+ if ((node.start as number) === elemStart) {
2898
+ newId = eid;
2899
+ break;
2900
+ }
2901
+ if (out.slice(node.start as number, node.end as number) === cloneText) fallback = eid;
2902
+ }
2903
+ return { source: out, newId: newId ?? fallback };
2904
+ }
2905
+
2906
+ /** Duplicate an element on disk (atomic write + cross-process lock). */
2907
+ export async function duplicateElement(
2908
+ canvasAbsPath: string,
2909
+ id: string,
2910
+ occurrence?: number
2911
+ ): Promise<{ source: string; newId: string | null }> {
2912
+ return withLock(canvasAbsPath, async () => {
2913
+ const file = Bun.file(canvasAbsPath);
2914
+ if (!(await file.exists())) {
2915
+ throw new CanvasEditError(`Canvas not found: ${canvasAbsPath}`, {
2916
+ canvas: canvasAbsPath,
2917
+ id,
2918
+ });
2919
+ }
2920
+ const source = await file.text();
2921
+ const next = applyDuplicateElement(canvasAbsPath, source, id, occurrence);
2922
+ if (next.source === source) return next;
2923
+ const tmp = `${canvasAbsPath}.tmp.${Math.random().toString(36).slice(2, 10)}`;
2924
+ await Bun.write(tmp, next.source);
2925
+ const { rename } = await import('node:fs/promises');
2926
+ await rename(tmp, canvasAbsPath);
2927
+ return next;
2928
+ });
2929
+ }
2930
+
2931
+ /** Collect every JSXElement whose opening tag is `tagName` (document order). */
2932
+ function collectJsxByTag(program: AnyNode, tagName: string): AnyNode[] {
2933
+ const out: AnyNode[] = [];
2934
+ function visit(node: AnyNode): void {
2935
+ if (!node || typeof node !== 'object') return;
2936
+ if (Array.isArray(node)) {
2937
+ for (const c of node) visit(c);
2938
+ return;
2939
+ }
2940
+ if (typeof node.type !== 'string') return;
2941
+ if (node.type === 'JSXElement') {
2942
+ const n = node.openingElement?.name;
2943
+ if (n?.type === 'JSXIdentifier' && n.name === tagName) out.push(node);
2944
+ }
2945
+ for (const k of Object.keys(node)) {
2946
+ if (k === 'loc' || k === 'range' || k === 'start' || k === 'end' || k === 'type') continue;
2947
+ visit(node[k]);
2948
+ }
2949
+ }
2950
+ visit(program);
2951
+ return out;
2952
+ }
2953
+
2954
+ /**
2955
+ * Write a NUMERIC JSX attribute (`width={430}`) — the shape `<DCArtboard>` size
2956
+ * props take (DDR-027). Distinct from `editStringAttr`, which would emit
2957
+ * `width="430"` and break the numeric prop. Overwrites an existing `{expr}` or
2958
+ * `"literal"`, or inserts the attribute after the tag name if missing.
2959
+ */
2960
+ function writeNumericAttr(s: MagicString, opening: AnyNode, name: string, value: number): void {
2961
+ const num = Math.max(1, Math.round(value));
2962
+ const attr = findAttribute(opening, name);
2963
+ if (attr) {
2964
+ const v = attr.value;
2965
+ if (!v) {
2966
+ s.appendLeft(attr.end as number, `={${num}}`);
2967
+ return;
2968
+ }
2969
+ // Any value shape (expression container or string literal) → a numeric expr.
2970
+ s.overwrite(v.start as number, v.end as number, `{${num}}`);
2971
+ return;
2972
+ }
2973
+ const insertAt: number | undefined = opening?.name?.end;
2974
+ if (typeof insertAt !== 'number') throw new Error('Opening element has no resolvable name range');
2975
+ s.appendLeft(insertAt, ` ${name}={${num}}`);
2976
+ }
2977
+
2978
+ /**
2979
+ * Free-hand artboard resize (Stage D4). Rewrites the `width` / `height` NUMERIC
2980
+ * props on the `<DCArtboard>` whose `id` prop equals `artboardId` — per DDR-027
2981
+ * artboard size is JSX-authoritative (not a `layout` field), and an artboard's
2982
+ * rendered `<article data-dc-screen>` carries no `data-cd-id`, so it's addressed
2983
+ * by its own `id` prop, not the cd-id lane. Pure; reparse-gated.
2984
+ */
2985
+ export function applyResizeArtboard(
2986
+ canvasAbsPath: string,
2987
+ source: string,
2988
+ artboardId: string,
2989
+ width: number | undefined,
2990
+ height: number | undefined
2991
+ ): { source: string } {
2992
+ if (width == null && height == null) return { source };
2993
+ const parsed = parseSync(canvasAbsPath, source, { sourceType: 'module' });
2994
+ if (parsed.errors && parsed.errors.length > 0) {
2995
+ throw new CanvasEditError(
2996
+ `oxc-parser failed on ${canvasAbsPath}: ${parsed.errors[0]?.message ?? 'unknown'}`,
2997
+ { canvas: canvasAbsPath, id: artboardId }
2998
+ );
2999
+ }
3000
+ const artboards = collectJsxByTag(parsed.program, 'DCArtboard');
3001
+ const target = artboards.find((a) => getStringAttr(a.openingElement, 'id') === artboardId);
3002
+ if (!target) {
3003
+ throw new CanvasEditError(`<DCArtboard id="${artboardId}"> not found in ${canvasAbsPath}`, {
3004
+ canvas: canvasAbsPath,
3005
+ id: artboardId,
3006
+ });
3007
+ }
3008
+ const s = new MagicString(source);
3009
+ if (typeof width === 'number') writeNumericAttr(s, target.openingElement, 'width', width);
3010
+ if (typeof height === 'number') writeNumericAttr(s, target.openingElement, 'height', height);
3011
+ const out = s.toString();
3012
+ const check = parseSync(canvasAbsPath, out, { sourceType: 'module' });
3013
+ if (check.errors && check.errors.length > 0) {
3014
+ throw new CanvasEditError(
3015
+ `artboard resize produced invalid source (${check.errors[0]?.message ?? 'parse error'})`,
3016
+ { canvas: canvasAbsPath, id: artboardId }
3017
+ );
3018
+ }
3019
+ return { source: out };
3020
+ }
3021
+
3022
+ /** Resize an artboard on disk (atomic write + cross-process lock). */
3023
+ export async function resizeArtboard(
3024
+ canvasAbsPath: string,
3025
+ artboardId: string,
3026
+ width: number | undefined,
3027
+ height: number | undefined
3028
+ ): Promise<{ source: string }> {
3029
+ return withLock(canvasAbsPath, async () => {
3030
+ const file = Bun.file(canvasAbsPath);
3031
+ if (!(await file.exists())) {
3032
+ throw new CanvasEditError(`Canvas not found: ${canvasAbsPath}`, {
3033
+ canvas: canvasAbsPath,
3034
+ id: artboardId,
3035
+ });
3036
+ }
3037
+ const source = await file.text();
3038
+ const next = applyResizeArtboard(canvasAbsPath, source, artboardId, width, height);
3039
+ if (next.source === source) return next;
3040
+ const tmp = `${canvasAbsPath}.tmp.${Math.random().toString(36).slice(2, 10)}`;
3041
+ await Bun.write(tmp, next.source);
3042
+ const { rename } = await import('node:fs/promises');
3043
+ await rename(tmp, canvasAbsPath);
3044
+ return next;
3045
+ });
3046
+ }
3047
+
3048
+ /**
3049
+ * Delete the `<DCArtboard id="…">` whose `id` prop equals `artboardId` — the
3050
+ * artboard counterpart of applyDeleteElement (an artboard is addressed by its id
3051
+ * PROP, since the rendered `<article data-dc-screen>` has no data-cd-id; same
3052
+ * convention as applyResizeArtboard). Removes the framed span + reparse-gates,
3053
+ * and refuses to delete the LAST artboard (a canvas with zero artboards renders
3054
+ * nothing). Whole-file-snapshot undo, like the other structural ops.
3055
+ */
3056
+ export function applyDeleteArtboard(
3057
+ canvasAbsPath: string,
3058
+ source: string,
3059
+ artboardId: string
3060
+ ): { source: string } {
3061
+ const parsed = parseSync(canvasAbsPath, source, { sourceType: 'module' });
3062
+ if (parsed.errors && parsed.errors.length > 0) {
3063
+ throw new CanvasEditError(
3064
+ `oxc-parser failed on ${canvasAbsPath}: ${parsed.errors[0]?.message ?? 'unknown'}`,
3065
+ { canvas: canvasAbsPath, id: artboardId }
3066
+ );
3067
+ }
3068
+ const artboards = collectJsxByTag(parsed.program, 'DCArtboard');
3069
+ if (artboards.length <= 1) {
3070
+ throw new CanvasEditError('cannot delete the last artboard on the canvas', {
3071
+ canvas: canvasAbsPath,
3072
+ id: artboardId,
3073
+ });
3074
+ }
3075
+ const target = artboards.find((a) => getStringAttr(a.openingElement, 'id') === artboardId);
3076
+ if (!target) {
3077
+ throw new CanvasEditError(`<DCArtboard id="${artboardId}"> not found in ${canvasAbsPath}`, {
3078
+ canvas: canvasAbsPath,
3079
+ id: artboardId,
3080
+ });
3081
+ }
3082
+ const [rs, re] = spanWithFraming(source, target.start as number, target.end as number);
3083
+ const s = new MagicString(source);
3084
+ s.remove(rs, re);
3085
+ const out = s.toString();
3086
+ const check = parseSync(canvasAbsPath, out, { sourceType: 'module' });
3087
+ if (check.errors && check.errors.length > 0) {
3088
+ throw new CanvasEditError(
3089
+ `artboard delete produced invalid source (${check.errors[0]?.message ?? 'parse error'})`,
3090
+ { canvas: canvasAbsPath, id: artboardId }
3091
+ );
3092
+ }
3093
+ return { source: out };
3094
+ }
3095
+
3096
+ /** Delete an artboard on disk (atomic write + cross-process lock). */
3097
+ export async function deleteArtboard(
3098
+ canvasAbsPath: string,
3099
+ artboardId: string
3100
+ ): Promise<{ source: string }> {
3101
+ return withLock(canvasAbsPath, async () => {
3102
+ const file = Bun.file(canvasAbsPath);
3103
+ if (!(await file.exists())) {
3104
+ throw new CanvasEditError(`Canvas not found: ${canvasAbsPath}`, {
3105
+ canvas: canvasAbsPath,
3106
+ id: artboardId,
3107
+ });
3108
+ }
3109
+ const source = await file.text();
3110
+ const next = applyDeleteArtboard(canvasAbsPath, source, artboardId);
3111
+ if (next.source === source) return next;
3112
+ const tmp = `${canvasAbsPath}.tmp.${Math.random().toString(36).slice(2, 10)}`;
3113
+ await Bun.write(tmp, next.source);
3114
+ const { rename } = await import('node:fs/promises');
3115
+ await rename(tmp, canvasAbsPath);
3116
+ return next;
3117
+ });
3118
+ }
3119
+
3120
+ /** Artboard id must be a safe JSX-attribute identifier (no quotes/markup). */
3121
+ const ARTBOARD_ID_RE = /^[A-Za-z][\w-]*$/;
3122
+
3123
+ /**
3124
+ * Insert a new EMPTY `<DCArtboard>` after the canvas's last artboard (Stage I4).
3125
+ * Pure. The size props (`width`/`height`) are JSX-authoritative (DDR-027); the
3126
+ * grid position is left to the caller's `patchCanvasMeta` (DDR-027 default-grid
3127
+ * places an unpositioned artboard at the next free slot). The artboard is empty
3128
+ * — a clean frame the user fills via insert-element — and non-self-closing so an
3129
+ * `inside-end` insert can target it. Reparse-gated. Rejects a duplicate `id`.
3130
+ */
3131
+ export function applyInsertArtboard(
3132
+ canvasAbsPath: string,
3133
+ source: string,
3134
+ opts: { id: string; label: string; width: number; height: number }
3135
+ ): { source: string; artboardId: string } {
3136
+ const id = opts.id.trim();
3137
+ const label = opts.label.trim();
3138
+ const width = Math.max(1, Math.round(opts.width));
3139
+ const height = Math.max(1, Math.round(opts.height));
3140
+ if (!ARTBOARD_ID_RE.test(id)) {
3141
+ throw new CanvasEditError(`invalid artboard id "${id}" (must match ${ARTBOARD_ID_RE})`, {
3142
+ canvas: canvasAbsPath,
3143
+ id,
3144
+ });
3145
+ }
3146
+ const parsed = parseSync(canvasAbsPath, source, { sourceType: 'module' });
3147
+ if (parsed.errors && parsed.errors.length > 0) {
3148
+ throw new CanvasEditError(
3149
+ `oxc-parser failed on ${canvasAbsPath}: ${parsed.errors[0]?.message ?? 'unknown'}`,
3150
+ { canvas: canvasAbsPath, id }
3151
+ );
3152
+ }
3153
+ const artboards = collectJsxByTag(parsed.program, 'DCArtboard');
3154
+ if (artboards.length === 0) {
3155
+ throw new CanvasEditError(
3156
+ 'no <DCArtboard> to anchor a new artboard — scaffold a canvas first',
3157
+ { canvas: canvasAbsPath, id }
3158
+ );
3159
+ }
3160
+ for (const a of artboards) {
3161
+ if (getStringAttr(a.openingElement, 'id') === id) {
3162
+ throw new CanvasEditError(`artboard id "${id}" already exists`, {
3163
+ canvas: canvasAbsPath,
3164
+ id,
3165
+ });
3166
+ }
3167
+ }
3168
+ const last = artboards[artboards.length - 1] as AnyNode;
3169
+ const indent = lineStartInfo(source, last.start as number).indent;
3170
+ const newText = `<DCArtboard id="${escapeAttr(id)}" label="${escapeAttr(label)}" width={${width}} height={${height}}></DCArtboard>`;
3171
+ const s = new MagicString(source);
3172
+ s.appendLeft(last.end as number, `\n${indent}${newText}`);
3173
+ const out = s.toString();
3174
+ const check = parseSync(canvasAbsPath, out, { sourceType: 'module' });
3175
+ if (check.errors && check.errors.length > 0) {
3176
+ throw new CanvasEditError(
3177
+ `insert-artboard produced invalid source (${check.errors[0]?.message ?? 'parse error'})`,
3178
+ { canvas: canvasAbsPath, id }
3179
+ );
3180
+ }
3181
+ return { source: out, artboardId: id };
3182
+ }
3183
+
3184
+ /** Insert a new empty artboard on disk (atomic write + cross-process lock). */
3185
+ export async function insertArtboard(
3186
+ canvasAbsPath: string,
3187
+ opts: { id: string; label: string; width: number; height: number }
3188
+ ): Promise<{ source: string; artboardId: string }> {
3189
+ return withLock(canvasAbsPath, async () => {
3190
+ const file = Bun.file(canvasAbsPath);
3191
+ if (!(await file.exists())) {
3192
+ throw new CanvasEditError(`Canvas not found: ${canvasAbsPath}`, {
3193
+ canvas: canvasAbsPath,
3194
+ id: opts.id,
3195
+ });
3196
+ }
3197
+ const source = await file.text();
3198
+ const next = applyInsertArtboard(canvasAbsPath, source, opts);
3199
+ if (next.source === source) return next;
3200
+ const tmp = `${canvasAbsPath}.tmp.${Math.random().toString(36).slice(2, 10)}`;
3201
+ await Bun.write(tmp, next.source);
3202
+ const { rename } = await import('node:fs/promises');
3203
+ await rename(tmp, canvasAbsPath);
3204
+ return next;
3205
+ });
3206
+ }
3207
+
3208
+ /** A clip in an assemble request — a dropped reference chip's src + kind. */
3209
+ export interface AssembleClip {
3210
+ src: string;
3211
+ mediaKind: 'video' | 'audio';
3212
+ /** Intrinsic duration probed client-side; falls back to fps*3 when absent. */
3213
+ durationInFrames?: number | null;
3214
+ }
3215
+
3216
+ /** Reject a src that isn't a contained relative asset path (no ../, no scheme). */
3217
+ function assertContainedAssetSrc(src: string, canvasAbsPath: string): void {
3218
+ const t = src.trim();
3219
+ // ALLOWLIST (G3 security, DDR-152 — hardened from a scheme denylist per the
3220
+ // adversarial review): a contained asset src MUST be `assets/<flat-name>` and
3221
+ // nothing else. This structurally excludes `..` traversal, absolute (`/etc/…`)
3222
+ // and protocol-relative (`//evil/x.png`) hosts, and every URL scheme in ONE
3223
+ // positive rule — the denylist the comment used to imply was never the real
3224
+ // control (CSP was); this makes the source-write gate match that intent.
3225
+ // Content-addressed assets are always flat (`assets/<sha8>.<ext>`).
3226
+ if (!t || /\.\./.test(t) || !/^assets\/[A-Za-z0-9._-]+$/.test(t)) {
3227
+ throw new CanvasEditError(
3228
+ 'asset src must be a contained path (assets/<name>, no ../ or scheme)',
3229
+ { canvas: canvasAbsPath, id: '' }
3230
+ );
3231
+ }
3232
+ }
3233
+
3234
+ /**
3235
+ * Generate a full video-comp canvas TSX from a set of dropped reference clips
3236
+ * (DDR-150 P4 Task 12 — the "udělej z toho video" one-click). Video clips are
3237
+ * laid back-to-back as named `<Sequence>`s (explicit from/durationInFrames — so
3238
+ * they're immediately hand-editable on the Timeline, DDR-150); audio clips become
3239
+ * `<Audio>` beds under the whole reel. Only bundled Remotion imports are used
3240
+ * (skill invariant). `componentName` is the exported Canvas component; every src
3241
+ * is escaped + contained. The comp is a genuine frame-driven composition, never a
3242
+ * frozen preview. Throws `CanvasEditError` on an empty set / bad src.
3243
+ */
3244
+ export function assembleCompSource(
3245
+ componentName: string,
3246
+ clips: AssembleClip[],
3247
+ opts: { fps?: number; width?: number; height?: number } = {}
3248
+ ): string {
3249
+ const fps = Math.max(1, Math.round(opts.fps ?? 30));
3250
+ const width = Math.max(1, Math.round(opts.width ?? 1280));
3251
+ const height = Math.max(1, Math.round(opts.height ?? 720));
3252
+ const videos = clips.filter((c) => c.mediaKind === 'video');
3253
+ const audios = clips.filter((c) => c.mediaKind === 'audio');
3254
+ if (videos.length === 0 && audios.length === 0) {
3255
+ throw new CanvasEditError('assemble needs at least one clip', {
3256
+ canvas: componentName,
3257
+ id: '',
3258
+ });
3259
+ }
3260
+ for (const c of clips) assertContainedAssetSrc(c.src, componentName);
3261
+
3262
+ const defDur = fps * 3;
3263
+ let cursor = 0;
3264
+ const seqLines: string[] = [];
3265
+ videos.forEach((c, i) => {
3266
+ const dur = Math.max(1, Math.round(c.durationInFrames ?? defDur));
3267
+ seqLines.push(
3268
+ ` <Sequence name="clip-${i + 1}" from={${cursor}} durationInFrames={${dur}}>`,
3269
+ ` <OffthreadVideo src="${escapeAttr(c.src)}" />`,
3270
+ ` </Sequence>`
3271
+ );
3272
+ cursor += dur;
3273
+ });
3274
+ // Audio beds run under the whole reel (no seek — a bed, not a clip).
3275
+ audios.forEach((c) => {
3276
+ seqLines.push(` <Audio src="${escapeAttr(c.src)}" />`);
3277
+ });
3278
+ const total = Math.max(cursor, defDur);
3279
+
3280
+ const remotionImports = ['AbsoluteFill', 'Sequence'];
3281
+ if (videos.length) remotionImports.push('OffthreadVideo');
3282
+ if (audios.length) remotionImports.push('Audio');
3283
+
3284
+ return [
3285
+ `import { DesignCanvas, DCSection, DCArtboard, VideoComp } from '@maude/canvas-lib';`,
3286
+ `import { ${remotionImports.join(', ')} } from 'remotion';`,
3287
+ ``,
3288
+ `const Comp = () => (`,
3289
+ ` <AbsoluteFill style={{ background: 'var(--bg-0)' }}>`,
3290
+ ...seqLines,
3291
+ ` </AbsoluteFill>`,
3292
+ `);`,
3293
+ ``,
3294
+ `export default function ${componentName}() {`,
3295
+ ` return (`,
3296
+ ` <DesignCanvas>`,
3297
+ ` <DCSection title="Assembled reel">`,
3298
+ ` <DCArtboard id="reel" label="Reel" width={${width}} height={${height}}>`,
3299
+ ` <VideoComp component={Comp} durationInFrames={${total}} fps={${fps}} width={${width}} height={${height}} />`,
3300
+ ` </DCArtboard>`,
3301
+ ` </DCSection>`,
3302
+ ` </DesignCanvas>`,
3303
+ ` );`,
3304
+ `}`,
3305
+ ``,
3306
+ ].join('\n');
3307
+ }
3308
+
3309
+ // ---------------------------------------------------------------------------
3310
+ // Edit shapes.
3311
+
3312
+ function editStringAttr(
3313
+ s: MagicString,
3314
+ opening: AnyNode,
3315
+ name: string,
3316
+ value: string,
3317
+ canvasAbsPath: string,
3318
+ id: string
3319
+ ): void {
3320
+ const attr = findAttribute(opening, name);
3321
+ if (attr) {
3322
+ // Replace existing value. JSX attribute value forms we handle:
3323
+ // - <Tag name="literal" /> → replace inside the quotes
3324
+ // - <Tag name={'literal'} /> → wrap quotes around new value
3325
+ // - <Tag name={expr} /> → replace the whole expression text
3326
+ // (ONLY when the expression is itself a trivial string literal — a
3327
+ // non-trivial expression is a BINDING, not a static value, and
3328
+ // blindly overwriting it would silently discard the binding. See the
3329
+ // `src` guard below.)
3330
+ // - <Tag name /> → no value node; add `="..."`
3331
+ const v = attr.value;
3332
+ if (!v) {
3333
+ // <Tag name /> → <Tag name="value" />
3334
+ s.appendLeft(attr.end, `="${escapeAttr(value)}"`);
3335
+ return;
3336
+ }
3337
+ if (v.type === 'Literal' || v.type === 'StringLiteral') {
3338
+ // Replace the whole `"value"` (quotes included) so we control the escaping.
3339
+ // The value lands in a JSX *attribute*, where `"` must become `&quot;` and
3340
+ // `<`/`>` their entities — NOT JS backslash escaping. `JSON.stringify` would
3341
+ // emit `\"`, which is invalid in a JSX attribute and corrupts the source on
3342
+ // any value containing a double quote. Use the same `escapeAttr` as the two
3343
+ // insert branches so all four paths agree. See DDR-103 / DDR-105.
3344
+ s.overwrite(v.start, v.end, `"${escapeAttr(value)}"`);
3345
+ return;
3346
+ }
3347
+ if (v.type === 'JSXExpressionContainer') {
3348
+ const inner = v.expression;
3349
+ // feature-element-editing-robustness Stage F2 — `src={someVar}` (or any
3350
+ // other non-literal binding) is NOT a static value; overwriting it with a
3351
+ // plain string literal would silently discard the binding, corrupting the
3352
+ // author's intent (the exact "Replace image…" gotcha the plan calls out —
3353
+ // canvas-shell.tsx's context-menu item assumed this refusal already
3354
+ // existed; it didn't). `{'literal'}`/`{"literal"}` IS a trivial, safely
3355
+ // overwritable case — only refuse a genuinely dynamic expression.
3356
+ if (name === 'src' && !(inner?.type === 'Literal' || inner?.type === 'StringLiteral')) {
3357
+ throw new CanvasEditError(
3358
+ `"${name}" is bound to a JS expression ({…}), not a static value — edit it via /design:edit`,
3359
+ { canvas: canvasAbsPath, id }
3360
+ );
3361
+ }
3362
+ // Replace the whole `{...}` with a plain quoted literal — keeps the
3363
+ // resulting JSX readable. Same JSX-attribute escaping as above (NOT
3364
+ // `JSON.stringify`, which would JS-escape a `"` and corrupt the source).
3365
+ s.overwrite(v.start, v.end, `"${escapeAttr(value)}"`);
3366
+ return;
3367
+ }
3368
+ // Unknown shape — refuse rather than corrupt.
3369
+ throw new CanvasEditError(`Unsupported JSX attribute value shape: ${v.type}`, {
3370
+ canvas: canvasAbsPath,
3371
+ id,
3372
+ });
3373
+ }
3374
+ // Attribute missing — insert right after the tag name (mirrors pipeline's
3375
+ // injection point so attribute order stays predictable).
3376
+ const insertAt: number | undefined = opening?.name?.end;
3377
+ if (typeof insertAt !== 'number') {
3378
+ throw new Error('Opening element has no resolvable name range');
3379
+ }
3380
+ s.appendLeft(insertAt, ` ${name}="${escapeAttr(value)}"`);
3381
+ }
3382
+
3383
+ function escapeAttr(value: string): string {
3384
+ return value.replace(/"/g, '&quot;').replace(/[<>]/g, (c) => (c === '<' ? '&lt;' : '&gt;'));
3385
+ }
3386
+
3387
+ /**
3388
+ * Escape JSX-significant characters so user-typed text lands in source `.tsx`
3389
+ * as inert TEXT, never as markup or an expression. The text is written between
3390
+ * an element's tags (`<button>HERE</button>`), where `<`/`>` would start a tag
3391
+ * and `{`/`}` would open a JSX expression — so all four (plus a bare `&`, which
3392
+ * begins an entity) are encoded. This is the load-bearing guard that keeps the
3393
+ * inline text editor (Phase 12) from being a source-injection vector. See
3394
+ * DDR-103.
3395
+ */
3396
+ function escapeJsxText(value: string): string {
3397
+ return value
3398
+ .replace(/&/g, '&amp;')
3399
+ .replace(/</g, '&lt;')
3400
+ .replace(/>/g, '&gt;')
3401
+ .replace(/\{/g, '&#123;')
3402
+ .replace(/\}/g, '&#125;');
3403
+ }
3404
+
3405
+ function editStyleProp(
3406
+ s: MagicString,
3407
+ opening: AnyNode,
3408
+ prop: string,
3409
+ value: string,
3410
+ canvasAbsPath: string,
3411
+ id: string
3412
+ ): void {
3413
+ const attr = findAttribute(opening, 'style');
3414
+ if (!attr) {
3415
+ // No style prop yet — insert one with a single key.
3416
+ const insertAt: number | undefined = opening?.name?.end;
3417
+ if (typeof insertAt !== 'number') {
3418
+ throw new Error('Opening element has no resolvable name range');
3419
+ }
3420
+ s.appendLeft(insertAt, ` style={{ ${jsKey(prop)}: ${value} }}`);
3421
+ return;
3422
+ }
3423
+ const v = attr.value;
3424
+ if (v?.type !== 'JSXExpressionContainer') {
3425
+ throw new CanvasEditError(
3426
+ `style attribute on ${id} is not a {{...}} expression — refusing to edit`,
3427
+ { canvas: canvasAbsPath, id }
3428
+ );
3429
+ }
3430
+ const obj = v.expression;
3431
+ if (obj?.type !== 'ObjectExpression') {
3432
+ throw new CanvasEditError(
3433
+ `style={...} on ${id} is not an inline ObjectExpression — refusing to edit`,
3434
+ { canvas: canvasAbsPath, id }
3435
+ );
3436
+ }
3437
+
3438
+ // Search for an existing property with the same key. JSX styles permit
3439
+ // camelCase identifiers (paddingTop) AND quoted strings ("padding-top").
3440
+ // Compare both forms.
3441
+ const propCamel = prop.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
3442
+ for (const p of obj.properties as AnyNode[]) {
3443
+ if (p?.type !== 'Property' && p?.type !== 'ObjectProperty') continue;
3444
+ const k = p.key;
3445
+ if (!k) continue;
3446
+ const kname = k.type === 'Identifier' ? k.name : k.type === 'Literal' ? String(k.value) : null;
3447
+ if (kname === prop || kname === propCamel) {
3448
+ s.overwrite(p.value.start, p.value.end, value);
3449
+ return;
3450
+ }
3451
+ }
3452
+
3453
+ // Key missing — append before the closing `}`. ObjectExpression's last char
3454
+ // is the `}`; magic-string appendLeft at end keeps the brace in place.
3455
+ //
3456
+ // TRAILING-COMMA guard: a frame-driven inline style often ends the last
3457
+ // property with a trailing comma (`opacity: o,\n}`). Unconditionally
3458
+ // prepending `, ` there produced `opacity: o, , color: X` — a double comma =
3459
+ // syntax error, so the canvas failed to rebuild and the Player kept rendering
3460
+ // the OLD source (the "my CSS edit resets when I replay the video" bug).
3461
+ // Detect an existing trailing comma between the last property and the `}` and
3462
+ // omit our separating comma when one is already there.
3463
+ const props = obj.properties as AnyNode[];
3464
+ let sep = ' ';
3465
+ if (props.length > 0) {
3466
+ const lastEnd = props[props.length - 1].end as number;
3467
+ const between = s.original.slice(lastEnd, (obj.end as number) - 1);
3468
+ sep = /,/.test(between) ? ' ' : ', ';
3469
+ }
954
3470
  // The object's textual end is `obj.end - 1` for `}` after the chars.
955
3471
  // appendLeft at obj.end -1 puts new text before the `}`.
956
- s.appendLeft(obj.end - 1, `${sep}${jsKey(prop)}: ${value} `);
957
- // Suppress unused-var lint without bypassing TS:
958
- void tail;
3472
+ s.appendLeft((obj.end as number) - 1, `${sep}${jsKey(prop)}: ${value} `);
959
3473
  }
960
3474
 
961
3475
  /**