reelkit-cli 0.12.5 → 0.12.8

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,18 @@
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.8 - 2026-10-10
7
+
8
+ - `reelkit update` checks npm for a newer version and prints the command that installs it, and every command ends with one line on stderr when a newer version is published. The skill tells the agent to run it at the start of a session and to update before going on. Turn the check off with `REELKIT_NO_UPDATE_CHECK=1`.
9
+
10
+ ## 0.12.7 - 2026-10-10
11
+
12
+ - A pushed template carries a small picture of each image and video file, so the file list on your templates page shows what each one is and opens it larger.
13
+
14
+ ## 0.12.6 - 2026-10-10
15
+
16
+ - `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.
17
+
6
18
  ## 0.12.5 - 2026-10-09
7
19
 
8
20
  - 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reelkit-cli",
3
- "version": "0.12.5",
3
+ "version": "0.12.8",
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.5 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.8 and its HyperFrames kit. **Start every session with `reelkit update`.** When it says a newer version is published, run the command it prints (`npm install -g reelkit-cli@latest && reelkit install`) before anything else, then read this skill again: it changed with the CLI. Any other command that ends with a line saying a newer version is published means the same: update, then continue. If the update cannot be installed, tell the user and continue with the installed version. 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
 
@@ -5,7 +5,7 @@ description: Use when the user wants a drawn, illustrated or art-directed film r
5
5
 
6
6
  # Fifteen art styles
7
7
 
8
- These looks continue the catalogue in `reference/styles.md` (they are numbered 14 to 28 so you can offer them in the same list). They come from mg-styles-15 by Vincentwei1021 (MIT): fifteen 10-second films, each the canonical form of one motion-design style, made entirely from code. The originals were web pages rendered frame by frame with synthesised sound; here each one is translated into reelkit's kit, HyperFrames at 30 frames a second, library components and library sounds. The film of each style is at `https://vincentwei1021.github.io/mg-styles-15/#<name>` (the name is in each card's heading), and when the reelkit repository is checked out, the full original prompt is at `mg-styles-15/prompts/<name>.md` and the source at `mg-styles-15/demos/<name>/`. Read the prompt's Creative seed and Signature features when the user picks one of these looks and you have them; the card is enough when you do not.
8
+ These looks continue the catalogue in `reference/styles.md` (they are numbered 14 to 28 so you can offer them in the same list). They come from mg-styles-15 by Vincentwei1021 (MIT): fifteen 10-second films, each the canonical form of one motion-design style, made entirely from code. The originals were web pages rendered frame by frame with synthesised sound; here each one is translated into reelkit's kit, HyperFrames at 30 frames a second, library components and library sounds. Each card below is the recipe. Do not look for the original prompts or demo source in this repository.
9
9
 
10
10
  ## What these looks share
11
11
 
