reelkit-cli 0.11.0 → 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.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,12 @@
3
3
  What changed in each published version of `reelkit-cli`, newest first. A push to `main` publishes the version in
4
4
  `package.json` when it is not on npm yet, and it must have an entry here.
5
5
 
6
+ ## 0.12.0 - 2026-10-09
7
+
8
+ - Templates: `reelkit template push` saves a whole project in your private files (the plan, the composition, everything under `assets/` and the rendered video), and `reelkit template clone <id>` unpacks one into a new project to continue or remix. `reelkit template list` and `reelkit template delete <id>` manage them.
9
+ - Your templates page on reelkit.cc shows each one: its video, a timeline of its scenes, text, voice, music, beats and sound effects, and its files.
10
+ - `reelkit render` writes `out/sounds.json`: which sound plays when. A template pushed after that names each sound effect on its timeline.
11
+
6
12
  ## 0.11.0 - 2026-10-09
7
13
 
8
14
  Ported from the reelkit-cli 0.9 toolset (written against the Remotion 0.8.0 CLI) onto the HyperFrames renderer.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reelkit-cli",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "CLI and Claude skill for making short-form video with a shared asset library.",
5
5
  "license": "MIT",
6
6
  "author": "Daniel Livshin",
package/skill/SKILL.md CHANGED
@@ -15,7 +15,7 @@ For image or video cutouts, editable Blender product shots, custom 3D materials,
15
15
 
16
16
  To implement a chosen style, read its row in `reference/style-recipes.md` for action, timing, sound and proof. `reference/style-components.md` maps the shared components and built-in kit to shot roles; inspect the selected source before using its props.
17
17
 
18
- This skill targets `reelkit-cli` 0.11.0 and its HyperFrames kit. Check the installed version with `reelkit --version`; in a source checkout, invoke its `bin/reelkit.mjs` with Node. Use the matching CLI before authoring with `reelkit/frame`.
18
+ This skill targets `reelkit-cli` 0.12.0 and its HyperFrames kit. Check the installed version with `reelkit --version`; in a source checkout, invoke its `bin/reelkit.mjs` with Node. Use the matching CLI before authoring with `reelkit/frame`.
19
19
 
20
20
  ## What decides whether the film is accepted
21
21
 
@@ -185,6 +185,7 @@ The pane never changes the project by itself. Its buttons (Edit this scene, Chan
185
185
  - A revision changes only what was asked. `reelkit render --allow <frames>` is how that is proved. Do not tidy, retime or recolour anything outside the list.
186
186
  - Never regenerate a voice the user did not ask you to touch, and never pick a take when they asked to hear the options.
187
187
  - On-screen text never has vowel points. Pointing belongs in the voice request only.
188
+ - `reelkit template push` saves the whole project in the user's private files: the plan, the composition, everything under `assets/` (their own pictures, footage and recorded voice too) and the rendered video. Run it only when the user asks to save, back up or reuse the project, and say in one sentence what it sends. Only they can see it, on their templates page and with `reelkit template list`. `reelkit template clone <id> [folder]` unpacks one into a new project to continue or remix; `reelkit template delete <id>` removes it.
188
189
  - A private project means `reelkit render --no-share` on every render, and neither `reelkit components share` nor `reelkit assets upload --share`.
189
190
  - Never print an API key, a token, or the contents of a credentials file.
190
191
  - If a command reports that a quota is used up, tell the user what ran out and when it resets. Do not work around it. A message that says to wait a minute or an hour ("Too many searches", "Too many uploads started") is a throttle, not a used-up quota: wait and retry once instead of stopping.
package/src/cli.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { templateClone, templateDelete, templateList, templatePush } from "./commands/template";
1
2
  import { Command } from "commander";
2
3
  import { ApiFailure } from "./api/client";
3
4
  import { assetsGenClip, assetsGenImage, assetsGenSvg, assetsLayers, assetsPull, assetsSearch, assetsUpload, assetsVoiceover, assetsVoices } from "./commands/assets";
@@ -150,6 +151,14 @@ program.command("tools").description("Print where the voice scripts that ship wi
150
151
  const names = existsSync(dir) ? readdirSync(dir).filter((f) => f.endsWith(".py") && !f.startsWith("_")).sort() : [];
151
152
  return { ok: names.length > 0, data: { dir, scripts: names }, summary: names.length ? `${dir}\n${names.map((n) => ` python3 ${dir}/${n} --help`).join("\n")}\nRead ${dir}/README.md for the order to use them in.` : "The voice scripts are missing from this installation. Reinstall reelkit." };
152
153
  }));
