reelkit-cli 0.5.0 → 0.8.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 +7 -2
- package/skill/SKILL.md +22 -11
- package/skill/THIRD_PARTY.md +102 -0
- package/skill/commands/launch-film.md +7 -0
- package/skill/reference/asset-reuse.md +13 -2
- package/skill/reference/backgrounds.md +63 -0
- package/skill/reference/beat-sync.md +32 -17
- package/skill/reference/captions.md +11 -5
- package/skill/reference/component-authoring.md +12 -1
- package/skill/reference/continuity.md +21 -2
- package/skill/reference/kit.md +140 -11
- package/skill/reference/launch-film.md +190 -0
- package/skill/reference/remotion-composition.md +4 -3
- package/skill/reference/scene-treatments.md +20 -0
- package/skill/reference/scriptwriting.md +4 -1
- package/skill/reference/three-d.md +134 -0
- package/skill/reference/voice-sync.md +108 -0
- package/src/agents.ts +23 -12
- package/src/api/client.ts +4 -1
- package/src/cli.ts +25 -8
- package/src/commands/assets.ts +334 -36
- package/src/commands/build.ts +172 -36
- package/src/commands/components.ts +220 -0
- package/src/commands/init.ts +9 -3
- package/src/commands/install.ts +1 -1
- package/src/commands/plan.ts +8 -5
- package/src/commands/ref.ts +5 -2
- package/src/contract/index.ts +27 -5
- package/src/pipeline/beatsnap.ts +72 -0
- package/src/pipeline/review.ts +67 -7
- package/src/pipeline/schema.ts +51 -4
- package/src/pipeline/timing.ts +27 -1
- package/src/project/background.ts +33 -0
- package/src/project/layers.ts +60 -0
- package/src/project/loudness.ts +68 -0
- package/src/project/manifest.ts +68 -14
- package/src/project/music.ts +19 -5
- package/src/project/project.ts +7 -2
- package/src/project/refmeasure.ts +1 -1
- package/src/project/soundreport.ts +347 -0
- package/src/project/svgcheck.ts +21 -0
- package/src/remotion/kit/Assemble3D.tsx +92 -0
- package/src/remotion/kit/BrowserFrame.tsx +83 -0
- package/src/remotion/kit/Camera.tsx +6 -4
- package/src/remotion/kit/Captions.tsx +33 -17
- package/src/remotion/kit/Card3D.tsx +211 -0
- package/src/remotion/kit/ChapterFrame.tsx +68 -0
- package/src/remotion/kit/CounterRoll.tsx +75 -0
- package/src/remotion/kit/GlassPanel.tsx +43 -0
- package/src/remotion/kit/Grounds.tsx +177 -0
- package/src/remotion/kit/Headline.tsx +97 -0
- package/src/remotion/kit/Hero3D.tsx +197 -0
- package/src/remotion/kit/HudOverlay.tsx +52 -0
- package/src/remotion/kit/ImageLayers.tsx +48 -0
- package/src/remotion/kit/Music.tsx +4 -4
- package/src/remotion/kit/NamedCursor.tsx +54 -0
- package/src/remotion/kit/Orbit3D.tsx +49 -0
- package/src/remotion/kit/Particles3D.tsx +74 -0
- package/src/remotion/kit/Place.tsx +12 -0
- package/src/remotion/kit/PromptBox.tsx +84 -0
- package/src/remotion/kit/Scene3D.tsx +70 -0
- package/src/remotion/kit/SceneFrame.tsx +88 -11
- package/src/remotion/kit/Sfx.tsx +12 -6
- package/src/remotion/kit/SoundCues.tsx +22 -0
- package/src/remotion/kit/TerminalLog.tsx +98 -0
- package/src/remotion/kit/Text3D.tsx +78 -0
- package/src/remotion/kit/TextOnImage.tsx +41 -0
- package/src/remotion/kit/Warp3D.tsx +59 -0
- package/src/remotion/kit/bg-math.ts +179 -0
- package/src/remotion/kit/caption-groups.ts +7 -3
- package/src/remotion/kit/caption-style.ts +45 -0
- package/src/remotion/kit/docs.ts +133 -11
- package/src/remotion/kit/image-layers-math.ts +115 -0
- package/src/remotion/kit/index.ts +43 -1
- package/src/remotion/kit/inter-bold-typeface.ts +3 -0
- package/src/remotion/kit/media.ts +5 -3
- package/src/remotion/kit/motion-math.ts +36 -2
- package/src/remotion/kit/music-math.ts +27 -10
- package/src/remotion/kit/quiet-three.ts +11 -0
- package/src/remotion/kit/sample-text.ts +55 -0
- package/src/remotion/kit/scene3d-context.ts +5 -0
- package/src/remotion/kit/seeded.ts +13 -0
- package/src/remotion/kit/sound-cues.ts +89 -0
- package/src/remotion/kit/sound-kinds.ts +122 -0
- package/src/remotion/kit/theme.ts +2 -0
- package/src/remotion/kit/three-fx-math.ts +192 -0
- package/src/remotion/kit/three-math.ts +145 -0
- package/src/remotion/kit/transition-math.ts +116 -0
- package/src/remotion/kit/ui-math.ts +145 -0
- package/src/remotion/kit/ui-theme.ts +25 -0
- package/src/remotion/kit/word-anchor.ts +107 -0
- package/src/render/contact-sheet.ts +39 -0
- package/src/render/continuity.ts +14 -4
- package/src/render/deps.ts +15 -3
- package/src/render/master.ts +31 -0
- package/src/render/render.ts +15 -8
- package/src/render/sound-notes.ts +106 -0
- package/src/render/static-check.ts +156 -0
- package/src/render/validate.ts +3 -150
- package/src/render/word-check.ts +181 -0
- package/src/testing/conformance.ts +61 -1
- package/src/testing/fake-api.ts +11 -5
- package/src/testing/fixtures.ts +3 -0
package/src/commands/build.ts
CHANGED
|
@@ -1,17 +1,26 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
|
-
import { mkdirSync, readdirSync, readFileSync, rmSync } from "node:fs";
|
|
3
|
-
import { join } from "node:path";
|
|
2
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync } from "node:fs";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
4
|
import { promisify } from "node:util";
|
|
5
5
|
import type { Ctx, Result } from "../context";
|
|
6
|
-
import type
|
|
7
|
-
import {
|
|
6
|
+
import { isVoiceless, type AssetManifest, type ScenePlan } from "../pipeline/schema";
|
|
7
|
+
import { compareAcross, SAMPLE_WIDTH, summariseContinuity, type Boundary } from "../render/continuity";
|
|
8
8
|
import { buildManifest, missingAssets } from "../project/manifest";
|
|
9
|
+
import { readBackground } from "../project/background";
|
|
9
10
|
import { beatReport } from "../project/music";
|
|
10
|
-
import { openProject, type Project } from "../project/project";
|
|
11
|
+
import { FILES, openProject, type Project } from "../project/project";
|
|
12
|
+
import { structureNotes } from "../pipeline/review";
|
|
13
|
+
import type { SearchRecord } from "./assets";
|
|
14
|
+
import { libraryUse, shareAfterRender } from "./components";
|
|
11
15
|
import { mediaUrls, serveDir } from "../project/serve";
|
|
16
|
+
import { contactSheet } from "../render/contact-sheet";
|
|
12
17
|
import { bundleProject, disposeBundle, ensureRenderBrowser, renderStills, renderVideo, writeEntry, type EnsureBrowser } from "../render/render";
|
|
18
|
+
import { MASTER_LUFS, MASTER_TRUE_PEAK_DB, masterLoudness, type Master, type Mastered } from "../render/master";
|
|
19
|
+
import { detailLines, measureSound, soundAdvice, spokenWords, voiceAdvice, type SoundReport, type SpokenWord } from "../project/soundreport";
|
|
20
|
+
import { soundNotes } from "../render/sound-notes";
|
|
13
21
|
import { FILE_NAME, MAIN_FILE, jsxUses, staticCheck, typecheck } from "../render/validate";
|
|
14
22
|
import { loadPlan } from "./plan";
|
|
23
|
+
import { MAX_WORD_FRAMES, usesWordTiming, WORD_FRAME_AFTER, wordCalls, wordErrors, wordTargets } from "../render/word-check";
|
|
15
24
|
|
|
16
25
|
const exec = promisify(execFile);
|
|
17
26
|
const MEDIA_PATH = /["'`](assets\/[^"'`\s]+\.(?:mp3|wav|m4a|mp4|mov|webm|png|jpe?g|webp|svg|gif))["'`]/g;
|
|
@@ -31,6 +40,13 @@ function inspect(project: Project): { errors: string[]; manifest?: AssetManifest
|
|
|
31
40
|
if (!project.exists(m[1])) errors.push(`${name} uses "${m[1]}", which is not in this project. Get it with \`reelkit assets pull <id>\` first.`);
|
|
32
41
|
}
|
|
33
42
|
}
|
|
43
|
+
// A word given to wordFrame, onWord or onWordBeat that its scene never says would stop the render, so it is an error here.
|
|
44
|
+
if (!isVoiceless(plan)) {
|
|
45
|
+
const ids = plan.scenes.map((s) => s.id);
|
|
46
|
+
for (const name of names.filter((n) => FILE_NAME.test(n))) {
|
|
47
|
+
errors.push(...wordErrors(name, wordCalls(readFileSync(project.path(join("src", name)), "utf8"), ids, name), plan.scenes));
|
|
48
|
+
}
|
|
49
|
+
}
|
|
34
50
|
if (errors.length) return { errors };
|
|
35
51
|
const typeErrors = typecheck(names.map((n) => project.path(join("src", n))));
|
|
36
52
|
if (typeErrors.length) return { errors: typeErrors };
|
|
@@ -39,13 +55,44 @@ function inspect(project: Project): { errors: string[]; manifest?: AssetManifest
|
|
|
39
55
|
}
|
|
40
56
|
|
|
41
57
|
// Advice about a composition that passes: never an error.
|
|
42
|
-
function compositionNotes(project: Project, plan: ScenePlan): string[] {
|
|
58
|
+
function compositionNotes(project: Project, plan: ScenePlan, manifest?: AssetManifest): string[] {
|
|
43
59
|
const notes: string[] = [];
|
|
44
60
|
const names = readdirSync(project.path("src")).filter((f) => FILE_NAME.test(f));
|
|
45
61
|
const sources = names.map((n) => [n, readFileSync(project.path(join("src", n)), "utf8")] as const);
|
|
46
62
|
const rendersCaptions = sources.some(([n, src]) => jsxUses(src, n, "Captions").length > 0);
|
|
47
63
|
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
64
|
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.`);
|
|
65
|
+
if (!isVoiceless(plan) && !sources.some(([n, src]) => usesWordTiming(src, n))) {
|
|
66
|
+
notes.push("Nothing on screen is timed to a spoken word: the composition never uses wordFrame, onWord, onWordBeat or a scene's words (other than to draw the subtitles). Place what illustrates a word on that word, e.g. <Entrance delay={onWord(s, \"tasks\")}>; see reference/voice-sync.md.");
|
|
67
|
+
}
|
|
68
|
+
notes.push(...structureNotes(plan, { footage: project.footage() }));
|
|
69
|
+
if (isVoiceless(plan) && manifest) {
|
|
70
|
+
notes.push(...soundNotes(sources.map(([name, source]) => ({ name, source })), { seconds: manifest.totalFrames / manifest.fps, fps: manifest.fps, musicSet: manifest.music !== undefined }));
|
|
71
|
+
}
|
|
72
|
+
// A ground that was chosen and then never drawn is wasted: say so. A video ground with a low dim under type is hard to read.
|
|
73
|
+
const bg = readBackground(project);
|
|
74
|
+
const recorded = [...(bg.film ? [bg.film.key] : []), ...Object.values(bg.scenes).map((e) => e.key)];
|
|
75
|
+
const usesBackground = (src: string) => /\.background\b|bgPerScene/.test(src) || recorded.some((k) => src.includes(k));
|
|
76
|
+
if (recorded.length && !sources.some(([, src]) => usesBackground(src))) notes.push(`A background is recorded (${recorded[0]}) but the composition never uses it. Draw it with <BgVideo> or <BgImage> from manifest.background, or <BgSequence items={bgPerScene(...)}>; see reference/backgrounds.md.`);
|
|
77
|
+
if (plan.scenes.some((s) => s.onScreenText.length)) {
|
|
78
|
+
for (const [name, src] of sources) {
|
|
79
|
+
const lowDim = jsxUses(src, name, "BgVideo").some((u) => { const m = /\bdim=\{\s*(\d*\.?\d+)\s*\}/.exec(u.example); return m !== null && Number(m[1]) < 0.2; });
|
|
80
|
+
if (lowDim) { notes.push(`${name}: a BgVideo with a dim under 0.2 sits behind on-screen text, which will be hard to read. Raise dim to 0.3 or more, or blur the clip.`); break; }
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
// An image scene with words on it is built in layers. Decidable only when the composition names one of the two components, or names the scene's image (its key, or
|
|
84
|
+
// imageKey): a composition that never mentions the picture says nothing here.
|
|
85
|
+
const images = project.readJsonOr<Record<string, string>>(FILES.images, {});
|
|
86
|
+
const layered = sources.some(([n, src]) => jsxUses(src, n, "ImageLayers").length > 0 || jsxUses(src, n, "TextOnImage").length > 0);
|
|
87
|
+
const withText = plan.scenes.filter((sc) => sc.treatment === "illustration" && sc.onScreenText.length > 0 && images[sc.id]);
|
|
88
|
+
if (!layered && withText.length && sources.some(([, src]) => /\bimageKey\b/.test(src) || withText.some((sc) => src.includes(images[sc.id]!)))) {
|
|
89
|
+
notes.push(`Scene ${withText.map((sc) => sc.id).join(", ")} ${withText.length === 1 ? "has" : "have"} an image and on-screen text, but the composition uses neither <ImageLayers> nor <TextOnImage>: the text is not a layer of the picture. Put the words in the picture's calm zone with <TextOnImage layers={s.imageLayers}> inside <ImageLayers layers={s.imageLayers}> (run \`reelkit assets layers --scene <id>\` for a hero picture so the words can pass behind its subject), not on a flat card beside it.`);
|
|
90
|
+
}
|
|
91
|
+
const { pulled, written } = libraryUse(project);
|
|
92
|
+
if (!pulled.length && plan.scenes.length >= 4) {
|
|
93
|
+
const searched = project.readJsonOr<SearchRecord[]>(FILES.searches, []).filter((s) => s.kind === "component").length;
|
|
94
|
+
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\`.`);
|
|
95
|
+
}
|
|
49
96
|
return notes;
|
|
50
97
|
}
|
|
51
98
|
|
|
@@ -53,11 +100,11 @@ const failed = (errors: string[]): Result => ({ ok: false, data: { errors }, sum
|
|
|
53
100
|
|
|
54
101
|
export async function check(ctx: Ctx): Promise<Result> {
|
|
55
102
|
const project = openProject(ctx.cwd);
|
|
56
|
-
const { errors } = inspect(project);
|
|
103
|
+
const { errors, manifest } = inspect(project);
|
|
57
104
|
if (errors.length) return failed(errors);
|
|
58
|
-
const shouldImprove = compositionNotes(project, loadPlan(project));
|
|
105
|
+
const shouldImprove = compositionNotes(project, loadPlan(project), manifest);
|
|
59
106
|
return {
|
|
60
|
-
ok: true, data: { errors: [], shouldImprove },
|
|
107
|
+
ok: true, data: { errors: [], shouldImprove, library: libraryUse(project) },
|
|
61
108
|
summary: ["The composition passes. Run `reelkit preview` to see it.", ...(shouldImprove.length ? [`Worth improving:\n- ${shouldImprove.join("\n- ")}`] : [])].join("\n"),
|
|
62
109
|
};
|
|
63
110
|
}
|
|
@@ -92,33 +139,54 @@ export async function ensureBrowserWithNotice(ctx: Ctx, ensure: EnsureBrowser =
|
|
|
92
139
|
const ffmpegToJpeg = (png: string, jpg: string) => exec("ffmpeg", ["-y", "-loglevel", "error", "-i", png, "-vf", "scale=540:-2", "-q:v", "5", jpg]).then(() => undefined);
|
|
93
140
|
|
|
94
141
|
type PreviewPoint = { sceneId: string; point: "early" | "late" | "middle" | "end" | "start"; frame: number; name: string };
|
|
142
|
+
type WordFrameFile = { sceneId: string; word: string; label: string; frame: number; wordStart: number; name: string };
|
|
143
|
+
|
|
144
|
+
// Whether any file of the composition draws a Scene3D, which is the only case that needs the software GL in the browser.
|
|
145
|
+
export const usesScene3D = (project: Project): boolean => readdirSync(project.path("src")).filter((f) => f.endsWith(".tsx")).some((n) => /<Scene3D[\s>/]/.test(readFileSync(project.path(join("src", n)), "utf8")));
|
|
146
|
+
|
|
147
|
+
// How many frames at each end of a scene SceneFrame may be fading or moving: 8 unless the composition asks for longer with a literal
|
|
148
|
+
// transitionFrames={N} (at most 20, as SceneFrame allows). A value that is not a literal cannot be read, so the default is used.
|
|
149
|
+
export const DEFAULT_FADE = 8;
|
|
150
|
+
export function fadeFrames(sources: string[]): number {
|
|
151
|
+
let n = DEFAULT_FADE;
|
|
152
|
+
for (const src of sources) for (const m of src.matchAll(/transitionFrames=\{\s*(\d+)\s*\}/g)) n = Math.max(n, Math.min(20, Number(m[1])));
|
|
153
|
+
return n;
|
|
154
|
+
}
|
|
95
155
|
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
|
|
99
|
-
|
|
156
|
+
// The frames of a scene, counted from its start, where it is fully visible: SceneFrame fades or moves it at both ends for up to `fade` frames,
|
|
157
|
+
// but never more than half its length.
|
|
158
|
+
const visibleRange = (d: number, fade: number) => { const edge = Math.max(1, Math.min(fade, Math.floor(d / 2))); return { lo: edge, hi: d - edge, edge }; };
|
|
159
|
+
|
|
160
|
+
// 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, both moved into the
|
|
161
|
+
// part where the scene is fully visible (a quick scene's 90% falls inside its fade and would look dim). A scene too short to have two distinct
|
|
162
|
+
// fully visible frames gets its middle one. The scene's number leads each name so the files sort by scene order and then by time.
|
|
163
|
+
export function previewPoints(scenes: { id: string; startFrame: number; durationFrames: number }[], fade = DEFAULT_FADE): PreviewPoint[] {
|
|
100
164
|
return scenes.flatMap((s, i) => {
|
|
101
165
|
const at = (point: PreviewPoint["point"], offset: number): PreviewPoint => ({ sceneId: s.id, point, frame: s.startFrame + offset, name: `${String(i + 1).padStart(2, "0")}-${s.id}-${point}` });
|
|
102
|
-
|
|
103
|
-
|
|
166
|
+
const { lo, hi } = visibleRange(s.durationFrames, fade);
|
|
167
|
+
if (s.durationFrames < 4 || hi - lo < 2) return [at("middle", Math.floor(s.durationFrames / 2))];
|
|
168
|
+
const inside = (share: number) => Math.max(lo, Math.min(hi, Math.round(s.durationFrames * share)));
|
|
104
169
|
return [at("early", inside(0.3)), at("late", inside(0.9))];
|
|
105
170
|
});
|
|
106
171
|
}
|
|
107
172
|
|
|
108
|
-
type BoundaryPoint = { from: string; to: string; end: PreviewPoint; start: PreviewPoint };
|
|
173
|
+
type BoundaryPoint = { from: string; to: string; end: PreviewPoint; start: PreviewPoint; settled: PreviewPoint };
|
|
109
174
|
|
|
110
|
-
// SceneFrame fades each scene in and out
|
|
111
|
-
//
|
|
112
|
-
//
|
|
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)));
|
|
175
|
+
// SceneFrame fades or moves each scene in and out, so the very last and very first frames of two scenes are nearly empty. The frames that show
|
|
176
|
+
// what a scene really holds are the last one at full strength and the first one at full strength. A third frame, six frames after the first,
|
|
177
|
+
// catches a move that is still settling; it is measured but not saved. The files are named by the change, b01-end-<scene> then b01-start-<scene>.
|
|
178
|
+
export function boundaryPoints(scenes: { id: string; startFrame: number; durationFrames: number }[], fade = DEFAULT_FADE): BoundaryPoint[] {
|
|
115
179
|
return scenes.slice(0, -1).map((a, i) => {
|
|
116
180
|
const b = scenes[i + 1]!;
|
|
117
181
|
const n = `b${String(i + 1).padStart(2, "0")}`;
|
|
182
|
+
const ea = visibleRange(a.durationFrames, fade), eb = visibleRange(b.durationFrames, fade);
|
|
183
|
+
const startOffset = Math.min(b.durationFrames - 1, eb.edge);
|
|
184
|
+
const settledOffset = Math.min(b.durationFrames - 1, Math.max(startOffset, Math.min(startOffset + 6, eb.hi)));
|
|
118
185
|
return {
|
|
119
186
|
from: a.id, to: b.id,
|
|
120
|
-
end: { sceneId: a.id, point: "end", frame: a.startFrame + Math.max(0,
|
|
121
|
-
start: { sceneId: b.id, point: "start", frame: b.startFrame +
|
|
187
|
+
end: { sceneId: a.id, point: "end", frame: a.startFrame + Math.max(0, ea.hi), name: `${n}-end-${a.id}` },
|
|
188
|
+
start: { sceneId: b.id, point: "start", frame: b.startFrame + startOffset, name: `${n}-start-${b.id}` },
|
|
189
|
+
settled: { sceneId: b.id, point: "start", frame: b.startFrame + settledOffset, name: `${n}-settled-${b.id}` },
|
|
122
190
|
};
|
|
123
191
|
});
|
|
124
192
|
}
|
|
@@ -137,17 +205,25 @@ export async function preview(ctx: Ctx, deps: { toJpeg?: (png: string, jpg: stri
|
|
|
137
205
|
const dir = project.path("out/preview");
|
|
138
206
|
rmSync(dir, { recursive: true, force: true });
|
|
139
207
|
mkdirSync(dir, { recursive: true });
|
|
140
|
-
const
|
|
141
|
-
const
|
|
208
|
+
const plan = loadPlan(project);
|
|
209
|
+
const intended = new Set(plan.scenes.filter((s) => s.cutIn).map((s) => s.id));
|
|
210
|
+
const fade = fadeFrames(readdirSync(project.path("src")).filter((f) => f.endsWith(".tsx")).map((n) => readFileSync(project.path(join("src", n)), "utf8")));
|
|
211
|
+
const sceneFrames = previewPoints(manifest.scenes, fade);
|
|
212
|
+
const boundaries = boundaryPoints(manifest.scenes, fade);
|
|
142
213
|
const points = [...sceneFrames, ...boundaries.flatMap((b) => [b.end, b.start])];
|
|
214
|
+
// A narrated film also gets one frame just after each word its code times something to: the thing the word names should already be there.
|
|
215
|
+
const srcNames = readdirSync(project.path("src")).filter((f) => FILE_NAME.test(f));
|
|
216
|
+
const wordPlan = isVoiceless(plan) ? undefined : wordTargets(srcNames.flatMap((n) => wordCalls(readFileSync(project.path(join("src", n)), "utf8"), plan.scenes.map((sc) => sc.id), n)), manifest);
|
|
217
|
+
const wordFiles: WordFrameFile[] = (wordPlan?.targets ?? []).map((t, i) => ({ ...t, name: `w${String(i + 1).padStart(2, "0")}-${t.sceneId}-${t.word.toLowerCase().replace(/[^a-z0-9]+/g, "") || "word"}` }));
|
|
218
|
+
const measuredFrames = [...points.map((p) => p.frame), ...boundaries.map((b) => b.settled.frame), ...wordFiles.map((w) => w.frame)];
|
|
143
219
|
try {
|
|
144
220
|
try {
|
|
145
221
|
await ensureBrowserWithNotice(ctx, deps.ensureBrowser);
|
|
146
|
-
await withBundle(project, (serveUrl, urls) => renderStills(serveUrl, { manifest, urls }, [...new Set(
|
|
222
|
+
await withBundle(project, (serveUrl, urls) => renderStills(serveUrl, { manifest, urls }, [...new Set(measuredFrames)], dir, { uses3D: usesScene3D(project) }));
|
|
147
223
|
} catch (e) {
|
|
148
224
|
return failed([`Render failed: ${e instanceof Error ? e.message : String(e)}`]);
|
|
149
225
|
}
|
|
150
|
-
const frames: { sceneId: string; point: PreviewPoint["point"]; path: string }[] = [];
|
|
226
|
+
const frames: { sceneId: string; point: PreviewPoint["point"] | "word"; path: string; word?: string; label?: string }[] = [];
|
|
151
227
|
try {
|
|
152
228
|
for (const m of points) {
|
|
153
229
|
// Shrunk to phone width: small enough to read cheaply, and what is unreadable here is unreadable on a phone.
|
|
@@ -155,9 +231,23 @@ export async function preview(ctx: Ctx, deps: { toJpeg?: (png: string, jpg: stri
|
|
|
155
231
|
await toJpeg(join(dir, `still-${m.frame}.png`), project.path(path));
|
|
156
232
|
frames.push({ sceneId: m.sceneId, point: m.point, path });
|
|
157
233
|
}
|
|
234
|
+
for (const w of wordFiles) {
|
|
235
|
+
const path = `out/preview/${w.name}.jpg`;
|
|
236
|
+
await toJpeg(join(dir, `still-${w.frame}.png`), project.path(path));
|
|
237
|
+
frames.push({ sceneId: w.sceneId, point: "word", path, word: w.word, label: w.label });
|
|
238
|
+
}
|
|
158
239
|
} catch {
|
|
159
240
|
return failed(["Could not write the preview frames: ffmpeg failed. Check that ffmpeg is installed (macOS: `brew install ffmpeg`) and run `reelkit preview` again."]);
|
|
160
241
|
}
|
|
242
|
+
// One picture of all the frames, so that a look at it comes before opening single frames. It is a convenience: failing to make it changes nothing else.
|
|
243
|
+
let sheetLine = "";
|
|
244
|
+
let sheet: { path: string; cols: number; rows: number } | undefined;
|
|
245
|
+
try {
|
|
246
|
+
const names = frames.map((f) => (f.point === "word" ? f.label! : `${f.sceneId} ${f.point === "early" || f.point === "late" || f.point === "middle" ? f.point : `change ${f.point}`}`));
|
|
247
|
+
const r = await contactSheet(frames.map((f) => project.path(f.path)), names, project.path("out/preview/sheet.jpg"), manifest.width / manifest.height, { newRowAt: wordFiles.length ? points.length : undefined });
|
|
248
|
+
sheet = { path: "out/preview/sheet.jpg", cols: r.cols, rows: r.rows };
|
|
249
|
+
sheetLine = `Contact sheet, one picture of all ${frames.length} frames in the order below${r.labelled ? ", each labelled with its scene and early, late or change" : ""} (${r.cols} across${wordFiles.length ? `; the word frames are in the last row${r.labelled ? ", labelled word > scene" : ""}` : ""}): out/preview/sheet.jpg. Look at it first, then open single frames where something needs a closer look.\n`;
|
|
250
|
+
} catch { /* no sheet */ }
|
|
161
251
|
// Whether anything survives each scene change is advice. If it cannot be measured, the preview is still the preview.
|
|
162
252
|
let continuity: { score: number | null; boundaries: Boundary[] } | undefined;
|
|
163
253
|
let continuityLines: string[] = [];
|
|
@@ -165,9 +255,9 @@ export async function preview(ctx: Ctx, deps: { toJpeg?: (png: string, jpg: stri
|
|
|
165
255
|
const h = Math.round((SAMPLE_WIDTH * manifest.height) / manifest.width);
|
|
166
256
|
const measured: Boundary[] = [];
|
|
167
257
|
for (const b of boundaries) {
|
|
168
|
-
const [a, c] = await Promise.all([
|
|
169
|
-
const r =
|
|
170
|
-
measured.push({ from: b.from, to: b.to, survived: Math.round(r.survived * 100) / 100, kind: r.kind });
|
|
258
|
+
const [a, c, d] = await Promise.all([b.end.frame, b.start.frame, b.settled.frame].map((f) => decodeGray(join(dir, `still-${f}.png`), SAMPLE_WIDTH, h)));
|
|
259
|
+
const r = compareAcross(a!, [c!, d!], SAMPLE_WIDTH, h);
|
|
260
|
+
measured.push({ from: b.from, to: b.to, survived: Math.round(r.survived * 100) / 100, kind: intended.has(b.to) && r.kind !== "empty" ? "cut (intended)" : r.kind });
|
|
171
261
|
}
|
|
172
262
|
if (measured.length) {
|
|
173
263
|
const summary = summariseContinuity(measured);
|
|
@@ -175,27 +265,73 @@ export async function preview(ctx: Ctx, deps: { toJpeg?: (png: string, jpg: stri
|
|
|
175
265
|
continuityLines = summary.lines;
|
|
176
266
|
}
|
|
177
267
|
} catch { /* no continuity report */ }
|
|
178
|
-
const
|
|
268
|
+
const wordLines = wordPlan && (wordFiles.length || wordPlan.skipped)
|
|
269
|
+
? `\nWord frames: ${wordFiles.length} frame${wordFiles.length === 1 ? "" : "s"}, each ${WORD_FRAME_AFTER} frames AFTER its word starts, in a row of their own on the sheet (labelled "word ▸ scene"). Each word frame should already show the thing that word names: if it does not, the thing is late (or on the wrong word) and its timing needs fixing.${wordFiles.length ? `\n${wordFiles.map((w) => `${w.label}: out/preview/${w.name}.jpg`).join("\n")}` : ""}${wordPlan.skipped ? `\n${wordPlan.skipped} word timing${wordPlan.skipped === 1 ? " was" : "s were"} not shown (${[wordPlan.undecidable ? `${wordPlan.undecidable} name${wordPlan.undecidable === 1 ? "s" : ""} a word or a scene the code does not give as a literal` : "", wordPlan.capped ? `${wordPlan.capped} beyond the first ${MAX_WORD_FRAMES}` : ""].filter(Boolean).join(", ")}).` : ""}`
|
|
270
|
+
: wordPlan ? "\nWord frames: none, because the composition has no wordFrame, onWord or onWordBeat call with a literal scene and word." : "";
|
|
271
|
+
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" && f.point !== "word").map((f) => `${f.sceneId} (${f.point}): ${f.path}`).join("\n")}`;
|
|
179
272
|
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}.` : ""}` };
|
|
273
|
+
const beat = beatReport(manifest, { finer: isVoiceless(plan) });
|
|
274
|
+
return { ok: true, data: { frames, ...(wordPlan ? { wordFrames: { shown: wordFiles.map((w) => ({ scene: w.sceneId, word: w.word, wordStart: w.wordStart, frame: w.frame })), skipped: wordPlan.skipped } } : {}), ...(sheet ? { sheet } : {}), ...(continuity ? { continuity } : {}), ...(beat ? { beat: { bpm: beat.bpm, boundariesOnBeat: beat.boundariesOnBeat, boundaries: beat.boundaries } } : {}) }, summary: `${sheetLine}${sceneLines}${changeLines}${wordLines}${continuityLines.length ? `\nContinuity: ${continuityLines.join("\n")}` : ""}${beat ? `\nBeat: ${beat.line}.` : ""}` };
|
|
182
275
|
} finally {
|
|
183
276
|
// The full-size stills are only inputs; none stays behind however this ends.
|
|
184
277
|
for (const f of readdirSync(dir)) if (/^still-\d+\.png$/.test(f)) rmSync(join(dir, f), { force: true });
|
|
185
278
|
}
|
|
186
279
|
}
|
|
187
280
|
|
|
188
|
-
export async function render(ctx: Ctx, deps: { ensureBrowser?: EnsureBrowser } = {}): Promise<Result> {
|
|
281
|
+
export async function render(ctx: Ctx, deps: { ensureBrowser?: EnsureBrowser; master?: Master; noShare?: boolean; measure?: (path: string, loudness?: number, words?: SpokenWord[]) => Promise<SoundReport> } = {}): Promise<Result> {
|
|
189
282
|
const project = openProject(ctx.cwd);
|
|
190
283
|
const { errors, manifest } = inspect(project);
|
|
191
284
|
if (!manifest) return failed(errors);
|
|
192
285
|
const path = "out/video.mp4";
|
|
193
286
|
try {
|
|
194
287
|
await ensureBrowserWithNotice(ctx, deps.ensureBrowser);
|
|
195
|
-
await withBundle(project, (serveUrl, urls) => renderVideo(serveUrl, { manifest, urls }, project.path(path)));
|
|
288
|
+
await withBundle(project, (serveUrl, urls) => renderVideo(serveUrl, { manifest, urls }, project.path(path), { uses3D: usesScene3D(project) }));
|
|
196
289
|
} catch (e) {
|
|
197
290
|
return failed([`Render failed: ${e instanceof Error ? e.message : String(e)}`]);
|
|
198
291
|
}
|
|
199
292
|
const seconds = manifest.totalFrames / manifest.fps;
|
|
200
|
-
|
|
293
|
+
// One last pass brings the whole programme to the loudness platforms play at. If it fails the render is still the render.
|
|
294
|
+
let loudness: Mastered | undefined;
|
|
295
|
+
let masterNote = "";
|
|
296
|
+
try {
|
|
297
|
+
loudness = await (deps.master ?? masterLoudness)(project.path(path));
|
|
298
|
+
masterNote = ` Mastered to ${loudness.outputLufs} LUFS (it was ${loudness.inputLufs}), peaks under ${MASTER_TRUE_PEAK_DB} dBTP.`;
|
|
299
|
+
} catch (e) {
|
|
300
|
+
masterNote = ` It was not mastered to ${MASTER_LUFS} LUFS (${e instanceof Error ? e.message : String(e)}); the video is kept as rendered.`;
|
|
301
|
+
}
|
|
302
|
+
// How the finished film sounds against what it shows. It is advice, so a measurement that fails leaves the render as it was.
|
|
303
|
+
let sound: SoundReport | undefined;
|
|
304
|
+
let soundLine: string | undefined;
|
|
305
|
+
try {
|
|
306
|
+
const voiceless = isVoiceless(loadPlan(project));
|
|
307
|
+
sound = await (deps.measure ?? measureSound)(project.path(path), loudness?.outputLufs, voiceless ? undefined : spokenWords(manifest));
|
|
308
|
+
if (voiceless) soundLine = soundAdvice(sound);
|
|
309
|
+
else if (sound.voice) soundLine = voiceAdvice(sound.voice);
|
|
310
|
+
} catch { sound = undefined; }
|
|
311
|
+
const share = await shareAfterRender(ctx, project, { noShare: deps.noShare });
|
|
312
|
+
return {
|
|
313
|
+
ok: true, data: { path, seconds, ...(loudness ? { loudness } : {}),
|
|
314
|
+
...(sound ? { sound: { hitsPerSecond: sound.hitsPerSecond, swellsPerSecond: sound.swellsPerSecond, pictureChanges: sound.pictureChanges, changesWithHit: sound.changesWithHit, firstSoundSec: sound.firstSoundSec }, ...(sound.voice ? { voice: sound.voice } : {}) } : {}), ...(share.data ? { shared: share.data.shared.map((s) => s.name) } : {}) },
|
|
315
|
+
summary: [`Video: ${project.path(path)} (${seconds.toFixed(1)}s, ${manifest.width}x${manifest.height}).${masterNote}`, ...(soundLine ? [soundLine] : []), ...share.lines].join("\n"),
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// `reelkit sound [file]`: the same measurement on any film, by default the last render. `--detail` lists every picture change with its nearest hit, every swell and the first sound.
|
|
320
|
+
export async function soundCommand(ctx: Ctx, file?: string, opts: { detail?: boolean } = {}, deps: { measure?: (path: string, loudness?: number, words?: SpokenWord[]) => Promise<SoundReport> } = {}): Promise<Result> {
|
|
321
|
+
let project: Project | undefined;
|
|
322
|
+
try { project = openProject(ctx.cwd); } catch { project = undefined; }
|
|
323
|
+
const path = file ? resolve(ctx.cwd, file) : project ? project.path("out/video.mp4") : resolve(ctx.cwd, "out/video.mp4");
|
|
324
|
+
if (!existsSync(path)) return { ok: false, summary: `${file ?? "out/video.mp4"} does not exist. ${file ? "Check the path." : "Run `reelkit render` first, or give the file."}` };
|
|
325
|
+
let narrated = false;
|
|
326
|
+
try { narrated = project ? !isVoiceless(loadPlan(project)) : false; } catch { narrated = false; }
|
|
327
|
+
// A narrated film is also measured against its words: the manifest has their times.
|
|
328
|
+
let words: SpokenWord[] | undefined;
|
|
329
|
+
if (narrated && project?.exists(FILES.manifest) && (!file || resolve(ctx.cwd, file) === resolve(project.path("out/video.mp4")))) { try { words = spokenWords(project.readJson<AssetManifest>(FILES.manifest)); } catch { words = undefined; } }
|
|
330
|
+
let report: SoundReport;
|
|
331
|
+
try { report = await (deps.measure ?? measureSound)(path, undefined, words); } catch (e) { return { ok: false, summary: `Could not measure the sound: ${e instanceof Error ? e.message : String(e)}` }; }
|
|
332
|
+
const numbers = `${report.hitsPerSecond} hits a second, ${report.swellsPerSecond} swells a second, ${report.changesWithHit} of ${report.pictureChanges} picture changes with a hit within 0.1s, first sound at ${report.firstSoundSec ?? "never"}s${report.loudnessLufs !== undefined ? `, ${report.loudnessLufs} LUFS` : ""}.`;
|
|
333
|
+
const { detail, ...counts } = report;
|
|
334
|
+
const body = narrated ? `${numbers}${report.voice ? `\n${voiceAdvice(report.voice)}` : ""}` : `${soundAdvice(report)}\n${numbers}`;
|
|
335
|
+
if (opts.detail && detail) return { ok: true, data: { path, ...counts, detail }, summary: `${body}\n${detailLines(detail).join("\n")}` };
|
|
336
|
+
return { ok: true, data: { path, ...counts }, summary: `${body}\nRun with --detail to see each event.` };
|
|
201
337
|
}
|
|
@@ -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
|
+
? "This project is private: nothing from it is sent to the library, not its components and not the images, clips or graphics generated for it, whatever the plan says."
|
|
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/install.ts
CHANGED
|
@@ -17,7 +17,7 @@ export async function install(ctx: Ctx, opts: { agent?: string; force?: boolean
|
|
|
17
17
|
}
|
|
18
18
|
const name = (id: string) => AGENTS.find((a) => a.id === id)!.name;
|
|
19
19
|
const lines = [
|
|
20
|
-
...report.installed.map((i) => `Installed the Reelkit skill for ${name(i.agent)}: ${i.path}`),
|
|
20
|
+
...report.installed.map((i) => `Installed the Reelkit skill for ${name(i.agent)}: ${i.path}${i.commands.length ? ` and the commands ${i.commands.map((c) => `/${c}`).join(" and ")}` : ""}`),
|
|
21
21
|
...report.skipped.filter((s) => agents || s.reason !== "not installed on this machine").map((s) => `Skipped ${name(s.agent)}: ${s.reason}`),
|
|
22
22
|
];
|
|
23
23
|
return { ok: true, data: report, summary: lines.join("\n") };
|