@@ -30,7 +30,7 @@ These looks continue the catalogue in `reference/styles.md` (they are numbered 1
30
30
  **When:** a city, a campus, an office, a data centre, an architecture diagram, a product's parts in a tiny world. **How:** a pale pastel ground (lavender, mint, peach, sky, white; a flat field is right here). True isometric faces from CSS transforms, without three.js: every box is three divs (top, left, right) and each face takes the same formula, `scaleY(0.866)` then `skewX(±30deg)` then `rotate(∓30deg)`; or one SVG with those matrices. Depth is the screen's y: sort the children by grid row plus column and draw in that order. Anything that moves along the grid keeps a 2:1 pixel slope. Build in this order: ground tiles drop in as a wave from the centre, each with a tiny bounce and a delay by its distance (2 frames per ring); buildings grow: the base lands, the walls `scaleY` from 0 with overshoot (`transformOrigin` at the bottom), the roof caps 2 to 3 frames later; trees pop; windows light in sequence; then life: cars loop on the roads, a drone carries a packet, a data stream pulses between buildings, a turbine turns. The camera is the group translating: `Camera` keys with x and y only, never `rotate`, over layers at 1 : 0.8 : 0.6 parallax (foreground clouds fastest). End: the camera pulls back to the whole island floating in the pastel void, and the title is set on the plane, skewed into the same iso space, or the user's logo flat and 2D above it. Components in the library: `IsoGrid` (search "iso grid"), `IsoBox` (search "iso box"), `IsoTitle` (search "iso title"). **Timing:** tiles 1 to 2 seconds, growth 3, life 3, pull-back 2. **Sound:** music "plucky marimba" tech; a "soft click" or "pop" on every tile and building landing (`cuesFor(..., "pop")` grades them); an "airy whoosh" on the pull-back. **Forbidden:** any vanishing point, a camera that rotates, hard black shadows, more than six colours, a building that appears whole. **Bends:** a flat ground; overshoot on growth.
31
31
 
32
32
  ### 17. Soft 3D render (restrained) · `04-3d-render`
33
- **When:** a product ident, the user's logo or wordmark as a candy object, a "satisfying" brand film. Read `reference/three-d.md` first. **How:** `Scene3D` with `mood="studio"` over a pale pastel ground (`BgAurora` at intensity 0.3, or a flat pale field seen through the transparent scene). The hero is `Hero3D` (`kind="logo"` with the user's word, or `"device"` with their screenshot) dropping in on `poses` that land with a jiggle: a pose at `scale` 1.06 on the impact frame, 0.97 four frames later, 1 after ten, so it reads as a soft body settling. Under it a cloner field: `Assemble3D` with `shape="grid"` of rounded blocks, or `Particles3D` from `"plane"`, rippling with a wave the hero's arrival starts (each block offset by a sine of its distance from the impact minus time). Materials come from the mood: clearcoat, two coloured rim lights, a soft floor (`floor="soft"`). The camera moves with a long tail, 80 percent of the move in the first 20 percent: `Scene3D camera={{ keys, lead }}` with a suitable lead. End on one clean hero composition held a second or more, hovering. Components in the library: `PastelCyc` (search "pastel cyc"), `SoftTitle` (search "soft title"). The example's picture is a Blender plate rendered from mg-styles-15/demos/04-3d-render (see the example's blender/README.md); the kit's 3D elements are the route when Blender is not available. **Sound:** "soft thud" and "squish" on the impact, "bubble pop" on the ripple, music "airy cinematic pad". **Forbidden:** hard shadows, saturated primaries, more than one hero object, extruded text beyond the one word, a logo that spins. **Bends:** none beyond the 3D layer itself; this is a restrained look.
33
+ **When:** a product ident, the user's logo or wordmark as a candy object, a "satisfying" brand film. Read `reference/three-d.md` first. **How:** `Scene3D` with `mood="studio"` over a pale pastel ground (`BgAurora` at intensity 0.3, or a flat pale field seen through the transparent scene). The hero is `Hero3D` (`kind="logo"` with the user's word, or `"device"` with their screenshot) dropping in on `poses` that land with a jiggle: a pose at `scale` 1.06 on the impact frame, 0.97 four frames later, 1 after ten, so it reads as a soft body settling. Under it a cloner field: `Assemble3D` with `shape="grid"` of rounded blocks, or `Particles3D` from `"plane"`, rippling with a wave the hero's arrival starts (each block offset by a sine of its distance from the impact minus time). Materials come from the mood: clearcoat, two coloured rim lights, a soft floor (`floor="soft"`). The camera moves with a long tail, 80 percent of the move in the first 20 percent: `Scene3D camera={{ keys, lead }}` with a suitable lead. End on one clean hero composition held a second or more, hovering. Components in the library: `PastelCyc` (search "pastel cyc"), `SoftTitle` (search "soft title"). The example's picture is a Blender plate; the kit's 3D elements are the route when Blender is not available. **Sound:** "soft thud" and "squish" on the impact, "bubble pop" on the ripple, music "airy cinematic pad". **Forbidden:** hard shadows, saturated primaries, more than one hero object, extruded text beyond the one word, a logo that spins. **Bends:** none beyond the 3D layer itself; this is a restrained look.
34
34
 
