@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.
- package/dist/assets/codecs/types.d.ts +2 -0
- package/dist/assets/store.d.ts +13 -0
- package/dist/assets/store.js +73 -5
- package/dist/audio/mix.js +2 -1
- package/dist/render/renderer.d.ts +2 -0
- package/dist/render/renderer.js +6 -0
- package/dist/render/sounds.d.ts +4 -1
- package/dist/render/sounds.js +6 -2
- package/package.json +2 -2
|
@@ -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
|
}
|
package/dist/assets/store.d.ts
CHANGED
|
@@ -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;
|
package/dist/assets/store.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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. */
|
package/dist/render/renderer.js
CHANGED
|
@@ -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. */
|
package/dist/render/sounds.d.ts
CHANGED
|
@@ -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
|
|
12
|
+
export declare function audioClips(plan: FilmPlan, assets: LoadedAssets, range?: {
|
|
13
|
+
readonly from: number;
|
|
14
|
+
readonly to: number;
|
|
15
|
+
}): ReadonlyArray<ClipAudio>;
|
package/dist/render/sounds.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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.
|
|
29
|
+
"@tongue-core/film": "0.1.0-rc.9"
|
|
30
30
|
},
|
|
31
31
|
"publishConfig": {
|
|
32
32
|
"access": "public"
|