reelkit-cli 0.10.6 → 0.12.0

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.
@@ -0,0 +1,149 @@
1
+ // What can be read off the rendered pictures alone: letters cut by an edge of the frame, and flashes that cover a held shot. Pure: grey frames in, findings out.
2
+ // These are measurements with known limits, so they are reported as advice with frame numbers to look at, never as errors.
3
+
4
+ export type Edge = "top" | "bottom" | "left" | "right";
5
+ export const EDGES: Edge[] = ["top", "bottom", "left", "right"];
6
+
7
+ export type EdgeOptions = {
8
+ // How deep the strip along each edge is, as a share of the frame's short side.
9
+ depth?: number;
10
+ // How far a stroke must be from the ground behind it, in grey levels.
11
+ contrast?: number;
12
+ // The widest a stroke may be, as a share of the frame's long side.
13
+ maxStroke?: number;
14
+ };
15
+
16
+ export type EdgeHit = { edge: Edge; // Where along the edge the cut strokes are, 0 to 1 (left to right, or top to bottom).
17
+ at: number; strokes: number };
18
+
19
+ const median = (values: number[]): number => { const s = [...values].sort((a, b) => a - b); return s[Math.floor(s.length / 2)] ?? 0; };
20
+
21
+ // The strip along one edge as rows of `depth` lines, line 0 being the outermost. Each line runs along the edge.
22
+ function strip(frame: Uint8Array, w: number, h: number, edge: Edge, depth: number): Uint8Array[] {
23
+ const lines: Uint8Array[] = [];
24
+ const along = edge === "top" || edge === "bottom" ? w : h;
25
+ for (let d = 0; d < depth; d++) {
26
+ const line = new Uint8Array(along);
27
+ for (let i = 0; i < along; i++) {
28
+ const x = edge === "left" ? d : edge === "right" ? w - 1 - d : i;
29
+ const y = edge === "top" ? d : edge === "bottom" ? h - 1 - d : i;
30
+ line[i] = frame[y * w + x]!;
31
+ }
32
+ lines.push(line);
33
+ }
34
+ return lines;
35
+ }
36
+
37
+ const shareNear = (values: number[], tol: number) => { const g = median(values); return values.filter((v) => Math.abs(v - g) <= tol).length / values.length; };
38
+
39
+ // Letters cut by an edge look like this: several short strokes of one ink on the last line of pixels, still there a couple of lines further in,
40
+ // on a ground that is calm beside them. A photograph that runs off the frame has no calm ground. A plate or a bar is one wide run, which is ignored
41
+ // so it does not hide the letters sitting on it. A wide run used to throw the whole window away, which missed titles and pills cut beside a plate.
42
+ export function edgeHits(frame: Uint8Array, w: number, h: number, opts: EdgeOptions = {}): EdgeHit[] {
43
+ const depth = Math.max(4, Math.round(Math.min(w, h) * (opts.depth ?? 0.035)));
44
+ const contrast = opts.contrast ?? 64;
45
+ const hits: EdgeHit[] = [];
46
+ for (const edge of EDGES) {
47
+ const lines = strip(frame, w, h, edge, depth);
48
+ const along = lines[0]!.length;
49
+ const win = Math.max(24, Math.round(along * 0.16));
50
+ // A stem of a letter is thin: at most about 1.6% of the frame's long side. Anything wider is a shape, a plate or a picture.
51
+ const maxStroke = Math.max(2, Math.round(Math.max(w, h) * (opts.maxStroke ?? 0.016)));
52
+ for (let start = 0; start < along; start += Math.round(win / 2)) {
53
+ const end = Math.min(along, start + win);
54
+ if (end - start < win / 2) break;
55
+ const all: number[] = [];
56
+ for (const line of lines) for (let i = start; i < end; i++) all.push(line[i]!);
57
+ const ground = median(all);
58
+ const flat = shareNear(all, 14);
59
+ // Strokes on the outermost line that are still there two lines further in. A wide run is a plate or a bar: skip that run, keep any letters beside it.
60
+ let strokes = 0, covered = 0, run = 0, first = -1, last = -1;
61
+ const ink: number[] = [], cols: number[] = [];
62
+ const close = (i: number) => {
63
+ if (run > 0 && run <= maxStroke) { strokes++; covered += run; if (first < 0) first = i - run; last = i; for (let k = i - run; k < i; k++) { ink.push(lines[0]![k]!); cols.push(k); } }
64
+ run = 0;
65
+ };
66
+ for (let i = start; i < end; i++) {
67
+ const on = Math.abs(lines[0]![i]! - ground) > contrast && Math.abs(lines[2]![i]! - ground) > contrast;
68
+ if (on) run++; else close(i);
69
+ }
70
+ close(end);
71
+ if (strokes < 3 || covered / (end - start) > 0.4) continue;
72
+ // Lettering is one ink: the strokes share a level. The edge of a cut-out photograph does not.
73
+ const inkLevel = median(ink);
74
+ if (ink.filter((v) => Math.abs(v - inkLevel) <= 28).length / ink.length < 0.8) continue;
75
+ if (flat < 0.6) {
76
+ // The strokes themselves make a textured window look uneven. Judge the ground on the pixels that are not strokes.
77
+ const stroke = new Set(cols);
78
+ const quiet: number[] = [];
79
+ for (const line of lines.slice(2)) for (let i = start; i < end; i++) if (!stroke.has(i)) quiet.push(line[i]!);
80
+ if (quiet.length < 8 || shareNear(quiet, 22) < 0.72) continue;
81
+ }
82
+ hits.push({ edge, at: Math.round(((first + last) / 2 / along) * 100) / 100, strokes });
83
+ }
84
+ }
85
+ return hits;
86
+ }
87
+
88
+ export type EdgeFinding = { edge: Edge; at: number; from: number; to: number };
89
+
90
+ // Dust and scratches of a film look touch an edge for a frame or two; a caption that is cut stays cut. Only a hit that lasts is kept:
91
+ // the same edge, at about the same place, for at least `minFrames` frames in a row.
92
+ export function persistentEdgeHits(perFrame: EdgeHit[][], minFrames = 4, firstFrame = 0): EdgeFinding[] {
93
+ const out: EdgeFinding[] = [];
94
+ const open: { edge: Edge; at: number; from: number; last: number }[] = [];
95
+ perFrame.forEach((hits, i) => {
96
+ for (const h of hits) {
97
+ const o = open.find((x) => x.edge === h.edge && Math.abs(x.at - h.at) <= 0.12 && x.last >= i - 1);
98
+ if (o) { o.last = i; o.at = (o.at + h.at) / 2; }
99
+ else open.push({ edge: h.edge, at: h.at, from: i, last: i });
100
+ }
101
+ for (let k = open.length - 1; k >= 0; k--) {
102
+ const o = open[k]!;
103
+ if (o.last < i - 1) { if (o.last - o.from + 1 >= minFrames) out.push({ edge: o.edge, at: Math.round(o.at * 100) / 100, from: o.from + firstFrame, to: o.last + firstFrame }); open.splice(k, 1); }
104
+ }
105
+ });
106
+ for (const o of open) if (o.last - o.from + 1 >= minFrames) out.push({ edge: o.edge, at: Math.round(o.at * 100) / 100, from: o.from + firstFrame, to: o.last + firstFrame });
107
+ return out.sort((a, b) => a.from - b.from);
108
+ }
109
+
110
+ export type FrameStat = { mean: number; std: number };
111
+
112
+ export function frameStat(frame: Uint8Array): FrameStat {
113
+ let sum = 0, sq = 0;
114
+ for (let i = 0; i < frame.length; i++) { sum += frame[i]!; sq += frame[i]! * frame[i]!; }
115
+ const mean = sum / frame.length;
116
+ return { mean, std: Math.sqrt(Math.max(0, sq / frame.length - mean * mean)) };
117
+ }
118
+
119
+ export type Flash = { from: number; to: number; // "fill": a near-uniform frame (a white, black or colour flash); "picture": another picture cut in.
120
+ kind: "fill" | "picture" };
121
+
122
+ export type FlashInput = {
123
+ // Mean absolute difference of each frame to the one before it (index 0 is 0).
124
+ toPrev: number[];
125
+ // Difference between the frame before a candidate flash and the frame after it: asked for only where needed.
126
+ across: (before: number, after: number) => number;
127
+ stats: FrameStat[];
128
+ };
129
+
130
+ // A flash covers a held shot when the picture jumps away and, one to `maxFrames` frames later, jumps back to nearly what it was. Whatever was on screen
131
+ // (a caption, a word being said) is hidden for those frames.
132
+ export function flashes(input: FlashInput, opts: { jump?: number; back?: number; maxFrames?: number } = {}): Flash[] {
133
+ const jump = opts.jump ?? 22, back = opts.back ?? 9, maxFrames = opts.maxFrames ?? 3;
134
+ const out: Flash[] = [];
135
+ const n = input.toPrev.length;
136
+ for (let i = 1; i < n - 1; i++) {
137
+ if (input.toPrev[i]! < jump) continue;
138
+ for (let len = 1; len <= maxFrames && i + len < n; len++) {
139
+ if (input.toPrev[i + len]! < jump) continue;
140
+ if (input.across(i - 1, i + len) <= back) {
141
+ const fill = input.stats.slice(i, i + len).every((s) => s.std < 12);
142
+ out.push({ from: i, to: i + len - 1, kind: fill ? "fill" : "picture" });
143
+ i += len;
144
+ }
145
+ break;
146
+ }
147
+ }
148
+ return out;
149
+ }
@@ -0,0 +1,186 @@
1
+ // What can be read off the composition's source: right-to-left text that is never told to run right to left, vowel points in captions, a Sequence
2
+ // that falls outside the one around it, and shots on a reference clock that the film never shows. Pure: source text in, findings out.
3
+ import ts from "typescript";
4
+ import { filmRanges, type Clock } from "../clock/clock";
5
+
6
+ export type Finding = { rule: string; file: string; line: number; message: string };
7
+
8
+ const RTL = /[֐-׿؀-ۿיִ-ﭏ]/;
9
+ // Hebrew cantillation marks and vowel points (not the maqaf U+05BE, a hyphen that plain text does use).
10
+ const NIQQUD = /[֑-ׇֽֿׁׂׅׄ]/;
11
+
12
+ const parse = (source: string, file: string) => ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
13
+ const lineOf = (sf: ts.SourceFile, node: ts.Node) => sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1;
14
+ const short = (s: string) => (s.length > 24 ? `${s.slice(0, 24)}...` : s);
15
+
16
+ const opening = (node: ts.Node): ts.JsxOpeningLikeElement | undefined => (ts.isJsxSelfClosingElement(node) ? node : ts.isJsxElement(node) ? node.openingElement : undefined);
17
+ const attr = (el: ts.JsxOpeningLikeElement, name: string): ts.JsxAttribute | undefined => el.attributes.properties.find((p): p is ts.JsxAttribute => ts.isJsxAttribute(p) && p.name.getText() === name);
18
+
19
+ // A literal number in a JSX attribute: from={12}, from={-3}, from={1541 - 1534}. Anything else is unknown.
20
+ function numberOf(e: ts.Expression | undefined): number | undefined {
21
+ if (!e) return undefined;
22
+ if (ts.isParenthesizedExpression(e)) return numberOf(e.expression);
23
+ if (ts.isNumericLiteral(e)) return Number(e.text);
24
+ if (ts.isPrefixUnaryExpression(e) && e.operator === ts.SyntaxKind.MinusToken) { const v = numberOf(e.operand); return v === undefined ? undefined : -v; }
25
+ if (ts.isBinaryExpression(e)) {
26
+ const a = numberOf(e.left), b = numberOf(e.right);
27
+ if (a === undefined || b === undefined) return undefined;
28
+ if (e.operatorToken.kind === ts.SyntaxKind.PlusToken) return a + b;
29
+ if (e.operatorToken.kind === ts.SyntaxKind.MinusToken) return a - b;
30
+ if (e.operatorToken.kind === ts.SyntaxKind.AsteriskToken) return a * b;
31
+ }
32
+ return undefined;
33
+ }
34
+ const numAttr = (el: ts.JsxOpeningLikeElement, name: string): number | undefined => { const a = attr(el, name); return a?.initializer && ts.isJsxExpression(a.initializer) ? numberOf(a.initializer.expression) : undefined; };
35
+
36
+ // Whether this element says which way its text runs: a `direction` in its style, a dir attribute, or an rtl / ltr prop.
37
+ function setsDirection(el: ts.JsxOpeningLikeElement, sf: ts.SourceFile): boolean {
38
+ if (attr(el, "dir") || attr(el, "rtl") || attr(el, "ltr") || attr(el, "direction")) return true;
39
+ const style = attr(el, "style");
40
+ return Boolean(style && /\bdirection\b/.test(style.getText(sf)));
41
+ }
42
+
43
+ // The source of each component declared in the file, by name: `const Word: React.FC = ...`, `function Word(...)`.
44
+ function declaredComponents(sf: ts.SourceFile): Map<string, ts.Node> {
45
+ const out = new Map<string, ts.Node>();
46
+ sf.forEachChild((node) => {
47
+ if (ts.isFunctionDeclaration(node) && node.name) out.set(node.name.text, node);
48
+ if (ts.isVariableStatement(node)) for (const d of node.declarationList.declarations) if (ts.isIdentifier(d.name) && d.initializer) out.set(d.name.text, d.initializer);
49
+ });
50
+ return out;
51
+ }
52
+
53
+ // Right-to-left text needs `direction: "rtl"` (or dir="rtl") on the element or on something around it. Without it, punctuation and numbers land on the
54
+ // wrong side and a mixed line reorders. Text handed to a component as a prop is that component's job: it is flagged only when the component is declared
55
+ // in this file and never mentions a direction.
56
+ export function rtlFindings(source: string, file: string): Finding[] {
57
+ const sf = parse(source, file);
58
+ const comps = declaredComponents(sf);
59
+ const out: Finding[] = [];
60
+ const visit = (node: ts.Node, directed: boolean) => {
61
+ const el = opening(node);
62
+ const here = directed || Boolean(el && setsDirection(el, sf));
63
+ if (ts.isJsxText(node) && RTL.test(node.text) && !directed) {
64
+ out.push({ rule: "rtl-direction", file, line: lineOf(sf, node), message: `Right-to-left text "${short(node.text.trim())}" is drawn with no direction set. Put direction: "rtl" in the style of this element or of one around it.` });
65
+ }
66
+ if (el) {
67
+ const tag = el.tagName.getText(sf);
68
+ const body = comps.get(tag);
69
+ if (body && !/\bdirection\b|\bdir=/.test(body.getText(sf))) {
70
+ for (const p of el.attributes.properties) {
71
+ if (!ts.isJsxAttribute(p) || !p.initializer) continue;
72
+ const text = ts.isStringLiteral(p.initializer) ? p.initializer.text : ts.isJsxExpression(p.initializer) && p.initializer.expression && ts.isStringLiteralLike(p.initializer.expression) ? p.initializer.expression.text : undefined;
73
+ if (text && RTL.test(text) && !here) out.push({ rule: "rtl-direction", file, line: lineOf(sf, p), message: `<${tag}> is given right-to-left text "${short(text)}" but never sets a direction. Add direction: "rtl" where ${tag} draws its text.` });
74
+ }
75
+ }
76
+ }
77
+ ts.forEachChild(node, (c) => visit(c, here));
78
+ };
79
+ visit(sf, false);
80
+ return out;
81
+ }
82
+
83
+ // Vowel points belong in the text sent to a voice, not in what is drawn. Every string in the composition that carries them is listed.
84
+ export function niqqudFindings(source: string, file: string): Finding[] {
85
+ const sf = parse(source, file);
86
+ const out: Finding[] = [];
87
+ const visit = (node: ts.Node) => {
88
+ const text = ts.isStringLiteralLike(node) ? node.text : ts.isJsxText(node) ? node.text : undefined;
89
+ if (text !== undefined && NIQQUD.test(text)) out.push({ rule: "niqqud", file, line: lineOf(sf, node), message: `"${short(text.trim())}" carries vowel points (niqqud). On-screen text is plain: keep the pointed spelling for the voice only.` });
90
+ ts.forEachChild(node, visit);
91
+ };
92
+ visit(sf);
93
+ return out;
94
+ }
95
+
96
+ // A <Sequence> (or anything with from and durationInFrames) written inside another one runs on the outer one's clock and is clipped to its length.
97
+ // A child that starts at or after the parent's end, or ends at or before its start, is never drawn; one that sticks out is cut short.
98
+ export function nestedSequenceFindings(source: string, file: string): Finding[] {
99
+ const sf = parse(source, file);
100
+ const out: Finding[] = [];
101
+ const visit = (node: ts.Node, parent?: { tag: string; duration: number }) => {
102
+ const el = opening(node);
103
+ let next = parent;
104
+ if (el) {
105
+ const tag = el.tagName.getText(sf);
106
+ const from = numAttr(el, "from"), duration = numAttr(el, "durationInFrames");
107
+ if (parent && from !== undefined) {
108
+ const end = duration === undefined ? undefined : from + duration;
109
+ if (from >= parent.duration || (end !== undefined && end <= 0)) out.push({ rule: "sequence-outside-parent", file, line: lineOf(sf, node), message: `<${tag} from={${from}}${duration === undefined ? "" : ` durationInFrames={${duration}}`}> lies outside the <${parent.tag}> around it (${parent.duration} frames long): it is never drawn.` });
110
+ else if (end !== undefined && end > parent.duration) out.push({ rule: "sequence-outside-parent", file, line: lineOf(sf, node), message: `<${tag}> runs to frame ${end} of the <${parent.tag}> around it, which is only ${parent.duration} frames long: its last ${end - parent.duration} frame(s) are cut off.` });
111
+ }
112
+ if (duration !== undefined && attr(el, "durationInFrames")) next = { tag, duration };
113
+ }
114
+ ts.forEachChild(node, (c) => visit(c, next));
115
+ };
116
+ visit(sf);
117
+ return out;
118
+ }
119
+
120
+ export type ShotOptions = { tags?: string[]; scenes?: { id: string; startFrame: number; durationFrames: number }[]; sectionsConst?: string };
121
+
122
+ type Shot = { a: number; b: number; line: number; tag: string };
123
+
124
+ // Shots written on a reference clock, such as <S a={802} b={805}>: one that lies wholly inside a range the film cuts out is silently never shown, and
125
+ // one placed in the wrong scene section is clipped away by that scene. The first needs only the clock. The second needs to know which section a shot is
126
+ // in: the composition's `SECTIONS = { sceneId: Component }` map, followed through the components each section draws.
127
+ export function shotFindings(source: string, file: string, clock: Clock, opts: ShotOptions = {}): Finding[] {
128
+ const sf = parse(source, file);
129
+ const tags = new Set(opts.tags ?? ["S", "Shot"]);
130
+ const comps = declaredComponents(sf);
131
+ const shotsIn = new Map<string, Shot[]>();
132
+ const usesIn = new Map<string, Set<string>>();
133
+ const all: Shot[] = [];
134
+ for (const [name, body] of comps) {
135
+ const shots: Shot[] = [], uses = new Set<string>();
136
+ const visit = (node: ts.Node) => {
137
+ const el = opening(node);
138
+ if (el) {
139
+ const tag = el.tagName.getText(sf);
140
+ if (tags.has(tag)) {
141
+ const a = numAttr(el, "a") ?? numAttr(el, "from"), b = numAttr(el, "b") ?? numAttr(el, "to");
142
+ if (a !== undefined && b !== undefined) shots.push({ a, b, line: lineOf(sf, node), tag });
143
+ } else if (comps.has(tag) && tag !== name) uses.add(tag);
144
+ }
145
+ ts.forEachChild(node, visit);
146
+ };
147
+ visit(body);
148
+ shotsIn.set(name, shots); usesIn.set(name, uses); all.push(...shots);
149
+ }
150
+ const out: Finding[] = [];
151
+ for (const s of all) {
152
+ if (s.b <= s.a) { out.push({ rule: "shot-range", file, line: s.line, message: `<${s.tag} a={${s.a}} b={${s.b}}> ends at or before its start: it is never drawn.` }); continue; }
153
+ // The opening runs on its own clock from 0, so a range below the first kept range belongs to it.
154
+ if (clock.opening > 0 && s.b <= clock.opening) continue;
155
+ if (!filmRanges(clock, s.a, s.b).length) out.push({ rule: "shot-in-cut", file, line: s.line, message: `<${s.tag} a={${s.a}} b={${s.b}}> lies wholly inside a range the film cuts out: it is never drawn. Remove it, or move it into a kept range.` });
156
+ }
157
+ // Which scene section each shot is drawn in, and whether that scene shows those frames.
158
+ const sectionsName = opts.sectionsConst ?? "SECTIONS";
159
+ const sections = comps.get(sectionsName);
160
+ if (opts.scenes?.length && sections && ts.isObjectLiteralExpression(sections)) {
161
+ const collect = (comp: string, seen = new Set<string>()): Shot[] => {
162
+ if (seen.has(comp)) return [];
163
+ seen.add(comp);
164
+ return [...(shotsIn.get(comp) ?? []), ...[...(usesIn.get(comp) ?? [])].flatMap((u) => collect(u, seen))];
165
+ };
166
+ for (const prop of sections.properties) {
167
+ if (!ts.isPropertyAssignment(prop) && !ts.isShorthandPropertyAssignment(prop)) continue;
168
+ const id = prop.name.getText(sf).replace(/^["']|["']$/g, "");
169
+ const comp = ts.isPropertyAssignment(prop) ? prop.initializer.getText(sf) : id;
170
+ const scene = opts.scenes.find((sc) => sc.id === id);
171
+ if (!scene || !comps.has(comp)) continue;
172
+ const lo = scene.startFrame, hi = scene.startFrame + scene.durationFrames;
173
+ for (const s of collect(comp)) {
174
+ // A section that starts inside the opening is written on the opening's own clock.
175
+ const inOpening = clock.opening > 0 && lo < clock.opening;
176
+ const ranges = inOpening ? [{ from: s.a, to: s.b }] : filmRanges(clock, s.a, s.b);
177
+ if (!ranges.length) continue;
178
+ const shown = ranges.some((r) => r.from < hi && r.to > lo);
179
+ const clipped = ranges.some((r) => r.from < lo || r.to > hi);
180
+ if (!shown) out.push({ rule: "shot-outside-section", file, line: s.line, message: `<${s.tag} a={${s.a}} b={${s.b}}> is film frames ${ranges.map((r) => `${r.from}-${r.to}`).join(", ")} but sits in the "${id}" section, which is film frames ${lo}-${hi}: the scene clips it away and it is never drawn. Move it into the section that holds those frames.` });
181
+ else if (clipped) out.push({ rule: "shot-outside-section", file, line: s.line, message: `<${s.tag} a={${s.a}} b={${s.b}}> (film frames ${ranges.map((r) => `${r.from}-${r.to}`).join(", ")}) sticks out of the "${id}" section (film frames ${lo}-${hi}): the part outside is not drawn.` });
182
+ }
183
+ }
184
+ }
185
+ return out.sort((x, y) => x.line - y.line);
186
+ }
@@ -0,0 +1,84 @@
1
+ // Small ffmpeg and ffprobe helpers shared by export, diff, lint and the chunked render. Each failure is a one-line Error for the person at the terminal.
2
+ import { spawn } from "node:child_process";
3
+ import { closeSync, fsyncSync, openSync, readSync, statSync } from "node:fs";
4
+ import { tool } from "../project/refmeasure";
5
+ import type { ProbedStream } from "../export/presets";
6
+
7
+ export async function probeStreams(path: string): Promise<{ streams: ProbedStream[]; durationSec: number }> {
8
+ const { stdout } = await tool("ffprobe", ["-v", "error", "-print_format", "json", "-show_format", "-show_streams", path]);
9
+ const info = JSON.parse(stdout.toString("utf8")) as { streams?: ProbedStream[]; format?: { duration?: string } };
10
+ return { streams: info.streams ?? [], durationSec: Number(info.format?.duration ?? 0) };
11
+ }
12
+
13
+ export type VideoFacts = { width: number; height: number; fps: number; pixFmt?: string; colorRange?: string; hasAudio: boolean; durationSec: number };
14
+
15
+ export async function videoFacts(path: string): Promise<VideoFacts> {
16
+ const { streams, durationSec } = await probeStreams(path);
17
+ const v = streams.find((s) => s.codec_type === "video") as (ProbedStream & { avg_frame_rate?: string; r_frame_rate?: string }) | undefined;
18
+ if (!v || !v.width || !v.height) throw new Error(`${path} has no video stream that can be read.`);
19
+ const rate = (r?: string) => { const [a, b] = (r ?? "").split("/").map(Number); return a && b ? a / b : a || 0; };
20
+ return { width: v.width, height: v.height, fps: rate(v.avg_frame_rate) || rate(v.r_frame_rate), pixFmt: v.pix_fmt, colorRange: v.color_range, hasAudio: streams.some((s) => s.codec_type === "audio"), durationSec };
21
+ }
22
+
23
+ // Decodes the whole file and throws nothing away but the pictures: the number of frames decoded and every line ffmpeg printed at -v error.
24
+ export async function fullDecode(path: string): Promise<{ frames: number; errorLines: string[] }> {
25
+ return new Promise((resolve, reject) => {
26
+ const p = spawn("ffmpeg", ["-nostdin", "-v", "error", "-stats", "-i", path, "-f", "null", "-"], { stdio: ["ignore", "ignore", "pipe"] });
27
+ let err = "";
28
+ p.stderr.on("data", (d: Buffer) => { err += d.toString("utf8"); });
29
+ p.on("error", (e) => reject((e as NodeJS.ErrnoException).code === "ENOENT" ? new Error("ffmpeg was not found. Install ffmpeg and try again.") : e));
30
+ p.on("close", () => {
31
+ // -stats writes "frame= 2000 fps=..." progress on carriage returns; everything else is an error line.
32
+ const pieces = err.split(/[\r\n]+/).map((l) => l.trim()).filter(Boolean);
33
+ const stats = pieces.filter((l) => /^frame=/.test(l));
34
+ const frames = Number(/^frame=\s*(\d+)/.exec(stats.at(-1) ?? "")?.[1] ?? 0);
35
+ resolve({ frames, errorLines: pieces.filter((l) => !/^frame=/.test(l) && !/^size=/.test(l)) });
36
+ });
37
+ });
38
+ }
39
+
40
+ // Streams a video as small grey frames, calling onFrame for each. Frames come in order, one at a time, so a long film never sits in memory.
41
+ export async function grayFrames(path: string, width: number, height: number, onFrame: (frame: Uint8Array, index: number) => void, opts: { from?: number; to?: number } = {}): Promise<number> {
42
+ const select = opts.from !== undefined || opts.to !== undefined ? [`select='between(n\\,${opts.from ?? 0}\\,${opts.to ?? 1e9})'`] : [];
43
+ const vf = [...select, `scale=${width}:${height}:flags=area`, "format=gray"].join(",");
44
+ return new Promise((resolve, reject) => {
45
+ const p = spawn("ffmpeg", ["-nostdin", "-v", "error", "-i", path, "-an", "-vf", vf, "-fps_mode", "passthrough", "-f", "rawvideo", "-pix_fmt", "gray", "-"], { stdio: ["ignore", "pipe", "pipe"] });
46
+ const size = width * height;
47
+ let held: Buffer = Buffer.alloc(0);
48
+ let index = opts.from ?? 0, err = "";
49
+ p.stdout.on("data", (d: Buffer) => {
50
+ held = held.length ? Buffer.concat([held, d]) : d;
51
+ while (held.length >= size) {
52
+ onFrame(new Uint8Array(held.buffer, held.byteOffset, size).slice(), index++);
53
+ held = held.subarray(size);
54
+ }
55
+ });
56
+ p.stderr.on("data", (d: Buffer) => { err += d.toString("utf8"); });
57
+ p.on("error", (e) => reject((e as NodeJS.ErrnoException).code === "ENOENT" ? new Error("ffmpeg was not found. Install ffmpeg and try again.") : e));
58
+ p.on("close", (code) => (code === 0 ? resolve(index - (opts.from ?? 0)) : reject(new Error(`ffmpeg could not decode ${path}: ${err.trim().split("\n")[0] ?? `exit ${code}`}`))));
59
+ });
60
+ }
61
+
62
+ // The sound as mono 16-bit samples at `rate`, or undefined for a file with no audio.
63
+ export async function monoSamples(path: string, rate = 8000): Promise<Int16Array | undefined> {
64
+ const { streams } = await probeStreams(path);
65
+ if (!streams.some((s) => s.codec_type === "audio")) return undefined;
66
+ const { stdout } = await tool("ffmpeg", ["-nostdin", "-v", "error", "-i", path, "-vn", "-ac", "1", "-ar", String(rate), "-f", "s16le", "-"]);
67
+ return new Int16Array(stdout.buffer, stdout.byteOffset, Math.floor(stdout.byteLength / 2));
68
+ }
69
+
70
+ export function readBytes(path: string): { read: (offset: number, length: number) => Uint8Array; size: number; close: () => void } {
71
+ const fd = openSync(path, "r");
72
+ const size = statSync(path).size;
73
+ return {
74
+ size,
75
+ read: (offset, length) => { const b = Buffer.alloc(Math.min(length, Math.max(0, size - offset))); readSync(fd, b, 0, b.length, offset); return b; },
76
+ close: () => closeSync(fd),
77
+ };
78
+ }
79
+
80
+ // Asks the system to put the file on the disk now. After a crash or a reboot an unsynced file can be empty although the program reported success.
81
+ export function syncFile(path: string): void {
82
+ const fd = openSync(path, "r+");
83
+ try { fsyncSync(fd); } finally { closeSync(fd); }
84
+ }
@@ -0,0 +1,139 @@
1
+ // Rendering a long film in pieces that survive a crash. Each piece is a silent H.264 file of a range of frames, written whole or not at all; a piece
2
+ // that is already on disk, reads back with the right number of frames and has its audio sidecar is not rendered again. The pieces are joined
3
+ // without re-encoding. The sound is mixed once from those sidecars and laid under them. The planning here is pure; the rendering is handed in.
4
+ import { createHash } from "node:crypto";
5
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { syncFile } from "../media/ffmpeg";
8
+ import { tool } from "../project/refmeasure";
9
+
10
+ export const DEFAULT_CHUNK_FRAMES = 300;
11
+ export const MAX_ATTEMPTS = 3;
12
+
13
+ export type Chunk = { index: number; from: number; to: number; file: string };
14
+
15
+ // Frame ranges (inclusive) that cover the film.
16
+ export function planChunks(totalFrames: number, chunkFrames = DEFAULT_CHUNK_FRAMES): Chunk[] {
17
+ const size = Math.max(1, Math.floor(chunkFrames));
18
+ const out: Chunk[] = [];
19
+ for (let from = 0, index = 0; from < totalFrames; from += size, index++) {
20
+ const to = Math.min(totalFrames, from + size) - 1;
21
+ out.push({ index, from, to, file: `chunk-${String(from).padStart(6, "0")}-${String(to).padStart(6, "0")}.mp4` });
22
+ }
23
+ return out;
24
+ }
25
+
26
+ // What the pieces were rendered from. A piece made from other source, other media or another size is not reused.
27
+ export function renderKey(parts: { sources: Record<string, string>; manifest: unknown; media: Record<string, number>; chunkFrames: number }): string {
28
+ const h = createHash("sha256");
29
+ for (const name of Object.keys(parts.sources).sort()) h.update(name).update("\0").update(parts.sources[name]!).update("\0");
30
+ h.update(JSON.stringify(parts.manifest)).update("\0");
31
+ for (const name of Object.keys(parts.media).sort()) h.update(`${name}:${parts.media[name]}\0`);
32
+ h.update(String(parts.chunkFrames));
33
+ return h.digest("hex").slice(0, 16);
34
+ }
35
+
36
+ // A failure of the browser rather than of the composition: worth another attempt.
37
+ export const isBrowserFailure = (e: unknown): boolean => /browser|Target closed|ProtocolError|Protocol error|Navigation|WebSocket|ECONNRESET|ECONNREFUSED|Timed out|timeout|crash|disconnected|Session closed/i.test(e instanceof Error ? e.message : String(e));
38
+
39
+ // Runs fn, and again after a browser failure, up to `attempts` times in all. An error of the composition itself is thrown at once.
40
+ export async function withRetry<T>(fn: (attempt: number) => Promise<T>, opts: { attempts?: number; onRetry?: (attempt: number, error: unknown) => void; wait?: (ms: number) => Promise<void> } = {}): Promise<T> {
41
+ const attempts = opts.attempts ?? MAX_ATTEMPTS;
42
+ for (let attempt = 1; ; attempt++) {
43
+ try { return await fn(attempt); }
44
+ catch (e) {
45
+ if (attempt >= attempts || !isBrowserFailure(e)) throw e;
46
+ opts.onRetry?.(attempt, e);
47
+ await (opts.wait ?? ((ms) => new Promise((r) => setTimeout(r, ms))))(1500 * attempt);
48
+ }
49
+ }
50
+ }
51
+
52
+ export type ChunkDeps = {
53
+ // Renders frames from..to (inclusive), silent, to `out`. May also write `${out}.audio.json`.
54
+ renderRange: (from: number, to: number, out: string) => Promise<void>;
55
+ // Renders the whole film's sound to `out` (a wav or m4a); resolves false when the film has none.
56
+ renderSound: (out: string) => Promise<boolean>;
57
+ // How many frames a finished piece holds; undefined when it cannot be read.
58
+ countFrames: (file: string) => Promise<number | undefined>;
59
+ log: (line: string) => void;
60
+ concat?: (list: string, sound: string | undefined, out: string) => Promise<void>;
61
+ // Extra reason to render a piece again (for example its audio sidecar is missing).
62
+ reuseExtra?: (file: string) => boolean;
63
+ };
64
+
65
+ export type CapturedAudio = {
66
+ id: string; src: string; start: number; end: number; mediaStart: number; layer: number; volume: number;
67
+ volumeKeyframes: { time: number; volume: number }[]; type: "audio" | "video"; loop: boolean;
68
+ };
69
+
70
+ // Pieces of one film are captured in separate browser sessions, so the same clip can come back under different ids.
71
+ // Merge by where it sits, and keep every volume sample.
72
+ export function mergeAudioRecords(lists: CapturedAudio[][]): CapturedAudio[] {
73
+ const byKey = new Map<string, CapturedAudio>();
74
+ for (const list of lists) for (const record of list) {
75
+ const key = `${record.type}|${record.src}|${record.start}|${record.mediaStart}|${record.loop}`;
76
+ const prev = byKey.get(key);
77
+ if (!prev) { byKey.set(key, { ...record, volumeKeyframes: [...record.volumeKeyframes] }); continue; }
78
+ prev.end = Math.max(prev.end, record.end);
79
+ prev.volumeKeyframes.push(...record.volumeKeyframes);
80
+ }
81
+ for (const record of byKey.values()) {
82
+ const seen = new Map<number, { time: number; volume: number }>();
83
+ for (const key of record.volumeKeyframes) seen.set(key.time, key);
84
+ record.volumeKeyframes = [...seen.values()].sort((a, b) => a.time - b.time);
85
+ }
86
+ return [...byKey.values()];
87
+ }
88
+
89
+ export async function countFrames(file: string): Promise<number | undefined> {
90
+ try {
91
+ const { stdout } = await tool("ffprobe", ["-v", "error", "-select_streams", "v:0", "-count_packets", "-show_entries", "stream=nb_read_packets", "-of", "csv=p=0", file]);
92
+ const n = Number(stdout.toString("utf8").trim().split(/[,\n]/)[0]);
93
+ return Number.isFinite(n) ? n : undefined;
94
+ } catch { return undefined; }
95
+ }
96
+
97
+ // Joins the pieces without touching their pictures and lays the sound under them.
98
+ export async function concatChunks(list: string, sound: string | undefined, out: string): Promise<void> {
99
+ await tool("ffmpeg", ["-nostdin", "-y", "-v", "error", "-f", "concat", "-safe", "0", "-i", list, ...(sound ? ["-i", sound] : []), "-map", "0:v:0", ...(sound ? ["-map", "1:a:0", "-c:a", "aac", "-b:a", "320k", "-ar", "48000"] : []), "-c:v", "copy", "-movflags", "+faststart", out]);
100
+ }
101
+
102
+ export type ChunkedResult = { chunks: number; reused: number; rendered: number; retries: number };
103
+
104
+ // Renders whatever pieces are missing into `dir`, then assembles `outPath`. Safe to run again after any interruption: finished pieces are kept.
105
+ export async function renderChunked(dir: string, key: string, totalFrames: number, chunkFrames: number, outPath: string, deps: ChunkDeps): Promise<ChunkedResult> {
106
+ mkdirSync(dir, { recursive: true });
107
+ const keyFile = join(dir, "key");
108
+ // Pieces of another version of the film are of no use: start clean.
109
+ if (existsSync(keyFile) && readFileSync(keyFile, "utf8").trim() !== key) { for (const f of readdirSync(dir)) rmSync(join(dir, f), { force: true, recursive: true }); }
110
+ writeFileSync(keyFile, key);
111
+ const chunks = planChunks(totalFrames, chunkFrames);
112
+ let reused = 0, rendered = 0, retries = 0;
113
+ for (const c of chunks) {
114
+ const file = join(dir, c.file);
115
+ const want = c.to - c.from + 1;
116
+ if (existsSync(file) && statSync(file).size > 0 && (await deps.countFrames(file)) === want && (deps.reuseExtra?.(file) ?? true)) { reused++; continue; }
117
+ const part = `${file}.part.mp4`;
118
+ const partAudio = `${part}.audio.json`;
119
+ await withRetry(async () => { rmSync(part, { force: true }); rmSync(partAudio, { force: true }); await deps.renderRange(c.from, c.to, part); }, { onRetry: (n, e) => { retries++; deps.log(`The browser failed on frames ${c.from}-${c.to} (${(e instanceof Error ? e.message : String(e)).split("\n")[0]}); trying again (${n + 1} of ${MAX_ATTEMPTS}).`); } });
120
+ const got = await deps.countFrames(part);
121
+ if (got !== want) throw new Error(`frames ${c.from}-${c.to} rendered as ${got ?? "an unreadable file"} instead of ${want} frames`);
122
+ syncFile(part);
123
+ renameSync(part, file);
124
+ if (existsSync(partAudio)) { syncFile(partAudio); renameSync(partAudio, `${file}.audio.json`); }
125
+ rendered++;
126
+ deps.log(`Frames ${c.from}-${c.to} done (${c.index + 1} of ${chunks.length}).`);
127
+ }
128
+ const soundFile = join(dir, "sound.m4a");
129
+ let hasSound = existsSync(soundFile) && statSync(soundFile).size > 44;
130
+ if (!hasSound) {
131
+ const part = join(dir, "sound.part.m4a");
132
+ hasSound = await withRetry(() => deps.renderSound(part), { onRetry: () => { retries++; } });
133
+ if (hasSound) { syncFile(part); renameSync(part, soundFile); } else rmSync(part, { force: true });
134
+ }
135
+ const list = join(dir, "list.txt");
136
+ writeFileSync(list, chunks.map((c) => `file '${join(dir, c.file).replace(/'/g, "'\\''")}'`).join("\n") + "\n");
137
+ await (deps.concat ?? concatChunks)(list, hasSound ? soundFile : undefined, outPath);
138
+ return { chunks: chunks.length, reused, rendered, retries };
139
+ }
@@ -45,6 +45,7 @@ export async function disposeBundle(dir: string): Promise<void> {
45
45
  export type EnsureBrowser = (onDownload: () => void) => Promise<void>;
46
46
  export const GL_BACKEND = "software" as const;
47
47
  export type RenderOptions = { uses3D?: boolean; silent?: boolean; crf?: number };
48
+ export type FrameRange = { from: number; to: number };
48
49
  export function browserPath(): string | undefined {
49
50
  if (process.env.REELKIT_CHROME_PATH) return process.env.REELKIT_CHROME_PATH;
50
51
  const paths = process.platform === "darwin" ? ["/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "/Applications/Chromium.app/Contents/MacOS/Chromium"] : process.platform === "linux" ? ["/usr/bin/chromium", "/usr/bin/chromium-browser", "/usr/bin/google-chrome"] : [];
@@ -63,17 +64,34 @@ export const ensureRenderBrowser: EnsureBrowser = async (onDownload) => {
63
64
  const browser = await install({ browser: Browser.CHROMEHEADLESSSHELL, buildId: await resolveBuildId(Browser.CHROMEHEADLESSSHELL, platform, "stable"), cacheDir: join(PKG_ROOT, ".browser"), platform });
64
65
  process.env.REELKIT_CHROME_PATH = browser.executablePath;
65
66
  };
66
- async function render(dir: string, props: VideoProps, mode: "stills" | "video", output: string, frames: number[], options?: RenderOptions) {
67
+ async function render(dir: string, props: VideoProps, mode: "stills" | "video", output: string, frames: number[], options?: RenderOptions, extra?: { range?: FrameRange; audioSidecar?: string }) {
67
68
  await ensureRenderBrowser(() => {});
68
69
  await writeFile(join(dir, "props.js"), `window.__reelkitProps=${JSON.stringify(props).replace(/</g, "\\u003c")};`);
69
70
  // Isolate engine diagnostics from the CLI's JSON stdout.
70
71
  const request = join(dir, "render-request.json");
71
- await writeFile(request, JSON.stringify({ dir, props, mode, output, frames, options, chromePath: browserPath() }));
72
+ await writeFile(request, JSON.stringify({ dir, props, mode, output, frames, options, chromePath: browserPath(), ...extra }));
72
73
  await run(process.execPath, ["--import", join(packageDir("tsx"), "dist/loader.mjs"), join(PKG_ROOT, "src/render/worker.ts"), request], { maxBuffer: 16 * 1024 * 1024 });
73
74
  }
74
75
  export async function renderStills(dir: string, props: VideoProps, frames: number[], outDir: string, options?: RenderOptions) {
75
76
  await mkdir(outDir, { recursive: true }); await render(dir, props, "stills", outDir, frames, options);
76
77
  }
77
- export async function renderVideo(dir: string, props: VideoProps, outPath: string, options?: RenderOptions) {
78
- await mkdir(dirname(outPath), { recursive: true }); await render(dir, props, "video", outPath, [], options);
78
+ // `audioFile`, when given, receives the audio records the film registered (which sound plays from when to when), as JSON.
79
+ export async function renderVideo(dir: string, props: VideoProps, outPath: string, options?: RenderOptions, audioFile?: string) {
80
+ await mkdir(dirname(outPath), { recursive: true }); await render(dir, props, "video", outPath, [], options, audioFile ? { audioSidecar: audioFile } : undefined);
81
+ }
82
+ // One inclusive range of frames as a silent file, plus the audio records those frames registered (`${outPath}.audio.json`).
83
+ export async function renderVideoRange(dir: string, props: VideoProps, outPath: string, from: number, to: number, options?: RenderOptions) {
84
+ await mkdir(dirname(outPath), { recursive: true });
85
+ await render(dir, props, "video", outPath, [], { ...options, silent: true }, { range: { from, to }, audioSidecar: `${outPath}.audio.json` });
86
+ }
87
+ // Mix audio records already collected from the pieces. No picture is drawn.
88
+ export async function mixCapturedAudio(records: unknown[], outPath: string, durationSec: number) {
89
+ const dir = await mkdtemp(join(tmpdir(), "reelkit-mix-"));
90
+ try {
91
+ const request = join(dir, "render-request.json");
92
+ await writeFile(request, JSON.stringify({ mode: "mix", output: outPath, records, durationSec, frames: [] }));
93
+ await run(process.execPath, ["--import", join(packageDir("tsx"), "dist/loader.mjs"), join(PKG_ROOT, "src/render/worker.ts"), request], { maxBuffer: 16 * 1024 * 1024 });
94
+ } finally {
95
+ await rm(dir, { recursive: true, force: true });
96
+ }
79
97
  }