@tongue-core/sdk 0.1.0-rc.1 → 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.
Files changed (72) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +215 -24
  3. package/dist/TongueServices.d.ts +5 -0
  4. package/dist/TongueServices.js +2 -0
  5. package/dist/build/build.d.ts +5 -2
  6. package/dist/build/build.js +29 -16
  7. package/dist/check.d.ts +2 -1
  8. package/dist/check.js +3 -2
  9. package/dist/client.d.ts +5 -1
  10. package/dist/client.js +9 -9
  11. package/dist/encode/encoders.d.ts +2 -1
  12. package/dist/encode/encoders.js +6 -4
  13. package/dist/errors.d.ts +14 -2
  14. package/dist/errors.js +14 -1
  15. package/dist/import/convert.js +4 -2
  16. package/dist/import/importer.d.ts +23 -8
  17. package/dist/import/importer.js +28 -92
  18. package/dist/import/local.d.ts +17 -0
  19. package/dist/import/local.js +97 -0
  20. package/dist/import/media.d.ts +1 -1
  21. package/dist/import/media.js +6 -4
  22. package/dist/import/types.d.ts +10 -0
  23. package/dist/import/types.js +1 -0
  24. package/dist/index.d.ts +14 -4
  25. package/dist/index.js +13 -3
  26. package/dist/media/cache.d.ts +40 -0
  27. package/dist/media/cache.js +161 -0
  28. package/dist/media/prepare.d.ts +44 -0
  29. package/dist/media/prepare.js +206 -0
  30. package/dist/media/snapshot.d.ts +16 -0
  31. package/dist/media/snapshot.js +38 -0
  32. package/dist/points.d.ts +28 -0
  33. package/dist/points.js +76 -0
  34. package/dist/project.d.ts +38 -8
  35. package/dist/project.js +114 -19
  36. package/dist/render/budget.d.ts +45 -0
  37. package/dist/render/budget.js +83 -0
  38. package/dist/render/entries.d.ts +2 -0
  39. package/dist/render/entries.js +7 -0
  40. package/dist/render/events.d.ts +107 -0
  41. package/dist/render/events.js +1 -0
  42. package/dist/render/film-loader.d.ts +3 -2
  43. package/dist/render/film-loader.js +51 -15
  44. package/dist/render/frame-pipe.d.ts +18 -0
  45. package/dist/render/frame-pipe.js +46 -0
  46. package/dist/render/frame.d.ts +11 -2
  47. package/dist/render/frame.js +30 -8
  48. package/dist/render/inputs.d.ts +5 -0
  49. package/dist/render/inputs.js +36 -0
  50. package/dist/render/pipeline.d.ts +23 -20
  51. package/dist/render/pipeline.js +142 -53
  52. package/dist/render/protocol.d.ts +79 -5
  53. package/dist/render/protocol.js +112 -4
  54. package/dist/render/runs.d.ts +10 -0
  55. package/dist/render/runs.js +42 -0
  56. package/dist/render/soundtrack-process.js +77 -23
  57. package/dist/render/stills.d.ts +7 -6
  58. package/dist/render/stills.js +17 -10
  59. package/dist/render/stopwatch.d.ts +10 -0
  60. package/dist/render/stopwatch.js +16 -0
  61. package/dist/render/video-frames.d.ts +28 -2
  62. package/dist/render/video-frames.js +208 -53
  63. package/dist/render/worker.js +89 -39
  64. package/dist/sound/AudioProcessor.d.ts +59 -0
  65. package/dist/sound/AudioProcessor.js +18 -0
  66. package/dist/sound/Sound.d.ts +48 -0
  67. package/dist/sound/Sound.js +92 -0
  68. package/dist/sound/soundtrack.d.ts +21 -0
  69. package/dist/sound/soundtrack.js +141 -0
  70. package/dist/studio/server.d.ts +3 -1
  71. package/dist/studio/server.js +33 -10
  72. package/package.json +13 -10
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
@@ -7,9 +7,12 @@ here, with the same results. Built with Effect v4 (orchestration only: the per-f
7
7
  TypeScript); a Promise facade is included for applications that don't use Effect.
8
8
 
9
9
  ```bash
10
- bun add @tongue-core/sdk@next # Bun ≥ 1.3; ffmpeg and ffprobe on the PATH
10
+ bun add @tongue-core/sdk@next effect@4.0.6 # Bun ≥ 1.3; ffmpeg and ffprobe on the PATH
11
11
  ```
12
12
 
13
+ `effect` is a peer dependency: your application and the SDK share one copy of it, so the SDK's
14
+ services, errors and Layers are the same values as yours.
15
+
13
16
  ## Promise API
14
17
 
15
18
  ```ts
@@ -31,27 +34,32 @@ Failures reject with the SDK's tagged errors (below).
31
34
  ## Effect API
32
35
 
33
36
  ```ts
34
- import { BunServices } from "@effect/platform-bun";
35
- import { openFilm, renderVideo } from "@tongue-core/sdk";
37
+ import { openFilm, renderVideo, TongueServices } from "@tongue-core/sdk";
36
38
  import { Effect } from "effect";
37
39
 
38
40
  const program = Effect.gen(function* () {
39
41
  const film = yield* openFilm("src/film.tsx"); // load, import assets, plan
40
42
  return yield* renderVideo(film, { out: "out/film.mp4", encoder: "auto", raster: "auto" });
41
43
  });
