reelkit-cli 0.10.5 → 0.11.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,147 @@
1
+ // Delivery encodes of a finished render. A HyperFrames render is already yuv420p, TV range, tagged BT.709. An older render, or a file from somewhere
2
+ // else, may still be full-range yuvj420p with no colour tags, which some phones play washed out or refuse. Every preset here converts full range to
3
+ // TV range exactly once (a source that is already TV range is not converted again), tags the stream as BT.709 both in the filter chain (setparams) and
4
+ // in the encoder (x264 params), pins profile and level, encodes AAC at 48 kHz and puts the index at the front of the file. Everything in this module
5
+ // is pure: it builds argument lists and judges ffprobe output, and never runs a program.
6
+
7
+ export type ExportPreset = {
8
+ id: string;
9
+ // What it is for, in one line, shown by `reelkit export --list`.
10
+ purpose: string;
11
+ // Output height in pixels on the short side of a 16:9 or 9:16 frame; the width follows the source's shape. undefined keeps the source size (capped at 1080 on the short side).
12
+ shortSide: number;
13
+ profile: "main" | "high";
14
+ level: string;
15
+ // Constant quality, or a fixed bitrate when `videoKbps` is given.
16
+ crf?: number;
17
+ videoKbps?: number;
18
+ maxrateKbps?: number;
19
+ bufsizeKbps?: number;
20
+ audioKbps: number;
21
+ x264Preset: string;
22
+ };
23
+
24
+ export const EXPORT_PRESETS: ExportPreset[] = [
25
+ { id: "iphone", purpose: "full quality for a phone or a desktop: 1080p, High profile level 4.0, CRF 18", shortSide: 1080, profile: "high", level: "4.0", crf: 18, maxrateKbps: 12000, bufsizeKbps: 24000, audioKbps: 192, x264Preset: "slow" },
26
+ { id: "phone-720", purpose: "a lighter phone copy: 720p, High profile level 4.0, CRF 20 capped at 4 Mb/s", shortSide: 720, profile: "high", level: "4.0", crf: 20, maxrateKbps: 4000, bufsizeKbps: 8000, audioKbps: 160, x264Preset: "slow" },
27
+ { id: "chat", purpose: "small enough to send in a chat: 720p, Main profile level 4.0, about 900 kb/s", shortSide: 720, profile: "main", level: "4.0", videoKbps: 900, maxrateKbps: 1200, bufsizeKbps: 2400, audioKbps: 128, x264Preset: "slow" },
28
+ ];
29
+
30
+ export const presetById = (id: string): ExportPreset | undefined => EXPORT_PRESETS.find((p) => p.id === id);
31
+
32
+ export type SourceInfo = { width: number; height: number; pixFmt?: string; colorRange?: string; hasAudio: boolean };
33
+
34
+ const even = (n: number) => Math.max(2, Math.round(n / 2) * 2);
35
+
36
+ // The output size: the short side becomes the preset's, the long side keeps the source's shape. A source smaller than the preset is never enlarged.
37
+ export function outputSize(src: { width: number; height: number }, shortSide: number): { width: number; height: number } {
38
+ const short = Math.min(src.width, src.height);
39
+ const k = Math.min(1, shortSide / short);
40
+ return { width: even(src.width * k), height: even(src.height * k) };
41
+ }
42
+
43
+ // Whether the source's pixels are full range. yuvj420p always is; otherwise the tag decides, and an untagged yuv420p file is taken as TV range.
44
+ export const isFullRange = (src: Pick<SourceInfo, "pixFmt" | "colorRange">): boolean => /^yuvj/.test(src.pixFmt ?? "") || src.colorRange === "pc" || src.colorRange === "jpeg";
45
+
46
+ // The filter chain. The range is converted here and nowhere else: converting twice lifts the blacks (#101010 becomes #1d1d1d).
47
+ export function videoFilter(src: SourceInfo, preset: ExportPreset): string {
48
+ const size = outputSize(src, preset.shortSide);
49
+ const range = isFullRange(src) ? "in_range=full:out_range=tv" : "in_range=tv:out_range=tv";
50
+ return [
51
+ `scale=${size.width}:${size.height}:flags=lanczos:${range}:in_color_matrix=bt709:out_color_matrix=bt709`,
52
+ "format=yuv420p",
53
+ // Command-line -color_trc / -color_primaries are dropped when a filter chain is present; setparams is what reaches the stream.
54
+ "setparams=range=tv:color_primaries=bt709:color_trc=bt709:colorspace=bt709",
55
+ ].join(",");
56
+ }
57
+
58
+ // The whole ffmpeg argument list. `-nostdin -y` first: a scripted run must never wait on an overwrite question.
59
+ export function exportArgs(input: string, output: string, src: SourceInfo, preset: ExportPreset): string[] {
60
+ const rate = preset.videoKbps !== undefined
61
+ ? ["-b:v", `${preset.videoKbps}k`]
62
+ : ["-crf", String(preset.crf ?? 20)];
63
+ const cap = preset.maxrateKbps ? ["-maxrate", `${preset.maxrateKbps}k`, "-bufsize", `${preset.bufsizeKbps ?? preset.maxrateKbps * 2}k`] : [];
64
+ return [
65
+ "-nostdin", "-y", "-v", "error", "-i", input,
66
+ "-map", "0:v:0", ...(src.hasAudio ? ["-map", "0:a:0"] : []),
67
+ "-vf", videoFilter(src, preset),
68
+ "-c:v", "libx264", "-preset", preset.x264Preset, "-profile:v", preset.profile, "-level:v", preset.level, "-pix_fmt", "yuv420p",
69
+ ...rate, ...cap,
70
+ "-color_range", "tv", "-colorspace", "bt709", "-color_trc", "bt709", "-color_primaries", "bt709",
71
+ "-x264-params", "colorprim=bt709:transfer=bt709:colormatrix=bt709:range=tv",
72
+ ...(src.hasAudio ? ["-c:a", "aac", "-b:a", `${preset.audioKbps}k`, "-ar", "48000", "-ac", "2"] : ["-an"]),
73
+ "-movflags", "+faststart", output,
74
+ ];
75
+ }
76
+
77
+ // What ffprobe says about a stream, as far as the checks need it.
78
+ export type ProbedStream = { codec_type?: string; codec_name?: string; profile?: string; level?: number; pix_fmt?: string; color_range?: string; color_space?: string; color_transfer?: string; color_primaries?: string; width?: number; height?: number; sample_rate?: string; nb_read_frames?: string; nb_frames?: string };
79
+
80
+ export type ExportCheck = { name: string; ok: boolean; found: string; want: string };
81
+
82
+ export type VerifyInput = {
83
+ preset: ExportPreset;
84
+ size: { width: number; height: number };
85
+ streams: ProbedStream[];
86
+ // Frames counted by decoding the output and the source.
87
+ frames: number; sourceFrames: number;
88
+ // Lines ffmpeg printed at -v error while decoding the whole file.
89
+ decodeErrors: number;
90
+ // Byte offsets of the two top-level boxes; -1 when one was not found.
91
+ moovAt: number; mdatAt: number;
92
+ bytes: number;
93
+ sourceHasAudio: boolean;
94
+ };
95
+
96
+ const PROFILE_NAME: Record<ExportPreset["profile"], string> = { main: "Main", high: "High" };
97
+
98
+ // Every property a phone needs, one row each. The export passes only when all of them do.
99
+ export function verifyExport(v: VerifyInput): ExportCheck[] {
100
+ const video = v.streams.find((s) => s.codec_type === "video");
101
+ const audio = v.streams.find((s) => s.codec_type === "audio");
102
+ const level = Math.round(Number(v.preset.level) * 10);
103
+ const row = (name: string, found: string | number | undefined, want: string | number, ok?: boolean): ExportCheck => ({ name, found: String(found ?? "missing"), want: String(want), ok: ok ?? String(found) === String(want) });
104
+ return [
105
+ row("file", v.bytes > 0 ? `${v.bytes} bytes` : "empty", "written, not empty", v.bytes > 0),
106
+ row("video codec", video?.codec_name, "h264"),
107
+ row("profile", video?.profile, PROFILE_NAME[v.preset.profile]),
108
+ row("level", video?.level, `at most ${level}`, video?.level !== undefined && video.level <= level),
109
+ row("pixel format", video?.pix_fmt, "yuv420p"),
110
+ row("colour range", video?.color_range, "tv"),
111
+ row("colour matrix", video?.color_space, "bt709"),
112
+ row("colour transfer", video?.color_transfer, "bt709"),
113
+ row("colour primaries", video?.color_primaries, "bt709"),
114
+ row("size", video ? `${video.width}x${video.height}` : undefined, `${v.size.width}x${v.size.height}`),
115
+ row("frames", v.frames, `${v.sourceFrames} (same as the source)`, v.frames === v.sourceFrames && v.frames > 0),
116
+ row("full decode", `${v.decodeErrors} error line${v.decodeErrors === 1 ? "" : "s"}`, "0 error lines", v.decodeErrors === 0),
117
+ row("index at the front (faststart)", v.moovAt >= 0 && v.mdatAt >= 0 ? `moov at ${v.moovAt}, mdat at ${v.mdatAt}` : "moov or mdat not found", "moov before mdat", v.moovAt >= 0 && v.mdatAt >= 0 && v.moovAt < v.mdatAt),
118
+ ...(v.sourceHasAudio ? [row("audio", audio ? `${audio.codec_name} ${audio.sample_rate} Hz` : undefined, "aac 48000 Hz", audio?.codec_name === "aac" && audio.sample_rate === "48000")] : []),
119
+ ];
120
+ }
121
+
122
+ // The offsets of the top-level `moov` and `mdat` boxes of an MP4, read from the box headers themselves (a search for the letters would also find them inside the data).
123
+ export function topLevelBoxes(read: (offset: number, length: number) => Uint8Array, size: number): { moovAt: number; mdatAt: number } {
124
+ let moovAt = -1, mdatAt = -1;
125
+ for (let at = 0; at + 8 <= size && (moovAt < 0 || mdatAt < 0);) {
126
+ const h = read(at, 16);
127
+ if (h.length < 8) break;
128
+ const view = new DataView(h.buffer, h.byteOffset, h.byteLength);
129
+ let length = view.getUint32(0);
130
+ const type = String.fromCharCode(h[4]!, h[5]!, h[6]!, h[7]!);
131
+ if (length === 1 && h.length >= 16) length = Number(view.getBigUint64(8));
132
+ else if (length === 0) length = size - at;
133
+ if (type === "moov") moovAt = at;
134
+ if (type === "mdat") mdatAt = at;
135
+ if (length < 8) break;
136
+ at += length;
137
+ }
138
+ return { moovAt, mdatAt };
139
+ }
140
+
141
+ const pad = (s: string, n: number) => s + " ".repeat(Math.max(0, n - s.length));
142
+
143
+ // The pass/fail table as plain text.
144
+ export function checkTable(checks: ExportCheck[]): string {
145
+ const a = Math.max(...checks.map((c) => c.name.length), 5), b = Math.max(...checks.map((c) => c.found.length), 5);
146
+ return [`${pad("check", a)} ${pad("found", b)} result`, ...checks.map((c) => `${pad(c.name, a)} ${pad(c.found, b)} ${c.ok ? "pass" : `FAIL (want ${c.want})`}`)].join("\n");
147
+ }
@@ -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
+ }