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.
@@ -1,5 +1,5 @@
1
1
  import { execFile } from "node:child_process";
2
- import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync } from "node:fs";
2
+ import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs";
3
3
  import { join, resolve } from "node:path";
4
4
  import { promisify } from "node:util";
5
5
  import type { Ctx, Result } from "../context";
@@ -14,7 +14,10 @@ import type { SearchRecord } from "./assets";
14
14
  import { libraryUse, shareAfterRender } from "./components";
15
15
  import { mediaUrls, serveDir } from "../project/serve";
16
16
  import { contactSheet } from "../render/contact-sheet";
17
- import { bundleProject, disposeBundle, ensureRenderBrowser, renderStills, renderVideo, writeEntry, type EnsureBrowser } from "../render/render";
17
+ import { bundleProject, disposeBundle, ensureRenderBrowser, mixCapturedAudio, renderStills, renderVideo, renderVideoRange, writeEntry, type EnsureBrowser } from "../render/render";
18
+ import { DEFAULT_CHUNK_FRAMES, countFrames, mergeAudioRecords, renderChunked, renderKey, withRetry, type CapturedAudio, type ChunkedResult } from "../render/chunked";
19
+ import { fullDecode, syncFile } from "../media/ffmpeg";
20
+ import { compareFilms, diffSummary, type DiffReport } from "./diff";
18
21
  import { MASTER_LUFS, MASTER_TRUE_PEAK_DB, masterLoudness, type Master, type Mastered } from "../render/master";
19
22
  import { detailLines, measureSound, soundAdvice, spokenWords, voiceAdvice, type SoundReport, type SpokenWord } from "../project/soundreport";
20
23
  import { soundNotes } from "../render/sound-notes";
