@tongue-core/ui 0.1.0-rc.7 → 0.1.0-rc.9
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/README.md +24 -0
- package/dist/player/assets.d.ts +26 -2
- package/dist/player/assets.js +84 -9
- package/dist/player/audio.d.ts +2 -1
- package/dist/player/audio.js +13 -3
- package/dist/player/frames.d.ts +4 -0
- package/dist/player/frames.js +12 -0
- package/dist/player/index.d.ts +1 -1
- package/dist/player/needs.d.ts +7 -1
- package/dist/player/needs.js +52 -8
- package/dist/player/player.d.ts +9 -3
- package/dist/player/player.js +87 -20
- package/dist/player/pool.d.ts +3 -0
- package/dist/player/pool.js +11 -2
- package/dist/player/protocol.d.ts +15 -0
- package/dist/player/protocol.js +12 -1
- package/dist/player/source.d.ts +3 -2
- package/dist/player/source.js +17 -5
- package/dist/player/video.d.ts +4 -0
- package/dist/player/video.js +90 -9
- package/dist/player/worker.js +36 -8
- package/dist/timeline/connect.js +1 -0
- package/dist/timeline/timeline.d.ts +1 -0
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -57,6 +57,30 @@ is there**; until then the last frame stays up. Video plays from the proxy segme
|
|
|
57
57
|
(H.264, decoded with WebCodecs in a worker), sound from the soundtrack mixed a few seconds at a time.
|
|
58
58
|
Frames, video frames and sound are byte-for-byte what Bun makes from the same build.
|
|
59
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. `mediaCacheBytes`
|
|
66
|
+
(default 64 MiB) and `mediaAhead` configure the window; files required by active requests remain
|
|
67
|
+
pinned until those requests finish. Decoded video frames have a separate shared byte budget.
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
await player.load({
|
|
71
|
+
read: (path, signal) => commit.bytes(path, signal),
|
|
72
|
+
request: (url, signal, headers) => fetch(url, { signal, headers }),
|
|
73
|
+
});
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
The optional `request` hook supplies authentication or URL refresh. Transient downloads retry
|
|
77
|
+
through the shared media transport. `readiness.failed` reports permanent failures; `player.retry()`
|
|
78
|
+
retries them after the host restores access. `readiness.mediaBytes` reports retained compressed
|
|
79
|
+
data. Failure records also expose `reason`, `status` and `retryable` when known. For streaming
|
|
80
|
+
builds, progress counts describe the current fetch window. Readiness ranges for dynamic scene
|
|
81
|
+
video conservatively report already rendered exact frames, since a scene can choose arbitrary
|
|
82
|
+
source times. Local packaged builds preserve their existing background preloading behavior.
|
|
83
|
+
|
|
60
84
|
## A timeline
|
|
61
85
|
|
|
62
86
|
```ts
|
package/dist/player/assets.d.ts
CHANGED
|
@@ -1,4 +1,12 @@
|
|
|
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
|
+
}
|
|
1
8
|
export interface AssetFileInfo {
|
|
9
|
+
readonly url?: string;
|
|
2
10
|
/** Relative to the build's assets folder: fonts/inter.ttf. */
|
|
3
11
|
readonly path: string;
|
|
4
12
|
/** Its size, when the build says (0 when unknown). */
|
|
@@ -8,6 +16,8 @@ export interface AssetFileInfo {
|
|
|
8
16
|
}
|
|
9
17
|
export type AssetStatus = "waiting" | "loading" | "ready" | "failed";
|
|
10
18
|
export interface AssetProgress {
|
|
19
|
+
/** Counts describe the current fetch window when streaming. */
|
|
20
|
+
readonly streaming?: boolean;
|
|
11
21
|
readonly files: number;
|
|
12
22
|
readonly ready: number;
|
|
13
23
|
readonly failed: number;
|
|
@@ -19,20 +29,34 @@ export type FetchBytes = (path: string, signal: AbortSignal) => Promise<Uint8Arr
|
|
|
19
29
|
export declare class AssetLoader {
|
|
20
30
|
private readonly fetchBytes;
|
|
21
31
|
private readonly concurrency;
|
|
32
|
+
private readonly maxBytes;
|
|
22
33
|
private readonly entries;
|
|
23
34
|
private readonly listeners;
|
|
24
35
|
private readonly abort;
|
|
25
36
|
private active;
|
|
26
|
-
|
|
37
|
+
private clock;
|
|
38
|
+
private readonly retained;
|
|
39
|
+
onEvict: (path: string) => void;
|
|
40
|
+
onCancel: (path: string) => void;
|
|
41
|
+
constructor(files: ReadonlyArray<AssetFileInfo>, fetchBytes: FetchBytes, concurrency?: number, maxBytes?: number);
|
|
27
42
|
/** Asks for files at a priority: lower is sooner. Files the build doesn't list are ignored. */
|
|
28
43
|
want(paths: Iterable<string>, priority: number): void;
|
|
29
44
|
status(path: string): AssetStatus | null;
|
|
30
45
|
readonly isReady: (path: string) => boolean;
|
|
31
46
|
/** A ready file's bytes. */
|
|
32
47
|
data(path: string): Uint8Array | null;
|
|
48
|
+
/** Changes the fetch window. Obsolete transfers stop; old data is evicted under the byte budget. */
|
|
49
|
+
retain(paths: Iterable<string>): void;
|
|
50
|
+
get heldBytes(): number;
|
|
51
|
+
private evict;
|
|
33
52
|
/** Why a file failed. */
|
|
34
53
|
error(path: string): string | null;
|
|
35
|
-
|
|
54
|
+
failure(path: string): AssetFailureInfo;
|
|
55
|
+
failMissing(path: string): void;
|
|
56
|
+
failures(): ReadonlyArray<{
|
|
57
|
+
readonly path: string;
|
|
58
|
+
} & AssetFailureInfo>;
|
|
59
|
+
progress(windowOnly?: boolean): AssetProgress;
|
|
36
60
|
/** Takes over files another loader (the last build's) already has, where the hashes match: a
|
|
37
61
|
* rebuild fetches only what changed. Returns the paths taken over. */
|
|
38
62
|
carryOver(previous: AssetLoader): ReadonlyArray<string>;
|
package/dist/player/assets.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// time, each checked against the hash its build gives it. Every file has a status, and the loader
|
|
4
4
|
// says when one changes: that is what readiness, progress bars and "can this frame be drawn
|
|
5
5
|
// exactly" are made of. Nothing here knows about frames.
|
|
6
|
+
import { MediaFailure } from "@tongue-core/project/media";
|
|
6
7
|
async function sha256(bytes) {
|
|
7
8
|
const copy = new Uint8Array(bytes.length);
|
|
8
9
|
copy.set(bytes);
|
|
@@ -12,20 +13,28 @@ async function sha256(bytes) {
|
|
|
12
13
|
export class AssetLoader {
|
|
13
14
|
fetchBytes;
|
|
14
15
|
concurrency;
|
|
16
|
+
maxBytes;
|
|
15
17
|
entries = new Map();
|
|
16
18
|
listeners = new Set();
|
|
17
19
|
abort = new AbortController();
|
|
18
20
|
active = 0;
|
|
19
|
-
|
|
21
|
+
clock = 0;
|
|
22
|
+
retained = new Set();
|
|
23
|
+
onEvict = () => { };
|
|
24
|
+
onCancel = () => { };
|
|
25
|
+
constructor(files, fetchBytes, concurrency = 6, maxBytes = Infinity) {
|
|
20
26
|
this.fetchBytes = fetchBytes;
|
|
21
27
|
this.concurrency = concurrency;
|
|
28
|
+
this.maxBytes = maxBytes;
|
|
22
29
|
for (const info of files)
|
|
23
|
-
this.entries.set(info.path, { info, status: "waiting", priority: Number.POSITIVE_INFINITY, data: null, error: null });
|
|
30
|
+
this.entries.set(info.path, { info, status: "waiting", priority: Number.POSITIVE_INFINITY, data: null, error: null, abort: null, used: 0, failure: null });
|
|
24
31
|
}
|
|
25
32
|
/** Asks for files at a priority: lower is sooner. Files the build doesn't list are ignored. */
|
|
26
33
|
want(paths, priority) {
|
|
27
34
|
for (const path of paths) {
|
|
28
35
|
const e = this.entries.get(path);
|
|
36
|
+
if (e)
|
|
37
|
+
e.used = ++this.clock;
|
|
29
38
|
if (e && priority < e.priority)
|
|
30
39
|
e.priority = priority;
|
|
31
40
|
}
|
|
@@ -37,15 +46,64 @@ export class AssetLoader {
|
|
|
37
46
|
isReady = (path) => this.entries.get(path)?.status === "ready";
|
|
38
47
|
/** A ready file's bytes. */
|
|
39
48
|
data(path) {
|
|
40
|
-
|
|
49
|
+
const entry = this.entries.get(path);
|
|
50
|
+
if (entry)
|
|
51
|
+
entry.used = ++this.clock;
|
|
52
|
+
return entry?.data ?? null;
|
|
53
|
+
}
|
|
54
|
+
/** Changes the fetch window. Obsolete transfers stop; old data is evicted under the byte budget. */
|
|
55
|
+
retain(paths) {
|
|
56
|
+
this.retained.clear();
|
|
57
|
+
for (const path of paths)
|
|
58
|
+
this.retained.add(path);
|
|
59
|
+
for (const [path, entry] of this.entries)
|
|
60
|
+
if (!this.retained.has(path)) {
|
|
61
|
+
entry.priority = Infinity;
|
|
62
|
+
if (entry.abort && !entry.abort.signal.aborted) {
|
|
63
|
+
entry.abort.abort();
|
|
64
|
+
this.onCancel(path);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
this.evict();
|
|
68
|
+
}
|
|
69
|
+
get heldBytes() { let bytes = 0; for (const entry of this.entries.values())
|
|
70
|
+
bytes += entry.data?.length ?? 0; return bytes; }
|
|
71
|
+
evict() {
|
|
72
|
+
let bytes = this.heldBytes;
|
|
73
|
+
const candidates = [...this.entries.values()].filter((entry) => entry.data && !this.retained.has(entry.info.path)).sort((a, b) => a.used - b.used);
|
|
74
|
+
for (const entry of candidates) {
|
|
75
|
+
if (bytes <= this.maxBytes)
|
|
76
|
+
break;
|
|
77
|
+
bytes -= entry.data?.length ?? 0;
|
|
78
|
+
entry.data = null;
|
|
79
|
+
entry.status = "waiting";
|
|
80
|
+
entry.priority = Infinity;
|
|
81
|
+
this.onEvict(entry.info.path);
|
|
82
|
+
this.changed(entry.info.path);
|
|
83
|
+
}
|
|
41
84
|
}
|
|
42
85
|
/** Why a file failed. */
|
|
43
86
|
error(path) {
|
|
44
87
|
return this.entries.get(path)?.error ?? null;
|
|
45
88
|
}
|
|
46
|
-
|
|
47
|
-
|
|
89
|
+
failure(path) {
|
|
90
|
+
const entry = this.entries.get(path), failure = entry?.failure;
|
|
91
|
+
return { message: entry?.error ?? "failed", ...(failure ? { reason: failure.reason, retryable: failure.retryable, ...(failure.status === undefined ? {} : { status: failure.status }) } : {}) };
|
|
92
|
+
}
|
|
93
|
+
failMissing(path) {
|
|
94
|
+
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.` });
|
|
95
|
+
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 });
|
|
96
|
+
this.changed(path);
|
|
97
|
+
}
|
|
98
|
+
failures() {
|
|
99
|
+
return [...this.entries.values()].filter((entry) => entry.status === "failed").map((entry) => ({ path: entry.info.path, ...this.failure(entry.info.path) }));
|
|
100
|
+
}
|
|
101
|
+
progress(windowOnly = false) {
|
|
102
|
+
let ready = 0, failed = 0, bytes = 0, bytesReady = 0, files = 0;
|
|
48
103
|
for (const e of this.entries.values()) {
|
|
104
|
+
if (windowOnly && !this.retained.has(e.info.path))
|
|
105
|
+
continue;
|
|
106
|
+
files++;
|
|
49
107
|
bytes += e.info.bytes;
|
|
50
108
|
if (e.status === "ready") {
|
|
51
109
|
ready++;
|
|
@@ -54,7 +112,7 @@ export class AssetLoader {
|
|
|
54
112
|
if (e.status === "failed")
|
|
55
113
|
failed++;
|
|
56
114
|
}
|
|
57
|
-
return { files
|
|
115
|
+
return { files, ready, failed, bytes, bytesReady, ...(windowOnly ? { streaming: true } : {}) };
|
|
58
116
|
}
|
|
59
117
|
/** Takes over files another loader (the last build's) already has, where the hashes match: a
|
|
60
118
|
* rebuild fetches only what changed. Returns the paths taken over. */
|
|
@@ -73,9 +131,10 @@ export class AssetLoader {
|
|
|
73
131
|
/** Files that failed go back in the queue. */
|
|
74
132
|
retry() {
|
|
75
133
|
for (const e of this.entries.values())
|
|
76
|
-
if (e.status === "failed") {
|
|
134
|
+
if (e.status === "failed" && e.failure?.reason !== "invalid") {
|
|
77
135
|
e.status = "waiting";
|
|
78
136
|
e.error = null;
|
|
137
|
+
e.failure = null;
|
|
79
138
|
}
|
|
80
139
|
this.pump();
|
|
81
140
|
}
|
|
@@ -85,6 +144,8 @@ export class AssetLoader {
|
|
|
85
144
|
}
|
|
86
145
|
dispose() {
|
|
87
146
|
this.abort.abort();
|
|
147
|
+
for (const entry of this.entries.values())
|
|
148
|
+
entry.abort?.abort();
|
|
88
149
|
this.listeners.clear();
|
|
89
150
|
}
|
|
90
151
|
changed(path) {
|
|
@@ -115,19 +176,33 @@ export class AssetLoader {
|
|
|
115
176
|
}
|
|
116
177
|
}
|
|
117
178
|
async load(e) {
|
|
179
|
+
const abort = new AbortController();
|
|
180
|
+
e.abort = abort;
|
|
118
181
|
try {
|
|
119
|
-
const data = await this.fetchBytes(e.info.path, this.abort.signal);
|
|
182
|
+
const data = await this.fetchBytes(e.info.path, AbortSignal.any([this.abort.signal, abort.signal]));
|
|
183
|
+
const integrity = (message) => new MediaFailure({ asset: e.info.path, source: e.info.url ?? e.info.path, reason: "integrity", retryable: false, message });
|
|
184
|
+
if (e.info.bytes > 0 && data.length !== e.info.bytes)
|
|
185
|
+
throw integrity(`its size differs from the build (${data.length} bytes, expected ${e.info.bytes})`);
|
|
120
186
|
if (e.info.hash !== null && (await sha256(data)) !== e.info.hash)
|
|
121
|
-
throw
|
|
187
|
+
throw integrity("its contents don't match the build (the hash differs)");
|
|
122
188
|
e.data = data;
|
|
123
189
|
e.status = "ready";
|
|
124
190
|
}
|
|
125
191
|
catch (error) {
|
|
126
192
|
if (this.abort.signal.aborted)
|
|
127
193
|
return;
|
|
194
|
+
if (abort.signal.aborted) {
|
|
195
|
+
e.status = "waiting";
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
128
198
|
e.status = "failed";
|
|
199
|
+
e.failure = error instanceof MediaFailure ? error : null;
|
|
129
200
|
e.error = error instanceof Error ? error.message : String(error);
|
|
130
201
|
}
|
|
202
|
+
finally {
|
|
203
|
+
e.abort = null;
|
|
204
|
+
}
|
|
131
205
|
this.changed(e.info.path);
|
|
206
|
+
this.evict();
|
|
132
207
|
}
|
|
133
208
|
}
|
package/dist/player/audio.d.ts
CHANGED
|
@@ -4,7 +4,7 @@ export interface SoundtrackOptions {
|
|
|
4
4
|
/** The film's length in frames. */
|
|
5
5
|
readonly length: number;
|
|
6
6
|
/** Mixes frames [from, to). */
|
|
7
|
-
readonly mix: (from: number, to: number) => Promise<MixResult>;
|
|
7
|
+
readonly mix: (from: number, to: number, signal: AbortSignal) => Promise<MixResult>;
|
|
8
8
|
/** Frames per chunk (default 5 seconds). */
|
|
9
9
|
readonly chunk?: number;
|
|
10
10
|
/** Chunks kept mixed ahead of the playhead (default 3). */
|
|
@@ -15,6 +15,7 @@ export declare class Soundtrack {
|
|
|
15
15
|
private context;
|
|
16
16
|
private readonly chunks;
|
|
17
17
|
private readonly mixing;
|
|
18
|
+
private readonly controllers;
|
|
18
19
|
private playing;
|
|
19
20
|
private readonly size;
|
|
20
21
|
private readonly ahead;
|
package/dist/player/audio.js
CHANGED
|
@@ -7,6 +7,7 @@ export class Soundtrack {
|
|
|
7
7
|
context = null;
|
|
8
8
|
chunks = new Map();
|
|
9
9
|
mixing = new Map();
|
|
10
|
+
controllers = new Map();
|
|
10
11
|
playing = null;
|
|
11
12
|
size;
|
|
12
13
|
ahead;
|
|
@@ -104,6 +105,9 @@ export class Soundtrack {
|
|
|
104
105
|
this.stop();
|
|
105
106
|
this.chunks.clear();
|
|
106
107
|
this.mixing.clear();
|
|
108
|
+
for (const controller of this.controllers.values())
|
|
109
|
+
controller.abort();
|
|
110
|
+
this.controllers.clear();
|
|
107
111
|
this.generation++;
|
|
108
112
|
}
|
|
109
113
|
dispose() {
|
|
@@ -127,10 +131,13 @@ export class Soundtrack {
|
|
|
127
131
|
return pending;
|
|
128
132
|
const generation = this.generation;
|
|
129
133
|
const from = k * this.size, to = Math.min(this.options.length, from + this.size);
|
|
130
|
-
const
|
|
131
|
-
|
|
134
|
+
const controller = new AbortController();
|
|
135
|
+
this.controllers.set(k, controller);
|
|
136
|
+
const job = this.options.mix(from, to, controller.signal).then((mixed) => {
|
|
132
137
|
if (generation !== this.generation)
|
|
133
138
|
return null;
|
|
139
|
+
this.mixing.delete(k);
|
|
140
|
+
this.controllers.delete(k);
|
|
134
141
|
const buffer = new AudioBuffer({ numberOfChannels: 2, length: Math.max(1, mixed.left.length), sampleRate: mixed.sampleRate });
|
|
135
142
|
buffer.copyToChannel(own(mixed.left), 0);
|
|
136
143
|
buffer.copyToChannel(own(mixed.right), 1);
|
|
@@ -143,7 +150,10 @@ export class Soundtrack {
|
|
|
143
150
|
}
|
|
144
151
|
return buffer;
|
|
145
152
|
}, () => {
|
|
146
|
-
this.
|
|
153
|
+
if (generation === this.generation) {
|
|
154
|
+
this.mixing.delete(k);
|
|
155
|
+
this.controllers.delete(k);
|
|
156
|
+
}
|
|
147
157
|
return null;
|
|
148
158
|
});
|
|
149
159
|
this.mixing.set(k, job);
|
package/dist/player/frames.d.ts
CHANGED
package/dist/player/frames.js
CHANGED
|
@@ -61,6 +61,18 @@ export class FrameCache {
|
|
|
61
61
|
get size() {
|
|
62
62
|
return this.frames.size;
|
|
63
63
|
}
|
|
64
|
+
exactRanges() {
|
|
65
|
+
const frames = [...this.frames].filter(([, value]) => value.exact).map(([frame]) => frame).sort((a, b) => a - b);
|
|
66
|
+
const ranges = [];
|
|
67
|
+
for (const frame of frames) {
|
|
68
|
+
const last = ranges[ranges.length - 1];
|
|
69
|
+
if (last?.to === frame)
|
|
70
|
+
last.to++;
|
|
71
|
+
else
|
|
72
|
+
ranges.push({ from: frame, to: frame + 1 });
|
|
73
|
+
}
|
|
74
|
+
return ranges;
|
|
75
|
+
}
|
|
64
76
|
remove(frame, entry) {
|
|
65
77
|
entry.value.close();
|
|
66
78
|
this.frames.delete(frame);
|
package/dist/player/index.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ export { createPlayer, type Player, type PlayerOptions, type PlayerEvents, type
|
|
|
2
2
|
export { Cancelled, type FrameResult } from "./pool.js";
|
|
3
3
|
export { openFilm, openSource, openFiles, buildBase, SourceError, type FilmSource, type BuildSource, type FileSource, type OpenedBuild, type OpenedSource } from "./source.js";
|
|
4
4
|
export { FilmNeeds, type Span, type Range } from "./needs.js";
|
|
5
|
-
export { AssetLoader, type AssetFileInfo, type AssetStatus, type AssetProgress, type FetchBytes } from "./assets.js";
|
|
5
|
+
export { AssetLoader, type AssetFileInfo, type AssetStatus, type AssetProgress, type AssetFailureInfo, type FetchBytes } from "./assets.js";
|
|
6
6
|
export { FrameCache, type Cached, type Closable } from "./frames.js";
|
|
7
7
|
export { Transport, type PlayState, type TickInput, type TickResult } from "./transport.js";
|
|
8
8
|
export { wantedFrames, bufferedAhead, startBuffer, clockFrame, clampFrame, type Scales, type Want, type Situation } from "./schedule.js";
|
package/dist/player/needs.d.ts
CHANGED
|
@@ -20,12 +20,18 @@ export declare class FilmNeeds {
|
|
|
20
20
|
/** What the soundtrack needs. */
|
|
21
21
|
readonly audio: ReadonlyArray<string>;
|
|
22
22
|
readonly length: number;
|
|
23
|
+
private readonly audioSegments;
|
|
24
|
+
private readonly audioClips;
|
|
25
|
+
private readonly fps;
|
|
23
26
|
private constructor();
|
|
24
27
|
/** A film's needs from its film.json, and the files its build has (which videos have proxies,
|
|
25
28
|
* and how many segments). */
|
|
26
|
-
static of(doc: FilmDoc, files?: ReadonlyArray<string
|
|
29
|
+
static of(doc: FilmDoc, files?: ReadonlyArray<string>, options?: {
|
|
30
|
+
readonly demandVideo?: boolean;
|
|
31
|
+
}): FilmNeeds;
|
|
27
32
|
/** A film whose needs aren't known per clip: every frame needs every file. */
|
|
28
33
|
static everything(paths: ReadonlyArray<string>, length: number): FilmNeeds;
|
|
34
|
+
audioPathsIn(from: number, to: number): ReadonlyArray<string>;
|
|
29
35
|
/** The spans on screen at a frame, over every track. */
|
|
30
36
|
spansAt(frame: number): ReadonlyArray<Span>;
|
|
31
37
|
/** The spans on screen anywhere in [from, to). */
|
package/dist/player/needs.js
CHANGED
|
@@ -52,21 +52,27 @@ export class FilmNeeds {
|
|
|
52
52
|
all;
|
|
53
53
|
audio;
|
|
54
54
|
length;
|
|
55
|
+
audioSegments;
|
|
56
|
+
audioClips;
|
|
57
|
+
fps;
|
|
55
58
|
constructor(
|
|
56
59
|
/** Visual tracks: what frames need. */
|
|
57
60
|
lanes,
|
|
58
61
|
/** Every file the film loads: frames' and the soundtrack's. */
|
|
59
62
|
all,
|
|
60
63
|
/** What the soundtrack needs. */
|
|
61
|
-
audio, length) {
|
|
64
|
+
audio, length, audioSegments = new Map(), audioClips = [], fps = 30) {
|
|
62
65
|
this.lanes = lanes;
|
|
63
66
|
this.all = all;
|
|
64
67
|
this.audio = audio;
|
|
65
68
|
this.length = length;
|
|
69
|
+
this.audioSegments = audioSegments;
|
|
70
|
+
this.audioClips = audioClips;
|
|
71
|
+
this.fps = fps;
|
|
66
72
|
}
|
|
67
73
|
/** A film's needs from its film.json, and the files its build has (which videos have proxies,
|
|
68
74
|
* and how many segments). */
|
|
69
|
-
static of(doc, files = []) {
|
|
75
|
+
static of(doc, files = [], options = {}) {
|
|
70
76
|
const assets = clipAssets(doc);
|
|
71
77
|
// Proxy segments by video, from the build's files: video/<name>/proxy/<k>.mp4.
|
|
72
78
|
const segmentsOf = new Map();
|
|
@@ -79,13 +85,31 @@ export class FilmNeeds {
|
|
|
79
85
|
const allSegments = (name) => Array.from({ length: segmentsOf.get(name) ?? 0 }, (_, k) => videoProxyPath({ name }, k));
|
|
80
86
|
const lanes = [];
|
|
81
87
|
const audio = new Set();
|
|
88
|
+
const audioSegments = new Map();
|
|
89
|
+
const audioClips = [];
|
|
90
|
+
for (const path of files) {
|
|
91
|
+
const name = /^audio\/([^/]+)\/\d+\.wav$/.exec(path)?.[1];
|
|
92
|
+
if (name !== undefined)
|
|
93
|
+
audioSegments.set(name, (audioSegments.get(name) ?? 0) + 1);
|
|
94
|
+
}
|
|
82
95
|
for (const track of doc.tracks) {
|
|
83
96
|
if (track.kind !== "visual") {
|
|
84
|
-
for (const clip of track.clips)
|
|
97
|
+
for (const clip of track.clips) {
|
|
98
|
+
if (clip.kind === "audio")
|
|
99
|
+
audioClips.push(clip);
|
|
85
100
|
for (const a of assets.get(clip.id) ?? [])
|
|
86
|
-
if (a.kind === "audio")
|
|
87
|
-
|
|
88
|
-
|
|
101
|
+
if (a.kind === "audio") {
|
|
102
|
+
const count = audioSegments.get(a.name) ?? 0;
|
|
103
|
+
if (count > 0) {
|
|
104
|
+
audio.add(`audio/${a.name}/info.json`);
|
|
105
|
+
for (let k = 0; k < count; k++)
|
|
106
|
+
audio.add(`audio/${a.name}/${String(k).padStart(5, "0")}.wav`);
|
|
107
|
+
}
|
|
108
|
+
else
|
|
109
|
+
for (const path of importedFiles(a))
|
|
110
|
+
audio.add(path);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
89
113
|
continue;
|
|
90
114
|
}
|
|
91
115
|
const holds = new Map(track.transitions.map((t) => [t.from, t.frames]));
|
|
@@ -98,19 +122,39 @@ export class FilmNeeds {
|
|
|
98
122
|
if (clip.kind === "video" && segments > 0)
|
|
99
123
|
spans.push(...videoSpans(clip, to, base, segments, doc.film.fps));
|
|
100
124
|
else {
|
|
101
|
-
const shown = clip.kind === "scene" ? refs.filter((
|
|
125
|
+
const shown = clip.kind === "scene" && !options.demandVideo ? refs.filter((asset) => asset.kind === "video").flatMap((asset) => allSegments(asset.name)) : [];
|
|
102
126
|
spans.push({ clip: clip.id, from: clip.start, to, paths: [...base, ...shown] });
|
|
103
127
|
}
|
|
104
128
|
}
|
|
105
129
|
lanes.push(lane(spans));
|
|
106
130
|
}
|
|
107
131
|
const all = new Set([...lanes.flatMap((l) => l.spans.flatMap((s) => s.paths)), ...audio]);
|
|
108
|
-
return new FilmNeeds(lanes, [...all].sort(), [...audio].sort(), doc.film.length);
|
|
132
|
+
return new FilmNeeds(lanes, [...new Set([...all, ...[...segmentsOf.keys()].flatMap(allSegments)])].sort(), [...audio].sort(), doc.film.length, audioSegments, audioClips, doc.film.fps);
|
|
109
133
|
}
|
|
110
134
|
/** A film whose needs aren't known per clip: every frame needs every file. */
|
|
111
135
|
static everything(paths, length) {
|
|
112
136
|
return new FilmNeeds([lane([{ clip: "film", from: 0, to: length, paths }])], [...paths].sort(), [], length);
|
|
113
137
|
}
|
|
138
|
+
audioPathsIn(from, to) {
|
|
139
|
+
if (this.audioSegments.size === 0)
|
|
140
|
+
return this.audio;
|
|
141
|
+
const paths = new Set();
|
|
142
|
+
for (const clip of this.audioClips) {
|
|
143
|
+
if (clip.end <= from || clip.start >= to)
|
|
144
|
+
continue;
|
|
145
|
+
const count = this.audioSegments.get(clip.asset) ?? 0;
|
|
146
|
+
if (count === 0) {
|
|
147
|
+
paths.add(`audio/${clip.asset}.wav`);
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
paths.add(`audio/${clip.asset}/info.json`);
|
|
151
|
+
const first = Math.max(0, Math.floor((clip.in + Math.max(0, from - clip.start) / this.fps * clip.speed - 0.5 * clip.speed) / 4));
|
|
152
|
+
const last = Math.min(count - 1, Math.floor((clip.in + Math.min(clip.duration, to - clip.start) / this.fps * clip.speed + 0.5 * clip.speed) / 4));
|
|
153
|
+
for (let k = first; k <= last; k++)
|
|
154
|
+
paths.add(`audio/${clip.asset}/${String(k).padStart(5, "0")}.wav`);
|
|
155
|
+
}
|
|
156
|
+
return [...paths];
|
|
157
|
+
}
|
|
114
158
|
/** The spans on screen at a frame, over every track. */
|
|
115
159
|
spansAt(frame) {
|
|
116
160
|
return this.spansIn(frame, frame + 1);
|
package/dist/player/player.d.ts
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import type { PlanSummary } from "@tongue-core/film";
|
|
2
2
|
import type { BuildDoc, FilmDoc } from "@tongue-core/project/doc";
|
|
3
|
-
import { type AssetProgress } from "./assets.js";
|
|
3
|
+
import { type AssetFailureInfo, type AssetProgress } from "./assets.js";
|
|
4
4
|
import { type Range } from "./needs.js";
|
|
5
5
|
import { type Scales } from "./schedule.js";
|
|
6
6
|
import { type FilmSource } from "./source.js";
|
|
7
7
|
import { type PlayState } from "./transport.js";
|
|
8
8
|
export interface PlayerOptions {
|
|
9
|
+
/** Compressed media retained by the page; workers discard evicted segments too. Default 64 MiB. */
|
|
10
|
+
readonly mediaCacheBytes?: number;
|
|
11
|
+
/** Seconds of preview media to fetch ahead. Default 15. */
|
|
12
|
+
readonly mediaAhead?: number;
|
|
9
13
|
/** Where frames are drawn. Its pixel size follows the film's size at the still scale. */
|
|
10
14
|
readonly canvas: HTMLCanvasElement;
|
|
11
15
|
/** The worker script: @tongue-core/ui/player/worker, bundled. */
|
|
@@ -20,14 +24,14 @@ export interface PlayerOptions {
|
|
|
20
24
|
readonly ahead?: number;
|
|
21
25
|
}
|
|
22
26
|
export interface Readiness {
|
|
27
|
+
readonly mediaBytes?: number;
|
|
23
28
|
/** Stretches of the film whose frames can all be drawn now. */
|
|
24
29
|
readonly ranges: ReadonlyArray<Range>;
|
|
25
30
|
readonly progress: AssetProgress;
|
|
26
31
|
/** Files that couldn't be loaded, and why. */
|
|
27
32
|
readonly failed: ReadonlyArray<{
|
|
28
33
|
readonly path: string;
|
|
29
|
-
|
|
30
|
-
}>;
|
|
34
|
+
} & AssetFailureInfo>;
|
|
31
35
|
}
|
|
32
36
|
/** What is on the canvas: which frame, how sharp, and whether it is the playhead's, sharp. */
|
|
33
37
|
export interface Shown {
|
|
@@ -77,6 +81,8 @@ export interface Player {
|
|
|
77
81
|
load(source: FilmSource): Promise<LoadedFilm>;
|
|
78
82
|
/** Loads the current build again. */
|
|
79
83
|
reload(): Promise<LoadedFilm>;
|
|
84
|
+
/** Retries failed media after the host has restored access. */
|
|
85
|
+
retry(): void;
|
|
80
86
|
seek(frame: number): void;
|
|
81
87
|
play(): void;
|
|
82
88
|
pause(): void;
|