@1agh/maude 0.58.2 → 0.59.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/annotations-bindings.ts +83 -4
- package/apps/studio/annotations-layer.tsx +49 -15
- package/apps/studio/api.ts +6 -1
- package/apps/studio/bin/_fetch-asset.mjs +169 -5
- package/apps/studio/bin/_import-asset.mjs +90 -0
- package/apps/studio/bin/_import-figma.mjs +1775 -0
- package/apps/studio/bin/_perf-probe-safari.mjs +332 -0
- package/apps/studio/bin/_perf-probe.mjs +228 -0
- package/apps/studio/bin/_perf-shared.mjs +345 -0
- package/apps/studio/bin/_video-playwright.mjs +103 -7
- package/apps/studio/bin/import-figma.sh +47 -0
- package/apps/studio/bin/perf.sh +228 -0
- package/apps/studio/bin/read-annotations.mjs +11 -1
- package/apps/studio/bin/smoke.sh +49 -5
- package/apps/studio/bun.lock +16 -22
- package/apps/studio/canvas-edit.ts +29 -5
- package/apps/studio/canvas-lib.tsx +148 -6
- package/apps/studio/client/app.jsx +196 -38
- package/apps/studio/client/export-center.jsx +42 -4
- package/apps/studio/client/panels/CloudBar.jsx +92 -1
- package/apps/studio/client/panels/FigmaImportPanel.jsx +264 -0
- package/apps/studio/client/panels/GitPanel.jsx +26 -6
- package/apps/studio/client/panels/SettingsPanel.jsx +181 -0
- package/apps/studio/client/panels/SetupChecklist.jsx +26 -2
- package/apps/studio/client/panels/SyncPanel.jsx +229 -0
- package/apps/studio/client/panels/TimelinePanel.jsx +31 -3
- package/apps/studio/client/panels/timeline-comp-target.js +101 -0
- package/apps/studio/client/panels/timeline-parse.js +3 -3
- package/apps/studio/client/styles/3-shell-maude.css +37 -0
- package/apps/studio/client/styles/4-components.css +134 -0
- package/apps/studio/clip-ops.ts +93 -17
- package/apps/studio/cloud/endpoints.ts +78 -10
- package/apps/studio/cloud/renew.ts +183 -0
- package/apps/studio/context.ts +2 -1
- package/apps/studio/dist/client.bundle.js +1231 -1231
- package/apps/studio/dist/runtime/@remotion_media.js +56 -136
- package/apps/studio/dist/runtime/@remotion_player.js +18 -18
- package/apps/studio/dist/runtime/@remotion_transitions.js +9 -9
- package/apps/studio/dist/runtime/@remotion_transitions_clock-wipe.js +1 -1
- package/apps/studio/dist/runtime/remotion.js +12 -12
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/exporters/_browser-bundles.ts +20 -6
- package/apps/studio/exporters/_runtime.ts +19 -0
- package/apps/studio/exporters/degraded.ts +92 -0
- package/apps/studio/exporters/index.ts +5 -0
- package/apps/studio/exporters/jobs.ts +19 -0
- package/apps/studio/exporters/unsupported-media.ts +170 -0
- package/apps/studio/exporters/video-encode-lib.ts +35 -6
- package/apps/studio/exporters/video-render-lib.ts +6 -0
- package/apps/studio/exporters/video.ts +72 -1
- package/apps/studio/figma/assets.test.ts +464 -0
- package/apps/studio/figma/assets.ts +452 -0
- package/apps/studio/figma/client.test.ts +395 -0
- package/apps/studio/figma/client.ts +513 -0
- package/apps/studio/figma/codegen-client.test.ts +276 -0
- package/apps/studio/figma/codegen-client.ts +509 -0
- package/apps/studio/figma/codegen-fonts.test.ts +103 -0
- package/apps/studio/figma/codegen-fonts.ts +195 -0
- package/apps/studio/figma/codegen-values.test.ts +179 -0
- package/apps/studio/figma/codegen-values.ts +270 -0
- package/apps/studio/figma/comments-to-strokes.test.ts +194 -0
- package/apps/studio/figma/comments-to-strokes.ts +173 -0
- package/apps/studio/figma/endpoints.ts +273 -0
- package/apps/studio/figma/fig-decode.test.ts +702 -0
- package/apps/studio/figma/fig-decode.ts +617 -0
- package/apps/studio/figma/fig-kiwi.ts +410 -0
- package/apps/studio/figma/fig-zip.ts +270 -0
- package/apps/studio/figma/from-codegen.test.ts +408 -0
- package/apps/studio/figma/from-codegen.ts +1103 -0
- package/apps/studio/figma/sanitize.test.ts +325 -0
- package/apps/studio/figma/sanitize.ts +407 -0
- package/apps/studio/figma/style-map.ts +352 -0
- package/apps/studio/figma/tailwind-map.test.ts +142 -0
- package/apps/studio/figma/tailwind-map.ts +545 -0
- package/apps/studio/figma/to-artboard.test.ts +808 -0
- package/apps/studio/figma/to-artboard.ts +701 -0
- package/apps/studio/figma/to-render.test.ts +180 -0
- package/apps/studio/figma/to-render.ts +328 -0
- package/apps/studio/figma/to-strokes-roundtrip.test.ts +152 -0
- package/apps/studio/figma/to-strokes.test.ts +705 -0
- package/apps/studio/figma/to-strokes.ts +749 -0
- package/apps/studio/figma/to-tokens.test.ts +321 -0
- package/apps/studio/figma/to-tokens.ts +305 -0
- package/apps/studio/figma/types.ts +544 -0
- package/apps/studio/figma/url.test.ts +167 -0
- package/apps/studio/figma/url.ts +160 -0
- package/apps/studio/http.ts +176 -0
- package/apps/studio/sync/asset-push.ts +432 -0
- package/apps/studio/sync/connection-state.ts +82 -3
- package/apps/studio/sync/hub-link.ts +63 -7
- package/apps/studio/sync/hubs-config.ts +31 -3
- package/apps/studio/sync/index.ts +286 -27
- package/apps/studio/sync/migrate-flat-fallback.ts +121 -0
- package/apps/studio/sync/presentation.ts +45 -1
- package/apps/studio/sync/status.ts +18 -0
- package/apps/studio/sync/supervisor.ts +5 -1
- package/apps/studio/sync/workspace-signin.ts +7 -3
- package/apps/studio/test/annotations-bindings.test.ts +150 -12
- package/apps/studio/test/canvas-create-api.test.ts +4 -1
- package/apps/studio/test/canvas-origin-gate.test.ts +17 -0
- package/apps/studio/test/capture-determinism-shape.test.ts +135 -0
- package/apps/studio/test/clip-addressing.test.ts +6 -1
- package/apps/studio/test/clip-ops.test.ts +5 -1
- package/apps/studio/test/cloud-endpoints.test.ts +96 -0
- package/apps/studio/test/cloud-renew.test.ts +205 -0
- package/apps/studio/test/cloud-shell-surfaces.test.ts +11 -2
- package/apps/studio/test/exporters/degraded-propagation.test.ts +123 -0
- package/apps/studio/test/exporters/unsupported-media.test.ts +123 -0
- package/apps/studio/test/fetch-asset-gate.test.ts +189 -0
- package/apps/studio/test/figma-explode.test.ts +438 -0
- package/apps/studio/test/figma-provenance.test.ts +108 -0
- package/apps/studio/test/figma-routes.test.ts +294 -0
- package/apps/studio/test/fixtures/perf-canvas.mjs +201 -0
- package/apps/studio/test/git-cloud-posture.test.ts +50 -0
- package/apps/studio/test/hub-link.test.ts +11 -0
- package/apps/studio/test/import-figma.test.ts +667 -0
- package/apps/studio/test/sync-asset-push.test.ts +567 -0
- package/apps/studio/test/sync-connection-state.test.ts +79 -0
- package/apps/studio/test/sync-hubs-config.test.ts +5 -0
- package/apps/studio/test/sync-migrate-flat-fallback.test.ts +98 -0
- package/apps/studio/test/sync-panel-surface.test.ts +90 -0
- package/apps/studio/test/sync-path-pull.test.ts +63 -0
- package/apps/studio/test/sync-presentation.test.ts +77 -0
- package/apps/studio/test/sync-runtime.test.ts +316 -1
- package/apps/studio/test/sync-status.test.ts +28 -0
- package/apps/studio/test/timeline-comp-target.test.ts +139 -0
- package/apps/studio/test/video-comp.test.ts +104 -2
- package/apps/studio/test/video-encode-lib.test.ts +63 -0
- package/apps/studio/test/workspace-containment.test.ts +1 -0
- package/apps/studio/use-artboard-drag.tsx +37 -3
- package/apps/studio/video-comp.tsx +121 -6
- package/apps/studio/whats-new.json +98 -0
- package/apps/studio/workspace-mode.ts +4 -0
- package/cli/commands/design.mjs +15 -0
- package/cli/commands/kg.mjs +8 -1
- package/cli/commands/kg.test.mjs +24 -0
- package/cli/lib/figma-codegen-reachability.test.mjs +104 -0
- package/cli/lib/figma-import-controls.test.mjs +70 -0
- package/package.json +8 -8
- package/plugins/flow/.claude-plugin/config.schema.json +3 -3
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file figma/assets.ts — resolving image + vector fills (DDR-216 T8).
|
|
3
|
+
* @scope apps/studio/figma/assets.ts
|
|
4
|
+
* @purpose Turn `pendingImages` / `pendingExports` into real, local,
|
|
5
|
+
* content-addressed assets — batched, capped, and through the
|
|
6
|
+
* existing gates rather than a second download path.
|
|
7
|
+
*
|
|
8
|
+
* @invariant BATCH, NEVER ONE CALL PER NODE. `IMAGE_COST = 200` ⇒ ~30 req/min
|
|
9
|
+
* on the images endpoint — the tight one. Figma's own translator
|
|
10
|
+
* emitted 22 exports for a trivial 990×648 frame; a real page would
|
|
11
|
+
* be hundreds. `to-artboard`'s vector-cluster collapse is the other
|
|
12
|
+
* half of this mitigation.
|
|
13
|
+
*
|
|
14
|
+
* @invariant DOWNLOADS GO THROUGH `_fetch-asset.mjs`, WITH THE FIGMA LANE'S
|
|
15
|
+
* NARROWING. The URLs `/v1/images` returns are RESPONSE-CONTROLLED
|
|
16
|
+
* — Maude did not choose them. They get the full resolved-IP gate
|
|
17
|
+
* plus a host allowlist, a pinned port, and a tight byte cap. There
|
|
18
|
+
* is no second downloader here.
|
|
19
|
+
*
|
|
20
|
+
* @invariant FAIL CLOSED. A vector goes through TWO processes in TWO runtimes
|
|
21
|
+
* (node for the download, bun for the DDR-167 SVG lane). DDR-177
|
|
22
|
+
* documents that runtime-spawned helpers have shipped broken inside
|
|
23
|
+
* the packaged `.app` more than once. If the sanitize step is
|
|
24
|
+
* unavailable, the staged bytes are DELETED and the node is reported
|
|
25
|
+
* `asset-skipped` — never "we already have the bytes", which is the
|
|
26
|
+
* natural and wrong recovery.
|
|
27
|
+
*
|
|
28
|
+
* @invariant DOWNLOADS STAGE OUTSIDE THE DESIGN ROOT. The caller's per-run
|
|
29
|
+
* staging directory lives under the OS temp root, never under
|
|
30
|
+
* `<designRoot>/_history/` — "gitignored" is not "not replicated",
|
|
31
|
+
* and `~/git/.stignore` excludes neither.
|
|
32
|
+
*
|
|
33
|
+
* @limitation ASSETS PROMOTE PER-ASSET, NOT ONCE AT THE END. `deps.promote`
|
|
34
|
+
* writes into `<designRoot>/assets/` as each download completes, so
|
|
35
|
+
* a failure on frame 7 of 60 leaves earlier assets committed while
|
|
36
|
+
* the report says the import failed. D5 asked for a single
|
|
37
|
+
* directory rename; this is N renames. Stated as a KNOWN GAP rather
|
|
38
|
+
* than described as the guarantee it is not (post-implementation
|
|
39
|
+
* review F6) — content-addressing makes the residue harmless-but-
|
|
40
|
+
* untidy (orphan assets, no wrong content), which is why it is a
|
|
41
|
+
* limitation and not a blocker.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
import { fetchImageUrls, MAX_IMAGE_BATCH } from './client.ts';
|
|
45
|
+
import type { Disposition, ImportReport } from './sanitize.ts';
|
|
46
|
+
|
|
47
|
+
/** Hosts the Figma image lane may reach. Exact-or-dotted-suffix, frozen. */
|
|
48
|
+
export const FIGMA_ASSET_HOSTS = Object.freeze([
|
|
49
|
+
'figma.com',
|
|
50
|
+
'figma-alpha-api.s3.us-west-2.amazonaws.com',
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* D5 — total DISTINCT assets per import (post-dedupe).
|
|
55
|
+
*
|
|
56
|
+
* Raised from 200 on measurement, not on request. A normal 6-page product file
|
|
57
|
+
* — the live StudyFi onboarding file — carries 984 vector clusters that dedupe
|
|
58
|
+
* to ~530 distinct component renders. At 200 it dropped a THIRD of the file's
|
|
59
|
+
* artwork, which is not a backstop against an engineered document, it is a
|
|
60
|
+
* refusal to import normal work.
|
|
61
|
+
*
|
|
62
|
+
* The bound that actually protects the tree is `MAX_ASSET_BYTES_PER_IMPORT`:
|
|
63
|
+
* 530 icons are a few MB, while one photo-heavy board is tens. A count cap is
|
|
64
|
+
* the wrong dimension for vector art and was calibrated before anyone had run
|
|
65
|
+
* the thing on a real file.
|
|
66
|
+
*/
|
|
67
|
+
export const MAX_ASSETS_PER_IMPORT = 1500;
|
|
68
|
+
/** D5 — cumulative bytes, the cap the per-item ones do not give you. */
|
|
69
|
+
export const MAX_ASSET_BYTES_PER_IMPORT = 64 * 1024 * 1024;
|
|
70
|
+
/**
|
|
71
|
+
* A UI vector export is kilobytes. The shared helper's 10 MB default was sized
|
|
72
|
+
* for a hero photograph; this lane pins far below it, which is also what closes
|
|
73
|
+
* the "200 assets × 10 MB = 2 GB into a replicated tree" shape.
|
|
74
|
+
*/
|
|
75
|
+
export const FIGMA_ASSET_MAX_BYTES = 2 * 1024 * 1024;
|
|
76
|
+
/** D11 — well below DDR-167's 5 MB, because these bytes are now REMOTE. */
|
|
77
|
+
export const FIGMA_SVG_MAX_BYTES = 1 * 1024 * 1024;
|
|
78
|
+
/** Politeness + bounded local work. */
|
|
79
|
+
export const MAX_CONCURRENT_DOWNLOADS = 4;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* SVGs handed to ONE `promoteSvgBatch` call.
|
|
83
|
+
*
|
|
84
|
+
* The DDR-167 execution canary launches a browser per call, which is why the
|
|
85
|
+
* batch exists at all — a per-file promote made a real icon set a ~40-minute
|
|
86
|
+
* import. But the batch runs under `agent-browser`'s fixed 20 s spawn budget
|
|
87
|
+
* (`_import-asset.mjs`), so an UNBOUNDED batch trades one pathology for another:
|
|
88
|
+
* at 272 frame renders the canary timed out and the caller's fail-closed rule
|
|
89
|
+
* discarded every asset in one go.
|
|
90
|
+
*
|
|
91
|
+
* 24 is deliberately well under where it was measured to break (60 timed out;
|
|
92
|
+
* the same run had previously survived 272 by luck of timing) — the cost of a
|
|
93
|
+
* chunk too small is a few extra browser launches, and the cost of a chunk too
|
|
94
|
+
* large is losing the chunk.
|
|
95
|
+
*/
|
|
96
|
+
export const MAX_SVG_PROMOTE_CHUNK = 24;
|
|
97
|
+
|
|
98
|
+
export interface AssetRequest {
|
|
99
|
+
nodeId: string;
|
|
100
|
+
format: 'svg' | 'png';
|
|
101
|
+
/** What the emitted source currently references. */
|
|
102
|
+
placeholder: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Per-call knobs for the render-first lane. Both default to the icon-export
|
|
107
|
+
* behaviour, so the existing fill/vector path is byte-identical without them.
|
|
108
|
+
*/
|
|
109
|
+
export interface ResolveOptions {
|
|
110
|
+
/**
|
|
111
|
+
* `false` keeps real `<text>` runs in a rendered SVG instead of converting
|
|
112
|
+
* them to outlines. Whole-frame renders want that — an artboard whose text is
|
|
113
|
+
* curves is a picture of a screen, not a screen you can search.
|
|
114
|
+
*/
|
|
115
|
+
outlineText?: boolean;
|
|
116
|
+
/**
|
|
117
|
+
* Overrides `FIGMA_SVG_MAX_BYTES`. A single icon is kilobytes, but a whole
|
|
118
|
+
* rendered frame carries its raster fills inline as data URIs, so the 1 MB
|
|
119
|
+
* icon ceiling would reject perfectly normal screens.
|
|
120
|
+
*/
|
|
121
|
+
svgMaxBytes?: number;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** D11 — the whole-frame ceiling. Still far under DDR-167's own 5 MB. */
|
|
125
|
+
export const FIGMA_RENDER_MAX_BYTES = 4 * 1024 * 1024;
|
|
126
|
+
|
|
127
|
+
export interface ResolvedAsset {
|
|
128
|
+
nodeId: string;
|
|
129
|
+
placeholder: string;
|
|
130
|
+
/** The canvas reference path, e.g. `/assets/<sha8>.png`. */
|
|
131
|
+
ref: string;
|
|
132
|
+
bytes: number;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export interface ResolveDeps {
|
|
136
|
+
/**
|
|
137
|
+
* Download to a staged path under the FULL gate. Injected so this module is
|
|
138
|
+
* testable without the network — the real implementation is
|
|
139
|
+
* `_fetch-asset.mjs`'s `fetchAsset({ rawOut })`.
|
|
140
|
+
*/
|
|
141
|
+
stage(url: string, outPath: string, maxBytes: number): Promise<{ bytes: number; ext: string }>;
|
|
142
|
+
/**
|
|
143
|
+
* Sanitize + promote a staged file into `assets/`, returning the canvas ref.
|
|
144
|
+
* The real implementation routes SVG through `_import-asset.mjs`'s DDR-167
|
|
145
|
+
* lane and rasters through the content-addressed write.
|
|
146
|
+
*/
|
|
147
|
+
promote(stagedPath: string, kind: 'svg' | 'png'): Promise<{ ref: string }>;
|
|
148
|
+
/**
|
|
149
|
+
* Optional: promote MANY svg files in one pass. The DDR-167 execution canary
|
|
150
|
+
* launches a browser per call, so a per-file promote made a real icon set a
|
|
151
|
+
* ~40-minute import. When present this is used for every staged SVG; when
|
|
152
|
+
* absent the per-file `promote` is, so a caller that has not been updated
|
|
153
|
+
* still works.
|
|
154
|
+
*/
|
|
155
|
+
promoteSvgBatch?(stagedPaths: readonly string[]): Promise<Array<string | null>>;
|
|
156
|
+
/** Where staged bytes live — OUTSIDE the design root (D5). */
|
|
157
|
+
stagingPath(nodeId: string, ext: string): string;
|
|
158
|
+
/** Drop a staged file on any failure path. */
|
|
159
|
+
discard(path: string): void;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The caps, as ONE mutable budget for a whole import.
|
|
164
|
+
*
|
|
165
|
+
* They were function-locals of `resolveAssets`, which reads correctly and is
|
|
166
|
+
* wrong: `importFrames` calls it once PER FRAME, so the ceiling reset every
|
|
167
|
+
* frame and 60 frames reconstructed exactly the multi-GB, Syncthing-replicated
|
|
168
|
+
* shape D5 says it closed — while spending a browser launch per SVG canary.
|
|
169
|
+
* A budget you can only bound by asking "who owns the counter?" is not a bound
|
|
170
|
+
* (post-implementation review F4).
|
|
171
|
+
*/
|
|
172
|
+
export interface AssetBudget {
|
|
173
|
+
count: number;
|
|
174
|
+
bytes: number;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function makeAssetBudget(): AssetBudget {
|
|
178
|
+
return { count: 0, bytes: 0 };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export interface ResolveResult {
|
|
182
|
+
resolved: ResolvedAsset[];
|
|
183
|
+
/** placeholder → ref, for rewriting the emitted source in one pass. */
|
|
184
|
+
rewrites: Map<string, string>;
|
|
185
|
+
totalBytes: number;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* The id to RENDER for a node — an instance renders as its component.
|
|
190
|
+
*
|
|
191
|
+
* Figma scopes a node inside a component instance as `I<instancePath>;<compId>`,
|
|
192
|
+
* so 40 placements of one icon are 40 distinct node ids that render to
|
|
193
|
+
* byte-identical SVG. Measured on a live StudyFi file: 984 vector clusters
|
|
194
|
+
* across 6 pages collapsed to a small fraction once keyed this way. Without it
|
|
195
|
+
* the 200-asset cap eats a real file's icon set alive, and every duplicate also
|
|
196
|
+
* costs a download AND a browser canary.
|
|
197
|
+
*
|
|
198
|
+
* Content-addressing already dedupes on DISK; this dedupes the WORK.
|
|
199
|
+
*/
|
|
200
|
+
export function renderKey(nodeId: string): string {
|
|
201
|
+
const semi = nodeId.lastIndexOf(';');
|
|
202
|
+
return semi > 0 ? nodeId.slice(semi + 1) : nodeId;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Split ids into `/v1/images`-sized batches. Never one call per node. */
|
|
206
|
+
export function batchIds(ids: readonly string[], size = MAX_IMAGE_BATCH): string[][] {
|
|
207
|
+
const out: string[][] = [];
|
|
208
|
+
for (let i = 0; i < ids.length; i += size) out.push([...ids.slice(i, i + size)]);
|
|
209
|
+
return out;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Run `tasks` with bounded concurrency, preserving input order in the result. */
|
|
213
|
+
async function pooled<T, R>(
|
|
214
|
+
items: readonly T[],
|
|
215
|
+
limit: number,
|
|
216
|
+
run: (item: T, index: number) => Promise<R>
|
|
217
|
+
): Promise<R[]> {
|
|
218
|
+
const out: R[] = new Array(items.length);
|
|
219
|
+
let cursor = 0;
|
|
220
|
+
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
221
|
+
while (true) {
|
|
222
|
+
const i = cursor++;
|
|
223
|
+
if (i >= items.length) return;
|
|
224
|
+
out[i] = await run(items[i], i);
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
await Promise.all(workers);
|
|
228
|
+
return out;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Resolve every pending asset for one import.
|
|
233
|
+
*
|
|
234
|
+
* Caps are enforced BEFORE work is done where possible (asset count) and
|
|
235
|
+
* during it where they cannot be (cumulative bytes) — and a cap trip is a
|
|
236
|
+
* REPORTED bounded degradation (`asset-cap-reached`), not a silent stop: the
|
|
237
|
+
* import continues without that asset and the summary names it.
|
|
238
|
+
*/
|
|
239
|
+
export async function resolveAssets(
|
|
240
|
+
fileKey: string,
|
|
241
|
+
requests: readonly AssetRequest[],
|
|
242
|
+
deps: ResolveDeps,
|
|
243
|
+
report: ImportReport,
|
|
244
|
+
budget: AssetBudget = makeAssetBudget(),
|
|
245
|
+
opts: ResolveOptions = {}
|
|
246
|
+
): Promise<ResolveResult> {
|
|
247
|
+
const rewrites = new Map<string, string>();
|
|
248
|
+
const resolved: ResolvedAsset[] = [];
|
|
249
|
+
let totalBytes = 0;
|
|
250
|
+
|
|
251
|
+
if (requests.length === 0) return { resolved, rewrites, totalBytes };
|
|
252
|
+
|
|
253
|
+
// Dedupe by RENDER KEY first — the cap should bound distinct artwork, not
|
|
254
|
+
// repeated placements of the same icon.
|
|
255
|
+
const byKey = new Map<string, AssetRequest[]>();
|
|
256
|
+
for (const r of requests) {
|
|
257
|
+
const key = `${renderKey(r.nodeId)}:${r.format}`;
|
|
258
|
+
const list = byKey.get(key);
|
|
259
|
+
if (list) list.push(r);
|
|
260
|
+
else byKey.set(key, [r]);
|
|
261
|
+
}
|
|
262
|
+
const unique = [...byKey.values()].map((group) => group[0]);
|
|
263
|
+
|
|
264
|
+
const room = Math.max(0, MAX_ASSETS_PER_IMPORT - budget.count);
|
|
265
|
+
const accepted = unique.slice(0, room);
|
|
266
|
+
budget.count += accepted.length;
|
|
267
|
+
for (const dropped of unique.slice(room)) {
|
|
268
|
+
report.add(dropped.nodeId, 'ASSET', 'asset-cap-reached', `>${MAX_ASSETS_PER_IMPORT}`);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// ── Batched URL resolution, per format ──
|
|
272
|
+
const urlByNode = new Map<string, string>();
|
|
273
|
+
/** Effective format per node — an SVG that fell back to raster is tracked here. */
|
|
274
|
+
const formatByNode = new Map<string, 'svg' | 'png'>();
|
|
275
|
+
for (const format of ['svg', 'png'] as const) {
|
|
276
|
+
const ids = accepted.filter((r) => r.format === format).map((r) => r.nodeId);
|
|
277
|
+
if (ids.length === 0) continue;
|
|
278
|
+
for (const batch of batchIds(ids)) {
|
|
279
|
+
const { images } = await fetchImageUrls(
|
|
280
|
+
fileKey,
|
|
281
|
+
batch,
|
|
282
|
+
format,
|
|
283
|
+
2,
|
|
284
|
+
opts.outlineText !== undefined ? { outlineText: opts.outlineText } : {}
|
|
285
|
+
);
|
|
286
|
+
for (const [nodeId, url] of Object.entries(images)) {
|
|
287
|
+
if (typeof url === 'string' && url.length > 0) {
|
|
288
|
+
urlByNode.set(nodeId, url);
|
|
289
|
+
formatByNode.set(nodeId, format);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// RASTER FALLBACK. Figma answers `null` — not an error — for nodes it will
|
|
296
|
+
// not vectorize, and a null with no retry is an artboard whose <img> points
|
|
297
|
+
// at a placeholder that never resolves: a broken image where a screen should
|
|
298
|
+
// be. Ask for the same node as PNG before giving up on it.
|
|
299
|
+
const missing = accepted
|
|
300
|
+
.filter((r) => r.format === 'svg' && !urlByNode.has(r.nodeId))
|
|
301
|
+
.map((r) => r.nodeId);
|
|
302
|
+
for (const batch of batchIds(missing)) {
|
|
303
|
+
const { images } = await fetchImageUrls(fileKey, batch, 'png', 2);
|
|
304
|
+
for (const [nodeId, url] of Object.entries(images)) {
|
|
305
|
+
if (typeof url === 'string' && url.length > 0) {
|
|
306
|
+
urlByNode.set(nodeId, url);
|
|
307
|
+
formatByNode.set(nodeId, 'png');
|
|
308
|
+
report.add(nodeId, 'ASSET', 'asset-degraded', 'vector unavailable — rasterized');
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// ── Bounded-concurrency download; SVG promotes are batched afterwards ──
|
|
314
|
+
const stagedSvgs: Array<{ req: AssetRequest; staged: string }> = [];
|
|
315
|
+
|
|
316
|
+
await pooled(accepted, MAX_CONCURRENT_DOWNLOADS, async (req) => {
|
|
317
|
+
const url = urlByNode.get(req.nodeId);
|
|
318
|
+
if (!url) {
|
|
319
|
+
report.add(req.nodeId, 'ASSET', 'asset-skipped', 'figma declined to render');
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
if (budget.bytes >= MAX_ASSET_BYTES_PER_IMPORT) {
|
|
323
|
+
report.add(req.nodeId, 'ASSET', 'asset-cap-reached', 'total bytes');
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// What we ACTUALLY got, which is not always what we asked for — see the
|
|
328
|
+
// raster fallback above.
|
|
329
|
+
const eff = formatByNode.get(req.nodeId) ?? req.format;
|
|
330
|
+
const staged = deps.stagingPath(req.nodeId, eff);
|
|
331
|
+
try {
|
|
332
|
+
const cap = eff === 'svg' ? (opts.svgMaxBytes ?? FIGMA_SVG_MAX_BYTES) : FIGMA_ASSET_MAX_BYTES;
|
|
333
|
+
const { bytes, ext } = await deps.stage(url, staged, cap);
|
|
334
|
+
// Counted HERE, not after a successful promote: bytes that crossed the
|
|
335
|
+
// network and landed on disk cost the same whether the promote succeeded.
|
|
336
|
+
budget.bytes += bytes;
|
|
337
|
+
// The staged kind must agree with what we asked Figma to render. A
|
|
338
|
+
// mismatch means the response is not what the request implied — refuse
|
|
339
|
+
// rather than promote something into a versioned, peer-synced tree.
|
|
340
|
+
const kindOk = eff === 'svg' ? ext === 'svg' : ext !== 'svg';
|
|
341
|
+
if (!kindOk) {
|
|
342
|
+
deps.discard(staged);
|
|
343
|
+
report.add(req.nodeId, 'ASSET', 'asset-skipped', 'format mismatch');
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
if (eff === 'svg' && deps.promoteSvgBatch) {
|
|
347
|
+
// Defer — one browser session for all of them beats one each.
|
|
348
|
+
stagedSvgs.push({ req, staged });
|
|
349
|
+
totalBytes += bytes;
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
const { ref } = await deps.promote(staged, eff);
|
|
353
|
+
totalBytes += bytes;
|
|
354
|
+
resolved.push({ nodeId: req.nodeId, placeholder: req.placeholder, ref, bytes });
|
|
355
|
+
// Every placement that shares this render key gets the same asset.
|
|
356
|
+
for (const sibling of byKey.get(`${renderKey(req.nodeId)}:${req.format}`) ?? [req]) {
|
|
357
|
+
rewrites.set(sibling.placeholder, ref);
|
|
358
|
+
}
|
|
359
|
+
} catch {
|
|
360
|
+
// FAIL CLOSED — including when the bun-side sanitizer is simply not
|
|
361
|
+
// available in a packaged app (DDR-177). Delete the staged bytes and
|
|
362
|
+
// report; never keep them and never reference them.
|
|
363
|
+
deps.discard(staged);
|
|
364
|
+
report.add(req.nodeId, 'ASSET', 'asset-skipped', 'download or sanitize failed');
|
|
365
|
+
}
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
if (stagedSvgs.length > 0 && deps.promoteSvgBatch) {
|
|
369
|
+
const refs: Array<string | null> = [];
|
|
370
|
+
// CHUNKED, because "fail closed for the whole batch" and "one batch for the
|
|
371
|
+
// whole import" together turn a single browser timeout into total asset
|
|
372
|
+
// loss. Measured on a live 6-page import: the DDR-167 execution canary
|
|
373
|
+
// spawns `agent-browser` with a fixed 20 s budget (`_import-asset.mjs`), one
|
|
374
|
+
// session for the whole array — at 272 frame renders it blew that budget,
|
|
375
|
+
// the promote threw, and every one of the 272 became `asset-skipped`. The
|
|
376
|
+
// import still exited 0, so it reported success while producing a folder of
|
|
377
|
+
// broken images. Reproduced at 60 SVGs, and it reproduces WITHOUT any of the
|
|
378
|
+
// Figma-lane changes, so this is the shared lane's shape, not the caller's.
|
|
379
|
+
//
|
|
380
|
+
// Fail-closed is KEPT — it is the DDR-177 rule and it is right. What changes
|
|
381
|
+
// is the blast radius: a timeout now costs its own chunk, and the rest of
|
|
382
|
+
// the artwork still lands.
|
|
383
|
+
for (let start = 0; start < stagedSvgs.length; start += MAX_SVG_PROMOTE_CHUNK) {
|
|
384
|
+
const chunk = stagedSvgs.slice(start, start + MAX_SVG_PROMOTE_CHUNK);
|
|
385
|
+
let got: Array<string | null> | null = null;
|
|
386
|
+
// ONE RETRY, because the canary's failure is measurably TRANSIENT rather
|
|
387
|
+
// than a property of the input. Measured on a cleaned machine: the same
|
|
388
|
+
// lane promoted 4/4 (30.7 s) and 24/24 (42.3 s) but threw on 12 (24.3 s) —
|
|
389
|
+
// the budget is per `agent-browser` invocation, and a big frame render
|
|
390
|
+
// flirts with it. A chunk lost to a coin-flip is a permanently broken
|
|
391
|
+
// image in a versioned artifact, and a second attempt is one browser
|
|
392
|
+
// launch. Bounded at one: a lane that is genuinely down must still fail
|
|
393
|
+
// fast rather than retry 12 chunks into a multi-minute stall.
|
|
394
|
+
for (let attempt = 0; attempt < 2 && got === null; attempt += 1) {
|
|
395
|
+
try {
|
|
396
|
+
got = await deps.promoteSvgBatch(chunk.map((x) => x.staged));
|
|
397
|
+
} catch {
|
|
398
|
+
got = null;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
if (got === null) {
|
|
402
|
+
// FAIL CLOSED, same rule as the per-file path: the bun-side lane being
|
|
403
|
+
// unavailable must never become "we already have the bytes"
|
|
404
|
+
// (DDR-177's packaged-app failure mode).
|
|
405
|
+
for (let i = 0; i < chunk.length; i += 1) refs.push(null);
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
// A short return would silently shift every later ref onto the wrong
|
|
409
|
+
// node — pad rather than trust the length.
|
|
410
|
+
for (let i = 0; i < chunk.length; i += 1) refs.push(got[i] ?? null);
|
|
411
|
+
}
|
|
412
|
+
for (let i = 0; i < stagedSvgs.length; i += 1) {
|
|
413
|
+
const { req, staged } = stagedSvgs[i];
|
|
414
|
+
const ref = refs[i];
|
|
415
|
+
if (!ref) {
|
|
416
|
+
deps.discard(staged);
|
|
417
|
+
report.add(req.nodeId, 'ASSET', 'asset-skipped', 'sanitize refused');
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
resolved.push({ nodeId: req.nodeId, placeholder: req.placeholder, ref, bytes: 0 });
|
|
421
|
+
for (const sibling of byKey.get(`${renderKey(req.nodeId)}:svg`) ?? [req]) {
|
|
422
|
+
rewrites.set(sibling.placeholder, ref);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
return { resolved, rewrites, totalBytes };
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Rewrite placeholders in emitted source. A placeholder that never resolved is
|
|
432
|
+
* left in place deliberately — the canvas shows a visibly broken image, which
|
|
433
|
+
* is a far better failure than a silently-missing element, and the summary
|
|
434
|
+
* already names the node.
|
|
435
|
+
*/
|
|
436
|
+
export function applyRewrites(source: string, rewrites: ReadonlyMap<string, string>): string {
|
|
437
|
+
let out = source;
|
|
438
|
+
for (const [placeholder, ref] of rewrites) {
|
|
439
|
+
out = out.split(placeholder).join(ref);
|
|
440
|
+
}
|
|
441
|
+
return out;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/** The disposition set this module can emit — kept in sync with `sanitize.ts`.
|
|
445
|
+
* `asset-degraded` was missing here as well as from the union; both halves of
|
|
446
|
+
* the drift are closed together (DDR-219 D9). */
|
|
447
|
+
export const ASSET_DISPOSITIONS: readonly Disposition[] = [
|
|
448
|
+
'asset-pending',
|
|
449
|
+
'asset-skipped',
|
|
450
|
+
'asset-cap-reached',
|
|
451
|
+
'asset-degraded',
|
|
452
|
+
];
|