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

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 (63) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +98 -2
  3. package/dist/budgets.d.ts +17 -0
  4. package/dist/budgets.js +12 -0
  5. package/dist/player/assets.d.ts +48 -0
  6. package/dist/player/assets.js +133 -0
  7. package/dist/player/audio.d.ts +44 -0
  8. package/dist/player/audio.js +158 -0
  9. package/dist/player/frames.d.ts +34 -0
  10. package/dist/player/frames.js +83 -0
  11. package/dist/player/index.d.ts +9 -0
  12. package/dist/player/index.js +13 -0
  13. package/dist/player/needs.d.ts +41 -0
  14. package/dist/player/needs.js +173 -0
  15. package/dist/player/player.d.ts +106 -0
  16. package/dist/player/player.js +449 -0
  17. package/dist/player/pool.d.ts +77 -0
  18. package/dist/player/pool.js +223 -0
  19. package/dist/player/protocol.d.ts +101 -0
  20. package/dist/player/protocol.js +184 -0
  21. package/dist/player/schedule.d.ts +41 -0
  22. package/dist/player/schedule.js +51 -0
  23. package/dist/player/source.d.ts +28 -0
  24. package/dist/player/source.js +39 -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 +20 -0
  28. package/dist/player/video.js +259 -0
  29. package/dist/player/worker.d.ts +1 -0
  30. package/dist/player/worker.js +243 -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 +99 -0
  44. package/dist/react/timeline.js +254 -0
  45. package/dist/timeline/connect.d.ts +7 -0
  46. package/dist/timeline/connect.js +49 -0
  47. package/dist/timeline/index.d.ts +9 -0
  48. package/dist/timeline/index.js +13 -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 +53 -0
  52. package/dist/timeline/model.js +92 -0
  53. package/dist/timeline/previews.d.ts +39 -0
  54. package/dist/timeline/previews.js +106 -0
  55. package/dist/timeline/selection.d.ts +34 -0
  56. package/dist/timeline/selection.js +37 -0
  57. package/dist/timeline/ticks.d.ts +27 -0
  58. package/dist/timeline/ticks.js +56 -0
  59. package/dist/timeline/timeline.d.ts +107 -0
  60. package/dist/timeline/timeline.js +94 -0
  61. package/dist/timeline/view.d.ts +64 -0
  62. package/dist/timeline/view.js +83 -0
  63. 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,99 @@
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
+ A build is static files: `build.json`, `film.json`, `scenes/*.js`, `assets/…` (`tongue build`
42
+ writes one). The player fetches the manifests and bundles, then the asset files frames need, nearest
43
+ the playhead first, each checked against its hash. **A frame is drawn only once every file it needs
44
+ is there**; until then the last frame stays up. Video plays from the proxy segments import makes
45
+ (H.264, decoded with WebCodecs in a worker), sound from the soundtrack mixed a few seconds at a time.
46
+ Frames, video frames and sound are byte-for-byte what Bun makes from the same build.
47
+
48
+ ## A timeline
49
+
50
+ ```ts
51
+ import * as AtomRegistry from "effect/reactivity/AtomRegistry";
52
+ import { connectPlayer, makeTimeline } from "@tongue-core/ui/timeline";
53
+
54
+ const registry = AtomRegistry.make();
55
+ const timeline = makeTimeline();
56
+ const part = connectPlayer(registry, timeline, player); // reports in, commands out
57
+
58
+ registry.get(timeline.model); // rows, clips, edges: film.json laid out
59
+ registry.set(timeline.commands, { type: "nextEdge" }); // intents; the player carries them out
60
+ registry.set(timeline.view, { type: "zoom", factor: 2, anchor: 300 });
61
+ registry.subscribe(timeline.items("base"), (items) => …); // what a row draws at this zoom
62
+ ```
63
+
64
+ What depends on what decides what it costs: the model is rebuilt only for a new film.json; what a
65
+ row draws changes only when the view crosses a power-of-two step of frames or of zoom; the playhead
66
+ is a report that nothing heavy reads. Commands are numbered, so a reported playhead never becomes a
67
+ seek. Everything under the atoms is pure and tested on its own (`view.ts`, `ticks.ts`, `items.ts`…).
68
+
69
+ ## React
70
+
71
+ ```tsx
72
+ import { Timeline, TimelineProvider, usePlayer, useTimelineKeys } from "@tongue-core/ui/react";
73
+
74
+ <TimelineProvider timeline={timeline} registry={registry} previews={player}>
75
+ <Timeline.Root>
76
+ <Timeline.Scroller>
77
+ <Timeline.Ruler />
78
+ <Timeline.Ready />
79
+ <Timeline.Rows>{(row) => <Timeline.Row key={row.id} row={row} />}</Timeline.Rows>
80
+ <Timeline.Playhead />
81
+ </Timeline.Scroller>
82
+ </Timeline.Root>
83
+ <Timeline.Timecode />
84
+ </TimelineProvider>
85
+ ```
86
+
87
+ Unstyled: every element has `data-tl-*` attributes to style by (`[data-tl-clip][data-selected]`,
88
+ `[data-tl-clip][data-kind="video"]`, `[data-tl-run]`…). Positions are frames in CSS variables times
89
+ the zoom (`--tl-ppf`), so zooming and scrolling are the browser's work, not React's; the playhead and
90
+ the timecode write to their own elements, so playing commits nothing. Clips narrower than a few
91
+ pixels join runs, one element each, so an hour of a thousand clips is a few hundred elements zoomed
92
+ out. `useAtomEffect(atom, f)` is the same trick for your own readouts.
93
+
94
+ ## Checks
95
+
96
+ `bun test` runs the pure parts and the atom graph (in a bare registry). In headless Chrome:
97
+ `tools/ui-bench/player-check.ts` (the player on built projects, local and cross-origin, against the
98
+ budgets), `tools/ui-bench/react-check.ts` (the primitives on an hour of clips) and
99
+ `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,48 @@
1
+ export interface AssetFileInfo {
2
+ /** Relative to the build's assets folder: fonts/inter.ttf. */
3
+ readonly path: string;
4
+ /** Its size, when the build says (0 when unknown). */
5
+ readonly bytes: number;
6
+ /** SHA-256, hex, when the build says. */
7
+ readonly hash: string | null;
8
+ }
9
+ export type AssetStatus = "waiting" | "loading" | "ready" | "failed";
10
+ export interface AssetProgress {
11
+ readonly files: number;
12
+ readonly ready: number;
13
+ readonly failed: number;
14
+ /** Bytes of every file, and of those ready (sizes the build gives). */
15
+ readonly bytes: number;
16
+ readonly bytesReady: number;
17
+ }
18
+ export type FetchBytes = (path: string, signal: AbortSignal) => Promise<Uint8Array>;
19
+ export declare class AssetLoader {
20
+ private readonly fetchBytes;
21
+ private readonly concurrency;
22
+ private readonly entries;
23
+ private readonly listeners;
24
+ private readonly abort;
25
+ private active;
26
+ constructor(files: ReadonlyArray<AssetFileInfo>, fetchBytes: FetchBytes, concurrency?: number);
27
+ /** Asks for files at a priority: lower is sooner. Files the build doesn't list are ignored. */
28
+ want(paths: Iterable<string>, priority: number): void;
29
+ status(path: string): AssetStatus | null;
30
+ readonly isReady: (path: string) => boolean;
31
+ /** A ready file's bytes. */
32
+ data(path: string): Uint8Array | null;
33
+ /** Why a file failed. */
34
+ error(path: string): string | null;
35
+ progress(): AssetProgress;
36
+ /** Takes over files another loader (the last build's) already has, where the hashes match: a
37
+ * rebuild fetches only what changed. Returns the paths taken over. */
38
+ carryOver(previous: AssetLoader): ReadonlyArray<string>;
39
+ /** Files that failed go back in the queue. */
40
+ retry(): void;
41
+ onChange(listener: (path: string) => void): () => void;
42
+ dispose(): void;
43
+ private changed;
44
+ /** The most urgent file asked for and not started. */
45
+ private next;
46
+ private pump;
47
+ private load;
48
+ }
@@ -0,0 +1,133 @@
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
+ async function sha256(bytes) {
7
+ const copy = new Uint8Array(bytes.length);
8
+ copy.set(bytes);
9
+ const digest = await crypto.subtle.digest("SHA-256", copy);
10
+ return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, "0")).join("");
11
+ }
12
+ export class AssetLoader {
13
+ fetchBytes;
14
+ concurrency;
15
+ entries = new Map();
16
+ listeners = new Set();
17
+ abort = new AbortController();
18
+ active = 0;
19
+ constructor(files, fetchBytes, concurrency = 6) {
20
+ this.fetchBytes = fetchBytes;
21
+ this.concurrency = concurrency;
22
+ for (const info of files)
23
+ this.entries.set(info.path, { info, status: "waiting", priority: Number.POSITIVE_INFINITY, data: null, error: null });
24
+ }
25
+ /** Asks for files at a priority: lower is sooner. Files the build doesn't list are ignored. */
26
+ want(paths, priority) {
27
+ for (const path of paths) {
28
+ const e = this.entries.get(path);
29
+ if (e && priority < e.priority)
30
+ e.priority = priority;
31
+ }
32
+ this.pump();
33
+ }
34
+ status(path) {
35
+ return this.entries.get(path)?.status ?? null;
36
+ }
37
+ isReady = (path) => this.entries.get(path)?.status === "ready";
38
+ /** A ready file's bytes. */
39
+ data(path) {
40
+ return this.entries.get(path)?.data ?? null;
41
+ }
42
+ /** Why a file failed. */
43
+ error(path) {
44
+ return this.entries.get(path)?.error ?? null;
45
+ }
46
+ progress() {
47
+ let ready = 0, failed = 0, bytes = 0, bytesReady = 0;
48
+ for (const e of this.entries.values()) {
49
+ bytes += e.info.bytes;
50
+ if (e.status === "ready") {
51
+ ready++;
52
+ bytesReady += e.info.bytes;
53
+ }
54
+ if (e.status === "failed")
55
+ failed++;
56
+ }
57
+ return { files: this.entries.size, ready, failed, bytes, bytesReady };
58
+ }
59
+ /** Takes over files another loader (the last build's) already has, where the hashes match: a
60
+ * rebuild fetches only what changed. Returns the paths taken over. */
61
+ carryOver(previous) {
62
+ const taken = [];
63
+ for (const [path, e] of this.entries) {
64
+ const old = previous.entries.get(path);
65
+ if (!old || old.status !== "ready" || old.data === null || e.info.hash === null || old.info.hash !== e.info.hash)
66
+ continue;
67
+ e.data = old.data;
68
+ e.status = "ready";
69
+ taken.push(path);
70
+ }
71
+ return taken;
72
+ }
73
+ /** Files that failed go back in the queue. */
74
+ retry() {
75
+ for (const e of this.entries.values())
76
+ if (e.status === "failed") {
77
+ e.status = "waiting";
78
+ e.error = null;
79
+ }
80
+ this.pump();
81
+ }
82
+ onChange(listener) {
83
+ this.listeners.add(listener);
84
+ return () => { this.listeners.delete(listener); };
85
+ }
86
+ dispose() {
87
+ this.abort.abort();
88
+ this.listeners.clear();
89
+ }
90
+ changed(path) {
91
+ for (const listener of this.listeners)
92
+ listener(path);
93
+ }
94
+ /** The most urgent file asked for and not started. */
95
+ next() {
96
+ let best = null;
97
+ for (const e of this.entries.values()) {
98
+ if (e.status === "waiting" && e.priority < Number.POSITIVE_INFINITY && (!best || e.priority < best.priority))
99
+ best = e;
100
+ }
101
+ return best;
102
+ }
103
+ pump() {
104
+ while (this.active < this.concurrency && !this.abort.signal.aborted) {
105
+ const e = this.next();
106
+ if (!e)
107
+ return;
108
+ this.active++;
109
+ e.status = "loading";
110
+ this.changed(e.info.path);
111
+ void this.load(e).finally(() => {
112
+ this.active--;
113
+ this.pump();
114
+ });
115
+ }
116
+ }
117
+ async load(e) {
118
+ try {
119
+ const data = await this.fetchBytes(e.info.path, this.abort.signal);
120
+ if (e.info.hash !== null && (await sha256(data)) !== e.info.hash)
121
+ throw new Error("its contents don't match the build (the hash differs)");
122
+ e.data = data;
123
+ e.status = "ready";
124
+ }
125
+ catch (error) {
126
+ if (this.abort.signal.aborted)
127
+ return;
128
+ e.status = "failed";
129
+ e.error = error instanceof Error ? error.message : String(error);
130
+ }
131
+ this.changed(e.info.path);
132
+ }
133
+ }
@@ -0,0 +1,44 @@
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) => 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
+ export declare class Soundtrack {
14
+ private readonly options;
15
+ private context;
16
+ private readonly chunks;
17
+ private readonly mixing;
18
+ private playing;
19
+ private readonly size;
20
+ private readonly ahead;
21
+ private volume;
22
+ private gain;
23
+ private generation;
24
+ constructor(options: SoundtrackOptions);
25
+ /** The audio clock in seconds, when it is running (it runs only after a user gesture). */
26
+ clock(): number | null;
27
+ /** Creates or resumes the audio context; call it from a click or key press. */
28
+ wake(): void;
29
+ setVolume(volume: number): void;
30
+ /** Whether the chunk under `frame` is mixed (playback waits for it when there is a clock). */
31
+ ready(frame: number): boolean;
32
+ /** Mixes the chunks from `frame` on that aren't mixed yet. */
33
+ prepare(frame: number): void;
34
+ /** Starts sound at `frame`, at audio time `time` (from clock()). */
35
+ start(frame: number, time: number): void;
36
+ /** Schedules the chunks that are mixed and due; call it every tick while playing. */
37
+ schedule(): void;
38
+ stop(): void;
39
+ /** The film changed: forget every chunk. */
40
+ reset(): void;
41
+ dispose(): void;
42
+ private chunkOf;
43
+ private load;
44
+ }
@@ -0,0 +1,158 @@
1
+ /** Chunks kept around (about a minute at 5 s each), the most recently used. */
2
+ const KEPT = 12;
3
+ /** Seconds between asking to start and the first sample, so the start is never late. */
4
+ const LEAD = 0.05;
5
+ export class Soundtrack {
6
+ options;
7
+ context = null;
8
+ chunks = new Map();
9
+ mixing = new Map();
10
+ playing = null;
11
+ size;
12
+ ahead;
13
+ volume = 1;
14
+ gain = null;
15
+ generation = 0;
16
+ constructor(options) {
17
+ this.options = options;
18
+ this.size = Math.max(1, Math.round(options.chunk ?? options.fps * 5));
19
+ this.ahead = Math.max(1, options.ahead ?? 3);
20
+ }
21
+ /** The audio clock in seconds, when it is running (it runs only after a user gesture). */
22
+ clock() {
23
+ const ctx = this.context;
24
+ return ctx && ctx.state === "running" ? ctx.currentTime : null;
25
+ }
26
+ /** Creates or resumes the audio context; call it from a click or key press. */
27
+ wake() {
28
+ if (!this.context) {
29
+ this.context = new AudioContext({ sampleRate: 48000, latencyHint: "interactive" });
30
+ this.gain = this.context.createGain();
31
+ this.gain.gain.value = this.volume;
32
+ this.gain.connect(this.context.destination);
33
+ }
34
+ if (this.context.state === "suspended")
35
+ void this.context.resume();
36
+ }
37
+ setVolume(volume) {
38
+ this.volume = Math.max(0, Math.min(1, volume));
39
+ if (this.gain)
40
+ this.gain.gain.value = this.volume;
41
+ }
42
+ /** Whether the chunk under `frame` is mixed (playback waits for it when there is a clock). */
43
+ ready(frame) {
44
+ return this.chunks.has(this.chunkOf(frame));
45
+ }
46
+ /** Mixes the chunks from `frame` on that aren't mixed yet. */
47
+ prepare(frame) {
48
+ const first = this.chunkOf(frame);
49
+ for (let k = first; k < first + this.ahead && k * this.size < this.options.length; k++)
50
+ void this.load(k);
51
+ }
52
+ /** Starts sound at `frame`, at audio time `time` (from clock()). */
53
+ start(frame, time) {
54
+ this.stop();
55
+ const ctx = this.context;
56
+ if (!ctx || ctx.state !== "running")
57
+ return;
58
+ this.playing = { frame, time: time + LEAD, sources: [], next: this.chunkOf(frame) };
59
+ this.schedule();
60
+ }
61
+ /** Schedules the chunks that are mixed and due; call it every tick while playing. */
62
+ schedule() {
63
+ const p = this.playing, ctx = this.context, gain = this.gain;
64
+ if (!p || !ctx || !gain)
65
+ return;
66
+ this.prepare(p.next * this.size);
67
+ for (;;) {
68
+ const buffer = this.chunks.get(p.next);
69
+ if (!buffer)
70
+ return;
71
+ const chunkStart = p.next * this.size;
72
+ // Seconds after the start frame this chunk begins (negative for the chunk the start is in).
73
+ const at = (chunkStart - p.frame) / this.options.fps;
74
+ const source = ctx.createBufferSource();
75
+ source.buffer = buffer;
76
+ source.connect(gain);
77
+ const when = p.time + Math.max(0, at);
78
+ const offset = Math.max(0, -at);
79
+ if (offset < buffer.duration)
80
+ source.start(Math.max(when, ctx.currentTime), offset);
81
+ p.sources.push(source);
82
+ p.next++;
83
+ if (p.next * this.size >= this.options.length)
84
+ return;
85
+ // Far enough ahead: the rest is scheduled on later ticks.
86
+ if (when - ctx.currentTime > (this.size / this.options.fps) * 1.5)
87
+ return;
88
+ }
89
+ }
90
+ stop() {
91
+ if (!this.playing)
92
+ return;
93
+ for (const s of this.playing.sources) {
94
+ try {
95
+ s.stop();
96
+ }
97
+ catch { /* never started */ }
98
+ s.disconnect();
99
+ }
100
+ this.playing = null;
101
+ }
102
+ /** The film changed: forget every chunk. */
103
+ reset() {
104
+ this.stop();
105
+ this.chunks.clear();
106
+ this.mixing.clear();
107
+ this.generation++;
108
+ }
109
+ dispose() {
110
+ this.reset();
111
+ void this.context?.close();
112
+ this.context = null;
113
+ }
114
+ chunkOf(frame) {
115
+ return Math.max(0, Math.floor(frame / this.size));
116
+ }
117
+ load(k) {
118
+ const known = this.chunks.get(k);
119
+ if (known) {
120
+ // Most recently used goes last, so the oldest is dropped first.
121
+ this.chunks.delete(k);
122
+ this.chunks.set(k, known);
123
+ return Promise.resolve(known);
124
+ }
125
+ const pending = this.mixing.get(k);
126
+ if (pending)
127
+ return pending;
128
+ const generation = this.generation;
129
+ const from = k * this.size, to = Math.min(this.options.length, from + this.size);
130
+ const job = this.options.mix(from, to).then((mixed) => {
131
+ this.mixing.delete(k);
132
+ if (generation !== this.generation)
133
+ return null;
134
+ const buffer = new AudioBuffer({ numberOfChannels: 2, length: Math.max(1, mixed.left.length), sampleRate: mixed.sampleRate });
135
+ buffer.copyToChannel(own(mixed.left), 0);
136
+ buffer.copyToChannel(own(mixed.right), 1);
137
+ this.chunks.set(k, buffer);
138
+ while (this.chunks.size > KEPT) {
139
+ const oldest = this.chunks.keys().next();
140
+ if (oldest.done)
141
+ break;
142
+ this.chunks.delete(oldest.value);
143
+ }
144
+ return buffer;
145
+ }, () => {
146
+ this.mixing.delete(k);
147
+ return null;
148
+ });
149
+ this.mixing.set(k, job);
150
+ return job;
151
+ }
152
+ }
153
+ /** copyToChannel wants an ArrayBuffer-backed array. */
154
+ function own(data) {
155
+ const copy = new Float32Array(data.length);
156
+ copy.set(data);
157
+ return copy;
158
+ }
@@ -0,0 +1,34 @@
1
+ export interface Closable {
2
+ close(): void;
3
+ }
4
+ export interface Cached<T> {
5
+ readonly value: T;
6
+ readonly scale: number;
7
+ readonly bytes: number;
8
+ /** False when something stood in for part of it (a video's poster for its frames). */
9
+ readonly exact: boolean;
10
+ }
11
+ export declare class FrameCache<T extends Closable> {
12
+ private readonly budget;
13
+ private readonly frames;
14
+ private total;
15
+ private focus;
16
+ constructor(budget: number);
17
+ /** The best scale `frame` is cached at, or null. */
18
+ scaleOf(frame: number): number | null;
19
+ /** True when `frame` is cached at `scale` or better. */
20
+ has(frame: number, scale?: number): boolean;
21
+ get(frame: number): Cached<T> | undefined;
22
+ /** Keeps the better of what is there and what arrived (sharper; exact over stood-in), closing
23
+ * the other, then keeps to the budget. */
24
+ put(frame: number, value: T, scale: number, bytes: number, exact?: boolean): void;
25
+ /** Where the playhead is: eviction starts furthest from it. */
26
+ setFocus(frame: number): void;
27
+ /** The cached frame nearest `frame`, at most `radius` away (the earlier one on a tie). */
28
+ nearest(frame: number, radius: number): number | null;
29
+ clear(): void;
30
+ get bytes(): number;
31
+ get size(): number;
32
+ private remove;
33
+ private trim;
34
+ }
@@ -0,0 +1,83 @@
1
+ // Rendered frames kept for showing again, under a budget of bytes. A frame is kept at the best
2
+ // scale it was rendered at; when the budget is exceeded, the frames furthest from the focus (the
3
+ // playhead) go first, and whatever leaves is closed, so an ImageBitmap's memory is released at once.
4
+ export class FrameCache {
5
+ budget;
6
+ frames = new Map();
7
+ total = 0;
8
+ focus = 0;
9
+ constructor(budget) {
10
+ this.budget = budget;
11
+ }
12
+ /** The best scale `frame` is cached at, or null. */
13
+ scaleOf(frame) {
14
+ return this.frames.get(frame)?.scale ?? null;
15
+ }
16
+ /** True when `frame` is cached at `scale` or better. */
17
+ has(frame, scale = 0) {
18
+ const hit = this.frames.get(frame);
19
+ return hit !== undefined && hit.scale >= scale;
20
+ }
21
+ get(frame) {
22
+ return this.frames.get(frame);
23
+ }
24
+ /** Keeps the better of what is there and what arrived (sharper; exact over stood-in), closing
25
+ * the other, then keeps to the budget. */
26
+ put(frame, value, scale, bytes, exact = true) {
27
+ const old = this.frames.get(frame);
28
+ if (old && (old.scale > scale || (old.scale === scale && (old.exact || !exact)))) {
29
+ value.close();
30
+ return;
31
+ }
32
+ if (old)
33
+ this.remove(frame, old);
34
+ this.frames.set(frame, { value, scale, bytes, exact });
35
+ this.total += bytes;
36
+ this.trim();
37
+ }
38
+ /** Where the playhead is: eviction starts furthest from it. */
39
+ setFocus(frame) {
40
+ this.focus = frame;
41
+ }
42
+ /** The cached frame nearest `frame`, at most `radius` away (the earlier one on a tie). */
43
+ nearest(frame, radius) {
44
+ for (let k = 0; k <= radius; k++) {
45
+ if (this.frames.has(frame - k))
46
+ return frame - k;
47
+ if (this.frames.has(frame + k))
48
+ return frame + k;
49
+ }
50
+ return null;
51
+ }
52
+ clear() {
53
+ for (const entry of this.frames.values())
54
+ entry.value.close();
55
+ this.frames.clear();
56
+ this.total = 0;
57
+ }
58
+ get bytes() {
59
+ return this.total;
60
+ }
61
+ get size() {
62
+ return this.frames.size;
63
+ }
64
+ remove(frame, entry) {
65
+ entry.value.close();
66
+ this.frames.delete(frame);
67
+ this.total -= entry.bytes;
68
+ }
69
+ trim() {
70
+ if (this.total <= this.budget)
71
+ return;
72
+ const byDistance = [...this.frames.keys()].sort((a, b) => Math.abs(b - this.focus) - Math.abs(a - this.focus));
73
+ for (const frame of byDistance) {
74
+ if (this.total <= this.budget)
75
+ break;
76
+ if (frame === this.focus)
77
+ continue;
78
+ const entry = this.frames.get(frame);
79
+ if (entry)
80
+ this.remove(frame, entry);
81
+ }
82
+ }
83
+ }
@@ -0,0 +1,9 @@
1
+ export { createPlayer, type Player, type PlayerOptions, type PlayerEvents, type PlayerEvent, type Readiness, type Shown, type LoadedFilm, type Pixels, type Mix } from "./player.js";
2
+ export { Cancelled, type FrameResult } from "./pool.js";
3
+ export { openSource, buildBase, SourceError, type BuildSource, type OpenedSource } from "./source.js";
4
+ export { FilmNeeds, type Span, type Range } from "./needs.js";
5
+ export { AssetLoader, type AssetFileInfo, type AssetStatus, type AssetProgress, type FetchBytes } from "./assets.js";
6
+ export { FrameCache, type Cached, type Closable } from "./frames.js";
7
+ export { Transport, type PlayState, type TickInput, type TickResult } from "./transport.js";
8
+ export { wantedFrames, bufferedAhead, startBuffer, clockFrame, clampFrame, type Scales, type Want, type Situation } from "./schedule.js";
9
+ export { BUDGETS, type Budgets } from "../budgets.js";