hci-atrium 0.1.0 → 0.3.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.
Files changed (96) hide show
  1. package/README.md +266 -8
  2. package/dist/asset-meta.d.ts +31 -0
  3. package/dist/asset-meta.d.ts.map +1 -0
  4. package/dist/asset-meta.js +25 -0
  5. package/dist/asset-meta.js.map +1 -0
  6. package/dist/atrium.d.ts +220 -7
  7. package/dist/atrium.d.ts.map +1 -1
  8. package/dist/atrium.js +410 -39
  9. package/dist/atrium.js.map +1 -1
  10. package/dist/dash.d.ts +206 -0
  11. package/dist/dash.d.ts.map +1 -0
  12. package/dist/dash.js +302 -0
  13. package/dist/dash.js.map +1 -0
  14. package/dist/direct.d.ts +86 -0
  15. package/dist/direct.d.ts.map +1 -0
  16. package/dist/direct.js +121 -0
  17. package/dist/direct.js.map +1 -0
  18. package/dist/index.d.ts +8 -2
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +6 -1
  21. package/dist/index.js.map +1 -1
  22. package/dist/models.d.ts +70 -0
  23. package/dist/models.d.ts.map +1 -1
  24. package/dist/models.js +56 -0
  25. package/dist/models.js.map +1 -1
  26. package/dist/player/audio-layers.d.ts +306 -0
  27. package/dist/player/audio-layers.d.ts.map +1 -0
  28. package/dist/player/audio-layers.js +1245 -0
  29. package/dist/player/audio-layers.js.map +1 -0
  30. package/dist/player/index.d.ts +13 -0
  31. package/dist/player/index.d.ts.map +1 -0
  32. package/dist/player/index.js +13 -0
  33. package/dist/player/index.js.map +1 -0
  34. package/dist/player/layer-signals.d.ts +80 -0
  35. package/dist/player/layer-signals.d.ts.map +1 -0
  36. package/dist/player/layer-signals.js +174 -0
  37. package/dist/player/layer-signals.js.map +1 -0
  38. package/dist/player/play-command.d.ts +55 -0
  39. package/dist/player/play-command.d.ts.map +1 -0
  40. package/dist/player/play-command.js +36 -0
  41. package/dist/player/play-command.js.map +1 -0
  42. package/dist/player/play-intent.d.ts +18 -0
  43. package/dist/player/play-intent.d.ts.map +1 -0
  44. package/dist/player/play-intent.js +26 -0
  45. package/dist/player/play-intent.js.map +1 -0
  46. package/dist/player/runtime.d.ts +105 -0
  47. package/dist/player/runtime.d.ts.map +1 -0
  48. package/dist/player/runtime.js +402 -0
  49. package/dist/player/runtime.js.map +1 -0
  50. package/dist/react/index.d.ts +1 -0
  51. package/dist/react/index.d.ts.map +1 -1
  52. package/dist/react/index.js +1 -0
  53. package/dist/react/index.js.map +1 -1
  54. package/dist/react/use-room-connection.d.ts +40 -0
  55. package/dist/react/use-room-connection.d.ts.map +1 -0
  56. package/dist/react/use-room-connection.js +85 -0
  57. package/dist/react/use-room-connection.js.map +1 -0
  58. package/dist/rooms/recorder.d.ts +4 -0
  59. package/dist/rooms/recorder.d.ts.map +1 -1
  60. package/dist/rooms/recorder.js +7 -0
  61. package/dist/rooms/recorder.js.map +1 -1
  62. package/dist/rooms/room.d.ts +78 -0
  63. package/dist/rooms/room.d.ts.map +1 -1
  64. package/dist/rooms/room.js +208 -3
  65. package/dist/rooms/room.js.map +1 -1
  66. package/dist/rooms/standing-source.d.ts +3 -0
  67. package/dist/rooms/standing-source.d.ts.map +1 -1
  68. package/dist/rooms/standing-source.js +6 -0
  69. package/dist/rooms/standing-source.js.map +1 -1
  70. package/dist/rooms/transcriber.d.ts +4 -0
  71. package/dist/rooms/transcriber.d.ts.map +1 -1
  72. package/dist/rooms/transcriber.js +1 -0
  73. package/dist/rooms/transcriber.js.map +1 -1
  74. package/dist/shaping/index.d.ts +1 -0
  75. package/dist/shaping/index.d.ts.map +1 -1
  76. package/dist/shaping/index.js +1 -0
  77. package/dist/shaping/index.js.map +1 -1
  78. package/dist/shaping/request.d.ts +1 -1
  79. package/dist/shaping/request.d.ts.map +1 -1
  80. package/dist/shaping/surveys.d.ts +69 -0
  81. package/dist/shaping/surveys.d.ts.map +1 -0
  82. package/dist/shaping/surveys.js +129 -0
  83. package/dist/shaping/surveys.js.map +1 -0
  84. package/dist/tools.d.ts +84 -0
  85. package/dist/tools.d.ts.map +1 -0
  86. package/dist/tools.js +105 -0
  87. package/dist/tools.js.map +1 -0
  88. package/dist/trace.d.ts +96 -0
  89. package/dist/trace.d.ts.map +1 -0
  90. package/dist/trace.js +251 -0
  91. package/dist/trace.js.map +1 -0
  92. package/dist/transcribe.d.ts +5 -0
  93. package/dist/transcribe.d.ts.map +1 -1
  94. package/dist/transcribe.js +1 -1
  95. package/dist/transcribe.js.map +1 -1
  96. package/package.json +5 -1
