@1agh/maude 0.41.0 → 0.43.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/.ai/cache/_stats.json +7 -0
- package/apps/studio/acp/bootstrap-brief.ts +12 -2
- package/apps/studio/annotations-layer.tsx +1055 -278
- package/apps/studio/annotations-model.ts +58 -0
- package/apps/studio/api.ts +173 -63
- package/apps/studio/bin/_html-playwright.mjs +26 -4
- package/apps/studio/bin/_ingest-footage.mjs +386 -0
- package/apps/studio/bin/_pdf-playwright.mjs +13 -2
- package/apps/studio/bin/_png-playwright.mjs +15 -2
- package/apps/studio/bin/_pptx-playwright.mjs +17 -4
- package/apps/studio/bin/_probe-footage-playwright.mjs +269 -0
- package/apps/studio/bin/_screenshot-playwright.mjs +17 -2
- package/apps/studio/bin/_svg-playwright.mjs +26 -4
- package/apps/studio/bin/_video-playwright.mjs +141 -71
- package/apps/studio/bin/ingest-footage.sh +27 -0
- package/apps/studio/bin/photo-adjust.sh +163 -0
- package/apps/studio/bin/photo-bg-remove.sh +160 -0
- package/apps/studio/bin/probe-footage.sh +28 -0
- package/apps/studio/bin/read-annotations.mjs +125 -2
- package/apps/studio/bin/screenshot.sh +53 -4
- package/apps/studio/canvas-edit.ts +459 -10
- package/apps/studio/canvas-lib.tsx +523 -5
- package/apps/studio/canvas-pipeline.ts +65 -7
- package/apps/studio/canvas-shell.tsx +156 -16
- package/apps/studio/client/app.jsx +476 -88
- package/apps/studio/client/export-center.jsx +460 -0
- package/apps/studio/client/github.js +4 -0
- package/apps/studio/client/panels/GitPanel.jsx +8 -0
- package/apps/studio/client/panels/RepoBranchSwitcher.jsx +133 -21
- package/apps/studio/client/panels/StickerPicker.jsx +160 -0
- package/apps/studio/client/panels/TimelinePanel.jsx +15 -2
- package/apps/studio/client/photo-knobs.jsx +437 -0
- package/apps/studio/client/styles/3-shell-maude.css +59 -0
- package/apps/studio/client/styles/4-components.css +150 -0
- package/apps/studio/commands/edit-source-command.ts +31 -4
- package/apps/studio/config.schema.json +2 -2
- package/apps/studio/context-menu.tsx +31 -6
- package/apps/studio/dist/client.bundle.js +5561 -19
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/runtime/.min-sizes.json +1 -0
- package/apps/studio/dist/runtime/@imgly_background-removal.js +5543 -0
- package/apps/studio/dist/runtime/pixi-js.js +519 -519
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/dom-selection.ts +25 -0
- package/apps/studio/export-dialog.tsx +18 -25
- package/apps/studio/exporters/_runtime.ts +104 -0
- package/apps/studio/exporters/html.ts +12 -20
- package/apps/studio/exporters/index.ts +14 -2
- package/apps/studio/exporters/jobs.ts +376 -0
- package/apps/studio/exporters/pdf.ts +16 -20
- package/apps/studio/exporters/png.ts +12 -20
- package/apps/studio/exporters/pptx.ts +22 -23
- package/apps/studio/exporters/scope.ts +1 -0
- package/apps/studio/exporters/svg.ts +14 -22
- package/apps/studio/exporters/video.ts +37 -19
- package/apps/studio/footage/schema.test.ts +195 -0
- package/apps/studio/footage/schema.ts +484 -0
- package/apps/studio/footage-store.ts +235 -0
- package/apps/studio/fs-watch.ts +10 -0
- package/apps/studio/git/endpoints.ts +68 -7
- package/apps/studio/git/service.ts +198 -20
- package/apps/studio/github/service.ts +67 -0
- package/apps/studio/handoff.ts +3 -2
- package/apps/studio/http.ts +318 -64
- package/apps/studio/input-router.tsx +10 -0
- package/apps/studio/inspect.ts +53 -1
- package/apps/studio/paths.ts +10 -0
- package/apps/studio/photo/filters.ts +285 -0
- package/apps/studio/photo/pipeline.ts +607 -0
- package/apps/studio/photo/schema.ts +367 -0
- package/apps/studio/photo-store.ts +154 -0
- package/apps/studio/runtime-bundle.ts +29 -6
- package/apps/studio/server.ts +8 -2
- package/apps/studio/stickers/figjam-doodle/manifest.json +104 -0
- package/apps/studio/stickers/figjam-doodle/slice1.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice10.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice11.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice12.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice13.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice14.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice15.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice16.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice17.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice18.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice19.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice2.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice20.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice21.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice22.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice23.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice24.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice3.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice4.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice5.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice6.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice7.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice8.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice9.png +0 -0
- package/apps/studio/stickers/life-style/best.png +0 -0
- package/apps/studio/stickers/life-style/come-on.png +0 -0
- package/apps/studio/stickers/life-style/cut.png +0 -0
- package/apps/studio/stickers/life-style/detox.png +0 -0
- package/apps/studio/stickers/life-style/just.png +0 -0
- package/apps/studio/stickers/life-style/manifest.json +68 -0
- package/apps/studio/stickers/life-style/mirror.png +0 -0
- package/apps/studio/stickers/life-style/nice.png +0 -0
- package/apps/studio/stickers/life-style/objects.png +0 -0
- package/apps/studio/stickers/life-style/ok.png +0 -0
- package/apps/studio/stickers/life-style/queen.png +0 -0
- package/apps/studio/stickers/life-style/star.png +0 -0
- package/apps/studio/stickers/life-style/sun.png +0 -0
- package/apps/studio/stickers/life-style/water.png +0 -0
- package/apps/studio/stickers/life-style/yeah.png +0 -0
- package/apps/studio/stickers/life-style/you-can.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/check-the-deets.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/cut-it.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/dope.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/fresh.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/hot.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/idea.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/keep-exploring.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/killed-it.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/love-it.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/manifest.json +88 -0
- package/apps/studio/stickers/opposing-thoughts/not-sure.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/ok.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/pure-gold.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/save-for-later.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/steal-this.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/take-a-peek.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/this-or-that.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/thoughts.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/vibes.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/winner.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/wip.png +0 -0
- package/apps/studio/stickers/project-status/group-100.png +0 -0
- package/apps/studio/stickers/project-status/group-101.png +0 -0
- package/apps/studio/stickers/project-status/group-102.png +0 -0
- package/apps/studio/stickers/project-status/group-103.png +0 -0
- package/apps/studio/stickers/project-status/group-104.png +0 -0
- package/apps/studio/stickers/project-status/group-105.png +0 -0
- package/apps/studio/stickers/project-status/group-106.png +0 -0
- package/apps/studio/stickers/project-status/group-107.png +0 -0
- package/apps/studio/stickers/project-status/group-108.png +0 -0
- package/apps/studio/stickers/project-status/group-109.png +0 -0
- package/apps/studio/stickers/project-status/group-110.png +0 -0
- package/apps/studio/stickers/project-status/group-111.png +0 -0
- package/apps/studio/stickers/project-status/group-112.png +0 -0
- package/apps/studio/stickers/project-status/group-113.png +0 -0
- package/apps/studio/stickers/project-status/group-114.png +0 -0
- package/apps/studio/stickers/project-status/group-115.png +0 -0
- package/apps/studio/stickers/project-status/group-117.png +0 -0
- package/apps/studio/stickers/project-status/group-118.png +0 -0
- package/apps/studio/stickers/project-status/group-119.png +0 -0
- package/apps/studio/stickers/project-status/group-120.png +0 -0
- package/apps/studio/stickers/project-status/group-121.png +0 -0
- package/apps/studio/stickers/project-status/group-122.png +0 -0
- package/apps/studio/stickers/project-status/group-41.png +0 -0
- package/apps/studio/stickers/project-status/group-42.png +0 -0
- package/apps/studio/stickers/project-status/group-43.png +0 -0
- package/apps/studio/stickers/project-status/group-44.png +0 -0
- package/apps/studio/stickers/project-status/group-45.png +0 -0
- package/apps/studio/stickers/project-status/group-46.png +0 -0
- package/apps/studio/stickers/project-status/group-47.png +0 -0
- package/apps/studio/stickers/project-status/group-48.png +0 -0
- package/apps/studio/stickers/project-status/group-49.png +0 -0
- package/apps/studio/stickers/project-status/group-50.png +0 -0
- package/apps/studio/stickers/project-status/group-51.png +0 -0
- package/apps/studio/stickers/project-status/group-52.png +0 -0
- package/apps/studio/stickers/project-status/group-53.png +0 -0
- package/apps/studio/stickers/project-status/group-54.png +0 -0
- package/apps/studio/stickers/project-status/group-55.png +0 -0
- package/apps/studio/stickers/project-status/group-56.png +0 -0
- package/apps/studio/stickers/project-status/group-57.png +0 -0
- package/apps/studio/stickers/project-status/group-58.png +0 -0
- package/apps/studio/stickers/project-status/group-59.png +0 -0
- package/apps/studio/stickers/project-status/group-60.png +0 -0
- package/apps/studio/stickers/project-status/group-61.png +0 -0
- package/apps/studio/stickers/project-status/group-62.png +0 -0
- package/apps/studio/stickers/project-status/group-63.png +0 -0
- package/apps/studio/stickers/project-status/group-64.png +0 -0
- package/apps/studio/stickers/project-status/group-65.png +0 -0
- package/apps/studio/stickers/project-status/group-66.png +0 -0
- package/apps/studio/stickers/project-status/group-67.png +0 -0
- package/apps/studio/stickers/project-status/group-68.png +0 -0
- package/apps/studio/stickers/project-status/group-69.png +0 -0
- package/apps/studio/stickers/project-status/group-70.png +0 -0
- package/apps/studio/stickers/project-status/group-71.png +0 -0
- package/apps/studio/stickers/project-status/group-72.png +0 -0
- package/apps/studio/stickers/project-status/group-73.png +0 -0
- package/apps/studio/stickers/project-status/group-74.png +0 -0
- package/apps/studio/stickers/project-status/group-75.png +0 -0
- package/apps/studio/stickers/project-status/group-76.png +0 -0
- package/apps/studio/stickers/project-status/group-77.png +0 -0
- package/apps/studio/stickers/project-status/group-78.png +0 -0
- package/apps/studio/stickers/project-status/group-79.png +0 -0
- package/apps/studio/stickers/project-status/group-80.png +0 -0
- package/apps/studio/stickers/project-status/group-81.png +0 -0
- package/apps/studio/stickers/project-status/group-82.png +0 -0
- package/apps/studio/stickers/project-status/group-83.png +0 -0
- package/apps/studio/stickers/project-status/group-84.png +0 -0
- package/apps/studio/stickers/project-status/group-85.png +0 -0
- package/apps/studio/stickers/project-status/group-86.png +0 -0
- package/apps/studio/stickers/project-status/group-87.png +0 -0
- package/apps/studio/stickers/project-status/group-88.png +0 -0
- package/apps/studio/stickers/project-status/group-89.png +0 -0
- package/apps/studio/stickers/project-status/group-90.png +0 -0
- package/apps/studio/stickers/project-status/group-91.png +0 -0
- package/apps/studio/stickers/project-status/group-92.png +0 -0
- package/apps/studio/stickers/project-status/group-93.png +0 -0
- package/apps/studio/stickers/project-status/group-94.png +0 -0
- package/apps/studio/stickers/project-status/group-96.png +0 -0
- package/apps/studio/stickers/project-status/group-97.png +0 -0
- package/apps/studio/stickers/project-status/group-98.png +0 -0
- package/apps/studio/stickers/project-status/group-99.png +0 -0
- package/apps/studio/stickers/project-status/manifest.json +328 -0
- package/apps/studio/test/acp-bootstrap-brief.test.ts +11 -0
- package/apps/studio/test/acp-commands.test.ts +2 -1
- package/apps/studio/test/asset-api.test.ts +3 -3
- package/apps/studio/test/canvas-edit.test.ts +17 -6
- package/apps/studio/test/canvas-origin-gate.test.ts +19 -0
- package/apps/studio/test/dns-rebinding-guard.test.ts +27 -0
- package/apps/studio/test/dynamic-text-edit.test.ts +162 -0
- package/apps/studio/test/edit-source-command.test.ts +23 -0
- package/apps/studio/test/element-structural-edit.test.ts +46 -0
- package/apps/studio/test/export-center.test.tsx +287 -0
- package/apps/studio/test/export-shim-multi-capture.test.ts +83 -0
- package/apps/studio/test/exporters/history.test.ts +32 -3
- package/apps/studio/test/exporters/jobs.test.ts +263 -0
- package/apps/studio/test/footage-store.test.ts +100 -0
- package/apps/studio/test/git-branches.test.ts +97 -0
- package/apps/studio/test/github-api.test.ts +56 -1
- package/apps/studio/test/input-router.test.ts +33 -0
- package/apps/studio/test/inspect-script-syntax.test.ts +53 -0
- package/apps/studio/test/photo-canvas-bundle.test.ts +61 -0
- package/apps/studio/test/photo-edit-api.test.ts +201 -0
- package/apps/studio/test/photo-filters.test.ts +149 -0
- package/apps/studio/test/photo-pipeline.test.ts +106 -0
- package/apps/studio/test/photo-taxonomy.test.ts +96 -0
- package/apps/studio/test/read-annotations.test.ts +164 -0
- package/apps/studio/test/shell-importmap.test.ts +49 -0
- package/apps/studio/test/text-editability-stamp.test.ts +131 -0
- package/apps/studio/test/timeline-parse.test.ts +57 -0
- package/apps/studio/test/tool-palette-insert-anchor.test.ts +5 -3
- package/apps/studio/test/undo-stack.test.ts +33 -0
- package/apps/studio/test/video-asset.test.ts +5 -5
- package/apps/studio/test/video-render-bridge.test.ts +23 -1
- package/apps/studio/text-caret.ts +239 -0
- package/apps/studio/tool-palette.tsx +43 -20
- package/apps/studio/undo-stack.ts +94 -2
- package/apps/studio/use-annotation-resize.tsx +4 -4
- package/apps/studio/use-canvas-media-drop.tsx +38 -1
- package/apps/studio/use-selection-set.tsx +21 -0
- package/apps/studio/video-comp.tsx +48 -7
- package/apps/studio/whats-new.json +70 -0
- package/apps/studio/ws.ts +6 -0
- package/cli/commands/design.mjs +34 -3
- package/cli/lib/gitignore-block.mjs +2 -0
- package/package.json +10 -10
- package/plugins/design/templates/_shell.html +1 -0
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
// Background export job queue (feature-background-export-notification-center).
|
|
2
|
+
//
|
|
3
|
+
// Turns the previously-synchronous `POST /_api/export` into a backgroundable
|
|
4
|
+
// operation: `enqueue()` returns immediately with a job id + a Promise that
|
|
5
|
+
// resolves with the same `ExportResult` the old synchronous call produced, so
|
|
6
|
+
// `/_api/export` stays byte-for-byte contract-unchanged (it just awaits the
|
|
7
|
+
// Promise). `POST /_api/export-jobs` returns the id WITHOUT awaiting, and the
|
|
8
|
+
// job progresses queued → running → done|failed in the background, emitting
|
|
9
|
+
// `bus.emit('export:job', job)` on every transition so ws.ts can broadcast a
|
|
10
|
+
// live snapshot to the notification center.
|
|
11
|
+
//
|
|
12
|
+
// Concurrency is capped by a small hand-rolled counting semaphore (same
|
|
13
|
+
// "no new dependency" convention as `writeLocator()` in http.ts) so N
|
|
14
|
+
// Playwright/Chromium-heavy renders don't all launch at once — MAUDE_EXPORT_
|
|
15
|
+
// MAX_CONCURRENT (default 2) lets a quick PNG start while a slow PDF/video is
|
|
16
|
+
// still running, without letting an unbounded number of Chromiums spawn.
|
|
17
|
+
//
|
|
18
|
+
// History persistence: the in-memory `jobs` Map IS the source of truth. The
|
|
19
|
+
// on-disk `_export-history.json` ledger is re-derived from it (done/failed
|
|
20
|
+
// jobs, newest-first, capped) and overwritten in one shot on every
|
|
21
|
+
// completion — no read-modify-write, so concurrent completions can't drop
|
|
22
|
+
// entries (the race the old api.ts loadExportHistory/appendExportHistory
|
|
23
|
+
// pair had). The ledger is seeded from disk once at construction so history
|
|
24
|
+
// survives a server restart even though job state itself doesn't.
|
|
25
|
+
|
|
26
|
+
import { readFileSync } from 'node:fs';
|
|
27
|
+
import { mkdir, rm } from 'node:fs/promises';
|
|
28
|
+
import path from 'node:path';
|
|
29
|
+
|
|
30
|
+
import type { Bus } from '../context.ts';
|
|
31
|
+
import {
|
|
32
|
+
type ExportContext,
|
|
33
|
+
type ExportOptions,
|
|
34
|
+
type ExportResult,
|
|
35
|
+
type Format,
|
|
36
|
+
runExport,
|
|
37
|
+
type Scope,
|
|
38
|
+
} from './index.ts';
|
|
39
|
+
import type { ResolveScopeArgs } from './scope.ts';
|
|
40
|
+
|
|
41
|
+
/** Extended shape of the old Phase 6.5 T10 history entry — additive fields only. */
|
|
42
|
+
export interface ExportHistoryEntry {
|
|
43
|
+
format: string;
|
|
44
|
+
scope: string;
|
|
45
|
+
options?: Record<string, unknown>;
|
|
46
|
+
filename: string;
|
|
47
|
+
at: string;
|
|
48
|
+
id?: string;
|
|
49
|
+
status?: 'done' | 'failed';
|
|
50
|
+
startedAt?: string;
|
|
51
|
+
finishedAt?: string;
|
|
52
|
+
error?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type ExportJobStatus = 'queued' | 'running' | 'done' | 'failed';
|
|
56
|
+
|
|
57
|
+
export interface ExportJob {
|
|
58
|
+
id: string;
|
|
59
|
+
format: Format;
|
|
60
|
+
scope: Scope;
|
|
61
|
+
options: ExportOptions;
|
|
62
|
+
status: ExportJobStatus;
|
|
63
|
+
progress?: { current: number; total: number };
|
|
64
|
+
createdAt: string;
|
|
65
|
+
startedAt?: string;
|
|
66
|
+
finishedAt?: string;
|
|
67
|
+
filename?: string;
|
|
68
|
+
contentType?: string;
|
|
69
|
+
error?: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface EnqueueArgs {
|
|
73
|
+
format: Format;
|
|
74
|
+
scope: Scope;
|
|
75
|
+
options: ExportOptions;
|
|
76
|
+
resolve: Omit<ResolveScopeArgs, 'scope'>;
|
|
77
|
+
ctx: ExportContext;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export type DownloadResult =
|
|
81
|
+
| { ok: true; bytes: Uint8Array; filename: string; contentType: string }
|
|
82
|
+
| { ok: false; reason: 'missing' | 'not-done' };
|
|
83
|
+
|
|
84
|
+
export interface ExportJobQueue {
|
|
85
|
+
enqueue(args: EnqueueArgs): { id: string; result: Promise<ExportResult> };
|
|
86
|
+
get(id: string): ExportJob | undefined;
|
|
87
|
+
list(): ExportJob[];
|
|
88
|
+
loadHistory(): ExportHistoryEntry[];
|
|
89
|
+
getBytes(id: string): Promise<DownloadResult>;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const HISTORY_DEPTH = 20;
|
|
93
|
+
const MAX_JOB_AGE_MS = 24 * 60 * 60 * 1000;
|
|
94
|
+
const DEFAULT_JOB_TIMEOUT_MS = 5 * 60 * 1000;
|
|
95
|
+
|
|
96
|
+
// Video/animation exports run a per-frame capture loop, so they legitimately
|
|
97
|
+
// take far longer than the 5-min default that's fine for image exports. The
|
|
98
|
+
// fast one-pass renderMediaOnWeb path is quick, but a comp that can't use it —
|
|
99
|
+
// e.g. it overflows @remotion/web-renderer's recursive precompositing (RCA
|
|
100
|
+
// issue-video-mp4-rendermediaonweb-stack-overflow) — degrades to frame-step
|
|
101
|
+
// screenshots at ~1.5–2 s/frame (2× scale). A 900-frame comp then runs 20–30
|
|
102
|
+
// min, so the image default would abort it mid-render (the "Target page closed
|
|
103
|
+
// at frame ~180/900" failure). Size the budget to the WORK — a generous
|
|
104
|
+
// per-frame estimate × the frame count — clamped to [5 min, 60 min]. The lower
|
|
105
|
+
// bound is deliberately the same 5 min as image exports (not a flat 30 min): a
|
|
106
|
+
// tiny clip that WEDGES shouldn't hold a scarce render slot for half an hour,
|
|
107
|
+
// and a security review flagged that a hostile comp which reliably overflows
|
|
108
|
+
// the renderer could otherwise turn a fast-fail into a long slot occupation.
|
|
109
|
+
// The upper bound is a runaway backstop. MAUDE_EXPORT_VIDEO_TIMEOUT_MS overrides
|
|
110
|
+
// end-to-end for renders bigger than the ceiling (e.g. if MAX_FRAMES is raised)
|
|
111
|
+
// — it DELIBERATELY bypasses the 60-min cap, since removing the backstop is the
|
|
112
|
+
// whole point of an operator escape hatch (trusted env, not attacker-reachable).
|
|
113
|
+
const VIDEO_FORMATS = new Set(['mp4', 'webm', 'gif']);
|
|
114
|
+
const VIDEO_TIMEOUT_MIN_MS = 5 * 60 * 1000; // 5 min — same baseline as image exports
|
|
115
|
+
const VIDEO_TIMEOUT_CEIL_MS = 60 * 60 * 1000; // 60 min hard ceiling (runaway backstop)
|
|
116
|
+
const VIDEO_PER_FRAME_BUDGET_MS = 2500; // ~2.5 s/frame — covers the slow frame-step path up to 3× scale
|
|
117
|
+
const VIDEO_SETUP_BUDGET_MS = 60 * 1000; // boot + goto + renderMediaOnWeb attempt before fallback
|
|
118
|
+
|
|
119
|
+
/** Wall-clock render budget for a job — sized to the frame count for video, 5 min otherwise. */
|
|
120
|
+
function jobTimeoutMs(args: EnqueueArgs): number {
|
|
121
|
+
if (!VIDEO_FORMATS.has(args.format)) return DEFAULT_JOB_TIMEOUT_MS;
|
|
122
|
+
// Operator escape hatch — intentionally uncapped (see note above).
|
|
123
|
+
const envOverride = Number(process.env.MAUDE_EXPORT_VIDEO_TIMEOUT_MS);
|
|
124
|
+
if (Number.isFinite(envOverride) && envOverride > 0) return envOverride;
|
|
125
|
+
const o = args.options ?? {};
|
|
126
|
+
const fps = Number(o.fps) || 30;
|
|
127
|
+
const explicitFrames = Number(o.frames);
|
|
128
|
+
const durationMs = Number(o.durationMs);
|
|
129
|
+
const frames =
|
|
130
|
+
Number.isFinite(explicitFrames) && explicitFrames > 0
|
|
131
|
+
? explicitFrames
|
|
132
|
+
: Number.isFinite(durationMs) && durationMs > 0
|
|
133
|
+
? Math.round((durationMs / 1000) * fps)
|
|
134
|
+
: 900; // no duration hint → assume the frame cap (worst case)
|
|
135
|
+
const est = VIDEO_SETUP_BUDGET_MS + frames * VIDEO_PER_FRAME_BUDGET_MS;
|
|
136
|
+
return Math.min(VIDEO_TIMEOUT_CEIL_MS, Math.max(VIDEO_TIMEOUT_MIN_MS, est));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Security fan-out finding (defender, /flow:done) — MEDIUM: `enqueue()` had
|
|
141
|
+
* no cap on queued/running jobs, so a flood of `POST /_api/export-jobs`
|
|
142
|
+
* (unauthenticated same-origin-omitted-Origin callers pass `sameOriginWrite`)
|
|
143
|
+
* could grow the in-memory Map and fill `_export-jobs/` on disk unbounded —
|
|
144
|
+
* the old synchronous `/_api/export` had natural backpressure (one render at
|
|
145
|
+
* a time from the client's own perspective) that the background queue
|
|
146
|
+
* removed. `MAX_PENDING` bounds queued+running jobs; `enqueue()` throws
|
|
147
|
+
* `ExportQueueFullError` past it and http.ts maps that to 429.
|
|
148
|
+
*/
|
|
149
|
+
const MAX_PENDING = Math.max(1, Number(process.env.MAUDE_EXPORT_MAX_QUEUED) || 20);
|
|
150
|
+
|
|
151
|
+
/** Thrown by `enqueue()` when `MAX_PENDING` queued/running jobs are already in flight. */
|
|
152
|
+
export class ExportQueueFullError extends Error {
|
|
153
|
+
constructor() {
|
|
154
|
+
super('export queue is full — too many pending exports, try again shortly');
|
|
155
|
+
this.name = 'ExportQueueFullError';
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Small counting semaphore — no new dependency, mirrors writeLocator()'s style. */
|
|
160
|
+
class Semaphore {
|
|
161
|
+
private active = 0;
|
|
162
|
+
private readonly waiters: Array<() => void> = [];
|
|
163
|
+
|
|
164
|
+
constructor(private readonly max: number) {}
|
|
165
|
+
|
|
166
|
+
async acquire(): Promise<() => void> {
|
|
167
|
+
if (this.active >= this.max) {
|
|
168
|
+
await new Promise<void>((resolve) => this.waiters.push(resolve));
|
|
169
|
+
}
|
|
170
|
+
this.active += 1;
|
|
171
|
+
let released = false;
|
|
172
|
+
return () => {
|
|
173
|
+
if (released) return;
|
|
174
|
+
released = true;
|
|
175
|
+
this.active -= 1;
|
|
176
|
+
const next = this.waiters.shift();
|
|
177
|
+
if (next) next();
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function isFinished(job: ExportJob): boolean {
|
|
183
|
+
return job.status === 'done' || job.status === 'failed';
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function createExportJobQueue(bus: Bus, designRoot: string): ExportJobQueue {
|
|
187
|
+
const jobsDir = path.join(designRoot, '_export-jobs');
|
|
188
|
+
const historyPath = path.join(designRoot, '_export-history.json');
|
|
189
|
+
const maxConcurrent = Math.max(1, Number(process.env.MAUDE_EXPORT_MAX_CONCURRENT) || 2);
|
|
190
|
+
const semaphore = new Semaphore(maxConcurrent);
|
|
191
|
+
const jobs = new Map<string, ExportJob>();
|
|
192
|
+
|
|
193
|
+
// Seed the ledger from disk ONCE — the only read of the history file. Every
|
|
194
|
+
// later persist derives fresh from `jobs` and overwrites; there is no
|
|
195
|
+
// subsequent read-then-write step, which is what eliminates the race.
|
|
196
|
+
try {
|
|
197
|
+
const raw = readFileSync(historyPath, 'utf8');
|
|
198
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
199
|
+
if (Array.isArray(parsed)) {
|
|
200
|
+
for (const entry of parsed) {
|
|
201
|
+
if (!entry || typeof entry !== 'object') continue;
|
|
202
|
+
const e = entry as Record<string, unknown>;
|
|
203
|
+
const id = typeof e.id === 'string' && e.id ? e.id : crypto.randomUUID();
|
|
204
|
+
const finishedAt =
|
|
205
|
+
typeof e.finishedAt === 'string'
|
|
206
|
+
? e.finishedAt
|
|
207
|
+
: typeof e.at === 'string'
|
|
208
|
+
? e.at
|
|
209
|
+
: undefined;
|
|
210
|
+
jobs.set(id, {
|
|
211
|
+
id,
|
|
212
|
+
format: e.format as Format,
|
|
213
|
+
scope: e.scope as Scope,
|
|
214
|
+
options: (e.options as ExportOptions) ?? {},
|
|
215
|
+
status: e.status === 'failed' ? 'failed' : 'done',
|
|
216
|
+
createdAt: (e.startedAt as string) ?? finishedAt ?? new Date().toISOString(),
|
|
217
|
+
startedAt: e.startedAt as string | undefined,
|
|
218
|
+
finishedAt,
|
|
219
|
+
filename: e.filename as string | undefined,
|
|
220
|
+
error: e.error as string | undefined,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
} catch {
|
|
225
|
+
/* no ledger yet, or unreadable — start empty */
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Orphaned `_export-jobs/*` dirs from a process that died mid-export — job
|
|
229
|
+
// state is in-memory only and doesn't survive a restart, so anything on
|
|
230
|
+
// disk from a prior run is stale. Best-effort, never blocks boot.
|
|
231
|
+
void rm(jobsDir, { recursive: true, force: true })
|
|
232
|
+
.catch(() => {})
|
|
233
|
+
.then(() => mkdir(jobsDir, { recursive: true }).catch(() => {}));
|
|
234
|
+
|
|
235
|
+
function emit(job: ExportJob): void {
|
|
236
|
+
bus.emit('export:job', { ...job });
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function deriveHistory(): ExportHistoryEntry[] {
|
|
240
|
+
return Array.from(jobs.values())
|
|
241
|
+
.filter(isFinished)
|
|
242
|
+
.sort((a, b) => (b.finishedAt ?? '').localeCompare(a.finishedAt ?? ''))
|
|
243
|
+
.slice(0, HISTORY_DEPTH)
|
|
244
|
+
.map((j) => ({
|
|
245
|
+
id: j.id,
|
|
246
|
+
format: j.format,
|
|
247
|
+
scope: j.scope,
|
|
248
|
+
options: j.options,
|
|
249
|
+
filename: j.filename ?? '',
|
|
250
|
+
at: j.finishedAt ?? j.createdAt,
|
|
251
|
+
status: j.status as 'done' | 'failed',
|
|
252
|
+
startedAt: j.startedAt,
|
|
253
|
+
finishedAt: j.finishedAt,
|
|
254
|
+
error: j.error,
|
|
255
|
+
}));
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async function persistAndEvict(): Promise<void> {
|
|
259
|
+
const history = deriveHistory();
|
|
260
|
+
await Bun.write(historyPath, JSON.stringify(history, null, 2));
|
|
261
|
+
|
|
262
|
+
// Evict bytes (+ the in-memory record) for anything that rolled past the
|
|
263
|
+
// cap, or aged out, whichever comes first.
|
|
264
|
+
const finished = Array.from(jobs.values())
|
|
265
|
+
.filter(isFinished)
|
|
266
|
+
.sort((a, b) => (b.finishedAt ?? '').localeCompare(a.finishedAt ?? ''));
|
|
267
|
+
const now = Date.now();
|
|
268
|
+
const stale = finished.filter((j, i) => {
|
|
269
|
+
if (i >= HISTORY_DEPTH) return true;
|
|
270
|
+
const finishedAt = j.finishedAt ? Date.parse(j.finishedAt) : Number.NaN;
|
|
271
|
+
return Number.isFinite(finishedAt) && now - finishedAt > MAX_JOB_AGE_MS;
|
|
272
|
+
});
|
|
273
|
+
for (const job of stale) {
|
|
274
|
+
jobs.delete(job.id);
|
|
275
|
+
await rm(path.join(jobsDir, job.id), { recursive: true, force: true }).catch(() => {});
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function enqueue(args: EnqueueArgs): { id: string; result: Promise<ExportResult> } {
|
|
280
|
+
let pending = 0;
|
|
281
|
+
for (const job of jobs.values()) {
|
|
282
|
+
if (job.status === 'queued' || job.status === 'running') pending += 1;
|
|
283
|
+
}
|
|
284
|
+
if (pending >= MAX_PENDING) throw new ExportQueueFullError();
|
|
285
|
+
|
|
286
|
+
const id = crypto.randomUUID();
|
|
287
|
+
const job: ExportJob = {
|
|
288
|
+
id,
|
|
289
|
+
format: args.format,
|
|
290
|
+
scope: args.scope,
|
|
291
|
+
options: args.options,
|
|
292
|
+
status: 'queued',
|
|
293
|
+
createdAt: new Date().toISOString(),
|
|
294
|
+
};
|
|
295
|
+
jobs.set(id, job);
|
|
296
|
+
emit(job);
|
|
297
|
+
|
|
298
|
+
const controller = new AbortController();
|
|
299
|
+
|
|
300
|
+
const result = (async (): Promise<ExportResult> => {
|
|
301
|
+
const release = await semaphore.acquire();
|
|
302
|
+
// Started here, not at enqueue — the timeout bounds RENDER time (the
|
|
303
|
+
// intent), not queue-wait time. A job can legitimately sit `queued`
|
|
304
|
+
// behind MAUDE_EXPORT_MAX_CONCURRENT other jobs for longer than its
|
|
305
|
+
// render budget; arming the timer before the semaphore resolves would
|
|
306
|
+
// abort it before it ever ran (code-review finding, /flow:done).
|
|
307
|
+
const timer = setTimeout(() => controller.abort(), jobTimeoutMs(args));
|
|
308
|
+
try {
|
|
309
|
+
job.status = 'running';
|
|
310
|
+
job.startedAt = new Date().toISOString();
|
|
311
|
+
emit(job);
|
|
312
|
+
|
|
313
|
+
const res = await runExport({
|
|
314
|
+
format: args.format,
|
|
315
|
+
scope: args.scope,
|
|
316
|
+
options: args.options,
|
|
317
|
+
resolve: args.resolve,
|
|
318
|
+
ctx: args.ctx,
|
|
319
|
+
hooks: {
|
|
320
|
+
signal: controller.signal,
|
|
321
|
+
onProgress: (update) => {
|
|
322
|
+
job.progress = update;
|
|
323
|
+
emit(job);
|
|
324
|
+
},
|
|
325
|
+
},
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
job.status = 'done';
|
|
329
|
+
job.finishedAt = new Date().toISOString();
|
|
330
|
+
job.filename = res.filename;
|
|
331
|
+
job.contentType = res.contentType;
|
|
332
|
+
if (res.body.byteLength) {
|
|
333
|
+
const dir = path.join(jobsDir, id);
|
|
334
|
+
await mkdir(dir, { recursive: true });
|
|
335
|
+
await Bun.write(path.join(dir, res.filename), res.body);
|
|
336
|
+
}
|
|
337
|
+
emit(job);
|
|
338
|
+
await persistAndEvict();
|
|
339
|
+
return res;
|
|
340
|
+
} catch (err) {
|
|
341
|
+
job.status = 'failed';
|
|
342
|
+
job.finishedAt = new Date().toISOString();
|
|
343
|
+
job.error = err instanceof Error ? err.message : String(err);
|
|
344
|
+
emit(job);
|
|
345
|
+
await persistAndEvict();
|
|
346
|
+
throw err;
|
|
347
|
+
} finally {
|
|
348
|
+
clearTimeout(timer);
|
|
349
|
+
release();
|
|
350
|
+
}
|
|
351
|
+
})();
|
|
352
|
+
|
|
353
|
+
return { id, result };
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
return {
|
|
357
|
+
enqueue,
|
|
358
|
+
get: (id) => jobs.get(id),
|
|
359
|
+
list: () => Array.from(jobs.values()).sort((a, b) => b.createdAt.localeCompare(a.createdAt)),
|
|
360
|
+
loadHistory: deriveHistory,
|
|
361
|
+
async getBytes(id) {
|
|
362
|
+
const job = jobs.get(id);
|
|
363
|
+
if (!job) return { ok: false, reason: 'missing' };
|
|
364
|
+
if (job.status !== 'done') return { ok: false, reason: 'not-done' };
|
|
365
|
+
if (!job.filename) return { ok: false, reason: 'missing' };
|
|
366
|
+
const file = Bun.file(path.join(jobsDir, id, job.filename));
|
|
367
|
+
if (!(await file.exists())) return { ok: false, reason: 'missing' };
|
|
368
|
+
return {
|
|
369
|
+
ok: true,
|
|
370
|
+
bytes: new Uint8Array(await file.arrayBuffer()),
|
|
371
|
+
filename: job.filename,
|
|
372
|
+
contentType: job.contentType ?? 'application/octet-stream',
|
|
373
|
+
};
|
|
374
|
+
},
|
|
375
|
+
};
|
|
376
|
+
}
|
|
@@ -13,10 +13,11 @@ import { tmpdir } from 'node:os';
|
|
|
13
13
|
import path from 'node:path';
|
|
14
14
|
|
|
15
15
|
import { PDFDocument } from 'pdf-lib';
|
|
16
|
-
import { exportShimPath,
|
|
16
|
+
import { exportShimPath, runShim } from './_runtime.ts';
|
|
17
17
|
import {
|
|
18
18
|
canvasShellUrl,
|
|
19
19
|
type ExportContext,
|
|
20
|
+
type ExportHooks,
|
|
20
21
|
type ExportOptions,
|
|
21
22
|
type ExportResult,
|
|
22
23
|
} from './index.ts';
|
|
@@ -29,7 +30,8 @@ async function capturePdf(
|
|
|
29
30
|
target: Extract<Target, { kind: 'element' }>,
|
|
30
31
|
ctx: ExportContext,
|
|
31
32
|
outDir: string,
|
|
32
|
-
timeoutSec: number
|
|
33
|
+
timeoutSec: number,
|
|
34
|
+
hooks?: ExportHooks
|
|
33
35
|
): Promise<string[]> {
|
|
34
36
|
const args = [
|
|
35
37
|
PDF_PLAYWRIGHT,
|
|
@@ -49,29 +51,18 @@ async function capturePdf(
|
|
|
49
51
|
args.push('--out', path.join(outDir, `${target.canvasSlug}.pdf`));
|
|
50
52
|
}
|
|
51
53
|
|
|
52
|
-
|
|
54
|
+
return runShim(args, {
|
|
53
55
|
cwd: path.dirname(PDF_PLAYWRIGHT),
|
|
54
|
-
|
|
55
|
-
|
|
56
|
+
signal: hooks?.signal,
|
|
57
|
+
onProgress: hooks?.onProgress,
|
|
56
58
|
});
|
|
57
|
-
const [stdout, stderr] = await Promise.all([
|
|
58
|
-
new Response(proc.stdout).text(),
|
|
59
|
-
new Response(proc.stderr).text(),
|
|
60
|
-
]);
|
|
61
|
-
const code = await proc.exited;
|
|
62
|
-
if (code !== 0) {
|
|
63
|
-
throw new Error(`_pdf-playwright exited ${code}: ${stderr.trim() || stdout.trim()}`);
|
|
64
|
-
}
|
|
65
|
-
return stdout
|
|
66
|
-
.split('\n')
|
|
67
|
-
.map((s) => s.trim())
|
|
68
|
-
.filter(Boolean);
|
|
69
59
|
}
|
|
70
60
|
|
|
71
61
|
export async function run(
|
|
72
62
|
targets: Target[],
|
|
73
63
|
options: ExportOptions,
|
|
74
|
-
ctx: ExportContext
|
|
64
|
+
ctx: ExportContext,
|
|
65
|
+
hooks?: ExportHooks
|
|
75
66
|
): Promise<ExportResult> {
|
|
76
67
|
if (!targets.length) {
|
|
77
68
|
return { filename: 'export.pdf', contentType: 'application/pdf', body: new Uint8Array(0) };
|
|
@@ -86,9 +77,14 @@ export async function run(
|
|
|
86
77
|
const tmp = mkdtempSync(path.join(tmpdir(), 'maude-pdf-'));
|
|
87
78
|
try {
|
|
88
79
|
const written: string[] = [];
|
|
89
|
-
for (
|
|
90
|
-
const paths = await capturePdf(
|
|
80
|
+
for (let i = 0; i < elementTargets.length; i += 1) {
|
|
81
|
+
const paths = await capturePdf(elementTargets[i], ctx, tmp, timeoutSec, hooks);
|
|
91
82
|
written.push(...paths);
|
|
83
|
+
// Outer-loop tick (N separate top-level Targets — rare). The granular
|
|
84
|
+
// "K of M artboards" signal for canvas-as-separate comes from the
|
|
85
|
+
// shim's own MAUDE_PROGRESS lines via spawnShim's onProgress inside
|
|
86
|
+
// capturePdf, above — both funnel into the same hooks.onProgress.
|
|
87
|
+
hooks?.onProgress?.({ current: i + 1, total: elementTargets.length });
|
|
92
88
|
}
|
|
93
89
|
if (!written.length) {
|
|
94
90
|
return { filename: 'export.pdf', contentType: 'application/pdf', body: new Uint8Array(0) };
|
|
@@ -12,10 +12,11 @@ import { tmpdir } from 'node:os';
|
|
|
12
12
|
import path from 'node:path';
|
|
13
13
|
|
|
14
14
|
import JSZip from 'jszip';
|
|
15
|
-
import { exportShimPath,
|
|
15
|
+
import { exportShimPath, runShim } from './_runtime.ts';
|
|
16
16
|
import {
|
|
17
17
|
canvasShellUrl,
|
|
18
18
|
type ExportContext,
|
|
19
|
+
type ExportHooks,
|
|
19
20
|
type ExportOptions,
|
|
20
21
|
type ExportResult,
|
|
21
22
|
} from './index.ts';
|
|
@@ -45,7 +46,8 @@ async function captureElement(
|
|
|
45
46
|
target: Extract<Target, { kind: 'element' }>,
|
|
46
47
|
ctx: ExportContext,
|
|
47
48
|
outDir: string,
|
|
48
|
-
options: CaptureOptions
|
|
49
|
+
options: CaptureOptions,
|
|
50
|
+
hooks?: ExportHooks
|
|
49
51
|
): Promise<string[]> {
|
|
50
52
|
const args = [
|
|
51
53
|
PNG_PLAYWRIGHT,
|
|
@@ -68,23 +70,11 @@ async function captureElement(
|
|
|
68
70
|
if (target.widen) args.push('--widen-to-artboard', '1');
|
|
69
71
|
args.push('--out', path.join(outDir, `${target.canvasSlug}.png`));
|
|
70
72
|
}
|
|
71
|
-
|
|
73
|
+
return runShim(args, {
|
|
72
74
|
cwd: path.dirname(PNG_PLAYWRIGHT),
|
|
73
|
-
|
|
74
|
-
|
|
75
|
+
signal: hooks?.signal,
|
|
76
|
+
onProgress: hooks?.onProgress,
|
|
75
77
|
});
|
|
76
|
-
const [stdout, stderr] = await Promise.all([
|
|
77
|
-
new Response(proc.stdout).text(),
|
|
78
|
-
new Response(proc.stderr).text(),
|
|
79
|
-
]);
|
|
80
|
-
const code = await proc.exited;
|
|
81
|
-
if (code !== 0) {
|
|
82
|
-
throw new Error(`_png-playwright exited ${code}: ${stderr.trim() || stdout.trim()}`);
|
|
83
|
-
}
|
|
84
|
-
return stdout
|
|
85
|
-
.split('\n')
|
|
86
|
-
.map((s) => s.trim())
|
|
87
|
-
.filter(Boolean);
|
|
88
78
|
}
|
|
89
79
|
|
|
90
80
|
function readBytes(paths: string[]): Array<{ name: string; bytes: Uint8Array }> {
|
|
@@ -105,7 +95,8 @@ async function bundleZip(entries: Array<{ name: string; bytes: Uint8Array }>): P
|
|
|
105
95
|
export async function run(
|
|
106
96
|
targets: Target[],
|
|
107
97
|
options: ExportOptions,
|
|
108
|
-
ctx: ExportContext
|
|
98
|
+
ctx: ExportContext,
|
|
99
|
+
hooks?: ExportHooks
|
|
109
100
|
): Promise<ExportResult> {
|
|
110
101
|
if (!targets.length) {
|
|
111
102
|
return { filename: 'export.png', contentType: 'image/png', body: new Uint8Array(0) };
|
|
@@ -128,9 +119,10 @@ export async function run(
|
|
|
128
119
|
|
|
129
120
|
try {
|
|
130
121
|
const written: string[] = [];
|
|
131
|
-
for (
|
|
132
|
-
const paths = await captureElement(
|
|
122
|
+
for (let i = 0; i < elementTargets.length; i += 1) {
|
|
123
|
+
const paths = await captureElement(elementTargets[i], ctx, tmp, captureOpts, hooks);
|
|
133
124
|
written.push(...paths);
|
|
125
|
+
hooks?.onProgress?.({ current: i + 1, total: elementTargets.length });
|
|
134
126
|
}
|
|
135
127
|
const entries = readBytes(written);
|
|
136
128
|
|
|
@@ -27,10 +27,11 @@ import path from 'node:path';
|
|
|
27
27
|
import PptxGenJS from 'pptxgenjs';
|
|
28
28
|
|
|
29
29
|
import { getBrowserBundle } from './_browser-bundles.ts';
|
|
30
|
-
import { exportShimPath,
|
|
30
|
+
import { exportShimPath, runShim } from './_runtime.ts';
|
|
31
31
|
import {
|
|
32
32
|
canvasShellUrl,
|
|
33
33
|
type ExportContext,
|
|
34
|
+
type ExportHooks,
|
|
34
35
|
type ExportOptions,
|
|
35
36
|
type ExportResult,
|
|
36
37
|
} from './index.ts';
|
|
@@ -51,25 +52,13 @@ function isElementTarget(t: Target): t is ElementTarget {
|
|
|
51
52
|
return t.kind === 'element';
|
|
52
53
|
}
|
|
53
54
|
|
|
54
|
-
/** Spawn a playwright shim via
|
|
55
|
-
|
|
56
|
-
|
|
55
|
+
/** Spawn a playwright shim via the shared runtime, returning the written file paths (stdout). */
|
|
56
|
+
function spawnPlaywrightShim(args: string[], hooks?: ExportHooks): Promise<string[]> {
|
|
57
|
+
return runShim(args, {
|
|
57
58
|
cwd: path.dirname(args[0]),
|
|
58
|
-
|
|
59
|
-
|
|
59
|
+
signal: hooks?.signal,
|
|
60
|
+
onProgress: hooks?.onProgress,
|
|
60
61
|
});
|
|
61
|
-
const [stdout, stderr] = await Promise.all([
|
|
62
|
-
new Response(proc.stdout).text(),
|
|
63
|
-
new Response(proc.stderr).text(),
|
|
64
|
-
]);
|
|
65
|
-
const code = await proc.exited;
|
|
66
|
-
if (code !== 0) {
|
|
67
|
-
throw new Error(`${path.basename(args[0])} exited ${code}: ${stderr.trim() || stdout.trim()}`);
|
|
68
|
-
}
|
|
69
|
-
return stdout
|
|
70
|
-
.split('\n')
|
|
71
|
-
.map((s) => s.trim())
|
|
72
|
-
.filter(Boolean);
|
|
73
62
|
}
|
|
74
63
|
|
|
75
64
|
// ─── SVG pre-processing (the fix that makes svg2pptx faithful) ────────────────
|
|
@@ -324,7 +313,8 @@ async function buildPngDeck(pngPaths: string[]): Promise<Uint8Array> {
|
|
|
324
313
|
export async function run(
|
|
325
314
|
targets: Target[],
|
|
326
315
|
options: ExportOptions,
|
|
327
|
-
ctx: ExportContext
|
|
316
|
+
ctx: ExportContext,
|
|
317
|
+
hooks?: ExportHooks
|
|
328
318
|
): Promise<ExportResult> {
|
|
329
319
|
const empty: ExportResult = {
|
|
330
320
|
filename: 'export.pptx',
|
|
@@ -352,10 +342,15 @@ export async function run(
|
|
|
352
342
|
try {
|
|
353
343
|
const bundle = await getBrowserBundle('dom-to-svg', 'domToSvg');
|
|
354
344
|
const svgFiles: string[] = [];
|
|
355
|
-
for (
|
|
345
|
+
for (let i = 0; i < elementTargets.length; i += 1) {
|
|
346
|
+
const t = elementTargets[i];
|
|
356
347
|
svgFiles.push(
|
|
357
|
-
...(await
|
|
348
|
+
...(await spawnPlaywrightShim(
|
|
349
|
+
svgArgsFor(t, canvasShellUrl(ctx, t.file), svgDir, bundle),
|
|
350
|
+
hooks
|
|
351
|
+
))
|
|
358
352
|
);
|
|
353
|
+
hooks?.onProgress?.({ current: i + 1, total: elementTargets.length });
|
|
359
354
|
}
|
|
360
355
|
if (!svgFiles.length) return empty;
|
|
361
356
|
const deckBuffers: Uint8Array[] = [];
|
|
@@ -382,8 +377,12 @@ export async function run(
|
|
|
382
377
|
|
|
383
378
|
// PNG fallback (faithful, universal, Canva-safe, not editable).
|
|
384
379
|
const pngFiles: string[] = [];
|
|
385
|
-
for (
|
|
386
|
-
|
|
380
|
+
for (let i = 0; i < elementTargets.length; i += 1) {
|
|
381
|
+
const t = elementTargets[i];
|
|
382
|
+
pngFiles.push(
|
|
383
|
+
...(await spawnPlaywrightShim(pngArgsFor(t, canvasShellUrl(ctx, t.file), pngDir), hooks))
|
|
384
|
+
);
|
|
385
|
+
hooks?.onProgress?.({ current: i + 1, total: elementTargets.length });
|
|
387
386
|
}
|
|
388
387
|
if (!pngFiles.length) return empty;
|
|
389
388
|
const body = await buildPngDeck(pngFiles);
|