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