reelkit-cli 0.12.4 → 0.12.6
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 +2 -1
- package/skill/reference/component-authoring.md +19 -0
- package/src/commands/template.ts +20 -3
- package/src/contract/index.ts +4 -0
- package/src/project/scene-components.ts +34 -2
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.6 - 2026-10-10
|
|
7
|
+
|
|
8
|
+
- `reelkit template push` leaves out `.remotion` (the browser an older project rendered with) and `.cache`, and when a project is still over the limit it names the largest folders.
|
|
9
|
+
|
|
10
|
+
## 0.12.5 - 2026-10-09
|
|
11
|
+
|
|
12
|
+
- The skill builds a film from component files instead of one long `Video.tsx`: every drawn part that could appear in another film is its own file in `src/`, so it can be reused and shows as a part of a template.
|
|
13
|
+
- A template lists the components a composition declares for itself, and ties a component to a scene when its name is the scene's id.
|
|
14
|
+
- A template carries the spoken words of each scene, for a subtitles row on its timeline, and the sound effects the project uses, so a sound on the timeline copies the command that pulls it.
|
|
15
|
+
|
|
6
16
|
## 0.12.4 - 2026-10-09
|
|
7
17
|
|
|
8
18
|
- 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.
|
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.6 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
|
|
|
@@ -133,6 +133,7 @@ Read `reference/hyperframes-composition.md` for the seekable HyperFrames timing
|
|
|
133
133
|
- For a brand reveal, a transformation, or a shot with real depth, read `reference/brand-motion.md` and `reference/three-d.md`. Choose the move by what it explains, preserve the supplied identity, and share its event frames with the sound cues. Keep reading-heavy shots in 2D; a requested brand film or showreel may use several purposeful 3D moments.
|
|
134
134
|
- For what sits behind the whole film (a video, a picture that changes, or an animated ground), read `reference/backgrounds.md`; pick one ground and keep it.
|
|
135
135
|
- Before writing a new component, read `reference/component-authoring.md`.
|
|
136
|
+
- **Build the film from component files, not one long `Video.tsx`.** Every drawn part that could appear in another film (a text reveal, a card, a character, a chart, a cursor, a shape that morphs, a field of particles) is its own file in `src/`, driven by props, with the comment that describes it. `Video.tsx` keeps only what belongs to this film: the palette, the words, the frame numbers, where each part sits in each scene, and the sound. A helper declared inside `Video.tsx` that returns JSX and holds none of this film's words is a component that was not split out; move it. A film of several scenes should end with several component files, and they are what the user reuses in the next video and what a template shows as its parts.
|
|
136
137
|
- Write `src/Video.tsx` and any component files beside it. `Video.tsx` may import only `react`, `reelkit/frame`, `reelkit/kit` and sibling components (`./Name`).
|
|
137
138
|
- Run `reelkit lint --no-video` together with `reelkit check`. It reads the source: right-to-left text with no direction, vowel points in on-screen text, a sequence that starts after its parent ends, and a shot written on a range the film never draws.
|
|
138
139
|
- `manifest.json` is the exact object passed to `Video` as the `manifest` prop. Media is referenced as `urls[path]`, where `path` is the file's path in the project, such as `urls[scene.voiceoverKey]` or `urls["assets/lib/<id>/clip.mp3"]`.
|
|
@@ -10,6 +10,25 @@ Run `reelkit assets search "<description>" --kind component`. If a library compo
|
|
|
10
10
|
|
|
11
11
|
Use `reference/style-components.md` to choose the role and `reference/style-recipes.md` for the shot's motion and proof. A catalog name does not mean a built-in kit export or a currently published asset; search, pull and inspect the actual source first.
|
|
12
12
|
|
|
13
|
+
## Split the film into parts
|
|
14
|
+
Searching first is about not writing a part twice. It is not a reason to write everything inside `Video.tsx`: a film whose whole picture lives in one file leaves nothing to reuse, shares nothing, and shows as one block when it is saved as a template.
|
|
15
|
+
|
|
16
|
+
Before writing `Video.tsx`, list the parts the film draws and decide where each one lives:
|
|
17
|
+
|
|
18
|
+
| It is | It goes | Example |
|
|
19
|
+
|---|---|---|
|
|
20
|
+
| A drawn thing with no words of its own | Its own file in `src/`, all values as props | `MaskRise` (a line that rises from behind a mask), `Blob`, `EyedCharacter`, `ChartCard`, `TabChip`, `DrawnCheck` |
|
|
21
|
+
| A way of placing or timing other things | Its own file, taking `children` | `Pin` (centre children on a point), `Shake`, `Orbit` |
|
|
22
|
+
| This film's scene: which parts, which words, which frames | `Video.tsx` | the `hook` shot: a `ChartCard` at frame 4, "Great month." at 53, the slam at 98 |
|
|
23
|
+
| The palette, the sound list, the frame numbers | `Video.tsx`, passed down as props | the blob gets `color={c.hero}` |
|
|
24
|
+
|
|
25
|
+
Rules of thumb:
|
|
26
|
+
- A function in `Video.tsx` that returns JSX and holds none of this film's words is a part. Move it to a file.
|
|
27
|
+
- A scene function may stay in `Video.tsx`, named after its scene id (`Hook` for `hook`, `OneNumber` for `one-number`), and should be mostly a list of parts with props. If it is long because it draws shapes itself, the shapes are parts.
|
|
28
|
+
- One part needs another (a monster has eyes): pass it in as `children` or a prop, since a component file imports no other component file.
|
|
29
|
+
- The same thing lives through several scenes (one ball that changes shape): one part with the values as props (`x`, `y`, `radius`, `morph`), and `Video.tsx` owns the keyframes.
|
|
30
|
+
- Do not split for its own sake: a two-line wrapper used once is not a part.
|
|
31
|
+
|
|
13
32
|
## Rules for a component file
|
|
14
33
|
- One component per file. File `NumberBadge.tsx` exports `export const NumberBadge: React.FC<NumberBadgeProps>`.
|
|
15
34
|
- Imports only from `react`, `reelkit/frame` and `reelkit/kit`. A component never imports another component file.
|
package/src/commands/template.ts
CHANGED
|
@@ -21,7 +21,8 @@ const sh = promisify(execFile);
|
|
|
21
21
|
// What is sent: the project folder as one archive (the plan, the composition, and every file under assets/, including the user's own
|
|
22
22
|
// pictures, footage and the recorded voice), the rendered video when there is one, and a small picture of each scene. What is left on
|
|
23
23
|
// this machine: out/ (renders and previews), refs/ (reference videos), caches and node_modules.
|
|
24
|
-
|
|
24
|
+
// .remotion is where a project made before 0.10 kept the browser it rendered with: hundreds of megabytes that are not the project.
|
|
25
|
+
export const TEMPLATE_SKIP = ["out", "refs", ".reelkit", ".remotion", ".cache", "node_modules", ".git", ".DS_Store"];
|
|
25
26
|
const VIDEO = "out/video.mp4";
|
|
26
27
|
// Where a project remembers the template it was cloned from, and the last one it was pushed as. Never part of a bundle.
|
|
27
28
|
const LINK = ".reelkit/template.json";
|
|
@@ -56,7 +57,7 @@ export function filmOf(project: Project): TemplateFilm | undefined {
|
|
|
56
57
|
return {
|
|
57
58
|
id: cut(m.id, 80), start: Math.max(0, Math.round(m.startFrame)), frames: Math.max(0, Math.round(m.durationFrames)),
|
|
58
59
|
...(p?.treatment ? { treatment: cut(p.treatment, 60) } : {}), ...(narration ? { narration: cut(narration, 4000) } : {}),
|
|
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
|
+
...(text.length ? { text } : {}), ...(m.words.length ? { words: m.words.slice(0, 600).map((w) => Math.max(0, Math.round(w.startSec * 1000) / 1000)), spoken: m.words.slice(0, 600).map((w) => cut(String(w.word ?? ""), 80)) } : {}),
|
|
60
61
|
...(p?.notes?.trim() ? { notes: cut(p.notes.trim(), 2000) } : {}),
|
|
61
62
|
...(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
63
|
};
|
|
@@ -66,9 +67,18 @@ export function filmOf(project: Project): TemplateFilm | undefined {
|
|
|
66
67
|
voice: plan.voice !== "none",
|
|
67
68
|
...(manifest.captions ? { captions: manifest.captions } : {}),
|
|
68
69
|
...cuesOf(project, manifest, music?.key),
|
|
70
|
+
...soundsOf(project),
|
|
69
71
|
};
|
|
70
72
|
}
|
|
71
73
|
|
|
74
|
+
// The sounds the project pulled from the library, by id: what a timeline can offer to copy when it cannot say which sound a hit is.
|
|
75
|
+
function soundsOf(project: Project): Pick<TemplateFilm, "sounds"> {
|
|
76
|
+
const sounds = Object.entries(project.readJsonOr<Record<string, { kind?: string; title?: string }>>(FILES.library, {}))
|
|
77
|
+
.filter(([, p]) => p.kind === "sfx" || p.kind === "music")
|
|
78
|
+
.map(([id, p]) => ({ id: id.slice(0, 128), title: (p.title ?? id).slice(0, 200), kind: p.kind! })).slice(0, 300);
|
|
79
|
+
return sounds.length ? { sounds } : {};
|
|
80
|
+
}
|
|
81
|
+
|
|
72
82
|
// 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
83
|
// 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
84
|
// track's own beats and length are used.
|
|
@@ -147,6 +157,13 @@ export function filesOf(dir: string): { files: TemplateFile[]; sources: Uint8Arr
|
|
|
147
157
|
return { files, sources: sources && sources.length <= MAX_TEMPLATE_SOURCES_BYTES ? sources : undefined };
|
|
148
158
|
}
|
|
149
159
|
|
|
160
|
+
// The three largest things at the top of the project, for the message that says a project is too large to push.
|
|
161
|
+
function largest(dir: string): string {
|
|
162
|
+
const size = (path: string): number => { try { const st = statSync(path); return st.isDirectory() ? readdirSync(path).reduce((sum, name) => sum + size(join(path, name)), 0) : st.size; } catch { return 0; } };
|
|
163
|
+
const top = readdirSync(dir).filter((name) => !TEMPLATE_SKIP.includes(name)).map((name) => ({ name, bytes: size(join(dir, name)) })).sort((a, b) => b.bytes - a.bytes).slice(0, 3).filter((x) => x.bytes > 1_000_000);
|
|
164
|
+
return top.length ? `The largest parts: ${top.map((x) => `${x.name} (${mb(x.bytes)})`).join(", ")}. ` : "";
|
|
165
|
+
}
|
|
166
|
+
|
|
150
167
|
const readLink = (project: Project): { id?: string; from?: string } => { try { return project.readJsonOr<{ id?: string; from?: string }>(LINK, {}); } catch { return {}; } };
|
|
151
168
|
|
|
152
169
|
export async function templatePush(ctx: Ctx, opts: { name?: string }): Promise<Result> {
|
|
@@ -162,7 +179,7 @@ export async function templatePush(ctx: Ctx, opts: { name?: string }): Promise<R
|
|
|
162
179
|
ctx.log("Packing the project.");
|
|
163
180
|
await sh("tar", ["-czf", bundle, ...TEMPLATE_SKIP.flatMap((x) => ["--exclude", `./${x}`]), "-C", project.dir, "."], { maxBuffer: 1 << 24 });
|
|
164
181
|
const bundleBytes = statSync(bundle).size;
|
|
165
|
-
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.
|
|
182
|
+
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. ${largest(project.dir)}Move out what the video does not use and push again.` };
|
|
166
183
|
const video = project.exists(VIDEO) ? project.path(VIDEO) : undefined;
|
|
167
184
|
const videoBytes = video ? statSync(video).size : 0;
|
|
168
185
|
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.` };
|
package/src/contract/index.ts
CHANGED
|
@@ -194,6 +194,8 @@ 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 spoken words themselves, one for each entry of `words`, so the captions can be laid out in time.
|
|
198
|
+
spoken: z.array(text(80)).max(600).optional(),
|
|
197
199
|
// The plan's visual direction for the scene, and the components the composition times to it (read from its source).
|
|
198
200
|
notes: text(2000).optional(), components: z.array(text(80)).max(60).optional(),
|
|
199
201
|
})).min(1).max(200),
|
|
@@ -209,6 +211,8 @@ export const TemplateFilmSchema = z.object({
|
|
|
209
211
|
// The sound effects by name, when the render recorded them: each one's title, when it starts and how long it plays, in seconds.
|
|
210
212
|
// `id` is the sound's id in the library, when it was pulled from there.
|
|
211
213
|
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(),
|
|
214
|
+
// The sounds the project pulled from the library (effects and music), whether or not the render recorded when each one plays.
|
|
215
|
+
sounds: z.array(z.object({ id: text(128), title: text(200), kind: text(24) })).max(300).optional(),
|
|
212
216
|
// 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
217
|
// many times it appears, and `about` is the comment above a project component.
|
|
214
218
|
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(),
|
|
@@ -103,7 +103,9 @@ export function componentsOf(source: string, opts: { sceneIds: string[]; pulled?
|
|
|
103
103
|
for (const [name, body] of local) if (body === node) { inside = name; here = new Set(); }
|
|
104
104
|
const opening = ts.isJsxSelfClosingElement(node) ? node : ts.isJsxElement(node) ? node.openingElement : undefined;
|
|
105
105
|
if (opening) {
|
|
106
|
-
|
|
106
|
+
// A spread of props ({...fx}) is a bundle, often of every scene's timings, so it names no scene: only the props written out do.
|
|
107
|
+
const own = new Set<string>();
|
|
108
|
+
for (const attr of opening.attributes.properties) if (!ts.isJsxSpreadAttribute(attr)) for (const id of idsIn(attr)) own.add(id);
|
|
107
109
|
here = own.size ? own : here;
|
|
108
110
|
const tag = opening.tagName.getText(sf);
|
|
109
111
|
const root = tag.split(".")[0]!;
|
|
@@ -154,10 +156,40 @@ export function componentsOf(source: string, opts: { sceneIds: string[]; pulled?
|
|
|
154
156
|
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
157
|
}
|
|
156
158
|
}
|
|
159
|
+
// A component of the file's own that is named after a scene ("Hate" for the scene "hate", "Good" for "too-good") is that scene's,
|
|
160
|
+
// however it is timed inside.
|
|
161
|
+
// Its name decides alone: whatever else its props mention, "Hate" is the scene "hate".
|
|
162
|
+
const plain = (text: string) => text.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
163
|
+
const byName = new Set<string>();
|
|
164
|
+
for (const name of scenesOfLocal.keys()) {
|
|
165
|
+
const n = plain(name);
|
|
166
|
+
const hits = [...known].filter((id) => { const i = plain(id); return n.length >= 3 && (i === n || (n.length >= 4 && (i.endsWith(n) || i.startsWith(n))) || (i.length >= 4 && (n.endsWith(i) || n.startsWith(i)))); });
|
|
167
|
+
if (hits.length) { scenesOfLocal.set(name, new Set(hits)); byName.add(name); }
|
|
168
|
+
}
|
|
169
|
+
// With scenes named this way, where a component sits says more than what its props mention (inside "Hate", everything is timed
|
|
170
|
+
// from the same handful of numbers): it belongs to the scenes of the component it is written in, and only a component written
|
|
171
|
+
// straight in the composition is read from its own props. Worked out again from the start on that rule.
|
|
172
|
+
const sceneFor = (use: Use): Iterable<string> => {
|
|
173
|
+
const around = byName.size ? scenesOfLocal.get(use.owner) : undefined;
|
|
174
|
+
return around?.size ? around : use.scenes.size ? use.scenes : (scenesOfLocal.get(use.owner) ?? []);
|
|
175
|
+
};
|
|
176
|
+
if (byName.size) {
|
|
177
|
+
for (const name of scenesOfLocal.keys()) if (!byName.has(name)) scenesOfLocal.set(name, new Set());
|
|
178
|
+
for (let changed = true, rounds = 0; changed && rounds < 12; rounds++) {
|
|
179
|
+
changed = false;
|
|
180
|
+
for (const [name, list] of localUses) {
|
|
181
|
+
if (byName.has(name)) continue;
|
|
182
|
+
const mine = scenesOfLocal.get(name)!;
|
|
183
|
+
for (const use of list) for (const id of sceneFor(use)) if (!mine.has(id)) { mine.add(id); changed = true; }
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
157
187
|
const used = new Map<string, { uses: number; scenes: Set<string> }>();
|
|
188
|
+
// The file's own components are listed too: they are what most compositions are made of.
|
|
189
|
+
for (const name of local.keys()) { const list = localUses.get(name) ?? []; if (list.length) { used.set(name, { uses: list.length, scenes: scenesOfLocal.get(name)! }); origin.set(name, "project"); } }
|
|
158
190
|
for (const [tag, list] of uses) {
|
|
159
191
|
const scenes = new Set<string>();
|
|
160
|
-
for (const use of list) for (const id of
|
|
192
|
+
for (const use of list) for (const id of sceneFor(use)) scenes.add(id);
|
|
161
193
|
used.set(tag, { uses: list.length, scenes });
|
|
162
194
|
}
|
|
163
195
|
|