reelkit-cli 0.4.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.
Files changed (50) hide show
  1. package/README.md +3 -2
  2. package/package.json +3 -2
  3. package/skill/SKILL.md +11 -5
  4. package/skill/reference/beat-sync.md +54 -0
  5. package/skill/reference/captions.md +18 -2
  6. package/skill/reference/component-authoring.md +12 -1
  7. package/skill/reference/continuity.md +99 -0
  8. package/skill/reference/kit.md +19 -1
  9. package/skill/reference/motion-design.md +9 -0
  10. package/skill/reference/references.md +11 -0
  11. package/skill/reference/remotion-composition.md +2 -1
  12. package/skill/reference/scene-treatments.md +1 -1
  13. package/skill/reference/scriptwriting.md +39 -5
  14. package/skill/reference/sound-design.md +11 -0
  15. package/skill/reference/styles.md +9 -0
  16. package/src/cli.ts +10 -4
  17. package/src/commands/assets.ts +46 -5
  18. package/src/commands/build.ts +99 -11
  19. package/src/commands/components.ts +220 -0
  20. package/src/commands/init.ts +9 -3
  21. package/src/commands/plan.ts +4 -2
  22. package/src/commands/ref.ts +11 -3
  23. package/src/contract/index.ts +23 -3
  24. package/src/pipeline/beatsnap.ts +52 -0
  25. package/src/pipeline/review.ts +54 -1
  26. package/src/pipeline/schema.ts +8 -0
  27. package/src/project/loudness.ts +68 -0
  28. package/src/project/manifest.ts +31 -1
  29. package/src/project/music.ts +25 -0
  30. package/src/project/project.ts +4 -2
  31. package/src/project/refmeasure.ts +45 -1
  32. package/src/remotion/Root.tsx +4 -2
  33. package/src/remotion/kit/Camera.tsx +22 -0
  34. package/src/remotion/kit/Captions.tsx +25 -8
  35. package/src/remotion/kit/Carry.tsx +38 -0
  36. package/src/remotion/kit/Music.tsx +19 -0
  37. package/src/remotion/kit/Sfx.tsx +12 -6
  38. package/src/remotion/kit/beat.ts +23 -0
  39. package/src/remotion/kit/caption-groups.ts +65 -0
  40. package/src/remotion/kit/docs.ts +19 -1
  41. package/src/remotion/kit/index.ts +7 -0
  42. package/src/remotion/kit/media.ts +17 -0
  43. package/src/remotion/kit/motion-math.ts +113 -0
  44. package/src/remotion/kit/music-math.ts +42 -0
  45. package/src/render/continuity.ts +87 -0
  46. package/src/render/master.ts +31 -0
  47. package/src/render/static-check.ts +156 -0
  48. package/src/render/validate.ts +30 -150
  49. package/src/testing/conformance.ts +49 -1
  50. package/src/testing/fake-api.ts +7 -1
@@ -9,10 +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";
13
+ import { measureMusic, type MusicRecord } from "../project/music";
12
14
  import { probeFile } from "../project/probe";
13
15
  import { FILES, openProject, type Project } from "../project/project";
14
16
 
15
- 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 };
16
19
 
17
20
  // A failure to lay out the timeline after a paid call must not hide that the call succeeded and was saved.
