@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
package/apps/studio/api.ts
CHANGED
|
@@ -51,7 +51,6 @@ import {
|
|
|
51
51
|
type MovePosition,
|
|
52
52
|
moveElement,
|
|
53
53
|
removeAttribute,
|
|
54
|
-
removeClip,
|
|
55
54
|
reorderClip,
|
|
56
55
|
resizeArtboard,
|
|
57
56
|
resolveEditScope,
|
|
@@ -65,6 +64,29 @@ import {
|
|
|
65
64
|
setArtboardStyle,
|
|
66
65
|
toggleClipHidden,
|
|
67
66
|
} from './canvas-edit.ts';
|
|
67
|
+
import {
|
|
68
|
+
applyClipAudio,
|
|
69
|
+
applyClipFraming,
|
|
70
|
+
applyClipGrade,
|
|
71
|
+
applyDetachAudio,
|
|
72
|
+
applyEditTransition,
|
|
73
|
+
applyFitTotalToContent,
|
|
74
|
+
applyInsertTransition,
|
|
75
|
+
applyMoveClipToOverlay,
|
|
76
|
+
applyMoveClipToStoryline,
|
|
77
|
+
applyOnDisk,
|
|
78
|
+
applyRemoveTransition,
|
|
79
|
+
applyReorderOverlayLayer,
|
|
80
|
+
applyResolvePlaceholder,
|
|
81
|
+
applySetClipText,
|
|
82
|
+
applySetPlaybackRate,
|
|
83
|
+
applySplitClip,
|
|
84
|
+
applyTrimIn,
|
|
85
|
+
type GradeParams,
|
|
86
|
+
insertClipAt,
|
|
87
|
+
removeClipRippled,
|
|
88
|
+
seriesMove,
|
|
89
|
+
} from './clip-ops.ts';
|
|
68
90
|
import type { Context } from './context.ts';
|
|
69
91
|
import {
|
|
70
92
|
type AudioMatch,
|
|
@@ -201,6 +223,12 @@ export interface Comment {
|
|
|
201
223
|
author: string;
|
|
202
224
|
thread: Reply[];
|
|
203
225
|
mentions: string[];
|
|
226
|
+
/** enhanced-video-editing (Task 23) — a TIMELINE anchor: preferred
|
|
227
|
+
* `{ clipStableId, frameOffset }` (survives reorder/ripple), fallback
|
|
228
|
+
* `{ frame }` (track-level). Absent on ordinary element comments. Comment
|
|
229
|
+
* text is untrusted user/peer text (DDR-054) — rendered as text, never
|
|
230
|
+
* into TSX. */
|
|
231
|
+
timeline?: { clipStableId?: string; frameOffset?: number; frame?: number; lane?: string };
|
|
204
232
|
}
|
|
205
233
|
|
|
206
234
|
export interface GitCommitter {
|
|
@@ -276,6 +304,8 @@ export interface Api {
|
|
|
276
304
|
// Canvas state
|
|
277
305
|
loadCanvasState(file: string): Promise<Record<string, unknown> | null>;
|
|
278
306
|
saveCanvasState(file: string, state: Record<string, unknown>): Promise<void>;
|
|
307
|
+
timelineMediaLoad(key: string): Promise<Record<string, unknown> | null>;
|
|
308
|
+
timelineMediaSave(key: string, data: Record<string, unknown>): Promise<boolean>;
|
|
279
309
|
// Canvas meta sidecar (Phase 4 T5 — .design/ui/<slug>.meta.json)
|
|
280
310
|
loadCanvasMeta(file: string): Promise<Record<string, unknown> | null>;
|
|
281
311
|
/** DDR-148 — raw .tsx source for the Timeline sequence/keyframe parser. */
|
|
@@ -394,6 +424,31 @@ export interface Api {
|
|
|
394
424
|
durationInFrames?: unknown;
|
|
395
425
|
from?: unknown;
|
|
396
426
|
}): Promise<{ ok: true; seq?: number } | { ok: false; status: number; error: string }>;
|
|
427
|
+
// feature-enhanced-video-editing (Phase 2) — parametric clip verbs (speed ·
|
|
428
|
+
// trim-in · audio · detach-audio · framing · grade · transition).
|
|
429
|
+
clipEditOp(input: {
|
|
430
|
+
canvas?: unknown;
|
|
431
|
+
artboardId?: unknown;
|
|
432
|
+
stableId?: unknown;
|
|
433
|
+
contentHash?: unknown;
|
|
434
|
+
verb?: unknown;
|
|
435
|
+
rate?: unknown;
|
|
436
|
+
deltaFrames?: unknown;
|
|
437
|
+
muted?: unknown;
|
|
438
|
+
volume?: unknown;
|
|
439
|
+
framing?: unknown;
|
|
440
|
+
grade?: unknown;
|
|
441
|
+
presentation?: unknown;
|
|
442
|
+
durationInFrames?: unknown;
|
|
443
|
+
atFrame?: unknown;
|
|
444
|
+
src?: unknown;
|
|
445
|
+
mediaKind?: unknown;
|
|
446
|
+
text?: unknown;
|
|
447
|
+
toIndex?: unknown;
|
|
448
|
+
}): Promise<
|
|
449
|
+
| { ok: true; seq?: number; extra?: Record<string, unknown> }
|
|
450
|
+
| { ok: false; status: number; error: string }
|
|
451
|
+
>;
|
|
397
452
|
// DDR-150 P3 — remove a clip addressed by stableId (fingerprint + semantic
|
|
398
453
|
// gate; refuses the only clip; drops an adjacent transition in a series).
|
|
399
454
|
removeSequenceOp(input: {
|
|
@@ -983,13 +1038,36 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
983
1038
|
}
|
|
984
1039
|
}
|
|
985
1040
|
|
|
1041
|
+
// Security (adversarial review 2026-07-30): the timeline anchor is read by
|
|
1042
|
+
// the /design:edit agent (edit.md §0.6b treats `lane` as navigation), and a
|
|
1043
|
+
// comment can arrive from an UNTRUSTED hub peer (DDR-054) via the sync-persist
|
|
1044
|
+
// path — which does NOT go through commentsAdd's validation. So re-clamp the
|
|
1045
|
+
// anchor HERE, at the read boundary every consumer (incl. /_comments served to
|
|
1046
|
+
// the agent) passes through: bound `lane`/`clipStableId` length, coerce frame
|
|
1047
|
+
// ints, and drop unknown fields — a poisoned over-long `lane` can't smuggle an
|
|
1048
|
+
// instruction past the 40-char label the feature is documented to carry.
|
|
1049
|
+
function sanitizeTimelineAnchor(raw: unknown): Comment['timeline'] | undefined {
|
|
1050
|
+
if (!raw || typeof raw !== 'object') return undefined;
|
|
1051
|
+
const t = raw as Record<string, unknown>;
|
|
1052
|
+
const anchor: NonNullable<Comment['timeline']> = {};
|
|
1053
|
+
if (typeof t.clipStableId === 'string' && t.clipStableId.length <= 200)
|
|
1054
|
+
anchor.clipStableId = t.clipStableId;
|
|
1055
|
+
if (Number.isFinite(Number(t.frameOffset)))
|
|
1056
|
+
anchor.frameOffset = Math.max(0, Math.round(Number(t.frameOffset)));
|
|
1057
|
+
if (Number.isFinite(Number(t.frame))) anchor.frame = Math.max(0, Math.round(Number(t.frame)));
|
|
1058
|
+
if (typeof t.lane === 'string' && t.lane.length <= 40) anchor.lane = t.lane;
|
|
1059
|
+
return anchor.clipStableId != null || anchor.frame != null ? anchor : undefined;
|
|
1060
|
+
}
|
|
1061
|
+
|
|
986
1062
|
function backfillComment(raw: unknown): Comment {
|
|
987
1063
|
const c = (raw ?? {}) as Partial<Comment>;
|
|
1064
|
+
const timeline = sanitizeTimelineAnchor((c as { timeline?: unknown }).timeline);
|
|
988
1065
|
return {
|
|
989
1066
|
...(c as Comment),
|
|
990
1067
|
author: typeof c.author === 'string' ? c.author : '',
|
|
991
1068
|
thread: Array.isArray(c.thread) ? c.thread : [],
|
|
992
1069
|
mentions: Array.isArray(c.mentions) ? c.mentions : [],
|
|
1070
|
+
...(timeline ? { timeline } : { timeline: undefined }),
|
|
993
1071
|
};
|
|
994
1072
|
}
|
|
995
1073
|
|
|
@@ -1148,6 +1226,30 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
1148
1226
|
thread: [],
|
|
1149
1227
|
mentions: parseMentions(text),
|
|
1150
1228
|
};
|
|
1229
|
+
// Task 23 — timeline anchor pass-through (validated shape only).
|
|
1230
|
+
const tl = payload.timeline;
|
|
1231
|
+
if (tl && typeof tl === 'object') {
|
|
1232
|
+
const anchor: NonNullable<Comment['timeline']> = {};
|
|
1233
|
+
if (typeof tl.clipStableId === 'string' && tl.clipStableId.length <= 200) {
|
|
1234
|
+
anchor.clipStableId = tl.clipStableId;
|
|
1235
|
+
}
|
|
1236
|
+
if (Number.isFinite(Number(tl.frameOffset))) {
|
|
1237
|
+
anchor.frameOffset = Math.max(0, Math.round(Number(tl.frameOffset)));
|
|
1238
|
+
}
|
|
1239
|
+
if (Number.isFinite(Number(tl.frame))) {
|
|
1240
|
+
anchor.frame = Math.max(0, Math.round(Number(tl.frame)));
|
|
1241
|
+
}
|
|
1242
|
+
// Dogfood (2026-07-30) — the C-tool records WHICH lane the click landed
|
|
1243
|
+
// on (storyline · V<n> overlay · A<n> audio) so an agent reading the
|
|
1244
|
+
// comment knows exactly where to look.
|
|
1245
|
+
if (
|
|
1246
|
+
typeof (tl as { lane?: unknown }).lane === 'string' &&
|
|
1247
|
+
(tl as { lane: string }).lane.length <= 40
|
|
1248
|
+
) {
|
|
1249
|
+
anchor.lane = (tl as { lane: string }).lane;
|
|
1250
|
+
}
|
|
1251
|
+
if (anchor.clipStableId != null || anchor.frame != null) c.timeline = anchor;
|
|
1252
|
+
}
|
|
1151
1253
|
list.push(c);
|
|
1152
1254
|
await saveCommentsForFile(payload.file, list);
|
|
1153
1255
|
onCommentsChanged(payload.file);
|
|
@@ -1236,6 +1338,60 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
1236
1338
|
}
|
|
1237
1339
|
}
|
|
1238
1340
|
|
|
1341
|
+
// ---------- Timeline media visuals cache (enhanced-video-editing Task 7) ----
|
|
1342
|
+
//
|
|
1343
|
+
// Filmstrip dataURL strips + waveform peak arrays, keyed `<sha8>:<bucket>`,
|
|
1344
|
+
// persisted under `_canvas-state/timeline-media/` — per-machine runtime state
|
|
1345
|
+
// (DDR-115; `_canvas-state/` is already on all three ignore lists, so a
|
|
1346
|
+
// subdirectory needs no list change).
|
|
1347
|
+
|
|
1348
|
+
const TL_MEDIA_KEY_RE = /^[A-Za-z0-9._:-]{1,160}$/;
|
|
1349
|
+
|
|
1350
|
+
function timelineMediaPath(key: string): string | null {
|
|
1351
|
+
if (!TL_MEDIA_KEY_RE.test(key) || key.includes('..')) return null;
|
|
1352
|
+
return path.join(paths.canvasStateDir, 'timeline-media', `${key.replaceAll(':', '__')}.json`);
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
async function timelineMediaLoad(key: string): Promise<Record<string, unknown> | null> {
|
|
1356
|
+
const p = timelineMediaPath(key);
|
|
1357
|
+
if (!p) return null;
|
|
1358
|
+
try {
|
|
1359
|
+
const obj = JSON.parse(await Bun.file(p).text());
|
|
1360
|
+
return obj && typeof obj === 'object' ? obj : null;
|
|
1361
|
+
} catch {
|
|
1362
|
+
return null;
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
async function timelineMediaSave(key: string, data: Record<string, unknown>): Promise<boolean> {
|
|
1367
|
+
const p = timelineMediaPath(key);
|
|
1368
|
+
if (!p) return false;
|
|
1369
|
+
// Shape gate (security review 2026-07-30): `strip[]` is later rendered as
|
|
1370
|
+
// `<img src={u}>` in the trusted shell (TimelinePanel), so persist only the
|
|
1371
|
+
// two known shapes — a filmstrip of `data:image/*` URLs and a numeric peak
|
|
1372
|
+
// array. Anything else (a poisoned `http(s)://` beacon URL, a non-array) is
|
|
1373
|
+
// dropped rather than cached. Defense-in-depth over the loopback guard.
|
|
1374
|
+
const clean: Record<string, unknown> = {};
|
|
1375
|
+
if (Array.isArray(data.strip)) {
|
|
1376
|
+
const strip = data.strip.filter(
|
|
1377
|
+
(u): u is string => typeof u === 'string' && /^data:image\//.test(u)
|
|
1378
|
+
);
|
|
1379
|
+
if (strip.length !== data.strip.length) return false; // reject if any entry was rejected
|
|
1380
|
+
clean.strip = strip;
|
|
1381
|
+
}
|
|
1382
|
+
if (Array.isArray(data.peaks)) {
|
|
1383
|
+
if (!data.peaks.every((n) => typeof n === 'number' && Number.isFinite(n))) return false;
|
|
1384
|
+
clean.peaks = data.peaks;
|
|
1385
|
+
}
|
|
1386
|
+
if (Object.keys(clean).length === 0) return false;
|
|
1387
|
+
try {
|
|
1388
|
+
await Bun.write(p, JSON.stringify(clean));
|
|
1389
|
+
return true;
|
|
1390
|
+
} catch {
|
|
1391
|
+
return false;
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1239
1395
|
// ---------- Canvas meta sidecar (Phase 4 T5; split DDR-115) ----------
|
|
1240
1396
|
//
|
|
1241
1397
|
// Each canvas under `<designRoot>/ui/<name>.tsx` has a sibling
|
|
@@ -2232,8 +2388,11 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
2232
2388
|
// Parse + validate the assemble clip list up front (video-comp only).
|
|
2233
2389
|
const assembleClips: AssembleClip[] = [];
|
|
2234
2390
|
if (kind === 'video-comp') {
|
|
2235
|
-
|
|
2236
|
-
|
|
2391
|
+
// Task 20 (enhanced-video-editing) — an EMPTY clips[] is the greenfield
|
|
2392
|
+
// "New video" flow: scaffold an editable empty comp, build the cut
|
|
2393
|
+
// drop-first on the Timeline.
|
|
2394
|
+
if (!Array.isArray(input.clips)) {
|
|
2395
|
+
return { ok: false, status: 400, error: 'video-comp needs a clips[] (may be empty)' };
|
|
2237
2396
|
}
|
|
2238
2397
|
if (input.clips.length > 60) {
|
|
2239
2398
|
return { ok: false, status: 400, error: 'too many clips (max 60)' };
|
|
@@ -2307,6 +2466,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
2307
2466
|
fps: Number.isFinite(Number(input.fps)) ? Number(input.fps) : undefined,
|
|
2308
2467
|
width: Number.isFinite(Number(input.width)) ? Number(input.width) : undefined,
|
|
2309
2468
|
height: Number.isFinite(Number(input.height)) ? Number(input.height) : undefined,
|
|
2469
|
+
allowEmpty: assembleClips.length === 0,
|
|
2310
2470
|
});
|
|
2311
2471
|
} catch (err) {
|
|
2312
2472
|
return {
|
|
@@ -3497,6 +3657,13 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
3497
3657
|
const before = await Bun.file(r.abs).text();
|
|
3498
3658
|
if (stableId) await retimeSequenceByClip(r.abs, artboardId, stableId, contentHash, patch);
|
|
3499
3659
|
else await retimeSequence(r.abs, index, patch);
|
|
3660
|
+
// A trim/move that pushes a clip past the comp end stretches the comp
|
|
3661
|
+
// (the timeline always reaches the last clip — dogfood rule).
|
|
3662
|
+
try {
|
|
3663
|
+
await applyOnDisk(r.abs, (src) => applyFitTotalToContent(r.abs, src, artboardId));
|
|
3664
|
+
} catch {
|
|
3665
|
+
/* fit is best-effort — the retime itself already landed */
|
|
3666
|
+
}
|
|
3500
3667
|
const after = await Bun.file(r.abs).text();
|
|
3501
3668
|
if (after === before) {
|
|
3502
3669
|
ctx.bus.emit('activity:unsuppress', rel);
|
|
@@ -3534,7 +3701,10 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
3534
3701
|
ctx.bus.emit('activity:suppress', rel);
|
|
3535
3702
|
try {
|
|
3536
3703
|
const before = await Bun.file(r.abs).text();
|
|
3537
|
-
|
|
3704
|
+
// feature-enhanced-video-editing — the iMovie Delete ripples: a series
|
|
3705
|
+
// beat's removal shrinks the comp TOTAL (duration − transition overlap);
|
|
3706
|
+
// a standalone overlay/audio clip removes without moving the cut.
|
|
3707
|
+
await removeClipRippled(r.abs, artboardId, stableId, contentHash);
|
|
3538
3708
|
const after = await Bun.file(r.abs).text();
|
|
3539
3709
|
if (after === before) {
|
|
3540
3710
|
ctx.bus.emit('activity:unsuppress', rel);
|
|
@@ -3563,6 +3733,9 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
3563
3733
|
durationInFrames?: unknown;
|
|
3564
3734
|
mediaTag?: unknown;
|
|
3565
3735
|
src?: unknown;
|
|
3736
|
+
lane?: unknown;
|
|
3737
|
+
index?: unknown;
|
|
3738
|
+
placeholder?: unknown;
|
|
3566
3739
|
}): Promise<
|
|
3567
3740
|
| { ok: true; stableId: string | null; seq?: number }
|
|
3568
3741
|
| { ok: false; status: number; error: string }
|
|
@@ -3578,11 +3751,36 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
3578
3751
|
: 90;
|
|
3579
3752
|
const mediaTag = typeof input.mediaTag === 'string' ? input.mediaTag : null;
|
|
3580
3753
|
const src = typeof input.src === 'string' ? input.src : null;
|
|
3754
|
+
// Task 6 — positional insert: lane + (storyline) index route through the
|
|
3755
|
+
// caret-aware engine; the legacy append path stays for lane-less callers.
|
|
3756
|
+
const lane =
|
|
3757
|
+
input.lane === 'storyline' || input.lane === 'overlay' || input.lane === 'audio'
|
|
3758
|
+
? input.lane
|
|
3759
|
+
: null;
|
|
3760
|
+
const laneIndex = Number.isInteger(Number(input.index))
|
|
3761
|
+
? Math.max(0, Math.round(Number(input.index)))
|
|
3762
|
+
: undefined;
|
|
3763
|
+
// Task 22 — a prompt-carrying AI slate instead of media.
|
|
3764
|
+
const ph = input.placeholder as { prompt?: unknown; kind?: unknown } | null | undefined;
|
|
3765
|
+
const placeholder =
|
|
3766
|
+
ph && typeof ph === 'object' && typeof ph.prompt === 'string'
|
|
3767
|
+
? { prompt: ph.prompt, kind: typeof ph.kind === 'string' ? ph.kind : 'veo' }
|
|
3768
|
+
: null;
|
|
3581
3769
|
const rel = path.relative(paths.designRoot, r.abs);
|
|
3582
3770
|
ctx.bus.emit('activity:suppress', rel);
|
|
3583
3771
|
try {
|
|
3584
3772
|
const before = await Bun.file(r.abs).text();
|
|
3585
|
-
const res =
|
|
3773
|
+
const res = lane
|
|
3774
|
+
? await insertClipAt(r.abs, artboardId, {
|
|
3775
|
+
lane,
|
|
3776
|
+
index: laneIndex,
|
|
3777
|
+
from,
|
|
3778
|
+
durationInFrames,
|
|
3779
|
+
mediaTag,
|
|
3780
|
+
src,
|
|
3781
|
+
placeholder,
|
|
3782
|
+
})
|
|
3783
|
+
: await insertClip(r.abs, artboardId, { from, durationInFrames, mediaTag, src });
|
|
3586
3784
|
const after = await Bun.file(r.abs).text();
|
|
3587
3785
|
let seq: number | undefined;
|
|
3588
3786
|
if (after === before) {
|
|
@@ -3606,6 +3804,166 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
3606
3804
|
}
|
|
3607
3805
|
}
|
|
3608
3806
|
|
|
3807
|
+
/**
|
|
3808
|
+
* feature-enhanced-video-editing (Phase 2) — the parametric clip verbs, one
|
|
3809
|
+
* dispatch op: speed · trim-in · audio · detach-audio · framing · grade ·
|
|
3810
|
+
* transition. Every verb is a pure clip-ops fn run under the file lock, with
|
|
3811
|
+
* the same stableId + contentHash optimistic-concurrency discipline, pre-op
|
|
3812
|
+
* snapshot, and whole-file undo seq as the structural clip ops.
|
|
3813
|
+
*/
|
|
3814
|
+
async function clipEditOp(input: {
|
|
3815
|
+
canvas?: unknown;
|
|
3816
|
+
artboardId?: unknown;
|
|
3817
|
+
stableId?: unknown;
|
|
3818
|
+
contentHash?: unknown;
|
|
3819
|
+
verb?: unknown;
|
|
3820
|
+
rate?: unknown;
|
|
3821
|
+
deltaFrames?: unknown;
|
|
3822
|
+
muted?: unknown;
|
|
3823
|
+
volume?: unknown;
|
|
3824
|
+
framing?: unknown;
|
|
3825
|
+
grade?: unknown;
|
|
3826
|
+
presentation?: unknown;
|
|
3827
|
+
durationInFrames?: unknown;
|
|
3828
|
+
atFrame?: unknown;
|
|
3829
|
+
src?: unknown;
|
|
3830
|
+
mediaKind?: unknown;
|
|
3831
|
+
text?: unknown;
|
|
3832
|
+
toIndex?: unknown;
|
|
3833
|
+
}): Promise<
|
|
3834
|
+
| { ok: true; seq?: number; extra?: Record<string, unknown> }
|
|
3835
|
+
| { ok: false; status: number; error: string }
|
|
3836
|
+
> {
|
|
3837
|
+
const r = resolveCanvasAbs(input.canvas);
|
|
3838
|
+
if (!r.ok) return r;
|
|
3839
|
+
const stableId = typeof input.stableId === 'string' ? input.stableId : null;
|
|
3840
|
+
if (!stableId) return { ok: false, status: 400, error: 'stableId required' };
|
|
3841
|
+
const artboardId = typeof input.artboardId === 'string' ? input.artboardId : undefined;
|
|
3842
|
+
const hash = typeof input.contentHash === 'string' ? input.contentHash : undefined;
|
|
3843
|
+
const verb = typeof input.verb === 'string' ? input.verb : '';
|
|
3844
|
+
const abs = r.abs;
|
|
3845
|
+
let run: ((source: string) => { source: string } & Record<string, unknown>) | null = null;
|
|
3846
|
+
switch (verb) {
|
|
3847
|
+
case 'speed': {
|
|
3848
|
+
const rate = Number(input.rate);
|
|
3849
|
+
run = (src) => applySetPlaybackRate(abs, src, artboardId, stableId, hash, rate);
|
|
3850
|
+
break;
|
|
3851
|
+
}
|
|
3852
|
+
case 'trim-in': {
|
|
3853
|
+
const delta = Number(input.deltaFrames);
|
|
3854
|
+
if (!Number.isFinite(delta))
|
|
3855
|
+
return { ok: false, status: 400, error: 'deltaFrames required' };
|
|
3856
|
+
run = (src) => applyTrimIn(abs, src, artboardId, stableId, hash, delta);
|
|
3857
|
+
break;
|
|
3858
|
+
}
|
|
3859
|
+
case 'audio': {
|
|
3860
|
+
const opts: { muted?: boolean; volume?: number | null } = {};
|
|
3861
|
+
if (typeof input.muted === 'boolean') opts.muted = input.muted;
|
|
3862
|
+
if (input.volume !== undefined)
|
|
3863
|
+
opts.volume = input.volume == null ? null : Number(input.volume);
|
|
3864
|
+
run = (src) => applyClipAudio(abs, src, artboardId, stableId, hash, opts);
|
|
3865
|
+
break;
|
|
3866
|
+
}
|
|
3867
|
+
case 'detach-audio': {
|
|
3868
|
+
run = (src) => applyDetachAudio(abs, src, artboardId, stableId, hash);
|
|
3869
|
+
break;
|
|
3870
|
+
}
|
|
3871
|
+
case 'framing': {
|
|
3872
|
+
const f = input.framing as { scale?: unknown; x?: unknown; y?: unknown } | null;
|
|
3873
|
+
const framing =
|
|
3874
|
+
f == null
|
|
3875
|
+
? null
|
|
3876
|
+
: { scale: Number(f.scale) || 1, x: Number(f.x) || 0, y: Number(f.y) || 0 };
|
|
3877
|
+
run = (src) => applyClipFraming(abs, src, artboardId, stableId, hash, framing);
|
|
3878
|
+
break;
|
|
3879
|
+
}
|
|
3880
|
+
case 'grade': {
|
|
3881
|
+
const grade = input.grade == null ? null : (input.grade as GradeParams);
|
|
3882
|
+
run = (src) => applyClipGrade(abs, src, artboardId, stableId, hash, grade);
|
|
3883
|
+
break;
|
|
3884
|
+
}
|
|
3885
|
+
case 'transition': {
|
|
3886
|
+
const opts: { presentation?: string; durationInFrames?: number } = {};
|
|
3887
|
+
if (typeof input.presentation === 'string') opts.presentation = input.presentation;
|
|
3888
|
+
if (input.durationInFrames != null) opts.durationInFrames = Number(input.durationInFrames);
|
|
3889
|
+
run = (src) => applyEditTransition(abs, src, artboardId, stableId, hash, opts);
|
|
3890
|
+
break;
|
|
3891
|
+
}
|
|
3892
|
+
case 'split': {
|
|
3893
|
+
const atFrame = Number(input.atFrame);
|
|
3894
|
+
if (!Number.isFinite(atFrame)) return { ok: false, status: 400, error: 'atFrame required' };
|
|
3895
|
+
run = (src) => applySplitClip(abs, src, artboardId, stableId, hash, atFrame);
|
|
3896
|
+
break;
|
|
3897
|
+
}
|
|
3898
|
+
case 'insert-transition': {
|
|
3899
|
+
const presentation = typeof input.presentation === 'string' ? input.presentation : 'fade';
|
|
3900
|
+
const frames = Number(input.durationInFrames) || 15;
|
|
3901
|
+
run = (src) =>
|
|
3902
|
+
applyInsertTransition(abs, src, artboardId, stableId, hash, presentation, frames);
|
|
3903
|
+
break;
|
|
3904
|
+
}
|
|
3905
|
+
case 'remove-transition': {
|
|
3906
|
+
run = (src) => applyRemoveTransition(abs, src, artboardId, stableId, hash);
|
|
3907
|
+
break;
|
|
3908
|
+
}
|
|
3909
|
+
case 'set-text': {
|
|
3910
|
+
const t = typeof input.text === 'string' ? input.text : '';
|
|
3911
|
+
run = (src) => applySetClipText(abs, src, artboardId, stableId, hash, t);
|
|
3912
|
+
break;
|
|
3913
|
+
}
|
|
3914
|
+
case 'to-overlay': {
|
|
3915
|
+
run = (src) => applyMoveClipToOverlay(abs, src, artboardId, stableId, hash);
|
|
3916
|
+
break;
|
|
3917
|
+
}
|
|
3918
|
+
case 'to-storyline': {
|
|
3919
|
+
run = (src) => applyMoveClipToStoryline(abs, src, artboardId, stableId, hash);
|
|
3920
|
+
break;
|
|
3921
|
+
}
|
|
3922
|
+
case 'layer-order': {
|
|
3923
|
+
const toIndex = Number(input.toIndex);
|
|
3924
|
+
if (!Number.isFinite(toIndex)) return { ok: false, status: 400, error: 'toIndex required' };
|
|
3925
|
+
run = (src) => applyReorderOverlayLayer(abs, src, artboardId, stableId, hash, toIndex);
|
|
3926
|
+
break;
|
|
3927
|
+
}
|
|
3928
|
+
case 'resolve-placeholder': {
|
|
3929
|
+
const src2 = typeof input.src === 'string' ? input.src : '';
|
|
3930
|
+
const mediaKind = input.mediaKind === 'image' ? 'image' : 'video';
|
|
3931
|
+
run = (src) =>
|
|
3932
|
+
applyResolvePlaceholder(abs, src, artboardId, stableId, hash, src2, mediaKind);
|
|
3933
|
+
break;
|
|
3934
|
+
}
|
|
3935
|
+
default:
|
|
3936
|
+
return { ok: false, status: 400, error: `unknown clip verb "${verb}"` };
|
|
3937
|
+
}
|
|
3938
|
+
const rel = path.relative(paths.designRoot, abs);
|
|
3939
|
+
ctx.bus.emit('activity:suppress', rel);
|
|
3940
|
+
try {
|
|
3941
|
+
const before = await Bun.file(abs).text();
|
|
3942
|
+
const result = await applyOnDisk(abs, run);
|
|
3943
|
+
const after = await Bun.file(abs).text();
|
|
3944
|
+
let seq: number | undefined;
|
|
3945
|
+
if (after === before) {
|
|
3946
|
+
ctx.bus.emit('activity:unsuppress', rel);
|
|
3947
|
+
} else {
|
|
3948
|
+
try {
|
|
3949
|
+
await history.writeSnapshot(rel, before, `pre-clip-${verb}`);
|
|
3950
|
+
} catch {
|
|
3951
|
+
/* snapshot best-effort */
|
|
3952
|
+
}
|
|
3953
|
+
seq = logUndo(abs, before, after);
|
|
3954
|
+
}
|
|
3955
|
+
const { source: _src, ...extra } = result;
|
|
3956
|
+
return { ok: true, seq, extra };
|
|
3957
|
+
} catch (err) {
|
|
3958
|
+
ctx.bus.emit('activity:unsuppress', rel);
|
|
3959
|
+
return {
|
|
3960
|
+
ok: false,
|
|
3961
|
+
status: err instanceof CanvasEditError ? 422 : 500,
|
|
3962
|
+
error: err instanceof Error ? err.message : `clip ${verb} failed`,
|
|
3963
|
+
};
|
|
3964
|
+
}
|
|
3965
|
+
}
|
|
3966
|
+
|
|
3609
3967
|
// Stage I (feature-element-editing-robustness) — general element structural
|
|
3610
3968
|
// edits (delete / insert / new-artboard) + free-hand artboard resize (D4). All
|
|
3611
3969
|
// MAIN-ORIGIN ONLY at the route layer; each logs a whole-file {before, after}
|
|
@@ -4676,6 +5034,9 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
4676
5034
|
refStableId?: unknown;
|
|
4677
5035
|
refContentHash?: unknown;
|
|
4678
5036
|
position?: unknown;
|
|
5037
|
+
/** 'move' = the magnetic drag commit: a real series MOVE (any distance)
|
|
5038
|
+
* instead of the legacy adjacent swap. */
|
|
5039
|
+
mode?: unknown;
|
|
4679
5040
|
}): Promise<
|
|
4680
5041
|
| { ok: true; stableId: string | null; seq?: number }
|
|
4681
5042
|
| { ok: false; status: number; error: string }
|
|
@@ -4696,15 +5057,26 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
4696
5057
|
ctx.bus.emit('activity:suppress', rel);
|
|
4697
5058
|
try {
|
|
4698
5059
|
const before = await Bun.file(r.abs).text();
|
|
4699
|
-
const res =
|
|
4700
|
-
|
|
4701
|
-
|
|
4702
|
-
|
|
4703
|
-
|
|
4704
|
-
|
|
4705
|
-
|
|
4706
|
-
|
|
4707
|
-
|
|
5060
|
+
const res =
|
|
5061
|
+
input.mode === 'move'
|
|
5062
|
+
? await seriesMove(
|
|
5063
|
+
r.abs,
|
|
5064
|
+
artboardId,
|
|
5065
|
+
stableId,
|
|
5066
|
+
contentHash,
|
|
5067
|
+
refStableId,
|
|
5068
|
+
refContentHash,
|
|
5069
|
+
position
|
|
5070
|
+
)
|
|
5071
|
+
: await reorderClip(
|
|
5072
|
+
r.abs,
|
|
5073
|
+
artboardId,
|
|
5074
|
+
stableId,
|
|
5075
|
+
contentHash,
|
|
5076
|
+
refStableId,
|
|
5077
|
+
refContentHash,
|
|
5078
|
+
position
|
|
5079
|
+
);
|
|
4708
5080
|
const after = await Bun.file(r.abs).text();
|
|
4709
5081
|
let seq: number | undefined;
|
|
4710
5082
|
if (after === before) {
|
|
@@ -5189,6 +5561,8 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
5189
5561
|
parseMentions,
|
|
5190
5562
|
loadCanvasState,
|
|
5191
5563
|
saveCanvasState,
|
|
5564
|
+
timelineMediaLoad,
|
|
5565
|
+
timelineMediaSave,
|
|
5192
5566
|
loadCanvasMeta,
|
|
5193
5567
|
loadCanvasSource,
|
|
5194
5568
|
patchCanvasMeta,
|
|
@@ -5211,6 +5585,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
5211
5585
|
editCss,
|
|
5212
5586
|
editText,
|
|
5213
5587
|
editAttr,
|
|
5588
|
+
clipEditOp,
|
|
5214
5589
|
removeSequenceOp,
|
|
5215
5590
|
insertSequenceOp,
|
|
5216
5591
|
reorderSequenceOp,
|
|
@@ -50,6 +50,7 @@ const {
|
|
|
50
50
|
'license-key': licenseKeyArg,
|
|
51
51
|
timeout = '60',
|
|
52
52
|
scale = '1',
|
|
53
|
+
'max-frames': maxFramesArg, // frame ceiling (exporters/video.ts resolveMaxFrames)
|
|
53
54
|
} = args;
|
|
54
55
|
|
|
55
56
|
if (!url) {
|
|
@@ -316,12 +317,20 @@ async function frameStepCapture({
|
|
|
316
317
|
}
|
|
317
318
|
}, compId);
|
|
318
319
|
const fps = Number(fpsArg) || compMeta?.fps || 30;
|
|
319
|
-
//
|
|
320
|
-
//
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
);
|
|
320
|
+
// Frame ceiling so a runaway/huge comp can't spawn an unbounded screenshot
|
|
321
|
+
// loop — passed by exporters/video.ts (resolveMaxFrames; raisable per-export
|
|
322
|
+
// via options.maxFrames). Clamping is LOUD: a truncated export says so on
|
|
323
|
+
// stderr instead of silently shipping a shorter file.
|
|
324
|
+
const maxFrames = Number(maxFramesArg) > 0 ? Number(maxFramesArg) : 3600;
|
|
325
|
+
const wantedFrames = Number(framesArg) || compMeta?.durationInFrames || Math.round(fps * 3); // 3 s default
|
|
326
|
+
const frameCount = Math.min(maxFrames, wantedFrames);
|
|
327
|
+
if (wantedFrames > maxFrames) {
|
|
328
|
+
console.error(
|
|
329
|
+
`⚠ _video-playwright: comp is ${wantedFrames} frames but the export cap is ` +
|
|
330
|
+
`${maxFrames} — capturing only the first ${maxFrames} frames. Pass ` +
|
|
331
|
+
'options.maxFrames to raise the cap.'
|
|
332
|
+
);
|
|
333
|
+
}
|
|
325
334
|
if (!Number.isFinite(fps) || fps <= 0 || !Number.isFinite(frameCount) || frameCount <= 0) {
|
|
326
335
|
console.error('_video-playwright: could not resolve fps/frameCount (no comp meta, no args)');
|
|
327
336
|
process.exit(2);
|