@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 +185 -0
- package/README.md +82 -3
- package/dist/core.cjs +177 -39
- package/dist/core.cjs.map +1 -1
- package/dist/core.d.cts +84 -4
- package/dist/core.d.ts +84 -4
- package/dist/core.js +164 -26
- package/dist/core.js.map +1 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +77 -2
- package/dist/index.d.ts +77 -2
- package/dist/index.js.map +1 -1
- package/dist/next.cjs +92 -0
- package/dist/next.cjs.map +1 -0
- package/dist/next.d.cts +74 -0
- package/dist/next.d.ts +74 -0
- package/dist/next.js +60 -0
- package/dist/next.js.map +1 -0
- package/dist/react.cjs +231 -80
- package/dist/react.cjs.map +1 -1
- package/dist/react.d.cts +10 -1
- package/dist/react.d.ts +10 -1
- package/dist/react.js +209 -57
- package/dist/react.js.map +1 -1
- package/dist/viewers.cjs +1747 -0
- package/dist/viewers.cjs.map +1 -0
- package/dist/viewers.d.cts +289 -0
- package/dist/viewers.d.ts +289 -0
- package/dist/viewers.js +1729 -0
- package/dist/viewers.js.map +1 -0
- package/package.json +32 -5
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 `/
|
|
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/
|
|
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
|
-
|
|
91
|
-
if (!ref)
|
|
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)
|
|
95
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
|
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
|
|
120
|
-
const
|
|
121
|
-
(0,
|
|
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
|
-
(
|
|
134
|
-
|
|
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
|
|
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,
|
|
164
|
-
const [retryToken, setRetryToken] = (0,
|
|
165
|
-
(0,
|
|
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
|
|
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,
|
|
205
|
-
const [progress, setProgress] = (0,
|
|
206
|
-
const [result, setResult] = (0,
|
|
207
|
-
const [error, setError] = (0,
|
|
208
|
-
const [entries, setEntries] = (0,
|
|
209
|
-
const activeCount = (0,
|
|
210
|
-
const patchEntry = (0,
|
|
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,
|
|
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,
|
|
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,
|
|
439
|
+
const clearEntry = (0, import_react6.useCallback)((id) => {
|
|
302
440
|
setEntries((current) => current.filter((e) => e.id !== id));
|
|
303
441
|
}, []);
|
|
304
|
-
const reset = (0,
|
|
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
|
|
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,
|
|
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
|
|
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,
|
|
401
|
-
const [lastError, setLastError] = (0,
|
|
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,
|
|
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,
|
|
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,
|
|
554
|
+
const run = (0, import_react8.useCallback)(
|
|
417
555
|
async (kind) => {
|
|
418
556
|
if (!context || !actions) return;
|
|
419
557
|
const handler = actions[kind];
|