35
35
  ### 18. Hand-drawn cel (either mode) · `05-cel-boil`
36
36
  **When:** a playful, crafted, personal tone; a title that feels drawn by hand; a mascot. **How:** a warm paper ground (search "paper texture" `--kind image` as a `BgImage` with no dim, or `BgGrain` on a cream base) with `Grain` multiplied at 10 to 15 percent, one static layer on top. Every mark is a drawn line: SVG paths with two or three hand-drawn variants of each, switched by the frame (`variant = Math.floor(frame / 2) % 3`): that is the boil, and it is never random. Animate on twos; fast actions on ones; holds on threes. A fast move gets one smear frame: the shape stretched 150 to 300 percent along its path for exactly one frame. Cel effects over the action, each a cycle of 4 to 8 drawings: a smoke puff curling, sparks, speed lines, a star burst. Fills sit slightly off-register from the lines (2 to 3 px). Three or four colours: ink, one warm, one bright, paper. The title is hand-lettered (`font("permanentMarker")`, or `font("amaticSC")` for Hebrew) and keeps boiling on its hold. Components in the library: `BoilStroke` (search "boil stroke"), `CelFx` (search "cel fx"), `SmearMove` (search "smear move"), `PaperGround` (search "paper ground"). **Timing:** everything on twos; the end held a second or more on threes. **Sound:** music "jazzy pizzicato" or "xylophone"; cartoon effects "fwoosh", "pop", "poof", "sparkle", on the frame. **Forbidden:** smooth springs on the drawn lines (they move in steps), gradients, crisp geometric type, more than four colours, a boil driven by anything but the frame number. **Bends:** stepped motion instead of springs; a boil instead of a breathe.
package/src/cli.ts CHANGED
@@ -17,6 +17,7 @@ import { refAnalyze, refAudio, refDownload, refList } from "./commands/ref";
17
17
  import type { Ctx, Result } from "./context";
18
18
  import { existsSync, readdirSync, readFileSync } from "node:fs";
19
19
  import { fileURLToPath } from "node:url";
20
+ import { newerVersion, UPDATE_COMMAND, updateNotice } from "./update";
20
21
 