154
+ const template = program.command("template").description("Whole projects kept in your private files, to continue later or to start a new video from");
155
+ template.command("push").description("Save this project as a template in your private files: the plan, the composition, everything under assets/ (your own files too), the rendered video when there is one, and a picture of each scene. Only you can see it")
156
+ .option("--name <name>", "what to call the template (default: the project's name)")
157
+ .action(run((ctx, opts: { name?: string }) => templatePush(ctx, opts)));
158
+ template.command("list").description("List your templates").action(run((ctx) => templateList(ctx)));
159
+ template.command("clone <id> [folder]").description("Unpack one of your templates into a new project folder, as a copy of its own to continue or to remix")
160
+ .action(run((ctx, id: string, folder: string | undefined) => templateClone(ctx, id, folder)));
161
+ template.command("delete <id>").description("Delete one of your templates from your private files").action(run((ctx, id: string) => templateDelete(ctx, id)));
153
162
  const components = program.command("components").description("Components written in this project");
154
163
  components.command("share [names...]").description("Share components written in this project with the library for review (all of them with no name): their source, a description and an example are sent, nothing else")
155
164
  .option("--describe <text>", "what it shows and when to use it (one component)").option("--example <jsx>", "an example use with plain values, such as '<StatRing value={42} />' (one component)").option("--tags <tags>", "comma-separated tags, 3 to 6 (one component)")
