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.
- package/README.md +1 -1
- package/package.json +1 -1
- package/skill/SKILL.md +11 -2
- package/skill/reference/beat-sync.md +45 -0
- package/skill/reference/captions.md +18 -2
- package/skill/reference/clips.md +1 -0
- package/skill/reference/continuity.md +99 -0
- package/skill/reference/kit.md +18 -1
- package/skill/reference/motion-design.md +9 -0
- package/skill/reference/references.md +43 -0
- package/skill/reference/remotion-composition.md +2 -1
- package/skill/reference/scene-treatments.md +1 -1
- package/skill/reference/scriptwriting.md +39 -5
- package/skill/reference/sound-design.md +11 -0
- package/skill/reference/styles.md +9 -0
- package/src/cli.ts +14 -2
- package/src/commands/assets.ts +38 -8
- package/src/commands/auth.ts +1 -1
- package/src/commands/build.ts +73 -8
- package/src/commands/plan.ts +26 -4
- package/src/commands/ref.ts +315 -0
- package/src/contract/index.ts +25 -1
- package/src/pipeline/beatsnap.ts +52 -0
- package/src/pipeline/review.ts +28 -1
- package/src/pipeline/schema.ts +14 -0
- package/src/project/beats.ts +130 -0
- package/src/project/chromakey.ts +14 -4
- package/src/project/manifest.ts +22 -1
- package/src/project/music.ts +25 -0
- package/src/project/project.ts +1 -1
- package/src/project/refmeasure.ts +192 -0
- package/src/remotion/Root.tsx +4 -2
- package/src/remotion/kit/Camera.tsx +22 -0
- package/src/remotion/kit/Captions.tsx +25 -8
- package/src/remotion/kit/Carry.tsx +38 -0
- package/src/remotion/kit/Music.tsx +19 -0
- package/src/remotion/kit/beat.ts +23 -0
- package/src/remotion/kit/caption-groups.ts +65 -0
- package/src/remotion/kit/docs.ts +18 -1
- package/src/remotion/kit/index.ts +7 -0
- package/src/remotion/kit/media.ts +15 -0
- package/src/remotion/kit/motion-math.ts +113 -0
- package/src/remotion/kit/music-math.ts +42 -0
- package/src/render/continuity.ts +87 -0
- package/src/render/validate.ts +27 -0
- package/src/testing/conformance.ts +107 -1
- package/src/testing/fake-api.ts +44 -6
package/src/commands/assets.ts
CHANGED
|
@@ -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
|
-
|
|
428
|
-
|
|
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: "" };
|
package/src/commands/auth.ts
CHANGED
|
@@ -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
|
}
|
package/src/commands/build.ts
CHANGED
|
@@ -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
|
|
43
|
-
|
|
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
|
|
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
|
-
|
|
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 });
|
package/src/commands/plan.ts
CHANGED
|
@@ -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
|
|
38
|
-
const
|
|
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
|
+
}
|