reelkit-cli 0.12.0 → 0.12.2

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,16 @@
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.2 - 2026-10-09
7
+
8
+ - You can keep 50 templates (it was 20).
9
+
10
+ ## 0.12.1 - 2026-10-09
11
+
12
+ - A pushed template now carries, for each scene, the plan's visual direction and the components the composition times to it, and for the film every component it uses (written for the project, pulled from the library or from the kit).
13
+ - Sound effects and the music track carry their library ids, so a template's timeline can copy the command that pulls each one.
14
+ - Scene pictures are sharper (640 pixels wide).
15
+
6
16
  ## 0.12.0 - 2026-10-09
7
17
 
8
18
  - 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reelkit-cli",
3
- "version": "0.12.0",
3
+ "version": "0.12.2",
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.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`.
18
+ This skill targets `reelkit-cli` 0.12.2 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
 
@@ -11,6 +11,8 @@ import type { MusicRecord } from "../project/music";
11
11
  import { FILES, openProject, Project } from "../project/project";
12
12
  import { SOUND_MAP, type SoundMapEntry } from "./build";
13
13
  import { decodeMono } from "../project/refmeasure";
14
+ import { componentsOf } from "../project/scene-components";
15
+ import { leadingComment } from "./components";
14
16
  import { AUDIO_RATE, hitTimes, swells } from "../project/soundreport";
15
17
 
16
18
  const sh = promisify(execFile);
@@ -38,6 +40,13 @@ export function filmOf(project: Project): TemplateFilm | undefined {
38
40
  const music = project.readJsonOr<MusicRecord | undefined>(FILES.music, undefined);
39
41
  const seconds = manifest.totalFrames / manifest.fps;
40
42
  const cut = (s: string, max: number) => (s.length > max ? s.slice(0, max) : s);
43
+ // The components, from the composition's source: which ones it uses, and which scenes time each of them.
44
+ const pulled = Object.values(project.readJsonOr<Record<string, { path?: string; kind?: string }>>(FILES.library, {})).filter((p) => p.kind === "component" && p.path).map((p) => basename(p.path!).replace(/\.tsx$/, ""));
45
+ let used: ReturnType<typeof componentsOf> = [];
46
+ try { if (project.exists("src/Video.tsx")) used = componentsOf(readFileSync(project.path("src/Video.tsx"), "utf8"), { sceneIds: plan.scenes.map((s) => s.id), pulled }); } catch { used = []; }
47
+ const about = (name: string): string | undefined => {
48
+ try { return project.exists(`src/${name}.tsx`) ? leadingComment(readFileSync(project.path(`src/${name}.tsx`), "utf8"), name)?.replace(/\s+/g, " ").trim() : undefined; } catch { return undefined; }
49
+ };
41
50
  return {
42
51
  aspect: plan.aspect, fps: manifest.fps, width: manifest.width, height: manifest.height, totalFrames: Math.round(manifest.totalFrames),
43
52
  scenes: manifest.scenes.slice(0, 200).map((m) => {
@@ -48,9 +57,12 @@ export function filmOf(project: Project): TemplateFilm | undefined {
48
57
  id: cut(m.id, 80), start: Math.max(0, Math.round(m.startFrame)), frames: Math.max(0, Math.round(m.durationFrames)),
49
58
  ...(p?.treatment ? { treatment: cut(p.treatment, 60) } : {}), ...(narration ? { narration: cut(narration, 4000) } : {}),
50
59
  ...(text.length ? { text } : {}), ...(m.words.length ? { words: m.words.slice(0, 600).map((w) => Math.max(0, Math.round(w.startSec * 1000) / 1000)) } : {}),
60
+ ...(p?.notes?.trim() ? { notes: cut(p.notes.trim(), 2000) } : {}),
61
+ ...(used.some((c) => c.scenes.includes(m.id)) ? { components: used.filter((c) => c.scenes.includes(m.id)).map((c) => cut(c.name, 80)).slice(0, 60) } : {}),
51
62
  };
52
63
  }),
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) } } : {}),
64
+ ...(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), ...(music.id ? { id: cut(music.id, 128) } : {}) } } : {}),
65
+ ...(used.length ? { components: used.slice(0, 300).map((c) => { const text = c.from === "kit" ? undefined : about(c.name); return { name: cut(c.name, 80), from: c.from, uses: c.uses, ...(text ? { about: cut(text, 400) } : {}) }; }) } : {}),
54
66
  voice: plan.voice !== "none",
55
67
  ...(manifest.captions ? { captions: manifest.captions } : {}),
56
68
  ...cuesOf(project, manifest, music?.key),
@@ -62,7 +74,7 @@ export function filmOf(project: Project): TemplateFilm | undefined {
62
74
  function cuesOf(project: Project, manifest: AssetManifest, musicKey: string | undefined): Pick<TemplateFilm, "cues"> {
63
75
  const map = project.readJsonOr<SoundMapEntry[]>(SOUND_MAP, []);
64
76
  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, {}));
77
+ const pulled = Object.entries(project.readJsonOr<Record<string, { path?: string; kind?: string; title?: string; meta?: { durationSec?: number } }>>(FILES.library, {})).map(([id, p]) => ({ ...p, id }));
66
78
  const voice = new Set(manifest.scenes.map((s) => s.voiceoverKey).filter(Boolean));
67
79
  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
80
  const lib = pulled.find((p) => p.path === e.path);
@@ -71,7 +83,7 @@ function cuesOf(project: Project, manifest: AssetManifest, musicKey: string | un
71
83
  // 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
84
  const own = typeof lib?.meta?.durationSec === "number" && lib.meta.durationSec > 0 ? lib.meta.durationSec : 0;
73
85
  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) } : {}) }];
86
+ return [{ at: Math.max(0, e.start), seconds, title, ...(lib?.kind ? { kind: lib.kind.slice(0, 24) } : {}), ...(lib?.id ? { id: lib.id.slice(0, 128) } : {}) }];
75
87
  }).slice(0, 1500);
76
88
  return cues.length ? { cues } : {};
77
89
  }
@@ -84,7 +96,7 @@ async function thumbsOf(video: string, film: TemplateFilm, dir: string): Promise
84
96
  const at = (scene.start + scene.frames / 2) / film.fps;
85
97
  const file = join(dir, `thumb-${i}.jpg`);
86
98
  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]);
99
+ await sh("ffmpeg", ["-nostdin", "-v", "error", "-y", "-ss", at.toFixed(3), "-i", video, "-frames:v", "1", "-vf", "scale=640:-2", "-q:v", "6", file]);
88
100
  const bytes = readFileSync(file);
89
101
  if (!bytes.length || bytes.length > MAX_TEMPLATE_THUMB_BYTES) break;
90
102
  out.push(new Uint8Array(bytes));
@@ -176,7 +176,7 @@ const Empty = z.object({});
176
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
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
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;
179
+ export const MAX_TEMPLATES = 50;
180
180
  export const MAX_TEMPLATE_THUMBS = 60;
181
181
  export const MAX_TEMPLATE_THUMB_BYTES = 200_000;
182
182
  export const TEMPLATE_BUNDLE_TYPE = "application/gzip";
@@ -194,9 +194,12 @@ export const TemplateFilmSchema = z.object({
194
194
  scenes: z.array(z.object({
195
195
  id: text(80), start: frames, frames, treatment: text(60).optional(), narration: text(4000).optional(),
196
196
  text: z.array(text(400)).max(20).optional(), words: z.array(z.number().min(0)).max(600).optional(),
197
+ // The plan's visual direction for the scene, and the components the composition times to it (read from its source).
198
+ notes: text(2000).optional(), components: z.array(text(80)).max(60).optional(),
197
199
  })).min(1).max(200),
198
200
  // 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(),
201
+ // `id` is the track's id in the library, when it was pulled from there.
202
+ 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(), id: text(128).optional() }).optional(),
200
203
  voice: z.boolean().optional(),
201
204
  captions: z.string().max(16).optional(),
202
205
  // 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
@@ -204,7 +207,11 @@ export const TemplateFilmSchema = z.object({
204
207
  hits: z.array(z.number().min(0)).max(3000).optional(),
205
208
  swells: z.array(z.object({ start: z.number().min(0), length: z.number().min(0) })).max(600).optional(),
206
209
  // 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(),
210
+ // `id` is the sound's id in the library, when it was pulled from there.
211
+ cues: z.array(z.object({ at: z.number().min(0), seconds: z.number().min(0), title: text(200), kind: text(24).optional(), id: text(128).optional() })).max(1500).optional(),
212
+ // Every component the composition uses: one written in the project, one pulled from the library, or one of the kit's. `uses` is how
213
+ // many times it appears, and `about` is the comment above a project component.
214
+ components: z.array(z.object({ name: text(80), from: z.enum(["project", "library", "kit"]), uses: z.number().int().min(1), about: text(400).optional() })).max(300).optional(),
208
215
  });
209
216
  export type TemplateFilm = z.infer<typeof TemplateFilmSchema>;
210
217
  export const TemplateSchema = z.object({
@@ -0,0 +1,90 @@
1
+ import ts from "typescript";
2
+ import { sourceFile } from "../render/static-check";
3
+
4
+ // Which components a composition uses, and in which scenes, read from its source without running it.
5
+ //
6
+ // A composition is one tree, not a file per scene, so "this component belongs to that scene" is read from how it is timed: a variable
7
+ // made with `sceneById(manifest, "id")` stands for a scene, a variable computed from one stands for the same scene, and a component
8
+ // whose props (or whose enclosing element's props) mention such a variable is counted for that scene. A component timed by plain
9
+ // numbers, or by nothing, is counted for the film as a whole and for no scene. It is a reading of the code, not a measurement.
10
+ export type UsedComponent = { name: string; from: "project" | "kit" | "library"; uses: number; scenes: string[] };
11
+
12
+ // Tags that are plumbing rather than something the film shows.
13
+ const PLUMBING = new Set(["Sequence", "AbsoluteFill", "Series", "Fragment", "React.Fragment", "Audio", "Img", "Video", "OffthreadVideo", "Loop", "Freeze"]);
14
+
15
+ export function componentsOf(source: string, opts: { sceneIds: string[]; pulled?: string[] } = { sceneIds: [] }): UsedComponent[] {
16
+ const sf = sourceFile(source, "Video.tsx");
17
+ const known = new Set(opts.sceneIds);
18
+ const pulled = new Set(opts.pulled ?? []);
19
+ // Where each imported name comes from.
20
+ const origin = new Map<string, UsedComponent["from"]>();
21
+ for (const stmt of sf.statements) {
22
+ if (!ts.isImportDeclaration(stmt) || !ts.isStringLiteral(stmt.moduleSpecifier) || !stmt.importClause) continue;
23
+ const spec = stmt.moduleSpecifier.text;
24
+ const from: UsedComponent["from"] | undefined = spec === "reelkit/kit" ? "kit" : spec.startsWith("./") ? "project" : undefined;
25
+ if (!from) continue;
26
+ const names: string[] = [];
27
+ if (stmt.importClause.name) names.push(stmt.importClause.name.text);
28
+ const bindings = stmt.importClause.namedBindings;
29
+ if (bindings && ts.isNamedImports(bindings)) for (const el of bindings.elements) if (!el.isTypeOnly) names.push(el.name.text);
30
+ for (const name of names) origin.set(name, from === "project" && pulled.has(name) ? "library" : from);
31
+ }
32
+
33
+ // Variables that stand for a scene: `const grow = sceneById(manifest, "grow")`, then anything computed from one, in source order.
34
+ const sceneOf = new Map<string, Set<string>>();
35
+ const idsIn = (node: ts.Node): Set<string> => {
36
+ const found = new Set<string>();
37
+ const walk = (n: ts.Node) => {
38
+ if (ts.isCallExpression(n) && ts.isIdentifier(n.expression) && n.expression.text === "sceneById") {
39
+ const arg = n.arguments[1];
40
+ if (arg && ts.isStringLiteralLike(arg) && (!known.size || known.has(arg.text))) found.add(arg.text);
41
+ }
42
+ if (ts.isIdentifier(n)) for (const id of sceneOf.get(n.text) ?? []) found.add(id);
43
+ ts.forEachChild(n, walk);
44
+ };
45
+ walk(node);
46
+ return found;
47
+ };
48
+ const declare = (node: ts.Node) => {
49
+ if (ts.isVariableDeclaration(node) && node.initializer) {
50
+ const ids = idsIn(node.initializer);
51
+ if (ids.size) {
52
+ const bind = (name: ts.BindingName) => {
53
+ if (ts.isIdentifier(name)) sceneOf.set(name.text, new Set([...(sceneOf.get(name.text) ?? []), ...ids]));
54
+ else for (const el of name.elements) if (ts.isBindingElement(el)) bind(el.name);
55
+ };
56
+ bind(node.name);
57
+ }
58
+ }
59
+ ts.forEachChild(node, declare);
60
+ };
61
+ // Twice, so a variable computed from one declared further down (a helper above its data) is still followed.
62
+ declare(sf); declare(sf);
63
+
64
+ const used = new Map<string, { uses: number; scenes: Set<string> }>();
65
+ const visit = (node: ts.Node, inherited: Set<string>) => {
66
+ let here = inherited;
67
+ const opening = ts.isJsxSelfClosingElement(node) ? node : ts.isJsxElement(node) ? node.openingElement : undefined;
68
+ if (opening) {
69
+ const own = idsIn(opening.attributes);
70
+ here = own.size ? own : inherited;
71
+ const tag = opening.tagName.getText(sf);
72
+ const from = origin.get(tag.split(".")[0]!);
73
+ if (from && !PLUMBING.has(tag) && /^[A-Z]/.test(tag)) {
74
+ const entry = used.get(tag) ?? { uses: 0, scenes: new Set<string>() };
75
+ entry.uses += 1;
76
+ for (const id of here) entry.scenes.add(id);
77
+ used.set(tag, entry);
78
+ }
79
+ }
80
+ // A block of JSX produced inside an expression that mentions a scene (a `.map` over that scene's items, a condition on its frame)
81
+ // belongs to that scene too.
82
+ if (ts.isJsxExpression(node) && node.expression) { const own = idsIn(node.expression); if (own.size && !ts.isJsxElement(node.expression)) here = new Set([...own]); }
83
+ ts.forEachChild(node, (child) => visit(child, here));
84
+ };
85
+ visit(sf, new Set());
86
+
87
+ const order = opts.sceneIds;
88
+ return [...used].map(([name, u]) => ({ name, from: origin.get(name.split(".")[0]!)!, uses: u.uses, scenes: [...u.scenes].sort((a, b) => order.indexOf(a) - order.indexOf(b)) }))
89
+ .sort((a, b) => (a.from === b.from ? b.uses - a.uses || a.name.localeCompare(b.name) : ["project", "library", "kit"].indexOf(a.from) - ["project", "library", "kit"].indexOf(b.from)));
90
+ }