42
- await Effect.runPromise(program.pipe(Effect.provide(BunServices.layer)));
44
+ await Effect.runPromise(program.pipe(Effect.provide(TongueServices.layer)));
43
45
  ```
44
46
 
47
+ `TongueServices.layer` is what every SDK function runs on (the file system, paths and child
48
+ processes, from Bun): provide it once, where your application runs its Effects.
49
+
45
50
  | Function | Does |
46
51
  |---|---|
47
52
  | `initProject(dir, { template, name })` | a film project in a new or empty folder, from a built-in template or a template folder |
48
53
  | `listTemplates()` / `readTemplate(nameOrPath)` | the built-in templates, or one by name or path |
49
- | `buildFilm(project, { out })` | builds a film project into `out` (default `dist/`): build.json, film.json, a bundle per scene |
50
- | `openFilm(path, { scene })` | a film ready to render: a project is built (into `.tongue/builds/<id>`, named by its content) and planned from film.json, a film module is imported and planned; assets imported (cached by size, mtime and options) either way. `scene: { id, props }` opens one scene of a project on its own |
54
+ | `buildFilm(project, { out, media })` | builds a film project into `out` (default `dist/`): build.json, film.json, a bundle per scene; `media` configures acquisition and hosted previews |
55
+ | `openFilm(path, { scene, sources, media })` | a film ready to render: a project is built (into `.tongue/builds/<id>`, named by its content) and planned from film.json, a build folder (what `buildFilm` writes) is opened as it is, a film module is imported and planned; assets imported either way. `scene: { id, props }` opens one scene of a project on its own; `sources` overrides video originals; `media` configures hosted-media acquisition and caching ([Footage](#footage)) |
51
56
  | `describeFilm(film)` | the plan as text (for agents to read) and as a JSON summary |
52
57
  | `checkFilm(path)` | problems and warnings as data |
53
58
  | `renderStills(film, { frames, scale, outDir })` / `renderSheet(…)` | PNGs drawn by the wasm raster (the player's), or tiled into one labelled sheet |
54
- | `renderVideo(film, options)` | an MP4 with sound: the whole film or `[from, to)`, at any scale |
59
+ | `renderVideo(film, options)` | an MP4 with sound: the whole film or `[from, to)`, at any scale (`out` defaults to `out/<name>.mp4`); `sound` runs [sound processing](#sound-processing-extensions); `onStart` once the request is checked and the raster, encoder and workers are chosen (`budget` says why that many workers: one per physical core, fewer when memory is short), `onProgress` while it draws. A frame that throws fails the render with `Frame <n>: <message>`. The result's `profile` says where the time went: milliseconds per stage of drawing, decoder starts, pieces encoded, and the wall time of each phase |
60
+ | `renderVideoEvents(film, options)` | the same render as a `Stream` of `RenderEvent`s: `Started`, `Frames`, `Sound` (each processor's progress), `Joining`, then `Done` with the result |
61
+ | `parsePoint`, `parseFrame`, `parseRange`, `clipRange`, `spreadFrames`, `everyFrames` | points in a film as people write them (`90`, `3s`, `50%`, `end`, `site`, `site+30`), for a command line or form of your own; bad ones fail with `InvalidRequest` saying how to write them |
62
+ | `videoInputs(film)`, `checkVideoInputs(film)` | where each video is decoded from, and a check that every one is there |
55
63
  | `listBackends()` | every raster and encoder, each tried for real |
56
64
  | `studioServer(path, { port, remote })` | the studio on a project (rebuilt and reloaded on every edit) or on a build served anywhere |
57
65
 
@@ -67,33 +75,211 @@ browser fetches it). `.tongue/types/film` links to the SDK's `@tongue-core/film`
67
75
  project's `tsconfig.json` points editors at.
68
76
 
69
77
  A **film module** (the way films were written before projects) is a `film.tsx` in a folder with a
70
- `package.json`, which installs `@tongue-core/film` and compiles JSX for it
71
- (`"jsx": "react-jsx", "jsxImportSource": "@tongue-core/film"`). It is imported and planned directly.
78
+ `package.json`. The SDK bundles it with Bun (into `.tongue/modules/`, JSX compiled for Tongue) against
79
+ its own `@tongue-core/film`, the copy the engine reads, as it links a project's bundles, then plans
80
+ it. The folder may install `@tongue-core/film` too, for its editor's types
81
+ (`"jsx": "react-jsx", "jsxImportSource": "@tongue-core/film"` in its tsconfig); that copy isn't the one
82
+ that runs.
72
83
 
73
- Either way, asset paths are relative to the root, Tongue writes `.tongue/` there (imported assets,
74
- builds, render work) and renders to `out/`.
84
+ Local asset paths are relative to the root. Tongue writes `.tongue/` there for builds and render
85
+ work, and renders to `out/`. Local converted assets use the project's cache; hosted-media
86
+ downloads and preparation use the shared cache outside the project.
75
87
 
76
88
  A film's assets are what its modules declare (`font()`, `image()`, `audio()`, `video()`) while
77
89
  the SDK loads it, plus the media its clips play; `<Film assets={[…]}>` replaces the declared ones.
78
90
  Importing turns them into what the engine reads: TrueType fonts, QOI images, WAV audio, video facts
79
91
  and a poster frame.
80
92
 
93
+ ## Footage
94
+
95
+ Hosted URLs are the normal source for application-owned media. Declare them with `video`, `audio`,
96
+ `image` or `font`, with an explicit name and a SHA-256 when the application knows it. The SDK
97
+ streams downloads into a shared cache outside the project, verifies content, and atomically
98
+ promotes completed files. Concurrent requests for the same pinned content share a download.
99
+ Unpinned URLs are revalidated with ETag/Last-Modified when available. Temporary network failures,
100
+ 408, 429 and server errors retry twice by default, with bounded backoff and Retry-After support;
101
+ missing files, denied access and integrity failures are reported as `MediaFailure`.
102
+
103
+ ```ts
104
+ const tongue = createTongue({
105
+ media: {
106
+ cacheDir: "/var/cache/tongue/media",
107
+ store: { directory: "/srv/media", baseUrl: "https://media.example/prepared/" },
108
+ },
109
+ });
110
+ await tongue.build("my-film");
111
+ await tongue.render("my-film/dist", { out: "out/film.mp4" });
112
+ ```
113
+
114
+ A portable build contains manifests and scene bundles, with media locations and hashes in
115
+ `build.json` version 2. Originals and generated remote media are never copied into it. Local
116
+ assets can still be packaged, and version 1 builds remain readable. The host must expose the
117
+ configured store at its `baseUrl` (with CORS for browser clients). The CLI reads the equivalent
118
+ `TONGUE_MEDIA_DIR`, `TONGUE_MEDIA_URL` and `TONGUE_MEDIA_CACHE` environment variables. The default
119
+ cache is `$XDG_CACHE_HOME/tongue/media` or `~/.cache/tongue/media`. `tongue studio` supplies a
120
+ local media store and serves it automatically.
121
+
122
+ For object storage, provide `media.publish(upload)`, an Effect returning the uploaded file's
123
+ HTTP(S) URL. `upload` holds the local `file`, `hash`, `bytes` and `contentType`. Storage belongs
124
+ to the application. A publish callback must finish storing the bytes before returning its URL.
125
+ Use the hash as an idempotent storage key and return a stable URL, so projects share uploaded
126
+ bytes. The built-in directory store does this automatically. Invalid publisher locations fail
127
+ before a portable build is written.
128
+
129
+ Applications can prepare media at ingestion: `prepareMedia(asset, mediaOptions)` returns a
130
+ `tongue.media` manifest with a canonical asset name, import recipe, source hash, duration and
131
+ prepared files. Host that JSON and pass its URL as the declaration's `prepared` option. The same
132
+ preparation is shared across projects and asset names. Preparation recipes include sizing and
133
+ format versions, so incompatible results are rebuilt. Audio previews are sample-indexed WAV
134
+ segments; video previews use the existing segmented H.264 format. Full-quality decoded audio
135
+ remains available to final renders.
136
+
137
+ `media.request(url, signal, headers)` can add authentication or refresh a signed URL without
138
+ serializing credentials into the build. Preserve the supplied signal and conditional headers.
139
+ `retries`, `retryDelayMs`, `timeoutMs`, `concurrency` and `onProgress` control acquisition. Original
140
+ downloads default to a 30-minute timeout; metadata/preview requests default to two minutes.
141
+ Cache contents are shared and host-managed; changing the cache directory isolates a job.
142
+
143
+ Final rendering acquires and verifies its video originals before starting any frame worker.
144
+ Converted assets are also acquired before use. Workers decode local files, with no networking
145
+ in the per-frame path. A fully cached pinned build can render with networking disabled. Build
146
+ snapshots retain immutable prepared-file bindings when a later version changes its assets.
147
+
148
+ A render decodes every video from its **original file**: a build holds only a video's facts, a
149
+ poster and a preview-quality proxy (what the studio plays). Before a frame is drawn, `renderVideo`
150
+ and `renderStills` check that each video's file is there and fail with `AssetFailure` naming the
151
+ ones that aren't; a frame ffmpeg can't decode, or a file shorter than its import counted, fails the
152
+ render with `Frame <n>: Video "<name>": …` and ffmpeg's reason. A poster never stands in for
153
+ footage in a render (that is the player's fallback while frames arrive).
154
+
155
+ A local video is decoded from its source path under the film's root, unless `sources` says otherwise:
156
+
157
+ ```ts
158
+ const film = yield* openFilm("renders/1234/dist", { // a build, without the project around it
159
+ sources: { screen: "/data/media/screen.mp4", camera: "/data/media/camera.mp4" },
160
+ });
161
+ ```
162
+
163
+ A build inside a project (`my-film/dist`) finds local footage in the project; a build on its own
164
+ needs `sources` for local videos that aren't beside it. Remote originals are resolved automatically
165
+ from the pinned registry in the manifest.
166
+
167
+ ## Sound processing (extensions)
168
+
169
+ A render can put part of its soundtrack through processing of your own (voice enhancement, noise
170
+ removal, levelling, effects) and still produce one MP4, in sync. The SDK defines the contract and
171
+ runs it; what a processor does and what it needs (a model, a native tool, a service) is yours, so
172
+ nobody who doesn't use it installs it.
173
+
174
+ ```ts
175
+ import { AudioProcessor, openFilm, renderVideo, Sound } from "@tongue-core/sdk";
176
+
177
+ const enhance = yield* enhanceVoice({ peakDb: -3 }); // an AudioProcessor (below)
178
+ yield* renderVideo(film, {
179
+ sound: [
180
+ Sound.track("voiceover", enhance), // the clips of the track with id "voiceover"
181
+ Sound.track("voiceover", level), // the same selection again: runs after enhance
182
+ Sound.master(loudness), // then the whole mix
183
+ ],
184
+ });
185
+ ```
186
+
187
+ **What a rule takes.** `Sound.track(id, p)`; `Sound.stem({ clips: ["a", "b"] }, p)`;
188
+ `Sound.stem({ voice: true }, p)` (every narration clip); `Sound.master(p)` (the whole mix, after
189
+ the stems, before the limiter). A selection is data (`Sound.AudioSelection` decodes it from flags or
190
+ JSON). The selected clips are mixed into a **stem** of their own (their trims, speed, gain and fades
191
+ applied) across the whole film, processed, and added back; everything else sounds as it always has.
192
+ A track or clip the film doesn't have, or a visual track, is an `InvalidRequest` listing what it
193
+ does have; a clip goes into one stem, so overlapping selections are refused unless they are the same
194
+ (then their processors run in order).
195
+
196
+ **The contract.** A processor gets a `Stem`: `samples` per channel (the whole film at 48 kHz, from
197
+ its first frame), `channels` (2), the stem as a float WAV `file` and as decoded `pcm`, the `clips` in
198
+ it and the `plan`, a `workDir`, and `progress(0..1)`. It returns `{ pcm }` or `{ file }`, plus an
199
+ optional `report` (any JSON). What it returns must have exactly the stem's rate, channels and
200
+ length, and only finite samples, or the render fails with `ProcessorFailure`: that is what keeps
201
+ the sound in sync, so a processor with a delay (a model's lookahead) takes it out itself. Any
202
+ failure of the processor, or a defect in it, is a `ProcessorFailure` naming it.
203
+
204
+ **Two ways to make one.** `AudioProcessor.fromPcm({ key, process: (pcm, stem) => pcm })` for
205
+ processing that needs only the samples. `AudioProcessor.make({ key, process: (stem) => Effect })`
206
+ for anything else: the services `process` uses are taken from where `make` runs and travel with the
207
+ processor, so the render itself never needs them:
208
+
209
+ ```ts
210
+ // voice-enhance: its own package, depending on @tongue-core/sdk and on what it needs.
211
+ interface DenoiserOperations {
212
+ readonly name: string;
213
+ readonly denoise: (channel: Float32Array, sampleRate: number) => Effect.Effect<Float32Array>;
214
+ }
215
+ export class Denoiser extends Context.Service<Denoiser, DenoiserOperations>()("voice-enhance/Denoiser") {
216
+ static readonly layer = Layer.effect(Denoiser, loadModel); // the heavy part, paid once, only by those who provide it
217
+ }
218
+
219
+ export const enhanceVoice = (settings: EnhanceSettings) =>
220
+ AudioProcessor.make({
221
+ key: `voice-enhance/v1 peak ${settings.peakDb} dB`,
222
+ process: (stem) => Effect.gen(function* () {
223
+ const denoiser = yield* Denoiser;
224
+ const pcm = yield* stem.pcm;
225
+ const channels = yield* Effect.forEach(pcm.channels, (c) => denoiser.denoise(c, pcm.sampleRate));
226
+ yield* stem.progress(1);
227
+ return { pcm: { sampleRate: pcm.sampleRate, channels }, report: { denoiser: denoiser.name } };
228
+ }),
229
+ }); // Effect<AudioProcessor, never, Denoiser>
230
+
231
+ // In your command line: make the processor where its Layer is provided.
232
+ const render = Command.make("render", { project: Argument.String("project"), enhance: Flag.String("enhance").pipe(Flag.atLeast(0)) },
233
+ Effect.fn(function* (flags) {
234
+ const film = yield* openFilm(flags.project);
235
+ const enhance = yield* enhanceVoice({ peakDb: -3 });
236
+ return yield* renderVideo(film, { sound: flags.enhance.map((track) => Sound.track(track, enhance)) });
237
+ }),
238
+ ).pipe(Command.provide(Denoiser.layer));
239
+ ```
240
+
241
+ **The key and the cache.** A processor's `key` names it, its version and its settings. A result is
242
+ cached by the key and the exact audio it was given (`.tongue/cache/sound`, the newest 32 kept): render
243
+ again after changing anything that doesn't change that audio (the picture, another track, the
244
+ range, the scale) and the processor doesn't run; the result says `cached: true`. Change the key
245
+ whenever the output would change.
246
+
247
+ **What happened.** `VideoResult.sound` lists each processor run: the stem, its clips, the
248
+ processor's key, whether it was cached, its seconds and its report. `renderVideoEvents` streams
249
+ `Sound` events (`started`, `progress`, `done`, `cached`) while it runs.
250
+
251
+ **Where it runs.** Processors run in your process, alongside the render workers, while the frames
252
+ are drawn (the soundtrack always has); keep heavy work off its main thread (`effect/workers`, a
253
+ child process), or it slows the hand-off of frames to the workers.
254
+
81
255
  ## How a video renders
82
256
 
83
257
  ```