@@ -101,6 +101,18 @@ function compositionNotes(project: Project, plan: ScenePlan, manifest?: AssetMan
101
101
 
102
102
  const failed = (errors: string[]): Result => ({ ok: false, data: { errors }, summary: `${errors.length} problem(s):\n- ${errors.join("\n- ")}` });
103
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
+
104
116
  export async function check(ctx: Ctx): Promise<Result> {
105
117
  const project = openProject(ctx.cwd);
106
118
  const { errors, manifest } = inspect(project);
@@ -334,6 +346,7 @@ export async function render(ctx: Ctx, deps: RenderDeps = {}): Promise<Result> {
334
346
  renderSound: async (out) => {
335
347
  const lists = readdirSync(chunkDir).filter((f) => f.endsWith(".audio.json")).map((f) => JSON.parse(readFileSync(join(chunkDir, f), "utf8")) as CapturedAudio[]);
336
348
  const merged = mergeAudioRecords(lists);
349
+ saveSoundMap(project, urls, merged);
337
350
  if (!merged.length) return false;
338
351
  await mixCapturedAudio(merged, out, manifest.totalFrames / manifest.fps);
339
352
  return existsSync(out) && statSync(out).size > 44;
@@ -343,7 +356,10 @@ export async function render(ctx: Ctx, deps: RenderDeps = {}): Promise<Result> {
343
356
  }));
344
357
  } else {
345
358
  // The browser can fail to start or drop its connection; that is no fault of the composition, so it gets another go.
346
- await withRetry(() => withBundle(project, (serveUrl, urls) => renderVideo(serveUrl, { manifest, urls }, project.path(path), options)), { onRetry: (n, e) => ctx.log(`The browser failed (${(e instanceof Error ? e.message : String(e)).split("\n")[0]}); rendering again (attempt ${n + 1}).`) });
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}).`) });
347
363
  }
348
364
  } catch (e) {
349
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."}`]);
@@ -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.
@@ -75,8 +75,9 @@ async function render(dir: string, props: VideoProps, mode: "stills" | "video",
75
75
  export async function renderStills(dir: string, props: VideoProps, frames: number[], outDir: string, options?: RenderOptions) {
76
76
  await mkdir(outDir, { recursive: true }); await render(dir, props, "stills", outDir, frames, options);
77
77
  }
78
- export async function renderVideo(dir: string, props: VideoProps, outPath: string, options?: RenderOptions) {
79
- await mkdir(dirname(outPath), { recursive: true }); await render(dir, props, "video", outPath, [], options);
78
+ // `audioFile`, when given, receives the audio records the film registered (which sound plays from when to when), as JSON.
79
+ export async function renderVideo(dir: string, props: VideoProps, outPath: string, options?: RenderOptions, audioFile?: string) {
80
+ await mkdir(dirname(outPath), { recursive: true }); await render(dir, props, "video", outPath, [], options, audioFile ? { audioSidecar: audioFile } : undefined);
80
81
  }
81
82
  // One inclusive range of frames as a silent file, plus the audio records those frames registered (`${outPath}.audio.json`).
82
83
  export async function renderVideoRange(dir: string, props: VideoProps, outPath: string, from: number, to: number, options?: RenderOptions) {
@@ -5,7 +5,7 @@ import { createServer } from "node:http";
5
5
  import type { AddressInfo } from "node:net";
6
6
  import { tmpdir } from "node:os";
7
7
  import { join } from "node:path";
8
- import { MAX_UPLOAD_BYTES, routes, type ErrorCode, type LibraryItem, type RouteName, type Voice } from "../contract";
8
+ import { MAX_TEMPLATES, MAX_UPLOAD_BYTES, TEMPLATE_BUNDLE_TYPE, routes, type ErrorCode, type LibraryItem, type RouteName, type Template, type TemplateFilm, type Voice } from "../contract";
9
9
  import { staticCheck } from "../render/validate";
10
10
  import { FAKE_SVG, PIXEL_PNG, silentWav } from "./fixtures";
11
11
 
@@ -124,6 +124,9 @@ export async function startFakeApi(opts: { voiceoverCharLimit?: number; imageLim
124
124
  const secret = () => randomBytes(16).toString("hex");
125
125
  let origin = "";
126
126
  // Every call makes a new URL for its own blob. The URL says nothing about the item behind it.
127
+ // A user's templates: the files as they were uploaded, and whether the template was committed.
128
+ type TemplateJob = { owner: string; record: Template; film: TemplateFilm; committed: boolean; bundle?: Uint8Array; video?: Uint8Array; sources?: Uint8Array; thumbs: (Uint8Array | undefined)[] };
129
+ const templates = new Map<string, TemplateJob>();
127
130
  const fileUrl = (blob: Blob) => { const t = secret(); blobs.set(t, blob); return `${origin}/files/${t}`; };
128
131
  const words = (s: string) => new Set(s.toLowerCase().match(/[a-z0-9]+/g) ?? []);
129
132
 
@@ -323,6 +326,51 @@ export async function startFakeApi(opts: { voiceoverCharLimit?: number; imageLim
323
326
  };
324
327
  return job.result;
325
328
  },
329
+ templateStart: (input, { userId }) => {
330
+ const owner = userId ?? "u-test";
331
+ if ([...templates.values()].filter((t) => t.owner === owner && t.committed).length >= MAX_TEMPLATES) throw new Fail(400, "invalid_request", `You already keep ${MAX_TEMPLATES} templates. Delete one with \`reelkit template delete <id>\` and push again.`);
332
+ const id = `tpl-${randomBytes(6).toString("hex")}`;
333
+ const film = input.film;
334
+ const record: Template = {
335
+ id, name: input.name, ...(input.title ? { title: input.title } : {}), createdAt: new Date().toISOString(), bundleBytes: input.bundleBytes,
336
+ ...(input.videoBytes ? { videoBytes: input.videoBytes } : {}), aspect: film.aspect, durationSec: Math.round((film.totalFrames / film.fps) * 100) / 100,
337
+ scenes: film.scenes.length, thumbs: input.thumbBytes.length, ...(input.from ? { from: input.from } : {}),
338
+ };
339
+ const job: TemplateJob = { owner, record, film, committed: false, thumbs: (input.thumbBytes as number[]).map(() => undefined) };
340
+ templates.set(id, job);
341
+ const put = (contentType: string, bytes: number, has: () => boolean, set: (b: Uint8Array) => void) => {
342
+ const token = secret();
343
+ uploads.set(token, { contentType, bytes, used: false, sink: { has, put: set } });
344
+ return `${origin}/upload/${token}`;
345
+ };
346
+ return {
347
+ id, bundleUrl: put(TEMPLATE_BUNDLE_TYPE, input.bundleBytes, () => Boolean(job.bundle), (b) => { job.bundle = b; }),
348
+ ...(input.videoBytes ? { videoUrl: put("video/mp4", input.videoBytes, () => Boolean(job.video), (b) => { job.video = b; }) } : {}),
349
+ ...(input.sourcesBytes ? { sourcesUrl: put("application/json", input.sourcesBytes, () => Boolean(job.sources), (b) => { job.sources = b; }) } : {}),
350
+ thumbUrls: (input.thumbBytes as number[]).map((n, i) => put("image/jpeg", n, () => Boolean(job.thumbs[i]), (b) => { job.thumbs[i] = b; })),
351
+ };
352
+ },
353
+ templateCommit: ({ id }, { userId }) => {
354
+ const job = templates.get(id);
355
+ if (!job || job.owner !== (userId ?? "u-test")) throw new Fail(404, "not_found", "No such template.");
356
+ if (!job.bundle || (job.record.videoBytes && !job.video)) throw new Fail(400, "invalid_request", "The template's files have not been uploaded. Run `reelkit template push` again.");
357
+ job.committed = true;
358
+ return { template: job.record };
359
+ },
360
+ templateList: (_input, { userId }) => ({
361
+ templates: [...templates.values()].filter((t) => t.owner === (userId ?? "u-test") && t.committed).map((t) => t.record).sort((a, b) => b.createdAt.localeCompare(a.createdAt)),
362
+ }),
363
+ templateGet: ({ id }, { userId }) => {
364
+ const job = templates.get(id);
365
+ if (!job || !job.committed || job.owner !== (userId ?? "u-test")) throw new Fail(404, "not_found", "No such template.");
366
+ return { template: job.record, film: job.film, bundleUrl: fileUrl({ filename: "bundle.tgz", contentType: TEMPLATE_BUNDLE_TYPE, bytes: job.bundle! }) };
367
+ },
368
+ templateDelete: ({ id }, { userId }) => {
369
+ const job = templates.get(id);
370
+ if (!job || !job.committed || job.owner !== (userId ?? "u-test")) throw new Fail(404, "not_found", "No such template.");
371
+ templates.delete(id);
372
+ return { deleted: true as const };
373
+ },
326
374
  publicLibrary: ({ q, kind, page, sort }) => {
327
375
  // Published items only. Newest first, or oldest first when asked; with a query, best match first and equal matches newest first.
328
376
  const want = q ? words(q) : undefined;