reelkit-cli 0.5.0 → 0.6.0

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/README.md CHANGED
@@ -54,6 +54,7 @@ reelkit render
54
54
  | `reelkit install` | Install the skill into your coding agents (`--agent <id>`, `--force`) |
55
55
  | `reelkit init [name]` | Set up a video project, in a new folder named after it |
56
56
  | `reelkit assets upload <file>` | Add one of your own files (private unless `--share`); `--green` or `--cutout` also makes a transparent copy of a video (`--cutout` sends it to the server) |
57
+ | `reelkit components share [name...]` | Send components written in this project to the library for review (source, description and an example only); a render does this by itself unless you use `reelkit render --no-share`, `reelkit init <name> --private` or `REELKIT_NO_SHARE=1` |
57
58
  | `reelkit assets search "<query>"` | Search the shared library by meaning; each result shows a match percentage |
58
59
  | `reelkit assets pull <id>` | Download a library item (a component lands in `src/`) |
59
60
  | `reelkit assets voices` | List narration voices |
@@ -67,7 +68,7 @@ reelkit render
67
68
 
68
69
  ## What is shared
69
70
 
70
- Your own files, your plan and your video stay on your machine. Illustrations that the plan marks as generic, and anything you add with `--share`, go to the shared library for review, where other people can reuse them.
71
+ Your own files, your plan and your video stay on your machine. Illustrations that the plan marks as generic, and anything you add with `--share`, go to the shared library for review, where other people can reuse them. New components written for your video are sent for review after a render too (their source, a description and an example, nothing else); turn that off with `reelkit init <name> --private`, `reelkit render --no-share` or `REELKIT_NO_SHARE=1`.
71
72
 
72
73
  A component you pull from the library is code, and it runs on your machine when you preview or render. The library only serves components published by Reelkit.
73
74
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reelkit-cli",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
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",
@@ -31,7 +31,8 @@
31
31
  "./client": "./src/api/client.ts",
32
32
  "./context": "./src/context.ts",
33
33
  "./commands/*": "./src/commands/*.ts",
34
- "./testing/conformance": "./src/testing/conformance.ts"
34
+ "./testing/conformance": "./src/testing/conformance.ts",
35
+ "./static-check": "./src/render/static-check.ts"
35
36
  },
