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