@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 +18 -10
- package/dist/encode/encoders.js +2 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -0
- package/dist/project.js +7 -3
- package/dist/render/budget.d.ts +45 -0
- package/dist/render/budget.js +83 -0
- package/dist/render/events.d.ts +36 -0
- package/dist/render/events.js +1 -1
- package/dist/render/frame-pipe.d.ts +18 -0
- package/dist/render/frame-pipe.js +46 -0
- package/dist/render/frame.d.ts +2 -1
- package/dist/render/frame.js +16 -3
- package/dist/render/inputs.js +2 -1
- package/dist/render/pipeline.d.ts +6 -4
- package/dist/render/pipeline.js +79 -26
- package/dist/render/protocol.d.ts +28 -4
- package/dist/render/protocol.js +54 -13
- package/dist/render/runs.d.ts +10 -0
- package/dist/render/runs.js +42 -0
- package/dist/render/stills.d.ts +2 -2
- package/dist/render/stills.js +6 -5
- package/dist/render/stopwatch.d.ts +10 -0
- package/dist/render/stopwatch.js +16 -0
- package/dist/render/video-frames.d.ts +22 -2
- package/dist/render/video-frames.js +162 -58
- package/dist/render/worker.js +47 -15
- package/package.json +8 -8
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
|
|
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 ─▶
|
|
204
|
-
◀─progress── worker 2 …
|
|
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
|
|
211
|
-
the
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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`).
|
package/dist/encode/encoders.js
CHANGED
|
@@ -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-
|
|
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
|
|
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
|
|
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
|
|
137
|
-
|
|
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();
|
package/dist/render/events.d.ts
CHANGED
|
@@ -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. */
|
package/dist/render/events.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/render/frame.d.ts
CHANGED
|
@@ -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>;
|
package/dist/render/frame.js
CHANGED
|
@@ -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
|
-
|
|
14
|
-
|
|
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);
|
package/dist/render/inputs.js
CHANGED
|
@@ -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"}
|
|
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
|
|
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
|
|
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. */
|
package/dist/render/pipeline.js
CHANGED
|
@@ -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
|
-
|
|
17
|
-
|
|
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
|
|
36
|
-
const runWorker = Effect.fn("runWorker")(function* (index, job, cwd,
|
|
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.
|
|
47
|
-
const segment =
|
|
48
|
-
|
|
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
|
-
|
|
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
|
|
65
|
-
|
|
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
|
|
112
|
-
const segmentSize = options.segment ?? { min: Math.round(plan.fps), max: Math.round(plan.fps
|
|
113
|
-
const segments = planSegments(from, to,
|
|
114
|
-
const
|
|
115
|
-
|
|
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
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
|
145
|
-
|
|
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
|
|
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
|