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