reelkit-cli 0.3.1 → 0.5.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 (47) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/skill/SKILL.md +11 -2
  4. package/skill/reference/beat-sync.md +45 -0
  5. package/skill/reference/captions.md +18 -2
  6. package/skill/reference/clips.md +1 -0
  7. package/skill/reference/continuity.md +99 -0
  8. package/skill/reference/kit.md +18 -1
  9. package/skill/reference/motion-design.md +9 -0
  10. package/skill/reference/references.md +43 -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 +14 -2
  17. package/src/commands/assets.ts +38 -8
  18. package/src/commands/auth.ts +1 -1
  19. package/src/commands/build.ts +73 -8
  20. package/src/commands/plan.ts +26 -4
  21. package/src/commands/ref.ts +315 -0
  22. package/src/contract/index.ts +25 -1
  23. package/src/pipeline/beatsnap.ts +52 -0
  24. package/src/pipeline/review.ts +28 -1
  25. package/src/pipeline/schema.ts +14 -0
  26. package/src/project/beats.ts +130 -0
  27. package/src/project/chromakey.ts +14 -4
  28. package/src/project/manifest.ts +22 -1
  29. package/src/project/music.ts +25 -0
  30. package/src/project/project.ts +1 -1
  31. package/src/project/refmeasure.ts +192 -0
  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/beat.ts +23 -0
  38. package/src/remotion/kit/caption-groups.ts +65 -0
  39. package/src/remotion/kit/docs.ts +18 -1
  40. package/src/remotion/kit/index.ts +7 -0
  41. package/src/remotion/kit/media.ts +15 -0
  42. package/src/remotion/kit/motion-math.ts +113 -0
  43. package/src/remotion/kit/music-math.ts +42 -0
  44. package/src/render/continuity.ts +87 -0
  45. package/src/render/validate.ts +27 -0
  46. package/src/testing/conformance.ts +107 -1
  47. package/src/testing/fake-api.ts +44 -6
@@ -5,10 +5,11 @@ import { ApiFailure, download, uploadTo } from "../api/client";
5
5
  import { client, type Ctx, type Result } from "../context";
6
6
  import { LibraryKindSchema, MAX_CUTOUT_SECONDS, MAX_UPLOAD_BYTES, UploadKindSchema, type CutoutType } from "../contract";
7
7
  import { PACE_SPEED, type AssetManifest, type AssetRecord, type ScenePlan } from "../pipeline/schema";
8
- import { keyGreen } from "../project/chromakey";
8
+ 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 { measureMusic, type MusicRecord } from "../project/music";
12
13
  import { probeFile } from "../project/probe";
13
14
  import { FILES, openProject, type Project } from "../project/project";
14
15
 
@@ -228,7 +229,7 @@ function setSceneClip(project: Project, sceneId: string, record: ClipRecord) {
228
229
  project.writeJson(FILES.clips, { ...project.readJsonOr<Record<string, ClipRecord>>(FILES.clips, {}), [sceneId]: record });
229
230
  }
230
231
 
