@1agh/maude 0.51.0 → 0.52.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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 +849 -121
- package/apps/studio/client/github.js +2 -0
- 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 +590 -0
- 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 +1 -0
- 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/git/service.ts +1 -1
- package/apps/studio/http.ts +113 -6
- package/apps/studio/ripple.ts +335 -0
- package/apps/studio/server.ts +7 -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 +28 -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/gitignore-block.mjs +1 -0
- 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
|
@@ -41,8 +41,17 @@ const CONTENT_TYPE: Record<string, string> = {
|
|
|
41
41
|
gif: 'image/gif',
|
|
42
42
|
};
|
|
43
43
|
|
|
44
|
-
/**
|
|
45
|
-
|
|
44
|
+
/** Default frame ceiling — 2 min @ 30 fps. Raisable per-export via
|
|
45
|
+
* `options.maxFrames` (an editor invites longer cuts than the original 30 s
|
|
46
|
+
* cap; the flatmap RCA's real 90 s reel already violated it). A request that
|
|
47
|
+
* exceeds the resolved ceiling is REFUSED with remediation, never silently
|
|
48
|
+
* truncated. */
|
|
49
|
+
const DEFAULT_MAX_FRAMES = 3600;
|
|
50
|
+
/** Absolute backstop for `options.maxFrames` itself (10 min @ 30 fps). */
|
|
51
|
+
const MAX_FRAMES_CEILING = 18000;
|
|
52
|
+
/** Above this, the frame-step fallback path gets slow (~2.5 s/frame) and 1080p
|
|
53
|
+
* captures have died from memory pressure — warn loudly, recommend ≤720p. */
|
|
54
|
+
const HEAVY_FRAMES_WARNING = 900; // 30 s @ 30 fps
|
|
46
55
|
|
|
47
56
|
async function runVideo(
|
|
48
57
|
format: VideoFormat,
|
|
@@ -112,8 +121,19 @@ async function runVideo(
|
|
|
112
121
|
const fps = clampInt(options.fps, 1, 60);
|
|
113
122
|
if (fps) args.push('--fps', String(fps));
|
|
114
123
|
|
|
115
|
-
const
|
|
124
|
+
const maxFrames = resolveMaxFrames(options);
|
|
125
|
+
const frames = resolveFrames(options, fps, maxFrames);
|
|
116
126
|
if (frames) args.push('--frames', String(frames));
|
|
127
|
+
// The shim clamps its comp-meta fallback to this same ceiling (and reports
|
|
128
|
+
// loudly when it has to), so raising the cap here raises it end-to-end.
|
|
129
|
+
args.push('--max-frames', String(maxFrames));
|
|
130
|
+
if ((frames ?? 0) > HEAVY_FRAMES_WARNING) {
|
|
131
|
+
console.error(
|
|
132
|
+
`⚠ long export: ${frames} frames. If the fast renderer falls back to ` +
|
|
133
|
+
`frame-step capture this runs ~2.5 s/frame; scale ≤2× (≈720–1080p) is ` +
|
|
134
|
+
'recommended for memory headroom.'
|
|
135
|
+
);
|
|
136
|
+
}
|
|
117
137
|
|
|
118
138
|
if (format === 'gif') {
|
|
119
139
|
const colors = clampInt(options.gifColors, 2, 256);
|
|
@@ -175,16 +195,35 @@ function clampInt(raw: unknown, lo: number, hi: number): number | undefined {
|
|
|
175
195
|
return Math.max(lo, Math.min(hi, n));
|
|
176
196
|
}
|
|
177
197
|
|
|
178
|
-
/**
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
198
|
+
/** The frame ceiling for this export: `options.maxFrames` (clamped to the
|
|
199
|
+
* absolute backstop) or the raised default. */
|
|
200
|
+
function resolveMaxFrames(options: ExportOptions): number {
|
|
201
|
+
return clampInt(options.maxFrames, 1, MAX_FRAMES_CEILING) ?? DEFAULT_MAX_FRAMES;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Resolve the frame count from options (frames | durationMs×fps). A request
|
|
205
|
+
* over the ceiling is REFUSED with remediation — never silently truncated. */
|
|
206
|
+
function resolveFrames(
|
|
207
|
+
options: ExportOptions,
|
|
208
|
+
fps: number | undefined,
|
|
209
|
+
maxFrames: number
|
|
210
|
+
): number | undefined {
|
|
211
|
+
const explicit = clampInt(options.frames, 1, MAX_FRAMES_CEILING);
|
|
182
212
|
const durationMs = Number(options.durationMs);
|
|
183
|
-
|
|
184
|
-
|
|
213
|
+
const fromDuration =
|
|
214
|
+
Number.isFinite(durationMs) && durationMs > 0 && fps
|
|
215
|
+
? Math.max(1, Math.round((durationMs / 1000) * fps))
|
|
216
|
+
: undefined;
|
|
217
|
+
const requested = explicit ?? fromDuration;
|
|
218
|
+
if (requested != null && requested > maxFrames) {
|
|
219
|
+
throw new Error(
|
|
220
|
+
`export of ${requested} frames exceeds the ${maxFrames}-frame cap — ` +
|
|
221
|
+
'pass options.maxFrames to raise it (long exports can take minutes and real memory)'
|
|
222
|
+
);
|
|
185
223
|
}
|
|
224
|
+
if (requested != null) return requested;
|
|
186
225
|
// No hint → the shim falls back to the in-page comp meta (durationInFrames),
|
|
187
|
-
//
|
|
226
|
+
// clamped to the --max-frames ceiling we pass it (loudly, never silently).
|
|
188
227
|
return undefined;
|
|
189
228
|
}
|
|
190
229
|
|
|
@@ -213,7 +213,7 @@ function isMaudeRuntimeState(p: string): boolean {
|
|
|
213
213
|
p
|
|
214
214
|
) ||
|
|
215
215
|
/(^|\/)_server\.(?:lock|log)$/.test(p) ||
|
|
216
|
-
/(^|\/)_(?:history|trash|draw|photo|smoke|canvas-state|state|chat|comments|untrusted|export-jobs)(?:\/|$)/.test(
|
|
216
|
+
/(^|\/)_(?:history|trash|draw|photo|smoke|reports|canvas-state|state|chat|comments|untrusted|export-jobs)(?:\/|$)/.test(
|
|
217
217
|
p
|
|
218
218
|
) ||
|
|
219
219
|
// kgai per-machine graph projection (feature-kgai-ecosystem-integration,
|
package/apps/studio/http.ts
CHANGED
|
@@ -24,6 +24,7 @@ import { ImportBrandError, importBrand } from './bin/_import-brand.mjs';
|
|
|
24
24
|
import { buildCanvasModule } from './canvas-build.ts';
|
|
25
25
|
import { canvasLibPath } from './canvas-lib-resolver.ts';
|
|
26
26
|
import { TranspileError } from './canvas-pipeline.ts';
|
|
27
|
+
import { createCloudEndpoints } from './cloud/endpoints.ts';
|
|
27
28
|
import type { AiActivity } from './collab/ai-activity.ts';
|
|
28
29
|
import type { Context } from './context.ts';
|
|
29
30
|
import { reloadConfig } from './context.ts';
|
|
@@ -78,7 +79,6 @@ import {
|
|
|
78
79
|
} from './generation/whisper-models.ts';
|
|
79
80
|
import { createGitEndpoints } from './git/endpoints.ts';
|
|
80
81
|
import { gitShowFile } from './git/service.ts';
|
|
81
|
-
import { createCloudEndpoints } from './cloud/endpoints.ts';
|
|
82
82
|
import { createGitHubEndpoints } from './github/endpoints.ts';
|
|
83
83
|
import type { Inspect } from './inspect.ts';
|
|
84
84
|
import { canvasSlug, writeLocator } from './locator.ts';
|
|
@@ -87,6 +87,7 @@ import { createPhotoStore, PHOTO_EDIT_MAX_BYTES } from './photo-store.ts';
|
|
|
87
87
|
import { probeReadiness } from './readiness.ts';
|
|
88
88
|
import { getRuntimeBundle, packageForSlug } from './runtime-bundle.ts';
|
|
89
89
|
import { linkHub } from './sync/hub-link.ts';
|
|
90
|
+
import { isHubReadOnly } from './sync/hubs-config.ts';
|
|
90
91
|
import { signInToWorkspace, workspaceDisclosure } from './sync/workspace-signin.ts';
|
|
91
92
|
import { readUiPrefs, type UiPrefs, writeUiPrefs } from './ui-prefs.ts';
|
|
92
93
|
import { loadWhatsNew, resolveMaudeVersion } from './whats-new.ts';
|
|
@@ -1163,7 +1164,17 @@ export function createHttp(
|
|
|
1163
1164
|
}
|
|
1164
1165
|
},
|
|
1165
1166
|
|
|
1166
|
-
|
|
1167
|
+
// `readOnly` is COMPUTED, not stored in cfg: it comes from the role the
|
|
1168
|
+
// workspace vouched for at sign-in (Cloud Phase 25 C2), so the client
|
|
1169
|
+
// knows before it draws anything. It decides what the UI OFFERS and is
|
|
1170
|
+
// never what stops a write — the cell enforces that (Phase 25 C1),
|
|
1171
|
+
// whatever a patched client believes.
|
|
1172
|
+
'/_config': () =>
|
|
1173
|
+
Response.json({
|
|
1174
|
+
...ctx.cfg,
|
|
1175
|
+
canvasOrigin: ctx.canvasOrigin,
|
|
1176
|
+
readOnly: ctx.cfg.linkedHub ? isHubReadOnly(ctx.cfg.linkedHub.url) : false,
|
|
1177
|
+
}),
|
|
1167
1178
|
|
|
1168
1179
|
// What's New feed (DDR-A) — read-only product-update list surfaced in the
|
|
1169
1180
|
// Maude UI chrome. Main-origin only by omission from the canvas-origin
|
|
@@ -1241,7 +1252,10 @@ export function createHttp(
|
|
|
1241
1252
|
const m = /^data:image\/(png|jpeg);base64,([A-Za-z0-9+/=]+)$/.exec(shot);
|
|
1242
1253
|
if (!m || m[2].length > 7_000_000) continue; // ~5 MB decoded cap
|
|
1243
1254
|
n += 1;
|
|
1244
|
-
await Bun.write(
|
|
1255
|
+
await Bun.write(
|
|
1256
|
+
join(dir, `screenshot-${n}.${m[1] === 'png' ? 'png' : 'jpg'}`),
|
|
1257
|
+
Buffer.from(m[2], 'base64')
|
|
1258
|
+
);
|
|
1245
1259
|
}
|
|
1246
1260
|
return Response.json(
|
|
1247
1261
|
{ dir: relative(ctx.paths.repoRoot, dir), screenshots: n },
|
|
@@ -1810,6 +1824,12 @@ export function createHttp(
|
|
|
1810
1824
|
// DELETE /_api/github/identity here; see DDR-114.)
|
|
1811
1825
|
'/_api/cloud/status': async (req: Request) => {
|
|
1812
1826
|
if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
|
|
1827
|
+
// isLoopbackHost is a DNS-rebind guard, not a CSRF guard: a cross-site
|
|
1828
|
+
// page can issue a no-cors GET at 127.0.0.1:<port>. These two GETs read
|
|
1829
|
+
// the cloud credential's identity and (for projects) make a Bearer call
|
|
1830
|
+
// whose 401 DELETES the stored credential — a confused deputy worth
|
|
1831
|
+
// closing (validate 2026-07-30, defender F6).
|
|
1832
|
+
if (!sameOriginRead(req)) return new Response('cross-origin rejected', { status: 403 });
|
|
1813
1833
|
if (!isLoopbackHost(req.headers.get('host')))
|
|
1814
1834
|
return new Response('local request required', { status: 403 });
|
|
1815
1835
|
return gitJson(cloudApi.status());
|
|
@@ -1841,6 +1861,7 @@ export function createHttp(
|
|
|
1841
1861
|
},
|
|
1842
1862
|
'/_api/cloud/projects': async (req: Request) => {
|
|
1843
1863
|
if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
|
|
1864
|
+
if (!sameOriginRead(req)) return new Response('cross-origin rejected', { status: 403 });
|
|
1844
1865
|
if (!isLoopbackHost(req.headers.get('host')))
|
|
1845
1866
|
return new Response('local request required', { status: 403 });
|
|
1846
1867
|
return gitJson(await cloudApi.projects());
|
|
@@ -1863,8 +1884,13 @@ export function createHttp(
|
|
|
1863
1884
|
return new Response('cross-origin write rejected', { status: 403 });
|
|
1864
1885
|
if (!isLoopbackHost(req.headers.get('host')))
|
|
1865
1886
|
return new Response('local request required', { status: 403 });
|
|
1866
|
-
const body = (await req.json().catch(() => ({}))) as { code?: string };
|
|
1867
|
-
return gitJson(
|
|
1887
|
+
const body = (await req.json().catch(() => ({}))) as { code?: string; project?: string };
|
|
1888
|
+
return gitJson(
|
|
1889
|
+
await cloudApi.attachCode(
|
|
1890
|
+
String(body.code ?? ''),
|
|
1891
|
+
typeof body.project === 'string' ? body.project : undefined
|
|
1892
|
+
)
|
|
1893
|
+
);
|
|
1868
1894
|
},
|
|
1869
1895
|
|
|
1870
1896
|
'/_api/github/identity': async (req: Request) => {
|
|
@@ -2186,6 +2212,53 @@ export function createHttp(
|
|
|
2186
2212
|
);
|
|
2187
2213
|
},
|
|
2188
2214
|
|
|
2215
|
+
'/_api/clip-edit': async (req: Request) => {
|
|
2216
|
+
// feature-enhanced-video-editing (Phase 2) — parametric clip verbs, one
|
|
2217
|
+
// dispatch route: POST { canvas, artboardId?, stableId, contentHash?,
|
|
2218
|
+
// verb: speed|trim-in|audio|detach-audio|framing|grade|transition, …verb
|
|
2219
|
+
// params }. Same MAIN-ORIGIN-ONLY trust boundary as the other source
|
|
2220
|
+
// writes (absent from CANVAS_SAFE_API + startCanvasServer routes,
|
|
2221
|
+
// DDR-054) — this is also the agent door for /design:edit's timeline
|
|
2222
|
+
// vocabulary.
|
|
2223
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
2224
|
+
if (!sameOriginWrite(req))
|
|
2225
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
2226
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
2227
|
+
return new Response('local request required (DNS-rebinding guard)', { status: 403 });
|
|
2228
|
+
const body = await readJson<{
|
|
2229
|
+
canvas?: unknown;
|
|
2230
|
+
artboardId?: unknown;
|
|
2231
|
+
stableId?: unknown;
|
|
2232
|
+
contentHash?: unknown;
|
|
2233
|
+
verb?: unknown;
|
|
2234
|
+
rate?: unknown;
|
|
2235
|
+
deltaFrames?: unknown;
|
|
2236
|
+
muted?: unknown;
|
|
2237
|
+
volume?: unknown;
|
|
2238
|
+
framing?: unknown;
|
|
2239
|
+
grade?: unknown;
|
|
2240
|
+
presentation?: unknown;
|
|
2241
|
+
durationInFrames?: unknown;
|
|
2242
|
+
atFrame?: unknown;
|
|
2243
|
+
src?: unknown;
|
|
2244
|
+
mediaKind?: unknown;
|
|
2245
|
+
text?: unknown;
|
|
2246
|
+
toIndex?: unknown;
|
|
2247
|
+
}>(req, 16 * 1024);
|
|
2248
|
+
if (!body) return new Response('body required', { status: 400 });
|
|
2249
|
+
const result = await api.clipEditOp(body);
|
|
2250
|
+
if (!result.ok) {
|
|
2251
|
+
return Response.json(
|
|
2252
|
+
{ ok: false, error: result.error },
|
|
2253
|
+
{ status: result.status, headers: { 'Cache-Control': 'no-store' } }
|
|
2254
|
+
);
|
|
2255
|
+
}
|
|
2256
|
+
return Response.json(
|
|
2257
|
+
{ ok: true, seq: result.seq, ...result.extra },
|
|
2258
|
+
{ status: 200, headers: { 'Cache-Control': 'no-store' } }
|
|
2259
|
+
);
|
|
2260
|
+
},
|
|
2261
|
+
|
|
2189
2262
|
'/_api/remove-sequence': async (req: Request) => {
|
|
2190
2263
|
// DDR-150 P3 — remove a clip. POST { canvas, stableId, artboardId?,
|
|
2191
2264
|
// contentHash? } → removeClip (fingerprint + semantic gate; refuses the
|
|
@@ -2234,7 +2307,10 @@ export function createHttp(
|
|
|
2234
2307
|
durationInFrames?: unknown;
|
|
2235
2308
|
mediaTag?: unknown;
|
|
2236
2309
|
src?: unknown;
|
|
2237
|
-
|
|
2310
|
+
lane?: unknown;
|
|
2311
|
+
index?: unknown;
|
|
2312
|
+
placeholder?: unknown;
|
|
2313
|
+
}>(req, 16 * 1024);
|
|
2238
2314
|
if (!body) return new Response('body required', { status: 400 });
|
|
2239
2315
|
const result = await api.insertSequenceOp(body);
|
|
2240
2316
|
if (!result.ok) {
|
|
@@ -2269,6 +2345,7 @@ export function createHttp(
|
|
|
2269
2345
|
refStableId?: unknown;
|
|
2270
2346
|
refContentHash?: unknown;
|
|
2271
2347
|
position?: unknown;
|
|
2348
|
+
mode?: unknown;
|
|
2272
2349
|
}>(req, 8 * 1024);
|
|
2273
2350
|
if (!body) return new Response('body required', { status: 400 });
|
|
2274
2351
|
const result = await api.reorderSequenceOp(body);
|
|
@@ -3750,6 +3827,36 @@ export function createHttp(
|
|
|
3750
3827
|
);
|
|
3751
3828
|
},
|
|
3752
3829
|
|
|
3830
|
+
'/_api/timeline-media': async (req: Request) => {
|
|
3831
|
+
// Task 7 (enhanced-video-editing) — filmstrip/waveform cache. Runtime
|
|
3832
|
+
// state under `_canvas-state/timeline-media/` (DDR-115). MAIN-ORIGIN only
|
|
3833
|
+
// (shell UI cache — not in CANVAS_SAFE_API / startCanvasServer routes).
|
|
3834
|
+
// DNS-rebinding guard on BOTH methods (security review 2026-07-30): under
|
|
3835
|
+
// rebinding sameOriginWrite passes (page origin == req origin == attacker
|
|
3836
|
+
// host), so a remote page could otherwise READ the cache (filmstrip JPEG
|
|
3837
|
+
// dataURLs of local footage → disclosure) or WRITE arbitrary JSON later
|
|
3838
|
+
// rendered as <img src> in the trusted shell. The loopback-host check is
|
|
3839
|
+
// the same backstop every sibling privileged route carries.
|
|
3840
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
3841
|
+
return new Response('local request required (DNS-rebinding guard)', { status: 403 });
|
|
3842
|
+
const url = new URL(req.url);
|
|
3843
|
+
const key = url.searchParams.get('key') ?? '';
|
|
3844
|
+
if (req.method === 'GET') {
|
|
3845
|
+
const data = await api.timelineMediaLoad(key);
|
|
3846
|
+
if (!data) return Response.json({ ok: false }, { status: 404 });
|
|
3847
|
+
return Response.json({ ok: true, data }, { headers: { 'Cache-Control': 'no-store' } });
|
|
3848
|
+
}
|
|
3849
|
+
if (req.method === 'PUT' || req.method === 'POST') {
|
|
3850
|
+
if (!sameOriginWrite(req))
|
|
3851
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
3852
|
+
const body = await readJson<Record<string, unknown>>(req, 8 * 1024 * 1024);
|
|
3853
|
+
if (!body) return new Response('body required', { status: 400 });
|
|
3854
|
+
const ok = await api.timelineMediaSave(key, body);
|
|
3855
|
+
return Response.json({ ok }, { status: ok ? 200 : 400 });
|
|
3856
|
+
}
|
|
3857
|
+
return new Response('Method not allowed', { status: 405 });
|
|
3858
|
+
},
|
|
3859
|
+
|
|
3753
3860
|
'/_canvas-state': async (req: Request) => {
|
|
3754
3861
|
const url = new URL(req.url);
|
|
3755
3862
|
if (req.method === 'GET') {
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
// ripple.ts — pure ripple engine (feature-enhanced-video-editing, Phase 0).
|
|
2
|
+
//
|
|
3
|
+
// When a clip's length changes (trim, speed, split, insert, remove), everything
|
|
4
|
+
// AFTER the edit point must shift so the cut stays gapless — the "magnetic"
|
|
5
|
+
// contract. Series clips (`TransitionSeries.Sequence` / `Series.Sequence`)
|
|
6
|
+
// need no `from` shift (Remotion computes their offsets), but the comp TOTAL
|
|
7
|
+
// still moves; standalone `<Sequence from={…}>` clips shift their `from`.
|
|
8
|
+
//
|
|
9
|
+
// The engine is PURE (source in → source out) and deliberately conservative:
|
|
10
|
+
// • literal ints rewrite in place (`from={90}` → `from={102}`);
|
|
11
|
+
// • additive const-arithmetic rewrites by merging the trailing integer term
|
|
12
|
+
// (`from={A + B - 20}` +12 → `from={A + B - 8}`) or appending one
|
|
13
|
+
// (`from={A}` +12 → `from={A + 12}`) — the symbolic form survives;
|
|
14
|
+
// • an expression it can't RESOLVE (function call, ternary, unknown ident)
|
|
15
|
+
// is refused loudly with a structured RippleError naming the clip — the UI
|
|
16
|
+
// renders that as guidance, never a silent mis-shift.
|
|
17
|
+
//
|
|
18
|
+
// Double-shift guard: the comp TOTAL is bumped FIRST (const-preferring, so a
|
|
19
|
+
// shared `const TOTAL = A + B` updates every user consistently). Each
|
|
20
|
+
// downstream `from` expression is then evaluated under the ORIGINAL and the
|
|
21
|
+
// POST-TOTAL const environments — an expression that already moved by the
|
|
22
|
+
// delta (e.g. `from={TOTAL - 60}` on an end card) is SKIPPED, one that didn't
|
|
23
|
+
// move gets the textual shift, and one that moved by anything else (partial
|
|
24
|
+
// coupling) is refused rather than guessed at.
|
|
25
|
+
|
|
26
|
+
import MagicString from 'magic-string';
|
|
27
|
+
import { parseSync } from 'oxc-parser';
|
|
28
|
+
|
|
29
|
+
import { assertCompSemantics, CanvasEditError, enumerateClips } from './canvas-edit.ts';
|
|
30
|
+
|
|
31
|
+
/** Structured refusal — a subclass so the api layer's `instanceof
|
|
32
|
+
* CanvasEditError → 422` mapping (and its error message plumbing) applies. */
|
|
33
|
+
export class RippleError extends CanvasEditError {
|
|
34
|
+
/** The unrewritable expression, when that's what was refused. */
|
|
35
|
+
readonly expr: string | null;
|
|
36
|
+
constructor(message: string, info: { canvas: string; id: string; expr?: string | null }) {
|
|
37
|
+
super(message, info);
|
|
38
|
+
this.name = 'RippleError';
|
|
39
|
+
this.expr = info.expr ?? null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface RippleResult {
|
|
44
|
+
source: string;
|
|
45
|
+
/** stableIds of clips whose `from` was textually shifted. */
|
|
46
|
+
shifted: string[];
|
|
47
|
+
/** True when the comp duration (attr or its const) was rewritten. */
|
|
48
|
+
totalEdited: boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface RippleOptions {
|
|
52
|
+
/** Shift downstream standalone `from` attrs (default true). */
|
|
53
|
+
shiftFroms?: boolean;
|
|
54
|
+
/** Bump the comp's durationInFrames / its const (default true). */
|
|
55
|
+
bumpTotal?: boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// Const resolution (mirrors timeline-parse.js — literal pass, then up to 3
|
|
60
|
+
// expression passes so `const TOTAL = A + B - XF` resolves).
|
|
61
|
+
|
|
62
|
+
const IDENT_RE = /^[A-Za-z_$][\w$]*$/;
|
|
63
|
+
const SAFE_EXPR_RE = /^[A-Za-z0-9_$+\-*/()\s.]+$/;
|
|
64
|
+
|
|
65
|
+
export function collectConsts(source: string): Record<string, number> {
|
|
66
|
+
const consts: Record<string, number> = {};
|
|
67
|
+
for (const m of source.matchAll(/\bconst\s+([A-Za-z_$][\w$]*)\s*=\s*(-?\d+)\s*;/g)) {
|
|
68
|
+
consts[m[1] as string] = Number(m[2]);
|
|
69
|
+
}
|
|
70
|
+
for (let pass = 0; pass < 3; pass += 1) {
|
|
71
|
+
for (const m of source.matchAll(/\bconst\s+([A-Za-z_$][\w$]*)\s*=\s*([^;\n]+);/g)) {
|
|
72
|
+
const name = m[1] as string;
|
|
73
|
+
if (Object.hasOwn(consts, name)) continue;
|
|
74
|
+
const v = resolveNum(m[2] as string, consts);
|
|
75
|
+
if (v != null) consts[name] = v;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return consts;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Resolve an expression to an integer: a literal, a known const, or simple
|
|
82
|
+
* arithmetic of them. `null` when not resolvable. */
|
|
83
|
+
export function resolveNum(raw: string, consts: Record<string, number>): number | null {
|
|
84
|
+
const t = String(raw ?? '').trim();
|
|
85
|
+
if (!t) return null;
|
|
86
|
+
if (/^-?\d+$/.test(t)) return Number(t);
|
|
87
|
+
if (IDENT_RE.test(t)) return Object.hasOwn(consts, t) ? (consts[t] as number) : null;
|
|
88
|
+
if (!SAFE_EXPR_RE.test(t)) return null;
|
|
89
|
+
const substituted = t.replace(/[A-Za-z_$][\w$]*/g, (id) =>
|
|
90
|
+
Object.hasOwn(consts, id) ? String(consts[id]) : id
|
|
91
|
+
);
|
|
92
|
+
if (!/^[-+*/()\d\s.]+$/.test(substituted) || !/\d/.test(substituted)) return null;
|
|
93
|
+
try {
|
|
94
|
+
// eslint-disable-next-line no-new-func
|
|
95
|
+
const v = Function(`"use strict";return (${substituted});`)() as unknown;
|
|
96
|
+
return typeof v === 'number' && Number.isFinite(v) ? Math.round(v) : null;
|
|
97
|
+
} catch {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Shift an additive-friendly expression by `delta`, preserving its symbolic
|
|
104
|
+
* form: a literal rewrites in place; a trailing integer term merges
|
|
105
|
+
* (`A + B - 20` +12 → `A + B - 8`); anything else valid gets ` + delta`
|
|
106
|
+
* appended. Returns null for an expression outside the safe charset — the
|
|
107
|
+
* caller refuses those (resolvability is checked separately).
|
|
108
|
+
*/
|
|
109
|
+
export function shiftExpression(expr: string, delta: number): string | null {
|
|
110
|
+
const t = String(expr ?? '').trim();
|
|
111
|
+
if (!t || !SAFE_EXPR_RE.test(t)) return null;
|
|
112
|
+
if (delta === 0) return t;
|
|
113
|
+
if (/^-?\d+$/.test(t)) return String(Number(t) + delta);
|
|
114
|
+
const trailing = t.match(/^(.*?)([+-])\s*(\d+)\s*$/);
|
|
115
|
+
if (trailing) {
|
|
116
|
+
const base = (trailing[1] as string).trim();
|
|
117
|
+
// Only merge when the trailing int is a TERM of a top-level sum — a base
|
|
118
|
+
// ending in an operator or '(' means the int is an operand of * / ( … ),
|
|
119
|
+
// where merging would change the math. Fall through to append instead.
|
|
120
|
+
if (base && !/[+\-*/(]$/.test(base)) {
|
|
121
|
+
const cur = (trailing[2] === '-' ? -1 : 1) * Number(trailing[3]);
|
|
122
|
+
const next = cur + delta;
|
|
123
|
+
if (next === 0) return base;
|
|
124
|
+
return `${base} ${next < 0 ? '-' : '+'} ${Math.abs(next)}`;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return delta < 0 ? `${t} - ${-delta}` : `${t} + ${delta}`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
// Attr helpers — regex over ONE opening tag's text (the retimeAttr house style).
|
|
132
|
+
|
|
133
|
+
export interface AttrSpan {
|
|
134
|
+
/** Absolute span of the inner expression (between the braces). */
|
|
135
|
+
start: number;
|
|
136
|
+
end: number;
|
|
137
|
+
inner: string;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Locate `name={…}` inside the opening tag that starts at `tagStart`. */
|
|
141
|
+
export function attrExprSpan(source: string, tagStart: number, name: string): AttrSpan | null {
|
|
142
|
+
const gt = source.indexOf('>', tagStart);
|
|
143
|
+
const tag = source.slice(tagStart, gt < 0 ? source.length : gt + 1);
|
|
144
|
+
const m = tag.match(new RegExp(`\\b${name}=\\{\\s*([^}]*?)\\s*\\}`));
|
|
145
|
+
if (!m || m.index == null) return null;
|
|
146
|
+
const braceRel = m[0].indexOf('{');
|
|
147
|
+
const innerRel = m[0].indexOf(m[1] as string, braceRel);
|
|
148
|
+
// Empty inner (`from={}` is invalid JSX anyway) — treat as absent.
|
|
149
|
+
if ((m[1] as string) === '') return null;
|
|
150
|
+
const start = tagStart + m.index + innerRel;
|
|
151
|
+
return { start, end: start + (m[1] as string).length, inner: m[1] as string };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
// TOTAL rewrite (const-preferring, mirrors retimeAttr's preference order).
|
|
156
|
+
|
|
157
|
+
function findVideoCompTagStart(source: string, compName: string): number | null {
|
|
158
|
+
const re = /<VideoComp\b[^>]*>/g;
|
|
159
|
+
let m: RegExpExecArray | null = re.exec(source);
|
|
160
|
+
while (m) {
|
|
161
|
+
if (new RegExp(`component=\\{\\s*${compName}\\s*\\}`).test(m[0])) return m.index;
|
|
162
|
+
m = re.exec(source);
|
|
163
|
+
}
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Plan the TOTAL bump on a scratch copy; returns the edited source or null
|
|
168
|
+
* when there is nothing to bump (no comp usage / no durationInFrames attr). */
|
|
169
|
+
function bumpTotal(
|
|
170
|
+
canvasAbsPath: string,
|
|
171
|
+
source: string,
|
|
172
|
+
compName: string,
|
|
173
|
+
delta: number
|
|
174
|
+
): string | null {
|
|
175
|
+
const tagStart = findVideoCompTagStart(source, compName);
|
|
176
|
+
if (tagStart == null) return null;
|
|
177
|
+
const attr = attrExprSpan(source, tagStart, 'durationInFrames');
|
|
178
|
+
if (!attr) return null;
|
|
179
|
+
const s = new MagicString(source);
|
|
180
|
+
const inner = attr.inner.trim();
|
|
181
|
+
if (IDENT_RE.test(inner)) {
|
|
182
|
+
// durationInFrames={TOTAL} — rewrite the const so every other user of it
|
|
183
|
+
// (end-card `from={TOTAL - 60}`, interpolate windows) follows consistently.
|
|
184
|
+
const cm = source.match(new RegExp(`\\bconst\\s+${inner}\\s*=\\s*([^;\\n]+);`));
|
|
185
|
+
if (!cm || cm.index == null) {
|
|
186
|
+
throw new RippleError(
|
|
187
|
+
`comp duration is {${inner}} but no \`const ${inner} = …\` was found to rewrite`,
|
|
188
|
+
{ canvas: canvasAbsPath, id: compName, expr: inner }
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
const init = (cm[1] as string).trim();
|
|
192
|
+
const initStart = cm.index + (cm[0] as string).indexOf(cm[1] as string);
|
|
193
|
+
const consts = collectConsts(source);
|
|
194
|
+
if (resolveNum(init, consts) == null) {
|
|
195
|
+
throw new RippleError(
|
|
196
|
+
`cannot ripple the comp duration: \`const ${inner} = ${init}\` is not simple arithmetic — adjust it by hand`,
|
|
197
|
+
{ canvas: canvasAbsPath, id: compName, expr: init }
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
const next = shiftExpression(init, delta);
|
|
201
|
+
if (next == null) {
|
|
202
|
+
throw new RippleError(`cannot ripple the comp duration expression \`${init}\``, {
|
|
203
|
+
canvas: canvasAbsPath,
|
|
204
|
+
id: compName,
|
|
205
|
+
expr: init,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
s.overwrite(initStart, initStart + (cm[1] as string).length, next);
|
|
209
|
+
return s.toString();
|
|
210
|
+
}
|
|
211
|
+
const consts = collectConsts(source);
|
|
212
|
+
if (resolveNum(inner, consts) == null) {
|
|
213
|
+
throw new RippleError(
|
|
214
|
+
`cannot ripple the comp duration: \`durationInFrames={${inner}}\` is not simple arithmetic`,
|
|
215
|
+
{ canvas: canvasAbsPath, id: compName, expr: inner }
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
const next = shiftExpression(inner, delta);
|
|
219
|
+
if (next == null) {
|
|
220
|
+
throw new RippleError(`cannot ripple the comp duration expression \`${inner}\``, {
|
|
221
|
+
canvas: canvasAbsPath,
|
|
222
|
+
id: compName,
|
|
223
|
+
expr: inner,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
s.overwrite(attr.start, attr.end, next);
|
|
227
|
+
return s.toString();
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ---------------------------------------------------------------------------
|
|
231
|
+
// The engine.
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Ripple everything after `afterStableId` by `deltaFrames` (positive = the
|
|
235
|
+
* clip grew / one was inserted; negative = it shrank / was removed). Pure —
|
|
236
|
+
* returns the new source; the caller owns disk writes and locking. Throws
|
|
237
|
+
* `RippleError` (a `CanvasEditError`) with a user-renderable message when an
|
|
238
|
+
* expression can't be rewritten safely.
|
|
239
|
+
*/
|
|
240
|
+
export function applyRippleAfterClip(
|
|
241
|
+
canvasAbsPath: string,
|
|
242
|
+
source: string,
|
|
243
|
+
artboardId: string | undefined,
|
|
244
|
+
afterStableId: string,
|
|
245
|
+
deltaFrames: number,
|
|
246
|
+
options: RippleOptions = {}
|
|
247
|
+
): RippleResult {
|
|
248
|
+
const shiftFroms = options.shiftFroms !== false;
|
|
249
|
+
const doTotal = options.bumpTotal !== false;
|
|
250
|
+
const delta = Math.round(deltaFrames);
|
|
251
|
+
if (delta === 0) return { source, shifted: [], totalEdited: false };
|
|
252
|
+
|
|
253
|
+
const before = enumerateClips(canvasAbsPath, source, artboardId);
|
|
254
|
+
const targetIdx = before.clips.findIndex((c) => c.stableId === afterStableId);
|
|
255
|
+
if (targetIdx < 0) {
|
|
256
|
+
throw new CanvasEditError(`clip "${afterStableId}" not found`, {
|
|
257
|
+
canvas: canvasAbsPath,
|
|
258
|
+
id: afterStableId,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
const compName = before.compName;
|
|
262
|
+
|
|
263
|
+
const consts0 = collectConsts(source);
|
|
264
|
+
|
|
265
|
+
// Stage 1 — TOTAL (const-preferring, so shared users follow consistently).
|
|
266
|
+
let mid = source;
|
|
267
|
+
let totalEdited = false;
|
|
268
|
+
if (doTotal && compName) {
|
|
269
|
+
const bumped = bumpTotal(canvasAbsPath, mid, compName, delta);
|
|
270
|
+
if (bumped != null) {
|
|
271
|
+
mid = bumped;
|
|
272
|
+
totalEdited = true;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
const consts1 = collectConsts(mid);
|
|
276
|
+
|
|
277
|
+
// Stage 2 — downstream standalone `from` shifts (fresh spans post-Stage-1).
|
|
278
|
+
const shifted: string[] = [];
|
|
279
|
+
if (shiftFroms) {
|
|
280
|
+
const after = enumerateClips(canvasAbsPath, mid, artboardId);
|
|
281
|
+
const idx = after.clips.findIndex((c) => c.stableId === afterStableId);
|
|
282
|
+
const downstream = after.clips.slice(idx + 1).filter((c) => c.kind === 'sequence');
|
|
283
|
+
const s = new MagicString(mid);
|
|
284
|
+
let touched = false;
|
|
285
|
+
for (const clip of downstream) {
|
|
286
|
+
if (clip.tag !== 'Sequence') continue; // series clips position themselves
|
|
287
|
+
const attr = attrExprSpan(mid, clip.start, 'from');
|
|
288
|
+
if (!attr) continue; // cursor-implicit — follows the flow by construction
|
|
289
|
+
const v0 = resolveNum(attr.inner, consts0);
|
|
290
|
+
const v1 = resolveNum(attr.inner, consts1);
|
|
291
|
+
if (v0 == null || v1 == null) {
|
|
292
|
+
throw new RippleError(
|
|
293
|
+
`cannot ripple "${clip.stableId}": \`from={${attr.inner.trim()}}\` is not simple arithmetic — move it by hand, then retry`,
|
|
294
|
+
{ canvas: canvasAbsPath, id: clip.stableId, expr: attr.inner.trim() }
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
if (v1 - v0 === delta) continue; // already follows the bumped const
|
|
298
|
+
if (v1 !== v0) {
|
|
299
|
+
throw new RippleError(
|
|
300
|
+
`cannot ripple "${clip.stableId}": \`from={${attr.inner.trim()}}\` is partially coupled to the comp duration — adjust it by hand`,
|
|
301
|
+
{ canvas: canvasAbsPath, id: clip.stableId, expr: attr.inner.trim() }
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
if (v0 + delta < 0) {
|
|
305
|
+
throw new RippleError(
|
|
306
|
+
`cannot ripple "${clip.stableId}": it would start at frame ${v0 + delta} (before 0)`,
|
|
307
|
+
{ canvas: canvasAbsPath, id: clip.stableId }
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
const next = shiftExpression(attr.inner, delta);
|
|
311
|
+
if (next == null) {
|
|
312
|
+
throw new RippleError(
|
|
313
|
+
`cannot ripple "${clip.stableId}": \`from={${attr.inner.trim()}}\` can't be rewritten`,
|
|
314
|
+
{ canvas: canvasAbsPath, id: clip.stableId, expr: attr.inner.trim() }
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
s.overwrite(attr.start, attr.end, next);
|
|
318
|
+
shifted.push(clip.stableId);
|
|
319
|
+
touched = true;
|
|
320
|
+
}
|
|
321
|
+
if (touched) mid = s.toString();
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (mid === source) return { source, shifted, totalEdited };
|
|
325
|
+
|
|
326
|
+
const parsed = parseSync(canvasAbsPath, mid, { sourceType: 'module' });
|
|
327
|
+
if (parsed.errors && parsed.errors.length > 0) {
|
|
328
|
+
throw new CanvasEditError(
|
|
329
|
+
`ripple produced invalid source: ${parsed.errors[0]?.message ?? 'parse error'}`,
|
|
330
|
+
{ canvas: canvasAbsPath, id: afterStableId }
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
assertCompSemantics(canvasAbsPath, mid);
|
|
334
|
+
return { source: mid, shifted, totalEdited };
|
|
335
|
+
}
|
package/apps/studio/server.ts
CHANGED
|
@@ -26,6 +26,7 @@ import { type AiActivityEntry, createAiActivity } from './collab/ai-activity.ts'
|
|
|
26
26
|
import { createGitLifecycle } from './collab/git-lifecycle.ts';
|
|
27
27
|
import { createCollab } from './collab/index.ts';
|
|
28
28
|
import { createContext, reloadConfig } from './context.ts';
|
|
29
|
+
import { installLogRing } from './debug-bundle.ts';
|
|
29
30
|
import { createExportJobQueue } from './exporters/jobs.ts';
|
|
30
31
|
import { createFsWatch } from './fs-watch.ts';
|
|
31
32
|
import { createGenerationJobQueue } from './generation/jobs.ts';
|
|
@@ -42,6 +43,12 @@ import {
|
|
|
42
43
|
} from './workspace-mode.ts';
|
|
43
44
|
import { createWs, isLoopbackHost, isSameOriginWs, parseCollabSlug, type WsData } from './ws.ts';
|
|
44
45
|
|
|
46
|
+
// feature-bug-report-button — mirror console output into the in-memory log
|
|
47
|
+
// ring BEFORE anything else logs, so a bug report's serverLogTail covers the
|
|
48
|
+
// boot sequence too (where "wrong project root" class failures announce
|
|
49
|
+
// themselves). Stdout/stderr behavior is unchanged.
|
|
50
|
+
installLogRing();
|
|
51
|
+
|
|
45
52
|
// Phase 19 / DDR-044 — covers the marketplace-cache-install gap where
|
|
46
53
|
// node_modules/ ships empty (git clone honors .gitignore). Auto-installs +
|
|
47
54
|
// builds on first boot; opt out with MAUDE_NO_AUTOBUILD=1.
|
|
@@ -108,8 +108,8 @@ async function probeHealth(url: string): Promise<{ ok: boolean; version?: string
|
|
|
108
108
|
}
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
-
/** Upsert the token under `normUrl` + record per-machine trust; mode 0600. */
|
|
112
|
-
export function saveHubCredential(normUrl: string, token: string): void {
|
|
111
|
+
/** Upsert the token (+ the vouched role) under `normUrl` + record per-machine trust; mode 0600. */
|
|
112
|
+
export function saveHubCredential(normUrl: string, token: string, role?: string): void {
|
|
113
113
|
const path = hubsConfigPath();
|
|
114
114
|
const dir = dirname(path);
|
|
115
115
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
@@ -123,7 +123,7 @@ export function saveHubCredential(normUrl: string, token: string): void {
|
|
|
123
123
|
/* malformed → start fresh rather than throw */
|
|
124
124
|
}
|
|
125
125
|
}
|
|
126
|
-
cfg.hubs[normUrl] = { token, linkedAt: Date.now() };
|
|
126
|
+
cfg.hubs[normUrl] = { token, linkedAt: Date.now(), ...(role ? { role } : {}) };
|
|
127
127
|
if (!Array.isArray(cfg.trusted)) cfg.trusted = [];
|
|
128
128
|
if (!cfg.trusted.includes(normUrl)) cfg.trusted.push(normUrl);
|
|
129
129
|
writeFileSync(path, `${JSON.stringify(cfg, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|