@buoy-gg/assets 7.0.28 → 7.0.30

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.
Files changed (41) hide show
  1. package/lib/commonjs/components/AssetRow.js +13 -4
  2. package/lib/commonjs/components/AssetRow.js.map +1 -1
  3. package/lib/commonjs/components/AssetsModal.js +2 -1
  4. package/lib/commonjs/components/AssetsModal.js.map +1 -1
  5. package/lib/commonjs/utils/testIDs.js +114 -0
  6. package/lib/commonjs/utils/testIDs.js.map +1 -0
  7. package/lib/module/components/AssetRow.js +13 -4
  8. package/lib/module/components/AssetRow.js.map +1 -1
  9. package/lib/module/components/AssetsModal.js +2 -1
  10. package/lib/module/components/AssetsModal.js.map +1 -1
  11. package/lib/module/utils/testIDs.js +103 -0
  12. package/lib/module/utils/testIDs.js.map +1 -0
  13. package/lib/typescript/components/AssetRow.d.ts.map +1 -1
  14. package/lib/typescript/utils/testIDs.d.ts +75 -0
  15. package/lib/typescript/utils/testIDs.d.ts.map +1 -0
  16. package/package.json +5 -6
  17. package/src/capture/expo.ts +0 -45
  18. package/src/capture/install.ts +0 -73
  19. package/src/capture/measure.ts +0 -167
  20. package/src/capture/metroGraph.ts +0 -146
  21. package/src/capture/registry.ts +0 -137
  22. package/src/components/AssetDetail.tsx +0 -207
  23. package/src/components/AssetRow.tsx +0 -243
  24. package/src/components/AssetsFilterBar.tsx +0 -99
  25. package/src/components/AssetsHeader.tsx +0 -75
  26. package/src/components/AssetsIcon.tsx +0 -9
  27. package/src/components/AssetsListControls.tsx +0 -101
  28. package/src/components/AssetsModal.tsx +0 -370
  29. package/src/components/BaselineBar.tsx +0 -98
  30. package/src/export.ts +0 -110
  31. package/src/format.ts +0 -53
  32. package/src/hooks/useAssetRecords.ts +0 -26
  33. package/src/index.tsx +0 -87
  34. package/src/preset.tsx +0 -35
  35. package/src/remote.ts +0 -65
  36. package/src/state.ts +0 -84
  37. package/src/store/assetStore.ts +0 -209
  38. package/src/store/baseline.ts +0 -152
  39. package/src/store/insights.ts +0 -145
  40. package/src/sync/assetsSyncAdapter.ts +0 -151
  41. package/src/types.ts +0 -160