84
- coordinator ──segments──▶ worker 1: frames ─▶ raster ─▶ encoder ─▶ seg-0000.mp4
85
- ◀─progress── worker 2 … seg-0001.mp4
258
+ coordinator ──segments──▶ worker 1: frames ─▶ raster ─▶ encoder ─▶ piece-000000.mp4
259
+ ◀─progress── worker 2 … piece-000280.mp4
86
260
  soundtrack: sounds + audio clips ─▶ mix ─▶ AAC audio.m4a
261
+ with sound processing: stems ─▶ processors (here, cached) ─▶ sum ─▶ master ─▶ limiter ─▶ AAC
87
262
  join: concat + audio ─(stream copy)─▶ film.mp4, frame count probed
88
263
  ```
89
264
 
90
- - **Workers** are processes (`src/render/worker.ts`), one per physical core by default. Each loads
91
- the film and its raster once and renders every segment it is sent into that segment's own
92
- encoder, so the JIT stays warm and glyphs and images stay uploaded.
93
- - **Segments** are one to two seconds of frames, shrinking towards the end so the last ones finish
94
- together. Each is a closed-GOP encode; the join copies streams without re-encoding.
95
- - **Video clips** are decoded by ffmpeg in order, frame after frame, and restarted on jumps.
96
- - **The soundtrack** is mixed in its own process while the workers draw (`soundtrack-process.ts`).
265
+ - **Workers** are processes (`src/render/worker.ts`), one per physical core by default, fewer when
266
+ the memory available wouldn't hold that many (`src/render/budget.ts`). Each loads
267
+ the film and its raster once and draws every segment it is sent, so the JIT stays warm and glyphs
268
+ and images stay uploaded.
269
+ - **Segments** are a quarter to half a second of frames: what a worker is given at a time. Each
270
+ worker starts on its own contiguous run of them; one that finishes early takes over the later
271
+ half of the longest run left (`src/render/runs.ts`). So every worker draws long contiguous
272
+ stretches, and its video decoders read straight through instead of seeking.
273
+ - **Pieces** are what is encoded: each contiguous stretch a worker draws goes to one encoder, as one
274
+ closed-GOP file; the join copies streams without re-encoding. Which pieces a render makes depends
275
+ on how the work was shared out; the frames don't.
276
+ - **Video clips** are decoded by ffmpeg from their original files, in order, frame after frame, a
277
+ few frames ahead, through a FIFO that holds ffmpeg back when it gets ahead; restarted on jumps,
278
+ a window at a time for clips playing backwards, and closed when unused. A frame that can't be
279
+ decoded fails the render ([Footage](#footage)).
280
+ - **The soundtrack** is mixed in its own process while the workers draw (`soundtrack-process.ts`);
281
+ with sound processing, it writes the stems, the processors run in the coordinator, and it adds
282
+ them back, limits and encodes (`sound/soundtrack.ts`).
97
283
 
98
284
  Rasters (who draws) and encoders (who encodes) are chosen by name, because the choice is made here
99
285
  but runs inside the workers:
@@ -119,11 +305,12 @@ gives the CLI's exit code.
119
305
  | Error | When | Exit |
120
306
  |---|---|---|
121
307
  | `FilmFailure` | the module is missing, throws, isn't a `<Film>`, or its timeline doesn't plan | 2 |
122
- | `AssetFailure` | an asset's file is missing or unreadable | 2 |
308
+ | `AssetFailure` | an asset's file is missing or unreadable, or a video's original file isn't there to render from | 2 |
123
309
  | `InvalidRequest` | a frame outside the film, a bad range or scale | 2 |
124
310
  | `ToolFailure` | ffmpeg or ffprobe is missing or failed | 3 |
125
311
  | `BackendUnavailable` | the raster or encoder asked for doesn't work here | 3 |
126
- | `RenderFailure` | a worker died or a frame failed | 3 |
312
+ | `RenderFailure` | a worker died, a frame failed, or a video frame couldn't be decoded | 3 |
313
+ | `ProcessorFailure` | a sound processor failed, or returned audio that would break sync (rate, channels, length) or isn't audio | 3 |
127
314
  | `LayoutError`, `CompileError`, `LoadError`, `TimelineError`, `DocError`, `TemplateError` | a film project doesn't build (from `@tongue-core/project`: each names its file, line or path) | 2 |
128
315
 
129
316
  ## Environment
@@ -144,10 +331,14 @@ src/
144
331
  init.ts init from templates
145
332
  check.ts, backends.ts checkFilm, listBackends
146
333
  import/ asset import: fonts, images, audio, video (ffprobe, posters)
147
- render/ stills, the video pipeline, its worker and soundtrack processes, video decoding
334
+ render/ stills, the video pipeline and its events, its worker and soundtrack processes, video decoding and inputs
335
+ sound/ sound processing: Sound (rules), AudioProcessor (the contract), the soundtrack around them
336
+ points.ts points and ranges in a film, as people write them
337
+ TongueServices.ts the layer the SDK runs on
148
338
  encode/encoders.ts the ffmpeg encoders and how each is tried
149
339
  raster/ the native and GPU raster bindings (index.ts) and the registry (rasters.ts)
150
340
  studio/server.ts the studio's server: the app bundled, a project's build served and rebuilt
151
- test/ unit tests, end-to-end ones on fixtures/tiny (a film with text, video and sound), and
341
+ test/ unit tests, end-to-end ones on fixtures/tiny (a film with text, video and sound; sound
342
+ processing and footage that can't be decoded on copies of it), builds rendered with `sources`, and
152
343
  projects from every template (project.test.ts: film.json draws the same frames as film.tsx)
153
344
  ```
