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 +10 -0
- package/package.json +1 -1
- package/skill/SKILL.md +1 -1
- package/src/commands/template.ts +16 -4
- package/src/contract/index.ts +10 -3
- package/src/project/scene-components.ts +90 -0
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
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.
|
|
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
|
|
package/src/commands/template.ts
CHANGED
|
@@ -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.
|
|
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=
|
|
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));
|
package/src/contract/index.ts
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|