package/src/export.ts DELETED
@@ -1,110 +0,0 @@
1
- /**
2
- * Markdown reports for the shared ToolbarCopyButton — the paste-into-your-
3
- * issue-tracker view of the inventory.
4
- */
5
-
6
- import { formatBytes, formatDimensions, formatScales } from "./format";
7
- import { estDecodedBytes } from "./store/assetStore";
8
- import type {
9
- AssetRecord,
10
- AssetsDiff,
11
- AssetsInsights,
12
- AssetsScanStatus,
13
- AssetsStats,
14
- } from "./types";
15
-
16
- export function formatAssetMarkdown(record: AssetRecord): string {
17
- const lines = [
18
- `# Asset: ${record.name}.${record.type}`,
19
- "",
20
- `- Kind: ${record.kind}`,
21
- `- Status: ${record.loaded ? "loaded at runtime" : "in bundle, never loaded"}`,
22
- ];
23
- if (record.kind === "image") lines.push(`- Dimensions: ${formatDimensions(record)} (logical)`);
24
- if (record.scales.length) lines.push(`- Scales: ${formatScales(record.scales)}`);
25
- if (record.sizeBytes != null && record.sizeSource === "measured") {
26
- lines.push(`- Size (all variants): ${formatBytes(record.sizeBytes)}`);
27
- }
28
- const estimate = estDecodedBytes(record);
29
- if (estimate > 0) lines.push(`- Decoded memory: ~${formatBytes(estimate)}`);
30
- if (record.hash) lines.push(`- Hash: ${record.hash}`);
31
- if (record.httpServerLocation) lines.push(`- Location: ${record.httpServerLocation}`);
32
- if (record.fileSystemLocation) lines.push(`- Source dir: ${record.fileSystemLocation}`);
33
- if (record.resolvedUri) lines.push(`- Resolved URI: ${record.resolvedUri}`);
34
- return lines.join("\n");
35
- }
36
-
37
- export function formatInventoryMarkdown(
38
- records: AssetRecord[],
39
- stats: AssetsStats,
40
- insights: AssetsInsights,
41
- scanStatus: AssetsScanStatus,
42
- diff?: AssetsDiff | null
43
- ): string {
44
- const lines = [
45
- "# Asset inventory",
46
- "",
47
- `${stats.total} assets · ${stats.loaded} loaded · ${stats.neverLoaded} never loaded` +
48
- (stats.totalBytes > 0
49
- ? ` · ${formatBytes(stats.totalBytes)} measured (${stats.measuredCount}/${stats.total})`
50
- : ""),
51
- ];
52
- if (scanStatus.fontFamilies.length) {
53
- lines.push(`Fonts loaded: ${scanStatus.fontFamilies.join(", ")}`);
54
- }
55
- if (!scanStatus.graphSupported) {
56
- lines.push(
57
- scanStatus.graphError
58
- ? `_Metro bundle graph unavailable (${scanStatus.graphError}) — runtime-registered assets only._`
59
- : "_No Metro bundle graph — runtime-registered assets only (release build)._"
60
- );
61
- }
62
-
63
- if (diff) {
64
- const net =
65
- diff.netBytes != null
66
- ? `${diff.netBytes >= 0 ? "+" : "−"}${formatBytes(Math.abs(diff.netBytes))} net`
67
- : "net size unknown";
68
- lines.push(
69
- "",
70
- `## Since baseline (${new Date(diff.baselineSavedAt).toISOString()})`,
71
- `- ${diff.added.length} added · ${diff.removed.length} removed · ${diff.grown.length} grew · ${net}`
72
- );
73
- for (const gone of diff.removed.slice(0, 20)) {
74
- lines.push(`- removed: ${gone.name}.${gone.type}`);
75
- }
76
- }
77
-
78
- const findings: string[] = [];
79
- if (insights.neverLoaded.length) findings.push(`- ${insights.neverLoaded.length} shipped but never loaded`);
80
- if (insights.duplicates.length) findings.push(`- ${insights.duplicates.length} duplicate content groups`);
81
- if (insights.webpCandidates.length) findings.push(`- ${insights.webpCandidates.length} WebP conversion candidates`);
82
- if (insights.scaleGaps.length) findings.push(`- ${insights.scaleGaps.length} scale-coverage gaps`);
83
- if (insights.overDecode.length) findings.push(`- ${insights.overDecode.length} images decoding larger than 2× the screen`);
84
- if (insights.heavyGifs.length) findings.push(`- ${insights.heavyGifs.length} heavy GIFs (convert to video/Lottie)`);
85
- if (insights.heavyFonts.length) findings.push(`- ${insights.heavyFonts.length} heavy fonts (subset candidates)`);
86
- if (insights.scaleAnomalies.length) findings.push(`- ${insights.scaleAnomalies.length} scale-variant anomalies (check exports)`);
87
- if (findings.length) lines.push("", "## Findings", ...findings);
88
-
89
- lines.push(
90
- "",
91
- "## Assets (largest first)",
92
- "",
93
- "| Asset | Kind | Dimensions | Scales | Size | Loaded |",
94
- "| --- | --- | --- | --- | --- | --- |"
95
- );
96
- for (const r of records) {
97
- const size =
98
- r.sizeBytes != null && r.sizeSource === "measured"
99
- ? formatBytes(r.sizeBytes)
100
- : estDecodedBytes(r) > 0
101
- ? `~${formatBytes(estDecodedBytes(r))}`
102
- : "—";
103
- lines.push(
104
- `| ${r.name}.${r.type} | ${r.kind} | ${
105
- r.kind === "image" ? formatDimensions(r) : "—"
106
- } | ${formatScales(r.scales)} | ${size} | ${r.loaded ? "yes" : "NO"} |`
107
- );
108
- }
109
- return lines.join("\n");
110
- }
package/src/format.ts DELETED
@@ -1,53 +0,0 @@
1
- /**
2
- * Shared formatting helpers for rows, detail view, and markdown export.
3
- */
4
-
5
- import type { AssetKind, AssetRecord } from "./types";
6
-
7
- export function formatBytes(bytes: number | null | undefined): string {
8
- if (bytes == null || bytes <= 0) return "—";
9
- if (bytes < 1024) return `${bytes} B`;
10
- if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
11
- return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
12
- }
13
-
14
- export function formatScales(scales: number[]): string {
15
- const unique = [...new Set(scales)].sort((a, b) => a - b);
16
- if (!unique.length) return "—";
17
- if (unique.length === 1) return `@${unique[0]}x`;
18
- if (unique.length <= 3) return unique.map((s) => `@${s}x`).join(" ");
19
- return `@${unique[0]}x–@${unique[unique.length - 1]}x`;
20
- }
21
-
22
- export function formatDimensions(record: AssetRecord): string {
23
- if (record.width == null || record.height == null) return "—";
24
- return `${Math.round(record.width)}×${Math.round(record.height)}`;
25
- }
26
-
27
- /** Display size: measured bytes, or "~" decoded estimate for unmeasured images. */
28
- export function formatSize(record: AssetRecord, estDecoded: number): string {
29
- if (record.sizeBytes != null && record.sizeSource === "measured") {
30
- return formatBytes(record.sizeBytes);
31
- }
32
- if (estDecoded > 0) return `~${formatBytes(estDecoded)}`;
33
- return "—";
34
- }
35
-
36
- export const KIND_LABELS: Record<AssetKind, string> = {
37
- image: "IMG",
38
- font: "FONT",
39
- video: "VID",
40
- audio: "AUD",
41
- data: "DATA",
42
- other: "MISC",
43
- };
44
-
45
- /** Family accent per kind — the card's left edge, like network's method bar. */
46
- export const KIND_COLORS: Record<AssetKind, string> = {
47
- image: "#2DD4BF",
48
- font: "#C084FC",
49
- video: "#FF9F1C",
50
- audio: "#FF3366",
51
- data: "#00D4FF",
52
- other: "#8E8E93",
53
- };
@@ -1,26 +0,0 @@
1
- /**
2
- * React bindings for the asset store.
3
- */
4
-
5
- import { useMemo, useSyncExternalStore } from "react";
6
- import { computeStats, getSnapshot, subscribe } from "../store/assetStore";
7
- import { computeInsights } from "../store/insights";
8
- import type { AssetRecord, AssetsInsights, AssetsStats } from "../types";
9
-
10
- export interface UseAssetRecordsResult {
11
- records: AssetRecord[];
12
- stats: AssetsStats;
13
- insights: AssetsInsights;
14
- }
15
-
16
- export function useAssetRecords(): UseAssetRecordsResult {
17
- const snapshot = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
18
- return useMemo(
19
- () => ({
20
- records: snapshot,
21
- stats: computeStats(snapshot),
22
- insights: computeInsights(snapshot),
23
- }),
24
- [snapshot]
25
- );
26
- }
package/src/index.tsx DELETED
@@ -1,87 +0,0 @@
1
- /**
2
- * @buoy-gg/assets — bundled-asset inventory for React Native / Expo.
3
- *
4
- * Enumerates every asset in the app (images, fonts, video, audio, …) via the
5
- * runtime asset registry, enriched in dev with the Metro graph (full-bundle
6
- * coverage, byte sizes, never-loaded detection) and on Expo with loaded font
7
- * families + the updates localAssets map. Zero configuration: importing this
8
- * package installs the (cheap, synchronous) registry capture.
9
- */
10
-
11
- // ---------------------------------------------------------------------------
12
- // Tool preset (FloatingDevTools)
13
- // ---------------------------------------------------------------------------
14
- export { assetsToolPreset, createAssetsTool } from "./preset";
15
-
16
- // ---------------------------------------------------------------------------
17
- // Components
18
- // ---------------------------------------------------------------------------
19
- export { AssetsModal, AssetsToolView } from "./components/AssetsModal";
20
- export { AssetsIcon, ASSETS_ICON_COLOR } from "./components/AssetsIcon";
21
-
22
- // ---------------------------------------------------------------------------
23
- // Capture / scanning
24
- // ---------------------------------------------------------------------------
25
- export { installAssetCapture, rescan, getScanStatus } from "./capture/install";
26
- export { measureSizes } from "./capture/measure";
27
- export { fetchAssetGraph, getDevServerOrigin } from "./capture/metroGraph";
28
- export { getLoadedFontFamilies, getLocalAssets } from "./capture/expo";
29
-
30
- // ---------------------------------------------------------------------------
31
- // Store
32
- // ---------------------------------------------------------------------------
33
- export {
34
- clearRecords,
35
- computeStats,
36
- estDecodedBytes,
37
- getRecord,
38
- getSnapshot,
39
- subscribe,
40
- } from "./store/assetStore";
41
- export { computeInsights } from "./store/insights";
42
- export {
43
- clearBaseline,
44
- computeDiff,
45
- getDiff,
46
- loadBaseline,
47
- saveBaseline,
48
- } from "./store/baseline";
49
- export { useAssetRecords } from "./hooks/useAssetRecords";
50
-
51
- // ---------------------------------------------------------------------------
52
- // Reports
53
- // ---------------------------------------------------------------------------
54
- export { formatAssetMarkdown, formatInventoryMarkdown } from "./export";
55
-
56
- // ---------------------------------------------------------------------------
57
- // External sync (desktop dashboard + MCP)
58
- // ---------------------------------------------------------------------------
59
- export { assetsSyncAdapter } from "./sync/assetsSyncAdapter";
60
- export {
61
- disableAssetsCapture,
62
- replaceAssetsFromSnapshot,
63
- setRemoteAssetsHandler,
64
- } from "./remote";
65
- export type { AssetsRemoteDispatch } from "./remote";
66
-
67
- // ---------------------------------------------------------------------------
68
- // Types
69
- // ---------------------------------------------------------------------------
70
- export type {
71
- AssetKind,
72
- AssetOrigin,
73
- AssetRecord,
74
- AssetRecordWire,
75
- AssetsBaseline,
76
- AssetsDiff,
77
- AssetsInsights,
78
- AssetsScanStatus,
79
- AssetsSnapshotPayload,
80
- AssetsStats,
81
- DuplicateInsight,
82
- } from "./types";
83
-
84
- // Side effect: install the registry capture at import (walk + patch — cheap,
85
- // synchronous, no network). The Metro graph fetch waits for the modal/action.
86
- import { installAssetCapture as installOnImport } from "./capture/install";
87
- installOnImport();
package/src/preset.tsx DELETED
@@ -1,35 +0,0 @@
1
- /**
2
- * Pre-configured Assets tool for FloatingDevTools.
3
- *
4
- * @example
5
- * ```tsx
6
- * import { assetsToolPreset } from '@buoy-gg/assets';
7
- *
8
- * <FloatingDevTools apps={[assetsToolPreset]} />
9
- * ```
10
- */
11
-
12
- import { AssetsIcon, ASSETS_ICON_COLOR } from "./components/AssetsIcon";
13
- import { AssetsModal } from "./components/AssetsModal";
14
-
15
- export const assetsToolPreset = {
16
- id: "assets",
17
- name: "ASSETS",
18
- description: "Every bundled asset — sizes, scale coverage, unused detection",
19
- slot: "both" as const,
20
- icon: ({ size, color }: { size: number; color?: string }) => (
21
- <AssetsIcon size={size} color={color || ASSETS_ICON_COLOR} />
22
- ),
23
- component: AssetsModal,
24
- };
25
-
26
- /** Create a custom Assets tool configuration. */
27
- export function createAssetsTool(
28
- options: { name?: string; description?: string } = {}
29
- ) {
30
- return {
31
- ...assetsToolPreset,
32
- name: options.name ?? assetsToolPreset.name,
33
- description: options.description ?? assetsToolPreset.description,
34
- };
35
- }
package/src/remote.ts DELETED
@@ -1,65 +0,0 @@
1
- /**
2
- * Remote-dispatch seam for dashboard mirrors (Buoy Desktop).
3
- *
4
- * On device this stays null and every action runs locally. A dashboard sets
5
- * a handler that forwards action name + params over external-sync to the
6
- * SAME adapter actions the MCP uses (sync/assetsSyncAdapter.ts), so one
7
- * action vocabulary serves phone UI, desktop UI, and LLM automation.
8
- */
9
-
10
- import { assetsState } from "./state";
11
- import { invalidate } from "./store/assetStore";
12
- import type { AssetsSnapshotPayload } from "./types";
13
-
14
- export type AssetsRemoteDispatch = (
15
- action: string,
16
- params?: Record<string, unknown>
17
- ) => Promise<unknown>;
18
-
19
- export function setRemoteAssetsHandler(
20
- handler: AssetsRemoteDispatch | null
21
- ): void {
22
- assetsState().remoteDispatch = handler;
23
- }
24
-
25
- export function remoteAssetsDispatch(): AssetsRemoteDispatch | null {
26
- return assetsState().remoteDispatch;
27
- }
28
-
29
- /** Dashboards never scan their own bundle — the store is purely a mirror. */
30
- export function disableAssetsCapture(): void {
31
- assetsState().captureDisabled = true;
32
- }
33
-
34
- /** Replace the whole store from a synced adapter snapshot. */
35
- export function replaceAssetsFromSnapshot(payload: AssetsSnapshotPayload): void {
36
- const s = assetsState();
37
- const wire = payload.records ?? [];
38
- s.records = wire.map((w) => ({
39
- id: w.id,
40
- key: w.key,
41
- registryId: w.registryId ?? undefined,
42
- name: w.name,
43
- type: w.type,
44
- kind: w.kind,
45
- hash: w.hash ?? undefined,
46
- httpServerLocation: w.location ?? undefined,
47
- width: w.width ?? undefined,
48
- height: w.height ?? undefined,
49
- scales: w.scales,
50
- resolvedUri: w.uri ?? undefined,
51
- loaded: w.loaded,
52
- origin: w.registryId != null ? ("registry" as const) : ("graph" as const),
53
- sizeBytes: w.sizeBytes ?? undefined,
54
- sizeSource: w.sizeSource ?? undefined,
55
- firstSeenAt: Date.now(),
56
- }));
57
- s.byKey = new Map(s.records.map((r) => [r.key, r]));
58
- s.byId = new Map(s.records.map((r) => [r.id, r]));
59
- if (payload.scanStatus) {
60
- s.mirrorScanStatus = payload.scanStatus;
61
- s.fontFamilies = payload.scanStatus.fontFamilies ?? [];
62
- }
63
- s.mirrorDiff = payload.diff ?? null;
64
- invalidate();
65
- }
package/src/state.ts DELETED
@@ -1,84 +0,0 @@
1
- /**
2
- * Global singleton state for the Assets tool.
3
- *
4
- * Same rationale as the images tool: the package can be evaluated as two
5
- * module instances (lib vs src resolution), and the registerAsset patch must
6
- * share one store with whichever copy the UI renders from. All mutable state
7
- * lives on globalThis under a well-known symbol.
8
- */
9
-
10
- import type { AssetRecord, AssetsBaseline, AssetsDiff, AssetsScanStatus } from "./types";
11
-
12
- export interface AssetsGlobalState {
13
- // store
14
- records: AssetRecord[];
15
- byKey: Map<string, AssetRecord>;
16
- byId: Map<number, AssetRecord>;
17
- nextId: number;
18
- listeners: Set<() => void>;
19
- snapshot: AssetRecord[];
20
- emitScheduled: boolean;
21
- // install / scan status
22
- installed: boolean;
23
- patched: boolean;
24
- registryAvailable: boolean;
25
- graphSupported: boolean;
26
- graphCount: number | null;
27
- graphError: string | null;
28
- graphFetchInFlight: boolean;
29
- lastScanAt: number | null;
30
- measuring: boolean;
31
- measuredCount: number;
32
- fontFamilies: string[];
33
- localAssetCount: number | null;
34
- // baseline diff
35
- baseline: AssetsBaseline | null;
36
- baselineLoaded: boolean;
37
- // mirror mode (desktop dashboard)
38
- captureDisabled: boolean;
39
- remoteDispatch:
40
- | ((action: string, params?: Record<string, unknown>) => Promise<unknown>)
41
- | null;
42
- mirrorScanStatus: AssetsScanStatus | null;
43
- /** Device PixelRatio adopted from a synced snapshot (scale-gap math). */
44
- mirrorPixelRatio: number | null;
45
- /** Device-computed diff adopted from a synced snapshot. */
46
- mirrorDiff: AssetsDiff | null;
47
- }
48
-
49
- const KEY = Symbol.for("buoy.assets.globalState.v1");
50
-
51
- export function assetsState(): AssetsGlobalState {
52
- const g = globalThis as Record<symbol, AssetsGlobalState | undefined>;
53
- if (!g[KEY]) {
54
- g[KEY] = {
55
- records: [],
56
- byKey: new Map(),
57
- byId: new Map(),
58
- nextId: 1,
59
- listeners: new Set(),
60
- snapshot: [],
61
- emitScheduled: false,
62
- installed: false,
63
- patched: false,
64
- registryAvailable: false,
65
- graphSupported: false,
66
- graphCount: null,
67
- graphError: null,
68
- graphFetchInFlight: false,
69
- lastScanAt: null,
70
- measuring: false,
71
- measuredCount: 0,
72
- fontFamilies: [],
73
- localAssetCount: null,
74
- baseline: null,
75
- baselineLoaded: false,
76
- captureDisabled: false,
77
- remoteDispatch: null,
78
- mirrorScanStatus: null,
79
- mirrorPixelRatio: null,
80
- mirrorDiff: null,
81
- };
82
- }
83
- return g[KEY];
84
- }
@@ -1,209 +0,0 @@
1
- /**
2
- * assetStore
3
- *
4
- * Bounded store of AssetRecords keyed by their stable asset key
5
- * (`location/name.type`). Follows the images tool's store shape — Set of
6
- * listeners, frame-coalesced emit, all mutable state on the globalThis
7
- * singleton (../state.ts) so dual module instances share one store.
8
- *
9
- * Unlike the images tool this is an INVENTORY, not an event log: upserts
10
- * merge into the existing record for a key instead of appending, and there
11
- * is no ring-buffer cap (a bundle has a bounded asset count by construction).
12
- */
13
-
14
- import { assetsState } from "../state";
15
- import type { AssetKind, AssetRecord, AssetsStats } from "../types";
16
-
17
- function emit(): void {
18
- const s = assetsState();
19
- if (s.emitScheduled) return;
20
- s.emitScheduled = true;
21
- requestAnimationFrame(() => {
22
- s.emitScheduled = false;
23
- for (const listener of s.listeners) listener();
24
- });
25
- }
26
-
27
- /** Snapshot identity only changes on emit, so useSyncExternalStore stays cheap. */
28
- export function invalidate(): void {
29
- const s = assetsState();
30
- s.snapshot = [...s.records];
31
- emit();
32
- }
33
-
34
- const KIND_BY_EXT: Record<string, AssetKind> = {
35
- png: "image", jpg: "image", jpeg: "image", gif: "image", webp: "image",
36
- bmp: "image", psd: "image", svg: "image", tiff: "image", ktx: "image",
37
- heic: "image", heif: "image", avif: "image",
38
- ttf: "font", otf: "font", woff: "font", woff2: "font", ttc: "font",
39
- mp4: "video", m4v: "video", mov: "video", mpeg: "video", mpg: "video",
40
- webm: "video", avi: "video",
41
- mp3: "audio", wav: "audio", aac: "audio", aiff: "audio", caf: "audio",
42
- m4a: "audio", ogg: "audio",
43
- json: "data", db: "data", yaml: "data", yml: "data", xml: "data",
44
- html: "data", pdf: "data", zip: "data",
45
- };
46
-
47
- export function kindForType(type: string): AssetKind {
48
- return KIND_BY_EXT[type.toLowerCase()] ?? "other";
49
- }
50
-
51
- export function assetKey(
52
- httpServerLocation: string | undefined,
53
- name: string,
54
- type: string
55
- ): string {
56
- // The runtime registry percent-encodes ?unstable_path= locations while the
57
- // Metro graph endpoint returns them decoded — normalize or every asset
58
- // splits into a registry record + a graph record.
59
- let location = httpServerLocation ?? "";
60
- try {
61
- location = decodeURIComponent(location);
62
- } catch {
63
- // malformed escapes: keep the raw string
64
- }
65
- return `${location}/${name}.${type}`;
66
- }
67
-
68
- export interface AssetUpsert {
69
- key: string;
70
- name: string;
71
- type: string;
72
- origin: AssetRecord["origin"];
73
- registryId?: number;
74
- hash?: string;
75
- httpServerLocation?: string;
76
- width?: number;
77
- height?: number;
78
- scales?: number[];
79
- resolvedUri?: string;
80
- resolvedScale?: number;
81
- loaded?: boolean;
82
- fileSystemLocation?: string;
83
- files?: string[];
84
- fileHashes?: string[];
85
- }
86
-
87
- /** Merge an asset sighting into the store; creates the record on first sight. */
88
- export function upsertRecord(input: AssetUpsert): AssetRecord {
89
- const s = assetsState();
90
- let record = s.byKey.get(input.key);
91
- if (!record) {
92
- record = {
93
- id: s.nextId++,
94
- key: input.key,
95
- name: input.name,
96
- type: input.type,
97
- kind: kindForType(input.type),
98
- scales: input.scales ?? [],
99
- loaded: input.loaded ?? false,
100
- origin: input.origin,
101
- firstSeenAt: Date.now(),
102
- };
103
- s.records.push(record);
104
- s.byKey.set(record.key, record);
105
- s.byId.set(record.id, record);
106
- }
107
- if (input.registryId != null) record.registryId = input.registryId;
108
- if (input.hash != null) record.hash = input.hash;
109
- if (input.httpServerLocation != null) record.httpServerLocation = input.httpServerLocation;
110
- if (input.width != null) record.width = input.width;
111
- if (input.height != null) record.height = input.height;
112
- if (input.scales && input.scales.length) {
113
- // Dedupe: foo.png + foo@1x.png both existing yields [1, 1, …] from Metro.
114
- record.scales = [...new Set(input.scales)].sort((a, b) => a - b);
115
- }
116
- if (input.resolvedUri != null) record.resolvedUri = input.resolvedUri;
117
- if (input.resolvedScale != null) record.resolvedScale = input.resolvedScale;
118
- if (input.loaded) record.loaded = true;
119
- if (input.fileSystemLocation != null) record.fileSystemLocation = input.fileSystemLocation;
120
- if (input.files) record.files = input.files;
121
- if (input.fileHashes) record.fileHashes = input.fileHashes;
122
- invalidate();
123
- return record;
124
- }
125
-
126
- /** Mutate a record in place (records are owned by the store) and notify. */
127
- export function updateRecord(
128
- id: number,
129
- patch: Partial<AssetRecord>
130
- ): AssetRecord | undefined {
131
- const s = assetsState();
132
- const record = s.byId.get(id);
133
- if (!record) return undefined;
134
- Object.assign(record, patch);
135
- invalidate();
136
- return record;
137
- }
138
-
139
- export function getRecord(id: number): AssetRecord | undefined {
140
- return assetsState().byId.get(id);
141
- }
142
-
143
- export function getRecordByKey(key: string): AssetRecord | undefined {
144
- return assetsState().byKey.get(key);
145
- }
146
-
147
- export function getSnapshot(): AssetRecord[] {
148
- return assetsState().snapshot;
149
- }
150
-
151
- export function subscribe(listener: () => void): () => void {
152
- const s = assetsState();
153
- s.listeners.add(listener);
154
- return () => assetsState().listeners.delete(listener);
155
- }
156
-
157
- export function clearRecords(): void {
158
- const s = assetsState();
159
- s.records = [];
160
- s.byKey = new Map();
161
- s.byId = new Map();
162
- s.measuredCount = 0;
163
- s.graphCount = null;
164
- invalidate();
165
- }
166
-
167
- /**
168
- * Estimated decoded-bitmap memory (RGBA8888) at the scale this device would
169
- * pick — the size fallback when bytes can't be measured (release builds).
170
- */
171
- export function estDecodedBytes(record: AssetRecord): number {
172
- if (record.kind !== "image" || !record.width || !record.height) return 0;
173
- const scale =
174
- record.resolvedScale ??
175
- (record.scales.length ? record.scales[record.scales.length - 1] : 1);
176
- return Math.round(record.width * scale * record.height * scale * 4);
177
- }
178
-
179
- export function computeStats(list: AssetRecord[]): AssetsStats {
180
- const s = assetsState();
181
- const byKind: AssetsStats["byKind"] = {
182
- image: { count: 0, bytes: 0 },
183
- font: { count: 0, bytes: 0 },
184
- video: { count: 0, bytes: 0 },
185
- audio: { count: 0, bytes: 0 },
186
- data: { count: 0, bytes: 0 },
187
- other: { count: 0, bytes: 0 },
188
- };
189
- const stats: AssetsStats = {
190
- total: list.length,
191
- loaded: 0,
192
- neverLoaded: 0,
193
- byKind,
194
- totalBytes: 0,
195
- measuredCount: 0,
196
- fontFamilies: s.fontFamilies.length,
197
- };
198
- for (const r of list) {
199
- if (r.loaded) stats.loaded++;
200
- else stats.neverLoaded++;
201
- byKind[r.kind].count++;
202
- if (r.sizeBytes != null && r.sizeSource === "measured") {
203
- byKind[r.kind].bytes += r.sizeBytes;
204
- stats.totalBytes += r.sizeBytes;
205
- stats.measuredCount++;
206
- }
207
- }
208
- return stats;
209
- }