@@ -0,0 +1,5 @@
1
+ import { BunServices } from "@effect/platform-bun";
2
+ import type { Layer } from "effect";
3
+ /** The services every SDK function needs. */
4
+ export type TongueServices = BunServices.BunServices;
5
+ export declare const layer: Layer.Layer<TongueServices>;
@@ -0,0 +1,2 @@
1
+ import { BunServices } from "@effect/platform-bun";
2
+ export const layer = BunServices.layer;
@@ -3,6 +3,7 @@ import { type Build, type ModuleHost, type ProjectFiles } from "@tongue-core/pro
3
3
  import { Effect, FileSystem, type PlatformError } from "effect";
4
4
  import { type ImportReport } from "../import/importer.js";
5
5
  import type { Project } from "../project.js";
6
+ import { type MediaOptions } from "../media/cache.js";
6
7
  /** A project's code and data: every .ts, .tsx and .json file outside hidden folders and the ones
7
8
  * tools write. */
8
9
  export declare const readProjectFiles: (root: string) => Effect.Effect<ProjectFiles, PlatformError.PlatformError, FileSystem.FileSystem>;
@@ -11,6 +12,7 @@ export declare const fileModuleHost: (dir: string) => ModuleHost;
11
12
  /** Points the project's `.tongue/types/film` at this SDK's @tongue-core/film (tsconfig.json reads it). */
