@tongue-core/sdk 0.1.0-rc.6 → 0.1.0-rc.8

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
@@ -56,7 +56,7 @@ processes, from Bun): provide it once, where your application runs its Effects.
56
56
  | `describeFilm(film)` | the plan as text (for agents to read) and as a JSON summary |
57
57
  | `checkFilm(path)` | problems and warnings as data |
58
58
  | `renderStills(film, { frames, scale, outDir })` / `renderSheet(…)` | PNGs drawn by the wasm raster (the player's), or tiled into one labelled sheet |
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 and encoder are chosen, `onProgress` while it draws. A frame that throws fails the render with `Frame <n>: <message>` |
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
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
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
62
  | `videoInputs(film)`, `checkVideoInputs(film)` | where each video is decoded from, and a check that every one is there |
@@ -200,20 +200,28 @@ child process), or it slows the hand-off of frames to the workers.
200
200
  ## How a video renders
201
201
 
202
202
  ```
203
- coordinator ──segments──▶ worker 1: frames ─▶ raster ─▶ encoder ─▶ seg-0000.mp4
204
- ◀─progress── worker 2 … seg-0001.mp4
203
+ coordinator ──segments──▶ worker 1: frames ─▶ raster ─▶ encoder ─▶ piece-000000.mp4
204
+ ◀─progress── worker 2 … piece-000280.mp4
205
205
  soundtrack: sounds + audio clips ─▶ mix ─▶ AAC audio.m4a
206
206
  with sound processing: stems ─▶ processors (here, cached) ─▶ sum ─▶ master ─▶ limiter ─▶ AAC
207
207
  join: concat + audio ─(stream copy)─▶ film.mp4, frame count probed
208
208
  ```
209
209
 
