@1agh/maude 0.58.2 → 0.58.3
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/api.ts +6 -1
- package/apps/studio/bin/_fetch-asset.mjs +169 -5
- package/apps/studio/bin/_import-asset.mjs +72 -0
- package/apps/studio/bin/_import-figma.mjs +1121 -0
- package/apps/studio/bin/_video-playwright.mjs +86 -3
- package/apps/studio/bin/import-figma.sh +38 -0
- package/apps/studio/bin/read-annotations.mjs +11 -1
- package/apps/studio/bun.lock +16 -22
- package/apps/studio/canvas-edit.ts +29 -5
- package/apps/studio/client/app.jsx +44 -1
- 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/TimelinePanel.jsx +2 -2
- package/apps/studio/client/panels/timeline-parse.js +3 -3
- package/apps/studio/client/styles/3-shell-maude.css +7 -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 +1261 -1261
- 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 +27 -1
- package/apps/studio/exporters/video-render-lib.ts +6 -0
- package/apps/studio/exporters/video.ts +62 -1
- package/apps/studio/figma/assets.test.ts +372 -0
- package/apps/studio/figma/assets.ts +398 -0
- package/apps/studio/figma/client.test.ts +395 -0
- package/apps/studio/figma/client.ts +513 -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 +200 -0
- package/apps/studio/figma/sanitize.test.ts +256 -0
- package/apps/studio/figma/sanitize.ts +315 -0
- package/apps/studio/figma/style-map.ts +352 -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 +306 -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 +539 -0
- package/apps/studio/figma/url.test.ts +167 -0
- package/apps/studio/figma/url.ts +160 -0
- package/apps/studio/http.ts +129 -0
- package/apps/studio/sync/asset-push.ts +124 -0
- package/apps/studio/sync/connection-state.ts +11 -0
- package/apps/studio/sync/hub-link.ts +63 -7
- package/apps/studio/sync/hubs-config.ts +31 -3
- package/apps/studio/sync/index.ts +276 -26
- package/apps/studio/sync/migrate-flat-fallback.ts +121 -0
- package/apps/studio/sync/presentation.ts +45 -1
- 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 +13 -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-provenance.test.ts +108 -0
- package/apps/studio/test/figma-routes.test.ts +294 -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 +479 -0
- package/apps/studio/test/sync-asset-push.test.ts +124 -0
- package/apps/studio/test/sync-connection-state.test.ts +13 -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-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/video-comp.test.ts +23 -1
- package/apps/studio/test/workspace-containment.test.ts +1 -0
- package/apps/studio/video-comp.tsx +70 -6
- package/apps/studio/whats-new.json +27 -0
- package/apps/studio/workspace-mode.ts +4 -0
- package/cli/commands/design.mjs +8 -0
- package/package.json +8 -8
- package/plugins/flow/.claude-plugin/config.schema.json +3 -3
|
@@ -0,0 +1,398 @@
|
|
|
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
|
+
export interface AssetRequest {
|
|
82
|
+
nodeId: string;
|
|
83
|
+
format: 'svg' | 'png';
|
|
84
|
+
/** What the emitted source currently references. */
|
|
85
|
+
placeholder: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Per-call knobs for the render-first lane. Both default to the icon-export
|
|
90
|
+
* behaviour, so the existing fill/vector path is byte-identical without them.
|
|
91
|
+
*/
|
|
92
|
+
export interface ResolveOptions {
|
|
93
|
+
/**
|
|
94
|
+
* `false` keeps real `<text>` runs in a rendered SVG instead of converting
|
|
95
|
+
* them to outlines. Whole-frame renders want that — an artboard whose text is
|
|
96
|
+
* curves is a picture of a screen, not a screen you can search.
|
|
97
|
+
*/
|
|
98
|
+
outlineText?: boolean;
|
|
99
|
+
/**
|
|
100
|
+
* Overrides `FIGMA_SVG_MAX_BYTES`. A single icon is kilobytes, but a whole
|
|
101
|
+
* rendered frame carries its raster fills inline as data URIs, so the 1 MB
|
|
102
|
+
* icon ceiling would reject perfectly normal screens.
|
|
103
|
+
*/
|
|
104
|
+
svgMaxBytes?: number;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** D11 — the whole-frame ceiling. Still far under DDR-167's own 5 MB. */
|
|
108
|
+
export const FIGMA_RENDER_MAX_BYTES = 4 * 1024 * 1024;
|
|
109
|
+
|
|
110
|
+
export interface ResolvedAsset {
|
|
111
|
+
nodeId: string;
|
|
112
|
+
placeholder: string;
|
|
113
|
+
/** The canvas reference path, e.g. `/assets/<sha8>.png`. */
|
|
114
|
+
ref: string;
|
|
115
|
+
bytes: number;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export interface ResolveDeps {
|
|
119
|
+
/**
|
|
120
|
+
* Download to a staged path under the FULL gate. Injected so this module is
|
|
121
|
+
* testable without the network — the real implementation is
|
|
122
|
+
* `_fetch-asset.mjs`'s `fetchAsset({ rawOut })`.
|
|
123
|
+
*/
|
|
124
|
+
stage(url: string, outPath: string, maxBytes: number): Promise<{ bytes: number; ext: string }>;
|
|
125
|
+
/**
|
|
126
|
+
* Sanitize + promote a staged file into `assets/`, returning the canvas ref.
|
|
127
|
+
* The real implementation routes SVG through `_import-asset.mjs`'s DDR-167
|
|
128
|
+
* lane and rasters through the content-addressed write.
|
|
129
|
+
*/
|
|
130
|
+
promote(stagedPath: string, kind: 'svg' | 'png'): Promise<{ ref: string }>;
|
|
131
|
+
/**
|
|
132
|
+
* Optional: promote MANY svg files in one pass. The DDR-167 execution canary
|
|
133
|
+
* launches a browser per call, so a per-file promote made a real icon set a
|
|
134
|
+
* ~40-minute import. When present this is used for every staged SVG; when
|
|
135
|
+
* absent the per-file `promote` is, so a caller that has not been updated
|
|
136
|
+
* still works.
|
|
137
|
+
*/
|
|
138
|
+
promoteSvgBatch?(stagedPaths: readonly string[]): Promise<Array<string | null>>;
|
|
139
|
+
/** Where staged bytes live — OUTSIDE the design root (D5). */
|
|
140
|
+
stagingPath(nodeId: string, ext: string): string;
|
|
141
|
+
/** Drop a staged file on any failure path. */
|
|
142
|
+
discard(path: string): void;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The caps, as ONE mutable budget for a whole import.
|
|
147
|
+
*
|
|
148
|
+
* They were function-locals of `resolveAssets`, which reads correctly and is
|
|
149
|
+
* wrong: `importFrames` calls it once PER FRAME, so the ceiling reset every
|
|
150
|
+
* frame and 60 frames reconstructed exactly the multi-GB, Syncthing-replicated
|
|
151
|
+
* shape D5 says it closed — while spending a browser launch per SVG canary.
|
|
152
|
+
* A budget you can only bound by asking "who owns the counter?" is not a bound
|
|
153
|
+
* (post-implementation review F4).
|
|
154
|
+
*/
|
|
155
|
+
export interface AssetBudget {
|
|
156
|
+
count: number;
|
|
157
|
+
bytes: number;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function makeAssetBudget(): AssetBudget {
|
|
161
|
+
return { count: 0, bytes: 0 };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export interface ResolveResult {
|
|
165
|
+
resolved: ResolvedAsset[];
|
|
166
|
+
/** placeholder → ref, for rewriting the emitted source in one pass. */
|
|
167
|
+
rewrites: Map<string, string>;
|
|
168
|
+
totalBytes: number;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* The id to RENDER for a node — an instance renders as its component.
|
|
173
|
+
*
|
|
174
|
+
* Figma scopes a node inside a component instance as `I<instancePath>;<compId>`,
|
|
175
|
+
* so 40 placements of one icon are 40 distinct node ids that render to
|
|
176
|
+
* byte-identical SVG. Measured on a live StudyFi file: 984 vector clusters
|
|
177
|
+
* across 6 pages collapsed to a small fraction once keyed this way. Without it
|
|
178
|
+
* the 200-asset cap eats a real file's icon set alive, and every duplicate also
|
|
179
|
+
* costs a download AND a browser canary.
|
|
180
|
+
*
|
|
181
|
+
* Content-addressing already dedupes on DISK; this dedupes the WORK.
|
|
182
|
+
*/
|
|
183
|
+
export function renderKey(nodeId: string): string {
|
|
184
|
+
const semi = nodeId.lastIndexOf(';');
|
|
185
|
+
return semi > 0 ? nodeId.slice(semi + 1) : nodeId;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Split ids into `/v1/images`-sized batches. Never one call per node. */
|
|
189
|
+
export function batchIds(ids: readonly string[], size = MAX_IMAGE_BATCH): string[][] {
|
|
190
|
+
const out: string[][] = [];
|
|
191
|
+
for (let i = 0; i < ids.length; i += size) out.push([...ids.slice(i, i + size)]);
|
|
192
|
+
return out;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Run `tasks` with bounded concurrency, preserving input order in the result. */
|
|
196
|
+
async function pooled<T, R>(
|
|
197
|
+
items: readonly T[],
|
|
198
|
+
limit: number,
|
|
199
|
+
run: (item: T, index: number) => Promise<R>
|
|
200
|
+
): Promise<R[]> {
|
|
201
|
+
const out: R[] = new Array(items.length);
|
|
202
|
+
let cursor = 0;
|
|
203
|
+
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
204
|
+
while (true) {
|
|
205
|
+
const i = cursor++;
|
|
206
|
+
if (i >= items.length) return;
|
|
207
|
+
out[i] = await run(items[i], i);
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
await Promise.all(workers);
|
|
211
|
+
return out;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Resolve every pending asset for one import.
|
|
216
|
+
*
|
|
217
|
+
* Caps are enforced BEFORE work is done where possible (asset count) and
|
|
218
|
+
* during it where they cannot be (cumulative bytes) — and a cap trip is a
|
|
219
|
+
* REPORTED bounded degradation (`asset-cap-reached`), not a silent stop: the
|
|
220
|
+
* import continues without that asset and the summary names it.
|
|
221
|
+
*/
|
|
222
|
+
export async function resolveAssets(
|
|
223
|
+
fileKey: string,
|
|
224
|
+
requests: readonly AssetRequest[],
|
|
225
|
+
deps: ResolveDeps,
|
|
226
|
+
report: ImportReport,
|
|
227
|
+
budget: AssetBudget = makeAssetBudget(),
|
|
228
|
+
opts: ResolveOptions = {}
|
|
229
|
+
): Promise<ResolveResult> {
|
|
230
|
+
const rewrites = new Map<string, string>();
|
|
231
|
+
const resolved: ResolvedAsset[] = [];
|
|
232
|
+
let totalBytes = 0;
|
|
233
|
+
|
|
234
|
+
if (requests.length === 0) return { resolved, rewrites, totalBytes };
|
|
235
|
+
|
|
236
|
+
// Dedupe by RENDER KEY first — the cap should bound distinct artwork, not
|
|
237
|
+
// repeated placements of the same icon.
|
|
238
|
+
const byKey = new Map<string, AssetRequest[]>();
|
|
239
|
+
for (const r of requests) {
|
|
240
|
+
const key = `${renderKey(r.nodeId)}:${r.format}`;
|
|
241
|
+
const list = byKey.get(key);
|
|
242
|
+
if (list) list.push(r);
|
|
243
|
+
else byKey.set(key, [r]);
|
|
244
|
+
}
|
|
245
|
+
const unique = [...byKey.values()].map((group) => group[0]);
|
|
246
|
+
|
|
247
|
+
const room = Math.max(0, MAX_ASSETS_PER_IMPORT - budget.count);
|
|
248
|
+
const accepted = unique.slice(0, room);
|
|
249
|
+
budget.count += accepted.length;
|
|
250
|
+
for (const dropped of unique.slice(room)) {
|
|
251
|
+
report.add(dropped.nodeId, 'ASSET', 'asset-cap-reached', `>${MAX_ASSETS_PER_IMPORT}`);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ── Batched URL resolution, per format ──
|
|
255
|
+
const urlByNode = new Map<string, string>();
|
|
256
|
+
/** Effective format per node — an SVG that fell back to raster is tracked here. */
|
|
257
|
+
const formatByNode = new Map<string, 'svg' | 'png'>();
|
|
258
|
+
for (const format of ['svg', 'png'] as const) {
|
|
259
|
+
const ids = accepted.filter((r) => r.format === format).map((r) => r.nodeId);
|
|
260
|
+
if (ids.length === 0) continue;
|
|
261
|
+
for (const batch of batchIds(ids)) {
|
|
262
|
+
const { images } = await fetchImageUrls(
|
|
263
|
+
fileKey,
|
|
264
|
+
batch,
|
|
265
|
+
format,
|
|
266
|
+
2,
|
|
267
|
+
opts.outlineText !== undefined ? { outlineText: opts.outlineText } : {}
|
|
268
|
+
);
|
|
269
|
+
for (const [nodeId, url] of Object.entries(images)) {
|
|
270
|
+
if (typeof url === 'string' && url.length > 0) {
|
|
271
|
+
urlByNode.set(nodeId, url);
|
|
272
|
+
formatByNode.set(nodeId, format);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// RASTER FALLBACK. Figma answers `null` — not an error — for nodes it will
|
|
279
|
+
// not vectorize, and a null with no retry is an artboard whose <img> points
|
|
280
|
+
// at a placeholder that never resolves: a broken image where a screen should
|
|
281
|
+
// be. Ask for the same node as PNG before giving up on it.
|
|
282
|
+
const missing = accepted
|
|
283
|
+
.filter((r) => r.format === 'svg' && !urlByNode.has(r.nodeId))
|
|
284
|
+
.map((r) => r.nodeId);
|
|
285
|
+
for (const batch of batchIds(missing)) {
|
|
286
|
+
const { images } = await fetchImageUrls(fileKey, batch, 'png', 2);
|
|
287
|
+
for (const [nodeId, url] of Object.entries(images)) {
|
|
288
|
+
if (typeof url === 'string' && url.length > 0) {
|
|
289
|
+
urlByNode.set(nodeId, url);
|
|
290
|
+
formatByNode.set(nodeId, 'png');
|
|
291
|
+
report.add(nodeId, 'ASSET', 'asset-degraded', 'vector unavailable — rasterized');
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// ── Bounded-concurrency download; SVG promotes are batched afterwards ──
|
|
297
|
+
const stagedSvgs: Array<{ req: AssetRequest; staged: string }> = [];
|
|
298
|
+
|
|
299
|
+
await pooled(accepted, MAX_CONCURRENT_DOWNLOADS, async (req) => {
|
|
300
|
+
const url = urlByNode.get(req.nodeId);
|
|
301
|
+
if (!url) {
|
|
302
|
+
report.add(req.nodeId, 'ASSET', 'asset-skipped', 'figma declined to render');
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
if (budget.bytes >= MAX_ASSET_BYTES_PER_IMPORT) {
|
|
306
|
+
report.add(req.nodeId, 'ASSET', 'asset-cap-reached', 'total bytes');
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// What we ACTUALLY got, which is not always what we asked for — see the
|
|
311
|
+
// raster fallback above.
|
|
312
|
+
const eff = formatByNode.get(req.nodeId) ?? req.format;
|
|
313
|
+
const staged = deps.stagingPath(req.nodeId, eff);
|
|
314
|
+
try {
|
|
315
|
+
const cap = eff === 'svg' ? (opts.svgMaxBytes ?? FIGMA_SVG_MAX_BYTES) : FIGMA_ASSET_MAX_BYTES;
|
|
316
|
+
const { bytes, ext } = await deps.stage(url, staged, cap);
|
|
317
|
+
// Counted HERE, not after a successful promote: bytes that crossed the
|
|
318
|
+
// network and landed on disk cost the same whether the promote succeeded.
|
|
319
|
+
budget.bytes += bytes;
|
|
320
|
+
// The staged kind must agree with what we asked Figma to render. A
|
|
321
|
+
// mismatch means the response is not what the request implied — refuse
|
|
322
|
+
// rather than promote something into a versioned, peer-synced tree.
|
|
323
|
+
const kindOk = eff === 'svg' ? ext === 'svg' : ext !== 'svg';
|
|
324
|
+
if (!kindOk) {
|
|
325
|
+
deps.discard(staged);
|
|
326
|
+
report.add(req.nodeId, 'ASSET', 'asset-skipped', 'format mismatch');
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
if (eff === 'svg' && deps.promoteSvgBatch) {
|
|
330
|
+
// Defer — one browser session for all of them beats one each.
|
|
331
|
+
stagedSvgs.push({ req, staged });
|
|
332
|
+
totalBytes += bytes;
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
const { ref } = await deps.promote(staged, eff);
|
|
336
|
+
totalBytes += bytes;
|
|
337
|
+
resolved.push({ nodeId: req.nodeId, placeholder: req.placeholder, ref, bytes });
|
|
338
|
+
// Every placement that shares this render key gets the same asset.
|
|
339
|
+
for (const sibling of byKey.get(`${renderKey(req.nodeId)}:${req.format}`) ?? [req]) {
|
|
340
|
+
rewrites.set(sibling.placeholder, ref);
|
|
341
|
+
}
|
|
342
|
+
} catch {
|
|
343
|
+
// FAIL CLOSED — including when the bun-side sanitizer is simply not
|
|
344
|
+
// available in a packaged app (DDR-177). Delete the staged bytes and
|
|
345
|
+
// report; never keep them and never reference them.
|
|
346
|
+
deps.discard(staged);
|
|
347
|
+
report.add(req.nodeId, 'ASSET', 'asset-skipped', 'download or sanitize failed');
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
if (stagedSvgs.length > 0 && deps.promoteSvgBatch) {
|
|
352
|
+
let refs: Array<string | null>;
|
|
353
|
+
try {
|
|
354
|
+
refs = await deps.promoteSvgBatch(stagedSvgs.map((x) => x.staged));
|
|
355
|
+
} catch {
|
|
356
|
+
// FAIL CLOSED for the whole batch, same rule as the per-file path: the
|
|
357
|
+
// bun-side lane being unavailable must never become "we already have the
|
|
358
|
+
// bytes" (DDR-177's packaged-app failure mode).
|
|
359
|
+
refs = stagedSvgs.map(() => null);
|
|
360
|
+
}
|
|
361
|
+
for (let i = 0; i < stagedSvgs.length; i += 1) {
|
|
362
|
+
const { req, staged } = stagedSvgs[i];
|
|
363
|
+
const ref = refs[i];
|
|
364
|
+
if (!ref) {
|
|
365
|
+
deps.discard(staged);
|
|
366
|
+
report.add(req.nodeId, 'ASSET', 'asset-skipped', 'sanitize refused');
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
resolved.push({ nodeId: req.nodeId, placeholder: req.placeholder, ref, bytes: 0 });
|
|
370
|
+
for (const sibling of byKey.get(`${renderKey(req.nodeId)}:svg`) ?? [req]) {
|
|
371
|
+
rewrites.set(sibling.placeholder, ref);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
return { resolved, rewrites, totalBytes };
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Rewrite placeholders in emitted source. A placeholder that never resolved is
|
|
381
|
+
* left in place deliberately — the canvas shows a visibly broken image, which
|
|
382
|
+
* is a far better failure than a silently-missing element, and the summary
|
|
383
|
+
* already names the node.
|
|
384
|
+
*/
|
|
385
|
+
export function applyRewrites(source: string, rewrites: ReadonlyMap<string, string>): string {
|
|
386
|
+
let out = source;
|
|
387
|
+
for (const [placeholder, ref] of rewrites) {
|
|
388
|
+
out = out.split(placeholder).join(ref);
|
|
389
|
+
}
|
|
390
|
+
return out;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** The disposition set this module can emit — kept in sync with `sanitize.ts`. */
|
|
394
|
+
export const ASSET_DISPOSITIONS: readonly Disposition[] = [
|
|
395
|
+
'asset-pending',
|
|
396
|
+
'asset-skipped',
|
|
397
|
+
'asset-cap-reached',
|
|
398
|
+
];
|