21
22
  const ctx: Ctx = {
22
23
  cwd: process.cwd(),
@@ -36,6 +37,14 @@ export function titleOf(args: unknown[]): string {
36
37
  return `reelkit ${names.join(" ")}`.trim();
37
38
  }
38
39
 
40
+ // After a command: one line on stderr when a later version is published, so the agent that ran it updates before going on.
41
+ let said = false;
42
+ async function noticeUpdate(): Promise<void> {
43
+ if (said) return;
44
+ const latest = await newerVersion(process.env, VERSION);
45
+ if (latest) console.error(`\n${updateNotice(latest, VERSION)}`);
46
+ }
47
+
39
48
  // Runs a command, prints its result, and sets the exit code. Progress goes to stderr so --json output stays clean.
40
49
  export function run<A extends unknown[]>(fn: (ctx: Ctx, ...args: A) => Promise<Result>) {
41
50
  return async (...args: A) => {
@@ -45,6 +54,7 @@ export function run<A extends unknown[]>(fn: (ctx: Ctx, ...args: A) => Promise<R
45
54
  const { runInk } = await import("./ui/run");
46
55
  const res = await runInk(titleOf(args), (log) => fn({ ...ctx, log }, ...args));
47
56
  if (!res.ok) process.exitCode = 1;
57
+ await noticeUpdate();
48
58
  return;
49
59
  }
50
60
  try {
@@ -58,6 +68,7 @@ export function run<A extends unknown[]>(fn: (ctx: Ctx, ...args: A) => Promise<R
58
68
  else console.error(message);
59
69
  process.exitCode = 1;
60
70
  }
71
+ await noticeUpdate();
61
72
  };
62
73
  }
63
74
 
@@ -120,6 +131,13 @@ ref.command("list").description("List the references in this project").action(ru
120
131
  program.command("plan").description("Work with plan.json").command("check").description("Validate plan.json and list what to fix or improve").action(run(planCheck));
121
132
 
122
133
  program.command("check").description("Check the composition in src/ without rendering").action(run(check));
134
+ program.command("update").description("Check npm for a newer reelkit-cli and say how to update. Run it at the start of a session").action(run(async () => {
135
+ const latest = await newerVersion(process.env, VERSION, { force: true });
136
+ said = true;
137
+ return latest
138
+ ? { ok: true, data: { current: VERSION, latest, upToDate: false, command: UPDATE_COMMAND }, summary: `reelkit-cli ${latest} is published and this is ${VERSION}. Update now, before any other command: \`${UPDATE_COMMAND}\`. Then read the skill again: it changed with the CLI.` }
139
+ : { ok: true, data: { current: VERSION, upToDate: true }, summary: `reelkit-cli ${VERSION} is the latest version (or npm could not be reached).` };
140
+ }));
123
141
  program.command("install").description("Install the Reelkit skill into your coding agents").option("--agent <id>", "claude, codex, cursor, gemini, agents or all").option("--force", "reinstall even when up to date").action(run((ctx, opts) => install(ctx, opts)));
124
142
  program.command("preview").description("Render preview frames into out/preview/: two per scene (at 30% and 90%) and the frames either side of each scene change, with a report of what carries across").action(run((ctx) => preview(ctx)));
125
143
  program.command("render").description("Render the video to out/video.mp4; new components written in this project are then shared with the library for review").option("--no-share", "do not share new components with the library after this render (also: REELKIT_NO_SHARE=1)")
@@ -5,7 +5,7 @@ import { basename, join, resolve } from "node:path";
5
5
  import { promisify } from "node:util";
6
6
  import { uploadTo } from "../api/client";
7
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";
8
+ import { MAX_TEMPLATE_FILES, MAX_TEMPLATE_PREVIEW_BYTES, MAX_TEMPLATE_PREVIEWS, 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
9
  import type { AssetManifest, ScenePlan } from "../pipeline/schema";
10
10
  import type { MusicRecord } from "../project/music";
11
11
  import { FILES, openProject, Project } from "../project/project";
@@ -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
- export const TEMPLATE_SKIP = ["out", "refs", ".reelkit", "node_modules", ".git", ".DS_Store"];
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";
@@ -127,6 +128,29 @@ async function thumbsOf(video: string, film: TemplateFilm, dir: string): Promise
127
128
  return out;
128
129
  }
129
130
 
131
+ const PICTURE_FILE = /\.(png|jpe?g|webp|gif|avif|bmp|mp4|mov|webm|m4v)$/i;
132
+
133
+ // A small picture of each image and video file, so the website's file list can show what it is. Six are made at a time; one that
134
+ // cannot be read, or comes out too large, is left without a picture.
135
+ async function previewsOf(dir: string, files: TemplateFile[], work: string): Promise<{ path: string; bytes: Uint8Array }[]> {
136
+ const wanted = files.filter((f) => PICTURE_FILE.test(f.path) && f.bytes > 0).slice(0, MAX_TEMPLATE_PREVIEWS);
137
+ const made: ({ path: string; bytes: Uint8Array } | undefined)[] = [];
138
+ let next = 0;
139
+ const worker = async () => {
140
+ while (next < wanted.length) {
141
+ const i = next++;
142
+ const out = join(work, `preview-${i}.jpg`);
143
+ try {
144
+ await sh("ffmpeg", ["-nostdin", "-v", "error", "-y", "-i", join(dir, wanted[i]!.path), "-frames:v", "1", "-vf", "scale='min(640,iw)':-2", "-q:v", "7", out], { timeout: 20_000 });
145
+ const bytes = readFileSync(out);
146
+ if (bytes.length && bytes.length <= MAX_TEMPLATE_PREVIEW_BYTES) made[i] = { path: wanted[i]!.path, bytes: new Uint8Array(bytes) };
147
+ } catch { /* not a picture ffmpeg can read */ }
148
+ }
149
+ };
150
+ await Promise.all(Array.from({ length: 6 }, worker));
151
+ return made.filter((p): p is { path: string; bytes: Uint8Array } => Boolean(p));
152
+ }
153
+
130
154
  const TEXT_FILE = /\.(tsx?|jsx?|mjs|cjs|json|md|txt|css|html|svg|ya?ml)$/i;
131
155
  const MAX_SOURCE_FILE = 200_000;
132
156
 
@@ -156,6 +180,13 @@ export function filesOf(dir: string): { files: TemplateFile[]; sources: Uint8Arr
156
180
  return { files, sources: sources && sources.length <= MAX_TEMPLATE_SOURCES_BYTES ? sources : undefined };
157
181
  }
158
182
 
183
+ // The three largest things at the top of the project, for the message that says a project is too large to push.
184
+ function largest(dir: string): string {
185
+ 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; } };
186
+ 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);
187
+ return top.length ? `The largest parts: ${top.map((x) => `${x.name} (${mb(x.bytes)})`).join(", ")}. ` : "";
188
+ }
189
+
159
190
  const readLink = (project: Project): { id?: string; from?: string } => { try { return project.readJsonOr<{ id?: string; from?: string }>(LINK, {}); } catch { return {}; } };
