@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.
- package/LICENSE +21 -0
- package/README.md +137 -2
- package/dist/budgets.d.ts +17 -0
- package/dist/budgets.js +12 -0
- package/dist/player/assets.d.ts +75 -0
- package/dist/player/assets.js +213 -0
- package/dist/player/audio.d.ts +72 -0
- package/dist/player/audio.js +182 -0
- package/dist/player/frames.d.ts +44 -0
- package/dist/player/frames.js +105 -0
- package/dist/player/index.d.ts +9 -0
- package/dist/player/index.js +14 -0
- package/dist/player/needs.d.ts +47 -0
- package/dist/player/needs.js +217 -0
- package/dist/player/player.d.ts +113 -0
- package/dist/player/player.js +532 -0
- package/dist/player/pool.d.ts +85 -0
- package/dist/player/pool.js +241 -0
- package/dist/player/protocol.d.ts +132 -0
- package/dist/player/protocol.js +200 -0
- package/dist/player/schedule.d.ts +54 -0
- package/dist/player/schedule.js +62 -0
- package/dist/player/source.d.ts +48 -0
- package/dist/player/source.js +79 -0
- package/dist/player/transport.d.ts +36 -0
- package/dist/player/transport.js +65 -0
- package/dist/player/video.d.ts +30 -0
- package/dist/player/video.js +368 -0
- package/dist/player/worker.d.ts +1 -0
- package/dist/player/worker.js +274 -0
- package/dist/react/context.d.ts +23 -0
- package/dist/react/context.js +24 -0
- package/dist/react/css.d.ts +6 -0
- package/dist/react/css.js +1 -0
- package/dist/react/hooks.d.ts +17 -0
- package/dist/react/hooks.js +40 -0
- package/dist/react/index.d.ts +5 -0
- package/dist/react/index.js +9 -0
- package/dist/react/keys.d.ts +1 -0
- package/dist/react/keys.js +64 -0
- package/dist/react/player.d.ts +5 -0
- package/dist/react/player.js +26 -0
- package/dist/react/timeline.d.ts +101 -0
- package/dist/react/timeline.js +254 -0
- package/dist/timeline/connect.d.ts +7 -0
- package/dist/timeline/connect.js +50 -0
- package/dist/timeline/index.d.ts +10 -0
- package/dist/timeline/index.js +14 -0
- package/dist/timeline/items.d.ts +25 -0
- package/dist/timeline/items.js +65 -0
- package/dist/timeline/model.d.ts +55 -0
- package/dist/timeline/model.js +92 -0
- package/dist/timeline/narration.d.ts +18 -0
- package/dist/timeline/narration.js +42 -0
- package/dist/timeline/previews.d.ts +39 -0
- package/dist/timeline/previews.js +106 -0
- package/dist/timeline/selection.d.ts +34 -0
- package/dist/timeline/selection.js +37 -0
- package/dist/timeline/ticks.d.ts +27 -0
- package/dist/timeline/ticks.js +56 -0
- package/dist/timeline/timeline.d.ts +108 -0
- package/dist/timeline/timeline.js +94 -0
- package/dist/timeline/view.d.ts +64 -0
- package/dist/timeline/view.js +83 -0
- 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
|
-
#
|
|
1
|
+
# @tongue-core/ui
|
|
2
2
|
|
|
3
|
-
|
|
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;
|
package/dist/budgets.js
ADDED
|
@@ -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
|
+
}
|