@ai-matrx/media 0.2.3 → 0.4.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/CHANGELOG.md CHANGED
@@ -1,5 +1,190 @@
1
1
  # @ai-matrx/media
2
2
 
3
+ ## 0.4.0 — 2026-08-30
4
+
5
+ **The package renders FILES now, not just media elements** — and it knows what
6
+ Next.js is. Two new subpaths close this package's ALL-INCLUSIVE-LAW retrofit
7
+ row: `./next` (the framework seam every Next host was rebuilding) and
8
+ `./viewers` (the full-pane file viewers matrx-frontend owned).
9
+
10
+ ### Consumer action (C28)
11
+
12
+ - **`./next` replaces every hand-written `next/image` adapter.** A host's
13
+ `ImageComponent` port collapses to
14
+ `ImageComponent: NextMediaImage` (or `createNextMediaImageComponent({…})`).
15
+ Delete the local wrapper — including the four `as React.*Handler` casts it
16
+ needed; the package owns those now. `next` is an **optional peer** (`>=14`),
17
+ so non-Next consumers are unaffected and nothing new installs for them.
18
+ - **`./viewers` replaces host-owned text / code / SVG / HTML / generic
19
+ previewers.** A host now registers only what the package cannot ship — its
20
+ heavy engines — and renders `<MediaFileViewer>`:
21
+
22
+ ```tsx
23
+ registerMediaViewer("pdf", HostPdfViewer); // PDF.js, react-pdf…
24
+ registerMediaCodeHighlighter(PrismHighlighter); // optional: colour
25
+ <MediaFileViewer source={{ kind: "ref", ref }} fileName={name} />
26
+ ```
27
+
28
+ Delete the absorbed bodies. Anything NOT registered renders the announcing
29
+ default, never a dead pane.
30
+ - 🚨 **`formatFileSize` behavior change (`/react`).** It now accepts
31
+ `number | null | undefined` and returns `"—"` for null / undefined / NaN /
32
+ negative, where it previously returned a confident, wrong `"0 B"`. Any
33
+ consumer asserting `"0 B"` for a missing size must update. (Defect found
34
+ while absorbing the viewers: matrx-frontend's own `formatFileSize` already
35
+ had this guard and the package's did not — C22 says the package carries the
36
+ better one.)
37
+ - **No other existing export changed shape.**
38
+
39
+ ### `./next` — the Next.js binding (NEW)
40
+
41
+ - `NextMediaImage` and `createNextMediaImageComponent({ unoptimized?, quality?,
42
+ sizes?, priority?, loader?, className? })` — a `MediaHostPorts.ImageComponent`
43
+ implementation over `next/image`, with `unoptimized: true` as the default
44
+ because Matrx durable URLs are session-cookie authenticated and Next's
45
+ optimizer fetches them SERVER-side, where there is no user session.
46
+ - **The deliberate divergence from `@ai-matrx/data`'s `/next`** (which imports
47
+ no `next/*` module): `next/image` is not data crossing a seam, it IS the
48
+ renderer, so no structural seam can express it — the ALL-INCLUSIVE LAW names
49
+ this exact wrapper as package-owned. Confined to this one entry; the
50
+ package's components still reach Next only through the `ImageComponent` port.
51
+ - The port's structurally-typed handlers are narrowed to DOM handler types
52
+ here, once, instead of in every host.
53
+
54
+ ### `./viewers` — full-pane file viewers (NEW)
55
+
56
+ Absorbed from matrx-frontend `features/files/components/core/FilePreview/`:
57
+ `PreviewerSwitch` (the dispatcher) plus the `TextPreview`, `CodePreview`,
58
+ `SvgPreview`, `HtmlPreview` and `GenericPreview` bodies. Behavior ported
59
+ verbatim except where noted below.
60
+
61
+ - `MediaFileViewer` — kind dispatch with the C23 resolution order: a host
62
+ registration wins, else the package's built-in body, else the **announcing
63
+ default** that names the missing engine and the remedy and still offers
64
+ download / view-as-text. A blank pane is not a reachable state.
65
+ - Built-in bodies: `TextViewer`, `CodeViewer`, `SvgViewer` (rendered + XML
66
+ source), `HtmlViewer` (sandboxed frame + source), `GenericViewer`.
67
+ - `registerMediaViewer(kind, Component)` / `registerMediaCodeHighlighter(fn)` —
68
+ the heavy-engine seam, on `globalThis` `Symbol.for` slots so a registration
69
+ made in one loader graph is visible from the other (the tarball canary
70
+ proves it across ESM/CJS). Both return an unregister function.
71
+ - `getPreviewKind(fileName, mimeType?)` — kind derivation from the package's
72
+ own file-kind registry, so a fresh app dispatches correctly with zero setup.
73
+ A host with a richer registry passes `previewKind` explicitly.
74
+ - `MediaViewerSource` has two lanes: `{kind:"ref"}` (everything through the
75
+ MediaClient) and `{kind:"blob"}` (bytes the host already downloaded — proven
76
+ by test to make **zero** client calls).
77
+ - The markup viewers take OPTIONAL `markupControls`; a host control rail drives
78
+ mode / viewport / reload, and without one the viewer draws its own toolbar
79
+ and is fully capable (the original hard-wired itself to an app context).
80
+ - **Why `CodeViewer` ships without a highlighter** (C8, the diff-package Monaco
81
+ precedent): Prism is a ~150KB browser-only engine and a headless consumer
82
+ must not carry it. The package ships monospace, line-numbered, wrap-toggling,
83
+ copyable, truncation-honest code — everything but colour — and the engine
84
+ rides `registerMediaCodeHighlighter`. That default announces nothing because
85
+ nothing failed.
86
+ - **`HtmlViewer`'s sandbox is non-configurable**: `allow-scripts allow-popups
87
+ allow-forms`, never `allow-same-origin`, plus `referrerPolicy="no-referrer"`.
88
+ One wrong token is a real XSS against the host origin, so hosts do not get a
89
+ knob. Asserted in the source suite AND on the packed artifact.
90
+
91
+ ### Defects fixed in the port (never silently — C22)
92
+
93
+ - **A user-visible debug artifact is gone.** `GenericPreview` shipped a
94
+ fuchsia `ring-2` and a "GenericPreview" corner badge — a layering-debug
95
+ visualization commented "Rip this out when done" — to every user opening an
96
+ unpreviewable file. The package has no debug chrome.
97
+ - **`blob.text()` is no longer assumed.** All four absorbed text-reading
98
+ previewers called it directly; it does not exist in Safari < 14, older
99
+ WebViews (the Tauri shell's floor), or jsdom, and the failure surfaced to the
100
+ user as `blob.text is not a function` — as though the FILE were broken.
101
+ `readBlobText` falls back through `arrayBuffer()` and `FileReader`.
102
+ - **`formatFileSize`** — see Consumer action above.
103
+ - **Four copies of the read-cap-truncate-report machinery became one**
104
+ (`useViewerText`), as did the error card, skeleton and copy button.
105
+
106
+ ### Gate
107
+
108
+ `pnpm typecheck && pnpm test && pnpm check:package` green: 125 tests
109
+ (14 new C23 tests across three targets — the announcing default incl. its
110
+ falsifiability leg, the built-in bodies unregistered, and dispatch with zero
111
+ host configuration), publint, attw node16, and a tarball canary extended with
112
+ the `/next` no-cast `MediaHostPorts` assignment, the `/viewers` ESM+CJS export
113
+ and render legs, the packed-artifact sandbox assertion, and the viewer-registry
114
+ dual-graph proof. `/next` is not loaded in the bare-node ESM leg by design:
115
+ Next publishes `next/image` extensionless with no `exports` map, so only
116
+ bundlers and TypeScript resolve it — it is proven via attw, the type canary,
117
+ and a real `require()` in the CJS leg.
118
+
119
+ ### Deliberately NOT absorbed (recorded, not forgotten)
120
+
121
+ - **The PDF viewer tree** (`matrx-frontend/features/pdf`, 49 files): PDF.js +
122
+ annotation layer + document scanner + surface registry + server-side
123
+ extraction API. It is a FEATURE, not a viewer body, and it rides the
124
+ `registerMediaViewer("pdf", …)` seam.
125
+ - **Markdown / spreadsheet / Office viewers**: remark+rehype+KaTeX, SheetJS +
126
+ PapaParse, and a server extraction endpoint respectively — heavy engines or
127
+ host-server-coupled. Same seam.
128
+ - **Image / video / audio viewers**: they bind host domain systems (a
129
+ zoom/rotate control rail, the unified playback-session registry, a
130
+ design-system slider). They already consume `/core`; absorbing their bodies
131
+ is the next wave, not this one.
132
+
133
+ ## 0.3.0 — 2026-08-30
134
+
135
+ **A terminal media failure can never be silent again.** Born from the
136
+ 2026-08-30 outage: server-side cookie-secret drift killed every private image
137
+ on prod, the honest "unavailable" panel rendered — and nothing reached the
138
+ host's error system, because the package had no diagnostics port and the host
139
+ cannot capture what it never learns about. Per ruling C22 the capture logic
140
+ now lives IN the package. Additive; no default behavior changes for renders.
141
+
142
+ - **New `diagnostics` port on `MediaHostPorts`** —
143
+ `diagnostics?: { capture?: (info: MediaFailureInfo) => void }`.
144
+ `MediaFailureInfo` is strictly typed: `source: "media"`, a
145
+ `phase` (`"resolve" | "session" | "blob" | "element-load" | "recovery"`),
146
+ a durable `mediaRef` identity (file id / query-stripped URL key — NEVER a
147
+ signed URL), `status?` when known, a typed `retryOutcome?`, `terminal`,
148
+ and `message`. **Absent port → the package `console.error`s the same
149
+ structured payload** (the loud default) — never fully silent. A throwing
150
+ sink also falls back to the console.
151
+ - **Every terminal failure path emits exactly one capture**, latched per
152
+ media identity (no spam across re-renders, remounts, or the panel
153
+ backstop): a throwing `resolve()` (phase `resolve`), a `getBlob()`
154
+ rejection (phase `blob`, with the structural HTTP status when the error
155
+ carries one), a rejecting `recoverLoadError` (phase `session`,
156
+ `recovery-failed`), a terminal verdict after the ONE granted retry (phase
157
+ `element-load`, `retried`), a terminal verdict on the first attempt (phase
158
+ `recovery`, `not-attempted`), an unrecoverable element failure (phase
159
+ `element-load`, `not-attempted`) — plus the unavailable panel mount as a
160
+ latch-deduped BACKSTOP (if the panel is on screen and no path captured,
161
+ the mount screams).
162
+ - **`MediaClient.recoverLoadError` may now return a structured
163
+ `MediaRecoveryReport`** (`{ verdict, status?, secretSource? }`) alongside
164
+ the still-fully-supported bare `"retry" | "terminal"` strings — a widening,
165
+ zero change required in any implementation. When the client forwards the
166
+ `/files/session` mint's `secret_source` and it is `"ephemeral"`, the
167
+ package captures ONE app-wide non-terminal warning ("server cookie secret
168
+ is ephemeral — private renders will break across restarts/instances") the
169
+ moment it is seen — before the outage, not after. The field is tolerated
170
+ absent.
171
+ - New `/core` exports: `useMediaFailureCapture`, `emitMediaFailure`,
172
+ `failureMediaIdentity`, `resetMediaFailureLatch` (tests + auth boundaries).
173
+ Root types: `MediaFailureInfo`, `MediaFailurePhase`, `MediaRetryOutcome`,
174
+ `MediaDiagnosticsPort`, `MediaRecoveryVerdict`, `MediaRecoveryReport`.
175
+ - `useMediaLoadRecovery` gains an optional `failureRef` option so failures
176
+ are reported against the durable ref identity instead of the (stripped)
177
+ URL; `InlineMediaRef`/`MediaThumbnail` pass it automatically.
178
+
179
+ **Consumer action:** bind the port —
180
+ `ports.diagnostics = { capture: (info) => yourErrorCapture(info) }` on the
181
+ object passed to `MediaProvider`/`MediaHostPorts` (matrx-frontend: wire to
182
+ `captureError` in `lib/diagnostics/errorCaptureStore`). Without the binding
183
+ you get loud console.error diagnostics; with it, failures land in your error
184
+ system. Clients implementing `MediaClient` SHOULD start returning
185
+ `MediaRecoveryReport` from `recoverLoadError` with the mint's
186
+ `secret_source` when the server sends one.
187
+
3
188
  ## 0.2.3 — 2026-08-30
4
189
 
5
190
  - `@ai-matrx/associations` is now a REAL dependency (`^0.5.2`) instead of an
package/README.md CHANGED
@@ -124,6 +124,85 @@ automatically (it brings `@ai-matrx/design-system` with it). Nothing extra to
124
124
  resolve; association affordances still degrade to absent at runtime when no
125
125
  `AssociationsProvider` wraps the tree. The other entries never touch it.
126
126
 
127
+ ### Diagnostics — a terminal failure is never silent
128
+
129
+ Every dead render (a refused `resolve()`, failed private-pixel bytes, a load
130
+ error the ONE recovery retry couldn't fix, a failed session mint) emits
131
+ exactly one strictly-typed `MediaFailureInfo` — `phase`, durable `mediaRef`
132
+ identity (never a signed URL), `status?`, `retryOutcome?`, `terminal`,
133
+ `message` — latched per media identity so re-renders never spam. Bind the
134
+ sink to your error system:
135
+
136
+ ```tsx
137
+ <MediaProvider
138
+ client={client}
139
+ ports={{ diagnostics: { capture: (info) => myErrorCapture(info) } }}
140
+ >
141
+ ```
142
+
143
+ Without a bound sink the package `console.error`s the same payload — loud by
144
+ default, never silent. If your `MediaClient.recoverLoadError` returns a
145
+ `MediaRecoveryReport` carrying the session mint's
146
+ `secret_source: "ephemeral"`, the package immediately captures an app-wide
147
+ warning that private renders will break across server restarts/instances.
148
+
149
+ ## File viewers (`/viewers`)
150
+
151
+ Full-pane rendering of ONE file — a preview pane, a file tab, a code editor's
152
+ binary-file fallback. `MediaFileViewer` dispatches on the file kind and ships
153
+ working bodies for text, code, SVG, HTML and unknown files.
154
+
155
+ ```tsx
156
+ import { MediaFileViewer } from "@ai-matrx/media/viewers";
157
+
158
+ <MediaFileViewer
159
+ source={{ kind: "ref", ref: { file_id } }}
160
+ fileName={file.name}
161
+ fileSize={file.size}
162
+ generic={{ onDownload }}
163
+ />;
164
+ ```
165
+
166
+ Kinds whose rendering IS a heavy browser-only engine — PDF, Markdown,
167
+ spreadsheets, Office documents — are yours to register, so a consumer that only
168
+ shows text files never carries a PDF engine:
169
+
170
+ ```tsx
171
+ import { registerMediaViewer, registerMediaCodeHighlighter } from "@ai-matrx/media/viewers";
172
+
173
+ registerMediaViewer("pdf", MyPdfViewer); // gets MediaViewerProps
174
+ registerMediaCodeHighlighter(MyPrismHighlighter); // optional: colour for code
175
+ ```
176
+
177
+ Until you register one, that kind renders an **announcing default**: it names
178
+ the missing engine and the remedy and still offers download / view-as-text. It
179
+ is never a blank pane. (Code is different: with no highlighter registered it
180
+ renders complete — monospace, line-numbered, copyable — just without colour,
181
+ so it announces nothing.)
182
+
183
+ Registering a kind the package already renders replaces the built-in, which is
184
+ how you plug in a domain-specific viewer of your own.
185
+
186
+ The `blob` source lane (`{ kind: "blob", blob, url }`) renders bytes you
187
+ already have without touching the `MediaClient` at all.
188
+
189
+ ## Next.js (`/next`)
190
+
191
+ The `ImageComponent` port, implemented over `next/image` so you never write
192
+ that wrapper again. `next` is an optional peer — nothing installs it for you,
193
+ and no other entry imports it.
194
+
195
+ ```tsx
196
+ import { NextMediaImage } from "@ai-matrx/media/next";
197
+
198
+ const ports: MediaHostPorts = { ImageComponent: NextMediaImage };
199
+ // or: createNextMediaImageComponent({ unoptimized: false, quality: 90 })
200
+ ```
201
+
202
+ It defaults to `unoptimized`, which is correct for Matrx media: durable file
203
+ URLs are session-cookie authenticated and Next's optimizer fetches them from
204
+ the server, where there is no user session.
205
+
127
206
  ## Styling
128
207
 
129
208
  `/react` uses Tailwind utility classes with semantic tokens (`bg-muted`,
@@ -135,9 +214,9 @@ resolve; association affordances still degrade to absent at runtime when no
135
214
 
136
215
  ## SSR / RSC
137
216
 
138
- The root entry is server-safe (types + one pure function). `/core`, `/react`
139
- and `/share` are client modules (`"use client"` is stamped per entry).
140
- Nothing touches `window` at import time.
217
+ The root entry is server-safe (types + one pure function). `/core`, `/react`,
218
+ `/share`, `/viewers` and `/next` are client modules (`"use client"` is stamped
219
+ per entry). Nothing touches `window` at import time.
141
220
 
142
221
  ## License
143
222
 
package/dist/core.cjs CHANGED
@@ -22,12 +22,16 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
22
22
  var core_exports = {};
23
23
  __export(core_exports, {
24
24
  MediaProvider: () => MediaProvider,
25
+ emitMediaFailure: () => emitMediaFailure,
26
+ failureMediaIdentity: () => failureMediaIdentity,
25
27
  getFileKindDetails: () => getFileKindDetails,
26
28
  getFolderKindDetails: () => getFolderKindDetails,
29
+ resetMediaFailureLatch: () => resetMediaFailureLatch,
27
30
  stableRefKey: () => stableRefKey,
28
31
  useMediaActions: () => useMediaActions,
29
32
  useMediaBlob: () => useMediaBlob,
30
33
  useMediaClient: () => useMediaClient,
34
+ useMediaFailureCapture: () => useMediaFailureCapture,
31
35
  useMediaHostPorts: () => useMediaHostPorts,
32
36
  useMediaLoadRecovery: () => useMediaLoadRecovery,
33
37
  useMediaResolution: () => useMediaResolution,
@@ -82,29 +86,107 @@ function stableRefKey(ref) {
82
86
  return "<empty-ref>";
83
87
  }
84
88
 
85
- // src/core/use-media-resolution.ts
89
+ // src/core/diagnostics.ts
86
90
  var import_react2 = require("react");
91
+ var LATCH_SLOT = /* @__PURE__ */ Symbol.for("ai-matrx.media.failure-latch");
92
+ function latch() {
93
+ const host = globalThis;
94
+ host[LATCH_SLOT] ??= /* @__PURE__ */ new Set();
95
+ return host[LATCH_SLOT];
96
+ }
97
+ function resetMediaFailureLatch() {
98
+ latch().clear();
99
+ }
100
+ function failureMediaIdentity(ref, src) {
101
+ if (ref && typeof ref === "object" && ref.file_id) return `file:${ref.file_id}`;
102
+ const raw = typeof ref === "string" ? ref : (ref && typeof ref === "object" ? ref.url ?? null : null) ?? src ?? null;
103
+ if (!raw) return "<unknown-media>";
104
+ if (raw.startsWith("blob:")) return "blob:object-url";
105
+ try {
106
+ const u = new URL(raw);
107
+ return `url:${u.origin}${u.pathname}`;
108
+ } catch {
109
+ const clean = raw.split(/[?#]/, 1)[0] ?? raw;
110
+ return `ref:${clean}`;
111
+ }
112
+ }
113
+ function emitMediaFailure(capture, info) {
114
+ const key = `${info.terminal ? "terminal" : "warning"}|${info.mediaRef}`;
115
+ const seen = latch();
116
+ if (seen.has(key)) return;
117
+ seen.add(key);
118
+ if (capture) {
119
+ try {
120
+ capture(info);
121
+ return;
122
+ } catch {
123
+ }
124
+ }
125
+ console.error(
126
+ "[@ai-matrx/media] media failure (no diagnostics sink captured it \u2014 bind MediaHostPorts.diagnostics.capture to your error system)",
127
+ info
128
+ );
129
+ }
130
+ function useMediaFailureCapture() {
131
+ const ports = useMediaHostPorts();
132
+ const capture = ports.diagnostics?.capture;
133
+ const captureRef = (0, import_react2.useRef)(capture);
134
+ captureRef.current = capture;
135
+ return (0, import_react2.useCallback)((info) => {
136
+ emitMediaFailure(captureRef.current, { source: "media", ...info });
137
+ }, []);
138
+ }
139
+
140
+ // src/core/use-media-resolution.ts
141
+ var import_react3 = require("react");
87
142
  function useMediaResolution(ref) {
88
143
  const client = useMediaClient();
144
+ const capture = useMediaFailureCapture();
89
145
  const refKey = stableRefKey(ref);
90
- return (0, import_react2.useMemo)(() => {
91
- if (!ref) return { resolution: null, status: "empty", reason: null };
146
+ const { state, failureMessage } = (0, import_react3.useMemo)(() => {
147
+ if (!ref) {
148
+ return {
149
+ state: { resolution: null, status: "empty", reason: null },
150
+ failureMessage: null
151
+ };
152
+ }
92
153
  try {
93
154
  const resolution = client.resolve(ref);
94
- if (!resolution) return { resolution: null, status: "empty", reason: null };
95
- return { resolution, status: "ready", reason: null };
155
+ if (!resolution) {
156
+ return {
157
+ state: { resolution: null, status: "empty", reason: null },
158
+ failureMessage: null
159
+ };
160
+ }
161
+ return {
162
+ state: { resolution, status: "ready", reason: null },
163
+ failureMessage: null
164
+ };
96
165
  } catch (err) {
97
166
  return {
98
- resolution: null,
99
- status: "unavailable",
100
- reason: client.classifyError(err)
167
+ state: {
168
+ resolution: null,
169
+ status: "unavailable",
170
+ reason: client.classifyError(err)
171
+ },
172
+ failureMessage: err instanceof Error ? err.message : String(err ?? "resolve() failed")
101
173
  };
102
174
  }
103
175
  }, [client, refKey]);
176
+ (0, import_react3.useEffect)(() => {
177
+ if (state.status !== "unavailable") return;
178
+ capture({
179
+ phase: "resolve",
180
+ mediaRef: failureMediaIdentity(ref ?? null),
181
+ terminal: true,
182
+ message: `resolve() refused (${state.reason ?? "unknown"}): ${failureMessage ?? "no error message"}`
183
+ });
184
+ }, [refKey, state.status]);
185
+ return state;
104
186
  }
105
187
 
106
188
  // src/core/use-media-load-recovery.ts
107
- var import_react3 = require("react");
189
+ var import_react4 = require("react");
108
190
 
109
191
  // src/types.ts
110
192
  function mintDurableSrc(url) {
@@ -112,26 +194,51 @@ function mintDurableSrc(url) {
112
194
  }
113
195
 
114
196
  // src/core/use-media-load-recovery.ts
197
+ function normalizeOutcome(outcome) {
198
+ return typeof outcome === "string" ? { verdict: outcome } : outcome;
199
+ }
200
+ var EPHEMERAL_SECRET_IDENTITY = "server:files-session-secret";
115
201
  function useMediaLoadRecovery(src, options) {
116
202
  const client = useMediaClient();
203
+ const capture = useMediaFailureCapture();
117
204
  const recoverable = options?.recoverable !== false;
118
205
  const onTerminal = options?.onTerminal;
119
- const [result, setResult] = (0, import_react3.useState)({ source: src, retryKey: 0, failed: false });
120
- const attemptRef = (0, import_react3.useRef)(0);
121
- (0, import_react3.useEffect)(() => {
206
+ const failureRef = options?.failureRef;
207
+ const [result, setResult] = (0, import_react4.useState)({ source: src, retryKey: 0, failed: false });
208
+ const attemptRef = (0, import_react4.useRef)(0);
209
+ (0, import_react4.useEffect)(() => {
122
210
  attemptRef.current = 0;
123
211
  }, [src]);
212
+ const identity = () => failureMediaIdentity(failureRef ?? null, src);
124
213
  const onLoadError = (event) => {
125
214
  if (!src || !recoverable) {
126
215
  setResult({ source: src, retryKey: 0, failed: true });
216
+ if (src) {
217
+ capture({
218
+ phase: "element-load",
219
+ mediaRef: identity(),
220
+ terminal: true,
221
+ retryOutcome: "not-attempted",
222
+ message: "media element failed to load and the source is unrecoverable (foreign/object URL \u2014 a session refresh cannot fix it)"
223
+ });
224
+ }
127
225
  onTerminal?.(event);
128
226
  return;
129
227
  }
130
228
  attemptRef.current += 1;
131
229
  const attempt = attemptRef.current;
132
230
  client.recoverLoadError(mintDurableSrc(src), attempt).then(
133
- (verdict) => {
134
- if (verdict === "retry") {
231
+ (outcome) => {
232
+ const report = normalizeOutcome(outcome);
233
+ if (report.secretSource === "ephemeral") {
234
+ capture({
235
+ phase: "session",
236
+ mediaRef: EPHEMERAL_SECRET_IDENTITY,
237
+ terminal: false,
238
+ message: "server cookie secret is ephemeral \u2014 private renders will break across restarts/instances (set a persistent files-session secret on the server)"
239
+ });
240
+ }
241
+ if (report.verdict === "retry") {
135
242
  setResult((current) => ({
136
243
  source: src,
137
244
  retryKey: current.source === src ? current.retryKey + 1 : 1,
@@ -139,11 +246,26 @@ function useMediaLoadRecovery(src, options) {
139
246
  }));
140
247
  } else {
141
248
  setResult({ source: src, retryKey: 0, failed: true });
249
+ capture({
250
+ phase: attempt > 1 ? "element-load" : "recovery",
251
+ mediaRef: identity(),
252
+ terminal: true,
253
+ retryOutcome: attempt > 1 ? "retried" : "not-attempted",
254
+ message: attempt > 1 ? "media element failed to load after the session-refresh retry" : "media load recovery declined on the first attempt (session refresh did not produce a usable session)",
255
+ ...report.status !== void 0 ? { status: report.status } : {}
256
+ });
142
257
  onTerminal?.(event);
143
258
  }
144
259
  },
145
- () => {
260
+ (err) => {
146
261
  setResult({ source: src, retryKey: 0, failed: true });
262
+ capture({
263
+ phase: "session",
264
+ mediaRef: identity(),
265
+ terminal: true,
266
+ retryOutcome: "recovery-failed",
267
+ message: `session recovery failed: ${err instanceof Error ? err.message : String(err ?? "unknown error")}`
268
+ });
147
269
  onTerminal?.(event);
148
270
  }
149
271
  );
@@ -156,13 +278,21 @@ function useMediaLoadRecovery(src, options) {
156
278
  }
157
279
 
158
280
  // src/core/use-media-blob.ts
159
- var import_react4 = require("react");
281
+ var import_react5 = require("react");
282
+ function statusOf(err) {
283
+ if (err && typeof err === "object") {
284
+ const candidate = err.status ?? err.statusCode;
285
+ if (typeof candidate === "number") return candidate;
286
+ }
287
+ return void 0;
288
+ }
160
289
  function useMediaBlob(ref) {
161
290
  const client = useMediaClient();
291
+ const capture = useMediaFailureCapture();
162
292
  const refKey = stableRefKey(ref);
163
- const [state, setState] = (0, import_react4.useState)({ url: null, blob: null, loading: !!ref, error: null });
164
- const [retryToken, setRetryToken] = (0, import_react4.useState)(0);
165
- (0, import_react4.useEffect)(() => {
293
+ const [state, setState] = (0, import_react5.useState)({ url: null, blob: null, loading: !!ref, error: null });
294
+ const [retryToken, setRetryToken] = (0, import_react5.useState)(0);
295
+ (0, import_react5.useEffect)(() => {
166
296
  if (!ref) {
167
297
  setState({ url: null, blob: null, loading: false, error: null });
168
298
  return void 0;
@@ -183,6 +313,14 @@ function useMediaBlob(ref) {
183
313
  if (cancelled) return;
184
314
  const message = err instanceof Error ? err.message : String(err ?? "Failed to load file");
185
315
  setState({ url: null, blob: null, loading: false, error: message });
316
+ const status = statusOf(err);
317
+ capture({
318
+ phase: "blob",
319
+ mediaRef: failureMediaIdentity(ref),
320
+ terminal: true,
321
+ message: `getBlob() failed: ${message}`,
322
+ ...status !== void 0 ? { status } : {}
323
+ });
186
324
  }
187
325
  );
188
326
  return () => {
@@ -197,17 +335,17 @@ function useMediaBlob(ref) {
197
335
  }
198
336
 
199
337
  // src/core/use-media-upload.ts
200
- var import_react5 = require("react");
338
+ var import_react6 = require("react");
201
339
  var entrySeq = 0;
202
340
  function useMediaUpload() {
203
341
  const client = useMediaClient();
204
- const [uploading, setUploading] = (0, import_react5.useState)(false);
205
- const [progress, setProgress] = (0, import_react5.useState)(null);
206
- const [result, setResult] = (0, import_react5.useState)(null);
207
- const [error, setError] = (0, import_react5.useState)(null);
208
- const [entries, setEntries] = (0, import_react5.useState)([]);
209
- const activeCount = (0, import_react5.useRef)(0);
210
- const patchEntry = (0, import_react5.useCallback)(
342
+ const [uploading, setUploading] = (0, import_react6.useState)(false);
343
+ const [progress, setProgress] = (0, import_react6.useState)(null);
344
+ const [result, setResult] = (0, import_react6.useState)(null);
345
+ const [error, setError] = (0, import_react6.useState)(null);
346
+ const [entries, setEntries] = (0, import_react6.useState)([]);
347
+ const activeCount = (0, import_react6.useRef)(0);
348
+ const patchEntry = (0, import_react6.useCallback)(
211
349
  (id, patch) => {
212
350
  setEntries(
213
351
  (current) => current.map((e) => e.id === id ? { ...e, ...patch } : e)
@@ -215,7 +353,7 @@ function useMediaUpload() {
215
353
  },
216
354
  []
217
355
  );
218
- const upload = (0, import_react5.useCallback)(
356
+ const upload = (0, import_react6.useCallback)(
219
357
  async (file, opts = {}) => {
220
358
  const id = `up-${++entrySeq}`;
221
359
  const fileName = opts.fileName ?? (file instanceof File ? file.name : "file");
@@ -269,7 +407,7 @@ function useMediaUpload() {
269
407
  },
270
408
  [client, patchEntry]
271
409
  );
272
- const uploadMany = (0, import_react5.useCallback)(
410
+ const uploadMany = (0, import_react6.useCallback)(
273
411
  async (files, opts = {}) => {
274
412
  if (client.uploadMany) {
275
413
  activeCount.current += 1;
@@ -298,10 +436,10 @@ function useMediaUpload() {
298
436
  },
299
437
  [client, upload]
300
438
  );
301
- const clearEntry = (0, import_react5.useCallback)((id) => {
439
+ const clearEntry = (0, import_react6.useCallback)((id) => {
302
440
  setEntries((current) => current.filter((e) => e.id !== id));
303
441
  }, []);
304
- const reset = (0, import_react5.useCallback)(() => {
442
+ const reset = (0, import_react6.useCallback)(() => {
305
443
  setResult(null);
306
444
  setError(null);
307
445
  setProgress(null);
@@ -321,12 +459,12 @@ function useMediaUpload() {
321
459
  }
322
460
 
323
461
  // src/core/use-thumbnail-source.ts
324
- var import_react6 = require("react");
462
+ var import_react7 = require("react");
325
463
  function useThumbnailSource(ref, options = {}) {
326
464
  const { thumbnailUrl, allowSourceFallback = true } = options;
327
465
  const client = useMediaClient();
328
466
  const { resolution } = useMediaResolution(ref);
329
- const explicitThumb = (0, import_react6.useMemo)(() => {
467
+ const explicitThumb = (0, import_react7.useMemo)(() => {
330
468
  if (!thumbnailUrl) return null;
331
469
  try {
332
470
  return client.resolve(thumbnailUrl);
@@ -393,19 +531,19 @@ function useThumbnailSource(ref, options = {}) {
393
531
  }
394
532
 
395
533
  // src/core/use-media-actions.ts
396
- var import_react7 = require("react");
534
+ var import_react8 = require("react");
397
535
  function useMediaActions(ref, options = {}) {
398
536
  const ports = useMediaHostPorts();
399
537
  const { resolution } = useMediaResolution(ref);
400
- const [busy, setBusy] = (0, import_react7.useState)(null);
401
- const [lastError, setLastError] = (0, import_react7.useState)(null);
538
+ const [busy, setBusy] = (0, import_react8.useState)(null);
539
+ const [lastError, setLastError] = (0, import_react8.useState)(null);
402
540
  const actions = ports.actions;
403
541
  const { fileName, alt } = options;
404
- const context = (0, import_react7.useMemo)(() => {
542
+ const context = (0, import_react8.useMemo)(() => {
405
543
  if (!ref) return null;
406
544
  return { ref, resolution, fileName, alt };
407
545
  }, [ref, resolution, fileName, alt]);
408
- const isAvailable = (0, import_react7.useCallback)(
546
+ const isAvailable = (0, import_react8.useCallback)(
409
547
  (kind) => {
410
548
  if (!context || !actions) return false;
411
549
  if (kind === "share") return Boolean(actions.share || actions.SharePopover);
@@ -413,7 +551,7 @@ function useMediaActions(ref, options = {}) {
413
551
  },
414
552
  [context, actions]
415
553
  );
416
- const run = (0, import_react7.useCallback)(
554
+ const run = (0, import_react8.useCallback)(
417
555
  async (kind) => {
418
556
  if (!context || !actions) return;
419
557
  const handler = actions[kind];