@@ -98,6 +101,18 @@ function compositionNotes(project: Project, plan: ScenePlan, manifest?: AssetMan
98
101
 
99
102
  const failed = (errors: string[]): Result => ({ ok: false, data: { errors }, summary: `${errors.length} problem(s):\n- ${errors.join("\n- ")}` });
100
103
 
104
+ // Which sound plays when, as the last render registered it: the project path of each sound, and its start and end in seconds. Written
105
+ // beside the video so that a timeline (the studio's, a template's) can name each sound effect instead of guessing from the mix.
106
+ export const SOUND_MAP = "out/sounds.json";
107
+ const SOUND_RECORDS = "out/.sound-records.json";
108
+ export type SoundMapEntry = { path: string; start: number; end: number; volume: number };
109
+ function saveSoundMap(project: { writeJson(rel: string, value: unknown): void }, urls: Record<string, string>, records: { src: string; start: number; end: number; volume: number }[]): void {
110
+ const pathOf = new Map(Object.entries(urls).map(([path, url]) => [url, path]));
111
+ const r3 = (n: number) => Math.round(n * 1000) / 1000;
112
+ const map: SoundMapEntry[] = records.flatMap((r) => { const path = pathOf.get(r.src); return path ? [{ path, start: r3(r.start), end: r3(r.end), volume: r3(r.volume) }] : []; }).sort((a, b) => a.start - b.start);
113
+ try { project.writeJson(SOUND_MAP, map); } catch { /* the map is a convenience: a render never fails for it */ }
114
+ }
115
+
101
116
  export async function check(ctx: Ctx): Promise<Result> {
102
117
  const project = openProject(ctx.cwd);
103
118
  const { errors, manifest } = inspect(project);
@@ -278,17 +293,80 @@ export async function preview(ctx: Ctx, deps: { toJpeg?: (png: string, jpg: stri
278
293
  }
279
294
  }
280
295
 
281
- export async function render(ctx: Ctx, deps: { ensureBrowser?: EnsureBrowser; master?: Master; noShare?: boolean; measure?: (path: string, loudness?: number, words?: SpokenWord[]) => Promise<SoundReport> } = {}): Promise<Result> {
296
+ // The last render, kept beside the new one so that `reelkit diff` (or `render --guard previous`) can prove what a revision changed.
297
+ export const PREVIOUS_RENDER = "out/previous.mp4";
298
+ const CHUNK_DIR = ".reelkit/chunks";
299
+
300
+ export type RenderDeps = {
301
+ ensureBrowser?: EnsureBrowser; master?: Master; noShare?: boolean; measure?: (path: string, loudness?: number, words?: SpokenWord[]) => Promise<SoundReport>;
302
+ // Render in pieces of this many frames that survive a crash and are reused by the next run (true: the default size).
303
+ chunk?: number | boolean;
304
+ // Compare the new render with this film (a path, or "previous" for the last render) and fail when anything outside `allow` changed.
305
+ guard?: string; allow?: string; allowAudio?: string; threshold?: string | number;
306
+ // What a test may replace: the check that the written file is whole. Resolves to a problem, or undefined when the file is good.
307
+ verify?: (path: string, frames: number) => Promise<string | undefined>;
308
+ };
309
+
310
+ // A render is only reported when its file is on the disk, decodes from end to end and holds every frame.
311
+ export async function verifyRender(path: string, frames: number): Promise<string | undefined> {
312
+ if (!existsSync(path) || statSync(path).size === 0) return "the file was not written";
313
+ syncFile(path);
314
+ const decoded = await fullDecode(path);
315
+ if (decoded.errorLines.length) return `the file does not decode cleanly (${decoded.errorLines[0]})`;
316
+ if (decoded.frames !== frames) return `the file holds ${decoded.frames} frames instead of ${frames}`;
317
+ return undefined;
318
+ }
319
+
320
+ export async function render(ctx: Ctx, deps: RenderDeps = {}): Promise<Result> {
282
321
  const project = openProject(ctx.cwd);
283
322
  const { errors, manifest } = inspect(project);
284
323
  if (!manifest) return failed(errors);
285
324
  const path = "out/video.mp4";
325
+ // Keep the render about to be replaced: it is the only proof of what the film was before this change.
326
+ let keptPrevious = false;
327
+ if (project.exists(path) && statSync(project.path(path)).size > 0) {
328
+ try { copyFileSync(project.path(path), project.path(PREVIOUS_RENDER)); syncFile(project.path(PREVIOUS_RENDER)); keptPrevious = true; } catch { keptPrevious = false; }
329
+ }
330
+ const guardPath = deps.guard === undefined && deps.allow === undefined && deps.allowAudio === undefined ? undefined : !deps.guard || deps.guard === "previous" ? project.path(PREVIOUS_RENDER) : resolve(ctx.cwd, deps.guard);
331
+ if (guardPath && !existsSync(guardPath)) return failed([`The film to compare with, ${deps.guard && deps.guard !== "previous" ? deps.guard : PREVIOUS_RENDER}, does not exist. Render once first, or give a file with --guard.`]);
332
+ let chunked: ChunkedResult | undefined;
286
333
  try {
287
334
  await ensureBrowserWithNotice(ctx, deps.ensureBrowser);
288
- await withBundle(project, (serveUrl, urls) => renderVideo(serveUrl, { manifest, urls }, project.path(path), { uses3D: usesScene3D(project) }));
335
+ const options = { uses3D: usesScene3D(project) };
336
+ if (deps.chunk) {
337
+ const chunkFrames = typeof deps.chunk === "number" && deps.chunk > 0 ? Math.floor(deps.chunk) : DEFAULT_CHUNK_FRAMES;
338
+ const names = readdirSync(project.path("src")).filter((f) => f.endsWith(".tsx")).sort();
339
+ const sources = Object.fromEntries(names.map((n) => [n, readFileSync(project.path(join("src", n)), "utf8")]));
340
+ const media: Record<string, number> = {};
341
+ for (const src of Object.values(sources)) for (const m of src.matchAll(MEDIA_PATH)) if (project.exists(m[1]!)) { const st = statSync(project.path(m[1]!)); media[m[1]!] = st.size + Math.round(st.mtimeMs); }
342
+ const key = renderKey({ sources, manifest, media, chunkFrames });
343
+ const chunkDir = project.path(CHUNK_DIR);
344
+ chunked = await withBundle(project, (serveUrl, urls) => renderChunked(chunkDir, key, manifest.totalFrames, chunkFrames, project.path(path), {
345
+ renderRange: (from, to, out) => renderVideoRange(serveUrl, { manifest, urls }, out, from, to, options),
346
+ renderSound: async (out) => {
347
+ const lists = readdirSync(chunkDir).filter((f) => f.endsWith(".audio.json")).map((f) => JSON.parse(readFileSync(join(chunkDir, f), "utf8")) as CapturedAudio[]);
348
+ const merged = mergeAudioRecords(lists);
349
+ saveSoundMap(project, urls, merged);
350
+ if (!merged.length) return false;
351
+ await mixCapturedAudio(merged, out, manifest.totalFrames / manifest.fps);
352
+ return existsSync(out) && statSync(out).size > 44;
353
+ },
354
+ countFrames, log: ctx.log,
355
+ reuseExtra: (file) => existsSync(`${file}.audio.json`),
356
+ }));
357
+ } else {
358
+ // The browser can fail to start or drop its connection; that is no fault of the composition, so it gets another go.
359
+ await withRetry(() => withBundle(project, (serveUrl, urls) => renderVideo(serveUrl, { manifest, urls }, project.path(path), options, project.path(SOUND_RECORDS)).then(() => {
360
+ const file = project.path(SOUND_RECORDS);
361
+ if (existsSync(file)) { saveSoundMap(project, urls, JSON.parse(readFileSync(file, "utf8")) as { src: string; start: number; end: number; volume: number }[]); rmSync(file, { force: true }); }
362
+ })), { onRetry: (n, e) => ctx.log(`The browser failed (${(e instanceof Error ? e.message : String(e)).split("\n")[0]}); rendering again (attempt ${n + 1}).`) });
363
+ }
289
364
  } catch (e) {
290
- return failed([`Render failed: ${e instanceof Error ? e.message : String(e)}`]);
365
+ return failed([`Render failed: ${e instanceof Error ? e.message : String(e)}${deps.chunk ? " The finished pieces are kept: run the same command again to continue from them." : " For a long film, \`reelkit render --chunk\` keeps finished pieces and continues from them."}`]);
291
366
  }
367
+ const problem = await (deps.verify ?? verifyRender)(project.path(path), manifest.totalFrames).catch((e) => `it could not be checked (${e instanceof Error ? e.message : String(e)})`);
368
+ if (problem) return failed([`The render finished but ${problem}. Render again${deps.chunk ? "; finished pieces are reused" : ""}.`]);
369
+ if (chunked) rmSync(project.path(CHUNK_DIR), { recursive: true, force: true });
292
370
  const seconds = manifest.totalFrames / manifest.fps;
293
371
  // One last pass brings the whole programme to the loudness platforms play at. If it fails the render is still the render.
294
372
  let loudness: Mastered | undefined;
@@ -308,11 +386,24 @@ export async function render(ctx: Ctx, deps: { ensureBrowser?: EnsureBrowser; ma
308
386
  if (voiceless) soundLine = soundAdvice(sound);
309
387
  else if (sound.voice) soundLine = voiceAdvice(sound.voice);
310
388
  } catch { sound = undefined; }
389
+ // The guard: what changed against the film before, and whether any of it lies outside what was asked for. A failed guard shares nothing.
390
+ let guard: { report: DiffReport; ok: boolean; lines: string[] } | undefined;
391
+ if (guardPath) {
392
+ try {
393
+ const report = await compareFilms(guardPath, project.path(path), { allow: deps.allow ?? "", allowAudio: deps.allowAudio, threshold: deps.threshold });
394
+ guard = { report, ...diffSummary(report, true) };
395
+ } catch (e) { return failed([`The render is written, but it could not be compared with ${guardPath}: ${e instanceof Error ? e.message : String(e)}`]); }
396
+ }
397
+ const pieces = chunked ? [`Rendered in ${chunked.chunks} pieces (${chunked.reused} reused from an earlier run, ${chunked.rendered} rendered${chunked.retries ? `, ${chunked.retries} retr${chunked.retries === 1 ? "y" : "ies"}` : ""}).`] : [];
398
+ const kept = keptPrevious ? [`The render before this one is kept as ${PREVIOUS_RENDER}; \`reelkit diff ${PREVIOUS_RENDER} ${path} --allow <frames>\` shows what changed.`] : [];
399
+ if (guard && !guard.ok) {
400
+ return { ok: false, data: { path, seconds, guard: guard.report }, summary: [`Video: ${project.path(path)} was rendered, but it changed more than was allowed.`, ...guard.lines, "Nothing was shared. Fix the composition, or widen --allow if the change is wanted."].join("\n") };
401
+ }
311
402
  const share = await shareAfterRender(ctx, project, { noShare: deps.noShare });
312
403
  return {
313
- ok: true, data: { path, seconds, ...(loudness ? { loudness } : {}),
404
+ ok: true, data: { path, seconds, ...(guard ? { guard: guard.report } : {}), ...(chunked ? { chunked } : {}), ...(loudness ? { loudness } : {}),
314
405
  ...(sound ? { sound: { hitsPerSecond: sound.hitsPerSecond, swellsPerSecond: sound.swellsPerSecond, pictureChanges: sound.pictureChanges, changesWithHit: sound.changesWithHit, firstSoundSec: sound.firstSoundSec }, ...(sound.voice ? { voice: sound.voice } : {}) } : {}), ...(share.data ? { shared: share.data.shared.map((s) => s.name) } : {}) },
315
- summary: [`Video: ${project.path(path)} (${seconds.toFixed(1)}s, ${manifest.width}x${manifest.height}).${masterNote}`, ...(soundLine ? [soundLine] : []), ...share.lines].join("\n"),
406
+ summary: [`Video: ${project.path(path)} (${seconds.toFixed(1)}s, ${manifest.width}x${manifest.height}).${masterNote}`, ...pieces, ...(soundLine ? [soundLine] : []), ...(guard ? guard.lines : []), ...kept, ...share.lines].join("\n"),
316
407
  };
317
408
  }
318
409
 
@@ -0,0 +1,37 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import type { Ctx, Result } from "../context";
4
+ import { clockFromSource, describe, filmLength, parseKeep, pieces, validateClock, type Clock } from "../clock/clock";
5
+
6
+ export type ClockOpts = { keep?: string; opening?: string | number; film?: boolean; fps?: string | number };
7
+
8
+ // The clock of this project: from the flags, else clock.json, else constants in src/Video.tsx.
9
+ export function loadClock(cwd: string, opts: ClockOpts = {}): { clock: Clock; from: string } | undefined {
10
+ if (opts.keep) return { clock: { opening: Number(opts.opening ?? 0), keep: parseKeep(opts.keep) }, from: "--keep" };
11
+ const file = join(cwd, "clock.json");
12
+ if (existsSync(file)) {
13
+ const j = JSON.parse(readFileSync(file, "utf8")) as Partial<Clock>;
14
+ return { clock: { opening: Number(j.opening ?? 0), keep: (j.keep ?? []) as [number, number][] }, from: "clock.json" };
15
+ }
16
+ const video = join(cwd, "src", "Video.tsx");
17
+ if (existsSync(video)) { const clock = clockFromSource(readFileSync(video, "utf8")); if (clock) return { clock, from: "src/Video.tsx (SEGS)" }; }
18
+ return undefined;
19
+ }
20
+
21
+ // `reelkit clock [frames...]`: where a frame of the reference lands in the film, or (--film) which reference frame a film frame shows.
22
+ export async function clockCommand(ctx: Ctx, values: string[], opts: ClockOpts = {}): Promise<Result> {
23
+ let loaded;
24
+ try { loaded = loadClock(ctx.cwd, opts); } catch (e) { return { ok: false, summary: e instanceof Error ? e.message : String(e) }; }
25
+ if (!loaded) return { ok: false, summary: 'No clock found. Give the kept reference ranges with --keep 152-653,782-1028 (and --opening <frames>), or write clock.json: { "opening": 179, "keep": [[152, 653], [782, 1028]] }.' };
26
+ const problems = validateClock(loaded.clock);
27
+ if (problems.length) return { ok: false, summary: `The clock (${loaded.from}) is not valid:\n- ${problems.join("\n- ")}` };
28
+ const fps = Number(opts.fps ?? 30);
29
+ const nums = values.map(Number);
30
+ if (nums.some((n) => !Number.isFinite(n))) return { ok: false, summary: "Give frames as whole numbers, such as `reelkit clock 802 1581`." };
31
+ const table = pieces(loaded.clock).map((p) => `reference ${p.refFrom}-${p.refTo} -> film ${p.filmFrom}-${p.filmTo} (film = reference ${p.filmFrom - p.refFrom >= 0 ? "+" : "-"} ${Math.abs(p.filmFrom - p.refFrom)})`);
32
+ const lines = describe(loaded.clock, nums, opts.film ? "film" : "ref", fps);
33
+ return {
34
+ ok: true, data: { clock: loaded.clock, from: loaded.from, pieces: pieces(loaded.clock), filmFrames: filmLength(loaded.clock), answers: lines },
35
+ summary: [`Clock from ${loaded.from}: opening ${loaded.clock.opening} frames, film ${filmLength(loaded.clock)} frames. End frames are exclusive.`, ...table, ...(lines.length ? ["", ...lines] : [])].join("\n"),
36
+ };
37
+ }
@@ -0,0 +1,81 @@
1
+ import { existsSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import type { Ctx, Result } from "../context";
4
+ import { DEFAULT_AUDIO_DB, DEFAULT_THRESHOLD, DIFF_HEIGHT, DIFF_WIDTH, judgePicture, judgeSound, meanAbsDiff, parseAllow, rangesText, windowLevels, type PictureVerdict, type Range, type SoundVerdict } from "../diff/framediff";
5
+ import { grayFrames, monoSamples, videoFacts } from "../media/ffmpeg";
6
+
7
+ export type DiffOpts = { allow?: string; allowAudio?: string; threshold?: string | number; audioThreshold?: string | number; audio?: boolean };
8
+
9
+ const AUDIO_RATE = 8000;
10
+
11
+ // The difference of every frame of two films, decoded side by side as small grey pictures. Memory holds one film's small frames (about 58 KB each) at a time
12
+ // only as long as the other catches up, so the old film is decoded first into a list and the new one is compared as it streams.
13
+ export async function pictureDiffs(oldPath: string, newPath: string): Promise<{ diffs: number[]; old: number; new: number }> {
14
+ const old: Uint8Array[] = [];
15
+ await grayFrames(oldPath, DIFF_WIDTH, DIFF_HEIGHT, (f) => { old.push(f); });
16
+ const diffs: number[] = [];
17
+ const count = await grayFrames(newPath, DIFF_WIDTH, DIFF_HEIGHT, (f, i) => { if (i < old.length) diffs.push(meanAbsDiff(old[i]!, f)); });
18
+ return { diffs, old: old.length, new: count };
19
+ }
20
+
21
+ export type DiffReport = { fps: number; allow: Range[]; picture: PictureVerdict; sound?: SoundVerdict };
22
+
23
+ export async function compareFilms(oldPath: string, newPath: string, opts: DiffOpts = {}): Promise<DiffReport> {
24
+ const facts = await videoFacts(newPath);
25
+ const fps = facts.fps || 30;
26
+ const allow = parseAllow(opts.allow, fps);
27
+ const threshold = opts.threshold === undefined ? DEFAULT_THRESHOLD : Number(opts.threshold);
28
+ if (!Number.isFinite(threshold) || threshold < 0) throw new Error(`--threshold must be a number of grey levels, such as 1.0 (got "${opts.threshold}").`);
29
+ const { diffs, old, new: count } = await pictureDiffs(oldPath, newPath);
30
+ const picture = judgePicture(diffs, allow, threshold, { old, new: count });
31
+ let sound: SoundVerdict | undefined;
32
+ if (opts.audio !== false) {
33
+ const [a, b] = [await monoSamples(oldPath, AUDIO_RATE), await monoSamples(newPath, AUDIO_RATE)];
34
+ if (a && b) {
35
+ const db = opts.audioThreshold === undefined ? DEFAULT_AUDIO_DB : Number(opts.audioThreshold);
36
+ if (!Number.isFinite(db) || db <= 0) throw new Error(`--audio-threshold must be a level difference in dB, such as 1.5 (got "${opts.audioThreshold}").`);
37
+ // The sound has its own allowed ranges when given; otherwise the picture's ranges apply to it too.
38
+ sound = judgeSound(windowLevels(a, AUDIO_RATE), windowLevels(b, AUDIO_RATE), opts.allowAudio !== undefined ? parseAllow(opts.allowAudio, fps) : allow, fps, db);
39
+ }
40
+ }
41
+ return { fps, allow, picture, ...(sound ? { sound } : {}) };
42
+ }
43
+
44
+ const secs = (rs: { from: number; to: number }[]) => (rs.length ? rs.map((r) => `${r.from.toFixed(1)}-${r.to.toFixed(1)}s`).join(", ") : "none");
45
+
46
+ export function diffSummary(r: DiffReport, strict: boolean): { ok: boolean; lines: string[] } {
47
+ const p = r.picture;
48
+ const lines = [
49
+ `Picture: ${p.frames} frames compared at ${DIFF_WIDTH}x${DIFF_HEIGHT} grey, a frame counts as changed above a mean difference of ${p.threshold}.`,
50
+ `Changed frames: ${rangesText(p.changed)}.`,
51
+ `Largest difference on an unchanged frame: ${p.maxUnchanged}.`,
52
+ ];
53
+ let ok = true;
54
+ if (p.lengthDiffers) { lines.push(`The films differ in length: ${p.lengthDiffers.old} frames before, ${p.lengthDiffers.new} now.`); if (strict) ok = false; }
55
+ if (strict) {
56
+ lines.push(`Allowed: ${rangesText(r.allow)}.`);
57
+ if (p.outside.length) { ok = false; lines.push(`CHANGED OUTSIDE the allowed ranges: ${rangesText(p.outside)}.`); }
58
+ else lines.push("Nothing changed outside the allowed ranges.");
59
+ if (p.untouchedAllowed.length) lines.push(`Allowed but identical (a change that was asked for and is not there?): ${rangesText(p.untouchedAllowed)}.`);
60
+ }
61
+ if (r.sound) {
62
+ lines.push(`Sound: ${r.sound.windows} windows of 0.1 s, changed where the level moved by more than ${r.sound.thresholdDb} dB: ${secs(r.sound.changedSec)}.`);
63
+ if (strict) {
64
+ if (r.sound.outsideSec.length) { ok = false; lines.push(`SOUND CHANGED OUTSIDE the allowed ranges: ${secs(r.sound.outsideSec)}.`); }
65
+ else lines.push("No sound changed outside the allowed ranges.");
66
+ }
67
+ }
68
+ return { ok, lines };
69
+ }
70
+
71
+ // `reelkit diff <old> <new>`: which frames and which stretches of sound differ. With --allow it is a guard: any change outside the allowed ranges fails.
72
+ export async function diffCommand(ctx: Ctx, oldFile: string, newFile: string, opts: DiffOpts = {}): Promise<Result> {
73
+ const [a, b] = [resolve(ctx.cwd, oldFile), resolve(ctx.cwd, newFile)];
74
+ for (const [p, name] of [[a, oldFile], [b, newFile]] as const) if (!existsSync(p)) return { ok: false, summary: `${name} does not exist. Check the path.` };
75
+ let report: DiffReport;
76
+ try { ctx.log("Decoding both films..."); report = await compareFilms(a, b, opts); }
77
+ catch (e) { return { ok: false, summary: e instanceof Error ? e.message : String(e) }; }
78
+ const strict = opts.allow !== undefined || opts.allowAudio !== undefined;
79
+ const { ok, lines } = diffSummary(report, strict);
80
+ return { ok, data: report, summary: lines.join("\n") };
81
+ }
@@ -0,0 +1,51 @@
1
+ import { existsSync, mkdirSync, statSync } from "node:fs";
2
+ import { basename, dirname, extname, join, resolve } from "node:path";
3
+ import type { Ctx, Result } from "../context";
4
+ import { EXPORT_PRESETS, checkTable, exportArgs, outputSize, presetById, topLevelBoxes, verifyExport, type ExportCheck, type ExportPreset } from "../export/presets";
5
+ import { fullDecode, probeStreams, readBytes, syncFile, videoFacts } from "../media/ffmpeg";
6
+ import { tool } from "../project/refmeasure";
7
+
8
+ export type ExportOpts = { preset?: string; out?: string; list?: boolean; verifyOnly?: boolean };
9
+ // What a test may replace: the encode itself.
10
+ export type ExportDeps = { encode?: (args: string[]) => Promise<void> };
11
+
12
+ export const defaultExportPath = (input: string, preset: ExportPreset) => join(dirname(input), `${basename(input, extname(input))}.${preset.id}.mp4`);
13
+
14
+ // Measures an encoded file against what its preset promises.
15
+ export async function verifyFile(output: string, source: string, preset: ExportPreset): Promise<ExportCheck[]> {
16
+ const bytes = existsSync(output) ? statSync(output).size : 0;
17
+ if (!bytes) return verifyExport({ preset, size: { width: 0, height: 0 }, streams: [], frames: 0, sourceFrames: -1, decodeErrors: 1, moovAt: -1, mdatAt: -1, bytes: 0, sourceHasAudio: false });
18
+ syncFile(output);
19
+ const src = await videoFacts(source);
20
+ const [{ streams }, decoded, sourceDecoded] = [await probeStreams(output), await fullDecode(output), source === output ? undefined : await fullDecode(source)];
21
+ const file = readBytes(output);
22
+ let boxes;
23
+ try { boxes = topLevelBoxes(file.read, file.size); } finally { file.close(); }
24
+ return verifyExport({ preset, size: outputSize(src, preset.shortSide), streams, frames: decoded.frames, sourceFrames: sourceDecoded?.frames ?? decoded.frames, decodeErrors: decoded.errorLines.length, ...boxes, bytes, sourceHasAudio: src.hasAudio });
25
+ }
26
+
27
+ // `reelkit export [file]`: a delivery copy of a finished render that a phone plays, then proof that it is one.
28
+ export async function exportCommand(ctx: Ctx, file: string | undefined, opts: ExportOpts = {}, deps: ExportDeps = {}): Promise<Result> {
29
+ if (opts.list) return { ok: true, data: { presets: EXPORT_PRESETS }, summary: EXPORT_PRESETS.map((p) => `${p.id}: ${p.purpose}`).join("\n") };
30
+ const input = resolve(ctx.cwd, file ?? "out/video.mp4");
31
+ if (!existsSync(input)) return { ok: false, summary: `${file ?? "out/video.mp4"} does not exist. ${file ? "Check the path." : "Run `reelkit render` first, or give the file."}` };
32
+ const preset = presetById(opts.preset ?? "iphone");
33
+ if (!preset) return { ok: false, summary: `Unknown preset "${opts.preset}". Use one of: ${EXPORT_PRESETS.map((p) => p.id).join(", ")}.` };
34
+ const output = opts.verifyOnly ? input : resolve(ctx.cwd, opts.out ?? defaultExportPath(input, preset));
35
+ if (!opts.verifyOnly) {
36
+ if (output === input) return { ok: false, summary: "The output would overwrite the input. Give another path with --out." };
37
+ const src = await videoFacts(input);
38
+ mkdirSync(dirname(output), { recursive: true });
39
+ ctx.log(`Encoding ${preset.id}: ${outputSize(src, preset.shortSide).width}x${outputSize(src, preset.shortSide).height}, ${preset.profile} profile level ${preset.level}...`);
40
+ try { await (deps.encode ?? (async (args) => { await tool("ffmpeg", args); }))(exportArgs(input, output, src, preset)); }
41
+ catch (e) { return { ok: false, summary: `The encode failed: ${(e instanceof Error ? e.message : String(e)).trim().split("\n").at(-1)}` }; }
42
+ }
43
+ ctx.log("Verifying: tags, full decode, frame count, index position...");
44
+ const checks = await verifyFile(output, input, preset);
45
+ const ok = checks.every((c) => c.ok);
46
+ const size = existsSync(output) ? `${(statSync(output).size / 1e6).toFixed(1)} MB` : "missing";
47
+ return {
48
+ ok, data: { path: output, preset: preset.id, bytes: existsSync(output) ? statSync(output).size : 0, checks },
49
+ summary: `${opts.verifyOnly ? "Checked" : "Export"}: ${output} (${preset.id}, ${size})\n${checkTable(checks)}\n${ok ? "All checks pass." : `${checks.filter((c) => !c.ok).length} check(s) FAILED: do not send this file.`}`,
50
+ };
51
+ }
@@ -0,0 +1,94 @@
1
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
2
+ import { join, resolve } from "node:path";
3
+ import type { Ctx, Result } from "../context";
4
+ import { loadClock } from "./clock";
5
+ import { meanAbsDiff } from "../diff/framediff";
6
+ import { edgeHits, flashes, frameStat, persistentEdgeHits, type EdgeFinding, type EdgeHit, type Flash, type FrameStat } from "../lint/pixel-rules";
7
+ import { nestedSequenceFindings, niqqudFindings, rtlFindings, shotFindings, type Finding } from "../lint/source-rules";
8
+ import { grayFrames, videoFacts } from "../media/ffmpeg";
9
+
10
+ export type LintOpts = { video?: boolean; minFrames?: string | number; from?: string | number; to?: string | number; allowNiqqud?: boolean };
11
+
12
+ const LINT_LONG_SIDE = 480;
13
+ const THUMB = 5;
14
+
15
+ // Each frame shrunk by whole blocks, small enough to keep every one in memory for the flash measurement.
16
+ function thumb(frame: Uint8Array, w: number, h: number): Uint8Array {
17
+ const tw = Math.floor(w / THUMB), th = Math.floor(h / THUMB);
18
+ const out = new Uint8Array(tw * th);
19
+ for (let y = 0; y < th; y++) for (let x = 0; x < tw; x++) {
20
+ let sum = 0;
21
+ for (let dy = 0; dy < THUMB; dy++) for (let dx = 0; dx < THUMB; dx++) sum += frame[(y * THUMB + dy) * w + x * THUMB + dx]!;
22
+ out[y * tw + x] = Math.round(sum / (THUMB * THUMB));
23
+ }
24
+ return out;
25
+ }
26
+
27
+ export type PictureLint = { frames: number; firstFrame: number; edges: EdgeFinding[]; flashes: Flash[] };
28
+
29
+ export async function lintPicture(path: string, opts: { minFrames?: number; from?: number; to?: number } = {}): Promise<PictureLint> {
30
+ const facts = await videoFacts(path);
31
+ const k = LINT_LONG_SIDE / Math.max(facts.width, facts.height);
32
+ const w = Math.round((facts.width * k) / 2) * 2, h = Math.round((facts.height * k) / 2) * 2;
33
+ const perFrame: EdgeHit[][] = [], thumbs: Uint8Array[] = [], stats: FrameStat[] = [], toPrev: number[] = [];
34
+ const frames = await grayFrames(path, w, h, (f) => {
35
+ perFrame.push(edgeHits(f, w, h));
36
+ const t = thumb(f, w, h);
37
+ toPrev.push(thumbs.length ? meanAbsDiff(thumbs.at(-1)!, t) : 0);
38
+ thumbs.push(t); stats.push(frameStat(t));
39
+ }, { from: opts.from, to: opts.to });
40
+ const first = opts.from ?? 0;
41
+ return {
42
+ frames, firstFrame: first,
43
+ edges: persistentEdgeHits(perFrame, opts.minFrames ?? 4, first),
44
+ flashes: flashes({ toPrev, stats, across: (a, b) => meanAbsDiff(thumbs[a]!, thumbs[b]!) }).map((f) => ({ ...f, from: f.from + first, to: f.to + first })),
45
+ };
46
+ }
47
+
48
+ // The source rules for every component file of a project.
49
+ export function lintSources(dir: string, opts: { allowNiqqud?: boolean } = {}): Finding[] {
50
+ const srcDir = join(dir, "src");
51
+ if (!existsSync(srcDir)) return [];
52
+ const config = existsSync(join(dir, "reelkit.json")) ? (JSON.parse(readFileSync(join(dir, "reelkit.json"), "utf8")) as { lint?: { allowNiqqud?: boolean } }) : {};
53
+ const allowNiqqud = opts.allowNiqqud ?? config.lint?.allowNiqqud ?? false;
54
+ const scenes = existsSync(join(dir, "manifest.json")) ? (JSON.parse(readFileSync(join(dir, "manifest.json"), "utf8")) as { scenes?: { id: string; startFrame: number; durationFrames: number }[] }).scenes : undefined;
55
+ let clock;
56
+ try { clock = loadClock(dir)?.clock; } catch { clock = undefined; }
57
+ const out: Finding[] = [];
58
+ for (const name of readdirSync(srcDir).filter((f) => f.endsWith(".tsx")).sort()) {
59
+ const source = readFileSync(join(srcDir, name), "utf8");
60
+ out.push(...rtlFindings(source, name), ...(allowNiqqud ? [] : niqqudFindings(source, name)), ...nestedSequenceFindings(source, name));
61
+ if (clock) out.push(...shotFindings(source, name, clock, { scenes }));
62
+ }
63
+ return out;
64
+ }
65
+
66
+ const edgeLine = (e: EdgeFinding) => `frames ${e.from}-${e.to}: strokes cut by the ${e.edge} edge, about ${Math.round(e.at * 100)}% along it`;
67
+
68
+ // `reelkit lint [file]`: advice from the source and from the rendered film. It never fails a build; it lists what to look at.
69
+ export async function lintCommand(ctx: Ctx, file: string | undefined, opts: LintOpts = {}): Promise<Result> {
70
+ const findings = lintSources(ctx.cwd, { allowNiqqud: opts.allowNiqqud });
71
+ const lines: string[] = [];
72
+ const byRule = new Map<string, Finding[]>();
73
+ for (const f of findings) byRule.set(f.rule, [...(byRule.get(f.rule) ?? []), f]);
74
+ lines.push(findings.length ? `Source: ${findings.length} finding(s).` : "Source: nothing found.");
75
+ for (const [rule, list] of byRule) lines.push(`[${rule}]`, ...list.map((f) => `- ${f.file} line ${f.line}: ${f.message}`));
76
+ let picture: PictureLint | undefined;
77
+ const path = resolve(ctx.cwd, file ?? "out/video.mp4");
78
+ if (opts.video !== false) {
79
+ if (!existsSync(path)) lines.push(`Picture: not checked, ${file ?? "out/video.mp4"} does not exist. Render first, or give a film: \`reelkit lint <file.mp4>\`.`);
80
+ else {
81
+ ctx.log("Reading the rendered frames...");
82
+ try {
83
+ picture = await lintPicture(path, { minFrames: opts.minFrames === undefined ? undefined : Number(opts.minFrames), from: opts.from === undefined ? undefined : Number(opts.from), to: opts.to === undefined ? undefined : Number(opts.to) });
84
+ lines.push(`Picture: ${picture.frames} frames of ${file ?? "out/video.mp4"} read.`);
85
+ lines.push(picture.edges.length ? `[text-at-edge] ${picture.edges.length} place(s) where something that looks like lettering is cut by an edge of the frame. Look at each; move or shrink the text if it is a word:` : "[text-at-edge] nothing that looks like lettering is cut by an edge.");
86
+ lines.push(...picture.edges.map((e) => `- ${edgeLine(e)}`));
87
+ lines.push(picture.flashes.length ? `[flash-over-shot] ${picture.flashes.length} flash(es) of 1 to 3 frames cover a shot that continues after them. A caption or a spoken word on screen is hidden for those frames: check that none is.` : "[flash-over-shot] no flash covers a held shot.");
88
+ lines.push(...picture.flashes.map((f) => `- frames ${f.from}-${f.to} (${f.kind === "fill" ? "a plain fill" : "another picture"})`));
89
+ } catch (e) { lines.push(`Picture: could not be read (${e instanceof Error ? e.message : String(e)}).`); }
90
+ }
91
+ }
92
+ lines.push("These are measurements, not rules: lettering on a photograph is not seen, and a pattern can look like lettering. Lettering on a flat or gently graded plate is. Open the frames named.");
93
+ return { ok: true, data: { source: findings, ...(picture ? { picture } : {}) }, summary: lines.join("\n") };
94
+ }