@1agh/maude 0.39.1 → 0.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/README.md +1 -1
  2. package/apps/studio/annotations-context-toolbar.tsx +44 -1
  3. package/apps/studio/annotations-layer.tsx +253 -3
  4. package/apps/studio/annotations-model.ts +107 -6
  5. package/apps/studio/api.ts +812 -64
  6. package/apps/studio/bin/_html-playwright.mjs +9 -1
  7. package/apps/studio/bin/_pdf-playwright.mjs +8 -1
  8. package/apps/studio/bin/_png-playwright.mjs +8 -1
  9. package/apps/studio/bin/_pw-launch.mjs +54 -0
  10. package/apps/studio/bin/_svg-playwright.mjs +8 -1
  11. package/apps/studio/bin/_video-playwright.mjs +452 -0
  12. package/apps/studio/bin/prep.sh +8 -1
  13. package/apps/studio/canvas-edit.ts +1885 -104
  14. package/apps/studio/canvas-lib.tsx +19 -0
  15. package/apps/studio/canvas-list-watch.ts +6 -2
  16. package/apps/studio/canvas-shell.tsx +27 -0
  17. package/apps/studio/client/app.jsx +1029 -30
  18. package/apps/studio/client/github.js +7 -0
  19. package/apps/studio/client/panels/TimelinePanel.jsx +860 -0
  20. package/apps/studio/client/panels/timeline-parse.js +229 -0
  21. package/apps/studio/client/panels/timeline-snap.js +55 -0
  22. package/apps/studio/client/styles/3-shell-maude.css +107 -0
  23. package/apps/studio/config.schema.json +14 -0
  24. package/apps/studio/context-menu.tsx +1 -1
  25. package/apps/studio/context.ts +113 -1
  26. package/apps/studio/dist/client.bundle.js +88 -16
  27. package/apps/studio/dist/comment-mount.js +1 -1
  28. package/apps/studio/dist/runtime/.min-sizes.json +11 -1
  29. package/apps/studio/dist/runtime/@remotion_media.js +491 -0
  30. package/apps/studio/dist/runtime/@remotion_player.js +56 -0
  31. package/apps/studio/dist/runtime/@remotion_transitions.js +300 -0
  32. package/apps/studio/dist/runtime/@remotion_transitions_clock-wipe.js +1 -0
  33. package/apps/studio/dist/runtime/@remotion_transitions_fade.js +1 -0
  34. package/apps/studio/dist/runtime/@remotion_transitions_flip.js +1 -0
  35. package/apps/studio/dist/runtime/@remotion_transitions_none.js +1 -0
  36. package/apps/studio/dist/runtime/@remotion_transitions_slide.js +1 -0
  37. package/apps/studio/dist/runtime/@remotion_transitions_wipe.js +1 -0
  38. package/apps/studio/dist/runtime/REMOTION-LICENSE.md +28 -0
  39. package/apps/studio/dist/runtime/remotion.js +42 -0
  40. package/apps/studio/dist/styles.css +1 -1
  41. package/apps/studio/exporters/_browser-bundles.ts +117 -0
  42. package/apps/studio/exporters/_runtime.ts +69 -0
  43. package/apps/studio/exporters/html.ts +4 -3
  44. package/apps/studio/exporters/index.ts +28 -2
  45. package/apps/studio/exporters/pdf.ts +4 -3
  46. package/apps/studio/exporters/png.ts +5 -3
  47. package/apps/studio/exporters/pptx.ts +6 -4
  48. package/apps/studio/exporters/svg.ts +9 -5
  49. package/apps/studio/exporters/video-encode-lib.ts +200 -0
  50. package/apps/studio/exporters/video-render-lib.ts +108 -0
  51. package/apps/studio/exporters/video.ts +184 -0
  52. package/apps/studio/http.ts +535 -27
  53. package/apps/studio/input-router.tsx +7 -1
  54. package/apps/studio/runtime-bundle.ts +30 -0
  55. package/apps/studio/server.ts +34 -9
  56. package/apps/studio/test/annotations-roundtrip.test.ts +47 -0
  57. package/apps/studio/test/canvas-create-api.test.ts +76 -0
  58. package/apps/studio/test/canvas-edit.test.ts +90 -0
  59. package/apps/studio/test/canvas-list-watch.test.ts +49 -0
  60. package/apps/studio/test/canvas-media-drop.test.ts +30 -1
  61. package/apps/studio/test/canvas-origin-gate.test.ts +36 -0
  62. package/apps/studio/test/clip-addressing.test.ts +732 -0
  63. package/apps/studio/test/config-reload.test.ts +230 -0
  64. package/apps/studio/test/dns-rebinding-guard.test.ts +74 -0
  65. package/apps/studio/test/edit-persistence.test.ts +91 -0
  66. package/apps/studio/test/exporters/runtime.test.ts +59 -0
  67. package/apps/studio/test/file-lock.test.ts +84 -0
  68. package/apps/studio/test/fixtures/video-comp-fixture.tsx +82 -0
  69. package/apps/studio/test/timeline-parse.test.ts +127 -0
  70. package/apps/studio/test/timeline-snap.test.ts +85 -0
  71. package/apps/studio/test/video-asset.test.ts +163 -0
  72. package/apps/studio/test/video-comp-fixture.test.ts +50 -0
  73. package/apps/studio/test/video-comp.test.ts +168 -0
  74. package/apps/studio/test/video-render-bridge.test.ts +149 -0
  75. package/apps/studio/use-annotation-resize.tsx +6 -3
  76. package/apps/studio/use-canvas-media-drop.tsx +66 -4
  77. package/apps/studio/video-comp.tsx +444 -0
  78. package/apps/studio/whats-new.json +27 -0
  79. package/apps/studio/ws.ts +5 -0
  80. package/package.json +8 -8
  81. package/plugins/design/templates/_shell.html +25 -2
  82. package/plugins/design/templates/design-system-inspiration/_MAPPING.md +1 -1
@@ -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
  // ---------------------------------------------------------------------------
@@ -402,6 +476,30 @@ export function applyTextEdit(
402
476
  });
403
477
  }
404
478
  const only = meaningful[0];