160
191
 
161
192
  export async function templatePush(ctx: Ctx, opts: { name?: string }): Promise<Result> {
@@ -171,7 +202,7 @@ export async function templatePush(ctx: Ctx, opts: { name?: string }): Promise<R
171
202
  ctx.log("Packing the project.");
172
203
  await sh("tar", ["-czf", bundle, ...TEMPLATE_SKIP.flatMap((x) => ["--exclude", `./${x}`]), "-C", project.dir, "."], { maxBuffer: 1 << 24 });
173
204
  const bundleBytes = statSync(bundle).size;
174
- 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.` };
205
+ 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.` };
175
206
  const video = project.exists(VIDEO) ? project.path(VIDEO) : undefined;
176
207
  const videoBytes = video ? statSync(video).size : 0;
177
208
  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.` };
@@ -187,17 +218,21 @@ export async function templatePush(ctx: Ctx, opts: { name?: string }): Promise<R
187
218
  }
188
219
  const link = readLink(project);
189
220
  const { files, sources } = filesOf(project.dir);
221
+ const previews = await previewsOf(project.dir, files, work);
190
222
  const api = client(ctx);
191
223
  const started = await api("templateStart", {
192
224
  name, ...(plan?.title ? { title: plan.title.slice(0, 200) } : {}), bundleBytes, ...(videoBytes ? { videoBytes } : {}),
193
225
  thumbBytes: thumbs.map((t) => t.length), film, ...(link.from && TEMPLATE_ID.test(link.from) ? { from: link.from } : {}),
194
226
  files, ...(sources ? { sourcesBytes: sources.length } : {}),
227
+ ...(previews.length ? { previews: previews.map((p) => ({ path: p.path, bytes: p.bytes.length })) } : {}),
195
228
  });
196
229
  ctx.log(`Uploading ${mb(bundleBytes + videoBytes)} to your private files.`);
197
230
  await uploadTo(started.bundleUrl, new Uint8Array(readFileSync(bundle)), TEMPLATE_BUNDLE_TYPE);
198
231
  if (video && started.videoUrl) await uploadTo(started.videoUrl, new Uint8Array(readFileSync(video)), "video/mp4");
199
232
  if (sources && started.sourcesUrl) await uploadTo(started.sourcesUrl, sources, "application/json");
200
233
  for (const [i, url] of started.thumbUrls.entries()) if (thumbs[i]) await uploadTo(url, thumbs[i]!, "image/jpeg");
234
+ // The file pictures are a convenience: one that fails to upload does not fail the push.
235
+ for (const [i, url] of (started.previewUrls ?? []).entries()) if (previews[i]) await uploadTo(url, previews[i]!.bytes, "image/jpeg").catch(() => undefined);
201
236
  const { template } = await api("templateCommit", { id: started.id });
202
237
  project.writeJson(LINK, { ...link, id: template.id });
203
238
  return {
@@ -184,6 +184,10 @@ export const TEMPLATE_BUNDLE_TYPE = "application/gzip";
184
184
  // files and open the source ones without unpacking the bundle.
185
185
  export const MAX_TEMPLATE_FILES = 3000;
186
186
  export const MAX_TEMPLATE_SOURCES_BYTES = 3_145_728;
187
+ // A small picture of each image and video file in the bundle (a JPEG, at most 640 pixels wide; a video's is a frame from it), so the
188
+ // file list can show what a media file is. `previews` names the files in the order of their upload links.
189
+ export const MAX_TEMPLATE_PREVIEWS = 300;
190
+ export const MAX_TEMPLATE_PREVIEW_BYTES = 150_000;
187
191
  export const TemplateFileSchema = z.object({ path: z.string().min(1).max(400), bytes: z.number().int().min(0) });
188
192
  export type TemplateFile = z.infer<typeof TemplateFileSchema>;
189
193
  const TemplateId = z.string().regex(/^tpl-[a-z0-9]{12}$/);
@@ -320,8 +324,9 @@ export const routes = {
320
324
  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(),
321
325
  thumbBytes: z.array(z.number().int().min(1).max(MAX_TEMPLATE_THUMB_BYTES)).max(MAX_TEMPLATE_THUMBS), film: TemplateFilmSchema, from: TemplateId.optional(),
322
326
  files: z.array(TemplateFileSchema).max(MAX_TEMPLATE_FILES).optional(), sourcesBytes: z.number().int().min(2).max(MAX_TEMPLATE_SOURCES_BYTES).optional(),
327
+ previews: z.array(z.object({ path: z.string().min(1).max(400), bytes: z.number().int().min(1).max(MAX_TEMPLATE_PREVIEW_BYTES) })).max(MAX_TEMPLATE_PREVIEWS).optional(),
323
328
  }),
324
- z.object({ id: TemplateId, bundleUrl: z.string(), videoUrl: z.string().optional(), thumbUrls: z.array(z.string()), sourcesUrl: z.string().optional() })),
329
+ z.object({ id: TemplateId, bundleUrl: z.string(), videoUrl: z.string().optional(), thumbUrls: z.array(z.string()), sourcesUrl: z.string().optional(), previewUrls: z.array(z.string()).optional() })),
325
330
  // Makes a started template real once its bundle (and its video, when one was named) has been uploaded; otherwise invalid_request.
326
331
  templateCommit: route("POST", "/templates/commit", true, z.object({ id: TemplateId }), z.object({ template: TemplateSchema })),
327
332
  // The caller's own templates, newest first.
@@ -125,7 +125,7 @@ export async function startFakeApi(opts: { voiceoverCharLimit?: number; imageLim
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
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)[] };
128
+ type TemplateJob = { owner: string; record: Template; film: TemplateFilm; committed: boolean; bundle?: Uint8Array; video?: Uint8Array; sources?: Uint8Array; thumbs: (Uint8Array | undefined)[]; previews?: (Uint8Array | undefined)[] };
129
129
  const templates = new Map<string, TemplateJob>();
130
130
  const fileUrl = (blob: Blob) => { const t = secret(); blobs.set(t, blob); return `${origin}/files/${t}`; };
131
131
  const words = (s: string) => new Set(s.toLowerCase().match(/[a-z0-9]+/g) ?? []);
@@ -348,6 +348,7 @@ export async function startFakeApi(opts: { voiceoverCharLimit?: number; imageLim
348
348
  ...(input.videoBytes ? { videoUrl: put("video/mp4", input.videoBytes, () => Boolean(job.video), (b) => { job.video = b; }) } : {}),
349
349
  ...(input.sourcesBytes ? { sourcesUrl: put("application/json", input.sourcesBytes, () => Boolean(job.sources), (b) => { job.sources = b; }) } : {}),
350
350
  thumbUrls: (input.thumbBytes as number[]).map((n, i) => put("image/jpeg", n, () => Boolean(job.thumbs[i]), (b) => { job.thumbs[i] = b; })),
351
+ ...(input.previews ? { previewUrls: (input.previews as { bytes: number }[]).map((p, i) => put("image/jpeg", p.bytes, () => Boolean(job.previews?.[i]), (b) => { (job.previews ??= [])[i] = b; })) } : {}),
351
352
  };
352
353
  },
353
354
  templateCommit: ({ id }, { userId }) => {
package/src/update.ts ADDED
@@ -0,0 +1,48 @@
1
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { configDir } from "./credentials";
4
+
5
+ type Env = Record<string, string | undefined>;
6
+ export const UPDATE_COMMAND = "npm install -g reelkit-cli@latest && reelkit install";
7
+ const LATEST_URL = "https://registry.npmjs.org/reelkit-cli/latest";
8
+ const FRESH_MS = 6 * 60 * 60 * 1000;
9
+
10
+ /** True when version `a` is later than `b` ("0.12.10" is later than "0.12.9"). Anything after the three numbers is ignored. */
11
+ export function later(a: string, b: string): boolean {
12
+ const parts = (v: string) => v.split("-")[0]!.split(".").map((n) => Number.parseInt(n, 10) || 0);
13
+ const [x, y] = [parts(a), parts(b)];
14
+ for (let i = 0; i < 3; i++) if ((x[i] ?? 0) !== (y[i] ?? 0)) return (x[i] ?? 0) > (y[i] ?? 0);
15
+ return false;
16
+ }
17
+
18
+ // The check is off when it is asked to be, under a test runner, and when the CLI talks to a server on this machine.
19
+ const off = (env: Env) => Boolean(env.REELKIT_NO_UPDATE_CHECK || env.VITEST || env.CI || /\/\/(localhost|127\.0\.0\.1|\[::1\])/.test(env.REELKIT_API_URL ?? ""));
20
+
21
+ /**
22
+ * The latest published version when it is later than `current`, otherwise undefined. npm is asked at most every six hours (always
23
+ * with `force`); between asks the answer is read from a small file beside the credentials. It never throws and waits two seconds at most.
24
+ */
25
+ export async function newerVersion(env: Env, current: string, opts: { force?: boolean; fetcher?: typeof fetch; now?: number } = {}): Promise<string | undefined> {
26
+ if (off(env) && !opts.fetcher) return undefined;
27
+ const file = join(configDir(env), "update.json");
28
+ const now = opts.now ?? Date.now();
29
+ let latest: string | undefined;
30
+ try {
31
+ const kept = JSON.parse(readFileSync(file, "utf8")) as { checkedAt?: number; latest?: string };
32
+ if (!opts.force && typeof kept.latest === "string" && typeof kept.checkedAt === "number" && now - kept.checkedAt < FRESH_MS) latest = kept.latest;
33
+ } catch { /* never checked on this machine */ }
34
+ if (!latest) {
35
+ try {
36
+ const response = await (opts.fetcher ?? fetch)(LATEST_URL, { signal: AbortSignal.timeout(2000) });
37
+ const version = response.ok ? ((await response.json()) as { version?: unknown }).version : undefined;
38
+ if (typeof version !== "string" || !/^\d+\.\d+\.\d+/.test(version)) return undefined;
39
+ latest = version;
40
+ try { mkdirSync(configDir(env), { recursive: true, mode: 0o700 }); writeFileSync(file, JSON.stringify({ checkedAt: now, latest })); } catch { /* a read-only home: ask again next time */ }
41
+ } catch { return undefined; }
42
+ }
43
+ return later(latest, current) ? latest : undefined;
44
+ }
45
+
46
+ /** The line every command ends with, on stderr, when a later version is published. It is written for the agent that runs the CLI. */
47
+ export const updateNotice = (latest: string, current: string) =>
48
+ `reelkit-cli ${latest} is published and this is ${current}. Update before you continue: \`${UPDATE_COMMAND}\` (the second command refreshes the skill; read it again after).`;