@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 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
@@ -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
- constructor(files: ReadonlyArray<AssetFileInfo>, fetchBytes: FetchBytes, concurrency?: number);
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
- progress(): AssetProgress;
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>;
@@ -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
- constructor(files, fetchBytes, concurrency = 6) {
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
- return this.entries.get(path)?.data ?? null;
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
- progress() {
47
- let ready = 0, failed = 0, bytes = 0, bytesReady = 0;
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: this.entries.size, ready, failed, bytes, bytesReady };
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 new Error("its contents don't match the build (the hash differs)");
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
  }
@@ -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;
@@ -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 job = this.options.mix(from, to).then((mixed) => {
131
- this.mixing.delete(k);
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.mixing.delete(k);
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);
@@ -29,6 +29,10 @@ export declare class FrameCache<T extends Closable> {
29
29
  clear(): void;
30
30
  get bytes(): number;
31
31
  get size(): number;
32
+ exactRanges(): ReadonlyArray<{
33
+ readonly from: number;
34
+ readonly to: number;
35
+ }>;
32
36
  private remove;
33
37
  private trim;
34
38
  }
@@ -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);
@@ -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";
@@ -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>): FilmNeeds;
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). */
@@ -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
- for (const path of importedFiles(a))
88
- audio.add(path);
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((a) => a.kind === "video").flatMap((a) => allSegments(a.name)) : [];
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);
@@ -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
- readonly message: string;
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;