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 +12 -0
- package/package.json +1 -1
- package/skill/SKILL.md +1 -1
- package/skill/reference/art-styles.md +2 -2
- package/src/cli.ts +18 -0
- package/src/commands/template.ts +38 -3
- package/src/contract/index.ts +6 -1
- package/src/testing/fake-api.ts +2 -1
- package/src/update.ts +48 -0
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
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.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.
|
|
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
|
|
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)")
|
package/src/commands/template.ts
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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 {
|
package/src/contract/index.ts
CHANGED
|
@@ -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.
|
package/src/testing/fake-api.ts
CHANGED
|
@@ -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).`;
|