@1agh/maude 0.45.1 → 0.46.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/acp/bridge.ts +628 -74
- package/apps/studio/acp/index.ts +172 -23
- package/apps/studio/acp/probe.ts +31 -7
- package/apps/studio/acp/transcript.ts +91 -5
- package/apps/studio/annotations-snap.ts +50 -10
- package/apps/studio/api.ts +344 -14
- package/apps/studio/artboard-guides-overlay.tsx +270 -0
- package/apps/studio/bin/_import-asset.mjs +35 -5
- package/apps/studio/bin/_pdf-playwright.mjs +35 -4
- package/apps/studio/bin/_png-playwright.mjs +38 -4
- package/apps/studio/bin/_pw-launch.mjs +52 -0
- package/apps/studio/bin/_pw-launch.test.mjs +90 -0
- package/apps/studio/bin/_smart-frames.mjs +419 -0
- package/apps/studio/bin/_smart-frames.test.mjs +140 -0
- package/apps/studio/bin/smart-frames.sh +30 -0
- package/apps/studio/canvas-edit.ts +302 -3
- package/apps/studio/canvas-lib.tsx +203 -11
- package/apps/studio/canvas-shell.tsx +167 -28
- package/apps/studio/client/app.jsx +678 -176
- package/apps/studio/client/panels/CapabilityBar.jsx +101 -0
- package/apps/studio/client/panels/ChatPanel.jsx +846 -133
- package/apps/studio/client/panels/ElicitationPrompt.jsx +429 -0
- package/apps/studio/client/panels/PermissionPrompt.jsx +119 -0
- package/apps/studio/client/panels/ReadinessList.jsx +17 -3
- package/apps/studio/client/panels/SettingsPanel.jsx +211 -0
- package/apps/studio/client/panels/ToolGroup.jsx +79 -0
- package/apps/studio/client/panels/acp-capabilities.js +82 -0
- package/apps/studio/client/panels/acp-elicitation.js +194 -0
- package/apps/studio/client/panels/acp-runtime.js +248 -9
- package/apps/studio/client/panels/acp-usage.js +65 -0
- package/apps/studio/client/panels/transcript-view.js +36 -0
- package/apps/studio/client/styles/6-acp-chat.css +689 -11
- package/apps/studio/dist/client.bundle.js +1738 -1736
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/dom-selection.ts +20 -0
- package/apps/studio/export-dialog.tsx +138 -18
- package/apps/studio/exporters/pdf.ts +332 -18
- package/apps/studio/exporters/png.ts +45 -3
- package/apps/studio/footage/schema.ts +17 -1
- package/apps/studio/generation/gemma-models.ts +224 -0
- package/apps/studio/generation/prefs.ts +43 -0
- package/apps/studio/generation/whisper-models.test.ts +28 -1
- package/apps/studio/generation/whisper-models.ts +22 -7
- package/apps/studio/http.ts +246 -9
- package/apps/studio/print/marks.ts +113 -0
- package/apps/studio/print/units.ts +269 -0
- package/apps/studio/print-overlay-content.tsx +132 -0
- package/apps/studio/test/acp-bridge.test.ts +11 -6
- package/apps/studio/test/acp-capabilities.test.ts +123 -0
- package/apps/studio/test/acp-caps-bridge.test.ts +274 -0
- package/apps/studio/test/acp-elicitation-bridge.test.ts +475 -0
- package/apps/studio/test/acp-elicitation.test.ts +251 -0
- package/apps/studio/test/acp-mode-banner.test.ts +45 -0
- package/apps/studio/test/acp-permission-prompt.test.ts +77 -0
- package/apps/studio/test/acp-permission.test.ts +262 -0
- package/apps/studio/test/acp-session-allowed-tools.test.ts +91 -0
- package/apps/studio/test/acp-toolgroup.test.ts +76 -0
- package/apps/studio/test/acp-transcript-view.test.ts +71 -0
- package/apps/studio/test/acp-transcript.test.ts +75 -2
- package/apps/studio/test/acp-usage-bridge.test.ts +136 -0
- package/apps/studio/test/acp-usage.test.ts +143 -0
- package/apps/studio/test/annotations-snap.test.ts +56 -0
- package/apps/studio/test/artboard-guides-overlay.test.tsx +152 -0
- package/apps/studio/test/artboard-kinds.test.tsx +83 -0
- package/apps/studio/test/artboard-selection-attrs.test.ts +69 -0
- package/apps/studio/test/canvas-meta-api.test.ts +167 -0
- package/apps/studio/test/element-structural-edit.test.ts +250 -0
- package/apps/studio/test/exporters/png.test.ts +49 -1
- package/apps/studio/test/fixtures/mock-acp-agent-caps.mjs +158 -0
- package/apps/studio/test/fixtures/mock-acp-agent-elicit-flood.mjs +46 -0
- package/apps/studio/test/fixtures/mock-acp-agent-elicit-url.mjs +42 -0
- package/apps/studio/test/fixtures/mock-acp-agent-elicit.mjs +63 -0
- package/apps/studio/test/fixtures/mock-acp-agent-permission-flood.mjs +51 -0
- package/apps/studio/test/fixtures/mock-acp-agent-permission.mjs +50 -0
- package/apps/studio/test/fixtures/mock-acp-agent-usage.mjs +56 -0
- package/apps/studio/test/import-asset.test.ts +31 -3
- package/apps/studio/test/pdf-print-boxes.test.ts +272 -0
- package/apps/studio/test/print-marks.test.ts +113 -0
- package/apps/studio/test/print-units.test.ts +173 -0
- package/apps/studio/test/use-snap-guides.test.ts +81 -0
- package/apps/studio/use-chrome-visibility.tsx +19 -0
- package/apps/studio/use-element-resize.tsx +21 -2
- package/apps/studio/use-snap-guides.tsx +73 -5
- package/apps/studio/use-spacing-handles.tsx +9 -5
- package/apps/studio/whats-new.json +62 -0
- package/cli/commands/design.mjs +116 -3
- package/cli/lib/pkg-root.mjs +42 -10
- package/cli/lib/pkg-root.test.mjs +33 -1
- package/package.json +11 -9
- package/plugins/design/dependencies.json +35 -0
package/apps/studio/http.ts
CHANGED
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
startSignin,
|
|
18
18
|
} from './acp/login-state.ts';
|
|
19
19
|
import { probeAcpAvailabilityAuthed } from './acp/probe.ts';
|
|
20
|
-
import { deleteChat, listChats, readChatMessages } from './acp/transcript.ts';
|
|
20
|
+
import { deleteChat, listChats, readChatMessages, writeChatMeta } from './acp/transcript.ts';
|
|
21
21
|
import { type Api, ASSET_MAX_BYTES, ASSET_MAX_VIDEO_BYTES } from './api.ts';
|
|
22
22
|
import { ImportAssetError, importSvg, SVG_MAX_BYTES } from './bin/_import-asset.mjs';
|
|
23
23
|
import { ImportBrandError, importBrand } from './bin/_import-brand.mjs';
|
|
@@ -40,6 +40,12 @@ import {
|
|
|
40
40
|
sanitizeReuseText,
|
|
41
41
|
} from './generation/audio-library.ts';
|
|
42
42
|
import { localizeGenAsset } from './generation/download.ts';
|
|
43
|
+
import {
|
|
44
|
+
downloadGemmaModel,
|
|
45
|
+
ffmpegAvailable,
|
|
46
|
+
listGemmaModels,
|
|
47
|
+
mlxVlmAvailable,
|
|
48
|
+
} from './generation/gemma-models.ts';
|
|
43
49
|
import { type GenerationJobQueue, GenerationQueueFullError } from './generation/jobs.ts';
|
|
44
50
|
import {
|
|
45
51
|
configuredProviders,
|
|
@@ -49,8 +55,11 @@ import {
|
|
|
49
55
|
setProviderKey,
|
|
50
56
|
} from './generation/keys.ts';
|
|
51
57
|
import {
|
|
58
|
+
isKeyframeEngine,
|
|
52
59
|
isTranscriptionProvider,
|
|
60
|
+
readKeyframeEngine,
|
|
53
61
|
readTranscriptionProvider,
|
|
62
|
+
writeKeyframeEngine,
|
|
54
63
|
writeTranscriptionProvider,
|
|
55
64
|
} from './generation/prefs.ts';
|
|
56
65
|
import {
|
|
@@ -691,6 +700,12 @@ export function createHttp(
|
|
|
691
700
|
error?: string;
|
|
692
701
|
} | null = null;
|
|
693
702
|
|
|
703
|
+
// feature-scene-aware-keyframes — in-flight Gemma-model download state, polled by
|
|
704
|
+
// the Settings "Scene-aware keyframes" card via GET /_api/generate/keyframe-model.
|
|
705
|
+
// Closure-scoped: one server, one download.
|
|
706
|
+
let keyframeDownload: { id: string; received: number; total: number; error?: string } | null =
|
|
707
|
+
null;
|
|
708
|
+
|
|
694
709
|
// Cache invalidation — when canvas-lib changes, every cached canvas bundle
|
|
695
710
|
// is stale because canvas-lib is inlined into each one via the resolver
|
|
696
711
|
// plugin. Drop the whole cache so the next request rebuilds with the fresh
|
|
@@ -1017,7 +1032,7 @@ export function createHttp(
|
|
|
1017
1032
|
Response.json(listChats(ctx.paths.designRoot), {
|
|
1018
1033
|
headers: { 'Cache-Control': 'no-store' },
|
|
1019
1034
|
}),
|
|
1020
|
-
'/_api/acp/chat': (req: Request) => {
|
|
1035
|
+
'/_api/acp/chat': async (req: Request) => {
|
|
1021
1036
|
const id = (new URL(req.url).searchParams.get('id') ?? '')
|
|
1022
1037
|
.replace(/[^a-z0-9_-]/gi, '')
|
|
1023
1038
|
.slice(0, 64);
|
|
@@ -1025,6 +1040,37 @@ export function createHttp(
|
|
|
1025
1040
|
const removed = id ? deleteChat(ctx.paths.designRoot, id) : false;
|
|
1026
1041
|
return Response.json({ ok: removed }, { headers: { 'Cache-Control': 'no-store' } });
|
|
1027
1042
|
}
|
|
1043
|
+
// Task C5 — Rename / Archive from the chat switcher's overflow menu.
|
|
1044
|
+
// Same CSRF + loopback gate as every other privileged write route
|
|
1045
|
+
// (DDR-088); id is already sanitized/contained above.
|
|
1046
|
+
if (req.method === 'PATCH') {
|
|
1047
|
+
if (!sameOriginWrite(req))
|
|
1048
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
1049
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
1050
|
+
return new Response('local request required (DNS-rebinding guard)', { status: 403 });
|
|
1051
|
+
if (!id) return new Response('missing id', { status: 400 });
|
|
1052
|
+
let body: unknown;
|
|
1053
|
+
try {
|
|
1054
|
+
body = await req.json();
|
|
1055
|
+
} catch {
|
|
1056
|
+
return new Response('invalid JSON body', { status: 400 });
|
|
1057
|
+
}
|
|
1058
|
+
if (!body || typeof body !== 'object') return new Response('invalid body', { status: 400 });
|
|
1059
|
+
const b = body as { title?: unknown; archived?: unknown };
|
|
1060
|
+
const patch: { title?: string | null; archived?: boolean } = {};
|
|
1061
|
+
if ('title' in b) {
|
|
1062
|
+
if (b.title === null) patch.title = null;
|
|
1063
|
+
else if (typeof b.title === 'string') patch.title = b.title;
|
|
1064
|
+
else return new Response('title must be a string or null', { status: 400 });
|
|
1065
|
+
}
|
|
1066
|
+
if ('archived' in b) {
|
|
1067
|
+
if (typeof b.archived !== 'boolean')
|
|
1068
|
+
return new Response('archived must be a boolean', { status: 400 });
|
|
1069
|
+
patch.archived = b.archived;
|
|
1070
|
+
}
|
|
1071
|
+
const meta = writeChatMeta(ctx.paths.designRoot, id, patch);
|
|
1072
|
+
return Response.json(meta, { headers: { 'Cache-Control': 'no-store' } });
|
|
1073
|
+
}
|
|
1028
1074
|
if (!id) return Response.json([], { headers: { 'Cache-Control': 'no-store' } });
|
|
1029
1075
|
return Response.json(readChatMessages(ctx.paths.designRoot, id), {
|
|
1030
1076
|
headers: { 'Cache-Control': 'no-store' },
|
|
@@ -2390,6 +2436,98 @@ export function createHttp(
|
|
|
2390
2436
|
);
|
|
2391
2437
|
},
|
|
2392
2438
|
|
|
2439
|
+
'/_api/set-artboard-kind': async (req: Request) => {
|
|
2440
|
+
// feature-1-artboard-kinds-foundation, T8. POST { canvas, artboardId,
|
|
2441
|
+
// kind: 'digital'|'print'|'web'|'video'|null } → api.setArtboardKindOp.
|
|
2442
|
+
// `null` clears the explicit prop back to the implicit default. Whole-
|
|
2443
|
+
// file undo seq. MAIN-ORIGIN ONLY; same gate pair as set-artboard-style
|
|
2444
|
+
// (the kind-switch surfaces — context menu, Inspector — are shell UI,
|
|
2445
|
+
// never reachable from the untrusted canvas origin).
|
|
2446
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
2447
|
+
if (!sameOriginWrite(req))
|
|
2448
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
2449
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
2450
|
+
return new Response('local request required (DNS-rebinding guard)', { status: 403 });
|
|
2451
|
+
const body = await readJson<{
|
|
2452
|
+
canvas?: unknown;
|
|
2453
|
+
artboardId?: unknown;
|
|
2454
|
+
kind?: unknown;
|
|
2455
|
+
}>(req, 2 * 1024);
|
|
2456
|
+
if (!body) return new Response('body required', { status: 400 });
|
|
2457
|
+
const result = await api.setArtboardKindOp(body);
|
|
2458
|
+
if (!result.ok) {
|
|
2459
|
+
return Response.json(
|
|
2460
|
+
{ ok: false, error: result.error },
|
|
2461
|
+
{ status: result.status, headers: { 'Cache-Control': 'no-store' } }
|
|
2462
|
+
);
|
|
2463
|
+
}
|
|
2464
|
+
return Response.json(
|
|
2465
|
+
{ ok: true, seq: result.seq },
|
|
2466
|
+
{ status: 200, headers: { 'Cache-Control': 'no-store' } }
|
|
2467
|
+
);
|
|
2468
|
+
},
|
|
2469
|
+
|
|
2470
|
+
'/_api/set-artboard-guides': async (req: Request) => {
|
|
2471
|
+
// feature-1-artboard-kinds-foundation, T5. POST { canvas, artboardId,
|
|
2472
|
+
// guides: {...}|null } → api.setArtboardGuidesOp. REPLACE-whole-prop
|
|
2473
|
+
// (see applySetArtboardGuides doc comment) — the caller sends the full
|
|
2474
|
+
// merged object, not a delta. MAIN-ORIGIN ONLY; same gate pair as
|
|
2475
|
+
// set-artboard-style.
|
|
2476
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
2477
|
+
if (!sameOriginWrite(req))
|
|
2478
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
2479
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
2480
|
+
return new Response('local request required (DNS-rebinding guard)', { status: 403 });
|
|
2481
|
+
const body = await readJson<{
|
|
2482
|
+
canvas?: unknown;
|
|
2483
|
+
artboardId?: unknown;
|
|
2484
|
+
guides?: unknown;
|
|
2485
|
+
}>(req, 8 * 1024);
|
|
2486
|
+
if (!body) return new Response('body required', { status: 400 });
|
|
2487
|
+
const result = await api.setArtboardGuidesOp(body);
|
|
2488
|
+
if (!result.ok) {
|
|
2489
|
+
return Response.json(
|
|
2490
|
+
{ ok: false, error: result.error },
|
|
2491
|
+
{ status: result.status, headers: { 'Cache-Control': 'no-store' } }
|
|
2492
|
+
);
|
|
2493
|
+
}
|
|
2494
|
+
return Response.json(
|
|
2495
|
+
{ ok: true, seq: result.seq },
|
|
2496
|
+
{ status: 200, headers: { 'Cache-Control': 'no-store' } }
|
|
2497
|
+
);
|
|
2498
|
+
},
|
|
2499
|
+
|
|
2500
|
+
'/_api/set-artboard-print': async (req: Request) => {
|
|
2501
|
+
// feature-2-print-artboards T2. POST { canvas, artboardId, print:
|
|
2502
|
+
// {paper, orientation?, bleedMm?, marginsMm?}|null } → api.setArtboardPrintOp.
|
|
2503
|
+
// REPLACE-whole-prop (see applySetArtboardPrint's own doc comment) — the
|
|
2504
|
+
// caller sends the full merged object, not a delta. MAIN-ORIGIN ONLY;
|
|
2505
|
+
// same gate pair as set-artboard-guides (Inspector picker is shell UI,
|
|
2506
|
+
// never reachable from the untrusted canvas origin).
|
|
2507
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
2508
|
+
if (!sameOriginWrite(req))
|
|
2509
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
2510
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
2511
|
+
return new Response('local request required (DNS-rebinding guard)', { status: 403 });
|
|
2512
|
+
const body = await readJson<{
|
|
2513
|
+
canvas?: unknown;
|
|
2514
|
+
artboardId?: unknown;
|
|
2515
|
+
print?: unknown;
|
|
2516
|
+
}>(req, 2 * 1024);
|
|
2517
|
+
if (!body) return new Response('body required', { status: 400 });
|
|
2518
|
+
const result = await api.setArtboardPrintOp(body);
|
|
2519
|
+
if (!result.ok) {
|
|
2520
|
+
return Response.json(
|
|
2521
|
+
{ ok: false, error: result.error },
|
|
2522
|
+
{ status: result.status, headers: { 'Cache-Control': 'no-store' } }
|
|
2523
|
+
);
|
|
2524
|
+
}
|
|
2525
|
+
return Response.json(
|
|
2526
|
+
{ ok: true, seq: result.seq },
|
|
2527
|
+
{ status: 200, headers: { 'Cache-Control': 'no-store' } }
|
|
2528
|
+
);
|
|
2529
|
+
},
|
|
2530
|
+
|
|
2393
2531
|
'/_api/delete-artboard': async (req: Request) => {
|
|
2394
2532
|
// Delete an artboard by its `id` prop (Backspace / context-menu on a frame).
|
|
2395
2533
|
// POST { canvas, artboardId } → api.deleteArtboardOp (removes the
|
|
@@ -2911,20 +3049,38 @@ export function createHttp(
|
|
|
2911
3049
|
return new Response('local request required (DNS-rebinding guard)', { status: 403 });
|
|
2912
3050
|
if (req.method === 'GET') {
|
|
2913
3051
|
return Response.json(
|
|
2914
|
-
{
|
|
3052
|
+
{
|
|
3053
|
+
transcriptionProvider: readTranscriptionProvider(ctx.paths.repoRoot),
|
|
3054
|
+
keyframeEngine: readKeyframeEngine(ctx.paths.repoRoot),
|
|
3055
|
+
},
|
|
2915
3056
|
{ headers: { 'Cache-Control': 'no-store' } }
|
|
2916
3057
|
);
|
|
2917
3058
|
}
|
|
2918
3059
|
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
2919
3060
|
if (!sameOriginWrite(req))
|
|
2920
3061
|
return new Response('cross-origin write rejected', { status: 403 });
|
|
2921
|
-
const body = await readJson<{ transcriptionProvider?: unknown }>(
|
|
2922
|
-
|
|
2923
|
-
|
|
2924
|
-
|
|
2925
|
-
|
|
2926
|
-
});
|
|
3062
|
+
const body = await readJson<{ transcriptionProvider?: unknown; keyframeEngine?: unknown }>(
|
|
3063
|
+
req,
|
|
3064
|
+
4 * 1024
|
|
3065
|
+
);
|
|
3066
|
+
// The panel POSTs ONE pref at a time (transcription engine OR keyframe engine).
|
|
2927
3067
|
try {
|
|
3068
|
+
if ('keyframeEngine' in (body ?? {})) {
|
|
3069
|
+
const engine = body?.keyframeEngine;
|
|
3070
|
+
if (!isKeyframeEngine(engine))
|
|
3071
|
+
return new Response('keyframeEngine must be auto|gemma|ffmpeg|blind', { status: 400 });
|
|
3072
|
+
await writeKeyframeEngine(ctx.paths.repoRoot, engine);
|
|
3073
|
+
reloadConfig(ctx);
|
|
3074
|
+
return Response.json(
|
|
3075
|
+
{ keyframeEngine: engine },
|
|
3076
|
+
{ headers: { 'Cache-Control': 'no-store' } }
|
|
3077
|
+
);
|
|
3078
|
+
}
|
|
3079
|
+
const provider = body?.transcriptionProvider;
|
|
3080
|
+
if (!isTranscriptionProvider(provider))
|
|
3081
|
+
return new Response('transcriptionProvider must be whisper|elevenlabs|groq', {
|
|
3082
|
+
status: 400,
|
|
3083
|
+
});
|
|
2928
3084
|
await writeTranscriptionProvider(ctx.paths.repoRoot, provider);
|
|
2929
3085
|
reloadConfig(ctx);
|
|
2930
3086
|
return Response.json(
|
|
@@ -3146,6 +3302,87 @@ export function createHttp(
|
|
|
3146
3302
|
);
|
|
3147
3303
|
},
|
|
3148
3304
|
|
|
3305
|
+
// feature-scene-aware-keyframes — managed local Gemma-4 MLX scout models: GET
|
|
3306
|
+
// lists the registry + which are downloaded + any in-flight download + the two
|
|
3307
|
+
// availability signals (mlx-vlm runtime, ffmpeg) the Settings card needs to say
|
|
3308
|
+
// which tier will run; POST {id} downloads one via huggingface_hub (gated on
|
|
3309
|
+
// mlx-vlm being installed — the model is useless without it). MAIN-ORIGIN ONLY.
|
|
3310
|
+
'/_api/generate/keyframe-model': async (req: Request) => {
|
|
3311
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
3312
|
+
return new Response('local request required (DNS-rebinding guard)', { status: 403 });
|
|
3313
|
+
if (req.method === 'GET') {
|
|
3314
|
+
// The GET builds availability by spawning subprocesses (import mlx_vlm is
|
|
3315
|
+
// heavy). Gate it same-origin so a cross-origin no-cors flood can't drive a
|
|
3316
|
+
// spawn-storm against the loopback server (DDR-183 security finding — the
|
|
3317
|
+
// sibling audio-search GET added this guard for the same reason). The probes
|
|
3318
|
+
// are also TTL-cached in gemma-models.ts as defence-in-depth.
|
|
3319
|
+
if (!sameOriginRead(req))
|
|
3320
|
+
return new Response('cross-origin read rejected', { status: 403 });
|
|
3321
|
+
return Response.json(
|
|
3322
|
+
{
|
|
3323
|
+
models: listGemmaModels(),
|
|
3324
|
+
downloading: keyframeDownload,
|
|
3325
|
+
mlxVlmAvailable: mlxVlmAvailable(),
|
|
3326
|
+
ffmpegAvailable: ffmpegAvailable(),
|
|
3327
|
+
},
|
|
3328
|
+
{ headers: { 'Cache-Control': 'no-store' } }
|
|
3329
|
+
);
|
|
3330
|
+
}
|
|
3331
|
+
if (req.method === 'DELETE') {
|
|
3332
|
+
// Free a wedged download slot (a stalled HF connection) without waiting out
|
|
3333
|
+
// the 60-min abort timeout (DDR-183 F4). Same-origin write-gated.
|
|
3334
|
+
if (!sameOriginWrite(req))
|
|
3335
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
3336
|
+
keyframeDownload = null;
|
|
3337
|
+
return Response.json({ cancelled: true }, { headers: { 'Cache-Control': 'no-store' } });
|
|
3338
|
+
}
|
|
3339
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
3340
|
+
if (!sameOriginWrite(req))
|
|
3341
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
3342
|
+
if (!mlxVlmAvailable())
|
|
3343
|
+
return new Response(
|
|
3344
|
+
'mlx-vlm not installed — the Gemma scout needs an Apple-Silicon Mac + `pip install mlx-vlm`.',
|
|
3345
|
+
{
|
|
3346
|
+
status: 400,
|
|
3347
|
+
}
|
|
3348
|
+
);
|
|
3349
|
+
const body = await readJson<{ id?: unknown }>(req, 4 * 1024);
|
|
3350
|
+
const id = typeof body?.id === 'string' ? body.id : '';
|
|
3351
|
+
if (!listGemmaModels().some((m) => m.id === id))
|
|
3352
|
+
return new Response('unknown model id', { status: 400 });
|
|
3353
|
+
if (keyframeDownload && !keyframeDownload.error)
|
|
3354
|
+
return new Response(`a download is already in progress (${keyframeDownload.id})`, {
|
|
3355
|
+
status: 409,
|
|
3356
|
+
});
|
|
3357
|
+
keyframeDownload = { id, received: 0, total: 0 };
|
|
3358
|
+
void downloadGemmaModel(
|
|
3359
|
+
id,
|
|
3360
|
+
(received, total) => {
|
|
3361
|
+
if (keyframeDownload && keyframeDownload.id === id) {
|
|
3362
|
+
keyframeDownload.received = received;
|
|
3363
|
+
keyframeDownload.total = total;
|
|
3364
|
+
}
|
|
3365
|
+
},
|
|
3366
|
+
AbortSignal.timeout(60 * 60_000) // multi-GB snapshots on a slow link
|
|
3367
|
+
)
|
|
3368
|
+
.then(() => {
|
|
3369
|
+
if (keyframeDownload?.id === id) keyframeDownload = null;
|
|
3370
|
+
})
|
|
3371
|
+
.catch((err) => {
|
|
3372
|
+
if (keyframeDownload?.id === id)
|
|
3373
|
+
keyframeDownload = {
|
|
3374
|
+
id,
|
|
3375
|
+
received: keyframeDownload.received,
|
|
3376
|
+
total: keyframeDownload.total,
|
|
3377
|
+
error: err instanceof Error ? err.message : 'download failed',
|
|
3378
|
+
};
|
|
3379
|
+
});
|
|
3380
|
+
return Response.json(
|
|
3381
|
+
{ started: id },
|
|
3382
|
+
{ status: 202, headers: { 'Cache-Control': 'no-store' } }
|
|
3383
|
+
);
|
|
3384
|
+
},
|
|
3385
|
+
|
|
3149
3386
|
'/_canvas-state': async (req: Request) => {
|
|
3150
3387
|
const url = new URL(req.url);
|
|
3151
3388
|
if (req.method === 'GET') {
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// feature-2-print-artboards T1/T5 — pure crop/registration-mark geometry.
|
|
2
|
+
//
|
|
3
|
+
// Consumed by the pdf-lib post-pass (T5) to draw vector marks, and reusable
|
|
4
|
+
// by a future on-canvas marks preview (T3 left that optional — see the plan).
|
|
5
|
+
// Coordinates are PDF POINTS in the BLEED-BOX-origin space: x ∈ [0, pageWidthPt],
|
|
6
|
+
// y ∈ [0, pageHeightPt] is the rendered page itself (Chromium's page.pdf()
|
|
7
|
+
// output, unchanged); marks live OUTSIDE that box, at negative coordinates or
|
|
8
|
+
// beyond pageWidthPt/pageHeightPt. The pdf-lib post-pass enlarges MediaBox
|
|
9
|
+
// with a negative origin to make room for them without moving page content.
|
|
10
|
+
|
|
11
|
+
import { MARK_LENGTH_MM, MARK_STROKE_MM, mmToPt } from './units.ts';
|
|
12
|
+
|
|
13
|
+
export interface LineSegment {
|
|
14
|
+
x1: number;
|
|
15
|
+
y1: number;
|
|
16
|
+
x2: number;
|
|
17
|
+
y2: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface Circle {
|
|
21
|
+
cx: number;
|
|
22
|
+
cy: number;
|
|
23
|
+
r: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface RegistrationMark {
|
|
27
|
+
circle: Circle;
|
|
28
|
+
crosshair: LineSegment[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface MarksGeometry {
|
|
32
|
+
cropMarks: LineSegment[];
|
|
33
|
+
registrationMarks: RegistrationMark[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const REG_RADIUS_MM = 1.5;
|
|
37
|
+
const REG_GAP_MM = 1;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The extra margin MediaBox must extend beyond the bleed box on every side to
|
|
41
|
+
* fit the requested marks without clipping. Callers ask for this BEFORE
|
|
42
|
+
* calling computeMarksGeometry, to size the enlarged MediaBox first.
|
|
43
|
+
*/
|
|
44
|
+
export function requiredSlugPt(args: { crop?: boolean; registration?: boolean }): number {
|
|
45
|
+
const markLen = mmToPt(MARK_LENGTH_MM);
|
|
46
|
+
let slug = 0;
|
|
47
|
+
if (args.crop) slug = Math.max(slug, markLen);
|
|
48
|
+
if (args.registration) {
|
|
49
|
+
slug = Math.max(slug, markLen + mmToPt(REG_GAP_MM) + 2 * mmToPt(REG_RADIUS_MM));
|
|
50
|
+
}
|
|
51
|
+
return slug;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Compute crop-mark (8 segments, 2 per corner) and registration-mark (4,
|
|
56
|
+
* side-midpoints) geometry for a page of the given size + bleed. Crop marks
|
|
57
|
+
* are aligned to the TRIM line but drawn starting exactly at the bleed-box
|
|
58
|
+
* edge (T1's "offset = bleed"), extending outward by `MARK_LENGTH_MM`.
|
|
59
|
+
* Registration marks sit just beyond the crop marks' reach.
|
|
60
|
+
*/
|
|
61
|
+
export function computeMarksGeometry(args: {
|
|
62
|
+
pageWidthPt: number;
|
|
63
|
+
pageHeightPt: number;
|
|
64
|
+
bleedPt: number;
|
|
65
|
+
crop?: boolean;
|
|
66
|
+
registration?: boolean;
|
|
67
|
+
}): MarksGeometry {
|
|
68
|
+
const { pageWidthPt, pageHeightPt, bleedPt } = args;
|
|
69
|
+
const markLen = mmToPt(MARK_LENGTH_MM);
|
|
70
|
+
const trimX0 = bleedPt;
|
|
71
|
+
const trimY0 = bleedPt;
|
|
72
|
+
const trimX1 = pageWidthPt - bleedPt;
|
|
73
|
+
const trimY1 = pageHeightPt - bleedPt;
|
|
74
|
+
|
|
75
|
+
const cropMarks: LineSegment[] = [];
|
|
76
|
+
if (args.crop) {
|
|
77
|
+
// Bottom-left
|
|
78
|
+
cropMarks.push({ x1: trimX0, y1: 0, x2: trimX0, y2: -markLen });
|
|
79
|
+
cropMarks.push({ x1: 0, y1: trimY0, x2: -markLen, y2: trimY0 });
|
|
80
|
+
// Bottom-right
|
|
81
|
+
cropMarks.push({ x1: trimX1, y1: 0, x2: trimX1, y2: -markLen });
|
|
82
|
+
cropMarks.push({ x1: pageWidthPt, y1: trimY0, x2: pageWidthPt + markLen, y2: trimY0 });
|
|
83
|
+
// Top-left
|
|
84
|
+
cropMarks.push({ x1: trimX0, y1: pageHeightPt, x2: trimX0, y2: pageHeightPt + markLen });
|
|
85
|
+
cropMarks.push({ x1: 0, y1: trimY1, x2: -markLen, y2: trimY1 });
|
|
86
|
+
// Top-right
|
|
87
|
+
cropMarks.push({ x1: trimX1, y1: pageHeightPt, x2: trimX1, y2: pageHeightPt + markLen });
|
|
88
|
+
cropMarks.push({ x1: pageWidthPt, y1: trimY1, x2: pageWidthPt + markLen, y2: trimY1 });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const registrationMarks: RegistrationMark[] = [];
|
|
92
|
+
if (args.registration) {
|
|
93
|
+
const regR = mmToPt(REG_RADIUS_MM);
|
|
94
|
+
const offset = markLen + mmToPt(REG_GAP_MM) + regR;
|
|
95
|
+
const mk = (cx: number, cy: number): RegistrationMark => ({
|
|
96
|
+
circle: { cx, cy, r: regR },
|
|
97
|
+
crosshair: [
|
|
98
|
+
{ x1: cx - regR, y1: cy, x2: cx + regR, y2: cy },
|
|
99
|
+
{ x1: cx, y1: cy - regR, x2: cx, y2: cy + regR },
|
|
100
|
+
],
|
|
101
|
+
});
|
|
102
|
+
registrationMarks.push(
|
|
103
|
+
mk(pageWidthPt / 2, pageHeightPt + offset), // top-center
|
|
104
|
+
mk(pageWidthPt / 2, -offset), // bottom-center
|
|
105
|
+
mk(-offset, pageHeightPt / 2), // left-center
|
|
106
|
+
mk(pageWidthPt + offset, pageHeightPt / 2) // right-center
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return { cropMarks, registrationMarks };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export const MARK_STROKE_PT = mmToPt(MARK_STROKE_MM);
|