210
- - **Workers** are processes (`src/render/worker.ts`), one per physical core by default. Each loads
211
- the film and its raster once and renders every segment it is sent into that segment's own
212
- encoder, so the JIT stays warm and glyphs and images stay uploaded.
213
- - **Segments** are one to two seconds of frames, shrinking towards the end so the last ones finish
214
- together. Each is a closed-GOP encode; the join copies streams without re-encoding.
215
- - **Video clips** are decoded by ffmpeg from their original files, in order, frame after frame,
216
- and restarted on jumps; a frame that can't be decoded fails the render ([Footage](#footage)).
210
+ - **Workers** are processes (`src/render/worker.ts`), one per physical core by default, fewer when
211
+ the memory available wouldn't hold that many (`src/render/budget.ts`). Each loads
212
+ the film and its raster once and draws every segment it is sent, so the JIT stays warm and glyphs
213
+ and images stay uploaded.
214
+ - **Segments** are a quarter to half a second of frames: what a worker is given at a time. Each
215
+ worker starts on its own contiguous run of them; one that finishes early takes over the later
216
+ half of the longest run left (`src/render/runs.ts`). So every worker draws long contiguous
217
+ stretches, and its video decoders read straight through instead of seeking.
218
+ - **Pieces** are what is encoded: each contiguous stretch a worker draws goes to one encoder, as one
219
+ closed-GOP file; the join copies streams without re-encoding. Which pieces a render makes depends
220
+ on how the work was shared out; the frames don't.
221
+ - **Video clips** are decoded by ffmpeg from their original files, in order, frame after frame, a
222
+ few frames ahead, through a FIFO that holds ffmpeg back when it gets ahead; restarted on jumps,
223
+ a window at a time for clips playing backwards, and closed when unused. A frame that can't be
224
+ decoded fails the render ([Footage](#footage)).
217
225
  - **The soundtrack** is mixed in its own process while the workers draw (`soundtrack-process.ts`);
218
226
  with sound processing, it writes the stems, the processors run in the coordinator, and it adds
219
227
  them back, limits and encodes (`sound/soundtrack.ts`).
@@ -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
  }
@@ -94,7 +94,7 @@ export async function encoderWorks(ffmpeg, spec, size = { width: 320, height: 24
94
94
  }
95
95
  export const DEFAULT_SETTINGS = { preset: "veryfast", crf: 14, threads: 2, qp: null, gop: null, x264Params: "" };
96
96
  export const X264_PRESETS = ["ultrafast", "superfast", "veryfast", "faster", "fast", "medium", "slow", "slower", "veryslow"];
97
- /** 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. */
98
98
  const HARDWARE_QP = { h264: 16, hevc: 18 };
99
99
  const profile = (codec) => (codec === "hevc" ? "main" : "high");
100
100
  const vaapiDevice = () => process.env["TONGUE_VAAPI_DEVICE"] ?? "/dev/dri/renderD128";
package/dist/index.d.ts CHANGED
@@ -8,7 +8,7 @@ export { importAssets, type ImportReport } from "./import/importer.js";
8
8
  export { checkFilm, type CheckReport, type CheckProblem } from "./check.js";
9
9
  export { renderStills, renderSheet, type StillsOptions, type Still } from "./render/stills.js";
10
10
  export { renderVideo, renderVideoEvents, defaultWorkers, type VideoOptions } from "./render/pipeline.js";
11
- export type { RenderEvent, RenderStart, SoundReport, VideoResult } from "./render/events.js";
11
+ export { STAGES, type RenderEvent, type RenderProfile, type RenderStart, type SoundReport, type Stage, type VideoResult } from "./render/events.js";
12
12
  export { checkVideoInputs } from "./render/inputs.js";
13
13
  export { parsePoint, parseFrame, parseRange, clipRange, spreadFrames, everyFrames, type Planned } from "./points.js";
14
14
  export { listBackends, type BackendsReport } from "./backends.js";
package/dist/index.js CHANGED
@@ -11,6 +11,7 @@ export { importAssets } from "./import/importer.js";
11
11
  export { checkFilm } from "./check.js";
12
12
  export { renderStills, renderSheet } from "./render/stills.js";
13
13
  export { renderVideo, renderVideoEvents, defaultWorkers } from "./render/pipeline.js";
14
+ export { STAGES } from "./render/events.js";
14
15
  export { checkVideoInputs } from "./render/inputs.js";
15
16
  export { parsePoint, parseFrame, parseRange, clipRange, spreadFrames, everyFrames } from "./points.js";
16
17
  export { listBackends } from "./backends.js";
package/dist/project.js CHANGED
@@ -132,9 +132,13 @@ export const openFilm = Effect.fn("openFilm")(function* (path, options = {}) {
132
132
  * entry, else its source path under the root. */
133
133
  export const videoInputs = (film) => {
134
134
  const out = {};
135
- for (const asset of film.assets)
136
- if (asset.kind === "video")
137
- out[asset.name] = film.sources?.[asset.name] ?? resolve(film.project.root, asset.source);
135
+ for (const asset of film.assets) {
136
+ if (asset.kind !== "video")
137
+ continue;
138
+ // Own keys only: a video called "constructor" mustn't find Object's.
139
+ const given = film.sources && Object.hasOwn(film.sources, asset.name) ? film.sources[asset.name] : undefined;
140
+ out[asset.name] = given ?? resolve(film.project.root, asset.source);
141
+ }
138
142
  return out;
139
143
  };
140
144
  /** What a film's outputs are called: the project's folder (or the module's file) name, and the
@@ -0,0 +1,45 @@
1
+ /** Frames the encoder `name` holds with x264 `preset`. */
2
+ export declare const encoderFrames: (name: string, preset: string) => number;
3
+ export interface WorkerBudget {
4
+ readonly workers: number;
5
+ /** MiB one worker is expected to use at its peak, with its decoders and encoder. */
6
+ readonly perWorker: number;
7
+ /** MiB available when the render started (null where the system doesn't say). */
8
+ readonly available: number | null;
9
+ /** What set the number: the request, the cores, the memory, or how many segments there are. */
10
+ readonly limit: "requested" | "cores" | "memory" | "segments";
11
+ }
12
+ export interface BudgetInput {
13
+ /** Workers asked for, if any: used as asked. */
14
+ readonly requested: number | undefined;
15
+ /** At most this many (the raster's own limit). */
16
+ readonly most: number;
17
+ readonly segments: number;
18
+ /** The frames drawn, and every video's decoded size. */
19
+ readonly frame: {
20
+ readonly width: number;
21
+ readonly height: number;
22
+ };
23
+ readonly videos: ReadonlyArray<{
24
+ readonly width: number;
25
+ readonly height: number;
26
+ }>;
27
+ /** Frames the encoder holds (`encoderFrames`). */
28
+ readonly encoder: number;
29
+ /** MiB available now; null where unknown. */
30
+ readonly available: number | null;
31
+ readonly cores: number;
32
+ }
33
+ /** MiB a worker is expected to use at its peak. */
34
+ export declare function perWorker(frame: BudgetInput["frame"], videos: BudgetInput["videos"], encoderFrames: number): number;
35
+ /** The workers for a render: as asked, else one per physical core, fewer if memory is short. */
36
+ export declare function budget(input: BudgetInput): WorkerBudget;
37
+ /** MiB the system says can be used now without swapping: Linux's MemAvailable, else free memory. */
38
+ export declare function availableMemory(): number | null;
39
+ /** A video's decoded size from its imported facts (`video/<name>/info.json`), or null. */
40
+ export declare function videoSize(text: string): {
41
+ readonly width: number;
42
+ readonly height: number;
43
+ } | null;
44
+ /** Logical processors (two per physical core on most machines). */
45
+ export declare const logicalCores: () => number;
@@ -0,0 +1,83 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { availableParallelism, freemem } from "node:os";
3
+ import { isRecord } from "../guards.js";
4
+ // How many workers a render runs. One per physical core, as long as the machine has the memory for
5
+ // them: a worker's memory is mostly frames, its raster's (the frame in floats, its layers), its
6
+ // video decoders' (ffmpeg holds a few dozen decoded frames of a video) and its encoder's, so it is
7
+ // estimated from the frame size, the film's largest videos and the encoder. The constants are fitted to the
8
+ // render bench's peaks (docs/render-performance.md); they are meant to be a little high. Plain
9
+ // TypeScript: the coordinator calls it once.
10
+ const MiB = 1024 * 1024;
11
+ /** What a worker holds besides frames: Bun, the film, its fonts and images. */
12
+ const WORKER_BASE = 500;
13
+ /** Float frames the raster keeps at the render's size: the frame and its layers. */
14
+ const RASTER_FRAMES = 4;
15
+ /** Decoded frames an ffmpeg decoder holds (its references and threads' frames), and its own size. */
16
+ const DECODER_FRAMES = 40;
17
+ const DECODER_BASE = 30;
18
+ /** Frames an encoder holds: x264's lookahead and references, which grow with its preset (fitted to
19
+ * its peaks at 1080p: 250 MB an encoder at veryfast, 445 at medium, 935 at veryslow); a GPU
20
+ * encoder's, which are few. A preset not listed counts as the slowest. */
21
+ const X264_FRAMES = { ultrafast: 60, superfast: 60, veryfast: 80, faster: 100, fast: 120, medium: 145, slow: 180, slower: 300, veryslow: 300 };
22
+ const GPU_ENCODER_FRAMES = 50;
23
+ /** Frames the encoder `name` holds with x264 `preset`. */
24
+ export const encoderFrames = (name, preset) => (name === "x264" ? (X264_FRAMES[preset] ?? 300) : GPU_ENCODER_FRAMES);
25
+ /** Videos a worker decodes at once: the two clips of a transition, or a scene's two videos. */
26
+ const VIDEOS_AT_ONCE = 2;
27
+ /** Of the memory available when the render starts, the share its workers may use. */
28
+ const USABLE = 0.8;
29
+ /** MiB a worker is expected to use at its peak. */
30
+ export function perWorker(frame, videos, encoderFrames) {
31
+ const pixels = frame.width * frame.height;
32
+ const raster = (pixels * 16 * RASTER_FRAMES) / MiB;
33
+ const encoder = (pixels * 1.5 * encoderFrames) / MiB;
34
+ const decoders = [...videos]
35
+ .map((v) => (v.width * v.height * 1.5 * DECODER_FRAMES) / MiB + DECODER_BASE)
36
+ .sort((a, b) => b - a)
37
+ .slice(0, VIDEOS_AT_ONCE)
38
+ .reduce((sum, mb) => sum + mb, 0);
39
+ return Math.ceil(WORKER_BASE + raster + encoder + decoders);
40
+ }
41
+ /** The workers for a render: as asked, else one per physical core, fewer if memory is short. */
42
+ export function budget(input) {
43
+ const each = perWorker(input.frame, input.videos, input.encoder);
44
+ const cap = (workers, limit) => {
45
+ const most = Math.max(1, Math.min(input.most, input.segments));
46
+ if (workers <= most)
47
+ return { workers: Math.max(1, workers), perWorker: each, available: input.available, limit };
48
+ return { workers: most, perWorker: each, available: input.available, limit: input.segments < input.most ? "segments" : limit };
49
+ };
50
+ if (input.requested !== undefined)
51
+ return cap(input.requested, "requested");
52
+ const cores = Math.max(1, Math.round(input.cores / 2));
53
+ const fit = input.available === null ? cores : Math.max(1, Math.floor((input.available * USABLE) / each));
54
+ return fit < cores ? cap(fit, "memory") : cap(cores, "cores");
55
+ }
56
+ /** MiB the system says can be used now without swapping: Linux's MemAvailable, else free memory. */
57
+ export function availableMemory() {
58
+ try {
59
+ const line = /^MemAvailable:\s+(\d+) kB$/m.exec(readFileSync("/proc/meminfo", "utf8"));
60
+ if (line?.[1])
61
+ return Math.floor(Number(line[1]) / 1024);
62
+ }
63
+ catch {
64
+ // Not Linux: fall through.
65
+ }
66
+ const free = freemem();
67
+ return free > 0 ? Math.floor(free / MiB) : null;
68
+ }
69
+ /** A video's decoded size from its imported facts (`video/<name>/info.json`), or null. */
70
+ export function videoSize(text) {
71
+ try {
72
+ const value = JSON.parse(text);
73
+ if (!isRecord(value))
74
+ return null;
75
+ const { width, height } = value;
76
+ return typeof width === "number" && typeof height === "number" && width > 0 && height > 0 ? { width, height } : null;
77
+ }
78
+ catch {
79
+ return null;
80
+ }
81
+ }
82
+ /** Logical processors (two per physical core on most machines). */
83
+ export const logicalCores = () => availableParallelism();
@@ -7,6 +7,14 @@ export interface RenderStart {
7
7
  readonly raster: RasterName;
8
8
  readonly encoder: string;
9
9
  readonly workers: number;
10
+ /** Why that many workers: as asked, one per physical core, as many as the memory available
11
+ * holds, or as many as there are segments; and the memory each is expected to use at its
12
+ * peak, against what was available when the render started (MiB; null where unknown). */
13
+ readonly budget: {
14
+ readonly limit: "requested" | "cores" | "memory" | "segments";
15
+ readonly perWorker: number;
16
+ readonly available: number | null;
17
+ };
10
18
  }
11
19
  /** One sound processor's run on one stem (or on the whole mix, "master"). */
12
20
  export interface SoundReport {
@@ -23,6 +31,33 @@ export interface SoundReport {
23
31
  /** What the processor said about its work, if anything. */
24
32
  readonly report: Json | null;
25
33
  }
34
+ /** The stages of drawing a frame in a render worker, in order: waiting for its video frames, building
35
+ * its display list (evaluating the scenes), handing images, glyphs and video frames to the raster,
36
+ * drawing (and converting to YUV), handing the frame to the encoder (waiting when it is behind), and
37
+ * waiting at the end for the encoders to finish. */
38
+ export type Stage = "decode" | "compose" | "upload" | "raster" | "encode" | "finish";
39
+ export declare const STAGES: ReadonlyArray<Stage>;
40
+ /** Where a render's time went. */
41
+ export interface RenderProfile {
42
+ /** Milliseconds by stage, summed over every worker and frame (workers run side by side, so these
43
+ * add up to more than the render took). */
44
+ readonly stages: Readonly<Record<Stage, number>>;
45
+ /** Video decoders started (once per video per seek), and frames decoded only to reach another. */
46
+ readonly decoders: {
47
+ readonly starts: number;
48
+ readonly skipped: number;
49
+ };
50
+ /** Pieces encoded and joined: one per contiguous stretch a worker drew. */
51
+ readonly pieces: number;
52
+ /** Seconds of the render's own time: getting ready (checks, choosing backends), drawing (until the
53
+ * last worker is done), then waiting for the soundtrack, then joining the pieces. */
54
+ readonly wall: {
55
+ readonly setup: number;
56
+ readonly drawing: number;
57
+ readonly soundtrack: number;
58
+ readonly join: number;
59
+ };
60
+ }
26
61
  export interface VideoResult {
27
62
  readonly out: string;
28
63
  readonly width: number;
@@ -38,6 +73,7 @@ export interface VideoResult {
38
73
  readonly fps: number;
39
74
  /** The sound processing that ran, in order (empty without any). */
40
75
  readonly sound: ReadonlyArray<SoundReport>;
76
+ readonly profile: RenderProfile;
41
77
  }
42
78
  export type RenderEvent =
43
79
  /** The request is checked and the raster and encoder are chosen; nothing is drawn yet. */
@@ -1 +1 @@
1
- export {};
1
+ export const STAGES = ["decode", "compose", "upload", "raster", "encode", "finish"];
@@ -0,0 +1,18 @@
1
+ export declare class FramePipe {
2
+ private readonly handle;
3
+ private readonly path;
4
+ private constructor();
5
+ /** Makes a FIFO at `path` for `start` to write to, starts it, and opens the reading end. A child
6
+ * that exits without ever opening the FIFO (it couldn't read its input) would leave the open
7
+ * waiting forever, so then we open the writing end ourselves and close it: the open completes
8
+ * and reads end at once, and the child's exit says what went wrong. */
9
+ static connect<P extends {
10
+ readonly exited: Promise<number>;
11
+ }>(path: string, start: (fifo: string) => P): Promise<{
12
+ readonly pipe: FramePipe;
13
+ readonly proc: P;
14
+ }>;
15
+ /** Fills `into` with the next bytes; false when the writer has gone before it was full. */
16
+ read(into: Uint8Array): Promise<boolean>;
17
+ close(): Promise<void>;
18
+ }
@@ -0,0 +1,46 @@
1
+ import { rmSync } from "node:fs";
2
+ import { open } from "node:fs/promises";
3
+ // Raw frames from a child process, through a FIFO: the child writes, we read exact frames into
4
+ // buffers we own. The kernel's pipe is the only buffer between us, so a child that gets ahead simply
5
+ // waits, and nothing piles up in this process however long it goes without reading. (Bun's
6
+ // subprocess streams don't hold back like this: under 1.3.14 they read on into memory, gigabytes a
7
+ // minute; under 1.4.4 they can stop waking the reader with data waiting. See
8
+ // docs/render-performance.md.) Plain TypeScript: reads run on Bun's I/O threads.
9
+ export class FramePipe {
10
+ handle;
11
+ path;
12
+ constructor(handle, path) {
13
+ this.handle = handle;
14
+ this.path = path;
15
+ }
16
+ /** Makes a FIFO at `path` for `start` to write to, starts it, and opens the reading end. A child
17
+ * that exits without ever opening the FIFO (it couldn't read its input) would leave the open
18
+ * waiting forever, so then we open the writing end ourselves and close it: the open completes
19
+ * and reads end at once, and the child's exit says what went wrong. */
20
+ static async connect(path, start) {
21
+ const made = Bun.spawnSync(["mkfifo", "-m", "600", path], { stdout: "ignore", stderr: "pipe" });
22
+ if (made.exitCode !== 0)
23
+ throw new Error(`Couldn't make a FIFO at ${path}: ${made.stderr.toString().trim()}`);
24
+ const proc = start(path);
25
+ const opening = open(path, "r");
26
+ const first = await Promise.race([opening.then(() => "opened"), proc.exited.then(() => "exited")]);
27
+ if (first === "exited")
28
+ await (await open(path, "w")).close();
29
+ return { pipe: new FramePipe(await opening, path), proc };
30
+ }
31
+ /** Fills `into` with the next bytes; false when the writer has gone before it was full. */
32
+ async read(into) {
33
+ let filled = 0;
34
+ while (filled < into.length) {
35
+ const { bytesRead } = await this.handle.read(into, filled, into.length - filled, null);
36
+ if (bytesRead === 0)
37
+ return false;
38
+ filled += bytesRead;
39
+ }
40
+ return true;
41
+ }
42
+ async close() {
43
+ await this.handle.close().catch(() => undefined);
44
+ rmSync(this.path, { force: true });
45
+ }
46
+ }
@@ -1,4 +1,5 @@
1
1
  import type { DrawnFrame, FilmRenderer } from "@tongue-core/engine";
2
+ import type { Stopwatch } from "./stopwatch.js";
2
3
  import type { FfmpegVideoFrames } from "./video-frames.js";
3
4
  /** A video frame a film frame shows couldn't be decoded. The message names the video, its file
4
5
  * and why (ffmpeg's error, or where the file ends). */
@@ -10,4 +11,4 @@ export declare class VideoFrameError extends Error {
10
11
  * Every video frame it shows must have been decoded: a render never lets the engine's poster
11
12
  * stand in for footage (that is the player's fallback while frames arrive), so a frame that is
12
13
  * still missing throws VideoFrameError. */
13
- export declare function frameWithVideo(renderer: FilmRenderer, frame: number, scale: number, video: FfmpegVideoFrames): Promise<DrawnFrame>;
14
+ export declare function frameWithVideo(renderer: FilmRenderer, frame: number, scale: number, video: FfmpegVideoFrames, clock?: Stopwatch): Promise<DrawnFrame>;
@@ -8,13 +8,26 @@ export class VideoFrameError extends Error {
8
8
  * Every video frame it shows must have been decoded: a render never lets the engine's poster
9
9
  * stand in for footage (that is the player's fallback while frames arrive), so a frame that is
10
10
  * still missing throws VideoFrameError. */
11
- export async function frameWithVideo(renderer, frame, scale, video) {
11
+ export async function frameWithVideo(renderer, frame, scale, video, clock) {
12
+ let at = performance.now();
13
+ const lap = (stage) => {
14
+ if (clock)
15
+ at = clock.lap(stage, at);
16
+ };
12
17
  await video.prefetch(renderer.videoFramesAt(frame));
13
- const first = renderer.displayList(frame, { scale, video });
14
- const drawn = missingIn(first, video).length === 0 ? first : await video.prefetch(missingIn(first, video)).then(() => renderer.displayList(frame, { scale, video }));
18
+ lap("decode");
19
+ let drawn = renderer.displayList(frame, { scale, video });
20
+ lap("compose");
21
+ if (missingIn(drawn, video).length > 0) {
22
+ await video.prefetch(missingIn(drawn, video));
23
+ lap("decode");
24
+ drawn = renderer.displayList(frame, { scale, video });
25
+ lap("compose");
26
+ }
15
27
  const [missing] = missingIn(drawn, video);
16
28
  if (missing)
17
29
  throw new VideoFrameError(video.explain(missing.asset, missing.frame));
30
+ await video.tick();
18
31
  return drawn;
19
32
  }
20
33
  const missingIn = (drawn, video) => drawn.videoFrames.filter((r) => video.frame(r.asset, r.frame) === null);
@@ -18,8 +18,9 @@ export const checkVideoInputs = Effect.fn("checkVideoInputs")(function* (film) {
18
18
  return inputs;
19
19
  const listed = missing.map((m) => `"${m.name}" at ${m.path}`).join(", ");
20
20
  const example = missing.map((m) => `"${m.name}": "/path/to/${m.name}.mp4"`).join(", ");
21
+ const flags = missing.map((m) => `--source ${m.name}=/path/to/${m.name}.mp4`).join(" ");
21
22
  return yield* new AssetFailure({
22
23
  asset: missing.map((m) => `video:${m.name}`).join(", "),
23
- message: `A render decodes each video from its original file, and ${missing.length === 1 ? `video ${listed} isn't there` : `these aren't there: ${listed}`}. Put ${missing.length === 1 ? "it" : "them"} there, or say where ${missing.length === 1 ? "it is" : "they are"} with openFilm's sources option ({ ${example} }).`,
24
+ message: `A render decodes each video from its original file, and ${missing.length === 1 ? `video ${listed} isn't there` : `these aren't there: ${listed}`}. Put ${missing.length === 1 ? "it" : "them"} there, or say where ${missing.length === 1 ? "it is" : "they are"}: openFilm's sources option ({ ${example} }), or ${flags} with the tongue command.`,
24
25
  });
25
26
  });
@@ -5,8 +5,8 @@ import { BackendUnavailable, InvalidRequest, RenderFailure } from "../errors.js"
5
5
  import { type RasterName } from "../raster/rasters.js";
6
6
  import { type OpenFilm } from "../project.js";
7
7
  import { type SoundRule } from "../sound/Sound.js";
8
- import type { RenderEvent, RenderStart, VideoResult } from "./events.js";
9
- export type { RenderEvent, RenderStart, SoundReport, VideoResult } from "./events.js";
8
+ import { type RenderEvent, type RenderStart, type VideoResult } from "./events.js";
9
+ export type { RenderEvent, RenderProfile, RenderStart, SoundReport, Stage, VideoResult } from "./events.js";
10
10
  export interface VideoOptions {
11
11
  /** The MP4 to write (default: out/<name>.mp4 under the film's root, see defaultVideoOut). */
12
12
  readonly out?: string;
@@ -20,7 +20,8 @@ export interface VideoOptions {
20
20
  readonly settings?: Partial<EncoderSettings>;
21
21
  /** Render processes (default: one per physical core). */
22
22
  readonly workers?: number;
23
- /** Segment length bounds in frames (default one to two seconds). */
23
+ /** Segment length bounds in frames: how much a worker is given to draw at a time (default a
24
+ * quarter to half a second). Smaller segments share the work out more evenly at the end. */
24
25
  readonly segment?: {
25
26
  readonly min: number;
26
27
  readonly max: number;
@@ -37,7 +38,8 @@ export interface VideoOptions {
37
38
  readonly fps: number;
38
39
  }) => void;
39
40
  }
40
- /** One worker per physical core: each draws on one thread while its encoder uses the other. */
41
+ /** One worker per physical core: each draws on one thread while its encoder uses the other. (A
42
+ * render runs fewer when the machine hasn't the memory for them; see budget.ts.) */
41
43
  export declare const defaultWorkers: () => number;
42
44
  /** Renders a film (or a range of it) to an MP4 with its sound. `onStart` and `onProgress` hear how
43
45
  * it goes; renderVideoEvents streams everything that happens instead. */
@@ -1,4 +1,3 @@
1
- import { availableParallelism } from "node:os";
2
1
  import { dirname, join } from "node:path";
3
2
  import { sizeAtScale } from "@tongue-core/engine";
4
3
  import { Cause, Deferred, Effect, Fiber, FileSystem, Queue, Stream } from "effect";
@@ -11,10 +10,28 @@ import { plan as planSound } from "../sound/Sound.js";
11
10
  import { renderSoundtrack } from "../sound/soundtrack.js";
12
11
  import { ffmpegPath, ffprobePath, runTool } from "../tools.js";
13
12
  import { entry } from "./entries.js";
13
+ import { availableMemory, budget, encoderFrames, logicalCores, videoSize } from "./budget.js";
14
+ import { STAGES } from "./events.js";
14
15
  import { checkVideoInputs } from "./inputs.js";
15
16
  import { parseReport, planSegments } from "./protocol.js";
16
- /** One worker per physical core: each draws on one thread while its encoder uses the other. */
17
- export const defaultWorkers = () => Math.max(1, Math.round(availableParallelism() / 2));
17
+ import { Runs } from "./runs.js";
18
+ import { noTimes } from "./stopwatch.js";
19
+ /** One worker per physical core: each draws on one thread while its encoder uses the other. (A
20
+ * render runs fewer when the machine hasn't the memory for them; see budget.ts.) */
21
+ export const defaultWorkers = () => Math.max(1, Math.round(logicalCores() / 2));
22
+ /** Each video's decoded size, from its imported facts; its longest side square where they can't
23
+ * be read (an overestimate, which is what a memory budget wants). */
24
+ const videoSizes = Effect.fn("videoSizes")(function* (film) {
25
+ const fs = yield* FileSystem.FileSystem;
26
+ const sizes = [];
27
+ for (const asset of film.assets) {
28
+ if (asset.kind !== "video")
29
+ continue;
30
+ const text = yield* fs.readFileString(join(film.project.assetsDir, "video", asset.name, "info.json")).pipe(Effect.orElseSucceed(() => ""));
31
+ sizes.push(videoSize(text) ?? { width: asset.maxSize, height: asset.maxSize });
32
+ }
33
+ return sizes;
34
+ });
18
35
  const resolveRaster = (choice) => {
19
36
  if (choice === "auto")
20
37
  return Effect.succeed(autoRaster());
@@ -32,8 +49,8 @@ const resolveEncoder = Effect.fn("resolveEncoder")(function* (choice, settings,
32
49
  }
33
50
  return yield* new BackendUnavailable({ backend: "encoder", message: "No video encoder works on this machine: is ffmpeg installed with libx264?" });
34
51
  });
35
- /** Runs one worker process: it pulls segments from `queue` until the queue is done. */
36
- const runWorker = Effect.fn("runWorker")(function* (index, job, cwd, queue, onFrames, segments) {
52
+ /** Runs one worker process: it draws the segments `runs` gives it until there are none left. */
53
+ const runWorker = Effect.fn("runWorker")(function* (index, job, cwd, runs, hooks, segments) {
37
54
  const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
38
55
  const mine = yield* Queue.unbounded();
39
56
  const fail = (message) => new RenderFailure({ message: `Render worker ${index + 1}: ${message}` });
@@ -43,11 +60,9 @@ const runWorker = Effect.fn("runWorker")(function* (index, job, cwd, queue, onFr
43
60
  forceKillAfter: "2 seconds",
44
61
  })).pipe(Effect.mapError((e) => fail(e.message)));
45
62
  const stderr = yield* handle.stderr.pipe(Stream.decodeText(), Stream.mkString, Effect.forkScoped);
46
- const next = Effect.gen(function* () {
47
- const segment = yield* Queue.take(queue).pipe(Effect.option);
48
- if (segment._tag === "None")
49
- return yield* Queue.end(mine);
50
- return yield* Queue.offer(mine, segment.value);
63
+ const next = Effect.suspend(() => {
64
+ const segment = runs.next(index);
65
+ return segment ? Queue.offer(mine, segment) : Queue.end(mine);
51
66
  });
52
67
  const ready = yield* Deferred.make();
53
68
  const reported = { last: null };
@@ -59,10 +74,14 @@ const runWorker = Effect.fn("runWorker")(function* (index, job, cwd, queue, onFr
59
74
  return Effect.sync(() => { reported.last = report; });
60
75
  if (report.kind === "ready")
61
76
  return Deferred.succeed(ready, undefined).pipe(Effect.andThen(next));
62
- const segment = segments[report.id];
77
+ if (report.kind === "stats")
78
+ return Effect.sync(() => hooks.stats(report.stats));
79
+ if (report.kind === "piece")
80
+ return Effect.sync(() => hooks.piece(report));
63
81
  if (report.kind === "progress")
64
- return onFrames(report.id, report.frames);
65
- return onFrames(report.id, segment ? segment.to - segment.from : 0).pipe(Effect.andThen(next));
82
+ return hooks.frames(report.id, report.frames);
83
+ const segment = segments[report.id];
84
+ return hooks.frames(report.id, segment ? segment.to - segment.from : 0).pipe(Effect.andThen(next));
66
85
  }), Effect.mapError((e) => fail(e.message)));
67
86
  const code = yield* handle.exitCode.pipe(Effect.mapError((e) => fail(e.message)));
68
87
  if (code !== 0) {
@@ -74,6 +93,19 @@ const runWorker = Effect.fn("runWorker")(function* (index, job, cwd, queue, onFr
74
93
  return yield* fail(`exited with ${code}: ${errors.trim().split("\n").slice(-12).join("\n")}`);
75
94
  }
76
95
  }, Effect.scoped);
96
+ /** The pieces in order, if they cover [from, to) exactly; a gap or an overlap is a worker's bug. */
97
+ const inOrder = (pieces, from, to) => {
98
+ const ordered = [...pieces].sort((a, b) => a.from - b.from);
99
+ let at = from;
100
+ for (const piece of ordered) {
101
+ if (piece.from !== at)
102
+ return Effect.fail(new RenderFailure({ message: `The encoded pieces don't fit together: one starts at frame ${piece.from}, where frame ${at} was expected.` }));
103
+ at = piece.to;
104
+ }
105
+ if (at !== to)
106
+ return Effect.fail(new RenderFailure({ message: `The encoded pieces end at frame ${at}, not ${to}.` }));
107
+ return Effect.succeed(ordered);
108
+ };
77
109
  /** Renders a film to video, telling `emit` how it goes. */
78
110
  const render = Effect.fn("renderVideo")(function* (film, options, emit) {
79
111
  const fs = yield* FileSystem.FileSystem;
@@ -108,13 +140,14 @@ const render = Effect.fn("renderVideo")(function* (film, options, emit) {
108
140
  yield* fs.makeDirectory(renders, { recursive: true });
109
141
  const work = yield* fs.makeTempDirectoryScoped({ directory: renders, prefix: "render-" });
110
142
  yield* fs.makeDirectory(dirname(out), { recursive: true });
111
- const wanted = Math.max(1, Math.min(options.workers ?? defaultWorkers(), raster.maxWorkers ?? Infinity));
112
- const segmentSize = options.segment ?? { min: Math.round(plan.fps), max: Math.round(plan.fps * 2) };
113
- const segments = planSegments(from, to, wanted, segmentSize).map((s, id) => ({ id, ...s, out: join(work, `seg-${String(id).padStart(4, "0")}.mp4`) }));
114
- const workers = Math.min(wanted, segments.length);
115
- yield* emit({ _tag: "Started", start: { frames: to - from, width: size.width, height: size.height, raster: raster.name, encoder: encoder.spec.name, workers } });
143
+ const most = raster.maxWorkers ?? Infinity;
144
+ const segmentSize = options.segment ?? { min: Math.max(1, Math.round(plan.fps / 4)), max: Math.max(1, Math.round(plan.fps / 2)) };
145
+ const segments = planSegments(from, to, Math.min(options.workers ?? defaultWorkers(), most), segmentSize).map((s, id) => ({ id, ...s }));
146
+ const planned = budget({ requested: options.workers, most, segments: segments.length, frame: size, videos: yield* videoSizes(film), encoder: encoderFrames(encoder.name, settings.preset), available: availableMemory(), cores: logicalCores() });
147
+ const workers = planned.workers;
148
+ yield* emit({ _tag: "Started", start: { frames: to - from, width: size.width, height: size.height, raster: raster.name, encoder: encoder.spec.name, workers, budget: { limit: planned.limit, perWorker: planned.perWorker, available: planned.available } } });
116
149
  const job = {
117
- source: film.source, videos, assetsDir: film.project.assetsDir, scale,
150
+ source: film.source, work, videos, assetsDir: film.project.assetsDir, scale,
118
151
  raster: raster.name, encoder: encoder.name, settings, ffmpeg, ffprobe,
119
152
  };
120
153
  // The soundtrack, alongside the video (and its sound processing, if any).
@@ -133,16 +166,32 @@ const render = Effect.fn("renderVideo")(function* (film, options, emit) {
133
166
  lastReport = now;
134
167
  return emit(progress(frames()));
135
168
  });
136
- const queue = yield* Queue.unbounded();
137
- yield* Queue.offerAll(queue, segments);
138
- yield* Queue.end(queue);
139
- yield* Effect.forEach(Array.from({ length: workers }, (_, i) => i), (i) => runWorker(i, job, film.project.root, queue, onFrames, segments), { concurrency: "unbounded" });
169
+ const runs = new Runs(segments, workers);
170
+ // What every worker reported, added up: the render's profile, and the pieces to join.
171
+ const stages = noTimes();
172
+ const decoders = { starts: 0, skipped: 0 };
173
+ const pieces = [];
174
+ const hooks = {
175
+ frames: onFrames,
176
+ piece: (piece) => pieces.push(piece),
177
+ stats: (stats) => {
178
+ for (const stage of STAGES)
179
+ stages[stage] += stats.times[stage];
180
+ decoders.starts += stats.decoderStarts;
181
+ decoders.skipped += stats.framesSkipped;
182
+ },
183
+ };
184
+ const drawingFrom = performance.now();
185
+ yield* Effect.forEach(Array.from({ length: workers }, (_, i) => i), (i) => runWorker(i, job, film.project.root, runs, hooks, segments), { concurrency: "unbounded" });
140
186
  yield* emit(progress(total));
187
+ const drawn = performance.now();
141
188
  const { audio, sound: processed } = yield* Fiber.join(soundtrack);
189
+ const soundReady = performance.now();
142
190
  // Join without re-encoding, then check what came out.
143
191
  yield* emit({ _tag: "Joining" });
144
- const list = join(work, "segments.txt");
145
- yield* fs.writeFileString(list, segments.map((s) => `file '${s.out}'`).join("\n") + "\n");
192
+ const ordered = yield* inOrder(pieces, from, to);
193
+ const list = join(work, "pieces.txt");
194
+ yield* fs.writeFileString(list, ordered.map((p) => `file '${p.out}'`).join("\n") + "\n");
146
195
  yield* runTool(ffmpeg, ["-y", "-v", "error", "-f", "concat", "-safe", "0", "-i", list, "-i", audio, "-map", "0:v", "-map", "1:a", "-c", "copy", "-movflags", "+faststart", out]);
147
196
  const { stdout } = yield* runTool(ffprobe, ["-v", "error", "-count_packets", "-select_streams", "v", "-show_entries", "stream=width,height,nb_read_packets", "-of", "csv=p=0", out]);
148
197
  const [width = 0, height = 0, count = 0] = stdout.trim().split(",").map(Number);
@@ -151,7 +200,11 @@ const render = Effect.fn("renderVideo")(function* (film, options, emit) {
151
200
  if (width !== size.width || height !== size.height)
152
201
  return yield* new RenderFailure({ message: `${out} came out ${width}×${height}, not ${size.width}×${size.height} (the ${encoder.spec.name} encoder).` });
153
202
  const seconds = (performance.now() - started) / 1000;
154
- const result = { out, width, height, frames: count, duration: total / plan.fps, raster: raster.name, encoder: encoder.spec.name, workers, seconds, fps: total / seconds, sound: processed };
203
+ const profile = {
204
+ stages, decoders, pieces: ordered.length,
205
+ wall: { setup: (drawingFrom - started) / 1000, drawing: (drawn - drawingFrom) / 1000, soundtrack: (soundReady - drawn) / 1000, join: (performance.now() - soundReady) / 1000 },
206
+ };
207
+ const result = { out, width, height, frames: count, duration: total / plan.fps, raster: raster.name, encoder: encoder.spec.name, workers, seconds, fps: total / seconds, sound: processed, profile };
155
208
  return result;
156
209
  }, Effect.scoped);
157
210
  /** Renders a film (or a range of it) to an MP4 with its sound. `onStart` and `onProgress` hear how