reelkit-cli 0.12.2 → 0.12.4

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,14 @@
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.4 - 2026-10-09
7
+
8
+ - A template's timeline shows the music and its beats to the end of the film when the track repeats, not only for the track's own length.
9
+
10
+ ## 0.12.3 - 2026-10-09
11
+
12
+ - A template's components are tied to scenes in more of the ways compositions are written: a project's own helper that is handed a scene's id, a lookup by id, a list that pairs ids with components, a branch on a scene's id, and components the file declares for itself.
13
+
6
14
  ## 0.12.2 - 2026-10-09
7
15
 
8
16
  - You can keep 50 templates (it was 20).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reelkit-cli",
3
- "version": "0.12.2",
3
+ "version": "0.12.4",
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.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`.
18
+ This skill targets `reelkit-cli` 0.12.4 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
 
@@ -61,7 +61,7 @@ export function filmOf(project: Project): TemplateFilm | undefined {
61
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) } : {}),
62
62
  };
63
63
  }),
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) } : {}) } } : {}),
64
+ ...(music ? { music: musicOf(music, manifest, seconds) } : {}),
65
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) } : {}) }; }) } : {}),
66
66
  voice: plan.voice !== "none",
67
67
  ...(manifest.captions ? { captions: manifest.captions } : {}),
@@ -69,6 +69,19 @@ export function filmOf(project: Project): TemplateFilm | undefined {
69
69
  };
70
70
  }
71
71
 
72
+ // The music as it plays in the film. A track shorter than the film is repeated by the composition, and the manifest holds every beat
73
+ // that falls inside the film (the repeats too), so those are the beats shown; the track then runs to the film's end. Without them the
74
+ // track's own beats and length are used.
75
+ function musicOf(music: MusicRecord, manifest: AssetManifest, seconds: number): NonNullable<TemplateFilm["music"]> {
76
+ const inFilm = (manifest.music?.beatFrames ?? []).map((f) => Math.round((f / manifest.fps) * 1000) / 1000).filter((b) => b >= 0 && b <= seconds);
77
+ const beats = (inFilm.length ? inFilm : music.beats.filter((b) => b >= 0 && b <= seconds)).slice(0, 4000);
78
+ const repeats = inFilm.length > 0 && inFilm[inFilm.length - 1]! > music.durationSec;
79
+ return {
80
+ title: music.title.slice(0, 200), seconds: repeats ? seconds : Math.min(music.durationSec, seconds), ...(music.bpm ? { bpm: music.bpm } : {}), beats,
81
+ ...(music.id ? { id: music.id.slice(0, 128) } : {}),
82
+ };
83
+ }
84
+
72
85
  // 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
73
86
  // voice, with the title it has in the library when it was pulled from there, or its file name.
74
87
  function cuesOf(project: Project, manifest: AssetManifest, musicKey: string | undefined): Pick<TemplateFilm, "cues"> {
@@ -3,10 +3,12 @@ import { sourceFile } from "../render/static-check";
3
3
 
4
4
  // Which components a composition uses, and in which scenes, read from its source without running it.
5
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.
6
+ // A composition is one tree, not a file per scene, so "this component belongs to that scene" is read from how it is placed and timed.
7
+ // A call that is handed a scene's id as a string (`sceneById(manifest, "ask")`, a project's own `scene("ask", <>...</>)` or
8
+ // `at("ask")`), or a lookup by it (`s["ask"]`), is about that scene. A variable made from such an expression stands for the scene, and
9
+ // so does one computed from it. A component is counted for a scene when it is written inside such a call, or when its props (or its
10
+ // enclosing element's props) mention one. A component placed by plain numbers, or by nothing, is counted for no scene. It is a
11
+ // reading of the code, not a measurement.
10
12
  export type UsedComponent = { name: string; from: "project" | "kit" | "library"; uses: number; scenes: string[] };
11
13
 
12
14
  // Tags that are plumbing rather than something the film shows.
@@ -32,13 +34,20 @@ export function componentsOf(source: string, opts: { sceneIds: string[]; pulled?
32
34
 
33
35
  // Variables that stand for a scene: `const grow = sceneById(manifest, "grow")`, then anything computed from one, in source order.
34
36
  const sceneOf = new Map<string, Set<string>>();
37
+ // The scene ids this one node names outright: a string argument of a call, or the key of a lookup. With the plan's ids known, any
38
+ // call counts; without them only `sceneById`, so an ordinary string is never taken for a scene.
39
+ const named = (n: ts.Node): string[] => {
40
+ if (ts.isCallExpression(n)) {
41
+ if (!known.size) { const arg = ts.isIdentifier(n.expression) && n.expression.text === "sceneById" ? n.arguments[1] : undefined; return arg && ts.isStringLiteralLike(arg) ? [arg.text] : []; }
42
+ return n.arguments.filter((a): a is ts.StringLiteralLike => ts.isStringLiteralLike(a) && known.has(a.text)).map((a) => a.text);
43
+ }
44
+ if (ts.isElementAccessExpression(n) && ts.isStringLiteralLike(n.argumentExpression) && known.has(n.argumentExpression.text)) return [n.argumentExpression.text];
45
+ return [];
46
+ };
35
47
  const idsIn = (node: ts.Node): Set<string> => {
36
48
  const found = new Set<string>();
37
49
  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
- }
50
+ for (const id of named(n)) found.add(id);
42
51
  if (ts.isIdentifier(n)) for (const id of sceneOf.get(n.text) ?? []) found.add(id);
43
52
  ts.forEachChild(n, walk);
44
53
  };
@@ -61,28 +70,96 @@ export function componentsOf(source: string, opts: { sceneIds: string[]; pulled?
61
70
  // Twice, so a variable computed from one declared further down (a helper above its data) is still followed.
62
71
  declare(sf); declare(sf);
63
72
 
64
- const used = new Map<string, { uses: number; scenes: Set<string> }>();
65
- const visit = (node: ts.Node, inherited: Set<string>) => {
73
+ // The components the file declares for itself (a `const HookShot = () => ...`): what is written inside one belongs to the scenes that
74
+ // one is used in. The composition itself (`Video`) is not one of them.
75
+ const local = new Map<string, ts.Node>();
76
+ const collect = (node: ts.Node) => {
77
+ if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && /^[A-Z]/.test(node.name.text) && node.initializer && (ts.isArrowFunction(node.initializer) || ts.isFunctionExpression(node.initializer))) local.set(node.name.text, node.initializer);
78
+ if (ts.isFunctionDeclaration(node) && node.name && /^[A-Z]/.test(node.name.text)) local.set(node.name.text, node);
79
+ ts.forEachChild(node, collect);
80
+ };
81
+ collect(sf);
82
+ local.delete("Video");
83
+
84
+ type Use = { scenes: Set<string>; owner: string };
85
+ const uses = new Map<string, Use[]>(); // imported components
86
+ const localUses = new Map<string, Use[]>(); // the file's own components
87
+ const note = (map: Map<string, Use[]>, name: string, scenes: Set<string>, owner: string) => map.set(name, [...(map.get(name) ?? []), { scenes: new Set(scenes), owner }]);
88
+ // The ids a condition compares against: `scene.id === "hook"`.
89
+ const compared = (node: ts.Node): Set<string> => {
90
+ const found = new Set<string>();
91
+ const walk = (n: ts.Node) => {
92
+ if (ts.isBinaryExpression(n) && (n.operatorToken.kind === ts.SyntaxKind.EqualsEqualsEqualsToken || n.operatorToken.kind === ts.SyntaxKind.EqualsEqualsToken)) {
93
+ for (const side of [n.left, n.right]) if (ts.isStringLiteralLike(side) && known.has(side.text)) found.add(side.text);
94
+ }
95
+ ts.forEachChild(n, walk);
96
+ };
97
+ walk(node);
98
+ return found;
99
+ };
100
+ const visit = (node: ts.Node, inherited: Set<string>, owner: string) => {
66
101
  let here = inherited;
102
+ let inside = owner;
103
+ for (const [name, body] of local) if (body === node) { inside = name; here = new Set(); }
67
104
  const opening = ts.isJsxSelfClosingElement(node) ? node : ts.isJsxElement(node) ? node.openingElement : undefined;
68
105
  if (opening) {
69
106
  const own = idsIn(opening.attributes);
70
- here = own.size ? own : inherited;
107
+ here = own.size ? own : here;
71
108
  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
- }
109
+ const root = tag.split(".")[0]!;
110
+ if (local.has(root)) note(localUses, root, here, inside);
111
+ else if (origin.get(root) && !PLUMBING.has(tag) && /^[A-Z]/.test(tag)) note(uses, tag, here, inside);
79
112
  }
80
113
  // 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
114
  // belongs to that scene too.
82
115
  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));
116
+ // What is written inside a call that names a scene (`scene("ask", <>...</>)`) is that scene's.
117
+ const direct = named(node);
118
+ if (direct.length) here = new Set(direct);
119
+ // A list that pairs a scene's id with a component (`["hook", HookShot]`), or an object keyed by scene ids, places that component.
120
+ if (ts.isArrayLiteralExpression(node)) {
121
+ const ids = node.elements.filter((e): e is ts.StringLiteralLike => ts.isStringLiteralLike(e) && known.has(e.text)).map((e) => e.text);
122
+ if (ids.length) { here = new Set(ids); for (const e of node.elements) if (ts.isIdentifier(e) && local.has(e.text)) note(localUses, e.text, here, inside); }
123
+ }
124
+ if (ts.isPropertyAssignment(node) && (ts.isIdentifier(node.name) || ts.isStringLiteralLike(node.name)) && known.has(node.name.text)) {
125
+ here = new Set([node.name.text]);
126
+ if (ts.isIdentifier(node.initializer) && local.has(node.initializer.text)) note(localUses, node.initializer.text, here, inside);
127
+ }
128
+ // The branch taken when a scene's id is the one compared against is that scene's.
129
+ if (ts.isConditionalExpression(node) || ts.isIfStatement(node)) {
130
+ const cond = ts.isConditionalExpression(node) ? node.condition : node.expression;
131
+ const then = ts.isConditionalExpression(node) ? node.whenTrue : node.thenStatement;
132
+ const ids = compared(cond);
133
+ if (ids.size) {
134
+ ts.forEachChild(node, (child) => visit(child, child === then ? ids : here, inside));
135
+ return;
136
+ }
137
+ }
138
+ if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken) {
139
+ const ids = compared(node.left);
140
+ if (ids.size) { visit(node.left, here, inside); visit(node.right, ids, inside); return; }
141
+ }
142
+ if (ts.isCaseClause(node) && ts.isStringLiteralLike(node.expression) && known.has(node.expression.text)) here = new Set([node.expression.text]);
143
+ ts.forEachChild(node, (child) => visit(child, here, inside));
84
144
  };
85
- visit(sf, new Set());
145
+ visit(sf, new Set(), "");
146
+
147
+ // The scenes of each of the file's own components: where it is placed, or, when it is placed inside another of them with no scene
148
+ // of its own, wherever that one is. Repeated until nothing changes, so nesting of any depth is followed.
149
+ const scenesOfLocal = new Map<string, Set<string>>([...local.keys()].map((name) => [name, new Set<string>()]));
150
+ for (let changed = true, rounds = 0; changed && rounds < 12; rounds++) {
151
+ changed = false;
152
+ for (const [name, list] of localUses) {
153
+ const mine = scenesOfLocal.get(name)!;
154
+ for (const use of list) for (const id of use.scenes.size ? use.scenes : (scenesOfLocal.get(use.owner) ?? [])) if (!mine.has(id)) { mine.add(id); changed = true; }
155
+ }
156
+ }
157
+ const used = new Map<string, { uses: number; scenes: Set<string> }>();
158
+ for (const [tag, list] of uses) {
159
+ const scenes = new Set<string>();
160
+ for (const use of list) for (const id of use.scenes.size ? use.scenes : (scenesOfLocal.get(use.owner) ?? [])) scenes.add(id);
161
+ used.set(tag, { uses: list.length, scenes });
162
+ }
86
163
 
87
164
  const order = opts.sceneIds;
88
165
  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)) }))