231
- export async function assetsPull(ctx: Ctx, id: string, opts: { scene?: string; force?: boolean }, deps: ClipDeps = {}): Promise<Result> {
232
+ export async function assetsPull(ctx: Ctx, id: string, opts: { scene?: string; force?: boolean; music?: boolean }, deps: ClipDeps = {}): Promise<Result> {
232
233
  const project = openProject(ctx.cwd);
233
234
  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
235
  if (opts.scene) {
@@ -240,7 +241,9 @@ export async function assetsPull(ctx: Ctx, id: string, opts: { scene?: string; f
240
241
  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
242
  }
242
243
  }
244
+ 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
245
  const pulled = await client(ctx)("libraryPull", { id });
246
+ 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
247
  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
248
  if (opts.scene) {
246
249
  const treatment = loadPlan(project).scenes.find((x) => x.id === opts.scene)?.treatment;
@@ -273,16 +276,36 @@ export async function assetsPull(ctx: Ctx, id: string, opts: { scene?: string; f
273
276
  } else setSceneImage(project, opts.scene, path);
274
277
  if (project.exists(FILES.plan)) note = tryManifest(project, loadPlan(project)).note;
275
278
  }
279
+ if (pulled.item.kind === "music" && opts.music) return pullMusic(project, id, pulled.item.title, path);
280
+ 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
281
  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
282
  return {
278
283
  ok: true, data: { path, kind: pulled.item.kind, ...(keyedPath ? { keyedPath } : {}) },
279
284
  summary: [
280
- opts.scene ? `Pulled ${id} as the ${sceneIsClip ? "clip" : "image"} for scene ${opts.scene}.${keyedPath ? refs : ""}` : `Pulled ${id} to ${path}.${refs}`,
285
+ opts.scene ? `Pulled ${id} as the ${sceneIsClip ? "clip" : "image"} for scene ${opts.scene}.${keyedPath ? refs : ""}` : `Pulled ${id} to ${path}.${refs}${musicHint}`,
281
286
  ...(note ? [note] : []),
282
287
  ].join("\n"),
283
288
  };
284
289
  }
285
290
 
291
+ // 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> {
293
+ let measured: Awaited<ReturnType<typeof measureMusic>>;
294
+ try { measured = await measureMusic(project.path(path)); }
295
+ 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
+ const previous = project.readJsonOr<MusicRecord | undefined>(FILES.music, undefined);
297
+ const record: MusicRecord = { key: path, id, title, ...measured, gainDb: 0 };
298
+ project.writeJson(FILES.music, record);
299
+ const note = project.exists(FILES.plan) ? tryManifest(project, loadPlan(project)).note : undefined;
300
+ const beat = record.bpm
301
+ ? `about ${Math.round(record.bpm)} BPM; scene changes will land on its beat.`
302
+ : "no clear tempo was found, so scene changes stay where the narration puts them.";
303
+ return {
304
+ ok: true, data: { path, kind: "music", music: record },
305
+ 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"),
306
+ };
307
+ }
308
+
286
309
  export async function assetsVoices(ctx: Ctx): Promise<Result> {
287
310
  const { voices } = await client(ctx)("voices", {});
288
311
  return {
@@ -388,6 +411,10 @@ export async function assetsGenClip(
388
411
  if (opts.green && have.greenScreen && !have.keyedKey) {
389
412
  // The clip was paid for but not keyed (ffmpeg failed or --green came later): keying is free, so do it now.
390
413
  const keyed = await keyScene(project, scene.id, have, key);
414
+ if (!keyed.ok && (keyed.data as { dull?: boolean } | undefined)?.dull) {
415
+ const cut = await cutoutScene(ctx, project, scene.id, have, now);
416
+ return { ...cut, data: { path: have.key, ...(cut.ok ? { keyedPath: keyedKey } : {}) }, summary: cut.ok ? `The green in scene ${scene.id}'s clip was too dull to key on this machine, so the subject was cut out on the server instead. ${cut.summary}` : cut.summary };
417
+ }
391
418
  if (!keyed.ok) return keyed;
392
419
  return { ok: true, data: { path: have.key, keyedPath: keyedKey }, summary: `Keyed the green out of scene ${scene.id}'s clip into ${keyedKey}.` };
393
420
  }
@@ -421,20 +448,23 @@ export async function assetsGenClip(
421
448
  const record: ClipRecord = { key: path, greenScreen: green, durationSec: st.durationSec ?? seconds };
422
449
  setSceneClip(project, scene.id, record);
423
450
  let keyedPath: string | undefined;
451
+ let dullGreen = false;
424
452
  if (green || opts.green) {
425
453
  // The paid clip is already saved above; if keying fails, running the command again keys it without a new charge.
426
454
  const keyed = await keyScene(project, scene.id, { ...record, greenScreen: true }, key);
427
- if (!keyed.ok) return keyed;
428
- keyedPath = keyedKey;
455
+ // A model's green is often too dull to key. The subject is then cut out on the server, which needs no green at all.
456
+ if (!keyed.ok && (keyed.data as { dull?: boolean } | undefined)?.dull) dullGreen = true;
457
+ else if (!keyed.ok) return keyed;
458
+ else keyedPath = keyedKey;
429
459
  }
430
460
  let cutoutNote: string | undefined;
431
- if (opts.cutout) {
461
+ if (opts.cutout || dullGreen) {
432
462
  // The paid clip is already saved above; if the cutout fails, running the command again does only the cutout.
433
463
  const cut = await cutoutScene(ctx, project, scene.id, record, now);
434
464
  const shared = st.libraryId ? `, and shared it to the library as ${st.libraryId} (awaiting review)` : "";
435
465
  if (!cut.ok) return { ...cut, data: { id, path, durationSec: record.durationSec, libraryId: st.libraryId ?? null }, summary: `Generated the clip for scene ${scene.id}${shared}. ${cut.summary}` };
436
466
  keyedPath = keyedKey;
437
- cutoutNote = cut.summary;
467
+ cutoutNote = dullGreen ? `The generated green was too dull to key on this machine, so the subject was cut out on the server instead. ${cut.summary}` : cut.summary;
438
468
  }
439
469
  const { note } = tryManifest(project, plan);
440
470
  return {
@@ -508,7 +538,7 @@ async function keyScene(project: Project, sceneId: string, record: ClipRecord, k
508
538
  try {
509
539
  await key(project.path(record.key), project.path(keyedKey));
510
540
  } catch (e) {
511
- return { ok: false, data: { path: record.key }, summary: `The clip is saved at ${record.key}, but the green could not be keyed out: ${e instanceof Error ? e.message : String(e)}` };
541
+ return { ok: false, data: { path: record.key, ...(e instanceof GreenTooDullError ? { dull: true } : {}) }, summary: `The clip is saved at ${record.key}, but the green could not be keyed out: ${e instanceof Error ? e.message : String(e)}` };
512
542
  }
513
543
  setSceneClip(project, sceneId, { ...record, greenScreen: true, keyedKey });
514
544
  return { ok: true, summary: "" };
@@ -113,6 +113,6 @@ export async function whoami(ctx: Ctx): Promise<Result> {
113
113
  const q = me.quota;
114
114
  return {
115
115
  ok: true, data: me,
116
- summary: `${me.handle}\nVoiceover: ${q.voiceoverChars.used}/${q.voiceoverChars.limit} characters\nImages: ${q.images.used}/${q.images.limit}\nClips: ${q.clips.used}/${q.clips.limit}\nCutouts: ${q.cutoutSeconds.used}/${q.cutoutSeconds.limit} seconds\nResets: ${q.resetsAt.slice(0, 10)}\nContributions: ${me.contributions}`,
116
+ summary: `${me.handle}\nVoiceover: ${q.voiceoverChars.used}/${q.voiceoverChars.limit} characters\nImages: ${q.images.used}/${q.images.limit}\nClips: ${q.clips.used}/${q.clips.limit}\nCutouts: ${q.cutoutSeconds.used}/${q.cutoutSeconds.limit} seconds\nTranscription: ${q.transcribeSeconds.used}/${q.transcribeSeconds.limit} seconds\nResets: ${q.resetsAt.slice(0, 10)}\nContributions: ${me.contributions}`,
117
117
  };
118
118
  }
@@ -3,12 +3,14 @@ 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";
9
+ import { beatReport } from "../project/music";
8
10
  import { openProject, type Project } from "../project/project";
9
11
  import { mediaUrls, serveDir } from "../project/serve";
10
12
  import { bundleProject, disposeBundle, ensureRenderBrowser, renderStills, renderVideo, writeEntry, type EnsureBrowser } from "../render/render";
11
- import { FILE_NAME, MAIN_FILE, staticCheck, typecheck } from "../render/validate";
13
+ import { FILE_NAME, MAIN_FILE, jsxUses, staticCheck, typecheck } from "../render/validate";
12
14
  import { loadPlan } from "./plan";
13
15
 
14
16
  const exec = promisify(execFile);
@@ -36,11 +38,28 @@ function inspect(project: Project): { errors: string[]; manifest?: AssetManifest
36
38
  return manifest ? { errors: [], manifest } : { errors: ["The manifest could not be built. Run `reelkit assets voiceover --all`."] };
37
39
  }
38
40
 
41
+ // Advice about a composition that passes: never an error.
42
+ function compositionNotes(project: Project, plan: ScenePlan): string[] {
43
+ const notes: string[] = [];
44
+ const names = readdirSync(project.path("src")).filter((f) => FILE_NAME.test(f));
45
+ const sources = names.map((n) => [n, readFileSync(project.path(join("src", n)), "utf8")] as const);
46
+ const rendersCaptions = sources.some(([n, src]) => jsxUses(src, n, "Captions").length > 0);
47
+ 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
+ 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.`);
49
+ return notes;
50
+ }
51
+
39
52
  const failed = (errors: string[]): Result => ({ ok: false, data: { errors }, summary: `${errors.length} problem(s):\n- ${errors.join("\n- ")}` });
40
53
 
41
54
  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." };
55
+ const project = openProject(ctx.cwd);
56
+ const { errors } = inspect(project);
57
+ if (errors.length) return failed(errors);
58
+ const shouldImprove = compositionNotes(project, loadPlan(project));
59
+ return {
60
+ ok: true, data: { errors: [], shouldImprove },
61
+ summary: ["The composition passes. Run `reelkit preview` to see it.", ...(shouldImprove.length ? [`Worth improving:\n- ${shouldImprove.join("\n- ")}`] : [])].join("\n"),
62
+ };
44
63
  }
45
64
 
46
65
  // Bundles the composition and serves the project's media for as long as fn runs.
@@ -72,7 +91,7 @@ export async function ensureBrowserWithNotice(ctx: Ctx, ensure: EnsureBrowser =
72
91
 
73
92
  const ffmpegToJpeg = (png: string, jpg: string) => exec("ffmpeg", ["-y", "-loglevel", "error", "-i", png, "-vf", "scale=540:-2", "-q:v", "5", jpg]).then(() => undefined);
74
93
 
75
- type PreviewPoint = { sceneId: string; point: "early" | "late" | "middle"; frame: number; name: string };
94
+ type PreviewPoint = { sceneId: string; point: "early" | "late" | "middle" | "end" | "start"; frame: number; name: string };
76
95
 
77
96
  // 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
97
  // 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 +105,30 @@ export function previewPoints(scenes: { id: string; startFrame: number; duration
86
105
  });
87
106
  }
88
107
 
108
+ type BoundaryPoint = { from: string; to: string; end: PreviewPoint; start: PreviewPoint };
109
+
110
+ // 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.
111
+ // 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
112
+ // 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.
113
+ export function boundaryPoints(scenes: { id: string; startFrame: number; durationFrames: number }[]): BoundaryPoint[] {
114
+ const edge = (d: number) => Math.max(1, Math.min(8, Math.floor(d / 2)));
115
+ return scenes.slice(0, -1).map((a, i) => {
116
+ const b = scenes[i + 1]!;
117
+ const n = `b${String(i + 1).padStart(2, "0")}`;
118
+ return {
119
+ from: a.id, to: b.id,
120
+ end: { sceneId: a.id, point: "end", frame: a.startFrame + Math.max(0, a.durationFrames - edge(a.durationFrames)), name: `${n}-end-${a.id}` },
121
+ start: { sceneId: b.id, point: "start", frame: b.startFrame + Math.min(b.durationFrames - 1, edge(b.durationFrames)), name: `${n}-start-${b.id}` },
122
+ };
123
+ });
124
+ }
125
+
126
+ // A small grey copy of a frame as plain pixels, for the continuity measurement.
127
+ async function decodeGray(png: string, width: number, height: number): Promise<Uint8Array> {
128
+ 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 });
129
+ return new Uint8Array(stdout);
130
+ }
131
+
89
132
  export async function preview(ctx: Ctx, deps: { toJpeg?: (png: string, jpg: string) => Promise<void>; ensureBrowser?: EnsureBrowser } = {}): Promise<Result> {
90
133
  const toJpeg = deps.toJpeg ?? ffmpegToJpeg;
91
134
  const project = openProject(ctx.cwd);
@@ -94,11 +137,13 @@ export async function preview(ctx: Ctx, deps: { toJpeg?: (png: string, jpg: stri
94
137
  const dir = project.path("out/preview");
95
138
  rmSync(dir, { recursive: true, force: true });
96
139
  mkdirSync(dir, { recursive: true });
97
- const points = previewPoints(manifest.scenes);
140
+ const sceneFrames = previewPoints(manifest.scenes);
141
+ const boundaries = boundaryPoints(manifest.scenes);
142
+ const points = [...sceneFrames, ...boundaries.flatMap((b) => [b.end, b.start])];
98
143
  try {
99
144
  try {
100
145
  await ensureBrowserWithNotice(ctx, deps.ensureBrowser);
101
- await withBundle(project, (serveUrl, urls) => renderStills(serveUrl, { manifest, urls }, points.map((p) => p.frame), dir));
146
+ await withBundle(project, (serveUrl, urls) => renderStills(serveUrl, { manifest, urls }, [...new Set(points.map((p) => p.frame))], dir));
102
147
  } catch (e) {
103
148
  return failed([`Render failed: ${e instanceof Error ? e.message : String(e)}`]);
104
149
  }
@@ -113,7 +158,27 @@ export async function preview(ctx: Ctx, deps: { toJpeg?: (png: string, jpg: stri
113
158
  } catch {
114
159
  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
160
  }
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")}` };
161
+ // Whether anything survives each scene change is advice. If it cannot be measured, the preview is still the preview.
162
+ let continuity: { score: number | null; boundaries: Boundary[] } | undefined;
163
+ let continuityLines: string[] = [];
164
+ try {
165
+ const h = Math.round((SAMPLE_WIDTH * manifest.height) / manifest.width);
166
+ const measured: Boundary[] = [];
167
+ for (const b of boundaries) {
168
+ 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)]);
169
+ const r = compareFrames(a, c, SAMPLE_WIDTH, h);
170
+ measured.push({ from: b.from, to: b.to, survived: Math.round(r.survived * 100) / 100, kind: r.kind });
171
+ }
172
+ if (measured.length) {
173
+ const summary = summariseContinuity(measured);
174
+ continuity = { score: summary.score === null ? null : Math.round(summary.score * 100) / 100, boundaries: measured };
175
+ continuityLines = summary.lines;
176
+ }
177
+ } catch { /* no continuity report */ }
178
+ 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")}`;
179
+ 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")}` : "";
180
+ const beat = beatReport(manifest);
181
+ 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
182
  } finally {
118
183
  // The full-size stills are only inputs; none stays behind however this ends.
119
184
  for (const f of readdirSync(dir)) if (/^still-\d+\.png$/.test(f)) rmSync(join(dir, f), { force: true });
@@ -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
 
@@ -15,6 +15,25 @@ export function loadPlan(project: Project): ScenePlan {
15
15
  return parsed.data;
16
16
  }
17
17
 
18
+ // What a plan that follows a reference must satisfy: the reference has to be measured, and the pace should be of the same order.
19
+ function referenceChecks(project: Project, plan: ScenePlan): { mustFix: string[]; improve: string[] } {
20
+ if (!plan.reference) return { mustFix: [], improve: [] };
21
+ const id = plan.reference.id;
22
+ if (!/^r-[0-9a-f]{8}$/.test(id) || !project.exists(`refs/${id}/ref.json`))
23
+ return { mustFix: [`reference ${id} is not in this project. Run \`reelkit ref download <url-or-file>\` and use the id it prints, or remove "reference" from plan.json.`], improve: [] };
24
+ const file = `refs/${id}/breakdown.json`;
25
+ if (!project.exists(file)) return { mustFix: [`reference ${id} has not been analysed yet. Run \`reelkit ref analyze ${id}\`.`], improve: [] };
26
+ let average: unknown;
27
+ try { average = project.readJson<{ pacing?: { averageShotSec?: unknown } }>(file).pacing?.averageShotSec; } catch { average = undefined; }
28
+ if (typeof average !== "number" || !(average > 0)) return { mustFix: [`${file} has no average shot length. Run \`reelkit ref analyze ${id} --redo\`.`], improve: [] };
29
+ const ours = estimateLength(plan).seconds / plan.scenes.length;
30
+ const ratio = ours / average;
31
+ const improve = ratio > 2 || ratio < 0.5
32
+ ? [`The plan's scenes average ${ours.toFixed(1)}s but the reference's shots average ${average.toFixed(1)}s: ${ratio > 2 ? "the new video will feel much slower" : "the new video will feel much faster"}. Change the number of scenes or the narration length, or say in reference.take why the pace differs.`]
33
+ : [];
34
+ return { mustFix: [], improve };
35
+ }
36
+
18
37
  // Hard rules (mustFix) stop the video; soft notes (shouldImprove) are advice for a better script.
19
38
  export async function planCheck(ctx: Ctx): Promise<Result> {
20
39
  const project = openProject(ctx.cwd);
@@ -34,15 +53,18 @@ export async function planCheck(ctx: Ctx): Promise<Result> {
34
53
  const footage = project.footage();
35
54
  const assets = project.assets().filter((a) => a.id !== footage?.id);
36
55
  const voiceIds = loadCredentials(ctx.env) ? (await client(ctx)("voices", {})).voices.map((v) => v.id) : undefined;
37
- const mustFix = validatePlan(parsed.data, { aspect: project.config().aspect, footage, assets, voiceIds });
38
- const shouldImprove = mustFix.length ? [] : reviewPlan(parsed.data, { footage });
56
+ const ref = referenceChecks(project, parsed.data);
57
+ const mustFix = [...validatePlan(parsed.data, { aspect: project.config().aspect, footage, assets, voiceIds }), ...ref.mustFix];
58
+ const shouldImprove = mustFix.length ? [] : [...reviewPlan(parsed.data, { footage }), ...ref.improve];
39
59
  const length = estimateLength(parsed.data);
40
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;
41
63
  const lines = [
42
64
  mustFix.length ? `Fix these:\n- ${mustFix.join("\n- ")}` : "The plan is valid.",
43
65
  `Estimated length: about ${estimatedSeconds}s (${length.words} words).`,
44
66
  ...(shouldImprove.length ? [`Worth improving:\n- ${shouldImprove.join("\n- ")}`] : []),
45
67
  ...(voiceIds ? [] : ["The voice was not checked because you are not logged in. Run `reelkit auth login`."]),
46
68
  ];
47
- 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") };
48
70
  }
@@ -0,0 +1,315 @@
1
+ import { execFile } from "node:child_process";
2
+ import { createHash, randomUUID } from "node:crypto";
3
+ import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs";
4
+ import { basename, extname, resolve } from "node:path";
5
+ import { z } from "zod";
6
+ import { ApiFailure, uploadTo } from "../api/client";
7
+ import { client, type Ctx, type Result } from "../context";
8
+ import { MAX_TRANSCRIBE_SECONDS } from "../contract";
9
+ import { loadCredentials } from "../credentials";
10
+ import { analyzeBeats, cutsOnBeat } from "../project/beats";
11
+ import { openProject, type Project } from "../project/project";
12
+ import {
13
+ audioDuration, coefficientOfVariation, decodeMono, detectCuts, extractFrame, extractMp3, loudnessLufs, nearestAspect, paletteOfFrames, paletteOfVideo, probeVideo, REF_ASPECTS, stillnessOfVideo, toMp4,
14
+ } from "../project/refmeasure";
15
+
16
+ // A reference is for learning how a video is built, so ten minutes is more than it needs and keeps every step quick.
17
+ export const MAX_REF_SECONDS = 600;
18
+ const MAX_SCENES = 40;
19
+ const REF_ID = /^r-[0-9a-f]{8}$/;
20
+
21
+ export const RefRecordSchema = z.object({
22
+ id: z.string(), source: z.string(), urlHash: z.string().optional(),
23
+ durationSec: z.number(), width: z.number(), height: z.number(), aspect: z.enum(REF_ASPECTS), fps: z.number(), hasAudio: z.boolean(), createdAt: z.string(),
24
+ });
25
+ export type RefRecord = z.infer<typeof RefRecordSchema>;
26
+
27
+ export type Breakdown = {
28
+ source: string; durationSec: number; width: number; height: number; aspect: RefRecord["aspect"]; fps: number;
29
+ scenes: { index: number; startSec: number; endSec: number; durationSec: number; frames: string[]; palette: string[] }[];
30
+ // True when the video has more cuts than the 40 scenes listed: the last scene then holds everything after the 39th cut.
31
+ scenesCapped?: boolean;
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 };
37
+ palette: string[];
38
+ audio: { hasAudio: boolean; loudnessLufs?: number; tempoBpm?: number; beatConfidence: number };
39
+ beats?: number[];
40
+ transcript?: { language?: string; text: string; segments: { text: string; startSec: number; endSec: number }[] };
41
+ };
42
+
43
+ export const REF_NOTICE = "You are responsible for having the right to download this video; it is used only as a reference: none of its footage or music goes into the output.";
44
+
45
+ // The external program that fetches a link. Tests give their own so that nothing touches the network.
46
+ export type YtdlpRunner = { available(): Promise<boolean>; run(args: string[]): Promise<void> };
47
+ export type RefDeps = { ytdlp?: YtdlpRunner };
48
+
49
+ const realYtdlp: YtdlpRunner = {
50
+ available: () => new Promise((done) => { execFile("yt-dlp", ["--version"], (err) => done(!err)); }),
51
+ run: (args) => new Promise((done, fail) => {
52
+ // No shell: the link is one element of the argument list and can never become a command.
53
+ execFile("yt-dlp", args, { maxBuffer: 64 * 1024 * 1024 }, (err, _out, stderr) => {
54
+ if (!err) return done();
55
+ const last = String(stderr).trim().split("\n").filter(Boolean).at(-1) ?? err.message;
56
+ fail(new Error(last.replace(/^ERROR:\s*/, "")));
57
+ });
58
+ }),
59
+ };
60
+
61
+ const YTDLP_MISSING = "yt-dlp is not installed, and links need it. Install it (macOS: `brew install yt-dlp`, or `pipx install yt-dlp`) and run the command again; a video file on this machine works without it.";
62
+
63
+ // One video, at most 1080p, as mp4, and nothing longer than the limit: the flags that keep a link from fetching a playlist, a channel or a huge file.
64
+ export function ytdlpArgs(url: string, dest: string): string[] {
65
+ return [
66
+ "--ignore-config", "--no-playlist", "--max-downloads", "1", "--no-progress", "--no-warnings",
67
+ "--match-filter", `duration<=${MAX_REF_SECONDS}`,
68
+ "-f", "bv*[height<=1080][ext=mp4]+ba[ext=m4a]/b[height<=1080][ext=mp4]/bv*[height<=1080]+ba/b[height<=1080]",
69
+ "--merge-output-format", "mp4", "-o", dest, "--", url,
70
+ ];
71
+ }
72
+
73
+ const dirOf = (id: string) => `refs/${id}`;
74
+ const fileOf = (id: string, name: string) => `refs/${id}/${name}`;
75
+
76
+ function loadRef(project: Project, id: string): RefRecord {
77
+ if (!REF_ID.test(id)) throw new Error(`"${id}" is not a reference id. Run \`reelkit ref list\` to see them.`);
78
+ if (!project.exists(fileOf(id, "ref.json"))) throw new Error(`There is no reference ${id} in this project. Run \`reelkit ref list\`, or \`reelkit ref download <url-or-file>\`.`);
79
+ const parsed = RefRecordSchema.safeParse(project.readJson(fileOf(id, "ref.json")));
80
+ if (!parsed.success) throw new Error(`refs/${id}/ref.json is not valid. Download the reference again with \`reelkit ref download\`.`);
81
+ return parsed.data;
82
+ }
83
+
84
+ function allRefs(project: Project): RefRecord[] {
85
+ if (!project.exists("refs")) return [];
86
+ const out: RefRecord[] = [];
87
+ for (const name of readdirSync(project.path("refs")).sort()) {
88
+ if (!REF_ID.test(name) || !project.exists(fileOf(name, "ref.json"))) continue;
89
+ const parsed = RefRecordSchema.safeParse(project.readJsonOr(fileOf(name, "ref.json"), null));
90
+ if (parsed.success) out.push(parsed.data);
91
+ }
92
+ return out;
93
+ }
94
+
95
+ const fmt = (n: number) => String(Math.round(n * 10) / 10);
96
+ const minutes = (sec: number) => (sec >= 90 ? `${Math.round(sec / 60)} minutes` : `${Math.round(sec)} seconds`);
97
+ const tooLong = (what: string, sec: number) => `${what} is ${minutes(sec)} long and a reference is limited to ${MAX_REF_SECONDS / 60} minutes. Pick a shorter video, or trim it and give the file.`;
98
+
99
+ // A link has a scheme of two or more letters; "C:\\clip.mp4" is a path.
100
+ const looksLikeLink = (s: string) => /^[a-z][a-z0-9+.-]+:/i.test(s);
101
+
102
+ export async function refDownload(ctx: Ctx, input: string, opts: { redo?: boolean } = {}, deps: RefDeps = {}): Promise<Result> {
103
+ const project = openProject(ctx.cwd);
104
+ input = input.trim();
105
+ const isLink = looksLikeLink(input);
106
+ let url: URL | undefined;
107
+ if (isLink) {
108
+ try { url = new URL(input); } catch { return { ok: false, summary: `"${input}" is not a link that can be downloaded. Give an http or https link, or a video file.` }; }
109
+ if (url.protocol !== "http:" && url.protocol !== "https:") return { ok: false, summary: `Only http and https links can be downloaded, not ${url.protocol}. Give an http or https link, or a video file.` };
110
+ }
111
+
112
+ const urlHash = url ? createHash("sha256").update(url.href).digest("hex") : undefined;
113
+ let id = `r-${randomUUID().replace(/-/g, "").slice(0, 8)}`;
114
+ if (urlHash) {
115
+ const have = allRefs(project).find((r) => r.urlHash === urlHash && project.exists(fileOf(r.id, "video.mp4")));
116
+ if (have && !opts.redo) return { ok: true, data: have, summary: `This link is already downloaded as ${have.id} (refs/${have.id}/video.mp4). Use --redo to download it again. Next: \`reelkit ref analyze ${have.id}\`.\n${REF_NOTICE}` };
117
+ if (have) { id = have.id; rmSync(project.path(dirOf(id)), { recursive: true, force: true }); }
118
+ }
119
+
120
+ const video = project.path(fileOf(id, "video.mp4"));
121
+ const cleanup = () => rmSync(project.path(dirOf(id)), { recursive: true, force: true });
122
+ let source: string;
123
+ try {
124
+ if (url) {
125
+ const runner = deps.ytdlp ?? realYtdlp;
126
+ if (!(await runner.available())) return { ok: false, summary: YTDLP_MISSING };
127
+ source = url.href;
128
+ mkdirSync(project.path(dirOf(id)), { recursive: true });
129
+ ctx.log(`Downloading ${url.href} with yt-dlp.`);
130
+ try { await runner.run(ytdlpArgs(url.href, video)); }
131
+ catch (e) { cleanup(); return { ok: false, summary: `yt-dlp could not download this link: ${e instanceof Error ? e.message : String(e)} Check the link, or download the video yourself and give its file.` }; }
132
+ if (!existsSync(video)) { cleanup(); return { ok: false, summary: "yt-dlp finished but saved no video. The link may be a page without a video, or longer than 10 minutes. Check the link, or give a video file." }; }
133
+ } else {
134
+ const from = resolve(ctx.cwd, input);
135
+ if (!existsSync(from) || !statSync(from).isFile()) return { ok: false, summary: `${basename(from)} does not exist. Check the path.` };
136
+ source = basename(from);
137
+ const pre = await probeVideo(from, basename(from));
138
+ if (pre.durationSec > MAX_REF_SECONDS) return { ok: false, summary: tooLong(basename(from), pre.durationSec) };
139
+ mkdirSync(project.path(dirOf(id)), { recursive: true });
140
+ // mp4 is copied as it is; any other container is converted, so that refs/<id>/video.mp4 is an mp4 whatever was given.
141
+ if ([".mp4", ".m4v"].includes(extname(from).toLowerCase())) copyFileSync(from, video);
142
+ else await toMp4(from, video);
143
+ }
144
+ const info = await probeVideo(video, source);
145
+ if (info.durationSec > MAX_REF_SECONDS) { cleanup(); return { ok: false, summary: tooLong("This video", info.durationSec) }; }
146
+ const record: RefRecord = {
147
+ id, source, ...(urlHash ? { urlHash } : {}), ...info, aspect: nearestAspect(info.width, info.height), createdAt: new Date().toISOString(),
148
+ };
149
+ project.writeJson(fileOf(id, "ref.json"), record);
150
+ return {
151
+ ok: true, data: record,
152
+ summary: `Saved reference ${id}: ${fmt(info.durationSec)}s, ${info.width}x${info.height} (${record.aspect}), ${fmt(info.fps)} fps, ${info.hasAudio ? "with audio" : "no audio"}, at refs/${id}/video.mp4. Next: \`reelkit ref analyze ${id}\`.\n${REF_NOTICE}`,
153
+ };
154
+ } catch (e) {
155
+ cleanup();
156
+ // Every failure here is already a plain line written for the person who gave the video.
157
+ return { ok: false, summary: e instanceof Error ? e.message : String(e) };
158
+ }
159
+ }
160
+
161
+ // The audio of a reference as a small mp3, made once.
162
+ async function ensureAudio(project: Project, ref: RefRecord): Promise<string> {
163
+ const rel = fileOf(ref.id, "audio.mp3");
164
+ if (!project.exists(rel)) await extractMp3(project.path(fileOf(ref.id, "video.mp4")), project.path(rel));
165
+ return rel;
166
+ }
167
+
168
+ export async function refAudio(ctx: Ctx, id: string): Promise<Result> {
169
+ const project = openProject(ctx.cwd);
170
+ const ref = loadRef(project, id);
171
+ if (!project.exists(fileOf(id, "video.mp4"))) return { ok: false, summary: `refs/${id}/video.mp4 is missing. Download the reference again with \`reelkit ref download --redo\`.` };
172
+ if (!ref.hasAudio) return { ok: false, summary: `Reference ${id} has no audio track, so there is no audio to extract.` };
173
+ const had = project.exists(fileOf(id, "audio.mp3"));
174
+ const rel = await ensureAudio(project, ref);
175
+ const bytes = statSync(project.path(rel)).size;
176
+ return { ok: true, data: { path: rel, bytes, skipped: had }, summary: had ? `The audio of ${id} is already at ${rel}.` : `Saved the audio of ${id} at ${rel} (mono, 16 kHz, ${Math.round(bytes / 1024)} KB).` };
177
+ }
178
+
179
+ export async function refList(ctx: Ctx): Promise<Result> {
180
+ const project = openProject(ctx.cwd);
181
+ const refs = allRefs(project).map((r) => ({ id: r.id, source: r.source, durationSec: r.durationSec, analyzed: project.exists(fileOf(r.id, "breakdown.json")) }));
182
+ return {
183
+ ok: true, data: { references: refs },
184
+ summary: refs.length
185
+ ? refs.map((r) => `${r.id} ${fmt(r.durationSec)}s ${r.analyzed ? "analysed" : "not analysed"} ${r.source}`).join("\n")
186
+ : "No references in this project yet. Run `reelkit ref download <url-or-file>`.",
187
+ };
188
+ }
189
+
190
+ type TranscriptOutcome = { transcript?: NonNullable<Breakdown["transcript"]>; note: string };
191
+
192
+ // The audio (never the video) goes to the server and comes back as text. Whatever goes wrong here costs the transcript only: the measurements
193
+ // of the video do not depend on it.
194
+ async function transcribe(ctx: Ctx, project: Project, ref: RefRecord): Promise<TranscriptOutcome> {
195
+ const retry = `\`reelkit ref analyze ${ref.id} --redo\``;
196
+ if (!loadCredentials(ctx.env)) return { note: `No transcript: you are not logged in. Run \`reelkit auth login\`, then ${retry}.` };
197
+ try {
198
+ const rel = await ensureAudio(project, ref);
199
+ const path = project.path(rel);
200
+ const bytes = readFileSync(path);
201
+ const api = client(ctx);
202
+ ctx.log("Sending the audio (not the video) to the Reelkit server to be transcribed.");
203
+ const durationSec = Math.min(MAX_TRANSCRIBE_SECONDS, Math.max(0.1, await audioDuration(path)));
204
+ const up = await api("transcribeStart", { filename: "audio.mp3", contentType: "audio/mpeg", bytes: bytes.length, durationSec });
205
+ await uploadTo(up.uploadUrl, new Uint8Array(bytes), "audio/mpeg");
206
+ const t = await api("transcribeRun", { id: up.id });
207
+ return {
208
+ transcript: { ...(t.language ? { language: t.language } : {}), text: t.text, segments: t.segments },
209
+ note: `The audio (not the video) was sent to the Reelkit server to be transcribed and was deleted there. It used ${Math.ceil(t.durationSec)} seconds of your monthly transcription quota (\`reelkit whoami\`).`,
210
+ };
211
+ } catch (e) {
212
+ const why = e instanceof ApiFailure ? e.message : `the Reelkit server could not be reached (${e instanceof Error ? e.message : String(e)}).`;
213
+ return { note: `No transcript: ${why.endsWith(".") ? why : `${why}.`} Retry with ${retry}.` };
214
+ }
215
+ }
216
+
217
+ // Runs `fn` over the items, `size` at a time, and keeps the order of the results.
218
+ async function inBatches<T, R>(items: T[], size: number, fn: (item: T, index: number) => Promise<R>): Promise<R[]> {
219
+ const out: R[] = [];
220
+ for (let i = 0; i < items.length; i += size) out.push(...(await Promise.all(items.slice(i, i + size).map((x, j) => fn(x, i + j)))));
221
+ return out;
222
+ }
223
+
224
+ const round2 = (n: number) => Math.round(n * 100) / 100;
225
+
226
+ async function measure(ctx: Ctx, project: Project, ref: RefRecord): Promise<Breakdown> {
227
+ const video = project.path(fileOf(ref.id, "video.mp4"));
228
+ ctx.log("Finding the cuts.");
229
+ const cuts = await detectCuts(video, ref.durationSec);
230
+ // The scenes listed are capped; the pacing below is measured on every cut.
231
+ const sceneCuts = cuts.slice(0, MAX_SCENES - 1);
232
+ const bounds = [0, ...sceneCuts, ref.durationSec];
233
+ rmSync(project.path(fileOf(ref.id, "frames")), { recursive: true, force: true });
234
+ ctx.log(`Saving sample frames of ${bounds.length - 1} scene(s).`);
235
+ // Each scene is a few short ffmpeg runs; running several scenes at once keeps a video with many cuts quick.
236
+ const scenes = await inBatches(bounds.slice(0, -1), 6, async (start, i) => {
237
+ const end = bounds[i + 1]!, len = end - start;
238
+ const n = String(i + 1).padStart(2, "0");
239
+ const frames = [`${dirOf(ref.id)}/frames/scene-${n}-1.jpg`, `${dirOf(ref.id)}/frames/scene-${n}-2.jpg`];
240
+ // A little inside the end, so that the last frame of the video can be read.
241
+ await extractFrame(video, start + len * 0.25, project.path(frames[0]!));
242
+ await extractFrame(video, Math.min(start + len * 0.75, ref.durationSec - 0.05), project.path(frames[1]!));
243
+ return { index: i + 1, startSec: round2(start), endSec: round2(end), durationSec: round2(len), frames, palette: await paletteOfFrames(frames.map((f) => project.path(f))) };
244
+ });
245
+ ctx.log("Measuring colours and sound.");
246
+ const palette = await paletteOfVideo(video, ref.durationSec);
247
+
248
+ const shots = [0, ...cuts, ref.durationSec].map((t, i, all) => (i ? t - all[i - 1]! : 0)).slice(1);
249
+ const pacing: Breakdown["pacing"] = {
250
+ cuts: cuts.length, averageShotSec: round2(ref.durationSec / shots.length), shortestShotSec: round2(Math.min(...shots)), longestShotSec: round2(Math.max(...shots)),
251
+ cutsPerSecond: round2(cuts.length / ref.durationSec), ...(cuts.length ? { firstCutSec: round2(cuts[0]!) } : {}),
252
+ ...(shots.length >= 3 ? { shotLengthVariation: round2(coefficientOfVariation(shots)) } : {}),
253
+ };
254
+ ctx.log("Measuring how much of the time the picture is still.");
255
+ const still = await stillnessOfVideo(video, ref.width, ref.height);
256
+
257
+ const audio: Breakdown["audio"] = { hasAudio: ref.hasAudio, beatConfidence: 0 };
258
+ let beats: number[] | undefined;
259
+ if (ref.hasAudio) {
260
+ // A track that cannot be measured is a reference without a tempo, not a failed analysis.
261
+ const loud = await loudnessLufs(video).catch(() => undefined);
262
+ if (loud !== undefined) audio.loudnessLufs = loud;
263
+ try {
264
+ const rate = 11025;
265
+ const r = analyzeBeats(await decodeMono(video, rate), rate);
266
+ audio.beatConfidence = r.confidence;
267
+ if (r.tempoBpm !== undefined && r.beats) {
268
+ audio.tempoBpm = r.tempoBpm;
269
+ beats = r.beats.filter((t) => t <= ref.durationSec);
270
+ if (cuts.length) pacing.cutsOnBeat = round2(cutsOnBeat(cuts, beats));
271
+ }
272
+ } catch { /* no tempo */ }
273
+ }
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 } : {}) };
275
+ }
276
+
277
+ // A short text a person or an agent can read at once.
278
+ function digest(id: string, b: Breakdown, notes: string[]): string {
279
+ const lines = [
280
+ `Reference ${id}: ${fmt(b.durationSec)}s, ${b.width}x${b.height} (${b.aspect}), ${fmt(b.fps)} fps.`,
281
+ b.pacing.cuts
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.`
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).` : ""}`] : []),
285
+ `Palette: ${b.palette.join(", ")}.`,
286
+ !b.audio.hasAudio ? "Audio: none."
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` : ""}.`,
288
+ ];
289
+ if (b.transcript) lines.push(`Transcript${b.transcript.language ? ` (${b.transcript.language})` : ""}: "${b.transcript.text.slice(0, 200)}${b.transcript.text.length > 200 ? "..." : ""}"`);
290
+ lines.push(...notes);
291
+ lines.push(`Look at the frames now: refs/${id}/frames/ has two per scene (scene-01-1.jpg, scene-01-2.jpg, ...). The full numbers are in refs/${id}/breakdown.json.`);
292
+ return lines.join("\n");
293
+ }
294
+
295
+ export async function refAnalyze(ctx: Ctx, id: string, opts: { transcript?: boolean; redo?: boolean } = {}): Promise<Result> {
296
+ const project = openProject(ctx.cwd);
297
+ const ref = loadRef(project, id);
298
+ if (!project.exists(fileOf(id, "video.mp4"))) return { ok: false, summary: `refs/${id}/video.mp4 is missing. Download the reference again with \`reelkit ref download --redo\`.` };
299
+ const file = fileOf(id, "breakdown.json");
300
+ if (project.exists(file) && !opts.redo) {
301
+ const have = project.readJson<Breakdown>(file);
302
+ return { ok: true, data: have, summary: digest(id, have, [`Already analysed: this is the saved breakdown. Use --redo to measure again.`]) };
303
+ }
304
+ const breakdown = await measure(ctx, project, ref);
305
+ const notes: string[] = [];
306
+ if (opts.transcript === false) notes.push(`No transcript (--no-transcript). Nothing was sent anywhere. To add it later: \`reelkit ref analyze ${id} --redo\`.`);
307
+ else if (!ref.hasAudio) notes.push("No transcript: the reference has no audio. Nothing was sent anywhere.");
308
+ else {
309
+ const t = await transcribe(ctx, project, ref);
310
+ if (t.transcript) breakdown.transcript = t.transcript;
311
+ notes.push(t.note);
312
+ }
313
+ project.writeJson(file, breakdown);
314
+ return { ok: true, data: breakdown, summary: digest(id, breakdown, notes) };
315
+ }