package/README.md CHANGED
@@ -57,6 +57,36 @@ function Dashboard() {
57
57
  Atrium" button otherwise (pass `fallback` for your own landing page). The callback that Atrium
58
58
  redirects back to is handled automatically — no route or effect to wire up.
59
59
 
60
+ ### useRoomConnection
61
+
62
+ `useRoomConnection(code, { identity, baseUrl?, onRoom? })` is the connect/reconnect skeleton for a
63
+ component that consumes a [room](#live-rooms) by connect code — loginless, so it needs no provider
64
+ and no sign-in. It joins, classifies failures, and tears the handle down on unmount, returning
65
+ `{ status, room }`: `status` is `"connecting"` → `"connected"`, `"unreachable"` for a join/connect
66
+ failure, or a typed terminal end (`"bad-code"` / `"code-rotated"` / `"room-gone"`); `room` is the
67
+ `Room` handle, `null` before it exists and again after a terminal end. Register signal handlers in
68
+ `onRoom` — it runs the moment the handle exists, **before** `connect()`, so a replayed durable signal
69
+ isn't missed — and return a cleanup from it if you need one. `identity` is the presence name (e.g.
70
+ `"viewer:web"`); `baseUrl` defaults to `location.origin`.
71
+
72
+ ```tsx
73
+ import { useState } from "react"
74
+ import { useRoomConnection } from "hci-atrium/react"
75
+
76
+ function Scene({ code }: { code: string }) {
77
+ const [name, setName] = useState("—")
78
+ const { status, room } = useRoomConnection(code, {
79
+ identity: "viewer:web",
80
+ onRoom: (room) => {
81
+ room.on("scene.change", (e) => setName(String(e.payload.name)))
82
+ },
83
+ })
84
+
85
+ if (status !== "connected") return <p>{status}</p>
86
+ return <button onClick={() => void room?.send("applause")}>{name}</button>
87
+ }
88
+ ```
89
+
60
90
  ## Core API (no framework)
61
91
 
62
92
  ```ts
@@ -89,10 +119,10 @@ const atrium = new Atrium({ baseUrl, clientId })
89
119
  (retry once) on a 401, and concurrent callers share one refresh. If refresh fails, the SDK drops
90
120
  to a signed-out state and emits a change.
91
121
 
92
- ## Phase-1 surface
122
+ ## The client surface
93
123
 
94
- Beyond auth, the client exposes the library / assets / sources / sessions / rooms namespaces plus
95
- `chat` and `embed`. Every method takes an **options object** with `filters` kept nested
124
+ Beyond auth, the client exposes the library / assets / sources / sessions / rooms / surveys
125
+ namespaces plus `chat`, `agent` and `embed`. Every method takes an **options object** with `filters` kept nested
96
126
  (`search("rain", { modality, filters: { … } })`); names are **camelCase**, translated to the wire's
97
127
  snake_case in one place (the shaping layer). Authenticate with an OAuth `clientId` **or** an
98
128
  `apiKey` — the latter works in the browser too (a one-time console note points at PKCE for hosted
@@ -108,7 +138,10 @@ apps).
108
138
  | `sources.list()` / `sources.search(query, opts?)` / `sources.import(opts)` | External-provider gateway — read-only fan-out search, then import a `Candidate`. |
109
139
  | `sessions.start(opts?)` / `sessions.resume(id)` / `sessions.event(id, type, opts?)` | Durable, append-only session logs. |
110
140
  | `rooms.open(opts?)` / `rooms.get(id)` / `rooms.list()` / `rooms.join(code)` | Live [`Room`](#live-rooms) handles — `open`/`get`/`list` are authenticated, `join(code)` is a loginless participant. |
111
- | `chat(messages, opts?)` | Chat completion. Pass `responseFormat` a **Zod** schema for validated structured output (Zod is an optional peer dep). |
141
+ | `surveys.start(template?, opts)` / `surveys.template(name, body, opts?)` / | [Surveys](#surveys) on a session, and the project's survey templates. |
142
+ | `chat(messages, opts?)` | Chat completion. Pass `responseFormat` a **Zod** schema for validated structured output (Zod is an optional peer dep), or `stream: true` for an [`AsyncIterable<string>`](#streaming-chat) of deltas. |
143
+ | `agent(prompt, { tools, … })` | A [tool-calling loop](#agents-and-tools) over explicit tool descriptors. |
144
+ | `addDirectModel(name, { baseUrl, apiKey? })` | Route one model name straight to an OpenAI-compatible endpoint ([direct models](#direct-models)) instead of Atrium. |
112
145
  | `embed(opts)` | Embed exactly one of `text` / `image` / `audio` / `video` into a vector; `model` is required. |
113
146
  | `transcribe(audio, opts?)` | Speech-to-text over a multipart audio upload; `opts.verbose` fills `.language` / `.duration` / `.segments`. |
114
147
  | `by.*` | Operand grammar for `searchByExamples` / `compare`: `by.asset` / `by.text` / `by.file` / `by.concept`, and `by.vector([…])` — the only way to pass a raw vector. |
@@ -125,15 +158,172 @@ a matching `room.request(...)`); `room.send(type, payload, { to })` fans out; pl
125
158
  loudness/spectrum/beat stream. `connect()` opens the socket (every wire method calls it for you);
126
159
  `close()` / `await using` / a passed `AbortSignal` tears it down.
127
160
 
128
- Not yet ported (environment- or broker-bound, tracked as parity exceptions): `record(into=session)`
129
- host bridging, and `audioVectors` / `audioScores` (the embed/chat-broker protocol). Audio-signal
130
- *consumption* is complete. In Node <22 pass a `WebSocketCtor` (e.g. from `ws`); browsers and Node ≥22
131
- use the global.
161
+ Host-side surfaces on an **owned** room (`atrium.rooms.open/get/join`, not the loginless `joinRoom`):
162
+
163
+ | Member | Description |
164
+ | --- | --- |
165
+ | `room.record(into)` / `room.transcribe(opts?)` | Bridge the room into a durable session; run live transcription into `transcript.*` signals. |
166
+ | `room.audioVectors(opts?)` / `room.audioScores(anchors, opts?)` / `room.audioClips()` | Embed / score / take raw the live ~10s audio windows. |
167
+ | `room.audioTranscripts(opts?)` | The raw per-snippet transcript iterator — the speech-to-text sibling of `audioVectors`. Streaming when the backend offers it, per-clip batch otherwise (then each transcript carries its source window under `raw.snippet`). Takes `{ model, language, partials, skipEmpty, signal }`; `break` (or abort) ends the subscription. |
168
+ | `room.dashboard(opts?)` → [`Dashboard`](#observation-dashboard) | Host-side state for the hosted observation dashboard (`/tools/dashboard`). |
169
+ | `room.trace()` → `RoomTrace` | Mirror the SDK's own activity into the room as `trace.*` signals (default off). |
170
+ | `room.offerTranscription()` / `offerAudioCapture()` / `offerEmbeds()` / `offerChat()` | Standing services + capability brokers for code-joined peers. |
171
+
172
+ This SDK now covers every Python-SDK surface except the documented parity exceptions — the CLI, the
173
+ Home Assistant bridge, a sync surface, and raw-PCM mic input — each with its reason in
174
+ [`PARITY.md`](PARITY.md), alongside the handful of deliberate micro-divergences. In Node <22 pass a
175
+ `WebSocketCtor` (e.g. from `ws`); browsers and Node ≥22 use the global.
176
+
177
+ ### Observation dashboard
178
+
179
+ `room.dashboard({ title })` returns a `Dashboard`: the app owns its state as a grid of **tiles** and
180
+ retells it — each joining dashboard gets a full `dash.snapshot`, every mutation broadcasts a
181
+ `dash.delta`. All `dash.*` traffic is ephemeral (the room stores nothing — the durable record is the
182
+ session). Tiles: `status(id, label, value, { style })` · `text(id, text)` · `image(id, { assetId,
183
+ url, caption })` · `sound(id, { assetId, playing })` · `decision(id, candidates, { picked,
184
+ reasoning })`, each taking `{ title, colSpan, rowSpan, group }` (spans 1..3). `error(message,
185
+ detail?)` pins the dashboard's sticky error strip — it is *not* a tile. Controls ride the snapshot:
186
+ `button(id, label, handler, { variant, confirm })`, `textInput(id, handler, { label, placeholder })`
187
+ (the handler's `{ ok?, message? }` is toasted), and `onNote(handler)` for researcher notes.
188
+
189
+ ```ts
190
+ const dash = room.dashboard({ title: "Reactive Soundscape" })
191
+ await dash.status("phase", "Phase", "Calibration", { style: "good" })
192
+ await dash.decision("pick", ["Forest Rain", "City Drizzle"], { picked: "Forest Rain" })
193
+ dash.button("advance", "Start next phase", async () => {
194
+ await dash.status("phase", "Phase", "Running")
195
+ return { message: "Phase advanced" }
196
+ })
197
+ ```
198
+
199
+ ### Tracing
200
+
201
+ `room.trace()` mirrors what the SDK is doing into the room, so a dashboard shows it without any
202
+ app-side plumbing. Library/model calls on the same client (`library.search` / `searchByExamples` /
203
+ `enrich` / `chat`) and `room.play` surface a `trace.<label>` (a one-line summary plus a capped
204
+ detail); recorder / transcriber / standing-offer transitions surface a `trace.state.<label>`. It is
205
+ **off by default** (it broadcasts app internals to anyone holding the connect code), costs nothing
206
+ while off, never breaks the traced call, and stops with `t.stop()`, `using t = room.trace()`, or the
207
+ room's teardown.
208
+
209
+ ```ts
210
+ using t = room.trace()
211
+ await atrium.library.search("rain") // → trace.library.search on the dashboard
212
+ ```
132
213
 
133
214
  Errors derive from `AtriumError`: HTTP failures are `APIError` (401/403 → `AuthError`, 429/5xx →
134
215
  `TransientAuthError`), and caller mistakes (mutually-exclusive inputs, unsupported filters) are
135
216
  `InvalidArgumentError`.
136
217
 
218
+ ### Surveys
219
+
220
+ A **survey** is a data object on a session, guarded by a per-object capability token: whoever holds
221
+ it can read and write exactly that object, nothing else. `surveys.start()` creates it — pinning the
222
+ template's *current* version, so later edits never change a running survey — and returns the token
223
+ plus the hosted tool's `url`. Hand either to the participant-facing environment, or `survey.open()`
224
+ a tab on the machine itself.
225
+
226
+ `surveys.template(name, body)` is the code-first **ensure** flow: run it on every start-up. It
227
+ creates the template if it's missing, appends a new immutable version only when the body actually
228
+ changed, and flips `default` when asked — so your templates live in your repo, not in a UI.
229
+
230
+ ```ts
231
+ await atrium.surveys.template("post-study", postStudyBody, { default: true })
232
+
233
+ const session = await atrium.sessions.start({ participantRef: "p-07" })
234
+ const survey = await atrium.surveys.start("post-study", {
235
+ into: session,
236
+ returnTo: "https://study.example/next",
237
+ })
238
+ survey.open() // …or send survey.url / survey.token to the participant's device
239
+
240
+ const rows = await atrium.surveys.responses("post-study", { submittedOnly: true })
241
+ const csv = await atrium.surveys.responsesCsv("post-study") // Uint8Array, one row per response
242
+ ```
243
+
244
+ Everything project-scoped (`template` / `templates` / `responses` / `responsesCsv` / `uploadFile`)
245
+ takes `project` per call, or inherits the client's. `uploadFile(blob)` stores a write-once project
246
+ file (survey imagery) and returns the `id` a template body's `image` field references.
247
+
248
+ ### Streaming chat
249
+
250
+ `chat(messages, { stream: true })` returns an `AsyncIterable<string>` of content deltas — **not** a
251
+ promise, so there is nothing to `await` before the loop. It works the same on a direct endpoint, and
252
+ can't be combined with a schema `responseFormat` (structured output validates a complete reply).
253
+
254
+ ```ts
255
+ for await (const piece of atrium.chat("describe a rainy street", { stream: true })) {
256
+ process.stdout.write(piece)
257
+ }
258
+ ```
259
+
260
+ ### Agents and tools
261
+
262
+ `agent(prompt, { tools })` runs the tool-calling loop: the model may call your tools until it
263
+ answers, or until `maxTurns` (default 6). A tool is an explicit descriptor — TypeScript has no
264
+ runtime signatures or docstrings to derive a schema from, so what you write is what the model sees.
265
+ Pass `responseFormat` a Zod schema to structure the **final** answer (it applies after the loop —
266
+ json-mode conflicts with the tool-calling turns).
267
+
268
+ ```ts
269
+ const reply = await atrium.agent("what should I play for a storm scene?", {
270
+ tools: [
271
+ {
272
+ name: "search_library",
273
+ description: "Find sounds in the Atrium library.",
274
+ parameters: {
275
+ type: "object",
276
+ properties: { query: { type: "string" } },
277
+ required: ["query"],
278
+ },
279
+ execute: async ({ query }) => {
280
+ const hits = await atrium.library.search(String(query), { modality: "audio", limit: 3 })
281
+ return hits.map((h) => h.asset.title)
282
+ },
283
+ },
284
+ ],
285
+ })
286
+ ```
287
+
288
+ `parameters` is plain JSON Schema. To validate the model's arguments as well, pass
289
+ `{ jsonSchema, validate }` where `validate` is any [Standard Schema](https://standardschema.dev)
290
+ object (Zod ≥3.24, Valibot, ArkType): its parsed output is what `execute` receives. A *bare* schema
291
+ object is rejected — deriving JSON Schema from it would mean depending on that library, and this
292
+ package has no runtime dependencies.
293
+
294
+ ```ts
295
+ import { z } from "zod"
296
+
297
+ const city = z.object({ city: z.string() })
298
+ const tool = {
299
+ name: "get_weather",
300
+ parameters: {
301
+ jsonSchema: { type: "object", properties: { city: { type: "string" } }, required: ["city"] },
302
+ validate: city,
303
+ },
304
+ execute: ({ city }) => forecastFor(String(city)),
305
+ }
306
+ ```
307
+
308
+ ### Direct models
309
+
310
+ Register a model name to route its `chat` / `agent` calls **straight** to an OpenAI-compatible
311
+ endpoint — a local Ollama or LM Studio, or any remote server — instead of Atrium. A registered name
312
+ shadows an Atrium model of the same name (that's the point), and only chat is intercepted:
313
+ embeddings, search, sessions and rooms always go to Atrium.
314
+
315
+ ```ts
316
+ atrium.addDirectModel("gemma3", { baseUrl: "http://localhost:11434/v1" })
317
+ await atrium.chat("hi", { model: "gemma3" }) // → localhost, not Atrium
318
+
319
+ await atrium.discoverModels("http://localhost:11434/v1") // what's installed? (registers nothing)
320
+ ```
321
+
322
+ Direct calls carry **only** that endpoint's own key (or none) — your Atrium credential is never sent
323
+ to a third-party endpoint. `discoverModels` is subject to browser CORS: a local server that sends no
324
+ `Access-Control-Allow-Origin` blocks the read before the SDK sees it (that's the endpoint's policy to
325
+ change, e.g. Ollama's `OLLAMA_ORIGINS`); from Node it just works.
326
+
137
327
  ## Quickstart (search + play)
138
328
 
139
329
  ```ts
@@ -164,6 +354,72 @@ room.audioSignals((sig) => {
164
354
  })
165
355
  ```
166
356
 
357
+ ## Player
358
+
359
+ The `hci-atrium/player` subpath ships the audio-layers engine (`LayerEngine`), the headless
360
+ [`PlayerRuntime`](#playerruntime), and the `play` / `layer.*` dialect helpers that back the hosted
361
+ [Player tool](../../tools) — named, independently mixed WebAudio layers (music/ambient/SFX) with
362
+ crossfades, reverb/echo/distance effects, and 3D positioning. It's the code an embedding app drives
363
+ from resolved room signals; it owns the per-layer WebAudio graph and nothing about React or
364
+ transport.
365
+
366
+ ```ts
367
+ import { LayerEngine } from "hci-atrium/player"
368
+
369
+ const engine = new LayerEngine({ context: audioContext, destination: masterGain, loadBuffer })
370
+ engine.configure([{ id: "music", volume: 0.8 }])
371
+ engine.enqueue("music", [{ url: trackUrl }], "now")
372
+ ```
373
+
374
+ See [`docs/reference/audio-layers-dialect.md`](../../docs/reference/audio-layers-dialect.md) for the
375
+ full wire dialect.
376
+
377
+ ### PlayerRuntime
378
+
379
+ `PlayerRuntime` is the headless Player — the dialect brain of the hosted tool, lifted out of its
380
+ page. It owns the master chain (gain → limiter) *and* a `LayerEngine`, and dispatches the whole
381
+ `play` / `layer.*` dialect: hand it room frames via `handleSignal(frame)` and give it a `send` sink
382
+ for its outbound traffic (`layer.query` replies and `layer.state` broadcasts to `layer.subscribe`rs).
383
+ It never opens a socket, touches the DOM, or imports React — transport, UI, and
384
+ the intents it can't play itself (Spotify, "open link") stay with the host, the latter arriving
385
+ through the `unhandled` hook.
386
+
387
+ ```ts
388
+ import { joinRoom } from "hci-atrium"
389
+ import { PlayerRuntime } from "hci-atrium/player"
390
+
391
+ const baseUrl = "https://atrium.example.edu"
392
+ const room = await joinRoom("ABC12345", { baseUrl })
393
+ const context = new AudioContext() // create it on the user's Start gesture — autoplay policy
394
+
395
+ const runtime = new PlayerRuntime({
396
+ context,
397
+ baseUrl, // asset-resolution root for the `asset_id`-only play shape
398
+ send: (type, payload, opts) => void room.send(type, payload as Record<string, unknown>, opts),
399
+ onLog: (line) => console.log(line),
400
+ onSnapshot: (layers) => renderLayers(layers), // engine snapshot → your UI
401
+ })
402
+
403
+ // Every inbound frame goes to the runtime; types outside the dialect fall through to `unhandled`.
404
+ room.onAny((e) =>
405
+ runtime.handleSignal({
406
+ type: e.type,
407
+ payload: e.payload,
408
+ from: e.from ?? undefined,
409
+ ack_id: e.ackId ?? undefined,
410
+ }),
411
+ )
412
+ await room.connect()
413
+ ```
414
+
415
+ `LayerEngine` is the mixer; `PlayerRuntime` is the mixer *plus* the dialect. It exposes
416
+ `runtime.engine` for listener-side controls (per-layer / master volume, normalization, device-change
417
+ resume) and `runtime.masterGain` as an analyzer tap (pre-limiter); `dispose()` tears down the engine,
418
+ its timers, and the master chain it built, leaving the host's `AudioContext` alone. One caveat on the
419
+ `send` sink: `Room.send` carries `to` and `ephemeral` but not `replyTo`, so a host that must answer a
420
+ peer's `layer.query` builds the envelope itself — `buildSignalEnvelope` from the same subpath does
421
+ exactly that for a `POST /v1/rooms/{id}/signal` transport, which is what the hosted Player runs on.
422
+
167
423
  ## Honest caveat: the guard is UX, not secrecy
168
424
 
169
425
  For a static single-page app the client-side guard is **user experience, not a security boundary**
@@ -180,3 +436,5 @@ pnpm build # tsc → dist/ (.js + .d.ts)
180
436
  pnpm test # vitest (mocked fetch, no live server)
181
437
  pnpm lint # biome check
182
438
  ```
439
+
440
+ Releases are published to npm by hand from the maintainer's account — there is no CI publish job.
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Single-asset metadata fetch — the public content-by-id capability.
3
+ *
4
+ * ``GET /v1/assets/{id}`` needs no auth (asset bytes + metadata are public by id), so this is a
5
+ * plain ``httpFetch`` against the public shape. Used by the browser tools (Viewer, Player) to recover a
6
+ * video mime + a name fallback for a bare ``show``/``play`` that only carried an asset id, and to
7
+ * read extracted ``facts`` (e.g. ``bpm`` for the analyzer's tempo prior). A failed request or an
8
+ * aborted one (superseded by a newer show/play) resolves to ``null`` — no error surfaces.
9
+ */
10
+ /** The public fields the browser tools read from an asset (mirrors ``AssetPublicRead``). */
11
+ export interface AssetMeta {
12
+ /** Human name from an external source; the UI falls back to ``filename`` when null. */
13
+ title?: string | null;
14
+ filename?: string;
15
+ content_type?: string;
16
+ /** Extracted Tier-1 facts (duration, loudness, dimensions, bpm, …) — an opaque passthrough. */
17
+ facts?: {
18
+ bpm?: number;
19
+ loudness?: number;
20
+ [k: string]: unknown;
21
+ };
22
+ }
23
+ /**
24
+ * Fetch an asset's public metadata by id. Returns ``null`` on a non-OK response, a network error,
25
+ * or an aborted request (via ``opts.signal``). ``baseUrl`` is the resolution root (``""`` for
26
+ * same-origin).
27
+ */
28
+ export declare function fetchAssetMeta(baseUrl: string, assetId: string, opts?: {
29
+ signal?: AbortSignal;
30
+ }): Promise<AssetMeta | null>;
31
+ //# sourceMappingURL=asset-meta.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"asset-meta.d.ts","sourceRoot":"","sources":["../src/asset-meta.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAIH,4FAA4F;AAC5F,MAAM,WAAW,SAAS;IACxB,uFAAuF;IACvF,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACrB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,+FAA+F;IAC/F,KAAK,CAAC,EAAE;QAAE,GAAG,CAAC,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;QAAC,CAAC,CAAC,EAAE,MAAM,GAAG,OAAO,CAAA;KAAE,CAAA;CAClE;AAED;;;;GAIG;AACH,wBAAsB,cAAc,CAClC,OAAO,EAAE,MAAM,EACf,OAAO,EAAE,MAAM,EACf,IAAI,CAAC,EAAE;IAAE,MAAM,CAAC,EAAE,WAAW,CAAA;CAAE,GAC9B,OAAO,CAAC,SAAS,GAAG,IAAI,CAAC,CAU3B"}
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Single-asset metadata fetch — the public content-by-id capability.
3
+ *
4
+ * ``GET /v1/assets/{id}`` needs no auth (asset bytes + metadata are public by id), so this is a
5
+ * plain ``httpFetch`` against the public shape. Used by the browser tools (Viewer, Player) to recover a
6
+ * video mime + a name fallback for a bare ``show``/``play`` that only carried an asset id, and to
7
+ * read extracted ``facts`` (e.g. ``bpm`` for the analyzer's tempo prior). A failed request or an
8
+ * aborted one (superseded by a newer show/play) resolves to ``null`` — no error surfaces.
9
+ */
10
+ import { httpFetch } from "./http.js";
11
+ /**
12
+ * Fetch an asset's public metadata by id. Returns ``null`` on a non-OK response, a network error,
13
+ * or an aborted request (via ``opts.signal``). ``baseUrl`` is the resolution root (``""`` for
14
+ * same-origin).
15
+ */
16
+ export async function fetchAssetMeta(baseUrl, assetId, opts) {
17
+ try {
18
+ const res = await httpFetch(`${baseUrl}/v1/assets/${encodeURIComponent(assetId)}`, opts?.signal ? { signal: opts.signal } : undefined);
19
+ return res.ok ? (await res.json()) : null;
20
+ }
21
+ catch {
22
+ return null;
23
+ }
24
+ }
25
+ //# sourceMappingURL=asset-meta.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"asset-meta.js","sourceRoot":"","sources":["../src/asset-meta.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAE,SAAS,EAAE,MAAM,WAAW,CAAA;AAYrC;;;;GAIG;AACH,MAAM,CAAC,KAAK,UAAU,cAAc,CAClC,OAAe,EACf,OAAe,EACf,IAA+B;IAE/B,IAAI,CAAC;QACH,MAAM,GAAG,GAAG,MAAM,SAAS,CACzB,GAAG,OAAO,cAAc,kBAAkB,CAAC,OAAO,CAAC,EAAE,EACrD,IAAI,EAAE,MAAM,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,IAAI,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC,SAAS,CACnD,CAAA;QACD,OAAO,GAAG,CAAC,EAAE,CAAC,CAAC,CAAE,CAAC,MAAM,GAAG,CAAC,IAAI,EAAE,CAAe,CAAC,CAAC,CAAC,IAAI,CAAA;IAC1D,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAA;IACb,CAAC;AACH,CAAC"}