479
+ // A single `{'string literal'}` expression child — `<h1>{'Title'}</h1>` — is
480
+ // editable (DDR-150 P1): rewrite the literal in place. Written back via
481
+ // JSON.stringify so the result is an inert, correctly-escaped quoted string —
482
+ // the value never leaves the `{...}`, so (unlike JSXText) there is no markup /
483
+ // entity injection surface to guard. Any OTHER expression (identifier,
484
+ // template, call, member — `{title}`, `` {`${n} items`} ``) is genuinely
485
+ // dynamic: refuse and route to /design:edit rather than delete the binding.
486
+ if (meaningful.length === 1 && only?.type === 'JSXExpressionContainer') {
487
+ const expr = (only as AnyNode).expression;
488
+ if (
489
+ expr &&
490
+ (expr.type === 'Literal' || expr.type === 'StringLiteral') &&
491
+ typeof expr.value === 'string'
492
+ ) {
493
+ const s = new MagicString(source);
494
+ s.overwrite(expr.start as number, expr.end as number, JSON.stringify(text));
495
+ const out = s.toString();
496
+ return { source: out, delta: out.length - source.length };
497
+ }
498
+ throw new CanvasEditError(`element "${id}" has dynamic content — edit it via /design:edit`, {
499
+ canvas: canvasAbsPath,
500
+ id,
501
+ });
502
+ }
405
503
  if (meaningful.length > 1 || only?.type !== 'JSXText') {
406
504
  throw new CanvasEditError(
407
505
  `element "${id}" has mixed or expression content — edit it via /design:edit`,
@@ -831,131 +929,1814 @@ export function applyMove(
831
929
  }
832
930
 
833
931
  // ---------------------------------------------------------------------------
834
- // Edit shapes.
932
+ // DDR-148 — Timeline drag-to-retime. Rewrites a `<...Sequence>`'s
933
+ // `durationInFrames` / `from` to a new frame count. Sequences are addressed by
934
+ // their document ORDER (the same order timeline-parse.js tokenizes them), not a
935
+ // data-cd-id — a member-expression element (`TransitionSeries.Sequence`) has no
936
+ // stable cd-id, and the order is what the Timeline UI already knows.
937
+
938
+ export interface RetimePatch {
939
+ durationInFrames?: number;
940
+ from?: number;
941
+ }
835
942
 
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;
943
+ const SEQ_TAG_RE = /<(?:TransitionSeries\.Sequence|Series\.Sequence|Sequence)\b[^>]*>/g;
944
+
945
+ /**
946
+ * Rewrite one attribute on a sequence tag. Prefers editing a referenced const
947
+ * (`durationInFrames={A}`bump `const A = …`) so a derived total
948
+ * (`const TOTAL = A + B - XF`) updates in lock-step; falls back to editing a
949
+ * literal in place; refuses a non-trivial expression (returns false).
950
+ */
951
+ function retimeAttr(
952
+ s: MagicString,
953
+ source: string,
954
+ tag: string,
955
+ tagStart: number,
956
+ key: 'durationInFrames' | 'from',
957
+ newVal: number
958
+ ): boolean {
959
+ const am = tag.match(new RegExp(`\\b${key}=\\{\\s*([^}]*?)\\s*\\}`));
960
+ if (!am || am.index == null) {
961
+ // Attr absent. For `from`, INSERT it — moving a cursor-implicit clip
962
+ // (`<Sequence durationInFrames={…}>`) to a new start needs an explicit
963
+ // `from` (DDR-150 P3 Task 6). durationInFrames is required on a clip, so
964
+ // never auto-insert it.
965
+ if (key === 'from') {
966
+ const nameMatch = tag.match(/^<([A-Za-z][\w.]*)/);
967
+ if (nameMatch) {
968
+ s.appendLeft(tagStart + nameMatch[0].length, ` from={${newVal}}`);
969
+ return true;
970
+ }
849
971
  }
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;
972
+ return false;
973
+ }
974
+ const inner = am[1].trim();
975
+ if (/^[A-Za-z_$][\w$]*$/.test(inner)) {
976
+ const cm = source.match(new RegExp(`\\bconst\\s+${inner}\\s*=\\s*(-?\\d+)`));
977
+ if (cm && cm.index != null && cm[1]) {
978
+ const numStart = cm.index + cm[0].lastIndexOf(cm[1]);
979
+ s.overwrite(numStart, numStart + cm[1].length, String(newVal));
980
+ return true;
859
981
  }
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;
982
+ }
983
+ if (/^-?\d+$/.test(inner)) {
984
+ const innerRel = am[0].indexOf(inner, am[0].indexOf('{'));
985
+ const valStart = tagStart + am.index + innerRel;
986
+ s.overwrite(valStart, valStart + inner.length, String(newVal));
987
+ return true;
988
+ }
989
+ return false;
990
+ }
991
+
992
+ /** Pure retime — exposed for tests. Never mutates disk. */
993
+ export function applyRetimeSequence(
994
+ canvasAbsPath: string,
995
+ source: string,
996
+ seqIndex: number,
997
+ patch: RetimePatch
998
+ ): { source: string } {
999
+ const s = new MagicString(source);
1000
+ SEQ_TAG_RE.lastIndex = 0;
1001
+ let i = 0;
1002
+ let touched = false;
1003
+ let m: RegExpExecArray | null = SEQ_TAG_RE.exec(source);
1004
+ while (m) {
1005
+ if (i === seqIndex) {
1006
+ const tag = m[0];
1007
+ const tagStart = m.index;
1008
+ for (const [key, val] of [
1009
+ ['durationInFrames', patch.durationInFrames],
1010
+ ['from', patch.from],
1011
+ ] as const) {
1012
+ if (val == null || !Number.isFinite(val)) continue;
1013
+ if (retimeAttr(s, source, tag, tagStart, key, Math.max(0, Math.round(val)))) touched = true;
1014
+ }
1015
+ break;
866
1016
  }
867
- // Unknown shape — refuse rather than corrupt.
868
- throw new Error(`Unsupported JSX attribute value shape: ${v.type}`);
1017
+ i += 1;
1018
+ m = SEQ_TAG_RE.exec(source);
869
1019
  }
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');
1020
+ if (!touched) {
1021
+ throw new CanvasEditError(`no retimable sequence at index ${seqIndex}`, {
1022
+ canvas: canvasAbsPath,
1023
+ id: String(seqIndex),
1024
+ });
875
1025
  }
876
- s.appendLeft(insertAt, ` ${name}="${escapeAttr(value)}"`);
1026
+ const next = s.toString();
1027
+ const parsed = parseSync(canvasAbsPath, next, { sourceType: 'module' });
1028
+ if (parsed.errors && parsed.errors.length > 0) {
1029
+ throw new CanvasEditError(
1030
+ `retime produced invalid source: ${parsed.errors[0]?.message ?? 'parse error'}`,
1031
+ { canvas: canvasAbsPath, id: String(seqIndex) }
1032
+ );
1033
+ }
1034
+ return { source: next };
877
1035
  }
878
1036
 
879
- function escapeAttr(value: string): string {
880
- return value.replace(/"/g, '&quot;').replace(/[<>]/g, (c) => (c === '<' ? '&lt;' : '&gt;'));
1037
+ /** Retime a sequence on disk (atomic write + per-file lock, like moveElement). */
1038
+ export async function retimeSequence(
1039
+ canvasAbsPath: string,
1040
+ seqIndex: number,
1041
+ patch: RetimePatch
1042
+ ): Promise<{ source: string }> {
1043
+ return withLock(canvasAbsPath, async () => {
1044
+ const file = Bun.file(canvasAbsPath);
1045
+ if (!(await file.exists())) {
1046
+ throw new CanvasEditError(`Canvas not found: ${canvasAbsPath}`, {
1047
+ canvas: canvasAbsPath,
1048
+ id: String(seqIndex),
1049
+ });
1050
+ }
1051
+ const source = await file.text();
1052
+ const next = applyRetimeSequence(canvasAbsPath, source, seqIndex, patch);
1053
+ if (next.source === source) return next;
1054
+ const tmp = `${canvasAbsPath}.tmp.${Math.random().toString(36).slice(2, 10)}`;
1055
+ await Bun.write(tmp, next.source);
1056
+ const { rename } = await import('node:fs/promises');
1057
+ await rename(tmp, canvasAbsPath);
1058
+ return next;
1059
+ });
881
1060
  }
882
1061
 
883
1062
  /**
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.
1063
+ * Retime a clip addressed by the enumerator's `stableId` (comp-scoped) instead of
1064
+ * a whole-file index the DDR-150 P2 fix for the multi-comp mis-hit. Verifies
1065
+ * the content-hash fingerprint (refuse a stale/raced target), patches the clip's
1066
+ * own tag via `retimeAttr` (const-preferring), then reparse + semantic gate.
891
1067
  */
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,
1068
+ export function applyRetimeSequenceByClip(
906
1069
  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');
1070
+ source: string,
1071
+ artboardId: string | undefined,
1072
+ stableId: string,
1073
+ expectedHash: string | undefined,
1074
+ patch: RetimePatch
1075
+ ): { source: string } {
1076
+ const clip = resolveClip(canvasAbsPath, source, artboardId, stableId, expectedHash);
1077
+ // A `from` move is STANDALONE-<Sequence>-only. `<TransitionSeries.Sequence>` /
1078
+ // `<Series.Sequence>` compute their own offsets — Remotion silently IGNORES a
1079
+ // `from` prop on them, so patching/inserting one writes a lie: the timeline
1080
+ // draws a gap while the rendered video never changes (the dogfood bug —
1081
+ // "video je furt stejné, ať klipem pohnu jakkoliv"). Refuse loudly instead.
1082
+ if (patch.from != null && clip.tag !== 'Sequence') {
1083
+ throw new CanvasEditError(
1084
+ `"${stableId}" is a ${clip.tag} — the series computes its position, so moving it has no effect. Trim its duration instead, or reorder the beats.`,
1085
+ { canvas: canvasAbsPath, id: stableId }
1086
+ );
1087
+ }
1088
+ const s = new MagicString(source);
1089
+ SEQ_TAG_RE.lastIndex = 0;
1090
+ let touched = false;
1091
+ let m: RegExpExecArray | null = SEQ_TAG_RE.exec(source);
1092
+ while (m) {
1093
+ if (m.index === clip.start) {
1094
+ for (const [key, val] of [
1095
+ ['durationInFrames', patch.durationInFrames],
1096
+ ['from', patch.from],
1097
+ ] as const) {
1098
+ if (val == null || !Number.isFinite(val)) continue;
1099
+ if (retimeAttr(s, source, m[0], m.index, key, Math.max(0, Math.round(val)))) touched = true;
1100
+ }
1101
+ break;
915
1102
  }
916
- s.appendLeft(insertAt, ` style={{ ${jsKey(prop)}: ${value} }}`);
917
- return;
1103
+ m = SEQ_TAG_RE.exec(source);
918
1104
  }
919
- const v = attr.value;
920
- if (v?.type !== 'JSXExpressionContainer') {
1105
+ if (!touched) {
1106
+ throw new CanvasEditError(`clip "${stableId}" has no retimable from/durationInFrames`, {
1107
+ canvas: canvasAbsPath,
1108
+ id: stableId,
1109
+ });
1110
+ }
1111
+ const next = s.toString();
1112
+ const parsed = parseSync(canvasAbsPath, next, { sourceType: 'module' });
1113
+ if (parsed.errors && parsed.errors.length > 0) {
921
1114
  throw new CanvasEditError(
922
- `style attribute on ${id} is not a {{...}} expression — refusing to edit`,
923
- { canvas: canvasAbsPath, id }
1115
+ `retime produced invalid source: ${parsed.errors[0]?.message ?? 'parse error'}`,
1116
+ { canvas: canvasAbsPath, id: stableId }
924
1117
  );
925
1118
  }
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 }
1119
+ assertCompSemantics(canvasAbsPath, next);
1120
+ return { source: next };
1121
+ }
1122
+
1123
+ /** Retime a clip by stableId on disk (atomic write + cross-process lock). */
1124
+ export async function retimeSequenceByClip(
1125
+ canvasAbsPath: string,
1126
+ artboardId: string | undefined,
1127
+ stableId: string,
1128
+ expectedHash: string | undefined,
1129
+ patch: RetimePatch
1130
+ ): Promise<{ source: string }> {
1131
+ return withLock(canvasAbsPath, async () => {
1132
+ const file = Bun.file(canvasAbsPath);
1133
+ if (!(await file.exists())) {
1134
+ throw new CanvasEditError(`Canvas not found: ${canvasAbsPath}`, {
1135
+ canvas: canvasAbsPath,
1136
+ id: stableId,
1137
+ });
1138
+ }
1139
+ const source = await file.text();
1140
+ const next = applyRetimeSequenceByClip(
1141
+ canvasAbsPath,
1142
+ source,
1143
+ artboardId,
1144
+ stableId,
1145
+ expectedHash,
1146
+ patch
931
1147
  );
1148
+ if (next.source === source) return next;
1149
+ const tmp = `${canvasAbsPath}.tmp.${Math.random().toString(36).slice(2, 10)}`;
1150
+ await Bun.write(tmp, next.source);
1151
+ const { rename } = await import('node:fs/promises');
1152
+ await rename(tmp, canvasAbsPath);
1153
+ return next;
1154
+ });
1155
+ }
1156
+
1157
+ // ---------------------------------------------------------------------------
1158
+ // Clip addressing (DDR-150 P2). The SINGLE authoritative tokenizer for a
1159
+ // video-comp's clips. The Timeline UI addresses every op through the `stableId`
1160
+ // this returns, NOT its own regex position — killing the two-tokenizer
1161
+ // document-order disagreement that made destructive ops corrupt the wrong clip
1162
+ // on a multi-comp canvas (the debate's headline defect). AST-based, so it skips
1163
+ // tags inside comments/strings (the regex parser didn't) and scopes cleanly to
1164
+ // one comp's body even when several comps share a file.
1165
+
1166
+ /** Sequence-family "clip" tags (the timeline rows). */
1167
+ const CLIP_TAGS = new Set(['Sequence', 'Series.Sequence', 'TransitionSeries.Sequence']);
1168
+ /** Transition tags (occupy a slot between clips inside a TransitionSeries). */
1169
+ const TRANSITION_TAGS = new Set(['Series.Transition', 'TransitionSeries.Transition']);
1170
+ /** Media tags that carry a `src` (the replace-media + drop targets). */
1171
+ const MEDIA_TAGS = new Set(['Video', 'OffthreadVideo', 'Audio', 'Img', 'Image']);
1172
+ /** Remotion/layout primitives that are structural, not their own timeline layer. */
1173
+ const LAYER_SKIP_TAGS = new Set([
1174
+ 'AbsoluteFill',
1175
+ 'Sequence',
1176
+ 'Series',
1177
+ 'Series.Sequence',
1178
+ 'Series.Transition',
1179
+ 'TransitionSeries',
1180
+ 'TransitionSeries.Sequence',
1181
+ 'TransitionSeries.Transition',
1182
+ 'Loop',
1183
+ 'Freeze',
1184
+ 'Fragment',
1185
+ ]);
1186
+
1187
+ /** Full tag string of a JSXElement: `Sequence` | `TransitionSeries.Sequence` | … */
1188
+ function jsxTagName(node: AnyNode): string | null {
1189
+ const n = node?.openingElement?.name;
1190
+ if (!n) return null;
1191
+ if (n.type === 'JSXIdentifier') return typeof n.name === 'string' ? n.name : null;
1192
+ if (n.type === 'JSXMemberExpression') {
1193
+ const obj = n.object?.type === 'JSXIdentifier' ? n.object.name : null;
1194
+ const prop = n.property?.type === 'JSXIdentifier' ? n.property.name : null;
1195
+ if (obj && prop) return `${obj}.${prop}`;
932
1196
  }
1197
+ return null;
1198
+ }
933
1199
 
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);
1200
+ /** Evaluate a numeric AST expression against a resolved const map (literal,
1201
+ * negation, const identifier, or simple arithmetic of them). null if not
1202
+ * resolvable from/duration are best-effort labels, never load-bearing for
1203
+ * addressing (that's stableId + contentHash). */
1204
+ function evalNum(n: AnyNode, consts: Record<string, number>): number | null {
1205
+ if (!n || typeof n !== 'object') return null;
1206
+ const t = n.type;
1207
+ if ((t === 'Literal' || t === 'NumericLiteral') && typeof n.value === 'number') return n.value;
1208
+ if (t === 'UnaryExpression' && n.operator === '-') {
1209
+ const v = evalNum(n.argument, consts);
1210
+ return v == null ? null : -v;
1211
+ }
1212
+ if (t === 'Identifier') return Object.hasOwn(consts, n.name) ? (consts[n.name] as number) : null;
1213
+ if (t === 'BinaryExpression') {
1214
+ const l = evalNum(n.left, consts);
1215
+ const r = evalNum(n.right, consts);
1216
+ if (l == null || r == null) return null;
1217
+ switch (n.operator) {
1218
+ case '+':
1219
+ return l + r;
1220
+ case '-':
1221
+ return l - r;
1222
+ case '*':
1223
+ return l * r;
1224
+ case '/':
1225
+ return r ? l / r : null;
1226
+ default:
1227
+ return null;
1228
+ }
1229
+ }
1230
+ return null;
1231
+ }
1232
+
1233
+ /** Top-level numeric const bindings (3 passes so a derived `TOTAL = A + B` resolves). */
1234
+ function collectNumericConsts(program: AnyNode): Record<string, number> {
1235
+ const consts: Record<string, number> = {};
1236
+ const decls: Array<{ name: string; init: AnyNode }> = [];
1237
+ for (const node of program?.body ?? []) {
1238
+ if (node?.type !== 'VariableDeclaration') continue;
1239
+ for (const d of node.declarations ?? []) {
1240
+ if (d?.id?.type === 'Identifier' && d.init) decls.push({ name: d.id.name, init: d.init });
1241
+ }
1242
+ }
1243
+ for (let pass = 0; pass < 3; pass += 1) {
1244
+ for (const { name, init } of decls) {
1245
+ if (Object.hasOwn(consts, name)) continue;
1246
+ const v = evalNum(init, consts);
1247
+ if (v != null && Number.isFinite(v)) consts[name] = Math.round(v);
1248
+ }
1249
+ }
1250
+ return consts;
1251
+ }
1252
+
1253
+ /** Resolve a numeric JSX attribute (`from={20}` / `durationInFrames={A}`). */
1254
+ function numAttr(opening: AnyNode, name: string, consts: Record<string, number>): number | null {
1255
+ const a = findAttribute(opening, name);
1256
+ let v = a?.value;
1257
+ if (!v) return null;
1258
+ if (v.type === 'JSXExpressionContainer') v = v.expression;
1259
+ const n = evalNum(v, consts);
1260
+ return n == null ? null : Math.round(n);
1261
+ }
1262
+
1263
+ /** First media descendant of a clip (its `<Video>`/`<Audio>`/`<Img>` — the replace target). */
1264
+ function firstMediaDescendant(clip: AnyNode): AnyNode | null {
1265
+ let found: AnyNode | null = null;
1266
+ function walk(n: AnyNode): void {
1267
+ if (found || !n || typeof n !== 'object') return;
1268
+ if (Array.isArray(n)) {
1269
+ for (const c of n) {
1270
+ if (found) return;
1271
+ walk(c);
1272
+ }
945
1273
  return;
946
1274
  }
1275
+ if (n.type === 'JSXElement' && MEDIA_TAGS.has(jsxTagName(n) ?? '')) {
1276
+ found = n;
1277
+ return;
1278
+ }
1279
+ for (const k of Object.keys(n)) {
1280
+ if (k === 'loc' || k === 'range' || k === 'start' || k === 'end' || k === 'type') continue;
1281
+ walk(n[k]);
1282
+ }
947
1283
  }
1284
+ walk(clip.children ?? []);
1285
+ return found;
1286
+ }
948
1287
 
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 ? ', ' : ' ';
1288
+ /** Map every top-level component name its render body root (for nested-media resolution). */
1289
+ function collectComponentBodies(program: AnyNode): Map<string, AnyNode> {
1290
+ const out = new Map<string, AnyNode>();
1291
+ const body = (program?.body ?? []) as AnyNode[];
1292
+ for (const stmt of body) {
1293
+ if (stmt?.type === 'FunctionDeclaration' && isPascalIdent(stmt.id?.name)) {
1294
+ out.set(stmt.id.name, stmt.body);
1295
+ } else if (stmt?.type === 'VariableDeclaration') {
1296
+ for (const d of stmt.declarations ?? []) {
1297
+ const name = componentNameOf(d);
1298
+ if (name && d.init?.body) out.set(name, d.init.body);
1299
+ }
1300
+ }
1301
+ }
1302
+ return out;
1303
+ }
1304
+
1305
+ /** Map every top-level `const NAME = [ {…}, … ]` → its ArrayExpression (for `CLIPS[i].src`). */
1306
+ function collectArrayLiterals(program: AnyNode): Map<string, AnyNode> {
1307
+ const out = new Map<string, AnyNode>();
1308
+ for (const stmt of (program?.body ?? []) as AnyNode[]) {
1309
+ if (stmt?.type !== 'VariableDeclaration') continue;
1310
+ for (const d of stmt.declarations ?? []) {
1311
+ if (d?.id?.type === 'Identifier' && d.init?.type === 'ArrayExpression') {
1312
+ out.set(d.id.name, d.init);
1313
+ }
1314
+ }
1315
+ }
1316
+ return out;
1317
+ }
1318
+
1319
+ /** The first JSXElement child of `clip` whose tag is a PascalCase component (not a primitive). */
1320
+ function firstComponentChild(clip: AnyNode): AnyNode | null {
1321
+ for (const child of (clip.children ?? []) as AnyNode[]) {
1322
+ if (child?.type === 'JSXElement') {
1323
+ const tag = jsxTagName(child);
1324
+ if (tag && isPascalIdent(tag) && !MEDIA_TAGS.has(tag)) return child;
1325
+ }
1326
+ }
1327
+ return null;
1328
+ }
1329
+
1330
+ /** A media element's `src` attr node (JSX value), or null. */
1331
+ function srcAttrValue(mediaEl: AnyNode): AnyNode | null {
1332
+ const attr = findAttribute(mediaEl.openingElement, 'src');
1333
+ return attr?.value ?? null;
1334
+ }
1335
+
1336
+ /**
1337
+ * Resolve a clip's media even when it's nested one level inside a wrapper
1338
+ * component whose `src` is fed by an array element (the showreel
1339
+ * `<ClipShot clip={CLIPS[2]} />` → `<Video src={clip.src} />` pattern). Returns
1340
+ * the media tag + a replace target: a direct `cdId` (literal src), an
1341
+ * `arrayRef` (edit `NAME[i].field`), or `shared` (literal src in a reused comp).
1342
+ */
1343
+ function resolveNestedMedia(
1344
+ clip: AnyNode,
1345
+ componentBodies: Map<string, AnyNode>,
1346
+ arrays: Map<string, AnyNode>,
1347
+ cdIdOf: Map<AnyNode, string>
1348
+ ): {
1349
+ el: AnyNode;
1350
+ tag: string;
1351
+ src: string | null;
1352
+ cdId: string | null;
1353
+ arrayRef: { arrayName: string; index: number; field: string } | null;
1354
+ shared: boolean;
1355
+ } | null {
1356
+ // 1. Direct media descendant (literal or prop src).
1357
+ const direct = firstMediaDescendant(clip);
1358
+ if (direct) {
1359
+ return {
1360
+ el: direct,
1361
+ tag: jsxTagName(direct) ?? 'Video',
1362
+ src: getStringAttr(direct.openingElement, 'src'),
1363
+ cdId: cdIdOf.get(direct) ?? null,
1364
+ arrayRef: null,
1365
+ shared: false,
1366
+ };
1367
+ }
1368
+ // 2. Wrapper component: <Comp propName={ARR[i]} /> → Comp body → media <X src={param.field}>.
1369
+ const wrapper = firstComponentChild(clip);
1370
+ if (!wrapper) return null;
1371
+ const compName = jsxTagName(wrapper);
1372
+ const body = compName ? componentBodies.get(compName) : null;
1373
+ if (!body) return null;
1374
+ const media = firstMediaDescendant({ children: [body] });
1375
+ if (!media) return null;
1376
+ const tag = jsxTagName(media) ?? 'Video';
1377
+ const srcVal = srcAttrValue(media);
1378
+ // Literal src inside the shared component → replaceable, but shared.
1379
+ if (srcVal?.type === 'Literal' || srcVal?.type === 'StringLiteral') {
1380
+ return {
1381
+ el: media,
1382
+ tag,
1383
+ src: String(srcVal.value),
1384
+ cdId: cdIdOf.get(media) ?? null,
1385
+ arrayRef: null,
1386
+ shared: true,
1387
+ };
1388
+ }
1389
+ // Prop-bound src: `src={param.field}` where `param` is the component's first
1390
+ // destructured/parameter name, bound at the call site to `ARR[i]`.
1391
+ if (srcVal?.type === 'JSXExpressionContainer') {
1392
+ const expr = srcVal.expression;
1393
+ // member: param.field
1394
+ if (
1395
+ expr?.type === 'MemberExpression' &&
1396
+ expr.object?.type === 'Identifier' &&
1397
+ expr.property?.type === 'Identifier'
1398
+ ) {
1399
+ const field = expr.property.name;
1400
+ // Find the call-site prop bound to this component's param: <Comp X={ARR[i]}>.
1401
+ // The wrapper's first attribute value that is `ARR[i]` gives the array + index.
1402
+ for (const attr of (wrapper.openingElement?.attributes ?? []) as AnyNode[]) {
1403
+ const v = attr?.value;
1404
+ if (v?.type !== 'JSXExpressionContainer') continue;
1405
+ const e = v.expression;
1406
+ if (
1407
+ e?.type === 'MemberExpression' &&
1408
+ e.object?.type === 'Identifier' &&
1409
+ arrays.has(e.object.name) &&
1410
+ e.property?.type === 'Literal' &&
1411
+ typeof e.property.value === 'number'
1412
+ ) {
1413
+ const arr = arrays.get(e.object.name);
1414
+ const el = (arr?.elements ?? [])[e.property.value] as AnyNode | undefined;
1415
+ const prop =
1416
+ el?.type === 'ObjectExpression'
1417
+ ? (el.properties ?? []).find(
1418
+ (p: AnyNode) => p?.key?.name === field || p?.key?.value === field
1419
+ )
1420
+ : null;
1421
+ const litSrc =
1422
+ prop?.value && (prop.value.type === 'Literal' || prop.value.type === 'StringLiteral')
1423
+ ? String(prop.value.value)
1424
+ : null;
1425
+ return {
1426
+ el: media,
1427
+ tag,
1428
+ src: litSrc,
1429
+ cdId: null,
1430
+ arrayRef: { arrayName: e.object.name, index: e.property.value, field },
1431
+ shared: false,
1432
+ };
1433
+ }
1434
+ }
1435
+ }
1436
+ }
1437
+ // Media exists but src isn't resolvable to a replaceable target.
1438
+ return { el: media, tag, src: null, cdId: null, arrayRef: null, shared: false };
1439
+ }
1440
+
1441
+ /** One visually-meaningful layer inside a clip (a media element or a named
1442
+ * sub-component) — so the Timeline can show a ClipShot's mp4 background and its
1443
+ * title/lower-third as SEPARATE rows instead of one opaque "ClipShot". */
1444
+ export interface ClipLayer {
1445
+ kind: 'video' | 'image' | 'audio' | 'component';
1446
+ /** Human label — the media filename, or the sub-component's tag. */
1447
+ label: string;
1448
+ mediaTag: string | null;
1449
+ mediaSrc: string | null;
1450
+ mediaCdId: string | null;
1451
+ mediaArrayRef: { arrayName: string; index: number; field: string } | null;
1452
+ mediaShared: boolean;
1453
+ }
1454
+
1455
+ /**
1456
+ * Decompose a clip into its stacked layers (media elements + named sub-components),
1457
+ * resolving through a wrapper component (the showreel `<ClipShot clip={CLIPS[i]}>`
1458
+ * → its `<Video>` + `<LowerThird>`). Ordered by source position. Skips Remotion/
1459
+ * layout primitives (AbsoluteFill, Sequence, …). Returns [] when the clip has no
1460
+ * decomposable structure (a pure inline card).
1461
+ */
1462
+ function collectClipLayers(
1463
+ clip: AnyNode,
1464
+ componentBodies: Map<string, AnyNode>,
1465
+ arrays: Map<string, AnyNode>,
1466
+ cdIdOf: Map<AnyNode, string>
1467
+ ): ClipLayer[] {
1468
+ // Resolve the root to walk: the wrapper component's body if the clip wraps one,
1469
+ // else the clip's own children. Carry the wrapper element so prop-fed src (the
1470
+ // component's `clip` param → `CLIPS[i]`) resolves against the call site.
1471
+ const wrapper = firstComponentChild(clip);
1472
+ const wrapperTag = wrapper ? jsxTagName(wrapper) : null;
1473
+ const body = wrapperTag ? componentBodies.get(wrapperTag) : null;
1474
+ const root: AnyNode = body ? { children: [body] } : clip;
1475
+
1476
+ const layers: ClipLayer[] = [];
1477
+ (function walk(n: AnyNode): void {
1478
+ if (!n || typeof n !== 'object') return;
1479
+ if (Array.isArray(n)) {
1480
+ for (const c of n) walk(c);
1481
+ return;
1482
+ }
1483
+ if (n.type === 'JSXElement') {
1484
+ const tag = jsxTagName(n) ?? '';
1485
+ if (MEDIA_TAGS.has(tag)) {
1486
+ // Media layer — resolve src the same way replace does (literal / prop / array).
1487
+ const m = wrapper
1488
+ ? resolveMediaSrcThroughWrapper(n, wrapper, arrays, cdIdOf)
1489
+ : {
1490
+ src: getStringAttr(n.openingElement, 'src'),
1491
+ cdId: cdIdOf.get(n) ?? null,
1492
+ arrayRef: null as ClipLayer['mediaArrayRef'],
1493
+ shared: false,
1494
+ };
1495
+ const kind: ClipLayer['kind'] =
1496
+ tag === 'Audio' ? 'audio' : tag === 'Img' || tag === 'Image' ? 'image' : 'video';
1497
+ layers.push({
1498
+ kind,
1499
+ label: m.src ? String(m.src).split('/').pop() || tag : tag,
1500
+ mediaTag: tag,
1501
+ mediaSrc: m.src,
1502
+ mediaCdId: m.cdId,
1503
+ mediaArrayRef: m.arrayRef,
1504
+ mediaShared: m.shared,
1505
+ });
1506
+ } else if (
1507
+ /^[A-Z]/.test(tag) &&
1508
+ !LAYER_SKIP_TAGS.has(tag) &&
1509
+ tag !== wrapperTag &&
1510
+ componentBodies.has(tag) // a KNOWN locally-defined component → a real layer
1511
+ ) {
1512
+ // A named sub-component (LowerThird, TitleBadge, …) → a component layer.
1513
+ // Only surface locally-defined components (skip unknown/library tags to
1514
+ // avoid noise); its media (if any) is walked below and attributed to it.
1515
+ layers.push({
1516
+ kind: 'component',
1517
+ label: tag,
1518
+ mediaTag: null,
1519
+ mediaSrc: null,
1520
+ mediaCdId: null,
1521
+ mediaArrayRef: null,
1522
+ mediaShared: false,
1523
+ });
1524
+ }
1525
+ }
1526
+ // Generic recursion (mirrors firstMediaDescendant) so wrapped/parenthesized
1527
+ // arrow bodies + all child positions are traversed, not just `children`.
1528
+ for (const k of Object.keys(n)) {
1529
+ if (k === 'loc' || k === 'range' || k === 'start' || k === 'end' || k === 'type') continue;
1530
+ walk(n[k]);
1531
+ }
1532
+ })(root);
1533
+ return layers;
1534
+ }
1535
+
1536
+ /** Resolve a media element's src (literal / prop-fed-from-array) via its wrapper's call site. */
1537
+ function resolveMediaSrcThroughWrapper(
1538
+ media: AnyNode,
1539
+ wrapper: AnyNode,
1540
+ arrays: Map<string, AnyNode>,
1541
+ cdIdOf: Map<AnyNode, string>
1542
+ ): {
1543
+ src: string | null;
1544
+ cdId: string | null;
1545
+ arrayRef: ClipLayer['mediaArrayRef'];
1546
+ shared: boolean;
1547
+ } {
1548
+ const srcVal = srcAttrValue(media);
1549
+ if (srcVal?.type === 'Literal' || srcVal?.type === 'StringLiteral') {
1550
+ return {
1551
+ src: String(srcVal.value),
1552
+ cdId: cdIdOf.get(media) ?? null,
1553
+ arrayRef: null,
1554
+ shared: true,
1555
+ };
1556
+ }
1557
+ if (
1558
+ srcVal?.type === 'JSXExpressionContainer' &&
1559
+ srcVal.expression?.type === 'MemberExpression' &&
1560
+ srcVal.expression.property?.type === 'Identifier'
1561
+ ) {
1562
+ const field = srcVal.expression.property.name;
1563
+ for (const attr of (wrapper.openingElement?.attributes ?? []) as AnyNode[]) {
1564
+ const v = attr?.value;
1565
+ const e = v?.type === 'JSXExpressionContainer' ? v.expression : null;
1566
+ if (
1567
+ e?.type === 'MemberExpression' &&
1568
+ e.object?.type === 'Identifier' &&
1569
+ arrays.has(e.object.name) &&
1570
+ e.property?.type === 'Literal' &&
1571
+ typeof e.property.value === 'number'
1572
+ ) {
1573
+ const arr = arrays.get(e.object.name);
1574
+ const el = (arr?.elements ?? [])[e.property.value] as AnyNode | undefined;
1575
+ const prop =
1576
+ el?.type === 'ObjectExpression'
1577
+ ? (el.properties ?? []).find(
1578
+ (p: AnyNode) => p?.key?.name === field || p?.key?.value === field
1579
+ )
1580
+ : null;
1581
+ const lit =
1582
+ prop?.value && (prop.value.type === 'Literal' || prop.value.type === 'StringLiteral')
1583
+ ? String(prop.value.value)
1584
+ : null;
1585
+ return {
1586
+ src: lit,
1587
+ cdId: null,
1588
+ arrayRef: { arrayName: e.object.name, index: e.property.value, field },
1589
+ shared: false,
1590
+ };
1591
+ }
1592
+ }
1593
+ }
1594
+ return { src: null, cdId: cdIdOf.get(media) ?? null, arrayRef: null, shared: false };
1595
+ }
1596
+
1597
+ /** The child span [start, end] of a clip element (between its `>` and `</`), or null (self-closing). */
1598
+ function clipChildrenSpan(node: AnyNode): { start: number; end: number } | null {
1599
+ const oe = node.openingElement;
1600
+ const ce = node.closingElement;
1601
+ if (!oe || !ce || typeof oe.end !== 'number' || typeof ce.start !== 'number') return null;
1602
+ return { start: oe.end as number, end: ce.start as number };
1603
+ }
1604
+
1605
+ /** True when a clip's children are gated behind `{false && (…)}` (the hide marker). */
1606
+ function clipChildrenHidden(node: AnyNode, source: string): boolean {
1607
+ const span = clipChildrenSpan(node);
1608
+ if (!span) return false;
1609
+ return /^\s*\{false && \(/.test(source.slice(span.start, span.end));
1610
+ }
1611
+
1612
+ /** Stable content fingerprint of a clip's exact source span (optimistic-concurrency check). */
1613
+ function hashSpan(source: string, start: number, end: number): string {
1614
+ return Bun.hash(source.slice(start, end)).toString(16).padStart(16, '0').slice(0, 12);
1615
+ }
1616
+
1617
+ /** One clip (timeline row) + everything an op needs to address + label it. */
1618
+ export interface ClipInfo {
1619
+ /** Durable identity: `name:<Sequence name>` → `mclip:<sentinel>` → `<comp>#<indexInComp>`. */
1620
+ stableId: string;
1621
+ kind: 'sequence' | 'transition';
1622
+ tag: string;
1623
+ from: number | null;
1624
+ durationInFrames: number | null;
1625
+ mediaTag: string | null;
1626
+ mediaSrc: string | null;
1627
+ /** Positional cd-id of the media element (for `editAttribute` src-replace). */
1628
+ mediaCdId: string | null;
1629
+ /**
1630
+ * When the clip's media lives inside a wrapper component whose `src` is fed by
1631
+ * an array element (`<ClipShot clip={CLIPS[2]} />` → `<Video src={clip.src}>`),
1632
+ * this points at the array-literal string to edit for a replace (the showreel
1633
+ * pattern). `mediaCdId` is then null (the element's src is a prop, not literal).
1634
+ */
1635
+ mediaArrayRef: { arrayName: string; index: number; field: string } | null;
1636
+ /**
1637
+ * True when the media's literal `src` lives in a SHARED wrapper component (every
1638
+ * instance renders the same element) — replacing it changes every clip using
1639
+ * that component. The client warns before applying.
1640
+ */
1641
+ mediaShared: boolean;
1642
+ /** Positional cd-id of the clip's OWN `<Sequence>` node (for `moveElement` z-order reorder). */
1643
+ clipCdId: string | null;
1644
+ /** The clip's stacked layers (mp4 background + title/lower-third + …) for the
1645
+ * expandable timeline rows. Empty for a pure inline card. */
1646
+ layers: ClipLayer[];
1647
+ /** True when the clip's body is gated behind `{false && (…)}` (hidden — renders
1648
+ * nothing but keeps its time slot + the TransitionSeries alternation). */
1649
+ hidden: boolean;
1650
+ /** Fingerprint of the clip's source span — refuse an op if it no longer matches. */
1651
+ contentHash: string;
1652
+ start: number;
1653
+ end: number;
1654
+ }
1655
+
1656
+ /** A media element sitting DIRECTLY in the comp body (not inside a clip) — an
1657
+ * `<Audio>` music bed under the reel, a full-length `<Video>` background, … .
1658
+ * Not a clip (no from/duration semantics of its own), but it IS addressable:
1659
+ * the Timeline's audio rows use `cdId` for a replace (`editAttribute` on src). */
1660
+ export interface LooseMediaInfo {
1661
+ tag: string;
1662
+ src: string | null;
1663
+ cdId: string | null;
1664
+ contentHash: string;
1665
+ }
1666
+
1667
+ export interface CompClips {
1668
+ compName: string | null;
1669
+ artboardId: string | null;
1670
+ fps: number | null;
1671
+ durationInFrames: number | null;
1672
+ clips: ClipInfo[];
1673
+ /** DDR-150 dogfood #5 — loose media beds (document order), for audio replace. */
1674
+ media: LooseMediaInfo[];
1675
+ }
1676
+
1677
+ /**
1678
+ * Enumerate the clips of ONE video-comp (scoped by `artboardId` → its
1679
+ * `<VideoComp component={X}>` → component X's body). The Timeline UI renders
1680
+ * rows from this and addresses every op by `clip.stableId` — so UI and engine
1681
+ * can never disagree about which clip is which (the multi-comp defect). Throws
1682
+ * `CanvasEditError` on unparseable source.
1683
+ */
1684
+ export function enumerateClips(
1685
+ canvasAbsPath: string,
1686
+ source: string,
1687
+ artboardId?: string
1688
+ ): CompClips {
1689
+ const parsed = parseSync(canvasAbsPath, source, { sourceType: 'module' });
1690
+ if (parsed.errors && parsed.errors.length > 0) {
1691
+ throw new CanvasEditError(
1692
+ `oxc-parser failed on ${canvasAbsPath}: ${parsed.errors[0]?.message ?? 'unknown'}`,
1693
+ { canvas: canvasAbsPath, id: artboardId ?? '' }
1694
+ );
1695
+ }
1696
+ const program = parsed.program;
1697
+ const consts = collectNumericConsts(program);
1698
+ const cdIdOf = new Map<AnyNode, string>();
1699
+ for (const { id, node } of collectElements(program)) cdIdOf.set(node, id);
1700
+
1701
+ interface Usage {
1702
+ compName: string | null;
1703
+ artboardId: string | null;
1704
+ fps: number | null;
1705
+ duration: number | null;
1706
+ }
1707
+ interface RawClip {
1708
+ tag: string;
1709
+ kind: 'sequence' | 'transition';
1710
+ node: AnyNode;
1711
+ comp: string;
1712
+ indexInComp: number;
1713
+ start: number;
1714
+ end: number;
1715
+ }
1716
+ interface RawMedia {
1717
+ tag: string;
1718
+ node: AnyNode;
1719
+ comp: string;
1720
+ start: number;
1721
+ end: number;
1722
+ }
1723
+ const usages: Usage[] = [];
1724
+ const clips: RawClip[] = [];
1725
+ const mediaEls: RawMedia[] = [];
1726
+ const compClipCount: Record<string, number> = {};
1727
+ const compStack: string[] = [''];
1728
+ const artboardStack: Array<string | null> = [];
1729
+
1730
+ function visit(node: AnyNode): void {
1731
+ if (!node || typeof node !== 'object') return;
1732
+ if (Array.isArray(node)) {
1733
+ for (const c of node) visit(c);
1734
+ return;
1735
+ }
1736
+ if (typeof node.type !== 'string') return;
1737
+ const newComp = componentNameOf(node);
1738
+ let pushedComp = false;
1739
+ if (newComp !== null) {
1740
+ compStack.push(newComp);
1741
+ pushedComp = true;
1742
+ }
1743
+ if (node.type === 'JSXElement') {
1744
+ const tag = jsxTagName(node);
1745
+ let pushedArt = false;
1746
+ if (tag === 'DCArtboard') {
1747
+ artboardStack.push(getStringAttr(node.openingElement, 'id'));
1748
+ pushedArt = true;
1749
+ }
1750
+ if (tag === 'VideoComp') {
1751
+ const compAttr = findAttribute(node.openingElement, 'component');
1752
+ const cv = compAttr?.value;
1753
+ const cn =
1754
+ cv?.type === 'JSXExpressionContainer' && cv.expression?.type === 'Identifier'
1755
+ ? cv.expression.name
1756
+ : null;
1757
+ usages.push({
1758
+ compName: cn,
1759
+ artboardId: artboardStack.length ? artboardStack[artboardStack.length - 1] : null,
1760
+ fps: numAttr(node.openingElement, 'fps', consts),
1761
+ duration: numAttr(node.openingElement, 'durationInFrames', consts),
1762
+ });
1763
+ }
1764
+ if (tag && (CLIP_TAGS.has(tag) || TRANSITION_TAGS.has(tag))) {
1765
+ const comp = compStack[compStack.length - 1] as string;
1766
+ const idx = compClipCount[comp] ?? 0;
1767
+ compClipCount[comp] = idx + 1;
1768
+ clips.push({
1769
+ tag,
1770
+ kind: TRANSITION_TAGS.has(tag) ? 'transition' : 'sequence',
1771
+ node,
1772
+ comp,
1773
+ indexInComp: idx,
1774
+ start: node.start as number,
1775
+ end: node.end as number,
1776
+ });
1777
+ }
1778
+ if (tag && MEDIA_TAGS.has(tag)) {
1779
+ // Every media element, with its owning comp — loose beds are filtered
1780
+ // out of clip spans below (an <Audio> under the reel vs inside a clip).
1781
+ mediaEls.push({
1782
+ tag,
1783
+ node,
1784
+ comp: compStack[compStack.length - 1] as string,
1785
+ start: node.start as number,
1786
+ end: node.end as number,
1787
+ });
1788
+ }
1789
+ if (node.openingElement) visit(node.openingElement.attributes);
1790
+ visit(node.children);
1791
+ if (pushedArt) artboardStack.pop();
1792
+ if (pushedComp) compStack.pop();
1793
+ return;
1794
+ }
1795
+ for (const k of Object.keys(node)) {
1796
+ if (k === 'loc' || k === 'range' || k === 'start' || k === 'end' || k === 'type') continue;
1797
+ visit(node[k]);
1798
+ }
1799
+ if (pushedComp) compStack.pop();
1800
+ }
1801
+ visit(program);
1802
+
1803
+ // Resolve which comp to scope to. Preference mirrors timeline-parse.js:
1804
+ // the selected artboard's comp → a comp with clips → the first usage.
1805
+ const target =
1806
+ (artboardId && usages.find((u) => u.artboardId === artboardId && u.compName)) ||
1807
+ usages.find((u) => u.compName && clips.some((c) => c.comp === u.compName)) ||
1808
+ usages.find((u) => u.compName) ||
1809
+ null;
1810
+ const targetComp = target?.compName ?? clips[0]?.comp ?? null;
1811
+
1812
+ const componentBodies = collectComponentBodies(program);
1813
+ const arrays = collectArrayLiterals(program);
1814
+ const scoped = targetComp == null ? [] : clips.filter((c) => c.comp === targetComp);
1815
+ const clipInfos: ClipInfo[] = scoped.map((c) => {
1816
+ const opening = c.node.openingElement;
1817
+ const nameAttr = getStringAttr(opening, 'name');
1818
+ const before = source.slice(Math.max(0, c.start - 160), c.start);
1819
+ const sentinel = before.match(/\{\/\*\s*@mclip\s+([A-Za-z0-9_-]+)\s*\*\/\}\s*$/);
1820
+ const stableId =
1821
+ nameAttr != null && nameAttr !== ''
1822
+ ? `name:${nameAttr}`
1823
+ : sentinel
1824
+ ? `mclip:${sentinel[1]}`
1825
+ : `${targetComp}#${c.indexInComp}`;
1826
+ // Resolve media even through a wrapper component (the showreel pattern) so
1827
+ // the Timeline can badge the clip video/image/audio AND replace its source.
1828
+ const m = resolveNestedMedia(c.node, componentBodies, arrays, cdIdOf);
1829
+ return {
1830
+ stableId,
1831
+ kind: c.kind,
1832
+ tag: c.tag,
1833
+ from: numAttr(opening, 'from', consts),
1834
+ durationInFrames: numAttr(opening, 'durationInFrames', consts),
1835
+ mediaTag: m ? m.tag : null,
1836
+ mediaSrc: m ? m.src : null,
1837
+ mediaCdId: m ? m.cdId : null,
1838
+ mediaArrayRef: m ? m.arrayRef : null,
1839
+ mediaShared: m ? m.shared : false,
1840
+ clipCdId: cdIdOf.get(c.node) ?? null,
1841
+ layers: collectClipLayers(c.node, componentBodies, arrays, cdIdOf),
1842
+ hidden: clipChildrenHidden(c.node, source),
1843
+ contentHash: hashSpan(source, c.start, c.end),
1844
+ start: c.start,
1845
+ end: c.end,
1846
+ };
1847
+ });
1848
+
1849
+ // Loose media beds — media in the target comp OUTSIDE every clip span
1850
+ // (an <Audio> music bed, a background <Video>). Document order.
1851
+ const media: LooseMediaInfo[] = mediaEls
1852
+ .filter(
1853
+ (mel) =>
1854
+ mel.comp === targetComp && !scoped.some((c) => mel.start >= c.start && mel.end <= c.end)
1855
+ )
1856
+ .map((mel) => ({
1857
+ tag: mel.tag,
1858
+ src: getStringAttr(mel.node.openingElement, 'src'),
1859
+ cdId: cdIdOf.get(mel.node) ?? null,
1860
+ contentHash: hashSpan(source, mel.start, mel.end),
1861
+ }));
1862
+
1863
+ return {
1864
+ compName: targetComp,
1865
+ artboardId: target?.artboardId ?? null,
1866
+ fps: target?.fps ?? null,
1867
+ durationInFrames: target?.duration ?? null,
1868
+ clips: clipInfos,
1869
+ media,
1870
+ };
1871
+ }
1872
+
1873
+ /**
1874
+ * Resolve a `stableId` (from `enumerateClips`) to its live clip in `source`,
1875
+ * verifying the caller's `expectedHash` still matches (optimistic concurrency —
1876
+ * a stale UI index or a concurrent edit is refused, not silently mis-applied).
1877
+ * Returns the ClipInfo (with current start/end for the patch). Throws on a
1878
+ * missing id or a hash mismatch.
1879
+ */
1880
+ export function resolveClip(
1881
+ canvasAbsPath: string,
1882
+ source: string,
1883
+ artboardId: string | undefined,
1884
+ stableId: string,
1885
+ expectedHash?: string
1886
+ ): ClipInfo {
1887
+ const { clips } = enumerateClips(canvasAbsPath, source, artboardId);
1888
+ const hit = clips.find((c) => c.stableId === stableId);
1889
+ if (!hit) {
1890
+ throw new CanvasEditError(`clip "${stableId}" not found`, {
1891
+ canvas: canvasAbsPath,
1892
+ id: stableId,
1893
+ });
1894
+ }
1895
+ if (expectedHash != null && expectedHash !== hit.contentHash) {
1896
+ throw new CanvasEditError(
1897
+ `clip "${stableId}" changed since it was read (concurrent edit); reload and retry`,
1898
+ { canvas: canvasAbsPath, id: stableId }
1899
+ );
1900
+ }
1901
+ return hit;
1902
+ }
1903
+
1904
+ /**
1905
+ * The semantic gate (DDR-150 P2). Parse-clean is NOT correct: a wrong-clip
1906
+ * delete, an orphaned/leading/double `<TransitionSeries.Transition>`, all parse
1907
+ * fine yet render wrong. After any structural clip edit, assert every
1908
+ * `<TransitionSeries>` strictly alternates Sequence/Transition and begins + ends
1909
+ * with a Sequence. Throws `CanvasEditError` on a violation.
1910
+ */
1911
+ export function assertCompSemantics(canvasAbsPath: string, source: string): { ok: true } {
1912
+ const parsed = parseSync(canvasAbsPath, source, { sourceType: 'module' });
1913
+ if (parsed.errors && parsed.errors.length > 0) {
1914
+ throw new CanvasEditError(
1915
+ `oxc-parser failed on ${canvasAbsPath}: ${parsed.errors[0]?.message ?? 'unknown'}`,
1916
+ { canvas: canvasAbsPath, id: '' }
1917
+ );
1918
+ }
1919
+ const series: AnyNode[] = [];
1920
+ (function find(node: AnyNode): void {
1921
+ if (!node || typeof node !== 'object') return;
1922
+ if (Array.isArray(node)) {
1923
+ for (const c of node) find(c);
1924
+ return;
1925
+ }
1926
+ if (node.type === 'JSXElement' && jsxTagName(node) === 'TransitionSeries') series.push(node);
1927
+ for (const k of Object.keys(node)) {
1928
+ if (k === 'loc' || k === 'range' || k === 'start' || k === 'end' || k === 'type') continue;
1929
+ find(node[k]);
1930
+ }
1931
+ })(parsed.program);
1932
+
1933
+ for (const s of series) {
1934
+ const kinds: Array<'sequence' | 'transition'> = [];
1935
+ for (const child of s.children ?? []) {
1936
+ if (child?.type !== 'JSXElement') continue;
1937
+ const tag = jsxTagName(child);
1938
+ if (tag && TRANSITION_TAGS.has(tag)) kinds.push('transition');
1939
+ else if (tag && CLIP_TAGS.has(tag)) kinds.push('sequence');
1940
+ }
1941
+ if (kinds.length === 0) continue;
1942
+ const bad =
1943
+ kinds[0] !== 'sequence' ||
1944
+ kinds[kinds.length - 1] !== 'sequence' ||
1945
+ kinds.some((k, i) => i > 0 && k === kinds[i - 1]);
1946
+ if (bad) {
1947
+ throw new CanvasEditError(
1948
+ 'TransitionSeries must alternate Sequence/Transition and begin + end with a Sequence (a leading, trailing, or doubled Transition is invalid Remotion)',
1949
+ { canvas: canvasAbsPath, id: '' }
1950
+ );
1951
+ }
1952
+ }
1953
+ return { ok: true };
1954
+ }
1955
+
1956
+ /** Source span of an element extended back over its leading indent + newline
1957
+ * (so removing it doesn't leave a blank line) — mirrors moveElement's removeStart. */
1958
+ function spanWithFraming(source: string, start: number, end: number): [number, number] {
1959
+ const line = lineStartInfo(source, start);
1960
+ const rs = line.newlineBefore ? line.indentStart - 1 : line.indentStart;
1961
+ return [rs, end];
1962
+ }
1963
+
1964
+ /**
1965
+ * Remove a clip (DDR-150 P3), addressed by the enumerator's stableId. Verifies
1966
+ * the content-hash fingerprint; refuses removing the ONLY clip; when the clip
1967
+ * lives in a `<TransitionSeries>` also removes ONE adjacent transition so the
1968
+ * series stays valid; then reparse + semantic gate (the backstop that refuses a
1969
+ * remove which would leave a dangling/doubled transition, rather than corrupt).
1970
+ */
1971
+ export function applyRemoveClip(
1972
+ canvasAbsPath: string,
1973
+ source: string,
1974
+ artboardId: string | undefined,
1975
+ stableId: string,
1976
+ expectedHash: string | undefined
1977
+ ): { source: string } {
1978
+ const { clips } = enumerateClips(canvasAbsPath, source, artboardId);
1979
+ const idx = clips.findIndex((c) => c.stableId === stableId);
1980
+ if (idx < 0) {
1981
+ throw new CanvasEditError(`clip "${stableId}" not found`, {
1982
+ canvas: canvasAbsPath,
1983
+ id: stableId,
1984
+ });
1985
+ }
1986
+ const clip = clips[idx] as ClipInfo;
1987
+ if (expectedHash != null && expectedHash !== clip.contentHash) {
1988
+ throw new CanvasEditError(
1989
+ `clip "${stableId}" changed since it was read (concurrent edit); reload and retry`,
1990
+ { canvas: canvasAbsPath, id: stableId }
1991
+ );
1992
+ }
1993
+ if (clip.kind !== 'sequence') {
1994
+ throw new CanvasEditError(`"${stableId}" is a transition, not a removable clip`, {
1995
+ canvas: canvasAbsPath,
1996
+ id: stableId,
1997
+ });
1998
+ }
1999
+ if (clips.filter((c) => c.kind === 'sequence').length <= 1) {
2000
+ throw new CanvasEditError('cannot remove the only clip — delete the comp instead', {
2001
+ canvas: canvasAbsPath,
2002
+ id: stableId,
2003
+ });
2004
+ }
2005
+ const spans: Array<[number, number]> = [spanWithFraming(source, clip.start, clip.end)];
2006
+ if (clip.tag.startsWith('TransitionSeries.')) {
2007
+ const next = clips[idx + 1];
2008
+ const prev = clips[idx - 1];
2009
+ const t = next?.kind === 'transition' ? next : prev?.kind === 'transition' ? prev : null;
2010
+ if (t) spans.push(spanWithFraming(source, t.start, t.end));
2011
+ }
2012
+ const s = new MagicString(source);
2013
+ for (const [a, b] of spans) s.remove(a, b);
2014
+ const out = s.toString();
2015
+ const parsed = parseSync(canvasAbsPath, out, { sourceType: 'module' });
2016
+ if (parsed.errors && parsed.errors.length > 0) {
2017
+ throw new CanvasEditError(
2018
+ `remove produced invalid source: ${parsed.errors[0]?.message ?? 'parse error'}`,
2019
+ { canvas: canvasAbsPath, id: stableId }
2020
+ );
2021
+ }
2022
+ assertCompSemantics(canvasAbsPath, out); // refuse a dangling/doubled transition
2023
+ return { source: out };
2024
+ }
2025
+
2026
+ /** Remove a clip on disk (atomic write + cross-process lock). */
2027
+ export async function removeClip(
2028
+ canvasAbsPath: string,
2029
+ artboardId: string | undefined,
2030
+ stableId: string,
2031
+ expectedHash: string | undefined
2032
+ ): Promise<{ source: string }> {
2033
+ return withLock(canvasAbsPath, async () => {
2034
+ const file = Bun.file(canvasAbsPath);
2035
+ if (!(await file.exists())) {
2036
+ throw new CanvasEditError(`Canvas not found: ${canvasAbsPath}`, {
2037
+ canvas: canvasAbsPath,
2038
+ id: stableId,
2039
+ });
2040
+ }
2041
+ const source = await file.text();
2042
+ const nextSrc = applyRemoveClip(canvasAbsPath, source, artboardId, stableId, expectedHash);
2043
+ if (nextSrc.source === source) return nextSrc;
2044
+ const tmp = `${canvasAbsPath}.tmp.${Math.random().toString(36).slice(2, 10)}`;
2045
+ await Bun.write(tmp, nextSrc.source);
2046
+ const { rename } = await import('node:fs/promises');
2047
+ await rename(tmp, canvasAbsPath);
2048
+ return nextSrc;
2049
+ });
2050
+ }
2051
+
2052
+ /**
2053
+ * Reorder a clip among its STANDALONE `<Sequence>` siblings (DDR-150 P5) — the
2054
+ * z-order / render-stacking gesture (later sibling paints on top), distinct from
2055
+ * the horizontal `from`-move. Reuses the shipped `applyMove` (re-indent + reparse
2056
+ * + re-settle-through-id-churn) addressed by each clip's own cd-id, then runs the
2057
+ * semantic gate. Refuses a `<TransitionSeries.Sequence>`/`<Series.Sequence>`
2058
+ * (reordering those breaks the transition/back-to-back timing — not a stacking
2059
+ * op) and a self-move. Both `stableId`s are fingerprint-checked. Returns the moved
2060
+ * clip's stableId (recomputed from disk — a pure reorder leaves its content hash
2061
+ * unchanged, so it's found even when addressed by scoped index).
2062
+ */
2063
+ export function applyReorderClip(
2064
+ canvasAbsPath: string,
2065
+ source: string,
2066
+ artboardId: string | undefined,
2067
+ movedStableId: string,
2068
+ movedHash: string | undefined,
2069
+ refStableId: string,
2070
+ refHash: string | undefined,
2071
+ position: MovePosition
2072
+ ): { source: string; stableId: string } {
2073
+ if (movedStableId === refStableId) {
2074
+ throw new CanvasEditError('cannot reorder a clip relative to itself', {
2075
+ canvas: canvasAbsPath,
2076
+ id: movedStableId,
2077
+ });
2078
+ }
2079
+ const moved = resolveClip(canvasAbsPath, source, artboardId, movedStableId, movedHash);
2080
+ const ref = resolveClip(canvasAbsPath, source, artboardId, refStableId, refHash);
2081
+ const bothSeries =
2082
+ (moved.tag === 'TransitionSeries.Sequence' || moved.tag === 'Series.Sequence') &&
2083
+ (ref.tag === 'TransitionSeries.Sequence' || ref.tag === 'Series.Sequence');
2084
+ // TransitionSeries/Series clips play in ORDER (not z-stacked) — "reorder" means
2085
+ // change PLAY order. Physically moving the tag would break the S/T/S/T
2086
+ // alternation, so instead SWAP the two sequences' spans in place: the
2087
+ // transitions between them stay put, alternation is preserved, and the beats
2088
+ // change order (DDR-150 dogfood — "reorder klip vrstvy" on a showreel).
2089
+ if (bothSeries) {
2090
+ const a = moved.start <= ref.start ? moved : ref;
2091
+ const b = moved.start <= ref.start ? ref : moved;
2092
+ const s = new MagicString(source);
2093
+ s.overwrite(a.start, a.end, source.slice(b.start, b.end));
2094
+ s.overwrite(b.start, b.end, source.slice(a.start, a.end));
2095
+ const out = s.toString();
2096
+ const parsed = parseSync(canvasAbsPath, out, { sourceType: 'module' });
2097
+ if (parsed.errors && parsed.errors.length > 0) {
2098
+ throw new CanvasEditError(
2099
+ `reorder produced invalid source: ${parsed.errors[0]?.message ?? 'parse error'}`,
2100
+ { canvas: canvasAbsPath, id: movedStableId }
2101
+ );
2102
+ }
2103
+ assertCompSemantics(canvasAbsPath, out);
2104
+ const after = enumerateClips(canvasAbsPath, out, artboardId);
2105
+ const settled = after.clips.find((c) => c.contentHash === moved.contentHash);
2106
+ return { source: out, stableId: settled?.stableId ?? movedStableId };
2107
+ }
2108
+ if (moved.tag !== 'Sequence' || ref.tag !== 'Sequence') {
2109
+ throw new CanvasEditError(
2110
+ 'reorder needs two sequences of the same kind (standalone ↔ standalone, or series ↔ series)',
2111
+ { canvas: canvasAbsPath, id: movedStableId }
2112
+ );
2113
+ }
2114
+ if (!moved.clipCdId || !ref.clipCdId) {
2115
+ throw new CanvasEditError('clip has no addressable node to reorder', {
2116
+ canvas: canvasAbsPath,
2117
+ id: movedStableId,
2118
+ });
2119
+ }
2120
+ const res = applyMove(canvasAbsPath, source, moved.clipCdId, ref.clipCdId, position);
2121
+ assertCompSemantics(canvasAbsPath, res.source);
2122
+ // A pure reorder never touches the clip body → its content hash is preserved.
2123
+ // Re-address by that hash so a scoped-index stableId still resolves post-move.
2124
+ const after = enumerateClips(canvasAbsPath, res.source, artboardId);
2125
+ const settled = after.clips.find((c) => c.contentHash === moved.contentHash);
2126
+ return { source: res.source, stableId: settled?.stableId ?? movedStableId };
2127
+ }
2128
+
2129
+ /** Reorder a clip on disk (atomic write + cross-process lock). */
2130
+ export async function reorderClip(
2131
+ canvasAbsPath: string,
2132
+ artboardId: string | undefined,
2133
+ movedStableId: string,
2134
+ movedHash: string | undefined,
2135
+ refStableId: string,
2136
+ refHash: string | undefined,
2137
+ position: MovePosition
2138
+ ): Promise<{ source: string; stableId: string }> {
2139
+ return withLock(canvasAbsPath, async () => {
2140
+ const file = Bun.file(canvasAbsPath);
2141
+ if (!(await file.exists())) {
2142
+ throw new CanvasEditError(`Canvas not found: ${canvasAbsPath}`, {
2143
+ canvas: canvasAbsPath,
2144
+ id: movedStableId,
2145
+ });
2146
+ }
2147
+ const source = await file.text();
2148
+ const next = applyReorderClip(
2149
+ canvasAbsPath,
2150
+ source,
2151
+ artboardId,
2152
+ movedStableId,
2153
+ movedHash,
2154
+ refStableId,
2155
+ refHash,
2156
+ position
2157
+ );
2158
+ if (next.source === source) return next;
2159
+ const tmp = `${canvasAbsPath}.tmp.${Math.random().toString(36).slice(2, 10)}`;
2160
+ await Bun.write(tmp, next.source);
2161
+ const { rename } = await import('node:fs/promises');
2162
+ await rename(tmp, canvasAbsPath);
2163
+ return next;
2164
+ });
2165
+ }
2166
+
2167
+ /**
2168
+ * Toggle a clip's visibility (DDR-150 dogfood — "hide clip"). Gates the clip's
2169
+ * children behind `{false && (…)}` (hide) or strips it (show). The clip TAG stays
2170
+ * — so it keeps its time slot and, inside a `<TransitionSeries>`, the S/T/S
2171
+ * alternation stays valid — only its content stops rendering. Reversible.
2172
+ * Fingerprint-checked. Returns the new hidden state.
2173
+ */
2174
+ export function applyToggleClipHidden(
2175
+ canvasAbsPath: string,
2176
+ source: string,
2177
+ artboardId: string | undefined,
2178
+ stableId: string,
2179
+ expectedHash: string | undefined
2180
+ ): { source: string; hidden: boolean } {
2181
+ resolveClip(canvasAbsPath, source, artboardId, stableId, expectedHash); // validate + fingerprint
2182
+ // Re-parse to locate the clip's node (need its opening/closing tag spans).
2183
+ const parsed = parseSync(canvasAbsPath, source, { sourceType: 'module' });
2184
+ if (parsed.errors && parsed.errors.length > 0) {
2185
+ throw new CanvasEditError(`oxc-parser failed: ${parsed.errors[0]?.message ?? 'unknown'}`, {
2186
+ canvas: canvasAbsPath,
2187
+ id: stableId,
2188
+ });
2189
+ }
2190
+ const { clips } = enumerateClips(canvasAbsPath, source, artboardId);
2191
+ const info = clips.find((c) => c.stableId === stableId);
2192
+ if (!info)
2193
+ throw new CanvasEditError(`clip "${stableId}" not found`, {
2194
+ canvas: canvasAbsPath,
2195
+ id: stableId,
2196
+ });
2197
+ // Walk for the JSXElement whose span matches the resolved clip.
2198
+ let node: AnyNode | null = null;
2199
+ (function find(n: AnyNode): void {
2200
+ if (node || !n || typeof n !== 'object') return;
2201
+ if (Array.isArray(n)) {
2202
+ for (const c of n) find(c);
2203
+ return;
2204
+ }
2205
+ if (n.type === 'JSXElement' && n.start === info.start && n.end === info.end) {
2206
+ node = n;
2207
+ return;
2208
+ }
2209
+ for (const k of Object.keys(n)) {
2210
+ if (k === 'loc' || k === 'range' || k === 'start' || k === 'end' || k === 'type') continue;
2211
+ find(n[k]);
2212
+ }
2213
+ })(parsed.program);
2214
+ if (!node)
2215
+ throw new CanvasEditError(`clip "${stableId}" node not found`, {
2216
+ canvas: canvasAbsPath,
2217
+ id: stableId,
2218
+ });
2219
+ const span = clipChildrenSpan(node);
2220
+ if (!span) {
2221
+ throw new CanvasEditError('this clip has no body to hide (self-closing)', {
2222
+ canvas: canvasAbsPath,
2223
+ id: stableId,
2224
+ });
2225
+ }
2226
+ const children = source.slice(span.start, span.end);
2227
+ const s = new MagicString(source);
2228
+ let hidden: boolean;
2229
+ if (/^\s*\{false && \(/.test(children)) {
2230
+ // Unhide — strip the exact wrapper we added.
2231
+ const inner = children.replace(/^(\s*)\{false && \(/, '$1').replace(/\)\}(\s*)$/, '$1');
2232
+ s.overwrite(span.start, span.end, inner);
2233
+ hidden = false;
2234
+ } else {
2235
+ s.overwrite(span.start, span.end, `{false && (${children})}`);
2236
+ hidden = true;
2237
+ }
2238
+ const out = s.toString();
2239
+ const re = parseSync(canvasAbsPath, out, { sourceType: 'module' });
2240
+ if (re.errors && re.errors.length > 0) {
2241
+ throw new CanvasEditError(
2242
+ `hide produced invalid source: ${re.errors[0]?.message ?? 'parse error'}`,
2243
+ {
2244
+ canvas: canvasAbsPath,
2245
+ id: stableId,
2246
+ }
2247
+ );
2248
+ }
2249
+ assertCompSemantics(canvasAbsPath, out);
2250
+ return { source: out, hidden };
2251
+ }
2252
+
2253
+ /** Toggle a clip's hidden state on disk (atomic write + cross-process lock). */
2254
+ export async function toggleClipHidden(
2255
+ canvasAbsPath: string,
2256
+ artboardId: string | undefined,
2257
+ stableId: string,
2258
+ expectedHash: string | undefined
2259
+ ): Promise<{ source: string; hidden: boolean }> {
2260
+ return withLock(canvasAbsPath, async () => {
2261
+ const file = Bun.file(canvasAbsPath);
2262
+ if (!(await file.exists())) {
2263
+ throw new CanvasEditError(`Canvas not found: ${canvasAbsPath}`, {
2264
+ canvas: canvasAbsPath,
2265
+ id: stableId,
2266
+ });
2267
+ }
2268
+ const source = await file.text();
2269
+ const next = applyToggleClipHidden(canvasAbsPath, source, artboardId, stableId, expectedHash);
2270
+ if (next.source === source) return next;
2271
+ const tmp = `${canvasAbsPath}.tmp.${Math.random().toString(36).slice(2, 10)}`;
2272
+ await Bun.write(tmp, next.source);
2273
+ const { rename } = await import('node:fs/promises');
2274
+ await rename(tmp, canvasAbsPath);
2275
+ return next;
2276
+ });
2277
+ }
2278
+
2279
+ /** Media tags a caller may insert as a clip's child (also gated at the api layer). */
2280
+ const INSERTABLE_MEDIA = new Set(['Video', 'Audio', 'Img', 'OffthreadVideo']);
2281
+
2282
+ /**
2283
+ * Insert a new `<Sequence>` clip into a comp (DDR-150 P4), addressed by
2284
+ * artboardId. Appends after the comp's LAST clip (matching its indentation),
2285
+ * optionally with a media child. Scoped to STANDALONE `<Sequence>` comps —
2286
+ * refuses a `<TransitionSeries>` (its overlap/transition math isn't a clean
2287
+ * append) and a comp with no existing clip to anchor to. Reparse + semantic
2288
+ * gate; `src` contained (no ../ or script schemes). Returns the new stableId.
2289
+ */
2290
+ export function applyInsertClip(
2291
+ canvasAbsPath: string,
2292
+ source: string,
2293
+ artboardId: string | undefined,
2294
+ opts: { from: number; durationInFrames: number; mediaTag?: string | null; src?: string | null }
2295
+ ): { source: string; stableId: string | null } {
2296
+ const { clips, compName } = enumerateClips(canvasAbsPath, source, artboardId);
2297
+ if (!compName) {
2298
+ throw new CanvasEditError('no video-comp for this artboard', {
2299
+ canvas: canvasAbsPath,
2300
+ id: artboardId ?? '',
2301
+ });
2302
+ }
2303
+ if (clips.length === 0) {
2304
+ throw new CanvasEditError('comp has no existing clip to anchor the insert', {
2305
+ canvas: canvasAbsPath,
2306
+ id: artboardId ?? '',
2307
+ });
2308
+ }
2309
+ const last = clips[clips.length - 1] as ClipInfo;
2310
+ const from = Math.max(0, Math.round(opts.from));
2311
+ const dur = Math.max(1, Math.round(opts.durationInFrames));
2312
+ const indent = lineStartInfo(source, last.start).indent;
2313
+ const srcTrim = (opts.src ?? '').trim();
2314
+ if (
2315
+ opts.mediaTag &&
2316
+ INSERTABLE_MEDIA.has(opts.mediaTag) &&
2317
+ srcTrim &&
2318
+ (/\.\./.test(srcTrim) || /^\s*(javascript|vbscript|file|data):/i.test(srcTrim))
2319
+ ) {
2320
+ throw new CanvasEditError(
2321
+ 'media src must be a contained asset path (no ../ or script schemes)',
2322
+ {
2323
+ canvas: canvasAbsPath,
2324
+ id: artboardId ?? '',
2325
+ }
2326
+ );
2327
+ }
2328
+ const s = new MagicString(source);
2329
+ if (last.tag.startsWith('TransitionSeries.') || last.tag.startsWith('Series.')) {
2330
+ // DDR-150 dogfood — append INTO a TransitionSeries: add a transition + a new
2331
+ // TransitionSeries.Sequence, keeping the S/T/S alternation. The transition is
2332
+ // CLONED verbatim from an existing one in the series so its
2333
+ // presentation/timing imports are already satisfied (we can't invent a
2334
+ // `fade()` the comp may not import). Series sequences carry no `from`.
2335
+ const seriesPrefix = last.tag.split('.')[0]; // 'TransitionSeries' | 'Series'
2336
+ const proto = clips.find(
2337
+ (c) => c.kind === 'transition' && c.tag.startsWith(`${seriesPrefix}.`)
2338
+ );
2339
+ if (!proto) {
2340
+ throw new CanvasEditError(
2341
+ `cannot append into a <${seriesPrefix}> with no existing transition to clone — add a beat via chat`,
2342
+ { canvas: canvasAbsPath, id: artboardId ?? '' }
2343
+ );
2344
+ }
2345
+ const transitionText = source.slice(proto.start, proto.end);
2346
+ const media =
2347
+ opts.mediaTag && INSERTABLE_MEDIA.has(opts.mediaTag)
2348
+ ? `\n${indent} <${opts.mediaTag} src="${escapeAttr(srcTrim)}" />\n${indent}`
2349
+ : '';
2350
+ const clipText =
2351
+ `\n${indent}${transitionText}` +
2352
+ `\n${indent}<${seriesPrefix}.Sequence durationInFrames={${dur}}>${media}</${seriesPrefix}.Sequence>`;
2353
+ s.appendLeft(last.end, clipText);
2354
+ } else {
2355
+ const media =
2356
+ opts.mediaTag && INSERTABLE_MEDIA.has(opts.mediaTag)
2357
+ ? `\n${indent} <${opts.mediaTag} src="${escapeAttr(srcTrim)}" />\n${indent}`
2358
+ : '';
2359
+ const clipText = `\n${indent}<Sequence from={${from}} durationInFrames={${dur}}>${media}</Sequence>`;
2360
+ s.appendLeft(last.end, clipText);
2361
+ }
2362
+ const out = s.toString();
2363
+ const parsed = parseSync(canvasAbsPath, out, { sourceType: 'module' });
2364
+ if (parsed.errors && parsed.errors.length > 0) {
2365
+ throw new CanvasEditError(
2366
+ `insert produced invalid source: ${parsed.errors[0]?.message ?? 'parse error'}`,
2367
+ { canvas: canvasAbsPath, id: artboardId ?? '' }
2368
+ );
2369
+ }
2370
+ assertCompSemantics(canvasAbsPath, out);
2371
+ const after = enumerateClips(canvasAbsPath, out, artboardId);
2372
+ const newStableId = after.clips.length
2373
+ ? (after.clips[after.clips.length - 1]?.stableId ?? null)
2374
+ : null;
2375
+ return { source: out, stableId: newStableId };
2376
+ }
2377
+
2378
+ /** Insert a clip on disk (atomic write + cross-process lock). */
2379
+ export async function insertClip(
2380
+ canvasAbsPath: string,
2381
+ artboardId: string | undefined,
2382
+ opts: { from: number; durationInFrames: number; mediaTag?: string | null; src?: string | null }
2383
+ ): Promise<{ source: string; stableId: string | null }> {
2384
+ return withLock(canvasAbsPath, async () => {
2385
+ const file = Bun.file(canvasAbsPath);
2386
+ if (!(await file.exists())) {
2387
+ throw new CanvasEditError(`Canvas not found: ${canvasAbsPath}`, {
2388
+ canvas: canvasAbsPath,
2389
+ id: artboardId ?? '',
2390
+ });
2391
+ }
2392
+ const source = await file.text();
2393
+ const next = applyInsertClip(canvasAbsPath, source, artboardId, opts);
2394
+ if (next.source === source) return next;
2395
+ const tmp = `${canvasAbsPath}.tmp.${Math.random().toString(36).slice(2, 10)}`;
2396
+ await Bun.write(tmp, next.source);
2397
+ const { rename } = await import('node:fs/promises');
2398
+ await rename(tmp, canvasAbsPath);
2399
+ return next;
2400
+ });
2401
+ }
2402
+
2403
+ /**
2404
+ * Replace a media `src` that lives in an array-of-objects literal
2405
+ * (`const CLIPS = [{ src: 'assets/a.mp4', … }, …]`) — the showreel pattern where
2406
+ * a clip's `<Video src={clip.src}>` is fed by `<ClipShot clip={CLIPS[i]} />`.
2407
+ * Pure. Rewrites `NAME[index].field`'s string to `value` (contained asset path).
2408
+ * Throws `CanvasEditError` on a bad path / missing target.
2409
+ */
2410
+ export function applyEditArrayElementString(
2411
+ canvasAbsPath: string,
2412
+ source: string,
2413
+ arrayName: string,
2414
+ index: number,
2415
+ field: string,
2416
+ value: string
2417
+ ): { source: string } {
2418
+ const v = value.trim();
2419
+ if (!v || /\.\./.test(v) || /^\s*(javascript|vbscript|file|data|https?):/i.test(v)) {
2420
+ throw new CanvasEditError('src must be a contained asset path (no ../ or scheme)', {
2421
+ canvas: canvasAbsPath,
2422
+ id: `${arrayName}[${index}].${field}`,
2423
+ });
2424
+ }
2425
+ const parsed = parseSync(canvasAbsPath, source, { sourceType: 'module' });
2426
+ if (parsed.errors && parsed.errors.length > 0) {
2427
+ throw new CanvasEditError(`oxc-parser failed: ${parsed.errors[0]?.message ?? 'unknown'}`, {
2428
+ canvas: canvasAbsPath,
2429
+ id: arrayName,
2430
+ });
2431
+ }
2432
+ let arr: AnyNode | null = null;
2433
+ for (const stmt of (parsed.program?.body ?? []) as AnyNode[]) {
2434
+ if (stmt?.type !== 'VariableDeclaration') continue;
2435
+ for (const d of stmt.declarations ?? []) {
2436
+ if (
2437
+ d?.id?.type === 'Identifier' &&
2438
+ d.id.name === arrayName &&
2439
+ d.init?.type === 'ArrayExpression'
2440
+ ) {
2441
+ arr = d.init;
2442
+ }
2443
+ }
2444
+ }
2445
+ if (!arr)
2446
+ throw new CanvasEditError(`array "${arrayName}" not found`, {
2447
+ canvas: canvasAbsPath,
2448
+ id: arrayName,
2449
+ });
2450
+ const el = (arr.elements ?? [])[index] as AnyNode | undefined;
2451
+ if (!el || el.type !== 'ObjectExpression') {
2452
+ throw new CanvasEditError(`${arrayName}[${index}] is not an object literal`, {
2453
+ canvas: canvasAbsPath,
2454
+ id: `${arrayName}[${index}]`,
2455
+ });
2456
+ }
2457
+ const prop = (el.properties ?? []).find(
2458
+ (p: AnyNode) => p?.key?.name === field || p?.key?.value === field
2459
+ );
2460
+ const valNode = prop?.value;
2461
+ if (!valNode || (valNode.type !== 'Literal' && valNode.type !== 'StringLiteral')) {
2462
+ throw new CanvasEditError(`${arrayName}[${index}].${field} is not a string literal`, {
2463
+ canvas: canvasAbsPath,
2464
+ id: `${arrayName}[${index}].${field}`,
2465
+ });
2466
+ }
2467
+ const s = new MagicString(source);
2468
+ s.overwrite(valNode.start as number, valNode.end as number, JSON.stringify(v));
2469
+ const out = s.toString();
2470
+ const re = parseSync(canvasAbsPath, out, { sourceType: 'module' });
2471
+ if (re.errors && re.errors.length > 0) {
2472
+ throw new CanvasEditError(
2473
+ `edit produced invalid source: ${re.errors[0]?.message ?? 'parse error'}`,
2474
+ {
2475
+ canvas: canvasAbsPath,
2476
+ id: arrayName,
2477
+ }
2478
+ );
2479
+ }
2480
+ return { source: out };
2481
+ }
2482
+
2483
+ /** Edit an array-element src on disk (atomic write + cross-process lock). */
2484
+ export async function editArrayElementString(
2485
+ canvasAbsPath: string,
2486
+ arrayName: string,
2487
+ index: number,
2488
+ field: string,
2489
+ value: string
2490
+ ): Promise<{ source: string }> {
2491
+ return withLock(canvasAbsPath, async () => {
2492
+ const file = Bun.file(canvasAbsPath);
2493
+ if (!(await file.exists())) {
2494
+ throw new CanvasEditError(`Canvas not found: ${canvasAbsPath}`, {
2495
+ canvas: canvasAbsPath,
2496
+ id: arrayName,
2497
+ });
2498
+ }
2499
+ const source = await file.text();
2500
+ const next = applyEditArrayElementString(canvasAbsPath, source, arrayName, index, field, value);
2501
+ if (next.source === source) return next;
2502
+ const tmp = `${canvasAbsPath}.tmp.${Math.random().toString(36).slice(2, 10)}`;
2503
+ await Bun.write(tmp, next.source);
2504
+ const { rename } = await import('node:fs/promises');
2505
+ await rename(tmp, canvasAbsPath);
2506
+ return next;
2507
+ });
2508
+ }
2509
+
2510
+ /** A clip in an assemble request — a dropped reference chip's src + kind. */
2511
+ export interface AssembleClip {
2512
+ src: string;
2513
+ mediaKind: 'video' | 'audio';
2514
+ /** Intrinsic duration probed client-side; falls back to fps*3 when absent. */
2515
+ durationInFrames?: number | null;
2516
+ }
2517
+
2518
+ /** Reject a src that isn't a contained relative asset path (no ../, no scheme). */
2519
+ function assertContainedAssetSrc(src: string, canvasAbsPath: string): void {
2520
+ const t = src.trim();
2521
+ if (!t || /\.\./.test(t) || /^\s*(javascript|vbscript|file|data|https?):/i.test(t)) {
2522
+ throw new CanvasEditError(
2523
+ 'clip src must be a contained asset path (assets/…, no ../ or scheme)',
2524
+ { canvas: canvasAbsPath, id: '' }
2525
+ );
2526
+ }
2527
+ }
2528
+
2529
+ /**
2530
+ * Generate a full video-comp canvas TSX from a set of dropped reference clips
2531
+ * (DDR-150 P4 Task 12 — the "udělej z toho video" one-click). Video clips are
2532
+ * laid back-to-back as named `<Sequence>`s (explicit from/durationInFrames — so
2533
+ * they're immediately hand-editable on the Timeline, DDR-150); audio clips become
2534
+ * `<Audio>` beds under the whole reel. Only bundled Remotion imports are used
2535
+ * (skill invariant). `componentName` is the exported Canvas component; every src
2536
+ * is escaped + contained. The comp is a genuine frame-driven composition, never a
2537
+ * frozen preview. Throws `CanvasEditError` on an empty set / bad src.
2538
+ */
2539
+ export function assembleCompSource(
2540
+ componentName: string,
2541
+ clips: AssembleClip[],
2542
+ opts: { fps?: number; width?: number; height?: number } = {}
2543
+ ): string {
2544
+ const fps = Math.max(1, Math.round(opts.fps ?? 30));
2545
+ const width = Math.max(1, Math.round(opts.width ?? 1280));
2546
+ const height = Math.max(1, Math.round(opts.height ?? 720));
2547
+ const videos = clips.filter((c) => c.mediaKind === 'video');
2548
+ const audios = clips.filter((c) => c.mediaKind === 'audio');
2549
+ if (videos.length === 0 && audios.length === 0) {
2550
+ throw new CanvasEditError('assemble needs at least one clip', {
2551
+ canvas: componentName,
2552
+ id: '',
2553
+ });
2554
+ }
2555
+ for (const c of clips) assertContainedAssetSrc(c.src, componentName);
2556
+
2557
+ const defDur = fps * 3;
2558
+ let cursor = 0;
2559
+ const seqLines: string[] = [];
2560
+ videos.forEach((c, i) => {
2561
+ const dur = Math.max(1, Math.round(c.durationInFrames ?? defDur));
2562
+ seqLines.push(
2563
+ ` <Sequence name="clip-${i + 1}" from={${cursor}} durationInFrames={${dur}}>`,
2564
+ ` <OffthreadVideo src="${escapeAttr(c.src)}" />`,
2565
+ ` </Sequence>`
2566
+ );
2567
+ cursor += dur;
2568
+ });
2569
+ // Audio beds run under the whole reel (no seek — a bed, not a clip).
2570
+ audios.forEach((c) => {
2571
+ seqLines.push(` <Audio src="${escapeAttr(c.src)}" />`);
2572
+ });
2573
+ const total = Math.max(cursor, defDur);
2574
+
2575
+ const remotionImports = ['AbsoluteFill', 'Sequence'];
2576
+ if (videos.length) remotionImports.push('OffthreadVideo');
2577
+ if (audios.length) remotionImports.push('Audio');
2578
+
2579
+ return [
2580
+ `import { DesignCanvas, DCSection, DCArtboard, VideoComp } from '@maude/canvas-lib';`,
2581
+ `import { ${remotionImports.join(', ')} } from 'remotion';`,
2582
+ ``,
2583
+ `const Comp = () => (`,
2584
+ ` <AbsoluteFill style={{ background: 'var(--bg-0)' }}>`,
2585
+ ...seqLines,
2586
+ ` </AbsoluteFill>`,
2587
+ `);`,
2588
+ ``,
2589
+ `export default function ${componentName}() {`,
2590
+ ` return (`,
2591
+ ` <DesignCanvas>`,
2592
+ ` <DCSection title="Assembled reel">`,
2593
+ ` <DCArtboard id="reel" label="Reel" width={${width}} height={${height}}>`,
2594
+ ` <VideoComp component={Comp} durationInFrames={${total}} fps={${fps}} width={${width}} height={${height}} />`,
2595
+ ` </DCArtboard>`,
2596
+ ` </DCSection>`,
2597
+ ` </DesignCanvas>`,
2598
+ ` );`,
2599
+ `}`,
2600
+ ``,
2601
+ ].join('\n');
2602
+ }
2603
+
2604
+ // ---------------------------------------------------------------------------
2605
+ // Edit shapes.
2606
+
2607
+ function editStringAttr(s: MagicString, opening: AnyNode, name: string, value: string): void {
2608
+ const attr = findAttribute(opening, name);
2609
+ if (attr) {
2610
+ // Replace existing value. JSX attribute value forms we handle:
2611
+ // - <Tag name="literal" /> → replace inside the quotes
2612
+ // - <Tag name={'literal'} /> → wrap quotes around new value
2613
+ // - <Tag name={expr} /> → replace the whole expression text
2614
+ // - <Tag name /> → no value node; add `="..."`
2615
+ const v = attr.value;
2616
+ if (!v) {
2617
+ // <Tag name /> → <Tag name="value" />
2618
+ s.appendLeft(attr.end, `="${escapeAttr(value)}"`);
2619
+ return;
2620
+ }
2621
+ if (v.type === 'Literal' || v.type === 'StringLiteral') {
2622
+ // Replace the whole `"value"` (quotes included) so we control the escaping.
2623
+ // The value lands in a JSX *attribute*, where `"` must become `&quot;` and
2624
+ // `<`/`>` their entities — NOT JS backslash escaping. `JSON.stringify` would
2625
+ // emit `\"`, which is invalid in a JSX attribute and corrupts the source on
2626
+ // any value containing a double quote. Use the same `escapeAttr` as the two
2627
+ // insert branches so all four paths agree. See DDR-103 / DDR-105.
2628
+ s.overwrite(v.start, v.end, `"${escapeAttr(value)}"`);
2629
+ return;
2630
+ }
2631
+ if (v.type === 'JSXExpressionContainer') {
2632
+ // Replace the whole `{...}` with a plain quoted literal — keeps the
2633
+ // resulting JSX readable. Same JSX-attribute escaping as above (NOT
2634
+ // `JSON.stringify`, which would JS-escape a `"` and corrupt the source).
2635
+ s.overwrite(v.start, v.end, `"${escapeAttr(value)}"`);
2636
+ return;
2637
+ }
2638
+ // Unknown shape — refuse rather than corrupt.
2639
+ throw new Error(`Unsupported JSX attribute value shape: ${v.type}`);
2640
+ }
2641
+ // Attribute missing — insert right after the tag name (mirrors pipeline's
2642
+ // injection point so attribute order stays predictable).
2643
+ const insertAt: number | undefined = opening?.name?.end;
2644
+ if (typeof insertAt !== 'number') {
2645
+ throw new Error('Opening element has no resolvable name range');
2646
+ }
2647
+ s.appendLeft(insertAt, ` ${name}="${escapeAttr(value)}"`);
2648
+ }
2649
+
2650
+ function escapeAttr(value: string): string {
2651
+ return value.replace(/"/g, '&quot;').replace(/[<>]/g, (c) => (c === '<' ? '&lt;' : '&gt;'));
2652
+ }
2653
+
2654
+ /**
2655
+ * Escape JSX-significant characters so user-typed text lands in source `.tsx`
2656
+ * as inert TEXT, never as markup or an expression. The text is written between
2657
+ * an element's tags (`<button>HERE</button>`), where `<`/`>` would start a tag
2658
+ * and `{`/`}` would open a JSX expression — so all four (plus a bare `&`, which
2659
+ * begins an entity) are encoded. This is the load-bearing guard that keeps the
2660
+ * inline text editor (Phase 12) from being a source-injection vector. See
2661
+ * DDR-103.
2662
+ */
2663
+ function escapeJsxText(value: string): string {
2664
+ return value
2665
+ .replace(/&/g, '&amp;')
2666
+ .replace(/</g, '&lt;')
2667
+ .replace(/>/g, '&gt;')
2668
+ .replace(/\{/g, '&#123;')
2669
+ .replace(/\}/g, '&#125;');
2670
+ }
2671
+
2672
+ function editStyleProp(
2673
+ s: MagicString,
2674
+ opening: AnyNode,
2675
+ prop: string,
2676
+ value: string,
2677
+ canvasAbsPath: string,
2678
+ id: string
2679
+ ): void {
2680
+ const attr = findAttribute(opening, 'style');
2681
+ if (!attr) {
2682
+ // No style prop yet — insert one with a single key.
2683
+ const insertAt: number | undefined = opening?.name?.end;
2684
+ if (typeof insertAt !== 'number') {
2685
+ throw new Error('Opening element has no resolvable name range');
2686
+ }
2687
+ s.appendLeft(insertAt, ` style={{ ${jsKey(prop)}: ${value} }}`);
2688
+ return;
2689
+ }
2690
+ const v = attr.value;
2691
+ if (v?.type !== 'JSXExpressionContainer') {
2692
+ throw new CanvasEditError(
2693
+ `style attribute on ${id} is not a {{...}} expression — refusing to edit`,
2694
+ { canvas: canvasAbsPath, id }
2695
+ );
2696
+ }
2697
+ const obj = v.expression;
2698
+ if (obj?.type !== 'ObjectExpression') {
2699
+ throw new CanvasEditError(
2700
+ `style={...} on ${id} is not an inline ObjectExpression — refusing to edit`,
2701
+ { canvas: canvasAbsPath, id }
2702
+ );
2703
+ }
2704
+
2705
+ // Search for an existing property with the same key. JSX styles permit
2706
+ // camelCase identifiers (paddingTop) AND quoted strings ("padding-top").
2707
+ // Compare both forms.
2708
+ const propCamel = prop.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
2709
+ for (const p of obj.properties as AnyNode[]) {
2710
+ if (p?.type !== 'Property' && p?.type !== 'ObjectProperty') continue;
2711
+ const k = p.key;
2712
+ if (!k) continue;
2713
+ const kname = k.type === 'Identifier' ? k.name : k.type === 'Literal' ? String(k.value) : null;
2714
+ if (kname === prop || kname === propCamel) {
2715
+ s.overwrite(p.value.start, p.value.end, value);
2716
+ return;
2717
+ }
2718
+ }
2719
+
2720
+ // Key missing — append before the closing `}`. ObjectExpression's last char
2721
+ // is the `}`; magic-string appendLeft at end keeps the brace in place.
2722
+ //
2723
+ // TRAILING-COMMA guard: a frame-driven inline style often ends the last
2724
+ // property with a trailing comma (`opacity: o,\n}`). Unconditionally
2725
+ // prepending `, ` there produced `opacity: o, , color: X` — a double comma =
2726
+ // syntax error, so the canvas failed to rebuild and the Player kept rendering
2727
+ // the OLD source (the "my CSS edit resets when I replay the video" bug).
2728
+ // Detect an existing trailing comma between the last property and the `}` and
2729
+ // omit our separating comma when one is already there.
2730
+ const props = obj.properties as AnyNode[];
2731
+ let sep = ' ';
2732
+ if (props.length > 0) {
2733
+ const lastEnd = props[props.length - 1].end as number;
2734
+ const between = s.original.slice(lastEnd, (obj.end as number) - 1);
2735
+ sep = /,/.test(between) ? ' ' : ', ';
2736
+ }
954
2737
  // The object's textual end is `obj.end - 1` for `}` after the chars.
955
2738
  // 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;
2739
+ s.appendLeft((obj.end as number) - 1, `${sep}${jsKey(prop)}: ${value} `);
959
2740
  }
960
2741
 
961
2742
  /**