36
37
  "files": [
37
38
  "bin",
package/skill/SKILL.md CHANGED
@@ -74,7 +74,7 @@ Write `plan.json`:
74
74
 
75
75
  Run `reelkit plan check`; it prints the estimated length to tell the user. Fix everything under "Fix these". Act on "Worth improving" unless you have a good reason not to.
76
76
 
77
- **Checkpoint.** Show the user the look, the title, the estimated length, and each scene's narration, the exact on-screen text and one line on the visual, in plain words about what they will see. Wait for a clear yes. A question or a comment is not approval: answer it and ask again.
77
+ **Checkpoint.** Tell the user, in one sentence, that new components you write are shared with the Reelkit library for review after the render (their code, a description and an example, nothing else), and that they can say no: then use `reelkit init <name> --private` or `reelkit render --no-share`, or set REELKIT_NO_SHARE=1. Show the user the look, the title, the estimated length, and each scene's narration, the exact on-screen text and one line on the visual, in plain words about what they will see. Wait for a clear yes. A question or a comment is not approval: answer it and ask again.
78
78
 
79
79
  ### 4. Voice
80
80
  `reelkit assets voiceover --all`. It records each scene and prints the real length of the video. If the user wants a different voice, change `voiceId` in `plan.json` and run it again with `--redo`.
@@ -96,7 +96,7 @@ Read `reference/kit.md`, `reference/remotion-composition.md`, `reference/motion-
96
96
  - Write `src/Video.tsx` and any component files beside it. `Video.tsx` may import only `react`, `remotion`, `reelkit/kit` and sibling components (`./Name`).
97
97
  - `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"]`.
98
98
 
99
- Run `reelkit check` and fix every error until it passes. Then `reelkit preview` (the first preview on a machine downloads a browser once and can take a minute) and look at every frame in `out/preview/`: each scene has two, `early` (30% into the scene) and `late` (90%), so look at both frames of each scene. Look for: text cut off or overflowing, text overlapping other text or the captions, text too small or too low-contrast for a phone, an empty or broken frame, content hidden behind another layer, and any number, price or quote on screen that is not in the plan. A frame is one moment: an element mid-animation is not a problem, but anything that should be fully on screen by the late frame and is not is. Fix real problems and preview again. Go round at least twice, and finish with the studio test in `reference/motion-design.md`. `reelkit preview` also reports continuity: how many scene changes carry something across. Fix every `cut` it names, or keep it as a deliberate choice (a burst of fast hits, the end card); see `reference/continuity.md`.
99
+ Run `reelkit check` and fix every error until it passes. Read its "Worth improving" notes as well: they say when no library component was used, when every scene is type and shapes, and when the opening has no picture. Then `reelkit preview` (the first preview on a machine downloads a browser once and can take a minute) and look at every frame in `out/preview/`: each scene has two, `early` (30% into the scene) and `late` (90%), so look at both frames of each scene. Look for: text cut off or overflowing, text overlapping other text or the captions, text too small or too low-contrast for a phone, an empty or broken frame, content hidden behind another layer, and any number, price or quote on screen that is not in the plan. A frame is one moment: an element mid-animation is not a problem, but anything that should be fully on screen by the late frame and is not is. Fix real problems and preview again. Go round at least twice, and finish with the studio test in `reference/motion-design.md`. `reelkit preview` also reports continuity: how many scene changes carry something across. Fix every `cut` it names, or keep it as a deliberate choice (a burst of fast hits, the end card); see `reference/continuity.md`.
100
100
 
101
101
  You only ever see frames, never the moving video, so the frames are your eyes: look at every one. And whoever built a video is the worst judge of it. If you can start a separate agent, give it only the user's original request and the preview frames (not your explanations) and ask for a score out of 100 per scene, a list of flaws, and a concrete fix for each, in numbers ("raise the title 60 px", "hold the label 0.4 s longer"). Fix, preview, and have the same reviewer look again until every scene passes 90. If two rounds leave a scene under 70, stop and show the user the gap instead of spending more rounds.
102
102
 
@@ -111,9 +111,9 @@ If the render fails, read the error, fix the composition, and run `reelkit check
111
111
 
112
112
  - Search before generating. Reuse beats regenerate.
113
113
  - Never generate app UI. Use the user's real screenshots.
114
- - The user's own files are private. Do not pass `--share` unless they ask you to contribute a file.
114
+ - The user's own files (images, footage, sounds) stay private: pass `--share` only when they ask you to contribute a file. Components are the opposite, by default: new components you write are sent to the library for review after the render (source, description and example only), unless the user says no; then use `reelkit init <name> --private` or `reelkit render --no-share` (or REELKIT_NO_SHARE=1).
115
115
  - Pass the user's facts through unchanged. Never invent a number, statistic, price or quote.
116
- - Keep components driven by props, not hardcoded, so they can be reused.
116
+ - Keep components driven by props, not hardcoded, so they can be reused: nothing of this user's text in them, and every new component starts with a one or two sentence comment saying what it shows and when to use it (`reference/component-authoring.md`).
117
117
  - One stage at a time. Do not write composition code before the user has approved the plan, and do not render before they have approved the preview.
118
118
  - Never say a video is done without having looked at its frames and checked that the file exists.
119
119
  - If a command reports that a quota is used up, tell the user what ran out and when it resets. Do not work around it. A message that says to wait a minute or an hour ("Too many searches", "Too many uploads started") is a throttle, not a used-up quota: wait and retry once instead of stopping.
@@ -43,3 +43,12 @@ const onBeat = (s: { startFrame: number; words: { startSec: number }[] }, i: num
43
43
  ```
44
44
 
45
45
  `Music` ducks under the voice by itself and fades out over the last second; do not set its volume per scene.
46
+
47
+ ## Sound levels
48
+
49
+ Every sound you pull (`sfx` and `music`) is measured when it is pulled and levelled: a sound effect shorter than 3 seconds to a peak of -3 dBFS, a longer sound and the music to about -18 LUFS, never more than 18 dB either way. The gain is in `assets/library.json` (`gainDb`) and in the manifest's `soundGain`, and `Sfx` and `Music` apply it by themselves, so the same `volume` number sounds equally loud for every file. Then:
50
+
51
+ - the voiceover is `1` (the default);
52
+ - a sound effect is `0.2` to `0.45`; the default `0.35` is right for most, and a small tick can go lower;
53
+ - the music is `0.5` when it plays alone and drops to `0.12` under the voice by itself (`volume` and `duckTo` on `Music`); leave both alone unless a preview of the sound is clearly wrong;
54
+ - the render is mastered at the end to -14 LUFS with peaks under -1 dBTP, so do not try to make the whole video louder: balance the parts against each other.
@@ -16,6 +16,7 @@ Run `reelkit assets search "<description>" --kind component`. If a library compo
16
16
  - Size relative to `useVideoConfig()`, never fixed pixels.
17
17
  - Animate from the local `useCurrentFrame()`, and accept an optional `delay` prop in frames.
18
18
  - Declare the props type in the same file and export it.
19
+ - Start every new component with a comment of one or two sentences directly above `export const Name`, saying what it shows and when to use it. It becomes the library description when the component is shared, so it must make sense without this video: "A ring that fills to a percentage with a label in the middle. Use it for one big stat."
19
20
 
20
21
  ## Keep it reusable
21
22
  Write each component so it would suit a video on an unrelated topic:
@@ -23,4 +24,14 @@ Write each component so it would suit a video on an unrelated topic:
23
24
  - **Structural** - a badge, checklist, chart, progress bar, callout, quote card, icon animation, transition.
24
25
  - **Passing** - `reelkit check` passes with it in use.
25
26
 
26
- Avoid one-off layouts, anything with content baked in, or a near-duplicate of a library component. Sharing components back to the library is not available yet.
27
+ Avoid one-off layouts, anything with content baked in, or a near-duplicate of a library component.
28
+
29
+ ## Sharing
30
+ After a successful `reelkit render`, every new component written in the project is sent to the library for review (its source, the comment above it as the description, and an example taken from `Video.tsx`; nothing else), and only the owner publishes it. Make that work:
31
+ - Use the component in `Video.tsx` with plain values where you can (`<NumberBadge value={42} label="users" />`). The example is the first use there; if its props are variables or `urls[...]` or `s.words`, the component is skipped and `reelkit components share NumberBadge --example '<NumberBadge value={42} label="users" />'` sends it with an example you give.
32
+ - Keep the text out of the file: nothing from the user's script, no `assets/user/...` path, and not the video's title. A component that has any is refused.
33
+ - `reelkit components share [name...]` sends by hand (`--describe`, `--example`, `--tags` for one name); it does not wait for a render.
34
+ - The user may say no: `reelkit init <name> --private` for the project, `reelkit render --no-share` for one render, or `REELKIT_NO_SHARE=1`.
35
+
36
+ ## What keeps a component from being shared
37
+ A component is shared only when nothing of this video is written into it. It is skipped when it uses one of the user's files, contains the plan's title, contains any run of eight or more characters that also appears in the narration or the on-screen text, or has a whole sentence written into its code. Pass every word in as a prop, with a short neutral default ("Label", "42"), and the component is both reusable and shareable.
@@ -109,6 +109,7 @@ ScreenOverlay { src: string; durationSec?: number; opacity?: number }
109
109
 
110
110
  Sfx { src: string; at?: number; volume?: number }
111
111
  Plays one sound effect from the shared library, starting at the frame given by "at", counted from the start of the enclosing scene. Pull one with: reelkit assets search "<description>" --kind sfx, then reelkit assets pull <id>.
112
+ A pulled sound is levelled when it is pulled (the manifest's soundGain), and Sfx and Music apply that by themselves: the same volume sounds equally loud for every file. Nothing to pass.
112
113
  <Sfx src={urls["assets/lib/<id>/clip.mp3"]} at={10} volume={0.35} />
113
114
 
114
115
  Music { src: string; volume?: number; duckTo?: number }
package/src/cli.ts CHANGED
@@ -3,6 +3,7 @@ import { ApiFailure } from "./api/client";
3
3
  import { assetsGenClip, assetsGenImage, assetsPull, assetsSearch, assetsUpload, assetsVoiceover, assetsVoices } from "./commands/assets";
4
4
  import { authLogin, authLogout, whoami } from "./commands/auth";
5
5
  import { check, preview, render } from "./commands/build";
6
+ import { componentsShare } from "./commands/components";
6
7
  import { init } from "./commands/init";
7
8
  import { install } from "./commands/install";
8
9
  import { openInBrowser } from "./open";
@@ -65,7 +66,7 @@ auth.command("login").description("Log in to Reelkit: opens the login page in yo
65
66
  auth.command("logout").description("Log out and remove the stored token").action(run(authLogout));
66
67
  program.command("whoami").description("Show your account, quota and contributions").action(run(whoami));
67
68
 
68
- program.command("init [name]").description("Set up a video project (in a new folder named after it, or in this folder)").option("--aspect <aspect>", "9:16, 16:9 or 1:1").action(run(init));
69
+ program.command("init [name]").description("Set up a video project (in a new folder named after it, or in this folder)").option("--aspect <aspect>", "9:16, 16:9 or 1:1").option("--private", "do not share new components with the library after a render (writes shareComponents: false)").action(run(init));
69
70
 
70
71
  const assets = program.command("assets").description("Find, add and generate the files a video needs");
71
72
  assets.command("upload <file>").description("Add one of your own files to this project (private unless --share; with --cutout the video is sent to the Reelkit server to be processed)")
@@ -107,7 +108,12 @@ program.command("plan").description("Work with plan.json").command("check").desc
107
108
  program.command("check").description("Check the composition in src/ without rendering").action(run(check));
108
109
  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)));
