@tongue-core/ui 0.0.0-stage → 0.1.0-rc.10

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 (65) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +137 -2
  3. package/dist/budgets.d.ts +17 -0
  4. package/dist/budgets.js +12 -0
  5. package/dist/player/assets.d.ts +75 -0
  6. package/dist/player/assets.js +213 -0
  7. package/dist/player/audio.d.ts +72 -0
  8. package/dist/player/audio.js +182 -0
  9. package/dist/player/frames.d.ts +44 -0
  10. package/dist/player/frames.js +105 -0
  11. package/dist/player/index.d.ts +9 -0
  12. package/dist/player/index.js +14 -0
  13. package/dist/player/needs.d.ts +47 -0
  14. package/dist/player/needs.js +217 -0
  15. package/dist/player/player.d.ts +113 -0
  16. package/dist/player/player.js +532 -0
  17. package/dist/player/pool.d.ts +85 -0
  18. package/dist/player/pool.js +241 -0
  19. package/dist/player/protocol.d.ts +132 -0
  20. package/dist/player/protocol.js +200 -0
  21. package/dist/player/schedule.d.ts +54 -0
  22. package/dist/player/schedule.js +62 -0
  23. package/dist/player/source.d.ts +48 -0
  24. package/dist/player/source.js +79 -0
  25. package/dist/player/transport.d.ts +36 -0
  26. package/dist/player/transport.js +65 -0
  27. package/dist/player/video.d.ts +30 -0
  28. package/dist/player/video.js +368 -0
  29. package/dist/player/worker.d.ts +1 -0
  30. package/dist/player/worker.js +274 -0
  31. package/dist/react/context.d.ts +23 -0
  32. package/dist/react/context.js +24 -0
  33. package/dist/react/css.d.ts +6 -0
  34. package/dist/react/css.js +1 -0
  35. package/dist/react/hooks.d.ts +17 -0
  36. package/dist/react/hooks.js +40 -0
  37. package/dist/react/index.d.ts +5 -0
  38. package/dist/react/index.js +9 -0
  39. package/dist/react/keys.d.ts +1 -0
  40. package/dist/react/keys.js +64 -0
  41. package/dist/react/player.d.ts +5 -0
  42. package/dist/react/player.js +26 -0
  43. package/dist/react/timeline.d.ts +101 -0
  44. package/dist/react/timeline.js +254 -0
  45. package/dist/timeline/connect.d.ts +7 -0
  46. package/dist/timeline/connect.js +50 -0
  47. package/dist/timeline/index.d.ts +10 -0
  48. package/dist/timeline/index.js +14 -0
  49. package/dist/timeline/items.d.ts +25 -0
  50. package/dist/timeline/items.js +65 -0
  51. package/dist/timeline/model.d.ts +55 -0
  52. package/dist/timeline/model.js +92 -0
  53. package/dist/timeline/narration.d.ts +18 -0
  54. package/dist/timeline/narration.js +42 -0
  55. package/dist/timeline/previews.d.ts +39 -0
  56. package/dist/timeline/previews.js +106 -0
  57. package/dist/timeline/selection.d.ts +34 -0
  58. package/dist/timeline/selection.js +37 -0
  59. package/dist/timeline/ticks.d.ts +27 -0
  60. package/dist/timeline/ticks.js +56 -0
  61. package/dist/timeline/timeline.d.ts +108 -0
  62. package/dist/timeline/timeline.js +94 -0
  63. package/dist/timeline/view.d.ts +64 -0
  64. package/dist/timeline/view.js +83 -0
  65. package/package.json +50 -4
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Backpine
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,138 @@
1
- # Temporary Holding Version
1
+ # @tongue-core/ui
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ What a film's interface is built from: a **player**, a **timeline's state and geometry**, and
4
+ **unstyled React primitives** over the two. [@tongue-core/studio](../studio) is our app built from these
5
+ alone; a client builds theirs from the same pieces and gets the same speed, because the speed lives
6
+ below the UI. [docs/ui.md](../../docs/ui.md) is the design, with the budgets and what was measured.
7
+
8
+ ```bash
9
+ bun add @tongue-core/ui@next react react-dom effect @effect/atom-react # the last four are peers
10
+ ```
11
+
12
+ The player's worker (`@tongue-core/ui/player/worker`) is a script of its own: bundle it next to your
13
+ app and pass its URL to `createPlayer`.
14
+
15
+ ## Entry points
16
+
17
+ | Import | Holds | Depends on |
18
+ |---|---|---|
19
+ | `@tongue-core/ui/player` | `createPlayer`: a build played on a canvas, with workers, readiness, the frame cache, video and sound; and its parts (`FilmNeeds`, `AssetLoader`, `FrameCache`, `Transport`, `wantedFrames`) | the engine (in the workers only), `@tongue-core/project` |
20
+ | `@tongue-core/ui/player/worker` | the worker script: bundle it and pass its URL | the engine |
21
+ | `@tongue-core/ui/timeline` | `makeTimeline`: a timeline as Effect Atoms; `connectPlayer`; pure models of the film, the view, the ruler, selection, rows and previews | Effect, `@tongue-core/project/doc` |
22
+ | `@tongue-core/ui/react` | `TimelineProvider`, the `Timeline.*` primitives, `usePlayer`, `useTransport`, `useTimelineKeys`, `useAtomEffect` | React 19 (peer), `@effect/atom-react` |
23
+
24
+ The entries are boundaries, checked by bundling each alone: `/timeline` carries no engine and no
25
+ React; `/player`'s page code carries no React and no engine (the raster stays in the worker).
26
+
27
+ ## A player
28
+
29
+ ```ts
30
+ import { createPlayer } from "@tongue-core/ui/player";
31
+
32
+ const player = createPlayer({ canvas, workerUrl: new URL("./worker.js", import.meta.url) });
33
+ await player.load({ build: "https://cdn.example/films/promo/" }); // or "/build/": any folder with build.json
34
+ player.on("frame", ({ frame }) => …);
35
+ player.on("readiness", ({ ranges, progress }) => …); // what can be drawn, what is loaded
36
+ player.seek(240); player.play(); player.pause();
37
+ player.fit(canvas.clientWidth); // render at the size it is shown
38
+ const bitmap = await player.preview(900, 160); // a thumbnail, when the workers are free
39
+ ```
40
+
41
+ The picture keeps the film's shape in whatever box the page gives the canvas: unless the page
42
+ sets its own `object-fit`, the player sets `contain`, so a box of another shape letterboxes the
43
+ frame instead of stretching it.
44
+
45
+ A build is static files: `build.json`, `film.json`, `scenes/*.js`, `assets/…` (`tongue build`
46
+ writes one). The player fetches them from the build's folder or, given `read` instead of `build`,
47
+ reads them a file at a time from wherever the build is kept (a Git commit, memory, an archive):
48
+
49
+ ```ts
50
+ // Each path as the build gives it: "build.json", "scenes/title.5ebf1e9a05d5.js", "assets/fonts/inter.ttf"…
51
+ await player.load({ read: (path, signal) => commit.bytes(path, signal) });
52
+ ```
53
+
54
+ Either way it reads the manifests and bundles, then the asset files frames need, nearest the
55
+ playhead first, each checked against its hash. **A frame is drawn only once every file it needs
56
+ is there**; until then the last frame stays up. Video plays from the proxy segments import makes
57
+ (H.264, decoded with WebCodecs in a worker), sound from the soundtrack mixed a few seconds at a time.
58
+ Frames, video frames and sound are byte-for-byte what Bun makes from the same build.
59
+
60
+ Version 2 builds can keep their media outside the build. The player reads each remote file from
61
+ the URL in its manifest, verifies its hash, and bypasses `read` for that file. A Git-backed studio
62
+ therefore needs only code and manifests in Git; originals are never fetched by the browser.
63
+ Remote builds fetch a bounded window of proxy video and segmented audio (15 seconds ahead by
64
+ default). Scenes request their actual video segments when drawn. Old compressed data and decoded
65
+ audio segments are evicted, with stale transfers cancelled after seeks; a queued request the window
66
+ leaves behind is cancelled too, so no decoder waits on a segment nobody will fetch. A video's
67
+ decodes wait for their segments before taking their turn (playback is never held up behind a
68
+ thumbnail on its way), and a jump skips playback's decodes still queued for where it was. `mediaCacheBytes`
69
+ (default 64 MiB) and `mediaAhead` configure the window; files required by active requests remain
70
+ pinned until those requests finish. Decoded video frames have a separate shared byte budget.
71
+
72
+ ```ts
73
+ await player.load({
74
+ read: (path, signal) => commit.bytes(path, signal),
75
+ request: (url, signal, headers) => fetch(url, { signal, headers }),
76
+ });
77
+ ```
78
+
79
+ The optional `request` hook supplies authentication or URL refresh. Transient downloads retry
80
+ through the shared media transport. `readiness.failed` reports permanent failures; `player.retry()`
81
+ retries them after the host restores access. `readiness.mediaBytes` reports retained compressed
82
+ data. Failure records also expose `reason`, `status` and `retryable` when known. For streaming
83
+ builds, progress counts describe the current fetch window. Readiness ranges for dynamic scene
84
+ video conservatively report already rendered exact frames, since a scene can choose arbitrary
85
+ source times. Local packaged builds preserve their existing background preloading behavior.
86
+
87
+ ## A timeline
88
+
89
+ ```ts
90
+ import * as AtomRegistry from "effect/reactivity/AtomRegistry";
91
+ import { connectPlayer, makeTimeline } from "@tongue-core/ui/timeline";
92
+
93
+ const registry = AtomRegistry.make();
94
+ const timeline = makeTimeline();
95
+ const part = connectPlayer(registry, timeline, player); // reports in, commands out
96
+
97
+ registry.get(timeline.model); // rows, clips, edges: film.json laid out
98
+ registry.set(timeline.commands, { type: "nextEdge" }); // intents; the player carries them out
99
+ registry.set(timeline.view, { type: "zoom", factor: 2, anchor: 300 });
100
+ registry.subscribe(timeline.items("base"), (items) => …); // what a row draws at this zoom
101
+ ```
102
+
103
+ What depends on what decides what it costs: the model is rebuilt only for a new film.json; what a
104
+ row draws changes only when the view crosses a power-of-two step of frames or of zoom; the playhead
105
+ is a report that nothing heavy reads. Commands are numbered, so a reported playhead never becomes a
106
+ seek. Everything under the atoms is pure and tested on its own (`view.ts`, `ticks.ts`, `items.ts`…).
107
+
108
+ ## React
109
+
110
+ ```tsx
111
+ import { Timeline, TimelineProvider, usePlayer, useTimelineKeys } from "@tongue-core/ui/react";
112
+
113
+ <TimelineProvider timeline={timeline} registry={registry} previews={player}>
114
+ <Timeline.Root>
115
+ <Timeline.Scroller>
116
+ <Timeline.Ruler />
117
+ <Timeline.Ready />
118
+ <Timeline.Rows>{(row) => <Timeline.Row key={row.id} row={row} />}</Timeline.Rows>
119
+ <Timeline.Playhead />
120
+ </Timeline.Scroller>
121
+ </Timeline.Root>
122
+ <Timeline.Timecode />
123
+ </TimelineProvider>
124
+ ```
125
+
126
+ Unstyled: every element has `data-tl-*` attributes to style by (`[data-tl-clip][data-selected]`,
127
+ `[data-tl-clip][data-kind="video"]`, `[data-tl-run]`…). Positions are frames in CSS variables times
128
+ the zoom (`--tl-ppf`), so zooming and scrolling are the browser's work, not React's; the playhead and
129
+ the timecode write to their own elements, so playing commits nothing. Clips narrower than a few
130
+ pixels join runs, one element each, so an hour of a thousand clips is a few hundred elements zoomed
131
+ out. `useAtomEffect(atom, f)` is the same trick for your own readouts.
132
+
133
+ ## Checks
134
+
135
+ `bun test` runs the pure parts and the atom graph (in a bare registry). In headless Chrome:
136
+ `tools/ui-bench/player-check.ts` (the player on built projects, local and cross-origin, against the
137
+ budgets), `tools/ui-bench/react-check.ts` (the primitives on an hour of clips) and
138
+ `tools/ui-bench/studio-check.ts` (the studio). The lab pages they drive are in `lab/`.
@@ -0,0 +1,17 @@
1
+ export interface Budgets {
2
+ /** From load() to the first frame on screen, warm cache, local build. */
3
+ readonly firstFrameMs: number;
4
+ /** A jump anywhere while paused, to the frame on screen (assets already loaded): p95. */
5
+ readonly seekP95Ms: number;
6
+ /** Frames shown per second while scrubbing (dragging the playhead). */
7
+ readonly scrubFps: number;
8
+ /** Frames dropped per minute of playback at 1080p, 30 fps, render scale 0.5. */
9
+ readonly droppedPerMinute: number;
10
+ /** Frame time while panning and zooming a timeline of 1,000 clips: p95. */
11
+ readonly timelineFrameP95Ms: number;
12
+ /** React commits per playback frame (the playhead is drawn outside React). */
13
+ readonly commitsPerPlaybackFrame: number;
14
+ /** Bytes of rendered frames kept in memory. */
15
+ readonly frameCacheBytes: number;
16
+ }
17
+ export declare const BUDGETS: Budgets;
@@ -0,0 +1,12 @@
1
+ // The performance budgets every layer of the UI is built and measured against. The benchmark
2
+ // harness (tools/ui-bench) runs generated films (an hour with a thousand clips, ten minutes of
3
+ // footage) in headless Chrome and fails when one is exceeded. docs/ui.md explains each.
4
+ export const BUDGETS = {
5
+ firstFrameMs: 1000,
6
+ seekP95Ms: 120,
7
+ scrubFps: 20,
8
+ droppedPerMinute: 30,
9
+ timelineFrameP95Ms: 16.7,
10
+ commitsPerPlaybackFrame: 0,
11
+ frameCacheBytes: 256 * 1024 * 1024,
12
+ };
@@ -0,0 +1,75 @@
1
+ import { MediaFailure } from "@tongue-core/project/media";
2
+ export interface AssetFailureInfo {
3
+ readonly message: string;
4
+ readonly reason?: MediaFailure["reason"];
5
+ readonly status?: number;
6
+ readonly retryable?: boolean;
7
+ }
8
+ export interface AssetFileInfo {
9
+ readonly url?: string;
10
+ /** Relative to the build's assets folder: fonts/inter.ttf. */
11
+ readonly path: string;
12
+ /** Its size, when the build says (0 when unknown). */
13
+ readonly bytes: number;
14
+ /** SHA-256, hex, when the build says. */
15
+ readonly hash: string | null;
16
+ }
17
+ export type AssetStatus = "waiting" | "loading" | "ready" | "failed";
18
+ export interface AssetProgress {
19
+ /** Counts describe the current fetch window when streaming. */
20
+ readonly streaming?: boolean;
21
+ readonly files: number;
22
+ readonly ready: number;
23
+ readonly failed: number;
24
+ /** Bytes of every file, and of those ready (sizes the build gives). */
25
+ readonly bytes: number;
26
+ readonly bytesReady: number;
27
+ }
28
+ export type FetchBytes = (path: string, signal: AbortSignal) => Promise<Uint8Array>;
29
+ export declare class AssetLoader {
30
+ private readonly fetchBytes;
31
+ private readonly concurrency;
32
+ private readonly maxBytes;
33
+ private readonly entries;
34
+ private readonly listeners;
35
+ private readonly abort;
36
+ private active;
37
+ private clock;
38
+ private readonly retained;
39
+ onEvict: (path: string) => void;
40
+ /** A request for `path` was withdrawn before it was answered — its transfer stopped, or it never
41
+ * started — so whoever waits for the file hears it won't come (and asks again if it still wants it). */
42
+ onCancel: (path: string) => void;
43
+ constructor(files: ReadonlyArray<AssetFileInfo>, fetchBytes: FetchBytes, concurrency?: number, maxBytes?: number);
44
+ /** Asks for files at a priority: lower is sooner. Files the build doesn't list are ignored. */
45
+ want(paths: Iterable<string>, priority: number): void;
46
+ status(path: string): AssetStatus | null;
47
+ readonly isReady: (path: string) => boolean;
48
+ /** A ready file's bytes. */
49
+ data(path: string): Uint8Array | null;
50
+ /** Changes the fetch window. Requests outside it are withdrawn, under way or still queued; old data
51
+ * is evicted under the byte budget. */
52
+ retain(paths: Iterable<string>): void;
53
+ get heldBytes(): number;
54
+ private evict;
55
+ /** Why a file failed. */
56
+ error(path: string): string | null;
57
+ failure(path: string): AssetFailureInfo;
58
+ failMissing(path: string): void;
59
+ failures(): ReadonlyArray<{
60
+ readonly path: string;
61
+ } & AssetFailureInfo>;
62
+ progress(windowOnly?: boolean): AssetProgress;
63
+ /** Takes over files another loader (the last build's) already has, where the hashes match: a
64
+ * rebuild fetches only what changed. Returns the paths taken over. */
65
+ carryOver(previous: AssetLoader): ReadonlyArray<string>;
66
+ /** Files that failed go back in the queue. */
67
+ retry(): void;
68
+ onChange(listener: (path: string) => void): () => void;
69
+ dispose(): void;
70
+ private changed;
71
+ /** The most urgent file asked for and not started. */
72
+ private next;
73
+ private pump;
74
+ private load;
75
+ }
@@ -0,0 +1,213 @@
1
+ // The player's one fetcher of imported asset files. Files are fetched most urgent first (a lower
2
+ // priority number is sooner; a file keeps the most urgent priority anyone asked it at), a few at a
3
+ // time, each checked against the hash its build gives it. Every file has a status, and the loader
4
+ // says when one changes: that is what readiness, progress bars and "can this frame be drawn
5
+ // exactly" are made of. Nothing here knows about frames.
6
+ import { MediaFailure } from "@tongue-core/project/media";
7
+ async function sha256(bytes) {
8
+ const copy = new Uint8Array(bytes.length);
9
+ copy.set(bytes);
10
+ const digest = await crypto.subtle.digest("SHA-256", copy);
11
+ return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, "0")).join("");
12
+ }
13
+ export class AssetLoader {
14
+ fetchBytes;
15
+ concurrency;
16
+ maxBytes;
17
+ entries = new Map();
18
+ listeners = new Set();
19
+ abort = new AbortController();
20
+ active = 0;
21
+ clock = 0;
22
+ retained = new Set();
23
+ onEvict = () => { };
24
+ /** A request for `path` was withdrawn before it was answered — its transfer stopped, or it never
25
+ * started — so whoever waits for the file hears it won't come (and asks again if it still wants it). */
26
+ onCancel = () => { };
27
+ constructor(files, fetchBytes, concurrency = 6, maxBytes = Infinity) {
28
+ this.fetchBytes = fetchBytes;
29
+ this.concurrency = concurrency;
30
+ this.maxBytes = maxBytes;
31
+ for (const info of files)
32
+ this.entries.set(info.path, { info, status: "waiting", priority: Number.POSITIVE_INFINITY, data: null, error: null, abort: null, used: 0, failure: null });
33
+ }
34
+ /** Asks for files at a priority: lower is sooner. Files the build doesn't list are ignored. */
35
+ want(paths, priority) {
36
+ for (const path of paths) {
37
+ const e = this.entries.get(path);
38
+ if (e)
39
+ e.used = ++this.clock;
40
+ if (e && priority < e.priority)
41
+ e.priority = priority;
42
+ }
43
+ this.pump();
44
+ }
45
+ status(path) {
46
+ return this.entries.get(path)?.status ?? null;
47
+ }
48
+ isReady = (path) => this.entries.get(path)?.status === "ready";
49
+ /** A ready file's bytes. */
50
+ data(path) {
51
+ const entry = this.entries.get(path);
52
+ if (entry)
53
+ entry.used = ++this.clock;
54
+ return entry?.data ?? null;
55
+ }
56
+ /** Changes the fetch window. Requests outside it are withdrawn, under way or still queued; old data
57
+ * is evicted under the byte budget. */
58
+ retain(paths) {
59
+ this.retained.clear();
60
+ for (const path of paths)
61
+ this.retained.add(path);
62
+ for (const [path, entry] of this.entries) {
63
+ if (this.retained.has(path))
64
+ continue;
65
+ const queued = entry.status === "waiting" && entry.priority < Number.POSITIVE_INFINITY;
66
+ const transfer = entry.abort !== null && !entry.abort.signal.aborted ? entry.abort : null;
67
+ entry.priority = Number.POSITIVE_INFINITY;
68
+ transfer?.abort();
69
+ if (queued || transfer)
70
+ this.onCancel(path);
71
+ }
72
+ this.evict();
73
+ }
74
+ get heldBytes() { let bytes = 0; for (const entry of this.entries.values())
75
+ bytes += entry.data?.length ?? 0; return bytes; }
76
+ evict() {
77
+ let bytes = this.heldBytes;
78
+ const candidates = [...this.entries.values()].filter((entry) => entry.data && !this.retained.has(entry.info.path)).sort((a, b) => a.used - b.used);
79
+ for (const entry of candidates) {
80
+ if (bytes <= this.maxBytes)
81
+ break;
82
+ bytes -= entry.data?.length ?? 0;
83
+ entry.data = null;
84
+ entry.status = "waiting";
85
+ entry.priority = Infinity;
86
+ this.onEvict(entry.info.path);
87
+ this.changed(entry.info.path);
88
+ }
89
+ }
90
+ /** Why a file failed. */
91
+ error(path) {
92
+ return this.entries.get(path)?.error ?? null;
93
+ }
94
+ failure(path) {
95
+ const entry = this.entries.get(path), failure = entry?.failure;
96
+ return { message: entry?.error ?? "failed", ...(failure ? { reason: failure.reason, retryable: failure.retryable, ...(failure.status === undefined ? {} : { status: failure.status }) } : {}) };
97
+ }
98
+ failMissing(path) {
99
+ const failure = new MediaFailure({ asset: path, source: "build.json", reason: "invalid", retryable: false, message: `The build does not list required preview file ${path}. Rebuild or repair its prepared-media manifest.` });
100
+ this.entries.set(path, { info: { path, bytes: 0, hash: null }, status: "failed", priority: Infinity, data: null, error: failure.message, abort: null, used: ++this.clock, failure });
101
+ this.changed(path);
102
+ }
103
+ failures() {
104
+ return [...this.entries.values()].filter((entry) => entry.status === "failed").map((entry) => ({ path: entry.info.path, ...this.failure(entry.info.path) }));
105
+ }
106
+ progress(windowOnly = false) {
107
+ let ready = 0, failed = 0, bytes = 0, bytesReady = 0, files = 0;
108
+ for (const e of this.entries.values()) {
109
+ if (windowOnly && !this.retained.has(e.info.path))
110
+ continue;
111
+ files++;
112
+ bytes += e.info.bytes;
113
+ if (e.status === "ready") {
114
+ ready++;
115
+ bytesReady += e.info.bytes;
116
+ }
117
+ if (e.status === "failed")
118
+ failed++;
119
+ }
120
+ return { files, ready, failed, bytes, bytesReady, ...(windowOnly ? { streaming: true } : {}) };
121
+ }
122
+ /** Takes over files another loader (the last build's) already has, where the hashes match: a
123
+ * rebuild fetches only what changed. Returns the paths taken over. */
124
+ carryOver(previous) {
125
+ const taken = [];
126
+ for (const [path, e] of this.entries) {
127
+ const old = previous.entries.get(path);
128
+ if (!old || old.status !== "ready" || old.data === null || e.info.hash === null || old.info.hash !== e.info.hash)
129
+ continue;
130
+ e.data = old.data;
131
+ e.status = "ready";
132
+ taken.push(path);
133
+ }
134
+ return taken;
135
+ }
136
+ /** Files that failed go back in the queue. */
137
+ retry() {
138
+ for (const e of this.entries.values())
139
+ if (e.status === "failed" && e.failure?.reason !== "invalid") {
140
+ e.status = "waiting";
141
+ e.error = null;
142
+ e.failure = null;
143
+ }
144
+ this.pump();
145
+ }
146
+ onChange(listener) {
147
+ this.listeners.add(listener);
148
+ return () => { this.listeners.delete(listener); };
149
+ }
150
+ dispose() {
151
+ this.abort.abort();
152
+ for (const entry of this.entries.values())
153
+ entry.abort?.abort();
154
+ this.listeners.clear();
155
+ }
156
+ changed(path) {
157
+ for (const listener of this.listeners)
158
+ listener(path);
159
+ }
160
+ /** The most urgent file asked for and not started. */
161
+ next() {
162
+ let best = null;
163
+ for (const e of this.entries.values()) {
164
+ if (e.status === "waiting" && e.priority < Number.POSITIVE_INFINITY && (!best || e.priority < best.priority))
165
+ best = e;
166
+ }
167
+ return best;
168
+ }
169
+ pump() {
170
+ while (this.active < this.concurrency && !this.abort.signal.aborted) {
171
+ const e = this.next();
172
+ if (!e)
173
+ return;
174
+ this.active++;
175
+ e.status = "loading";
176
+ this.changed(e.info.path);
177
+ void this.load(e).finally(() => {
178
+ this.active--;
179
+ this.pump();
180
+ });
181
+ }
182
+ }
183
+ async load(e) {
184
+ const abort = new AbortController();
185
+ e.abort = abort;
186
+ try {
187
+ const data = await this.fetchBytes(e.info.path, AbortSignal.any([this.abort.signal, abort.signal]));
188
+ const integrity = (message) => new MediaFailure({ asset: e.info.path, source: e.info.url ?? e.info.path, reason: "integrity", retryable: false, message });
189
+ if (e.info.bytes > 0 && data.length !== e.info.bytes)
190
+ throw integrity(`its size differs from the build (${data.length} bytes, expected ${e.info.bytes})`);
191
+ if (e.info.hash !== null && (await sha256(data)) !== e.info.hash)
192
+ throw integrity("its contents don't match the build (the hash differs)");
193
+ e.data = data;
194
+ e.status = "ready";
195
+ }
196
+ catch (error) {
197
+ if (this.abort.signal.aborted)
198
+ return;
199
+ if (abort.signal.aborted) {
200
+ e.status = "waiting";
201
+ return;
202
+ }
203
+ e.status = "failed";
204
+ e.failure = error instanceof MediaFailure ? error : null;
205
+ e.error = error instanceof Error ? error.message : String(error);
206
+ }
207
+ finally {
208
+ e.abort = null;
209
+ }
210
+ this.changed(e.info.path);
211
+ this.evict();
212
+ }
213
+ }
@@ -0,0 +1,72 @@
1
+ import type { MixResult } from "./pool.js";
2
+ export interface SoundtrackOptions {
3
+ readonly fps: number;
4
+ /** The film's length in frames. */
5
+ readonly length: number;
6
+ /** Mixes frames [from, to). */
7
+ readonly mix: (from: number, to: number, signal: AbortSignal) => Promise<MixResult>;
8
+ /** Frames per chunk (default 5 seconds). */
9
+ readonly chunk?: number;
10
+ /** Chunks kept mixed ahead of the playhead (default 3). */
11
+ readonly ahead?: number;
12
+ }
13
+ /** A chunk to put on the audio clock: when it starts, and how far into it (the chunk playback started in starts part way). */
14
+ export interface DueChunk {
15
+ readonly chunk: number;
16
+ readonly when: number;
17
+ readonly offset: number;
18
+ }
19
+ export interface DueOptions {
20
+ /** The first chunk not yet on the clock. */
21
+ readonly next: number;
22
+ /** Frames per chunk, frames a second, and the film's length in frames. */
23
+ readonly size: number;
24
+ readonly fps: number;
25
+ readonly length: number;
26
+ /** Playback started at this frame, at this time on the audio clock. */
27
+ readonly startFrame: number;
28
+ readonly startTime: number;
29
+ /** The audio clock now, and how far ahead of it (seconds) sound may be put. */
30
+ readonly now: number;
31
+ readonly horizon: number;
32
+ readonly mixed: (chunk: number) => boolean;
33
+ }
34
+ /**
35
+ * The chunks to put on the audio clock now, in order from `next`: each one mixed, starting no further
36
+ * than `horizon` seconds ahead of the clock. It stops at the first chunk still being mixed (it is on
37
+ * its way) or too far ahead (a later tick takes it), and at the end of the film. Pure.
38
+ */
39
+ export declare function dueChunks(o: DueOptions): ReadonlyArray<DueChunk>;
40
+ export declare class Soundtrack {
41
+ private readonly options;
42
+ private context;
43
+ private readonly chunks;
44
+ private readonly mixing;
45
+ private readonly controllers;
46
+ private playing;
47
+ private readonly size;
48
+ private readonly ahead;
49
+ private volume;
50
+ private gain;
51
+ private generation;
52
+ constructor(options: SoundtrackOptions);
53
+ /** The audio clock in seconds, when it is running (it runs only after a user gesture). */
54
+ clock(): number | null;
55
+ /** Creates or resumes the audio context; call it from a click or key press. */
56
+ wake(): void;
57
+ setVolume(volume: number): void;
58
+ /** Whether the chunk under `frame` is mixed (playback waits for it when there is a clock). */
59
+ ready(frame: number): boolean;
60
+ /** Mixes the chunks from `frame` on that aren't mixed yet. */
61
+ prepare(frame: number): void;
62
+ /** Starts sound at `frame`, at audio time `time` (from clock()). */
63
+ start(frame: number, time: number): void;
64
+ /** Schedules the chunks that are mixed and due (`dueChunks`); call it every tick while playing. */
65
+ schedule(): void;
66
+ stop(): void;
67
+ /** The film changed: forget every chunk. */
68
+ reset(): void;
69
+ dispose(): void;
70
+ private chunkOf;
71
+ private load;
72
+ }