12
13
  export declare const linkFilmTypes: (root: string) => Effect.Effect<void, PlatformError.PlatformError, FileSystem.FileSystem>;
13
14
  export interface BuiltProject {
15
+ readonly assetsDir: string;
14
16
  readonly build: Build;
15
17
  /** Where the build was written. */
16
18
  readonly dir: string;
@@ -25,6 +27,7 @@ export declare const importedFiles: (project: Project, assets: readonly Asset[])
25
27
  readonly path: string;
26
28
  readonly bytes: number;
27
29
  readonly hash: string;
30
+ readonly url?: string | undefined;
28
31
  }[], PlatformError.PlatformError, FileSystem.FileSystem>;
29
32
  /** How a written build reaches its assets: copies of the files (a build to publish), or a link to
30
33
  * the project's imported assets (a build for Tongue's own use). */
@@ -43,6 +46,6 @@ export declare const writeBuildFiles: (dir: string, build: Build, assets: AssetP
43
46
  export declare const keepBuild: (project: Project, build: Build) => Effect.Effect<string, PlatformError.PlatformError, FileSystem.FileSystem>;
44
47
  /** Builds a film project into `dir` (what `tongue build` does: dist/ by default), replacing what
45
48
  * was there, with copies of its imported assets: a folder any static host can serve. */
46
- export declare const buildFilmProject: (project: Project, dir: string) => Effect.Effect<BuiltProject, import("../errors.js").AssetFailure | import("../errors.js").ToolFailure | import("@tongue-core/project").LayoutError | import("@tongue-core/project").CompileError | import("@tongue-core/project").LoadError | import("@tongue-core/project").TimelineError | import("@tongue-core/project").DocError | import("effect/Config").ConfigError | PlatformError.PlatformError, import("effect/process/ChildProcessSpawner").ChildProcessSpawner | FileSystem.FileSystem | import("effect/Path").Path>;
49
+ export declare const buildFilmProject: (project: Project, dir: string, options?: MediaOptions | undefined) => Effect.Effect<BuiltProject, import("../errors.js").AssetFailure | import("../errors.js").ToolFailure | import("@tongue-core/project/media").MediaFailure | import("@tongue-core/project").LayoutError | import("@tongue-core/project").CompileError | import("@tongue-core/project").LoadError | import("@tongue-core/project").TimelineError | import("@tongue-core/project").DocError | import("effect/Config").ConfigError | PlatformError.PlatformError | import("effect/Schema").SchemaError, import("effect/process/ChildProcessSpawner").ChildProcessSpawner | FileSystem.FileSystem | import("effect/Path").Path>;
47
50
  /** Builds a film project for Tongue's own use (stills, renders, dev): kept in .tongue/builds. */
48
- export declare const buildForWork: (project: Project) => Effect.Effect<BuiltProject, import("../errors.js").AssetFailure | import("../errors.js").ToolFailure | import("@tongue-core/project").LayoutError | import("@tongue-core/project").CompileError | import("@tongue-core/project").LoadError | import("@tongue-core/project").TimelineError | import("@tongue-core/project").DocError | import("effect/Config").ConfigError | PlatformError.PlatformError, import("effect/process/ChildProcessSpawner").ChildProcessSpawner | FileSystem.FileSystem | import("effect/Path").Path>;
51
+ export declare const buildForWork: (project: Project, options?: MediaOptions | undefined) => Effect.Effect<BuiltProject, import("../errors.js").AssetFailure | import("../errors.js").ToolFailure | import("@tongue-core/project/media").MediaFailure | import("@tongue-core/project").LayoutError | import("@tongue-core/project").CompileError | import("@tongue-core/project").LoadError | import("@tongue-core/project").TimelineError | import("@tongue-core/project").DocError | import("effect/Config").ConfigError | PlatformError.PlatformError | import("effect/Schema").SchemaError, import("effect/process/ChildProcessSpawner").ChildProcessSpawner | FileSystem.FileSystem | import("effect/Path").Path>;
@@ -14,6 +14,9 @@ import { compileProject, extractFilm, IGNORED_DIRS, loadProject, sha256, TYPES_L
14
14
  import { Effect, FileSystem } from "effect";
15
15
  import { importAssets } from "../import/importer.js";
16
16
  import { sdkVersion } from "../version.js";
17
+ import { isRemoteSource } from "@tongue-core/project/media";
18
+ import { mediaCacheDir, mediaLock } from "../media/cache.js";
19
+ import { snapshotAssets } from "../media/snapshot.js";
17
20
  // Building a film project on this machine: read its code, compile and bundle it, run the bundles,
18
21
  // import the assets they declare (media lengths go into film.json), extract film.json and write
19
22
  // the build, with the imported asset files listed in build.json (and copied beside it, or linked,
@@ -99,6 +102,8 @@ export const writeBuildFiles = Effect.fn("writeBuildFiles")(function* (dir, buil
99
102
  return;
100
103
  }
101
104
  for (const file of build.manifest.assets.files) {
105
+ if (file.url !== undefined)
106
+ continue;
102
107
  const path = join(assetsDir, file.path);
103
108
  yield* fs.makeDirectory(dirname(path), { recursive: true });
104
109
  yield* fs.copyFile(join(assets.from, file.path), path);
@@ -135,28 +140,36 @@ const pruneBuilds = Effect.fn("pruneBuilds")(function* (builds, keep) {
135
140
  });
136
141
  /** A film project built in memory: compiled, its bundles run, its assets imported (their lengths
137
142
  * go into film.json), film.json extracted. */
138
- const buildInMemory = Effect.fn("buildInMemory")(function* (project) {
139
- const files = yield* readProjectFiles(project.root);
140
- const compiled = yield* compileProject(files);
141
- const loaded = yield* loadProject(compiled, fileModuleHost(join(project.workDir, "modules")));
142
- const imported = yield* importAssets(project, loaded.assets);
143
- const media = { duration: (asset) => imported.durations.get(`${asset.kind}:${asset.name}`) ?? null };
144
- const doc = yield* extractFilm(loaded, { media });
145
- const build = writeBuild(compiled, doc, { tongue: yield* sdkVersion, assets: yield* importedFiles(project, loaded.assets) });
146
- yield* linkFilmTypes(project.root);
147
- return { build, imported, scenes: new Map(loaded.scenes.map((s) => [s.id, s.scene])), media };
143
+ const buildInMemory = Effect.fn("buildInMemory")(function* (input, options = {}) {
144
+ return yield* mediaLock(`project-build:${input.root}`, Effect.gen(function* () {
145
+ let project = input;
146
+ const files = yield* readProjectFiles(project.root);
147
+ const compiled = yield* compileProject(files);
148
+ const loaded = yield* loadProject(compiled, fileModuleHost(join(project.workDir, "modules")));
149
+ if (loaded.assets.some((asset) => isRemoteSource(asset.source)))
150
+ project = { ...project, assetsDir: join(mediaCacheDir(options), "projects", createHash("sha256").update(project.root).digest("hex"), "assets") };
151
+ const imported = yield* importAssets(project, loaded.assets, options);
152
+ const media = { duration: (asset) => imported.durations.get(`${asset.kind}:${asset.name}`) ?? null };
153
+ const doc = yield* extractFilm(loaded, { media });
154
+ const local = yield* importedFiles(project, loaded.assets.filter((asset) => !isRemoteSource(asset.source)));
155
+ const build = writeBuild(compiled, doc, { tongue: yield* sdkVersion, assets: [...local, ...(imported.remoteFiles ?? [])], originals: imported.originals });
156
+ if ((imported.originals?.length ?? 0) > 0 && !options.portable)
157
+ project = { ...project, assetsDir: yield* snapshotAssets(project.assetsDir, build.manifest.assets.files, imported.remoteFiles ?? [], options) };
158
+ yield* linkFilmTypes(project.root);
159
+ return { build, imported, scenes: new Map(loaded.scenes.map((s) => [s.id, s.scene])), media, assetsDir: project.assetsDir };
160
+ }));
148
161
  });
149
162
  /** Builds a film project into `dir` (what `tongue build` does: dist/ by default), replacing what
150
163
  * was there, with copies of its imported assets: a folder any static host can serve. */
151
- export const buildFilmProject = Effect.fn("buildFilmProject")(function* (project, dir) {
152
- const built = yield* buildInMemory(project);
153
- yield* writeBuildFiles(dir, built.build, { kind: "copy", from: project.assetsDir });
164
+ export const buildFilmProject = Effect.fn("buildFilmProject")(function* (project, dir, options = {}) {
165
+ const built = yield* buildInMemory(project, { ...options, portable: true });
166
+ yield* writeBuildFiles(dir, built.build, { kind: "copy", from: built.assetsDir });
154
167
  const out = { ...built, dir };
155
168
  return out;
156
169
  });
157
170
  /** Builds a film project for Tongue's own use (stills, renders, dev): kept in .tongue/builds. */
158
- export const buildForWork = Effect.fn("buildForWork")(function* (project) {
159
- const built = yield* buildInMemory(project);
160
- const out = { ...built, dir: yield* keepBuild(project, built.build) };
171
+ export const buildForWork = Effect.fn("buildForWork")(function* (project, options = {}) {
172
+ const built = yield* buildInMemory(project, options);
173
+ const out = { ...built, dir: yield* keepBuild({ ...project, assetsDir: built.assetsDir }, built.build) };
161
174
  return out;
162
175
  });
package/dist/check.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { PlanSummary } from "@tongue-core/film";
2
2
  import { Effect } from "effect";
3
+ import { type OpenOptions } from "./project.js";
3
4
  export interface CheckProblem {
4
5
  /** layout, compile, load, timeline, doc (film.json), asset. */
5
6
  readonly kind: string;
@@ -15,4 +16,4 @@ export interface CheckReport {
15
16
  readonly summary: PlanSummary | null;
16
17
  readonly text: string | null;
17
18
  }
18
- export declare const checkFilm: (path: string) => Effect.Effect<CheckReport, import("./errors.js").InvalidRequest | import("./errors.js").ToolFailure | import("effect/Config").ConfigError | import("effect/PlatformError").PlatformError, import("effect/process/ChildProcessSpawner").ChildProcessSpawner | import("effect/FileSystem").FileSystem | import("effect/Path").Path>;
19
+ export declare const checkFilm: (path: string, options?: OpenOptions | undefined) => Effect.Effect<CheckReport, import("./errors.js").InvalidRequest | import("./errors.js").ToolFailure | import("effect/Config").ConfigError | import("effect/PlatformError").PlatformError | import("effect/Schema").SchemaError, import("effect/process/ChildProcessSpawner").ChildProcessSpawner | import("effect/FileSystem").FileSystem | import("effect/Path").Path>;
package/dist/check.js CHANGED
@@ -1,13 +1,14 @@
1
1
  import { Effect } from "effect";
2
2
  import { describeFilm, openFilm } from "./project.js";
3
3
  const failed = (problems) => ({ ok: false, problems, warnings: [], summary: null, text: null });
4
- export const checkFilm = Effect.fn("checkFilm")(function* (path) {
5
- return yield* openFilm(path).pipe(Effect.map((film) => {
4
+ export const checkFilm = Effect.fn("checkFilm")(function* (path, options = {}) {
5
+ return yield* openFilm(path, options).pipe(Effect.map((film) => {
6
6
  const { text, summary } = describeFilm(film);
7
7
  return { ok: true, problems: [], warnings: film.plan.warnings, summary, text };
8
8
  }), Effect.catchTags({
9
9
  FilmFailure: (e) => Effect.succeed(failed([{ kind: e.phase === "load" ? "load" : "timeline", file: e.film, message: e.message }])),
10
10
  AssetFailure: (e) => Effect.succeed(failed([{ kind: "asset", file: null, message: e.message }])),
11
+ MediaFailure: (e) => Effect.succeed(failed([{ kind: "asset", file: null, message: e.message }])),
11
12
  LayoutError: (e) => Effect.succeed(failed(e.problems.map((p) => ({ kind: "layout", file: p.file, message: p.message })))),
12
13
  CompileError: (e) => Effect.succeed(failed([{ kind: "compile", file: e.line === null ? e.file : `${e.file}:${e.line}`, message: e.message }])),
13
14
  LoadError: (e) => Effect.succeed(failed([{ kind: "load", file: e.file, message: e.message }])),
package/dist/client.d.ts CHANGED
@@ -2,7 +2,10 @@ import { type InitOptions } from "./init.js";
2
2
  import { type OpenOptions } from "./project.js";
3
3
  import { type VideoOptions } from "./render/pipeline.js";
4
4
  import { type StillsOptions } from "./render/stills.js";
5
- export declare function createTongue(): {
5
+ import type { MediaOptions } from "./media/cache.js";
6
+ export declare function createTongue(configuration?: {
7
+ readonly media?: MediaOptions;
8
+ }): {
6
9
  /** Makes a film project in a new or empty folder from a template. */
7
10
  init: (dir: string, options?: InitOptions) => Promise<import("./init.js").InitReport>;
8
11
  /** The templates that come with Tongue. */
@@ -18,6 +21,7 @@ export declare function createTongue(): {
18
21
  /** Builds a film project: build.json, film.json and a bundle per scene, in its dist folder. */
19
22
  build: (project: string, options?: {
20
23
  readonly out?: string;
24
+ readonly media?: MediaOptions;
21
25
  }) => Promise<import("./index.js").BuiltProject>;
22
26
  /** Imports the film's assets and plans it; resolves to its timeline as text and JSON. */
23
27
  info: (film: string) => Promise<{
package/dist/client.js CHANGED
@@ -1,4 +1,3 @@
1
- import { BunServices } from "@effect/platform-bun";
2
1
  import { Cause, Effect, Exit } from "effect";
3
2
  import { listBackends } from "./backends.js";
4
3
  import { checkFilm } from "./check.js";
@@ -6,32 +5,33 @@ import { initProject, listTemplates } from "./init.js";
6
5
  import { buildFilm, describeFilm, openFilm } from "./project.js";
7
6
  import { renderVideo } from "./render/pipeline.js";
8
7
  import { renderSheet, renderStills } from "./render/stills.js";
8
+ import * as TongueServices from "./TongueServices.js";
9
9
  // The SDK as plain Promises, for applications that don't use Effect. Each call runs the same Effect
10
10
  // program the CLI runs; a failure rejects with the SDK's tagged error (FilmFailure, ToolFailure…).
11
11
  const run = async (effect) => {
12
- const exit = await Effect.runPromiseExit(effect.pipe(Effect.provide(BunServices.layer)));
12
+ const exit = await Effect.runPromiseExit(effect.pipe(Effect.provide(TongueServices.layer)));
13
13
  if (Exit.isSuccess(exit))
14
14
  return exit.value;
15
15
  throw Cause.squash(exit.cause);
16
16
  };
17
- export function createTongue() {
17
+ export function createTongue(configuration = {}) {
18
18
  return {
19
19
  /** Makes a film project in a new or empty folder from a template. */
20
20
  init: (dir, options) => run(initProject(dir, options)),
21
21
  /** The templates that come with Tongue. */
22
22
  templates: () => run(listTemplates()),
23
23
  /** Builds a film project: build.json, film.json and a bundle per scene, in its dist folder. */
24
- build: (project, options) => run(buildFilm(project, options)),
24
+ build: (project, options) => run(buildFilm(project, { media: configuration.media, ...options })),
25
25
  /** Imports the film's assets and plans it; resolves to its timeline as text and JSON. */
26
- info: (film) => run(openFilm(film).pipe(Effect.map(describeFilm))),
26
+ info: (film) => run(openFilm(film, configuration).pipe(Effect.map(describeFilm))),
27
27
  /** Problems and warnings as data: never rejects for a broken film. */
28
- check: (film) => run(checkFilm(film)),
28
+ check: (film) => run(checkFilm(film, configuration)),
29
29
  /** Frames as PNG files. */
30
- stills: (film, options) => run(openFilm(film, options).pipe(Effect.flatMap((f) => renderStills(f, options)))),
30
+ stills: (film, options) => run(openFilm(film, { ...configuration, ...options }).pipe(Effect.flatMap((f) => renderStills(f, options)))),
31
31
  /** Frames tiled into one labelled image. */
32
- sheet: (film, options) => run(openFilm(film, options).pipe(Effect.flatMap((f) => renderSheet(f, options)))),
32
+ sheet: (film, options) => run(openFilm(film, { ...configuration, ...options }).pipe(Effect.flatMap((f) => renderSheet(f, options)))),
33
33
  /** The film (or a range of it, or one scene of a project) as an MP4. */
34
- render: (film, options) => run(openFilm(film, options).pipe(Effect.flatMap((f) => renderVideo(f, options)))),
34
+ render: (film, options) => run(openFilm(film, { ...configuration, ...options }).pipe(Effect.flatMap((f) => renderVideo(f, options)))),
35
35
  /** The rasters and encoders this machine can use. */
36
36
  backends: () => run(listBackends()),
37
37
  };
@@ -23,7 +23,8 @@ export interface EncoderSpec {
23
23
  /** The filter from the raw input to what the encoder takes (uploads to the GPU). */
24
24
  readonly filter: string | null;
25
25
  readonly encode: ReadonlyArray<string>;
26
- /** AMD's HEVC encoder writes no conformance window, so 1080 rows decode as 1088. */
26
+ /** AMD's HEVC encoder pads the picture and writes no conformance window, or a wrong one, so 1080
27
+ * rows decode as 1088 and 540 columns as 572: the window is set again when muxing. */
27
28
  readonly fixCrop: boolean;
28
29
  }
29
30
  /** Where ffmpeg and ffprobe are. */
@@ -2,7 +2,7 @@
2
2
  // (VAAPI on AMD and Intel under Linux, NVENC on NVIDIA, Quick Sync on Intel) differ only in the
3
3
  // arguments around the input. `available()` runs a short encode through the exact same arguments,
4
4
  // so it proves the ffmpeg build, the device and the driver all work. Plain TypeScript: encoders run
5
- // in the render loop. The settings are the ones measured in docs/render-pipeline.md.
5
+ // in the render loop. The settings are the ones measured in docs/render-performance.md.
6
6
  export class EncoderError extends Error {
7
7
  name = "EncoderError";
8
8
  }
@@ -23,10 +23,12 @@ async function run(cmd) {
23
23
  /** Muxes a raw HEVC stream into the MP4, setting the conformance window (no re-encode) when the
24
24
  * driver padded the picture. Done on the raw stream: an MP4 already carries the unpadded size. */
25
25
  async function muxHevc(tools, raw, target) {
26
- const probe = await run([tools.ffprobe, "-v", "error", "-select_streams", "v", "-show_frames", "-read_intervals", "%+#1", "-show_entries", "frame=width,height", "-of", "csv=p=0", raw]);
26
+ // From the coded size, not the decoded one: hevc_metadata replaces the stream's window, and the
27
+ // driver sometimes writes part of one (540 across is coded as 576 and windowed to 572).
28
+ const probe = await run([tools.ffprobe, "-v", "error", "-select_streams", "v", "-show_entries", "stream=coded_width,coded_height", "-of", "csv=p=0", raw]);
27
29
  const [width = 0, height = 0] = (probe.stdout.trim().split("\n")[0] ?? "").split(",").map(Number);
28
30
  if (width < target.width || height < target.height)
29
- throw new EncoderError(`${raw} decodes at ${width}×${height}, smaller than ${target.width}×${target.height}.`);
31
+ throw new EncoderError(`${raw} is coded at ${width}×${height}, smaller than ${target.width}×${target.height}.`);
30
32
  const crop = width !== target.width || height !== target.height ? ["-bsf:v", `hevc_metadata=crop_right=${width - target.width}:crop_bottom=${height - target.height}`] : [];
31
33
  const mux = await run([tools.ffmpeg, "-hide_banner", "-v", "error", "-y", "-framerate", String(target.fps), "-i", raw, "-c", "copy", ...crop, "-tag:v", "hvc1", target.out]);
32
34
  if (mux.code !== 0)
@@ -92,7 +94,7 @@ export async function encoderWorks(ffmpeg, spec, size = { width: 320, height: 24
92
94
  }
93
95
  export const DEFAULT_SETTINGS = { preset: "veryfast", crf: 14, threads: 2, qp: null, gop: null, x264Params: "" };
94
96
  export const X264_PRESETS = ["ultrafast", "superfast", "veryfast", "faster", "fast", "medium", "slow", "slower", "veryslow"];
95
- /** Constant QP that clears the master-quality bar on our films (see docs/render-pipeline.md). */
97
+ /** Constant QP that clears the master-quality bar on our films. */
96
98
  const HARDWARE_QP = { h264: 16, hevc: 18 };
97
99
  const profile = (codec) => (codec === "hevc" ? "main" : "high");
98
100
  const vaapiDevice = () => process.env["TONGUE_VAAPI_DEVICE"] ?? "/dev/dri/renderD128";