reelkit-cli 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -2
- package/package.json +3 -2
- package/skill/SKILL.md +11 -5
- package/skill/reference/beat-sync.md +54 -0
- package/skill/reference/captions.md +18 -2
- package/skill/reference/component-authoring.md +12 -1
- package/skill/reference/continuity.md +99 -0
- package/skill/reference/kit.md +19 -1
- package/skill/reference/motion-design.md +9 -0
- package/skill/reference/references.md +11 -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 +10 -4
- package/src/commands/assets.ts +46 -5
- package/src/commands/build.ts +99 -11
- package/src/commands/components.ts +220 -0
- package/src/commands/init.ts +9 -3
- package/src/commands/plan.ts +4 -2
- package/src/commands/ref.ts +11 -3
- package/src/contract/index.ts +23 -3
- package/src/pipeline/beatsnap.ts +52 -0
- package/src/pipeline/review.ts +54 -1
- package/src/pipeline/schema.ts +8 -0
- package/src/project/loudness.ts +68 -0
- package/src/project/manifest.ts +31 -1
- package/src/project/music.ts +25 -0
- package/src/project/project.ts +4 -2
- package/src/project/refmeasure.ts +45 -1
- 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/Sfx.tsx +12 -6
- package/src/remotion/kit/beat.ts +23 -0
- package/src/remotion/kit/caption-groups.ts +65 -0
- package/src/remotion/kit/docs.ts +19 -1
- package/src/remotion/kit/index.ts +7 -0
- package/src/remotion/kit/media.ts +17 -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/master.ts +31 -0
- package/src/render/static-check.ts +156 -0
- package/src/render/validate.ts +30 -150
- package/src/testing/conformance.ts +49 -1
- package/src/testing/fake-api.ts +7 -1
package/src/commands/assets.ts
CHANGED
|
@@ -9,10 +9,13 @@ import { GreenTooDullError, keyGreen } from "../project/chromakey";
|
|
|
9
9
|
import { buildManifest, voiceoverStale, type ClipRecord, type Voiceover } from "../project/manifest";
|
|
10
10
|
import { loadPlan } from "./plan";
|
|
11
11
|
import { FILE_NAME } from "../render/validate";
|
|
12
|
+
import { levelOf } from "../project/loudness";
|
|
13
|
+
import { measureMusic, type MusicRecord } from "../project/music";
|
|
12
14
|
import { probeFile } from "../project/probe";
|
|
13
15
|
import { FILES, openProject, type Project } from "../project/project";
|
|
14
16
|
|
|
15
|
-
|
|
17
|
+
// gainDb is the levelling of a sound (sfx or music), in dB, measured when it was pulled: see src/project/loudness.ts.
|
|
18
|
+
export type LibraryEntry = { path: string; kind: string; title: string; meta: Record<string, unknown>; gainDb?: number };
|
|
16
19
|
|
|
17
20
|
// A failure to lay out the timeline after a paid call must not hide that the call succeeded and was saved.
|
|
18
21
|
function tryManifest(project: Project, plan: ScenePlan): { manifest?: AssetManifest; note?: string } {
|
|
@@ -105,7 +108,7 @@ export async function assetsUpload(
|
|
|
105
108
|
if (opts.green && opts.cutout) return { ok: false, summary: "Use --green or --cutout, not both: --green keys a green background on this machine for free, --cutout removes any background on the server." };
|
|
106
109
|
if (opts.resume && !opts.cutout) return { ok: false, summary: "--resume goes with --cutout: it keeps waiting for a cutout that was already started." };
|
|
107
110
|
if (opts.resume) return resumeUploadCutout(ctx, project, file, opts.resume, deps);
|
|
108
|
-
if (opts.share && opts.kind === "component") return { ok: false, summary: "Components
|
|
111
|
+
if (opts.share && opts.kind === "component") return { ok: false, summary: "Components are shared with `reelkit components share`, not with assets upload. Share images, overlays, sound effects, music or clips here." };
|
|
109
112
|
const source = resolve(ctx.cwd, file);
|
|
110
113
|
let probe: Awaited<ReturnType<typeof probeFile>>;
|
|
111
114
|
try {
|
|
@@ -200,10 +203,23 @@ async function resumeUploadCutout(ctx: Ctx, project: Project, file: string, id:
|
|
|
200
203
|
return { ...finished, summary: `Resumed ${record.filename} (${record.id}). ${finished.summary}` };
|
|
201
204
|
}
|
|
202
205
|
|
|
206
|
+
export type SearchRecord = { q: string; kind: string | null; at: string; top: { id: string; match: number }[] };
|
|
207
|
+
const MAX_SEARCHES = 50;
|
|
208
|
+
|
|
209
|
+
// What was searched for in this project, so `reelkit check` can say whether the library was looked at. A search outside a project is not recorded.
|
|
210
|
+
function recordSearch(ctx: Ctx, q: string, kind: string | null, items: { id: string; match: number }[]) {
|
|
211
|
+
try {
|
|
212
|
+
const project = openProject(ctx.cwd);
|
|
213
|
+
const all = project.readJsonOr<SearchRecord[]>(FILES.searches, []);
|
|
214
|
+
project.writeJson(FILES.searches, [...all, { q, kind, at: new Date().toISOString(), top: items.slice(0, 3).map((i) => ({ id: i.id, match: i.match })) }].slice(-MAX_SEARCHES));
|
|
215
|
+
} catch { /* not a project, or the file is unreadable: the search itself still works */ }
|
|
216
|
+
}
|
|
217
|
+
|
|
203
218
|
export async function assetsSearch(ctx: Ctx, query: string, opts: { kind?: string; limit?: string }): Promise<Result> {
|
|
204
219
|
const kind = opts.kind === undefined ? undefined : LibraryKindSchema.safeParse(opts.kind);
|
|
205
220
|
if (kind && !kind.success) return { ok: false, summary: `Unknown kind "${opts.kind}". Use one of: ${LibraryKindSchema.options.join(", ")}.` };
|
|
206
221
|
const { items } = await client(ctx)("librarySearch", { q: query, ...(kind?.success ? { kind: kind.data } : {}), ...(opts.limit ? { limit: Number(opts.limit) } : {}) });
|
|
222
|
+
recordSearch(ctx, query, kind?.success ? kind.data : null, items);
|
|
207
223
|
return {
|
|
208
224
|
ok: true, data: { items },
|
|
209
225
|
summary: items.length
|
|
@@ -228,7 +244,7 @@ function setSceneClip(project: Project, sceneId: string, record: ClipRecord) {
|
|
|
228
244
|
project.writeJson(FILES.clips, { ...project.readJsonOr<Record<string, ClipRecord>>(FILES.clips, {}), [sceneId]: record });
|
|
229
245
|
}
|
|
230
246
|
|
|
231
|
-
export async function assetsPull(ctx: Ctx, id: string, opts: { scene?: string; force?: boolean }, deps: ClipDeps = {}): Promise<Result> {
|
|
247
|
+
export async function assetsPull(ctx: Ctx, id: string, opts: { scene?: string; force?: boolean; music?: boolean }, deps: ClipDeps = {}): Promise<Result> {
|
|
232
248
|
const project = openProject(ctx.cwd);
|
|
233
249
|
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(id)) return { ok: false, summary: `"${id}" is not a library id. Copy the id from \`reelkit assets search\`.` };
|
|
234
250
|
if (opts.scene) {
|
|
@@ -240,7 +256,9 @@ export async function assetsPull(ctx: Ctx, id: string, opts: { scene?: string; f
|
|
|
240
256
|
return { ok: false, summary: `Scene ${opts.scene} is not an illustration scene in plan.json. ${illustrations.length ? `The illustration scenes are: ${illustrations.join(", ")}.` : "The plan has no illustration scenes."}${clipScenes.length ? ` The clip scenes are: ${clipScenes.join(", ")}.` : ""}` };
|
|
241
257
|
}
|
|
242
258
|
}
|
|
259
|
+
if (opts.music && opts.scene) return { ok: false, summary: "--music makes the item the video's track, not a scene's picture. Use --music or --scene, not both." };
|
|
243
260
|
const pulled = await client(ctx)("libraryPull", { id });
|
|
261
|
+
if (opts.music && pulled.item.kind !== "music") return { ok: false, summary: `${id} is ${pulled.item.kind}, not music, so it cannot be the video's track. Search with --kind music.` };
|
|
244
262
|
if (unsafeName(pulled.filename, pulled.item.kind)) return { ok: false, summary: `The library returned an unsafe file name for ${id}, so nothing was written. Report this item.` };
|
|
245
263
|
if (opts.scene) {
|
|
246
264
|
const treatment = loadPlan(project).scenes.find((x) => x.id === opts.scene)?.treatment;
|
|
@@ -259,7 +277,10 @@ export async function assetsPull(ctx: Ctx, id: string, opts: { scene?: string; f
|
|
|
259
277
|
}
|
|
260
278
|
const path = `assets/lib/${id}/${pulled.filename}`;
|
|
261
279
|
await download(pulled.url, project.path(path));
|
|
262
|
-
|
|
280
|
+
// A sound is levelled when it is pulled, so that the same volume number sounds equally loud for every file. A file that cannot be
|
|
281
|
+
// measured is used as it is.
|
|
282
|
+
const level = pulled.item.kind === "sfx" || pulled.item.kind === "music" ? await levelOf(project.path(path)) : undefined;
|
|
283
|
+
recordPull(project, id, { path, kind: pulled.item.kind, title: pulled.item.title, meta: pulled.item.meta, ...(level ? { gainDb: level.gainDb } : {}) });
|
|
263
284
|
// A clip filmed on green also comes keyed, so it can be laid over a background.
|
|
264
285
|
const green = pulled.item.kind === "clip" && pulled.item.meta.greenScreen === true;
|
|
265
286
|
const keyedPath = green ? keyedPathOf(path) : undefined;
|
|
@@ -273,16 +294,36 @@ export async function assetsPull(ctx: Ctx, id: string, opts: { scene?: string; f
|
|
|
273
294
|
} else setSceneImage(project, opts.scene, path);
|
|
274
295
|
if (project.exists(FILES.plan)) note = tryManifest(project, loadPlan(project)).note;
|
|
275
296
|
}
|
|
297
|
+
if (pulled.item.kind === "music" && opts.music) return pullMusic(project, id, pulled.item.title, path, level?.gainDb ?? 0);
|
|
298
|
+
const musicHint = pulled.item.kind === "music" ? ` To make it the video's track, with scene changes on its beat, run \`reelkit assets pull ${id} --music\`.` : "";
|
|
276
299
|
const refs = keyedPath ? ` The original is urls["${path}"]; the keyed copy with a transparent background is urls["${keyedPath}"].` : ` Reference it in the composition as urls["${path}"].`;
|
|
277
300
|
return {
|
|
278
301
|
ok: true, data: { path, kind: pulled.item.kind, ...(keyedPath ? { keyedPath } : {}) },
|
|
279
302
|
summary: [
|
|
280
|
-
opts.scene ? `Pulled ${id} as the ${sceneIsClip ? "clip" : "image"} for scene ${opts.scene}.${keyedPath ? refs : ""}` : `Pulled ${id} to ${path}.${refs}`,
|
|
303
|
+
opts.scene ? `Pulled ${id} as the ${sceneIsClip ? "clip" : "image"} for scene ${opts.scene}.${keyedPath ? refs : ""}` : `Pulled ${id} to ${path}.${refs}${musicHint}`,
|
|
281
304
|
...(note ? [note] : []),
|
|
282
305
|
].join("\n"),
|
|
283
306
|
};
|
|
284
307
|
}
|
|
285
308
|
|
|
309
|
+
// Makes a pulled track the video's one music track: measured here, saved in assets/music.json, and the timeline is laid out again around it.
|
|
310
|
+
async function pullMusic(project: Project, id: string, title: string, path: string, gainDb: number): Promise<Result> {
|
|
311
|
+
let measured: Awaited<ReturnType<typeof measureMusic>>;
|
|
312
|
+
try { measured = await measureMusic(project.path(path)); }
|
|
313
|
+
catch (e) { return { ok: false, summary: `${id} was downloaded to ${path} but could not be read as audio (${e instanceof Error ? e.message : String(e)}). Pull a different track.` }; }
|
|
314
|
+
const previous = project.readJsonOr<MusicRecord | undefined>(FILES.music, undefined);
|
|
315
|
+
const record: MusicRecord = { key: path, id, title, ...measured, gainDb };
|
|
316
|
+
project.writeJson(FILES.music, record);
|
|
317
|
+
const note = project.exists(FILES.plan) ? tryManifest(project, loadPlan(project)).note : undefined;
|
|
318
|
+
const beat = record.bpm
|
|
319
|
+
? `about ${Math.round(record.bpm)} BPM; scene changes will land on its beat.`
|
|
320
|
+
: "no clear tempo was found, so scene changes stay where the narration puts them.";
|
|
321
|
+
return {
|
|
322
|
+
ok: true, data: { path, kind: "music", music: record },
|
|
323
|
+
summary: [`Pulled ${id} as the video's music track${previous && previous.id !== id ? ` (it replaces ${previous.id})` : ""}: ${record.durationSec.toFixed(1)}s, ${beat} Place it once with <Music src={urls["${path}"]} />.`, ...(note ? [note] : [])].join("\n"),
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
286
327
|
export async function assetsVoices(ctx: Ctx): Promise<Result> {
|
|
287
328
|
const { voices } = await client(ctx)("voices", {});
|
|
288
329
|
return {
|
package/src/commands/build.ts
CHANGED
|
@@ -3,12 +3,18 @@ import { mkdirSync, readdirSync, readFileSync, rmSync } from "node:fs";
|
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { promisify } from "node:util";
|
|
5
5
|
import type { Ctx, Result } from "../context";
|
|
6
|
-
import type { AssetManifest } from "../pipeline/schema";
|
|
6
|
+
import type { AssetManifest, ScenePlan } from "../pipeline/schema";
|
|
7
|
+
import { compareFrames, SAMPLE_WIDTH, summariseContinuity, type Boundary } from "../render/continuity";
|
|
7
8
|
import { buildManifest, missingAssets } from "../project/manifest";
|
|
8
|
-
import {
|
|
9
|
+
import { beatReport } from "../project/music";
|
|
10
|
+
import { FILES, openProject, type Project } from "../project/project";
|
|
11
|
+
import { structureNotes } from "../pipeline/review";
|
|
12
|
+
import type { SearchRecord } from "./assets";
|
|
13
|
+
import { libraryUse, shareAfterRender } from "./components";
|
|
9
14
|
import { mediaUrls, serveDir } from "../project/serve";
|
|
10
15
|
import { bundleProject, disposeBundle, ensureRenderBrowser, renderStills, renderVideo, writeEntry, type EnsureBrowser } from "../render/render";
|
|
11
|
-
import {
|
|
16
|
+
import { MASTER_LUFS, MASTER_TRUE_PEAK_DB, masterLoudness, type Master, type Mastered } from "../render/master";
|
|
17
|
+
import { FILE_NAME, MAIN_FILE, jsxUses, staticCheck, typecheck } from "../render/validate";
|
|
12
18
|
import { loadPlan } from "./plan";
|
|
13
19
|
|
|
14
20
|
const exec = promisify(execFile);
|
|
@@ -36,11 +42,34 @@ function inspect(project: Project): { errors: string[]; manifest?: AssetManifest
|
|
|
36
42
|
return manifest ? { errors: [], manifest } : { errors: ["The manifest could not be built. Run `reelkit assets voiceover --all`."] };
|
|
37
43
|
}
|
|
38
44
|
|
|
45
|
+
// Advice about a composition that passes: never an error.
|
|
46
|
+
function compositionNotes(project: Project, plan: ScenePlan): string[] {
|
|
47
|
+
const notes: string[] = [];
|
|
48
|
+
const names = readdirSync(project.path("src")).filter((f) => FILE_NAME.test(f));
|
|
49
|
+
const sources = names.map((n) => [n, readFileSync(project.path(join("src", n)), "utf8")] as const);
|
|
50
|
+
const rendersCaptions = sources.some(([n, src]) => jsxUses(src, n, "Captions").length > 0);
|
|
51
|
+
if (plan.captions === "none" && rendersCaptions) notes.push('The plan says captions: "none" but the composition renders <Captions>. Remove them, or change the plan if the user wants captions.');
|
|
52
|
+
if ((plan.captions === "word" || plan.captions === "phrase") && !rendersCaptions) notes.push(`The plan asks for captions (${plan.captions}) but the composition renders no <Captions>. Add <Captions words={s.words} group={manifest.captions} /> in each scene, or set captions to "none" in the plan if the user does not want them.`);
|
|
53
|
+
notes.push(...structureNotes(plan, { footage: project.footage() }));
|
|
54
|
+
const { pulled, written } = libraryUse(project);
|
|
55
|
+
if (!pulled.length && plan.scenes.length >= 4) {
|
|
56
|
+
const searched = project.readJsonOr<SearchRecord[]>(FILES.searches, []).filter((s) => s.kind === "component").length;
|
|
57
|
+
notes.push(`No library component is used (${searched === 0 ? "no component search was made" : `${searched} component search${searched === 1 ? "" : "es"} made`} in this project). Search before writing: \`reelkit assets search "<what the scene shows>" --kind component\`.`);
|
|
58
|
+
}
|
|
59
|
+
return notes;
|
|
60
|
+
}
|
|
61
|
+
|
|
39
62
|
const failed = (errors: string[]): Result => ({ ok: false, data: { errors }, summary: `${errors.length} problem(s):\n- ${errors.join("\n- ")}` });
|
|
40
63
|
|
|
41
64
|
export async function check(ctx: Ctx): Promise<Result> {
|
|
42
|
-
const
|
|
43
|
-
|
|
65
|
+
const project = openProject(ctx.cwd);
|
|
66
|
+
const { errors } = inspect(project);
|
|
67
|
+
if (errors.length) return failed(errors);
|
|
68
|
+
const shouldImprove = compositionNotes(project, loadPlan(project));
|
|
69
|
+
return {
|
|
70
|
+
ok: true, data: { errors: [], shouldImprove, library: libraryUse(project) },
|
|
71
|
+
summary: ["The composition passes. Run `reelkit preview` to see it.", ...(shouldImprove.length ? [`Worth improving:\n- ${shouldImprove.join("\n- ")}`] : [])].join("\n"),
|
|
72
|
+
};
|
|
44
73
|
}
|
|
45
74
|
|
|
46
75
|
// Bundles the composition and serves the project's media for as long as fn runs.
|
|
@@ -72,7 +101,7 @@ export async function ensureBrowserWithNotice(ctx: Ctx, ensure: EnsureBrowser =
|
|
|
72
101
|
|
|
73
102
|
const ffmpegToJpeg = (png: string, jpg: string) => exec("ffmpeg", ["-y", "-loglevel", "error", "-i", png, "-vf", "scale=540:-2", "-q:v", "5", jpg]).then(() => undefined);
|
|
74
103
|
|
|
75
|
-
type PreviewPoint = { sceneId: string; point: "early" | "late" | "middle"; frame: number; name: string };
|
|
104
|
+
type PreviewPoint = { sceneId: string; point: "early" | "late" | "middle" | "end" | "start"; frame: number; name: string };
|
|
76
105
|
|
|
77
106
|
// Two frames per scene, at 30% and 90% of its length, so what enters late in a scene is seen as well as what opens it.
|
|
78
107
|
// A scene under 4 frames has no room for two distinct frames and gets its middle one. The scene's number leads each name
|
|
@@ -86,6 +115,30 @@ export function previewPoints(scenes: { id: string; startFrame: number; duration
|
|
|
86
115
|
});
|
|
87
116
|
}
|
|
88
117
|
|
|
118
|
+
type BoundaryPoint = { from: string; to: string; end: PreviewPoint; start: PreviewPoint };
|
|
119
|
+
|
|
120
|
+
// SceneFrame fades each scene in and out over up to 8 frames, so the very last and very first frames of two scenes are nearly empty.
|
|
121
|
+
// The frames that show what a scene really holds are the last one at full strength and the first one at full strength: those are the two
|
|
122
|
+
// either side of every change. The files are named by the change, b01-end-<scene> then b01-start-<scene>, so that they sort in order.
|
|
123
|
+
export function boundaryPoints(scenes: { id: string; startFrame: number; durationFrames: number }[]): BoundaryPoint[] {
|
|
124
|
+
const edge = (d: number) => Math.max(1, Math.min(8, Math.floor(d / 2)));
|
|
125
|
+
return scenes.slice(0, -1).map((a, i) => {
|
|
126
|
+
const b = scenes[i + 1]!;
|
|
127
|
+
const n = `b${String(i + 1).padStart(2, "0")}`;
|
|
128
|
+
return {
|
|
129
|
+
from: a.id, to: b.id,
|
|
130
|
+
end: { sceneId: a.id, point: "end", frame: a.startFrame + Math.max(0, a.durationFrames - edge(a.durationFrames)), name: `${n}-end-${a.id}` },
|
|
131
|
+
start: { sceneId: b.id, point: "start", frame: b.startFrame + Math.min(b.durationFrames - 1, edge(b.durationFrames)), name: `${n}-start-${b.id}` },
|
|
132
|
+
};
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// A small grey copy of a frame as plain pixels, for the continuity measurement.
|
|
137
|
+
async function decodeGray(png: string, width: number, height: number): Promise<Uint8Array> {
|
|
138
|
+
const { stdout } = await exec("ffmpeg", ["-v", "error", "-i", png, "-vf", `scale=${width}:${height}:flags=area,format=gray`, "-f", "rawvideo", "-pix_fmt", "gray", "-"], { encoding: "buffer", maxBuffer: 16 * 1024 * 1024 });
|
|
139
|
+
return new Uint8Array(stdout);
|
|
140
|
+
}
|
|
141
|
+
|
|
89
142
|
export async function preview(ctx: Ctx, deps: { toJpeg?: (png: string, jpg: string) => Promise<void>; ensureBrowser?: EnsureBrowser } = {}): Promise<Result> {
|
|
90
143
|
const toJpeg = deps.toJpeg ?? ffmpegToJpeg;
|
|
91
144
|
const project = openProject(ctx.cwd);
|
|
@@ -94,11 +147,13 @@ export async function preview(ctx: Ctx, deps: { toJpeg?: (png: string, jpg: stri
|
|
|
94
147
|
const dir = project.path("out/preview");
|
|
95
148
|
rmSync(dir, { recursive: true, force: true });
|
|
96
149
|
mkdirSync(dir, { recursive: true });
|
|
97
|
-
const
|
|
150
|
+
const sceneFrames = previewPoints(manifest.scenes);
|
|
151
|
+
const boundaries = boundaryPoints(manifest.scenes);
|
|
152
|
+
const points = [...sceneFrames, ...boundaries.flatMap((b) => [b.end, b.start])];
|
|
98
153
|
try {
|
|
99
154
|
try {
|
|
100
155
|
await ensureBrowserWithNotice(ctx, deps.ensureBrowser);
|
|
101
|
-
await withBundle(project, (serveUrl, urls) => renderStills(serveUrl, { manifest, urls }, points.map((p) => p.frame), dir));
|
|
156
|
+
await withBundle(project, (serveUrl, urls) => renderStills(serveUrl, { manifest, urls }, [...new Set(points.map((p) => p.frame))], dir));
|
|
102
157
|
} catch (e) {
|
|
103
158
|
return failed([`Render failed: ${e instanceof Error ? e.message : String(e)}`]);
|
|
104
159
|
}
|
|
@@ -113,14 +168,34 @@ export async function preview(ctx: Ctx, deps: { toJpeg?: (png: string, jpg: stri
|
|
|
113
168
|
} catch {
|
|
114
169
|
return failed(["Could not write the preview frames: ffmpeg failed. Check that ffmpeg is installed (macOS: `brew install ffmpeg`) and run `reelkit preview` again."]);
|
|
115
170
|
}
|
|
116
|
-
|
|
171
|
+
// Whether anything survives each scene change is advice. If it cannot be measured, the preview is still the preview.
|
|
172
|
+
let continuity: { score: number | null; boundaries: Boundary[] } | undefined;
|
|
173
|
+
let continuityLines: string[] = [];
|
|
174
|
+
try {
|
|
175
|
+
const h = Math.round((SAMPLE_WIDTH * manifest.height) / manifest.width);
|
|
176
|
+
const measured: Boundary[] = [];
|
|
177
|
+
for (const b of boundaries) {
|
|
178
|
+
const [a, c] = await Promise.all([decodeGray(join(dir, `still-${b.end.frame}.png`), SAMPLE_WIDTH, h), decodeGray(join(dir, `still-${b.start.frame}.png`), SAMPLE_WIDTH, h)]);
|
|
179
|
+
const r = compareFrames(a, c, SAMPLE_WIDTH, h);
|
|
180
|
+
measured.push({ from: b.from, to: b.to, survived: Math.round(r.survived * 100) / 100, kind: r.kind });
|
|
181
|
+
}
|
|
182
|
+
if (measured.length) {
|
|
183
|
+
const summary = summariseContinuity(measured);
|
|
184
|
+
continuity = { score: summary.score === null ? null : Math.round(summary.score * 100) / 100, boundaries: measured };
|
|
185
|
+
continuityLines = summary.lines;
|
|
186
|
+
}
|
|
187
|
+
} catch { /* no continuity report */ }
|
|
188
|
+
const sceneLines = `Two frames per scene, at 30% and at 90% of its length (a scene under 4 frames gets its middle one). Look at both frames of every scene:\n${frames.filter((f) => f.point !== "end" && f.point !== "start").map((f) => `${f.sceneId} (${f.point}): ${f.path}`).join("\n")}`;
|
|
189
|
+
const changeLines = boundaries.length ? `\nThe last and first frames either side of each scene change are saved too (b01-end-<scene>, b01-start-<scene>, ...), so that you can see what carries across:\n${frames.filter((f) => f.point === "end" || f.point === "start").map((f) => `${f.sceneId} (${f.point}): ${f.path}`).join("\n")}` : "";
|
|
190
|
+
const beat = beatReport(manifest);
|
|
191
|
+
return { ok: true, data: { frames, ...(continuity ? { continuity } : {}), ...(beat ? { beat: { bpm: beat.bpm, boundariesOnBeat: beat.boundariesOnBeat, boundaries: beat.boundaries } } : {}) }, summary: `${sceneLines}${changeLines}${continuityLines.length ? `\nContinuity: ${continuityLines.join("\n")}` : ""}${beat ? `\nBeat: ${beat.line}.` : ""}` };
|
|
117
192
|
} finally {
|
|
118
193
|
// The full-size stills are only inputs; none stays behind however this ends.
|
|
119
194
|
for (const f of readdirSync(dir)) if (/^still-\d+\.png$/.test(f)) rmSync(join(dir, f), { force: true });
|
|
120
195
|
}
|
|
121
196
|
}
|
|
122
197
|
|
|
123
|
-
export async function render(ctx: Ctx, deps: { ensureBrowser?: EnsureBrowser } = {}): Promise<Result> {
|
|
198
|
+
export async function render(ctx: Ctx, deps: { ensureBrowser?: EnsureBrowser; master?: Master; noShare?: boolean } = {}): Promise<Result> {
|
|
124
199
|
const project = openProject(ctx.cwd);
|
|
125
200
|
const { errors, manifest } = inspect(project);
|
|
126
201
|
if (!manifest) return failed(errors);
|
|
@@ -132,5 +207,18 @@ export async function render(ctx: Ctx, deps: { ensureBrowser?: EnsureBrowser } =
|
|
|
132
207
|
return failed([`Render failed: ${e instanceof Error ? e.message : String(e)}`]);
|
|
133
208
|
}
|
|
134
209
|
const seconds = manifest.totalFrames / manifest.fps;
|
|
135
|
-
|
|
210
|
+
// One last pass brings the whole programme to the loudness platforms play at. If it fails the render is still the render.
|
|
211
|
+
let loudness: Mastered | undefined;
|
|
212
|
+
let masterNote = "";
|
|
213
|
+
try {
|
|
214
|
+
loudness = await (deps.master ?? masterLoudness)(project.path(path));
|
|
215
|
+
masterNote = ` Mastered to ${loudness.outputLufs} LUFS (it was ${loudness.inputLufs}), peaks under ${MASTER_TRUE_PEAK_DB} dBTP.`;
|
|
216
|
+
} catch (e) {
|
|
217
|
+
masterNote = ` It was not mastered to ${MASTER_LUFS} LUFS (${e instanceof Error ? e.message : String(e)}); the video is kept as rendered.`;
|
|
218
|
+
}
|
|
219
|
+
const share = await shareAfterRender(ctx, project, { noShare: deps.noShare });
|
|
220
|
+
return {
|
|
221
|
+
ok: true, data: { path, seconds, ...(loudness ? { loudness } : {}), ...(share.data ? { shared: share.data.shared.map((s) => s.name) } : {}) },
|
|
222
|
+
summary: [`Video: ${project.path(path)} (${seconds.toFixed(1)}s, ${manifest.width}x${manifest.height}).${masterNote}`, ...share.lines].join("\n"),
|
|
223
|
+
};
|
|
136
224
|
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import ts from "typescript";
|
|
5
|
+
import { uploadTo } from "../api/client";
|
|
6
|
+
import { client, type Ctx, type Result } from "../context";
|
|
7
|
+
import { MAX_COMPONENT_BYTES, MAX_COMPONENT_EXAMPLE, MIN_COMPONENT_DESCRIPTION } from "../contract";
|
|
8
|
+
import { configDir, loadCredentials } from "../credentials";
|
|
9
|
+
import { FILES, openProject, type Project } from "../project/project";
|
|
10
|
+
import { componentName, FILE_NAME, jsxUses, MAIN_FILE, staticCheck } from "../render/validate";
|
|
11
|
+
|
|
12
|
+
export type SharedRecord = { name: string; libraryId: string; hash: string };
|
|
13
|
+
type Prepared = { name: string; file: string; source: string; hash: string; description: string; example: string; tags: string[] };
|
|
14
|
+
export type ShareOutcome = { shared: { name: string; libraryId: string; file: string; description: string; example: string }[]; skipped: { name: string; reason: string }[]; failed?: string };
|
|
15
|
+
|
|
16
|
+
const STOP_WORDS = new Set(["that", "this", "with", "from", "when", "have", "your", "into", "than", "then", "each", "show", "shows", "used", "uses", "use", "very", "which", "there", "their", "about", "over", "under", "while", "where", "also", "onto"]);
|
|
17
|
+
|
|
18
|
+
// The components in src/ that came from the library, and the ones written in this project.
|
|
19
|
+
export function libraryUse(project: Project): { pulled: string[]; written: string[] } {
|
|
20
|
+
const entries = Object.values(project.readJsonOr<Record<string, { path: string; kind: string }>>(FILES.library, {}));
|
|
21
|
+
const names = readdirSync(project.path("src")).filter((f) => FILE_NAME.test(f) && f !== MAIN_FILE);
|
|
22
|
+
const pulled = names.filter((n) => entries.some((e) => e.kind === "component" && e.path === `src/${n}`));
|
|
23
|
+
return { pulled: pulled.map(componentName), written: names.filter((n) => !pulled.includes(n)).map(componentName) };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const sf = (source: string, file: string) => ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
27
|
+
|
|
28
|
+
// The comment block directly above `export const <name>`, as plain words.
|
|
29
|
+
export function leadingComment(source: string, name: string): string | undefined {
|
|
30
|
+
const file = sf(source, `${name}.tsx`);
|
|
31
|
+
const stmt = file.statements.find((s) => ts.isVariableStatement(s) && s.declarationList.declarations.some((d) => ts.isIdentifier(d.name) && d.name.text === name));
|
|
32
|
+
if (!stmt) return undefined;
|
|
33
|
+
const ranges = ts.getLeadingCommentRanges(source, stmt.getFullStart()) ?? [];
|
|
34
|
+
const block: ts.CommentRange[] = [];
|
|
35
|
+
for (let i = ranges.length - 1; i >= 0; i--) {
|
|
36
|
+
const r = ranges[i]!;
|
|
37
|
+
// Only a comment with no blank line between it and what follows belongs to the declaration.
|
|
38
|
+
const gap = source.slice(r.end, i === ranges.length - 1 ? stmt.getStart() : ranges[i + 1]!.pos);
|
|
39
|
+
if ((gap.match(/\n/g) ?? []).length > 1) break;
|
|
40
|
+
block.unshift(r);
|
|
41
|
+
}
|
|
42
|
+
const text = block.map((r) => source.slice(r.pos, r.end).replace(/^\/\*+|\*+\/$/g, "").split("\n").map((l) => l.replace(/^\s*(\/\/+|\*+)\s?/, "").trim()).join(" ")).join(" ").replace(/\s+/g, " ").trim();
|
|
43
|
+
return text || undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Every piece of text a file writes out: strings, template text and JSX text. Names and comments are not text.
|
|
47
|
+
export function literalTexts(source: string, file: string): string[] {
|
|
48
|
+
const out: string[] = [];
|
|
49
|
+
const visit = (n: ts.Node) => {
|
|
50
|
+
if (ts.isStringLiteralLike(n)) out.push(n.text);
|
|
51
|
+
else if (ts.isTemplateHead(n) || ts.isTemplateMiddle(n) || ts.isTemplateTail(n)) out.push(n.text);
|
|
52
|
+
else if (ts.isJsxText(n) && n.text.trim()) out.push(n.text.trim());
|
|
53
|
+
ts.forEachChild(n, visit);
|
|
54
|
+
};
|
|
55
|
+
visit(sf(source, file));
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const cutShort = (t: string) => (t.length > 40 ? `${t.slice(0, 40)}…` : t);
|
|
60
|
+
|
|
61
|
+
const sharesRun = (a: string, b: string, n: number): boolean => {
|
|
62
|
+
for (let i = 0; i + n <= b.length; i++) if (a.includes(b.slice(i, i + n))) return true;
|
|
63
|
+
return false;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
// A cheap guard against sharing something that belongs to this user: their own files, their video's title, their words.
|
|
67
|
+
export function privateReason(source: string, file: string, plan: { title?: string; scenes?: { narration?: string; onScreenText?: string[] }[] }): string | undefined {
|
|
68
|
+
const texts = literalTexts(source, file);
|
|
69
|
+
if (texts.some((t) => t.startsWith("assets/user/"))) return "it uses one of your own files (assets/user/...). A shared component takes its pictures and text from props.";
|
|
70
|
+
const title = plan.title?.trim().toLowerCase();
|
|
71
|
+
if (title && title.length >= 4 && texts.some((t) => t.toLowerCase().includes(title))) return "it contains this video's title. A shared component takes its text from props.";
|
|
72
|
+
const norm = (s: string) => s.replace(/\s+/g, " ").trim();
|
|
73
|
+
for (const t of texts.map(norm).filter((t) => t.length > 60)) {
|
|
74
|
+
if ((plan.scenes ?? []).some((s) => sharesRun(norm(s.narration ?? ""), t, 61) || sharesRun(t, norm(s.narration ?? ""), 61))) return "it contains a long piece of this video's narration. A shared component takes its text from props.";
|
|
75
|
+
}
|
|
76
|
+
// Anything written into the component leaves the machine with it, so a component that carries this video's own words is not
|
|
77
|
+
// shared at all, however short they are: a customer's name or a headline is eight characters as easily as eighty. Two rules, both
|
|
78
|
+
// erring toward not sharing: text that also appears in the plan, and any whole sentence written into the code.
|
|
79
|
+
const low = (v: string) => norm(v).toLowerCase();
|
|
80
|
+
const planTexts = (plan.scenes ?? []).flatMap((sc) => [sc.narration ?? "", ...(sc.onScreenText ?? [])]).map(low).filter(Boolean);
|
|
81
|
+
for (const t of texts.map(low)) {
|
|
82
|
+
if (t.length >= 8 && planTexts.some((p) => p.includes(t))) return `it contains words from this video ("${cutShort(t)}"). A shared component takes its text from props.`;
|
|
83
|
+
// A font stack, a transform or a gradient is several words too: text with code punctuation, or a comma list of short items, is not a sentence.
|
|
84
|
+
const codeLike = /[():;%#{}=<>]/.test(t) || (t.split(",").length >= 3 && t.split(",").every((part) => part.trim().split(" ").length <= 3));
|
|
85
|
+
if (!codeLike && t.split(" ").filter((w) => /\p{L}{2,}/u.test(w)).length >= 5) return `it has a sentence written into it ("${cutShort(t)}"). A shared component takes its text from props.`;
|
|
86
|
+
}
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Lowercase words, 3 to 6: the words of the name first, then the longer words of the description.
|
|
91
|
+
export function deriveTags(name: string, description: string): string[] {
|
|
92
|
+
const fromName = name.replace(/([a-z0-9])([A-Z])/g, "$1 $2").toLowerCase().split(/\s+/);
|
|
93
|
+
const fromText = description.toLowerCase().match(/[a-z]{4,}/g) ?? [];
|
|
94
|
+
const tags = [...new Set([...fromName, ...fromText.filter((w) => !STOP_WORDS.has(w))])].filter((t) => t.length <= 40);
|
|
95
|
+
for (const filler of ["component", "motion", "video"]) if (tags.length < 3 && !tags.includes(filler)) tags.push(filler);
|
|
96
|
+
return tags.slice(0, 6);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function prepare(project: Project, name: string, opts: { describe?: string; example?: string; tags?: string }, plan: Parameters<typeof privateReason>[2], siblings: string[]): Prepared | { name: string; reason: string } {
|
|
100
|
+
const file = `${name}.tsx`;
|
|
101
|
+
const skip = (reason: string) => ({ name, reason });
|
|
102
|
+
if (!FILE_NAME.test(file) || file === MAIN_FILE || !project.exists(`src/${file}`)) return skip(`there is no component file src/${file}.`);
|
|
103
|
+
const source = readFileSync(project.path(`src/${file}`), "utf8");
|
|
104
|
+
if (Buffer.byteLength(source) > MAX_COMPONENT_BYTES) return skip(`it is larger than ${MAX_COMPONENT_BYTES / 1024} KB.`);
|
|
105
|
+
const problems = staticCheck(source, file, siblings);
|
|
106
|
+
if (problems.length) return skip(`it does not pass the check (${problems[0]}). Fix it with \`reelkit check\` first.`);
|
|
107
|
+
const reason = privateReason(source, file, plan);
|
|
108
|
+
if (reason) return skip(reason);
|
|
109
|
+
const description = (opts.describe ?? leadingComment(source, name) ?? "").trim();
|
|
110
|
+
if (description.length < MIN_COMPONENT_DESCRIPTION) return skip(`it has no description. Put a sentence or two above \`export const ${name}\` saying what it shows and when to use it${opts.describe === undefined ? ", or pass --describe" : ""}.`);
|
|
111
|
+
let example = opts.example?.trim();
|
|
112
|
+
if (opts.example !== undefined && !example?.startsWith(`<${name}`)) return skip(`--example must start with <${name}.`);
|
|
113
|
+
if (!example) {
|
|
114
|
+
const main = project.exists(`src/${MAIN_FILE}`) ? readFileSync(project.path(`src/${MAIN_FILE}`), "utf8") : "";
|
|
115
|
+
const use = jsxUses(main, MAIN_FILE, name)[0];
|
|
116
|
+
if (!use) return skip(`<${name}> is not used in src/${MAIN_FILE}, so there is no example to take. Pass --example '<${name} ... />'.`);
|
|
117
|
+
if (!use.literal) return skip(`its use in src/${MAIN_FILE} has props that are not plain values (variables or data from the video). Pass --example '<${name} ... />' with plain values.`);
|
|
118
|
+
example = use.example;
|
|
119
|
+
}
|
|
120
|
+
if (example.length > MAX_COMPONENT_EXAMPLE) return skip(`its example is longer than ${MAX_COMPONENT_EXAMPLE} characters. Pass a shorter --example.`);
|
|
121
|
+
const tags = opts.tags ? opts.tags.split(",").map((t) => t.trim().toLowerCase()).filter(Boolean) : deriveTags(name, description);
|
|
122
|
+
return { name, file, source, hash: createHash("sha256").update(source).digest("hex"), description, example, tags };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Which components a share would send, and why any other is left out. Nothing is sent here.
|
|
126
|
+
export function prepareShare(project: Project, names: string[] | undefined, opts: { describe?: string; example?: string; tags?: string } = {}): { ready: Prepared[]; skipped: { name: string; reason: string }[] } {
|
|
127
|
+
const plan = project.readJsonOr<Parameters<typeof privateReason>[2]>(FILES.plan, {});
|
|
128
|
+
const siblings = readdirSync(project.path("src")).filter((f) => f.endsWith(".tsx"));
|
|
129
|
+
const { pulled, written } = libraryUse(project);
|
|
130
|
+
const done = project.readJsonOr<SharedRecord[]>(FILES.shared, []);
|
|
131
|
+
const ready: Prepared[] = [], skipped: { name: string; reason: string }[] = [];
|
|
132
|
+
for (const name of names?.length ? names : written) {
|
|
133
|
+
if (pulled.includes(name)) { skipped.push({ name, reason: "it was pulled from the library, so it is already there." }); continue; }
|
|
134
|
+
const p = prepare(project, name, names?.length === 1 ? opts : {}, plan, siblings);
|
|
135
|
+
if ("reason" in p) { skipped.push(p); continue; }
|
|
136
|
+
// The same source is not sent twice.
|
|
137
|
+
if (done.some((d) => d.name === name && d.hash === p.hash)) continue;
|
|
138
|
+
ready.push(p);
|
|
139
|
+
}
|
|
140
|
+
return { ready, skipped };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function send(ctx: Ctx, project: Project, ready: Prepared[]): Promise<ShareOutcome["shared"]> {
|
|
144
|
+
const api = client(ctx);
|
|
145
|
+
const shared: ShareOutcome["shared"] = [];
|
|
146
|
+
for (const c of ready) {
|
|
147
|
+
// Only the component's own name, source, description, example and tags leave this machine: no project name, path or plan text.
|
|
148
|
+
const bytes = new TextEncoder().encode(c.source);
|
|
149
|
+
const up = await api("libraryUpload", { kind: "component", title: c.name, description: c.description, tags: c.tags, meta: { example: c.example }, filename: c.file, contentType: "text/plain", bytes: bytes.length, shareable: true });
|
|
150
|
+
await uploadTo(up.uploadUrl, new Uint8Array(bytes), "text/plain");
|
|
151
|
+
const libraryId = (await api("libraryCommit", { id: up.id })).item.id;
|
|
152
|
+
project.writeJson(FILES.shared, [...project.readJsonOr<SharedRecord[]>(FILES.shared, []).filter((d) => d.name !== c.name), { name: c.name, libraryId, hash: c.hash }]);
|
|
153
|
+
shared.push({ name: c.name, libraryId, file: c.file, description: c.description, example: c.example });
|
|
154
|
+
}
|
|
155
|
+
return shared;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export const STOP_SHARING = "To stop this, run `reelkit render --no-share`, set REELKIT_NO_SHARE=1, or start new projects with `reelkit init <name> --private`.";
|
|
159
|
+
|
|
160
|
+
const sentLines = (shared: ShareOutcome["shared"]) => shared.map((s) => ` ${s.file}: ${s.description} Example: ${s.example}`);
|
|
161
|
+
const skippedLines = (skipped: ShareOutcome["skipped"]) => skipped.map((s) => ` Not shared: ${s.name}: ${s.reason}`);
|
|
162
|
+
|
|
163
|
+
// `reelkit components share [name...]`: an explicit request, so it shares at once.
|
|
164
|
+
export async function componentsShare(ctx: Ctx, names: string[], opts: { describe?: string; example?: string; tags?: string }): Promise<Result> {
|
|
165
|
+
const project = openProject(ctx.cwd);
|
|
166
|
+
if (names.length !== 1 && (opts.describe !== undefined || opts.example !== undefined || opts.tags !== undefined)) return { ok: false, summary: "--describe, --example and --tags go with exactly one component name: reelkit components share StatRing --describe \"...\"." };
|
|
167
|
+
if (!loadCredentials(ctx.env)) return { ok: false, summary: "You are not logged in. Run `reelkit auth login`, then share again." };
|
|
168
|
+
const { ready, skipped } = prepareShare(project, names, opts);
|
|
169
|
+
const shared = await send(ctx, project, ready);
|
|
170
|
+
const data: ShareOutcome = { shared, skipped };
|
|
171
|
+
if (!shared.length && !skipped.length) return { ok: true, data, summary: "Nothing to share: every component written in this project has already been shared, or there are none." };
|
|
172
|
+
return {
|
|
173
|
+
ok: shared.length > 0 || skipped.length === 0, data,
|
|
174
|
+
summary: [
|
|
175
|
+
shared.length ? `Shared ${shared.length} component${shared.length === 1 ? "" : "s"} with the Reelkit library for review: ${shared.map((s) => s.name).join(", ")}. Nothing else from this project was sent.` : "No component was shared.",
|
|
176
|
+
...sentLines(shared), ...skippedLines(skipped),
|
|
177
|
+
].join("\n"),
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// The notice a person sees once per machine before components start to be shared by a render.
|
|
182
|
+
export const SHARING_NOTICE = `New components you write are shared with the Reelkit library for review after a render, starting with the next one: each component's source, a description and an example, and nothing else (no project name, file paths or script). ${STOP_SHARING}`;
|
|
183
|
+
|
|
184
|
+
const noticesPath = (env: Ctx["env"]) => join(configDir(env), "notices.json");
|
|
185
|
+
function sharingNoticeShown(env: Ctx["env"]): boolean {
|
|
186
|
+
try { return typeof (JSON.parse(readFileSync(noticesPath(env), "utf8")) as { componentSharing?: unknown }).componentSharing === "string"; } catch { return false; }
|
|
187
|
+
}
|
|
188
|
+
function recordSharingNotice(env: Ctx["env"]): boolean {
|
|
189
|
+
try {
|
|
190
|
+
let all: Record<string, unknown> = {};
|
|
191
|
+
try { all = JSON.parse(readFileSync(noticesPath(env), "utf8")) as Record<string, unknown>; } catch { /* a new file */ }
|
|
192
|
+
mkdirSync(configDir(env), { recursive: true, mode: 0o700 });
|
|
193
|
+
writeFileSync(noticesPath(env), JSON.stringify({ ...all, componentSharing: new Date().toISOString() }, null, 2));
|
|
194
|
+
return true;
|
|
195
|
+
} catch { return false; }
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// After a render: share the new components unless the person has opted out. It never fails the render; it returns the lines to add to its summary.
|
|
199
|
+
export async function shareAfterRender(ctx: Ctx, project: Project, opts: { noShare?: boolean }): Promise<{ lines: string[]; data?: ShareOutcome }> {
|
|
200
|
+
const off = ctx.env.REELKIT_NO_SHARE;
|
|
201
|
+
if (opts.noShare || (off && off !== "0" && off.toLowerCase() !== "false") || project.config().shareComponents === false) return { lines: ["No components were shared with the Reelkit library (sharing is turned off)."] };
|
|
202
|
+
if (!existsSync(project.path("src"))) return { lines: [] };
|
|
203
|
+
try {
|
|
204
|
+
const { ready, skipped } = prepareShare(project, undefined);
|
|
205
|
+
const notShared = skippedLines(skipped);
|
|
206
|
+
if (!ready.length) return { lines: [...notShared, ...(notShared.length ? [] : ["No new components to share with the Reelkit library."])] };
|
|
207
|
+
if (!loadCredentials(ctx.env)) return { lines: ["Components were not shared with the Reelkit library: you are not logged in.", ...notShared] };
|
|
208
|
+
if (!sharingNoticeShown(ctx.env)) {
|
|
209
|
+
// The first time on this machine: say what will happen, and share from the next render on.
|
|
210
|
+
return { lines: recordSharingNotice(ctx.env) ? [SHARING_NOTICE, ...notShared] : [`Components were not shared: ${"the notice could not be saved in " + configDir(ctx.env)}.`] };
|
|
211
|
+
}
|
|
212
|
+
const shared = await send(ctx, project, ready);
|
|
213
|
+
return {
|
|
214
|
+
data: { shared, skipped },
|
|
215
|
+
lines: [`Shared ${shared.length} new component${shared.length === 1 ? "" : "s"} with the Reelkit library for review: ${shared.map((s) => s.name).join(", ")}. Nothing else from this project was sent. ${STOP_SHARING}`, ...sentLines(shared), ...notShared],
|
|
216
|
+
};
|
|
217
|
+
} catch (e) {
|
|
218
|
+
return { lines: [`Components were not shared with the Reelkit library (${e instanceof Error ? e.message : String(e)}). The video is not affected.`] };
|
|
219
|
+
}
|
|
220
|
+
}
|
package/src/commands/init.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { FILES, Project } from "../project/project";
|
|
|
10
10
|
const exec = promisify(execFile);
|
|
11
11
|
const DIRS = ["assets", "refs", "src", "out"];
|
|
12
12
|
|
|
13
|
-
export async function init(ctx: Ctx, name: string | undefined, opts: { aspect?: string }): Promise<Result> {
|
|
13
|
+
export async function init(ctx: Ctx, name: string | undefined, opts: { aspect?: string; private?: boolean }): Promise<Result> {
|
|
14
14
|
const slug = name === undefined ? undefined : name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
15
15
|
if (slug === "") return { ok: false, summary: "Give the project a name with letters or digits, for example: reelkit init launch-video" };
|
|
16
16
|
const aspect = AspectSchema.safeParse(opts.aspect ?? "9:16");
|
|
@@ -22,11 +22,17 @@ export async function init(ctx: Ctx, name: string | undefined, opts: { aspect?:
|
|
|
22
22
|
if (slug && existsSync(dir) && !statSync(dir).isDirectory()) return { ok: false, summary: `A file named ${slug} is in the way. Choose another project name or remove it.` };
|
|
23
23
|
const project = new Project(dir);
|
|
24
24
|
for (const d of DIRS) mkdirSync(project.path(d), { recursive: true });
|
|
25
|
-
if (!project.exists(FILES.config)) project.writeJson(FILES.config, { aspect: aspect.data, ...(name !== undefined && { name }) });
|
|
25
|
+
if (!project.exists(FILES.config)) project.writeJson(FILES.config, { aspect: aspect.data, ...(name !== undefined && { name }), ...(opts.private && { shareComponents: false }) });
|
|
26
|
+
else if (opts.private) project.writeJson(FILES.config, { ...project.config(), shareComponents: false });
|
|
26
27
|
if (!existsSync(project.path(".gitignore"))) writeFileSync(project.path(".gitignore"), ".reelkit\nout\nrefs\n");
|
|
27
28
|
ensureSkill(ctx);
|
|
28
29
|
return {
|
|
29
30
|
ok: true, data: { dir, config: project.config() },
|
|
30
|
-
summary:
|
|
31
|
+
summary: [
|
|
32
|
+
`Project ready in ${dir} (${project.config().aspect}).${slug ? ` Next: cd ${slug}` : ""}`,
|
|
33
|
+
project.config().shareComponents === false
|
|
34
|
+
? "Nothing from this project is shared automatically."
|
|
35
|
+
: "After a render, new components written in this project are sent to the Reelkit library for review (their source, a description and an example; nothing else). To turn this off: `reelkit init <name> --private` for a new project, `reelkit render --no-share`, or REELKIT_NO_SHARE=1.",
|
|
36
|
+
].join("\n"),
|
|
31
37
|
};
|
|
32
38
|
}
|
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
|
|
|
@@ -58,11 +58,13 @@ export async function planCheck(ctx: Ctx): Promise<Result> {
|
|
|
58
58
|
const shouldImprove = mustFix.length ? [] : [...reviewPlan(parsed.data, { footage }), ...ref.improve];
|
|
59
59
|
const length = estimateLength(parsed.data);
|
|
60
60
|
const estimatedSeconds = Math.round(length.seconds);
|
|
61
|
+
const perScene = sceneSeconds(parsed.data).map((s) => ({ id: s.id, seconds: Math.round(s.seconds * 10) / 10 }));
|
|
62
|
+
const variation = Math.round(lengthVariation(sceneSeconds(parsed.data).map((s) => s.seconds)) * 100) / 100;
|
|
61
63
|
const lines = [
|
|
62
64
|
mustFix.length ? `Fix these:\n- ${mustFix.join("\n- ")}` : "The plan is valid.",
|
|
63
65
|
`Estimated length: about ${estimatedSeconds}s (${length.words} words).`,
|
|
64
66
|
...(shouldImprove.length ? [`Worth improving:\n- ${shouldImprove.join("\n- ")}`] : []),
|
|
65
67
|
...(voiceIds ? [] : ["The voice was not checked because you are not logged in. Run `reelkit auth login`."]),
|
|
66
68
|
];
|
|
67
|
-
return { ok: mustFix.length === 0, data: { mustFix, shouldImprove, estimatedSeconds, words: length.words }, summary: lines.join("\n") };
|
|
69
|
+
return { ok: mustFix.length === 0, data: { mustFix, shouldImprove, estimatedSeconds, words: length.words, sceneSeconds: perScene, lengthVariation: variation }, summary: lines.join("\n") };
|
|
68
70
|
}
|
package/src/commands/ref.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { loadCredentials } from "../credentials";
|
|
|
10
10
|
import { analyzeBeats, cutsOnBeat } from "../project/beats";
|
|
11
11
|
import { openProject, type Project } from "../project/project";
|
|
12
12
|
import {
|
|
13
|
-
audioDuration, decodeMono, detectCuts, extractFrame, extractMp3, loudnessLufs, nearestAspect, paletteOfFrames, paletteOfVideo, probeVideo, REF_ASPECTS, toMp4,
|
|
13
|
+
audioDuration, coefficientOfVariation, decodeMono, detectCuts, extractFrame, extractMp3, loudnessLufs, nearestAspect, paletteOfFrames, paletteOfVideo, probeVideo, REF_ASPECTS, stillnessOfVideo, toMp4,
|
|
14
14
|
} from "../project/refmeasure";
|
|
15
15
|
|
|
16
16
|
// A reference is for learning how a video is built, so ten minutes is more than it needs and keeps every step quick.
|
|
@@ -29,7 +29,11 @@ export type Breakdown = {
|
|
|
29
29
|
scenes: { index: number; startSec: number; endSec: number; durationSec: number; frames: string[]; palette: string[] }[];
|
|
30
30
|
// True when the video has more cuts than the 40 scenes listed: the last scene then holds everything after the 39th cut.
|
|
31
31
|
scenesCapped?: boolean;
|
|
32
|
-
pacing: { cuts: number; averageShotSec: number; shortestShotSec: number; longestShotSec: number; cutsPerSecond: number; firstCutSec?: number; cutsOnBeat?: number
|
|
32
|
+
pacing: { cuts: number; averageShotSec: number; shortestShotSec: number; longestShotSec: number; cutsPerSecond: number; firstCutSec?: number; cutsOnBeat?: number;
|
|
33
|
+
// How uneven the shot lengths are (standard deviation over the mean); left out with fewer than 3 shots. High means a quarter-second hit beside a long hold.
|
|
34
|
+
shotLengthVariation?: number };
|
|
35
|
+
// The share of the video's time in which the picture barely changes from frame to frame, and the longest such stretch. Missing from breakdowns saved by older versions.
|
|
36
|
+
stillness?: { share: number; longestSec: number };
|
|
33
37
|
palette: string[];
|
|
34
38
|
audio: { hasAudio: boolean; loudnessLufs?: number; tempoBpm?: number; beatConfidence: number };
|
|
35
39
|
beats?: number[];
|
|
@@ -245,7 +249,10 @@ async function measure(ctx: Ctx, project: Project, ref: RefRecord): Promise<Brea
|
|
|
245
249
|
const pacing: Breakdown["pacing"] = {
|
|
246
250
|
cuts: cuts.length, averageShotSec: round2(ref.durationSec / shots.length), shortestShotSec: round2(Math.min(...shots)), longestShotSec: round2(Math.max(...shots)),
|
|
247
251
|
cutsPerSecond: round2(cuts.length / ref.durationSec), ...(cuts.length ? { firstCutSec: round2(cuts[0]!) } : {}),
|
|
252
|
+
...(shots.length >= 3 ? { shotLengthVariation: round2(coefficientOfVariation(shots)) } : {}),
|
|
248
253
|
};
|
|
254
|
+
ctx.log("Measuring how much of the time the picture is still.");
|
|
255
|
+
const still = await stillnessOfVideo(video, ref.width, ref.height);
|
|
249
256
|
|
|
250
257
|
const audio: Breakdown["audio"] = { hasAudio: ref.hasAudio, beatConfidence: 0 };
|
|
251
258
|
let beats: number[] | undefined;
|
|
@@ -264,7 +271,7 @@ async function measure(ctx: Ctx, project: Project, ref: RefRecord): Promise<Brea
|
|
|
264
271
|
}
|
|
265
272
|
} catch { /* no tempo */ }
|
|
266
273
|
}
|
|
267
|
-
return { source: ref.source, durationSec: ref.durationSec, width: ref.width, height: ref.height, aspect: ref.aspect, fps: ref.fps, scenes, ...(cuts.length > MAX_SCENES - 1 ? { scenesCapped: true } : {}), pacing, palette, audio, ...(beats ? { beats } : {}) };
|
|
274
|
+
return { source: ref.source, durationSec: ref.durationSec, width: ref.width, height: ref.height, aspect: ref.aspect, fps: ref.fps, scenes, ...(cuts.length > MAX_SCENES - 1 ? { scenesCapped: true } : {}), pacing, stillness: still, palette, audio, ...(beats ? { beats } : {}) };
|
|
268
275
|
}
|
|
269
276
|
|
|
270
277
|
// A short text a person or an agent can read at once.
|
|
@@ -274,6 +281,7 @@ function digest(id: string, b: Breakdown, notes: string[]): string {
|
|
|
274
281
|
b.pacing.cuts
|
|
275
282
|
? `Shape: ${b.scenes.length}${b.scenesCapped ? "+" : ""} scenes, ${b.pacing.cuts} cuts; average shot ${fmt(b.pacing.averageShotSec)}s (shortest ${fmt(b.pacing.shortestShotSec)}s, longest ${fmt(b.pacing.longestShotSec)}s); first cut at ${fmt(b.pacing.firstCutSec!)}s.`
|
|
276
283
|
: "Shape: one continuous shot, no cuts found.",
|
|
284
|
+
...(b.stillness ? [`Still ${Math.round(b.stillness.share * 100)}% of the time; longest hold ${fmt(b.stillness.longestSec)} s.${b.pacing.shotLengthVariation !== undefined ? ` Shot lengths vary by ${b.pacing.shotLengthVariation} (the spread over the average).` : ""}`] : []),
|
|
277
285
|
`Palette: ${b.palette.join(", ")}.`,
|
|
278
286
|
!b.audio.hasAudio ? "Audio: none."
|
|
279
287
|
: `Audio:${b.audio.loudnessLufs !== undefined ? ` ${b.audio.loudnessLufs} LUFS;` : ""} ${b.audio.tempoBpm !== undefined ? `tempo about ${Math.round(b.audio.tempoBpm)} BPM (confidence ${b.audio.beatConfidence})` : "no clear beat"}${b.pacing.cutsOnBeat !== undefined ? `; ${Math.round(b.pacing.cutsOnBeat * b.pacing.cuts)} of ${b.pacing.cuts} cuts land on a beat` : ""}.`,
|