109
110
  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)));
110
- program.command("render").description("Render the video to out/video.mp4").action(run((ctx) => render(ctx)));
111
+ 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)")
112
+ .action(run((ctx, opts: { share?: boolean }) => render(ctx, { noShare: opts.share === false })));
113
+ const components = program.command("components").description("Components written in this project");
114
+ components.command("share [names...]").description("Share components written in this project with the library for review (all of them with no name): their source, a description and an example are sent, nothing else")
115
+ .option("--describe <text>", "what it shows and when to use it (one component)").option("--example <jsx>", "an example use with plain values, such as '<StatRing value={42} />' (one component)").option("--tags <tags>", "comma-separated tags, 3 to 6 (one component)")
116
+ .action(run((ctx, names: string[], opts) => componentsShare(ctx, names, opts)));
111
117
 
112
118
  // Parses argv and runs the chosen command. Importing this module parses nothing; bin/reelkit.mjs calls main.
113
119
  export async function main(argv: string[]): Promise<void> {
@@ -9,11 +9,13 @@ import { GreenTooDullError, keyGreen } from "../project/chromakey";
9
9
  import { buildManifest, voiceoverStale, type ClipRecord, type Voiceover } from "../project/manifest";
10
10
  import { loadPlan } from "./plan";
11
11
  import { FILE_NAME } from "../render/validate";
12
+ import { levelOf } from "../project/loudness";
12
13
  import { measureMusic, type MusicRecord } from "../project/music";
13
14
  import { probeFile } from "../project/probe";
14
15
  import { FILES, openProject, type Project } from "../project/project";
15
16
 
16
- export type LibraryEntry = { path: string; kind: string; title: string; meta: Record<string, unknown> };
17
+ // gainDb is the levelling of a sound (sfx or music), in dB, measured when it was pulled: see src/project/loudness.ts.
18
+ export type LibraryEntry = { path: string; kind: string; title: string; meta: Record<string, unknown>; gainDb?: number };
17
19
 
18
20
  // A failure to lay out the timeline after a paid call must not hide that the call succeeded and was saved.
19
21
  function tryManifest(project: Project, plan: ScenePlan): { manifest?: AssetManifest; note?: string } {
@@ -106,7 +108,7 @@ export async function assetsUpload(
106
108
  if (opts.green && opts.cutout) return { ok: false, summary: "Use --green or --cutout, not both: --green keys a green background on this machine for free, --cutout removes any background on the server." };
107
109
  if (opts.resume && !opts.cutout) return { ok: false, summary: "--resume goes with --cutout: it keeps waiting for a cutout that was already started." };
108
110
  if (opts.resume) return resumeUploadCutout(ctx, project, file, opts.resume, deps);
109
- if (opts.share && opts.kind === "component") return { ok: false, summary: "Components cannot be shared from the CLI yet. Share images, overlays, sound effects, music or clips." };
111
+ if (opts.share && opts.kind === "component") return { ok: false, summary: "Components are shared with `reelkit components share`, not with assets upload. Share images, overlays, sound effects, music or clips here." };
110
112
  const source = resolve(ctx.cwd, file);
111
113
  let probe: Awaited<ReturnType<typeof probeFile>>;
112
114
  try {
@@ -201,10 +203,23 @@ async function resumeUploadCutout(ctx: Ctx, project: Project, file: string, id:
201
203
  return { ...finished, summary: `Resumed ${record.filename} (${record.id}). ${finished.summary}` };
202
204
  }
203
205
 
206
+ export type SearchRecord = { q: string; kind: string | null; at: string; top: { id: string; match: number }[] };
207
+ const MAX_SEARCHES = 50;
208
+
209
+ // What was searched for in this project, so `reelkit check` can say whether the library was looked at. A search outside a project is not recorded.
210
+ function recordSearch(ctx: Ctx, q: string, kind: string | null, items: { id: string; match: number }[]) {
211
+ try {
212
+ const project = openProject(ctx.cwd);
213
+ const all = project.readJsonOr<SearchRecord[]>(FILES.searches, []);
214
+ project.writeJson(FILES.searches, [...all, { q, kind, at: new Date().toISOString(), top: items.slice(0, 3).map((i) => ({ id: i.id, match: i.match })) }].slice(-MAX_SEARCHES));
215
+ } catch { /* not a project, or the file is unreadable: the search itself still works */ }
216
+ }
217
+
204
218
  export async function assetsSearch(ctx: Ctx, query: string, opts: { kind?: string; limit?: string }): Promise<Result> {
205
219
  const kind = opts.kind === undefined ? undefined : LibraryKindSchema.safeParse(opts.kind);
206
220
  if (kind && !kind.success) return { ok: false, summary: `Unknown kind "${opts.kind}". Use one of: ${LibraryKindSchema.options.join(", ")}.` };
207
221
  const { items } = await client(ctx)("librarySearch", { q: query, ...(kind?.success ? { kind: kind.data } : {}), ...(opts.limit ? { limit: Number(opts.limit) } : {}) });
222
+ recordSearch(ctx, query, kind?.success ? kind.data : null, items);
208
223
  return {
209
224
  ok: true, data: { items },
210
225
  summary: items.length
@@ -262,7 +277,10 @@ export async function assetsPull(ctx: Ctx, id: string, opts: { scene?: string; f
262
277
  }
263
278
  const path = `assets/lib/${id}/${pulled.filename}`;
264
279
  await download(pulled.url, project.path(path));
265
- recordPull(project, id, { path, kind: pulled.item.kind, title: pulled.item.title, meta: pulled.item.meta });
280
+ // A sound is levelled when it is pulled, so that the same volume number sounds equally loud for every file. A file that cannot be
281
+ // measured is used as it is.
282
+ const level = pulled.item.kind === "sfx" || pulled.item.kind === "music" ? await levelOf(project.path(path)) : undefined;
283
+ recordPull(project, id, { path, kind: pulled.item.kind, title: pulled.item.title, meta: pulled.item.meta, ...(level ? { gainDb: level.gainDb } : {}) });
266
284
  // A clip filmed on green also comes keyed, so it can be laid over a background.
267
285
  const green = pulled.item.kind === "clip" && pulled.item.meta.greenScreen === true;
268
286
  const keyedPath = green ? keyedPathOf(path) : undefined;
@@ -276,7 +294,7 @@ export async function assetsPull(ctx: Ctx, id: string, opts: { scene?: string; f
276
294
  } else setSceneImage(project, opts.scene, path);
277
295
  if (project.exists(FILES.plan)) note = tryManifest(project, loadPlan(project)).note;
278
296
  }
279
- if (pulled.item.kind === "music" && opts.music) return pullMusic(project, id, pulled.item.title, path);
297
+ if (pulled.item.kind === "music" && opts.music) return pullMusic(project, id, pulled.item.title, path, level?.gainDb ?? 0);
280
298
  const musicHint = pulled.item.kind === "music" ? ` To make it the video's track, with scene changes on its beat, run \`reelkit assets pull ${id} --music\`.` : "";
281
299
  const refs = keyedPath ? ` The original is urls["${path}"]; the keyed copy with a transparent background is urls["${keyedPath}"].` : ` Reference it in the composition as urls["${path}"].`;
282
300
  return {
@@ -289,12 +307,12 @@ export async function assetsPull(ctx: Ctx, id: string, opts: { scene?: string; f
289
307
  }
290
308
 
291
309
  // Makes a pulled track the video's one music track: measured here, saved in assets/music.json, and the timeline is laid out again around it.
292
- async function pullMusic(project: Project, id: string, title: string, path: string): Promise<Result> {
310
+ async function pullMusic(project: Project, id: string, title: string, path: string, gainDb: number): Promise<Result> {
293
311
  let measured: Awaited<ReturnType<typeof measureMusic>>;
294
312
  try { measured = await measureMusic(project.path(path)); }
295
313
  catch (e) { return { ok: false, summary: `${id} was downloaded to ${path} but could not be read as audio (${e instanceof Error ? e.message : String(e)}). Pull a different track.` }; }
296
314
  const previous = project.readJsonOr<MusicRecord | undefined>(FILES.music, undefined);
297
- const record: MusicRecord = { key: path, id, title, ...measured, gainDb: 0 };
315
+ const record: MusicRecord = { key: path, id, title, ...measured, gainDb };
298
316
  project.writeJson(FILES.music, record);
299
317
  const note = project.exists(FILES.plan) ? tryManifest(project, loadPlan(project)).note : undefined;
300
318
  const beat = record.bpm
@@ -7,9 +7,13 @@ import type { AssetManifest, ScenePlan } from "../pipeline/schema";
7
7
  import { compareFrames, SAMPLE_WIDTH, summariseContinuity, type Boundary } from "../render/continuity";
8
8
  import { buildManifest, missingAssets } from "../project/manifest";
9
9
  import { beatReport } from "../project/music";
10
- import { openProject, type Project } from "../project/project";
10
+ import { FILES, openProject, type Project } from "../project/project";
11
+ import { structureNotes } from "../pipeline/review";
12
+ import type { SearchRecord } from "./assets";
13
+ import { libraryUse, shareAfterRender } from "./components";
11
14
  import { mediaUrls, serveDir } from "../project/serve";
12
15
  import { bundleProject, disposeBundle, ensureRenderBrowser, renderStills, renderVideo, writeEntry, type EnsureBrowser } from "../render/render";
16
+ import { MASTER_LUFS, MASTER_TRUE_PEAK_DB, masterLoudness, type Master, type Mastered } from "../render/master";
13
17
  import { FILE_NAME, MAIN_FILE, jsxUses, staticCheck, typecheck } from "../render/validate";
14
18
  import { loadPlan } from "./plan";
15
19
 
@@ -46,6 +50,12 @@ function compositionNotes(project: Project, plan: ScenePlan): string[] {
46
50
  const rendersCaptions = sources.some(([n, src]) => jsxUses(src, n, "Captions").length > 0);
47
51
  if (plan.captions === "none" && rendersCaptions) notes.push('The plan says captions: "none" but the composition renders <Captions>. Remove them, or change the plan if the user wants captions.');
48
52
  if ((plan.captions === "word" || plan.captions === "phrase") && !rendersCaptions) notes.push(`The plan asks for captions (${plan.captions}) but the composition renders no <Captions>. Add <Captions words={s.words} group={manifest.captions} /> in each scene, or set captions to "none" in the plan if the user does not want them.`);
53
+ notes.push(...structureNotes(plan, { footage: project.footage() }));
54
+ const { pulled, written } = libraryUse(project);
55
+ if (!pulled.length && plan.scenes.length >= 4) {
56
+ const searched = project.readJsonOr<SearchRecord[]>(FILES.searches, []).filter((s) => s.kind === "component").length;
57
+ notes.push(`No library component is used (${searched === 0 ? "no component search was made" : `${searched} component search${searched === 1 ? "" : "es"} made`} in this project). Search before writing: \`reelkit assets search "<what the scene shows>" --kind component\`.`);
58
+ }
49
59
  return notes;
50
60
  }
51
61
 
@@ -57,7 +67,7 @@ export async function check(ctx: Ctx): Promise<Result> {
57
67
  if (errors.length) return failed(errors);
58
68
  const shouldImprove = compositionNotes(project, loadPlan(project));
59
69
  return {
60
- ok: true, data: { errors: [], shouldImprove },
70
+ ok: true, data: { errors: [], shouldImprove, library: libraryUse(project) },
61
71
  summary: ["The composition passes. Run `reelkit preview` to see it.", ...(shouldImprove.length ? [`Worth improving:\n- ${shouldImprove.join("\n- ")}`] : [])].join("\n"),
62
72
  };
63
73
  }
@@ -185,7 +195,7 @@ export async function preview(ctx: Ctx, deps: { toJpeg?: (png: string, jpg: stri
185
195
  }
186
196
  }
187
197
 
188
- export async function render(ctx: Ctx, deps: { ensureBrowser?: EnsureBrowser } = {}): Promise<Result> {
198
+ export async function render(ctx: Ctx, deps: { ensureBrowser?: EnsureBrowser; master?: Master; noShare?: boolean } = {}): Promise<Result> {
189
199
  const project = openProject(ctx.cwd);
190
200
  const { errors, manifest } = inspect(project);
191
201
  if (!manifest) return failed(errors);
@@ -197,5 +207,18 @@ export async function render(ctx: Ctx, deps: { ensureBrowser?: EnsureBrowser } =
197
207
  return failed([`Render failed: ${e instanceof Error ? e.message : String(e)}`]);
198
208
  }
199
209
  const seconds = manifest.totalFrames / manifest.fps;
200
- return { ok: true, data: { path, seconds }, summary: `Video: ${project.path(path)} (${seconds.toFixed(1)}s, ${manifest.width}x${manifest.height})` };
210
+ // One last pass brings the whole programme to the loudness platforms play at. If it fails the render is still the render.
211
+ let loudness: Mastered | undefined;
212
+ let masterNote = "";
213
+ try {
214
+ loudness = await (deps.master ?? masterLoudness)(project.path(path));
215
+ masterNote = ` Mastered to ${loudness.outputLufs} LUFS (it was ${loudness.inputLufs}), peaks under ${MASTER_TRUE_PEAK_DB} dBTP.`;
216
+ } catch (e) {
217
+ masterNote = ` It was not mastered to ${MASTER_LUFS} LUFS (${e instanceof Error ? e.message : String(e)}); the video is kept as rendered.`;
218
+ }
219
+ const share = await shareAfterRender(ctx, project, { noShare: deps.noShare });
220
+ return {
221
+ ok: true, data: { path, seconds, ...(loudness ? { loudness } : {}), ...(share.data ? { shared: share.data.shared.map((s) => s.name) } : {}) },
222
+ summary: [`Video: ${project.path(path)} (${seconds.toFixed(1)}s, ${manifest.width}x${manifest.height}).${masterNote}`, ...share.lines].join("\n"),
223
+ };
201
224
  }
@@ -0,0 +1,220 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import ts from "typescript";
5
+ import { uploadTo } from "../api/client";
6
+ import { client, type Ctx, type Result } from "../context";
7
+ import { MAX_COMPONENT_BYTES, MAX_COMPONENT_EXAMPLE, MIN_COMPONENT_DESCRIPTION } from "../contract";
8
+ import { configDir, loadCredentials } from "../credentials";
9
+ import { FILES, openProject, type Project } from "../project/project";
10
+ import { componentName, FILE_NAME, jsxUses, MAIN_FILE, staticCheck } from "../render/validate";
11
+
12
+ export type SharedRecord = { name: string; libraryId: string; hash: string };
13
+ type Prepared = { name: string; file: string; source: string; hash: string; description: string; example: string; tags: string[] };
14
+ export type ShareOutcome = { shared: { name: string; libraryId: string; file: string; description: string; example: string }[]; skipped: { name: string; reason: string }[]; failed?: string };
15
+
16
+ const STOP_WORDS = new Set(["that", "this", "with", "from", "when", "have", "your", "into", "than", "then", "each", "show", "shows", "used", "uses", "use", "very", "which", "there", "their", "about", "over", "under", "while", "where", "also", "onto"]);
17
+
18
+ // The components in src/ that came from the library, and the ones written in this project.
19
+ export function libraryUse(project: Project): { pulled: string[]; written: string[] } {
20
+ const entries = Object.values(project.readJsonOr<Record<string, { path: string; kind: string }>>(FILES.library, {}));
21
+ const names = readdirSync(project.path("src")).filter((f) => FILE_NAME.test(f) && f !== MAIN_FILE);
22
+ const pulled = names.filter((n) => entries.some((e) => e.kind === "component" && e.path === `src/${n}`));
23
+ return { pulled: pulled.map(componentName), written: names.filter((n) => !pulled.includes(n)).map(componentName) };
24
+ }
25
+
26
+ const sf = (source: string, file: string) => ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
27
+
28
+ // The comment block directly above `export const <name>`, as plain words.
29
+ export function leadingComment(source: string, name: string): string | undefined {
30
+ const file = sf(source, `${name}.tsx`);
31
+ const stmt = file.statements.find((s) => ts.isVariableStatement(s) && s.declarationList.declarations.some((d) => ts.isIdentifier(d.name) && d.name.text === name));
32
+ if (!stmt) return undefined;
33
+ const ranges = ts.getLeadingCommentRanges(source, stmt.getFullStart()) ?? [];
34
+ const block: ts.CommentRange[] = [];
35
+ for (let i = ranges.length - 1; i >= 0; i--) {
36
+ const r = ranges[i]!;
37
+ // Only a comment with no blank line between it and what follows belongs to the declaration.
38
+ const gap = source.slice(r.end, i === ranges.length - 1 ? stmt.getStart() : ranges[i + 1]!.pos);
39
+ if ((gap.match(/\n/g) ?? []).length > 1) break;
40
+ block.unshift(r);
41
+ }
42
+ const text = block.map((r) => source.slice(r.pos, r.end).replace(/^\/\*+|\*+\/$/g, "").split("\n").map((l) => l.replace(/^\s*(\/\/+|\*+)\s?/, "").trim()).join(" ")).join(" ").replace(/\s+/g, " ").trim();
43
+ return text || undefined;
44
+ }
45
+
46
+ // Every piece of text a file writes out: strings, template text and JSX text. Names and comments are not text.
47
+ export function literalTexts(source: string, file: string): string[] {
48
+ const out: string[] = [];
49
+ const visit = (n: ts.Node) => {
50
+ if (ts.isStringLiteralLike(n)) out.push(n.text);
51
+ else if (ts.isTemplateHead(n) || ts.isTemplateMiddle(n) || ts.isTemplateTail(n)) out.push(n.text);
52
+ else if (ts.isJsxText(n) && n.text.trim()) out.push(n.text.trim());
53
+ ts.forEachChild(n, visit);
54
+ };
55
+ visit(sf(source, file));
56
+ return out;
57
+ }
58
+
59
+ const cutShort = (t: string) => (t.length > 40 ? `${t.slice(0, 40)}…` : t);
60
+
61
+ const sharesRun = (a: string, b: string, n: number): boolean => {
62
+ for (let i = 0; i + n <= b.length; i++) if (a.includes(b.slice(i, i + n))) return true;
63
+ return false;
64
+ };
65
+
66
+ // A cheap guard against sharing something that belongs to this user: their own files, their video's title, their words.
67
+ export function privateReason(source: string, file: string, plan: { title?: string; scenes?: { narration?: string; onScreenText?: string[] }[] }): string | undefined {
68
+ const texts = literalTexts(source, file);
69
+ if (texts.some((t) => t.startsWith("assets/user/"))) return "it uses one of your own files (assets/user/...). A shared component takes its pictures and text from props.";
70
+ const title = plan.title?.trim().toLowerCase();
71
+ if (title && title.length >= 4 && texts.some((t) => t.toLowerCase().includes(title))) return "it contains this video's title. A shared component takes its text from props.";
72
+ const norm = (s: string) => s.replace(/\s+/g, " ").trim();
73
+ for (const t of texts.map(norm).filter((t) => t.length > 60)) {
74
+ if ((plan.scenes ?? []).some((s) => sharesRun(norm(s.narration ?? ""), t, 61) || sharesRun(t, norm(s.narration ?? ""), 61))) return "it contains a long piece of this video's narration. A shared component takes its text from props.";
75
+ }
76
+ // Anything written into the component leaves the machine with it, so a component that carries this video's own words is not
77
+ // shared at all, however short they are: a customer's name or a headline is eight characters as easily as eighty. Two rules, both
78
+ // erring toward not sharing: text that also appears in the plan, and any whole sentence written into the code.
79
+ const low = (v: string) => norm(v).toLowerCase();
80
+ const planTexts = (plan.scenes ?? []).flatMap((sc) => [sc.narration ?? "", ...(sc.onScreenText ?? [])]).map(low).filter(Boolean);
81
+ for (const t of texts.map(low)) {
82
+ if (t.length >= 8 && planTexts.some((p) => p.includes(t))) return `it contains words from this video ("${cutShort(t)}"). A shared component takes its text from props.`;
83
+ // A font stack, a transform or a gradient is several words too: text with code punctuation, or a comma list of short items, is not a sentence.
84
+ const codeLike = /[():;%#{}=<>]/.test(t) || (t.split(",").length >= 3 && t.split(",").every((part) => part.trim().split(" ").length <= 3));
85
+ if (!codeLike && t.split(" ").filter((w) => /\p{L}{2,}/u.test(w)).length >= 5) return `it has a sentence written into it ("${cutShort(t)}"). A shared component takes its text from props.`;
86
+ }
87
+ return undefined;
88
+ }
89
+
90
+ // Lowercase words, 3 to 6: the words of the name first, then the longer words of the description.
91
+ export function deriveTags(name: string, description: string): string[] {
92
+ const fromName = name.replace(/([a-z0-9])([A-Z])/g, "$1 $2").toLowerCase().split(/\s+/);
93
+ const fromText = description.toLowerCase().match(/[a-z]{4,}/g) ?? [];
94
+ const tags = [...new Set([...fromName, ...fromText.filter((w) => !STOP_WORDS.has(w))])].filter((t) => t.length <= 40);
95
+ for (const filler of ["component", "motion", "video"]) if (tags.length < 3 && !tags.includes(filler)) tags.push(filler);
96
+ return tags.slice(0, 6);
97
+ }
98
+
99
+ function prepare(project: Project, name: string, opts: { describe?: string; example?: string; tags?: string }, plan: Parameters<typeof privateReason>[2], siblings: string[]): Prepared | { name: string; reason: string } {
100
+ const file = `${name}.tsx`;
101
+ const skip = (reason: string) => ({ name, reason });
102
+ if (!FILE_NAME.test(file) || file === MAIN_FILE || !project.exists(`src/${file}`)) return skip(`there is no component file src/${file}.`);
103
+ const source = readFileSync(project.path(`src/${file}`), "utf8");
104
+ if (Buffer.byteLength(source) > MAX_COMPONENT_BYTES) return skip(`it is larger than ${MAX_COMPONENT_BYTES / 1024} KB.`);
105
+ const problems = staticCheck(source, file, siblings);
106
+ if (problems.length) return skip(`it does not pass the check (${problems[0]}). Fix it with \`reelkit check\` first.`);
107
+ const reason = privateReason(source, file, plan);
108
+ if (reason) return skip(reason);
109
+ const description = (opts.describe ?? leadingComment(source, name) ?? "").trim();
110
+ if (description.length < MIN_COMPONENT_DESCRIPTION) return skip(`it has no description. Put a sentence or two above \`export const ${name}\` saying what it shows and when to use it${opts.describe === undefined ? ", or pass --describe" : ""}.`);
111
+ let example = opts.example?.trim();
112
+ if (opts.example !== undefined && !example?.startsWith(`<${name}`)) return skip(`--example must start with <${name}.`);
113
+ if (!example) {
114
+ const main = project.exists(`src/${MAIN_FILE}`) ? readFileSync(project.path(`src/${MAIN_FILE}`), "utf8") : "";
115
+ const use = jsxUses(main, MAIN_FILE, name)[0];
116
+ if (!use) return skip(`<${name}> is not used in src/${MAIN_FILE}, so there is no example to take. Pass --example '<${name} ... />'.`);
117
+ if (!use.literal) return skip(`its use in src/${MAIN_FILE} has props that are not plain values (variables or data from the video). Pass --example '<${name} ... />' with plain values.`);
118
+ example = use.example;
119
+ }
120
+ if (example.length > MAX_COMPONENT_EXAMPLE) return skip(`its example is longer than ${MAX_COMPONENT_EXAMPLE} characters. Pass a shorter --example.`);
121
+ const tags = opts.tags ? opts.tags.split(",").map((t) => t.trim().toLowerCase()).filter(Boolean) : deriveTags(name, description);
122
+ return { name, file, source, hash: createHash("sha256").update(source).digest("hex"), description, example, tags };
123
+ }
124
+
125
+ // Which components a share would send, and why any other is left out. Nothing is sent here.
126
+ export function prepareShare(project: Project, names: string[] | undefined, opts: { describe?: string; example?: string; tags?: string } = {}): { ready: Prepared[]; skipped: { name: string; reason: string }[] } {
127
+ const plan = project.readJsonOr<Parameters<typeof privateReason>[2]>(FILES.plan, {});
128
+ const siblings = readdirSync(project.path("src")).filter((f) => f.endsWith(".tsx"));
129
+ const { pulled, written } = libraryUse(project);
130
+ const done = project.readJsonOr<SharedRecord[]>(FILES.shared, []);
131
+ const ready: Prepared[] = [], skipped: { name: string; reason: string }[] = [];
132
+ for (const name of names?.length ? names : written) {
133
+ if (pulled.includes(name)) { skipped.push({ name, reason: "it was pulled from the library, so it is already there." }); continue; }
134
+ const p = prepare(project, name, names?.length === 1 ? opts : {}, plan, siblings);
135
+ if ("reason" in p) { skipped.push(p); continue; }
136
+ // The same source is not sent twice.
137
+ if (done.some((d) => d.name === name && d.hash === p.hash)) continue;
138
+ ready.push(p);
139
+ }
140
+ return { ready, skipped };
141
+ }
142
+
143
+ async function send(ctx: Ctx, project: Project, ready: Prepared[]): Promise<ShareOutcome["shared"]> {
144
+ const api = client(ctx);
145
+ const shared: ShareOutcome["shared"] = [];
146
+ for (const c of ready) {
147
+ // Only the component's own name, source, description, example and tags leave this machine: no project name, path or plan text.
148
+ const bytes = new TextEncoder().encode(c.source);
149
+ const up = await api("libraryUpload", { kind: "component", title: c.name, description: c.description, tags: c.tags, meta: { example: c.example }, filename: c.file, contentType: "text/plain", bytes: bytes.length, shareable: true });
150
+ await uploadTo(up.uploadUrl, new Uint8Array(bytes), "text/plain");
151
+ const libraryId = (await api("libraryCommit", { id: up.id })).item.id;
152
+ project.writeJson(FILES.shared, [...project.readJsonOr<SharedRecord[]>(FILES.shared, []).filter((d) => d.name !== c.name), { name: c.name, libraryId, hash: c.hash }]);
153
+ shared.push({ name: c.name, libraryId, file: c.file, description: c.description, example: c.example });
154
+ }
155
+ return shared;
156
+ }
157
+
158
+ export const STOP_SHARING = "To stop this, run `reelkit render --no-share`, set REELKIT_NO_SHARE=1, or start new projects with `reelkit init <name> --private`.";
159
+
160
+ const sentLines = (shared: ShareOutcome["shared"]) => shared.map((s) => ` ${s.file}: ${s.description} Example: ${s.example}`);
161
+ const skippedLines = (skipped: ShareOutcome["skipped"]) => skipped.map((s) => ` Not shared: ${s.name}: ${s.reason}`);
162
+
163
+ // `reelkit components share [name...]`: an explicit request, so it shares at once.
164
+ export async function componentsShare(ctx: Ctx, names: string[], opts: { describe?: string; example?: string; tags?: string }): Promise<Result> {
165
+ const project = openProject(ctx.cwd);
166
+ if (names.length !== 1 && (opts.describe !== undefined || opts.example !== undefined || opts.tags !== undefined)) return { ok: false, summary: "--describe, --example and --tags go with exactly one component name: reelkit components share StatRing --describe \"...\"." };
167
+ if (!loadCredentials(ctx.env)) return { ok: false, summary: "You are not logged in. Run `reelkit auth login`, then share again." };
168
+ const { ready, skipped } = prepareShare(project, names, opts);
169
+ const shared = await send(ctx, project, ready);
170
+ const data: ShareOutcome = { shared, skipped };
171
+ if (!shared.length && !skipped.length) return { ok: true, data, summary: "Nothing to share: every component written in this project has already been shared, or there are none." };
172
+ return {
173
+ ok: shared.length > 0 || skipped.length === 0, data,
174
+ summary: [
175
+ shared.length ? `Shared ${shared.length} component${shared.length === 1 ? "" : "s"} with the Reelkit library for review: ${shared.map((s) => s.name).join(", ")}. Nothing else from this project was sent.` : "No component was shared.",
176
+ ...sentLines(shared), ...skippedLines(skipped),
177
+ ].join("\n"),
178
+ };
179
+ }
180
+
181
+ // The notice a person sees once per machine before components start to be shared by a render.
182
+ export const SHARING_NOTICE = `New components you write are shared with the Reelkit library for review after a render, starting with the next one: each component's source, a description and an example, and nothing else (no project name, file paths or script). ${STOP_SHARING}`;
183
+
184
+ const noticesPath = (env: Ctx["env"]) => join(configDir(env), "notices.json");
185
+ function sharingNoticeShown(env: Ctx["env"]): boolean {
186
+ try { return typeof (JSON.parse(readFileSync(noticesPath(env), "utf8")) as { componentSharing?: unknown }).componentSharing === "string"; } catch { return false; }
187
+ }
188
+ function recordSharingNotice(env: Ctx["env"]): boolean {
189
+ try {
190
+ let all: Record<string, unknown> = {};
191
+ try { all = JSON.parse(readFileSync(noticesPath(env), "utf8")) as Record<string, unknown>; } catch { /* a new file */ }
192
+ mkdirSync(configDir(env), { recursive: true, mode: 0o700 });
193
+ writeFileSync(noticesPath(env), JSON.stringify({ ...all, componentSharing: new Date().toISOString() }, null, 2));
194
+ return true;
195
+ } catch { return false; }
196
+ }
197
+
198
+ // After a render: share the new components unless the person has opted out. It never fails the render; it returns the lines to add to its summary.
199
+ export async function shareAfterRender(ctx: Ctx, project: Project, opts: { noShare?: boolean }): Promise<{ lines: string[]; data?: ShareOutcome }> {
200
+ const off = ctx.env.REELKIT_NO_SHARE;
201
+ if (opts.noShare || (off && off !== "0" && off.toLowerCase() !== "false") || project.config().shareComponents === false) return { lines: ["No components were shared with the Reelkit library (sharing is turned off)."] };
202
+ if (!existsSync(project.path("src"))) return { lines: [] };
203
+ try {
204
+ const { ready, skipped } = prepareShare(project, undefined);
205
+ const notShared = skippedLines(skipped);
206
+ if (!ready.length) return { lines: [...notShared, ...(notShared.length ? [] : ["No new components to share with the Reelkit library."])] };
207
+ if (!loadCredentials(ctx.env)) return { lines: ["Components were not shared with the Reelkit library: you are not logged in.", ...notShared] };
208
+ if (!sharingNoticeShown(ctx.env)) {
209
+ // The first time on this machine: say what will happen, and share from the next render on.
210
+ return { lines: recordSharingNotice(ctx.env) ? [SHARING_NOTICE, ...notShared] : [`Components were not shared: ${"the notice could not be saved in " + configDir(ctx.env)}.`] };
211
+ }
212
+ const shared = await send(ctx, project, ready);
213
+ return {
214
+ data: { shared, skipped },
215
+ lines: [`Shared ${shared.length} new component${shared.length === 1 ? "" : "s"} with the Reelkit library for review: ${shared.map((s) => s.name).join(", ")}. Nothing else from this project was sent. ${STOP_SHARING}`, ...sentLines(shared), ...notShared],
216
+ };
217
+ } catch (e) {
218
+ return { lines: [`Components were not shared with the Reelkit library (${e instanceof Error ? e.message : String(e)}). The video is not affected.`] };
219
+ }
220
+ }
@@ -10,7 +10,7 @@ import { FILES, Project } from "../project/project";
10
10
  const exec = promisify(execFile);
11
11
  const DIRS = ["assets", "refs", "src", "out"];
12
12
 
13
- export async function init(ctx: Ctx, name: string | undefined, opts: { aspect?: string }): Promise<Result> {
13
+ export async function init(ctx: Ctx, name: string | undefined, opts: { aspect?: string; private?: boolean }): Promise<Result> {
14
14
  const slug = name === undefined ? undefined : name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
15
15
  if (slug === "") return { ok: false, summary: "Give the project a name with letters or digits, for example: reelkit init launch-video" };
16
16
  const aspect = AspectSchema.safeParse(opts.aspect ?? "9:16");
@@ -22,11 +22,17 @@ export async function init(ctx: Ctx, name: string | undefined, opts: { aspect?:
22
22
  if (slug && existsSync(dir) && !statSync(dir).isDirectory()) return { ok: false, summary: `A file named ${slug} is in the way. Choose another project name or remove it.` };
23
23
  const project = new Project(dir);
24
24
  for (const d of DIRS) mkdirSync(project.path(d), { recursive: true });
25
- if (!project.exists(FILES.config)) project.writeJson(FILES.config, { aspect: aspect.data, ...(name !== undefined && { name }) });
25
+ if (!project.exists(FILES.config)) project.writeJson(FILES.config, { aspect: aspect.data, ...(name !== undefined && { name }), ...(opts.private && { shareComponents: false }) });
26
+ else if (opts.private) project.writeJson(FILES.config, { ...project.config(), shareComponents: false });
26
27
  if (!existsSync(project.path(".gitignore"))) writeFileSync(project.path(".gitignore"), ".reelkit\nout\nrefs\n");
27
28
  ensureSkill(ctx);
28
29
  return {
29
30
  ok: true, data: { dir, config: project.config() },
30
- summary: `Project ready in ${dir} (${project.config().aspect}).${slug ? ` Next: cd ${slug}` : ""}`,
31
+ summary: [
32
+ `Project ready in ${dir} (${project.config().aspect}).${slug ? ` Next: cd ${slug}` : ""}`,
33
+ project.config().shareComponents === false
34
+ ? "Nothing from this project is shared automatically."
35
+ : "After a render, new components written in this project are sent to the Reelkit library for review (their source, a description and an example; nothing else). To turn this off: `reelkit init <name> --private` for a new project, `reelkit render --no-share`, or REELKIT_NO_SHARE=1.",
36
+ ].join("\n"),
31
37
  };
32
38
  }