18
21
  function tryManifest(project: Project, plan: ScenePlan): { manifest?: AssetManifest; note?: string } {
@@ -105,7 +108,7 @@ export async function assetsUpload(
105
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." };
106
109
  if (opts.resume && !opts.cutout) return { ok: false, summary: "--resume goes with --cutout: it keeps waiting for a cutout that was already started." };
107
110
  if (opts.resume) return resumeUploadCutout(ctx, project, file, opts.resume, deps);
108
- 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." };
109
112
  const source = resolve(ctx.cwd, file);
110
113
  let probe: Awaited<ReturnType<typeof probeFile>>;
111
114
  try {
@@ -200,10 +203,23 @@ async function resumeUploadCutout(ctx: Ctx, project: Project, file: string, id:
200
203
  return { ...finished, summary: `Resumed ${record.filename} (${record.id}). ${finished.summary}` };
201
204
  }
202
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
+
203
218
  export async function assetsSearch(ctx: Ctx, query: string, opts: { kind?: string; limit?: string }): Promise<Result> {
204
219
  const kind = opts.kind === undefined ? undefined : LibraryKindSchema.safeParse(opts.kind);
205
220
  if (kind && !kind.success) return { ok: false, summary: `Unknown kind "${opts.kind}". Use one of: ${LibraryKindSchema.options.join(", ")}.` };
206
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);
207
223
  return {
208
224
  ok: true, data: { items },
209
225
  summary: items.length
@@ -228,7 +244,7 @@ function setSceneClip(project: Project, sceneId: string, record: ClipRecord) {
228
244
  project.writeJson(FILES.clips, { ...project.readJsonOr<Record<string, ClipRecord>>(FILES.clips, {}), [sceneId]: record });
229
245
  }
230
246
 
231
- export async function assetsPull(ctx: Ctx, id: string, opts: { scene?: string; force?: boolean }, deps: ClipDeps = {}): Promise<Result> {
247
+ export async function assetsPull(ctx: Ctx, id: string, opts: { scene?: string; force?: boolean; music?: boolean }, deps: ClipDeps = {}): Promise<Result> {
232
248
  const project = openProject(ctx.cwd);
233
249
  if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(id)) return { ok: false, summary: `"${id}" is not a library id. Copy the id from \`reelkit assets search\`.` };
234
250
  if (opts.scene) {
@@ -240,7 +256,9 @@ export async function assetsPull(ctx: Ctx, id: string, opts: { scene?: string; f
240
256
  return { ok: false, summary: `Scene ${opts.scene} is not an illustration scene in plan.json. ${illustrations.length ? `The illustration scenes are: ${illustrations.join(", ")}.` : "The plan has no illustration scenes."}${clipScenes.length ? ` The clip scenes are: ${clipScenes.join(", ")}.` : ""}` };
241
257
  }
242
258
  }
259
+ if (opts.music && opts.scene) return { ok: false, summary: "--music makes the item the video's track, not a scene's picture. Use --music or --scene, not both." };
243
260
  const pulled = await client(ctx)("libraryPull", { id });
261
+ if (opts.music && pulled.item.kind !== "music") return { ok: false, summary: `${id} is ${pulled.item.kind}, not music, so it cannot be the video's track. Search with --kind music.` };
244
262
  if (unsafeName(pulled.filename, pulled.item.kind)) return { ok: false, summary: `The library returned an unsafe file name for ${id}, so nothing was written. Report this item.` };
245
263
  if (opts.scene) {
246
264
  const treatment = loadPlan(project).scenes.find((x) => x.id === opts.scene)?.treatment;
@@ -259,7 +277,10 @@ export async function assetsPull(ctx: Ctx, id: string, opts: { scene?: string; f
259
277
  }
260
278
  const path = `assets/lib/${id}/${pulled.filename}`;
261
279
  await download(pulled.url, project.path(path));
262
- 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 } : {}) });
263
284
  // A clip filmed on green also comes keyed, so it can be laid over a background.
264
285
  const green = pulled.item.kind === "clip" && pulled.item.meta.greenScreen === true;
265
286
  const keyedPath = green ? keyedPathOf(path) : undefined;
@@ -273,16 +294,36 @@ export async function assetsPull(ctx: Ctx, id: string, opts: { scene?: string; f
273
294
  } else setSceneImage(project, opts.scene, path);
274
295
  if (project.exists(FILES.plan)) note = tryManifest(project, loadPlan(project)).note;
275
296
  }
297
+ if (pulled.item.kind === "music" && opts.music) return pullMusic(project, id, pulled.item.title, path, level?.gainDb ?? 0);
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\`.` : "";
276
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}"].`;
277
300
  return {
278
301
  ok: true, data: { path, kind: pulled.item.kind, ...(keyedPath ? { keyedPath } : {}) },
279
302
  summary: [
280
- opts.scene ? `Pulled ${id} as the ${sceneIsClip ? "clip" : "image"} for scene ${opts.scene}.${keyedPath ? refs : ""}` : `Pulled ${id} to ${path}.${refs}`,
303
+ opts.scene ? `Pulled ${id} as the ${sceneIsClip ? "clip" : "image"} for scene ${opts.scene}.${keyedPath ? refs : ""}` : `Pulled ${id} to ${path}.${refs}${musicHint}`,
281
304
  ...(note ? [note] : []),
282
305
  ].join("\n"),
283
306
  };
284
307
  }
285
308
 
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.
310
+ async function pullMusic(project: Project, id: string, title: string, path: string, gainDb: number): Promise<Result> {
311
+ let measured: Awaited<ReturnType<typeof measureMusic>>;
312
+ try { measured = await measureMusic(project.path(path)); }
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.` }; }
314
+ const previous = project.readJsonOr<MusicRecord | undefined>(FILES.music, undefined);
315
+ const record: MusicRecord = { key: path, id, title, ...measured, gainDb };
316
+ project.writeJson(FILES.music, record);
317
+ const note = project.exists(FILES.plan) ? tryManifest(project, loadPlan(project)).note : undefined;
318
+ const beat = record.bpm
319
+ ? `about ${Math.round(record.bpm)} BPM; scene changes will land on its beat.`
320
+ : "no clear tempo was found, so scene changes stay where the narration puts them.";
321
+ return {
322
+ ok: true, data: { path, kind: "music", music: record },
323
+ summary: [`Pulled ${id} as the video's music track${previous && previous.id !== id ? ` (it replaces ${previous.id})` : ""}: ${record.durationSec.toFixed(1)}s, ${beat} Place it once with <Music src={urls["${path}"]} />.`, ...(note ? [note] : [])].join("\n"),
324
+ };
325
+ }
326
+
286
327
  export async function assetsVoices(ctx: Ctx): Promise<Result> {
287
328
  const { voices } = await client(ctx)("voices", {});
288
329
  return {
@@ -3,12 +3,18 @@ import { mkdirSync, readdirSync, readFileSync, rmSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { promisify } from "node:util";
5
5
  import type { Ctx, Result } from "../context";
6
- import type { AssetManifest } from "../pipeline/schema";
6
+ import type { AssetManifest, ScenePlan } from "../pipeline/schema";
7
+ import { compareFrames, SAMPLE_WIDTH, summariseContinuity, type Boundary } from "../render/continuity";
7
8
  import { buildManifest, missingAssets } from "../project/manifest";
8
- import { openProject, type Project } from "../project/project";
9
+ import { beatReport } from "../project/music";
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";
9
14
  import { mediaUrls, serveDir } from "../project/serve";
10
15
  import { bundleProject, disposeBundle, ensureRenderBrowser, renderStills, renderVideo, writeEntry, type EnsureBrowser } from "../render/render";
11
- import { FILE_NAME, MAIN_FILE, staticCheck, typecheck } from "../render/validate";
16
+ import { MASTER_LUFS, MASTER_TRUE_PEAK_DB, masterLoudness, type Master, type Mastered } from "../render/master";
17
+ import { FILE_NAME, MAIN_FILE, jsxUses, staticCheck, typecheck } from "../render/validate";
12
18
  import { loadPlan } from "./plan";
13
19
 
14
20
  const exec = promisify(execFile);
@@ -36,11 +42,34 @@ function inspect(project: Project): { errors: string[]; manifest?: AssetManifest
36
42
  return manifest ? { errors: [], manifest } : { errors: ["The manifest could not be built. Run `reelkit assets voiceover --all`."] };
37
43
  }
38
44
 
45
+ // Advice about a composition that passes: never an error.
46
+ function compositionNotes(project: Project, plan: ScenePlan): string[] {
47
+ const notes: string[] = [];
48
+ const names = readdirSync(project.path("src")).filter((f) => FILE_NAME.test(f));
49
+ const sources = names.map((n) => [n, readFileSync(project.path(join("src", n)), "utf8")] as const);
50
+ const rendersCaptions = sources.some(([n, src]) => jsxUses(src, n, "Captions").length > 0);
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.');
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
+ }
59
+ return notes;
60
+ }
61
+
39
62
  const failed = (errors: string[]): Result => ({ ok: false, data: { errors }, summary: `${errors.length} problem(s):\n- ${errors.join("\n- ")}` });
40
63
 
41
64
  export async function check(ctx: Ctx): Promise<Result> {
42
- const { errors } = inspect(openProject(ctx.cwd));
43
- return errors.length ? failed(errors) : { ok: true, data: { errors: [] }, summary: "The composition passes. Run `reelkit preview` to see it." };
65
+ const project = openProject(ctx.cwd);
66
+ const { errors } = inspect(project);
67
+ if (errors.length) return failed(errors);
68
+ const shouldImprove = compositionNotes(project, loadPlan(project));
69
+ return {
70
+ ok: true, data: { errors: [], shouldImprove, library: libraryUse(project) },
71
+ summary: ["The composition passes. Run `reelkit preview` to see it.", ...(shouldImprove.length ? [`Worth improving:\n- ${shouldImprove.join("\n- ")}`] : [])].join("\n"),
72
+ };
44
73
  }
45
74
 
46
75
  // Bundles the composition and serves the project's media for as long as fn runs.
@@ -72,7 +101,7 @@ export async function ensureBrowserWithNotice(ctx: Ctx, ensure: EnsureBrowser =
72
101
 
73
102
  const ffmpegToJpeg = (png: string, jpg: string) => exec("ffmpeg", ["-y", "-loglevel", "error", "-i", png, "-vf", "scale=540:-2", "-q:v", "5", jpg]).then(() => undefined);
74
103
 
75
- type PreviewPoint = { sceneId: string; point: "early" | "late" | "middle"; frame: number; name: string };
104
+ type PreviewPoint = { sceneId: string; point: "early" | "late" | "middle" | "end" | "start"; frame: number; name: string };
76
105
 
77
106
  // Two frames per scene, at 30% and 90% of its length, so what enters late in a scene is seen as well as what opens it.
78
107
  // A scene under 4 frames has no room for two distinct frames and gets its middle one. The scene's number leads each name
@@ -86,6 +115,30 @@ export function previewPoints(scenes: { id: string; startFrame: number; duration
86
115
  });
87
116
  }
88
117
 
118
+ type BoundaryPoint = { from: string; to: string; end: PreviewPoint; start: PreviewPoint };
119
+
120
+ // SceneFrame fades each scene in and out over up to 8 frames, so the very last and very first frames of two scenes are nearly empty.
121
+ // The frames that show what a scene really holds are the last one at full strength and the first one at full strength: those are the two
122
+ // either side of every change. The files are named by the change, b01-end-<scene> then b01-start-<scene>, so that they sort in order.
123
+ export function boundaryPoints(scenes: { id: string; startFrame: number; durationFrames: number }[]): BoundaryPoint[] {
124
+ const edge = (d: number) => Math.max(1, Math.min(8, Math.floor(d / 2)));
125
+ return scenes.slice(0, -1).map((a, i) => {
126
+ const b = scenes[i + 1]!;
127
+ const n = `b${String(i + 1).padStart(2, "0")}`;
128
+ return {
129
+ from: a.id, to: b.id,
130
+ end: { sceneId: a.id, point: "end", frame: a.startFrame + Math.max(0, a.durationFrames - edge(a.durationFrames)), name: `${n}-end-${a.id}` },
131
+ start: { sceneId: b.id, point: "start", frame: b.startFrame + Math.min(b.durationFrames - 1, edge(b.durationFrames)), name: `${n}-start-${b.id}` },
132
+ };
133
+ });
134
+ }
135
+
136
+ // A small grey copy of a frame as plain pixels, for the continuity measurement.
137
+ async function decodeGray(png: string, width: number, height: number): Promise<Uint8Array> {
138
+ const { stdout } = await exec("ffmpeg", ["-v", "error", "-i", png, "-vf", `scale=${width}:${height}:flags=area,format=gray`, "-f", "rawvideo", "-pix_fmt", "gray", "-"], { encoding: "buffer", maxBuffer: 16 * 1024 * 1024 });
139
+ return new Uint8Array(stdout);
140
+ }
141
+
89
142
  export async function preview(ctx: Ctx, deps: { toJpeg?: (png: string, jpg: string) => Promise<void>; ensureBrowser?: EnsureBrowser } = {}): Promise<Result> {
90
143
  const toJpeg = deps.toJpeg ?? ffmpegToJpeg;
91
144
  const project = openProject(ctx.cwd);
@@ -94,11 +147,13 @@ export async function preview(ctx: Ctx, deps: { toJpeg?: (png: string, jpg: stri
94
147
  const dir = project.path("out/preview");
95
148
  rmSync(dir, { recursive: true, force: true });
96
149
  mkdirSync(dir, { recursive: true });
97
- const points = previewPoints(manifest.scenes);
150
+ const sceneFrames = previewPoints(manifest.scenes);
151
+ const boundaries = boundaryPoints(manifest.scenes);
152
+ const points = [...sceneFrames, ...boundaries.flatMap((b) => [b.end, b.start])];
98
153
  try {
99
154
  try {
100
155
  await ensureBrowserWithNotice(ctx, deps.ensureBrowser);
101
- await withBundle(project, (serveUrl, urls) => renderStills(serveUrl, { manifest, urls }, points.map((p) => p.frame), dir));
156
+ await withBundle(project, (serveUrl, urls) => renderStills(serveUrl, { manifest, urls }, [...new Set(points.map((p) => p.frame))], dir));
102
157
  } catch (e) {
103
158
  return failed([`Render failed: ${e instanceof Error ? e.message : String(e)}`]);
104
159
  }
@@ -113,14 +168,34 @@ export async function preview(ctx: Ctx, deps: { toJpeg?: (png: string, jpg: stri
113
168
  } catch {
114
169
  return failed(["Could not write the preview frames: ffmpeg failed. Check that ffmpeg is installed (macOS: `brew install ffmpeg`) and run `reelkit preview` again."]);
115
170
  }
116
- return { ok: true, data: { frames }, summary: `Two frames per scene, at 30% and at 90% of its length (a scene under 4 frames gets its middle one). Look at both frames of every scene:\n${frames.map((f) => `${f.sceneId} (${f.point}): ${f.path}`).join("\n")}` };
171
+ // Whether anything survives each scene change is advice. If it cannot be measured, the preview is still the preview.
172
+ let continuity: { score: number | null; boundaries: Boundary[] } | undefined;
173
+ let continuityLines: string[] = [];
174
+ try {
175
+ const h = Math.round((SAMPLE_WIDTH * manifest.height) / manifest.width);
176
+ const measured: Boundary[] = [];
177
+ for (const b of boundaries) {
178
+ const [a, c] = await Promise.all([decodeGray(join(dir, `still-${b.end.frame}.png`), SAMPLE_WIDTH, h), decodeGray(join(dir, `still-${b.start.frame}.png`), SAMPLE_WIDTH, h)]);
179
+ const r = compareFrames(a, c, SAMPLE_WIDTH, h);
180
+ measured.push({ from: b.from, to: b.to, survived: Math.round(r.survived * 100) / 100, kind: r.kind });
181
+ }
182
+ if (measured.length) {
183
+ const summary = summariseContinuity(measured);
184
+ continuity = { score: summary.score === null ? null : Math.round(summary.score * 100) / 100, boundaries: measured };
185
+ continuityLines = summary.lines;
186
+ }
187
+ } catch { /* no continuity report */ }
188
+ const sceneLines = `Two frames per scene, at 30% and at 90% of its length (a scene under 4 frames gets its middle one). Look at both frames of every scene:\n${frames.filter((f) => f.point !== "end" && f.point !== "start").map((f) => `${f.sceneId} (${f.point}): ${f.path}`).join("\n")}`;
189
+ const changeLines = boundaries.length ? `\nThe last and first frames either side of each scene change are saved too (b01-end-<scene>, b01-start-<scene>, ...), so that you can see what carries across:\n${frames.filter((f) => f.point === "end" || f.point === "start").map((f) => `${f.sceneId} (${f.point}): ${f.path}`).join("\n")}` : "";
190
+ const beat = beatReport(manifest);
191
+ return { ok: true, data: { frames, ...(continuity ? { continuity } : {}), ...(beat ? { beat: { bpm: beat.bpm, boundariesOnBeat: beat.boundariesOnBeat, boundaries: beat.boundaries } } : {}) }, summary: `${sceneLines}${changeLines}${continuityLines.length ? `\nContinuity: ${continuityLines.join("\n")}` : ""}${beat ? `\nBeat: ${beat.line}.` : ""}` };
117
192
  } finally {
118
193
  // The full-size stills are only inputs; none stays behind however this ends.
119
194
  for (const f of readdirSync(dir)) if (/^still-\d+\.png$/.test(f)) rmSync(join(dir, f), { force: true });
120
195
  }
121
196
  }
122
197
 
123
- 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> {
124
199
  const project = openProject(ctx.cwd);
125
200
  const { errors, manifest } = inspect(project);
126
201
  if (!manifest) return failed(errors);
@@ -132,5 +207,18 @@ export async function render(ctx: Ctx, deps: { ensureBrowser?: EnsureBrowser } =
132
207
  return failed([`Render failed: ${e instanceof Error ? e.message : String(e)}`]);
133
208
  }
134
209
  const seconds = manifest.totalFrames / manifest.fps;
135
- 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
+ };
136
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
  }
@@ -1,6 +1,6 @@
1
1
  import { client, type Ctx, type Result } from "../context";
2
2
  import { loadCredentials } from "../credentials";
3
- import { estimateLength, reviewPlan } from "../pipeline/review";
3
+ import { estimateLength, lengthVariation, reviewPlan, sceneSeconds } from "../pipeline/review";
4
4
  import { ScenePlanSchema, validatePlan, type ScenePlan } from "../pipeline/schema";
5
5
  import { FILES, issueLines, openProject, type Project } from "../project/project";
6
6
 
@@ -58,11 +58,13 @@ export async function planCheck(ctx: Ctx): Promise<Result> {
58
58
  const shouldImprove = mustFix.length ? [] : [...reviewPlan(parsed.data, { footage }), ...ref.improve];
59
59
  const length = estimateLength(parsed.data);
60
60
  const estimatedSeconds = Math.round(length.seconds);
61
+ const perScene = sceneSeconds(parsed.data).map((s) => ({ id: s.id, seconds: Math.round(s.seconds * 10) / 10 }));
62
+ const variation = Math.round(lengthVariation(sceneSeconds(parsed.data).map((s) => s.seconds)) * 100) / 100;
61
63
  const lines = [
62
64
  mustFix.length ? `Fix these:\n- ${mustFix.join("\n- ")}` : "The plan is valid.",
63
65
  `Estimated length: about ${estimatedSeconds}s (${length.words} words).`,
64
66
  ...(shouldImprove.length ? [`Worth improving:\n- ${shouldImprove.join("\n- ")}`] : []),
65
67
  ...(voiceIds ? [] : ["The voice was not checked because you are not logged in. Run `reelkit auth login`."]),
66
68
  ];
67
- return { ok: mustFix.length === 0, data: { mustFix, shouldImprove, estimatedSeconds, words: length.words }, summary: lines.join("\n") };
69
+ return { ok: mustFix.length === 0, data: { mustFix, shouldImprove, estimatedSeconds, words: length.words, sceneSeconds: perScene, lengthVariation: variation }, summary: lines.join("\n") };
68
70
  }
@@ -10,7 +10,7 @@ import { loadCredentials } from "../credentials";
10
10
  import { analyzeBeats, cutsOnBeat } from "../project/beats";
11
11
  import { openProject, type Project } from "../project/project";
12
12
  import {
13
- audioDuration, decodeMono, detectCuts, extractFrame, extractMp3, loudnessLufs, nearestAspect, paletteOfFrames, paletteOfVideo, probeVideo, REF_ASPECTS, toMp4,
13
+ audioDuration, coefficientOfVariation, decodeMono, detectCuts, extractFrame, extractMp3, loudnessLufs, nearestAspect, paletteOfFrames, paletteOfVideo, probeVideo, REF_ASPECTS, stillnessOfVideo, toMp4,
14
14
  } from "../project/refmeasure";
15
15
 
16
16
  // A reference is for learning how a video is built, so ten minutes is more than it needs and keeps every step quick.
@@ -29,7 +29,11 @@ export type Breakdown = {
29
29
  scenes: { index: number; startSec: number; endSec: number; durationSec: number; frames: string[]; palette: string[] }[];
30
30
  // True when the video has more cuts than the 40 scenes listed: the last scene then holds everything after the 39th cut.
31
31
  scenesCapped?: boolean;
32
- pacing: { cuts: number; averageShotSec: number; shortestShotSec: number; longestShotSec: number; cutsPerSecond: number; firstCutSec?: number; cutsOnBeat?: number };
32
+ pacing: { cuts: number; averageShotSec: number; shortestShotSec: number; longestShotSec: number; cutsPerSecond: number; firstCutSec?: number; cutsOnBeat?: number;
33
+ // How uneven the shot lengths are (standard deviation over the mean); left out with fewer than 3 shots. High means a quarter-second hit beside a long hold.
34
+ shotLengthVariation?: number };
35
+ // The share of the video's time in which the picture barely changes from frame to frame, and the longest such stretch. Missing from breakdowns saved by older versions.
36
+ stillness?: { share: number; longestSec: number };
33
37
  palette: string[];
34
38
  audio: { hasAudio: boolean; loudnessLufs?: number; tempoBpm?: number; beatConfidence: number };
35
39
  beats?: number[];
@@ -245,7 +249,10 @@ async function measure(ctx: Ctx, project: Project, ref: RefRecord): Promise<Brea
245
249
  const pacing: Breakdown["pacing"] = {
246
250
  cuts: cuts.length, averageShotSec: round2(ref.durationSec / shots.length), shortestShotSec: round2(Math.min(...shots)), longestShotSec: round2(Math.max(...shots)),
247
251
  cutsPerSecond: round2(cuts.length / ref.durationSec), ...(cuts.length ? { firstCutSec: round2(cuts[0]!) } : {}),
252
+ ...(shots.length >= 3 ? { shotLengthVariation: round2(coefficientOfVariation(shots)) } : {}),
248
253
  };
254
+ ctx.log("Measuring how much of the time the picture is still.");
255
+ const still = await stillnessOfVideo(video, ref.width, ref.height);
249
256
 
250
257
  const audio: Breakdown["audio"] = { hasAudio: ref.hasAudio, beatConfidence: 0 };
251
258
  let beats: number[] | undefined;
@@ -264,7 +271,7 @@ async function measure(ctx: Ctx, project: Project, ref: RefRecord): Promise<Brea
264
271
  }
265
272
  } catch { /* no tempo */ }
266
273
  }
267
- return { source: ref.source, durationSec: ref.durationSec, width: ref.width, height: ref.height, aspect: ref.aspect, fps: ref.fps, scenes, ...(cuts.length > MAX_SCENES - 1 ? { scenesCapped: true } : {}), pacing, palette, audio, ...(beats ? { beats } : {}) };
274
+ return { source: ref.source, durationSec: ref.durationSec, width: ref.width, height: ref.height, aspect: ref.aspect, fps: ref.fps, scenes, ...(cuts.length > MAX_SCENES - 1 ? { scenesCapped: true } : {}), pacing, stillness: still, palette, audio, ...(beats ? { beats } : {}) };
268
275
  }
269
276
 
270
277
  // A short text a person or an agent can read at once.
@@ -274,6 +281,7 @@ function digest(id: string, b: Breakdown, notes: string[]): string {
274
281
  b.pacing.cuts
275
282
  ? `Shape: ${b.scenes.length}${b.scenesCapped ? "+" : ""} scenes, ${b.pacing.cuts} cuts; average shot ${fmt(b.pacing.averageShotSec)}s (shortest ${fmt(b.pacing.shortestShotSec)}s, longest ${fmt(b.pacing.longestShotSec)}s); first cut at ${fmt(b.pacing.firstCutSec!)}s.`
276
283
  : "Shape: one continuous shot, no cuts found.",
284
+ ...(b.stillness ? [`Still ${Math.round(b.stillness.share * 100)}% of the time; longest hold ${fmt(b.stillness.longestSec)} s.${b.pacing.shotLengthVariation !== undefined ? ` Shot lengths vary by ${b.pacing.shotLengthVariation} (the spread over the average).` : ""}`] : []),
277
285
  `Palette: ${b.palette.join(", ")}.`,
278
286
  !b.audio.hasAudio ? "Audio: none."
279
287
  : `Audio:${b.audio.loudnessLufs !== undefined ? ` ${b.audio.loudnessLufs} LUFS;` : ""} ${b.audio.tempoBpm !== undefined ? `tempo about ${Math.round(b.audio.tempoBpm)} BPM (confidence ${b.audio.beatConfidence})` : "no clear beat"}${b.pacing.cutsOnBeat !== undefined ? `; ${Math.round(b.pacing.cutsOnBeat * b.pacing.cuts)} of ${b.pacing.cuts} cuts land on a beat` : ""}.`,