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,217 @@
1
+ import { execFile } from "node:child_process";
2
+ import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { basename, join, resolve } from "node:path";
5
+ import { promisify } from "node:util";
6
+ import { uploadTo } from "../api/client";
7
+ import { client, type Ctx, type Result } from "../context";
8
+ import { MAX_TEMPLATE_FILES, MAX_TEMPLATE_SOURCES_BYTES, MAX_TEMPLATE_THUMBS, MAX_TEMPLATE_THUMB_BYTES, MAX_UPLOAD_BYTES, TEMPLATE_BUNDLE_TYPE, type Template, type TemplateFile, type TemplateFilm } from "../contract";
9
+ import type { AssetManifest, ScenePlan } from "../pipeline/schema";
10
+ import type { MusicRecord } from "../project/music";
11
+ import { FILES, openProject, Project } from "../project/project";
12
+ import { SOUND_MAP, type SoundMapEntry } from "./build";
13
+ import { decodeMono } from "../project/refmeasure";
14
+ import { AUDIO_RATE, hitTimes, swells } from "../project/soundreport";
15
+
16
+ const sh = promisify(execFile);
17
+
18
+ // A template is a whole project kept in its owner's private files on the server, to continue later or to start a new video from.
19
+ // What is sent: the project folder as one archive (the plan, the composition, and every file under assets/, including the user's own
20
+ // pictures, footage and the recorded voice), the rendered video when there is one, and a small picture of each scene. What is left on
21
+ // this machine: out/ (renders and previews), refs/ (reference videos), caches and node_modules.
22
+ export const TEMPLATE_SKIP = ["out", "refs", ".reelkit", "node_modules", ".git", ".DS_Store"];
23
+ const VIDEO = "out/video.mp4";
24
+ // Where a project remembers the template it was cloned from, and the last one it was pushed as. Never part of a bundle.
25
+ const LINK = ".reelkit/template.json";
26
+ const TEMPLATE_ID = /^tpl-[a-z0-9]{12}$/;
27
+
28
+ const mb = (bytes: number) => `${(bytes / 1_048_576).toFixed(bytes < 10_485_760 ? 1 : 0)} MB`;
29
+ const clock = (sec: number) => `${Math.floor(sec / 60)}:${String(Math.round(sec % 60)).padStart(2, "0")}`;
30
+
31
+ // The timeline as a page can draw it, from the manifest (the real timings) and the plan (the words). Times are in frames of the film;
32
+ // a scene's `words` and the music's `beats` are in seconds.
33
+ export function filmOf(project: Project): TemplateFilm | undefined {
34
+ const manifest = project.readJsonOr<AssetManifest | undefined>(FILES.manifest, undefined);
35
+ const plan = project.readJsonOr<ScenePlan | undefined>(FILES.plan, undefined);
36
+ if (!manifest?.scenes?.length || !plan) return undefined;
37
+ const byId = new Map(plan.scenes.map((s) => [s.id, s]));
38
+ const music = project.readJsonOr<MusicRecord | undefined>(FILES.music, undefined);
39
+ const seconds = manifest.totalFrames / manifest.fps;
40
+ const cut = (s: string, max: number) => (s.length > max ? s.slice(0, max) : s);
41
+ return {
42
+ aspect: plan.aspect, fps: manifest.fps, width: manifest.width, height: manifest.height, totalFrames: Math.round(manifest.totalFrames),
43
+ scenes: manifest.scenes.slice(0, 200).map((m) => {
44
+ const p = byId.get(m.id);
45
+ const narration = p?.narration?.trim();
46
+ const text = (p?.onScreenText ?? []).map((t) => cut(t, 400)).slice(0, 20);
47
+ return {
48
+ id: cut(m.id, 80), start: Math.max(0, Math.round(m.startFrame)), frames: Math.max(0, Math.round(m.durationFrames)),
49
+ ...(p?.treatment ? { treatment: cut(p.treatment, 60) } : {}), ...(narration ? { narration: cut(narration, 4000) } : {}),
50
+ ...(text.length ? { text } : {}), ...(m.words.length ? { words: m.words.slice(0, 600).map((w) => Math.max(0, Math.round(w.startSec * 1000) / 1000)) } : {}),
51
+ };
52
+ }),
53
+ ...(music ? { music: { title: cut(music.title, 200), seconds: music.durationSec, ...(music.bpm ? { bpm: music.bpm } : {}), beats: music.beats.filter((b) => b >= 0 && b <= seconds).slice(0, 4000) } } : {}),
54
+ voice: plan.voice !== "none",
55
+ ...(manifest.captions ? { captions: manifest.captions } : {}),
56
+ ...cuesOf(project, manifest, music?.key),
57
+ };
58
+ }
59
+
60
+ // The sound effects by name, from what the last render recorded (out/sounds.json): every sound that is not the music or a scene's
61
+ // voice, with the title it has in the library when it was pulled from there, or its file name.
62
+ function cuesOf(project: Project, manifest: AssetManifest, musicKey: string | undefined): Pick<TemplateFilm, "cues"> {
63
+ const map = project.readJsonOr<SoundMapEntry[]>(SOUND_MAP, []);
64
+ if (!Array.isArray(map) || !map.length) return {};
65
+ const pulled = Object.values(project.readJsonOr<Record<string, { path?: string; kind?: string; title?: string; meta?: { durationSec?: number } }>>(FILES.library, {}));
66
+ const voice = new Set(manifest.scenes.map((s) => s.voiceoverKey).filter(Boolean));
67
+ const cues = map.filter((e) => e && typeof e.path === "string" && e.path !== musicKey && e.path !== manifest.music?.key && !voice.has(e.path)).flatMap((e) => {
68
+ const lib = pulled.find((p) => p.path === e.path);
69
+ if (lib?.kind === "music") return [];
70
+ const title = (lib?.title ?? basename(e.path).replace(/\.[a-z0-9]+$/i, "")).slice(0, 200);
71
+ // A sound is registered until the end of the stretch it sits in, which is often the rest of the film: its own length is the file's.
72
+ const own = typeof lib?.meta?.durationSec === "number" && lib.meta.durationSec > 0 ? lib.meta.durationSec : 0;
73
+ const seconds = Math.max(0, Math.round(Math.min(own || 0, Math.max(0, e.end - e.start)) * 1000) / 1000);
74
+ return [{ at: Math.max(0, e.start), seconds, title, ...(lib?.kind ? { kind: lib.kind.slice(0, 24) } : {}) }];
75
+ }).slice(0, 1500);
76
+ return cues.length ? { cues } : {};
77
+ }
78
+
79
+ // One small JPEG per scene, taken from the middle of it in the rendered video. A scene whose frame cannot be read gets none, and the
80
+ // scenes after it still do: the list is in scene order, cut short at the first failure so that positions keep their meaning.
81
+ async function thumbsOf(video: string, film: TemplateFilm, dir: string): Promise<Uint8Array[]> {
82
+ const out: Uint8Array[] = [];
83
+ for (const [i, scene] of film.scenes.slice(0, MAX_TEMPLATE_THUMBS).entries()) {
84
+ const at = (scene.start + scene.frames / 2) / film.fps;
85
+ const file = join(dir, `thumb-${i}.jpg`);
86
+ try {
87
+ await sh("ffmpeg", ["-nostdin", "-v", "error", "-y", "-ss", at.toFixed(3), "-i", video, "-frames:v", "1", "-vf", "scale=320:-2", "-q:v", "7", file]);
88
+ const bytes = readFileSync(file);
89
+ if (!bytes.length || bytes.length > MAX_TEMPLATE_THUMB_BYTES) break;
90
+ out.push(new Uint8Array(bytes));
91
+ } catch { break; }
92
+ }
93
+ return out;
94
+ }
95
+
96
+ const TEXT_FILE = /\.(tsx?|jsx?|mjs|cjs|json|md|txt|css|html|svg|ya?ml)$/i;
97
+ const MAX_SOURCE_FILE = 200_000;
98
+
99
+ // What is in the bundle: every file's path and size, and the text of the small text files (the composition, the plan, the records), as
100
+ // one JSON object. The website lists the first and opens the second; media files are listed with their size only.
101
+ export function filesOf(dir: string): { files: TemplateFile[]; sources: Uint8Array | undefined } {
102
+ const files: TemplateFile[] = [];
103
+ const texts: Record<string, string> = {};
104
+ let room = MAX_TEMPLATE_SOURCES_BYTES - 1024;
105
+ const walk = (rel: string) => {
106
+ for (const entry of readdirSync(join(dir, rel), { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
107
+ if (!rel && TEMPLATE_SKIP.includes(entry.name)) continue;
108
+ if (entry.name === ".DS_Store") continue;
109
+ const path = rel ? `${rel}/${entry.name}` : entry.name;
110
+ if (entry.isDirectory()) { walk(path); continue; }
111
+ if (!entry.isFile() || files.length >= MAX_TEMPLATE_FILES || path.length > 400) continue;
112
+ const bytes = statSync(join(dir, path)).size;
113
+ files.push({ path, bytes });
114
+ if (TEXT_FILE.test(entry.name) && bytes <= MAX_SOURCE_FILE && bytes * 1.2 < room) {
115
+ const text = readFileSync(join(dir, path), "utf8");
116
+ if (!text.includes("\u0000")) { texts[path] = text; room -= Buffer.byteLength(JSON.stringify(text)) + path.length + 8; }
117
+ }
118
+ }
119
+ };
120
+ walk("");
121
+ const sources = Object.keys(texts).length ? new Uint8Array(Buffer.from(JSON.stringify(texts))) : undefined;
122
+ return { files, sources: sources && sources.length <= MAX_TEMPLATE_SOURCES_BYTES ? sources : undefined };
123
+ }
124
+
125
+ const readLink = (project: Project): { id?: string; from?: string } => { try { return project.readJsonOr<{ id?: string; from?: string }>(LINK, {}); } catch { return {}; } };
126
+
127
+ export async function templatePush(ctx: Ctx, opts: { name?: string }): Promise<Result> {
128
+ const project = openProject(ctx.cwd);
129
+ const film = filmOf(project);
130
+ if (!film) return { ok: false, summary: "This project has no timeline yet. Make its assets and run `reelkit check` (or `reelkit render`) first, then push it." };
131
+ const name = (opts.name ?? project.config().name ?? basename(resolve(ctx.cwd))).trim().slice(0, 80);
132
+ if (!name) return { ok: false, summary: "Give the template a name: `reelkit template push --name \"Launch film\"`." };
133
+ const plan = project.readJsonOr<ScenePlan | undefined>(FILES.plan, undefined);
134
+ const work = mkdtempSync(join(tmpdir(), "rk-template-"));
135
+ try {
136
+ const bundle = join(work, "bundle.tgz");
137
+ ctx.log("Packing the project.");
138
+ await sh("tar", ["-czf", bundle, ...TEMPLATE_SKIP.flatMap((x) => ["--exclude", `./${x}`]), "-C", project.dir, "."], { maxBuffer: 1 << 24 });
139
+ const bundleBytes = statSync(bundle).size;
140
+ if (bundleBytes > MAX_UPLOAD_BYTES) return { ok: false, summary: `The project packs to ${mb(bundleBytes)}, over the ${mb(MAX_UPLOAD_BYTES)} a template may be. Remove large files under assets/ that the video does not use and push again.` };
141
+ const video = project.exists(VIDEO) ? project.path(VIDEO) : undefined;
142
+ const videoBytes = video ? statSync(video).size : 0;
143
+ if (videoBytes > MAX_UPLOAD_BYTES) return { ok: false, summary: `The rendered video is ${mb(videoBytes)}, over the ${mb(MAX_UPLOAD_BYTES)} limit. Export a smaller one with \`reelkit export\` or push without a render.` };
144
+ const thumbs = video ? await thumbsOf(video, film, work) : [];
145
+ // The sound effects are placed by the composition's code, so where they land is measured from the render itself.
146
+ if (video) {
147
+ try {
148
+ const samples = await decodeMono(video, AUDIO_RATE);
149
+ const r3 = (n: number) => Math.round(n * 1000) / 1000;
150
+ film.hits = hitTimes(samples, AUDIO_RATE).slice(0, 3000).map(r3);
151
+ film.swells = swells(samples, AUDIO_RATE).slice(0, 600).map((w) => ({ start: r3(w.start), length: r3(w.length) }));
152
+ } catch { /* a render with no sound, or one that cannot be read: the timeline has no sound-effects row */ }
153
+ }
154
+ const link = readLink(project);
155
+ const { files, sources } = filesOf(project.dir);
156
+ const api = client(ctx);
157
+ const started = await api("templateStart", {
158
+ name, ...(plan?.title ? { title: plan.title.slice(0, 200) } : {}), bundleBytes, ...(videoBytes ? { videoBytes } : {}),
159
+ thumbBytes: thumbs.map((t) => t.length), film, ...(link.from && TEMPLATE_ID.test(link.from) ? { from: link.from } : {}),
160
+ files, ...(sources ? { sourcesBytes: sources.length } : {}),
161
+ });
162
+ ctx.log(`Uploading ${mb(bundleBytes + videoBytes)} to your private files.`);
163
+ await uploadTo(started.bundleUrl, new Uint8Array(readFileSync(bundle)), TEMPLATE_BUNDLE_TYPE);
164
+ if (video && started.videoUrl) await uploadTo(started.videoUrl, new Uint8Array(readFileSync(video)), "video/mp4");
165
+ if (sources && started.sourcesUrl) await uploadTo(started.sourcesUrl, sources, "application/json");
166
+ for (const [i, url] of started.thumbUrls.entries()) if (thumbs[i]) await uploadTo(url, thumbs[i]!, "image/jpeg");
167
+ const { template } = await api("templateCommit", { id: started.id });
168
+ project.writeJson(LINK, { ...link, id: template.id });
169
+ return {
170
+ ok: true, data: { template },
171
+ summary: `Saved the template "${template.name}" as ${template.id} in your private files: the project (${mb(bundleBytes)}, with your own files under assets/)${videoBytes ? `, the rendered video (${mb(videoBytes)})` : ", with no rendered video (render first to keep one)"} and ${thumbs.length} scene picture${thumbs.length === 1 ? "" : "s"}. Only you can see it. Start from it anywhere with \`reelkit template clone ${template.id}\`.`,
172
+ };
173
+ } finally { rmSync(work, { recursive: true, force: true }); }
174
+ }
175
+
176
+ const line = (t: Template) => `${t.id} ${t.name} ${t.aspect} ${clock(t.durationSec)} ${t.scenes} scene${t.scenes === 1 ? "" : "s"} ${mb(t.bundleBytes + (t.videoBytes ?? 0))} ${t.createdAt.slice(0, 10)}${t.from ? ` from ${t.from}` : ""}`;
177
+
178
+ export async function templateList(ctx: Ctx): Promise<Result> {
179
+ const { templates } = await client(ctx)("templateList", {});
180
+ return { ok: true, data: { templates }, summary: templates.length ? `${templates.map(line).join("\n")}\nClone one with \`reelkit template clone <id>\`.` : "You have no templates yet. In a project, run `reelkit template push`." };
181
+ }
182
+
183
+ // Entries of an archive that would land outside the folder it is unpacked into are refused before anything is written.
184
+ const unsafe = (entry: string) => entry.startsWith("/") || /^[A-Za-z]:/.test(entry) || entry.split(/[\\/]/).includes("..");
185
+
186
+ export async function templateClone(ctx: Ctx, id: string, dirArg?: string): Promise<Result> {
187
+ if (!TEMPLATE_ID.test(id)) return { ok: false, summary: `"${id}" is not a template id. List yours with \`reelkit template list\`.` };
188
+ const got = await client(ctx)("templateGet", { id });
189
+ const slug = got.template.name.toLowerCase().replace(/[^a-z0-9֐-׿]+/g, "-").replace(/^-+|-+$/g, "") || id;
190
+ const dir = resolve(ctx.cwd, dirArg ?? slug);
191
+ if (existsSync(dir) && readdirSync(dir).length) return { ok: false, summary: `${dir} already has files in it. Give another folder: \`reelkit template clone ${id} <folder>\`.` };
192
+ const work = mkdtempSync(join(tmpdir(), "rk-template-"));
193
+ try {
194
+ const res = await fetch(got.bundleUrl);
195
+ if (!res.ok) return { ok: false, summary: `The template could not be downloaded (${res.status}). Run the command again.` };
196
+ const bundle = join(work, "bundle.tgz");
197
+ writeFileSync(bundle, new Uint8Array(await res.arrayBuffer()));
198
+ const { stdout } = await sh("tar", ["-tzf", bundle], { maxBuffer: 1 << 26 });
199
+ const bad = stdout.split("\n").filter(Boolean).find(unsafe);
200
+ if (bad) return { ok: false, summary: `The template holds a path that leaves its folder (${bad}), so nothing was unpacked.` };
201
+ mkdirSync(dir, { recursive: true });
202
+ await sh("tar", ["-xzf", bundle, "-C", dir], { maxBuffer: 1 << 24 });
203
+ if (!existsSync(join(dir, FILES.config))) return { ok: false, summary: `The template unpacked into ${dir}, but it holds no ${FILES.config}: it is not a Reelkit project.` };
204
+ // The new folder is a project of its own: what is pushed from it is a new template that remembers where it came from.
205
+ new Project(dir).writeJson(LINK, { from: id });
206
+ return {
207
+ ok: true, data: { dir, template: got.template },
208
+ summary: `Cloned "${got.template.name}" into ${dir}: the plan, the composition and its assets. It is a copy of its own, so changing it does not change the template. Next: \`cd ${dirArg ?? slug}\`, then \`reelkit check\` and \`reelkit preview\`. \`reelkit template push\` there saves it as a new template.`,
209
+ };
210
+ } finally { rmSync(work, { recursive: true, force: true }); }
211
+ }
212
+
213
+ export async function templateDelete(ctx: Ctx, id: string): Promise<Result> {
214
+ if (!TEMPLATE_ID.test(id)) return { ok: false, summary: `"${id}" is not a template id. List yours with \`reelkit template list\`.` };
215
+ await client(ctx)("templateDelete", { id });
216
+ return { ok: true, data: { id }, summary: `Deleted the template ${id} from your private files. Projects cloned from it are not touched.` };
217
+ }
@@ -172,6 +172,49 @@ export const MeSchema = z.object({
172
172
  });
173
173
 
174
174
  const Empty = z.object({});
175
+ // ── Templates ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
176
+ // A template is a whole video project kept in its owner's private files: the bundle (the project folder as a .tgz, without out/, refs/
177
+ // and caches), the rendered video when there is one, a small picture per scene, and `film`, a summary of the timeline that a page can
178
+ // draw without opening the bundle. Only its owner can list, read, clone or delete it. Nothing here reaches the shared library.
179
+ export const MAX_TEMPLATES = 20;
180
+ export const MAX_TEMPLATE_THUMBS = 60;
181
+ export const MAX_TEMPLATE_THUMB_BYTES = 200_000;
182
+ export const TEMPLATE_BUNDLE_TYPE = "application/gzip";
183
+ // The bundle's file list, and the text of its small text files (one JSON object, path to text), so a page can show the project's
184
+ // files and open the source ones without unpacking the bundle.
185
+ export const MAX_TEMPLATE_FILES = 3000;
186
+ export const MAX_TEMPLATE_SOURCES_BYTES = 3_145_728;
187
+ export const TemplateFileSchema = z.object({ path: z.string().min(1).max(400), bytes: z.number().int().min(0) });
188
+ export type TemplateFile = z.infer<typeof TemplateFileSchema>;
189
+ const TemplateId = z.string().regex(/^tpl-[a-z0-9]{12}$/);
190
+ const frames = z.number().int().min(0).max(1_000_000);
191
+ export const TemplateFilmSchema = z.object({
192
+ aspect: z.string().max(8), fps: z.number().min(1).max(120), width: z.number().int().min(16).max(8192), height: z.number().int().min(16).max(8192), totalFrames: frames,
193
+ // One entry per scene, in order. `words` are the seconds from the scene's start at which each spoken word begins.
194
+ scenes: z.array(z.object({
195
+ id: text(80), start: frames, frames, treatment: text(60).optional(), narration: text(4000).optional(),
196
+ text: z.array(text(400)).max(20).optional(), words: z.array(z.number().min(0)).max(600).optional(),
197
+ })).min(1).max(200),
198
+ // The music track, with the seconds at which its beats fall inside the film.
199
+ music: z.object({ title: text(200), seconds: z.number().min(0), bpm: z.number().optional(), beats: z.array(z.number().min(0)).max(4000).optional() }).optional(),
200
+ voice: z.boolean().optional(),
201
+ captions: z.string().max(16).optional(),
202
+ // What a listener hears besides the voice and the music's pulse, measured from the rendered film's sound: the seconds at which a hit
203
+ // lands (a click, a pop, an impact) and the swells (a whoosh, a riser), each with its start and length in seconds.
204
+ hits: z.array(z.number().min(0)).max(3000).optional(),
205
+ swells: z.array(z.object({ start: z.number().min(0), length: z.number().min(0) })).max(600).optional(),
206
+ // The sound effects by name, when the render recorded them: each one's title, when it starts and how long it plays, in seconds.
207
+ cues: z.array(z.object({ at: z.number().min(0), seconds: z.number().min(0), title: text(200), kind: text(24).optional() })).max(1500).optional(),
208
+ });
209
+ export type TemplateFilm = z.infer<typeof TemplateFilmSchema>;
210
+ export const TemplateSchema = z.object({
211
+ id: TemplateId, name: z.string(), title: z.string().optional(), createdAt: z.string(), bundleBytes: z.number(), videoBytes: z.number().optional(),
212
+ aspect: z.string(), durationSec: z.number(), scenes: z.number(), thumbs: z.number(),
213
+ // The template this one was cloned from, when it was pushed from a clone.
214
+ from: z.string().optional(),
215
+ });
216
+ export type Template = z.infer<typeof TemplateSchema>;
217
+
175
218
  const route = <Q extends z.ZodType, S extends z.ZodType>(method: "GET" | "POST", path: string, auth: boolean, req: Q, res: S) => ({ method, path, auth, req, res });
176
219
 
177
220
  export const routes = {
@@ -258,6 +301,23 @@ export const routes = {
258
301
  transcribeRun: route("POST", "/transcripts/run", true,
259
302
  z.object({ id: ItemId }),
260
303
  z.object({ id: z.string(), text: z.string(), language: z.string().optional(), segments: z.array(TranscriptSegmentSchema), durationSec: z.number().positive() })),
304
+ // Starts a template: answers its id and one upload URL per file. Each URL takes one PUT of exactly the size and type named here
305
+ // (the bundle as application/gzip, the video as video/mp4, each thumb as image/jpeg, in scene order). The template exists for its
306
+ // owner only after `templateCommit`. A user may keep MAX_TEMPLATES; one more is refused with invalid_request.
307
+ templateStart: route("POST", "/templates", true,
308
+ z.object({
309
+ name: text(80).min(1), title: text(200).optional(), bundleBytes: z.number().int().min(1).max(MAX_UPLOAD_BYTES), videoBytes: z.number().int().min(1).max(MAX_UPLOAD_BYTES).optional(),
310
+ thumbBytes: z.array(z.number().int().min(1).max(MAX_TEMPLATE_THUMB_BYTES)).max(MAX_TEMPLATE_THUMBS), film: TemplateFilmSchema, from: TemplateId.optional(),
311
+ files: z.array(TemplateFileSchema).max(MAX_TEMPLATE_FILES).optional(), sourcesBytes: z.number().int().min(2).max(MAX_TEMPLATE_SOURCES_BYTES).optional(),
312
+ }),
313
+ z.object({ id: TemplateId, bundleUrl: z.string(), videoUrl: z.string().optional(), thumbUrls: z.array(z.string()), sourcesUrl: z.string().optional() })),
314
+ // Makes a started template real once its bundle (and its video, when one was named) has been uploaded; otherwise invalid_request.
315
+ templateCommit: route("POST", "/templates/commit", true, z.object({ id: TemplateId }), z.object({ template: TemplateSchema })),
316
+ // The caller's own templates, newest first.
317
+ templateList: route("GET", "/templates", true, z.object({}), z.object({ templates: z.array(TemplateSchema) })),
318
+ // One of the caller's templates with a link to its bundle that works for ten minutes. Another user's id is not_found.
319
+ templateGet: route("GET", "/templates/get", true, z.object({ id: TemplateId }), z.object({ template: TemplateSchema, bundleUrl: z.string(), film: TemplateFilmSchema })),
320
+ templateDelete: route("POST", "/templates/delete", true, z.object({ id: TemplateId }), z.object({ deleted: z.literal(true) })),
261
321
  publicLibrary: route("GET", "/public/library", false,
262
322
  // `sort` orders a listing without words by when each item was added: "new" is newest first, "old" is oldest first. Without it the
263
323
  // server chooses (newest first within a kind; the kinds take turns when none is named). It does not reorder a search by words.
@@ -0,0 +1,121 @@
1
+ // Comparing two renders of the same film, to prove that a revision changed only what it was asked to change. Pure: numbers in, verdict out.
2
+
3
+ export const DIFF_WIDTH = 320;
4
+ export const DIFF_HEIGHT = 180;
5
+ export const DEFAULT_THRESHOLD = 1.0;
6
+ export const AUDIO_WINDOW_SEC = 0.1;
7
+ export const DEFAULT_AUDIO_DB = 1.5;
8
+ // Below this level a window is silence in both films and its level is not compared.
9
+ export const AUDIO_FLOOR_DB = -55;
10
+
11
+ export type Range = { from: number; to: number };
12
+
13
+ // "623-679,1432-1436" in frames (inclusive), or "20.7s-22.6s" in seconds; a single value is a range of one. Returns frames.
14
+ export function parseAllow(text: string | undefined, fps: number): Range[] {
15
+ if (!text?.trim()) return [];
16
+ return text.split(",").map((part) => {
17
+ const p = part.trim();
18
+ const m = /^(\d+(?:\.\d+)?)(s?)(?:\s*-\s*(\d+(?:\.\d+)?)(s?))?$/.exec(p);
19
+ if (!m) throw new Error(`"${p}" is not a range. Write frames as 623-679 or seconds as 20.7s-22.6s, separated by commas.`);
20
+ const seconds = m[2] === "s" || m[4] === "s";
21
+ const a = Number(m[1]), b = m[3] === undefined ? a : Number(m[3]);
22
+ if (b < a) throw new Error(`"${p}" ends before it starts.`);
23
+ return seconds ? { from: Math.floor(a * fps), to: Math.max(Math.floor(a * fps), Math.ceil(b * fps) - 1) } : { from: Math.round(a), to: Math.round(b) };
24
+ });
25
+ }
26
+
27
+ export function meanAbsDiff(a: Uint8Array, b: Uint8Array): number {
28
+ const n = Math.min(a.length, b.length);
29
+ let sum = 0;
30
+ for (let i = 0; i < n; i++) sum += Math.abs(a[i]! - b[i]!);
31
+ return n ? sum / n : 0;
32
+ }
33
+
34
+ // Runs of consecutive indexes whose value is over the threshold.
35
+ export function changedRuns(values: number[], threshold: number): Range[] {
36
+ const runs: Range[] = [];
37
+ let start = -1;
38
+ values.forEach((v, i) => {
39
+ if (v > threshold) { if (start < 0) start = i; }
40
+ else if (start >= 0) { runs.push({ from: start, to: i - 1 }); start = -1; }
41
+ });
42
+ if (start >= 0) runs.push({ from: start, to: values.length - 1 });
43
+ return runs;
44
+ }
45
+
46
+ const allowed = (i: number, allow: Range[]) => allow.some((r) => i >= r.from && i <= r.to);
47
+
48
+ // The parts of each run that no allowed range covers.
49
+ export function outsideAllowed(runs: Range[], allow: Range[]): Range[] {
50
+ const out: Range[] = [];
51
+ for (const run of runs) {
52
+ let start = -1;
53
+ for (let i = run.from; i <= run.to; i++) {
54
+ if (!allowed(i, allow)) { if (start < 0) start = i; }
55
+ else if (start >= 0) { out.push({ from: start, to: i - 1 }); start = -1; }
56
+ }
57
+ if (start >= 0) out.push({ from: start, to: run.to });
58
+ }
59
+ return out;
60
+ }
61
+
62
+ export type PictureVerdict = {
63
+ frames: number; threshold: number;
64
+ changed: Range[]; outside: Range[];
65
+ // The largest difference on a frame that counts as unchanged: how close the quiet frames came to the threshold.
66
+ maxUnchanged: number;
67
+ // Allowed ranges in which nothing changed at all: a change that was asked for and did not happen.
68
+ untouchedAllowed: Range[];
69
+ lengthDiffers?: { old: number; new: number };
70
+ };
71
+
72
+ export function judgePicture(diffs: number[], allow: Range[], threshold = DEFAULT_THRESHOLD, lengths?: { old: number; new: number }): PictureVerdict {
73
+ const changed = changedRuns(diffs, threshold);
74
+ const quiet = diffs.filter((d) => d <= threshold);
75
+ return {
76
+ frames: diffs.length, threshold, changed, outside: outsideAllowed(changed, allow),
77
+ maxUnchanged: quiet.length ? Math.round(Math.max(...quiet) * 1000) / 1000 : 0,
78
+ untouchedAllowed: allow.filter((r) => !changed.some((c) => c.from <= r.to && c.to >= r.from)),
79
+ ...(lengths && lengths.old !== lengths.new ? { lengthDiffers: lengths } : {}),
80
+ };
81
+ }
82
+
83
+ // The level of each 100 ms window in dB relative to full scale.
84
+ export function windowLevels(samples: Int16Array, rate: number, windowSec = AUDIO_WINDOW_SEC): number[] {
85
+ const size = Math.max(1, Math.round(rate * windowSec));
86
+ const out: number[] = [];
87
+ for (let at = 0; at + size <= samples.length; at += size) {
88
+ let sum = 0;
89
+ for (let i = at; i < at + size; i++) { const v = samples[i]! / 32768; sum += v * v; }
90
+ out.push(10 * Math.log10(sum / size + 1e-12));
91
+ }
92
+ return out;
93
+ }
94
+
95
+ export type SoundVerdict = { windows: number; thresholdDb: number; changedSec: { from: number; to: number }[]; outsideSec: { from: number; to: number }[]; maxUnchangedDb: number };
96
+
97
+ // Two films' sound, window by window. A window changed when its level moved by more than `thresholdDb` and it is not silence in both. Levels, not samples,
98
+ // are compared: a lossy codec moves every sample of an unchanged passage, but not its level.
99
+ export function judgeSound(oldDb: number[], newDb: number[], allow: Range[], fps: number, thresholdDb = DEFAULT_AUDIO_DB, windowSec = AUDIO_WINDOW_SEC): SoundVerdict {
100
+ const n = Math.min(oldDb.length, newDb.length);
101
+ const delta: number[] = [];
102
+ for (let i = 0; i < n; i++) delta.push(oldDb[i]! < AUDIO_FLOOR_DB && newDb[i]! < AUDIO_FLOOR_DB ? 0 : Math.abs(oldDb[i]! - newDb[i]!));
103
+ const runs = changedRuns(delta, thresholdDb);
104
+ // A window is allowed when any frame it covers is.
105
+ const windowAllowed = (w: number) => { const a = Math.floor(w * windowSec * fps), b = Math.ceil((w + 1) * windowSec * fps) - 1; return allow.some((r) => a <= r.to && b >= r.from); };
106
+ const outside: Range[] = [];
107
+ for (const run of runs) {
108
+ let start = -1;
109
+ for (let w = run.from; w <= run.to; w++) {
110
+ if (!windowAllowed(w)) { if (start < 0) start = w; }
111
+ else if (start >= 0) { outside.push({ from: start, to: w - 1 }); start = -1; }
112
+ }
113
+ if (start >= 0) outside.push({ from: start, to: run.to });
114
+ }
115
+ const sec = (r: Range) => ({ from: Math.round(r.from * windowSec * 100) / 100, to: Math.round((r.to + 1) * windowSec * 100) / 100 });
116
+ const quiet = delta.filter((d) => d <= thresholdDb);
117
+ return { windows: n, thresholdDb, changedSec: runs.map(sec), outsideSec: outside.map(sec), maxUnchangedDb: quiet.length ? Math.round(Math.max(...quiet) * 100) / 100 : 0 };
118
+ }
119
+
120
+ export const rangeText = (r: Range) => (r.from === r.to ? String(r.from) : `${r.from}-${r.to}`);
121
+ export const rangesText = (rs: Range[]) => (rs.length ? rs.map(rangeText).join(", ") : "none");
@@ -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
+ }