@tongue-core/engine 0.1.0-rc.8 → 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.
@@ -6,6 +6,8 @@ export interface RgbaImage {
6
6
  }
7
7
  /** Planar float audio in -1..1. Every channel has the same length. */
8
8
  export interface PcmAudio {
9
+ /** Source sample represented by channels[0][0], for a window decoded from segmented audio. */
10
+ readonly offset?: number;
9
11
  readonly sampleRate: number;
10
12
  readonly channels: ReadonlyArray<Float32Array>;
11
13
  }
@@ -21,6 +21,7 @@ export interface LoadedVideo {
21
21
  readonly poster: LoadedImage | null;
22
22
  }
23
23
  export interface LoadedAssets {
24
+ readonly audioWindow?: (name: string, fromSeconds: number, toSeconds: number) => PcmAudio | null;
24
25
  readonly fonts: FontLibrary;
25
26
  readonly images: ReadonlyMap<string, LoadedImage>;
26
27
  readonly audio: ReadonlyMap<string, PcmAudio>;
@@ -65,6 +66,9 @@ export declare function loadAssets(assets: ReadonlyArray<Asset> | undefined, fet
65
66
  * need): the maps a renderer reads at draw time, filled as files come. Drawing a frame whose
66
67
  * files aren't here yet throws, as with any missing asset; nothing is drawn half loaded. */
67
68
  export declare class AssetShelf implements LoadedAssets {
69
+ private readonly pendingPosters;
70
+ private readonly audioInfo;
71
+ private readonly audioParts;
68
72
  readonly fonts: FontLibrary;
69
73
  readonly images: Map<string, LoadedImage>;
70
74
  readonly audio: Map<string, PcmAudio>;
@@ -75,7 +79,16 @@ export declare class AssetShelf implements LoadedAssets {
75
79
  add(asset: Asset, path: string, bytes: Uint8Array, audio?: boolean): void;
76
80
  /** Whether the file at `path` is on the shelf. */
77
81
  has(path: string): boolean;
82
+ remove(path: string): void;
83
+ readonly audioWindow: (name: string, fromSeconds: number, toSeconds: number) => PcmAudio | null;
78
84
  }
85
+ export declare function parseAudioInfo(bytes: Uint8Array): {
86
+ readonly sampleRate: number;
87
+ readonly segments: ReadonlyArray<{
88
+ readonly first: number;
89
+ readonly samples: number;
90
+ }>;
91
+ };
79
92
  /** A frame needs an image that isn't here: a host loading on demand fetches `asset` and draws again. */
80
93
  export declare class ImageNotLoaded extends AssetError {
81
94
  readonly asset: ImageAsset;
@@ -82,6 +82,9 @@ export async function loadAssets(assets = declaredAssets(), fetch, options = {})
82
82
  * need): the maps a renderer reads at draw time, filled as files come. Drawing a frame whose
83
83
  * files aren't here yet throws, as with any missing asset; nothing is drawn half loaded. */
84
84
  export class AssetShelf {
85
+ pendingPosters = new Map();
86
+ audioInfo = new Map();
87
+ audioParts = new Map();
85
88
  fonts = new FontLibrary();
86
89
  images = new Map();
87
90
  audio = new Map();
@@ -97,18 +100,33 @@ export class AssetShelf {
97
100
  case "image":
98
101
  this.images.set(asset.name, { qoi: bytes, ...qoiSize(bytes) });
99
102
  break;
100
- case "audio":
101
- if (audio)
103
+ case "audio": {
104
+ if (!audio)
105
+ break;
106
+ if (path.endsWith("/info.json"))
107
+ this.audioInfo.set(asset.name, parseAudioInfo(bytes));
108
+ else if (path.includes(`/audio/${asset.name}/`) || path.startsWith(`audio/${asset.name}/`)) {
109
+ const index = Number(/\/(\d+)\.wav$/.exec(path)?.[1]);
110
+ if (!Number.isInteger(index))
111
+ throw new AssetError(`Invalid audio segment ${path}.`);
112
+ const parts = this.audioParts.get(asset.name) ?? new Map();
113
+ parts.set(index, readWav(bytes));
114
+ this.audioParts.set(asset.name, parts);
115
+ }
116
+ else
102
117
  this.audio.set(asset.name, readWav(bytes));
103
118
  break;
119
+ }
104
120
  case "video": {
105
121
  const known = this.videos.get(asset.name);
106
- if (path === videoInfoPath(asset))
107
- this.videos.set(asset.name, { info: parseVideoInfo(bytes, asset.name), poster: known?.poster ?? null });
122
+ if (path === videoInfoPath(asset)) {
123
+ this.videos.set(asset.name, { info: parseVideoInfo(bytes, asset.name), poster: known?.poster ?? this.pendingPosters.get(asset.name) ?? null });
124
+ this.pendingPosters.delete(asset.name);
125
+ }
108
126
  else if (path === videoPosterPath(asset) && known)
109
127
  this.videos.set(asset.name, { info: known.info, poster: { qoi: bytes, ...qoiSize(bytes) } });
110
128
  else if (path === videoPosterPath(asset))
111
- throw new AssetError(`video "${asset.name}": its poster came before its info.json.`);
129
+ this.pendingPosters.set(asset.name, { qoi: bytes, ...qoiSize(bytes) });
112
130
  break;
113
131
  }
114
132
  }
@@ -118,6 +136,56 @@ export class AssetShelf {
118
136
  has(path) {
119
137
  return this.held.has(path);
120
138
  }
139
+ remove(path) {
140
+ this.held.delete(path);
141
+ const image = /^images\/([^/]+)\.qoi$/.exec(path)?.[1];
142
+ if (image !== undefined)
143
+ this.images.delete(image);
144
+ const poster = /^video\/([^/]+)\/poster\.qoi$/.exec(path)?.[1];
145
+ const video = poster === undefined ? undefined : this.videos.get(poster);
146
+ if (poster !== undefined)
147
+ this.pendingPosters.delete(poster);
148
+ if (poster !== undefined && video)
149
+ this.videos.set(poster, { ...video, poster: null });
150
+ const audio = /^audio\/([^/]+)\/(\d+)\.wav$/.exec(path);
151
+ if (audio?.[1] !== undefined)
152
+ this.audioParts.get(audio[1])?.delete(Number(audio[2]));
153
+ }
154
+ audioWindow = (name, fromSeconds, toSeconds) => {
155
+ const info = this.audioInfo.get(name);
156
+ if (!info)
157
+ return this.audio.get(name) ?? null;
158
+ const first = Math.max(0, Math.floor(fromSeconds * info.sampleRate));
159
+ const end = Math.ceil(toSeconds * info.sampleRate);
160
+ const channels = [new Float32Array(Math.max(0, end - first)), new Float32Array(Math.max(0, end - first))];
161
+ for (let k = 0; k < info.segments.length; k++) {
162
+ const segment = info.segments[k];
163
+ if (!segment || segment.first >= end || segment.first + segment.samples <= first)
164
+ continue;
165
+ const part = this.audioParts.get(name)?.get(k);
166
+ if (!part)
167
+ throw new AssetError(`audio "${name}": segment ${k} is not loaded.`);
168
+ if (part.sampleRate !== info.sampleRate || part.channels.length !== 2 || part.channels.some((channel) => channel.length !== segment.samples))
169
+ throw new AssetError(`audio "${name}": segment ${k} has a different rate, channel count or length from its metadata.`);
170
+ const a = Math.max(first, segment.first), b = Math.min(end, segment.first + segment.samples);
171
+ channels.forEach((channel, c) => channel.set(part.channels[c]?.subarray(a - segment.first, b - segment.first) ?? new Float32Array(b - a), a - first));
172
+ }
173
+ return { sampleRate: info.sampleRate, channels, offset: first };
174
+ };
175
+ }
176
+ export function parseAudioInfo(bytes) {
177
+ const value = JSON.parse(new TextDecoder().decode(bytes));
178
+ if (!isRecord(value) || value["sampleRate"] !== 48000 || value["channels"] !== 2 || !Array.isArray(value["segments"]))
179
+ throw new AssetError("Invalid segmented audio metadata.");
180
+ let end = 0;
181
+ const segments = value["segments"].map((entry) => {
182
+ if (!isRecord(entry) || entry["first"] !== end || typeof entry["samples"] !== "number" || !Number.isInteger(entry["samples"]) || entry["samples"] <= 0)
183
+ throw new AssetError("Audio segments must be contiguous and have a positive sample count.");
184
+ const segment = { first: end, samples: entry["samples"] };
185
+ end += segment.samples;
186
+ return segment;
187
+ });
188
+ return { sampleRate: 48000, segments };
121
189
  }
122
190
  /** A frame needs an image that isn't here: a host loading on demand fetches `asset` and draws again. */
123
191
  export class ImageNotLoaded extends AssetError {
package/dist/audio/mix.js CHANGED
@@ -94,9 +94,10 @@ function channelAt(pcm, channel, fromSeconds, length) {
94
94
  return out;
95
95
  const ratio = pcm.sampleRate / SAMPLE_RATE;
96
96
  const offset = fromSeconds * pcm.sampleRate;
97
+ const windowOffset = pcm.offset ?? 0;
97
98
  for (let i = 0; i < length; i++) {
98
99
  const pos = offset + i * ratio;
99
- const j = Math.floor(pos), f = pos - j;
100
+ const index = Math.floor(pos), j = index - windowOffset, f = pos - index;
100
101
  out[i] = (src[j] ?? 0) * (1 - f) + (src[j + 1] ?? 0) * f;
101
102
  }
102
103
  return out;
@@ -65,6 +65,8 @@ export declare class FilmRenderer {
65
65
  /** Puts an image in the raster now (decoded, mip-mapped), so the first frame that shows it doesn't
66
66
  * wait for that: what a player does with an image as it arrives. */
67
67
  preloadImage(name: string): void;
68
+ /** Releases the raster's decoded copy when a host evicts a remote image. */
69
+ forgetImage(name: string): void;
68
70
  /** Keeps only the images `out` draws in the raster and returns their names. Decoded images are
69
71
  * the raster's biggest cost, so a host with little memory (a Worker) calls this before drawing,
70
72
  * and drops the files of everything else. A later frame that shows one loads it again. */
@@ -172,6 +172,12 @@ export class FilmRenderer {
172
172
  this.backend.loadImage(id, image.qoi);
173
173
  this.loaded.set(id, "image");
174
174
  }
175
+ /** Releases the raster's decoded copy when a host evicts a remote image. */
176
+ forgetImage(name) {
177
+ const id = this.imageIds.get(`${IMAGE_KEY}${name}`);
178
+ if (id !== undefined && this.loaded.delete(id))
179
+ this.backend.loadImage(id, NOTHING);
180
+ }
175
181
  /** Keeps only the images `out` draws in the raster and returns their names. Decoded images are
176
182
  * the raster's biggest cost, so a host with little memory (a Worker) calls this before drawing,
177
183
  * and drops the files of everything else. A later frame that shows one loads it again. */
@@ -9,4 +9,7 @@ export declare function collectSounds(plan: FilmPlan, range?: {
9
9
  readonly to: number;
10
10
  }): ReadonlyArray<SoundEvent>;
11
11
  /** The film's audio clips with their decoded audio (clips whose audio isn't loaded are left out). */
12
- export declare function audioClips(plan: FilmPlan, assets: LoadedAssets): ReadonlyArray<ClipAudio>;
12
+ export declare function audioClips(plan: FilmPlan, assets: LoadedAssets, range?: {
13
+ readonly from: number;
14
+ readonly to: number;
15
+ }): ReadonlyArray<ClipAudio>;
@@ -34,12 +34,16 @@ export function collectSounds(plan, range) {
34
34
  return [...seen.values()].sort((a, b) => a.at - b.at);
35
35
  }
36
36
  /** The film's audio clips with their decoded audio (clips whose audio isn't loaded are left out). */
37
- export function audioClips(plan, assets) {
37
+ export function audioClips(plan, assets, range) {
38
38
  return plan.tracks.filter((t) => t.kind === "audio").flatMap((t) => t.clips).flatMap((clip) => {
39
39
  const s = clip.source;
40
40
  if (s.kind !== "audio")
41
41
  return [];
42
- const pcm = assets.audio.get(s.asset.name);
42
+ if (range && (clip.end <= range.from || clip.start >= range.to))
43
+ return [];
44
+ const from = range ? s.in + Math.max(0, (range.from - clip.start) / plan.fps) * s.speed : s.in;
45
+ const to = range ? s.in + Math.min(clip.duration / plan.fps, (range.to - clip.start) / plan.fps) * s.speed : s.out;
46
+ const pcm = assets.audioWindow?.(s.asset.name, Math.max(s.in, from - 0.2 * s.speed), to + 0.2 * s.speed) ?? assets.audio.get(s.asset.name);
43
47
  return pcm ? [{ pcm, start: clip.start, end: clip.end, in: s.in, speed: s.speed, gain: s.gain, fadeIn: s.fadeIn, fadeOut: s.fadeOut }] : [];
44
48
  });
45
49
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tongue-core/engine",
3
- "version": "0.1.0-rc.8",
3
+ "version": "0.1.0-rc.9",
4
4
  "description": "Frame to pixels for Tongue films: evaluate, layout, paint and rasterize, deterministic everywhere.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -26,7 +26,7 @@
26
26
  "README.md"
27
27
  ],
28
28
  "dependencies": {
29
- "@tongue-core/film": "0.1.0-rc.8"
29
+ "@tongue-core/film": "0.1.0-rc.9"
30
30
  },
31
31
  "publishConfig": {
32
32
  "access": "public"