reelkit-cli 0.1.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/LICENSE +21 -0
- package/README.md +91 -0
- package/bin/reelkit.mjs +5 -0
- package/package.json +89 -0
- package/skill/SKILL.md +101 -0
- package/skill/THIRD_PARTY.md +20 -0
- package/skill/command.md +5 -0
- package/skill/reference/asset-reuse.md +31 -0
- package/skill/reference/captions.md +57 -0
- package/skill/reference/component-authoring.md +26 -0
- package/skill/reference/kit.md +135 -0
- package/skill/reference/motion-design.md +62 -0
- package/skill/reference/remotion-composition.md +58 -0
- package/skill/reference/scene-treatments.md +35 -0
- package/skill/reference/scriptwriting.md +44 -0
- package/skill/reference/sound-design.md +43 -0
- package/src/agents.ts +103 -0
- package/src/api/client.ts +58 -0
- package/src/cli.ts +91 -0
- package/src/commands/assets.ts +196 -0
- package/src/commands/auth.ts +118 -0
- package/src/commands/build.ts +109 -0
- package/src/commands/init.ts +32 -0
- package/src/commands/install.ts +24 -0
- package/src/commands/plan.ts +45 -0
- package/src/context.ts +21 -0
- package/src/contract/index.ts +157 -0
- package/src/credentials.ts +55 -0
- package/src/open.ts +48 -0
- package/src/pipeline/review.ts +30 -0
- package/src/pipeline/schema.ts +110 -0
- package/src/pipeline/timing.ts +30 -0
- package/src/project/manifest.ts +71 -0
- package/src/project/probe.ts +54 -0
- package/src/project/project.ts +58 -0
- package/src/project/serve.ts +81 -0
- package/src/remotion/Root.tsx +29 -0
- package/src/remotion/kit/Captions.tsx +77 -0
- package/src/remotion/kit/Counter.tsx +18 -0
- package/src/remotion/kit/Entrance.tsx +53 -0
- package/src/remotion/kit/FootageLayer.tsx +10 -0
- package/src/remotion/kit/Icon.tsx +35 -0
- package/src/remotion/kit/KenBurnsImage.tsx +18 -0
- package/src/remotion/kit/Layers.tsx +46 -0
- package/src/remotion/kit/LowerThird.tsx +17 -0
- package/src/remotion/kit/SceneFrame.tsx +19 -0
- package/src/remotion/kit/ScreenOverlay.tsx +15 -0
- package/src/remotion/kit/Sfx.tsx +11 -0
- package/src/remotion/kit/TitleCard.tsx +23 -0
- package/src/remotion/kit/Voiceover.tsx +5 -0
- package/src/remotion/kit/brand-icons.ts +37 -0
- package/src/remotion/kit/docs.ts +98 -0
- package/src/remotion/kit/index.ts +20 -0
- package/src/remotion/kit/media.ts +10 -0
- package/src/remotion/kit/theme.ts +131 -0
- package/src/remotion/types.ts +2 -0
- package/src/render/component-preview.ts +65 -0
- package/src/render/deps.ts +8 -0
- package/src/render/render.ts +67 -0
- package/src/render/validate.ts +191 -0
- package/src/testing/conformance.ts +396 -0
- package/src/testing/fake-api.ts +228 -0
- package/src/testing/fixtures.ts +44 -0
- package/src/ui/CommandView.tsx +18 -0
- package/src/ui/run.tsx +20 -0
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { copyFileSync, mkdirSync, readFileSync } from "node:fs";
|
|
3
|
+
import { basename, dirname, resolve } from "node:path";
|
|
4
|
+
import { download, uploadTo } from "../api/client";
|
|
5
|
+
import { client, type Ctx, type Result } from "../context";
|
|
6
|
+
import { LibraryKindSchema, UploadKindSchema } from "../contract";
|
|
7
|
+
import { PACE_SPEED, type AssetManifest, type AssetRecord, type ScenePlan } from "../pipeline/schema";
|
|
8
|
+
import { buildManifest, voiceoverStale, type Voiceover } from "../project/manifest";
|
|
9
|
+
import { loadPlan } from "./plan";
|
|
10
|
+
import { FILE_NAME } from "../render/validate";
|
|
11
|
+
import { probeFile } from "../project/probe";
|
|
12
|
+
import { FILES, openProject, type Project } from "../project/project";
|
|
13
|
+
|
|
14
|
+
export type LibraryEntry = { path: string; kind: string; title: string; meta: Record<string, unknown> };
|
|
15
|
+
|
|
16
|
+
// A failure to lay out the timeline after a paid call must not hide that the call succeeded and was saved.
|
|
17
|
+
function tryManifest(project: Project, plan: ScenePlan): { manifest?: AssetManifest; note?: string } {
|
|
18
|
+
try {
|
|
19
|
+
return { manifest: buildManifest(project, plan) };
|
|
20
|
+
} catch (e) {
|
|
21
|
+
return { note: `The timeline could not be built yet: ${e instanceof Error ? e.message : String(e)}` };
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function recordPull(project: Project, id: string, entry: LibraryEntry) {
|
|
26
|
+
project.writeJson(FILES.library, { ...project.readJsonOr<Record<string, LibraryEntry>>(FILES.library, {}), [id]: entry });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function setSceneImage(project: Project, sceneId: string, path: string) {
|
|
30
|
+
project.writeJson(FILES.images, { ...project.readJsonOr<Record<string, string>>(FILES.images, {}), [sceneId]: path });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Registers one of the user's own files. It stays on this machine unless --share is given.
|
|
34
|
+
export async function assetsUpload(ctx: Ctx, file: string, opts: { describe?: string; footage?: boolean; share?: boolean; kind?: string; tags?: string }): Promise<Result> {
|
|
35
|
+
const project = openProject(ctx.cwd);
|
|
36
|
+
if (opts.share && opts.kind === "component") return { ok: false, summary: "Components cannot be shared from the CLI yet. Share images, overlays, sound effects, music or clips." };
|
|
37
|
+
const source = resolve(ctx.cwd, file);
|
|
38
|
+
let probe: Awaited<ReturnType<typeof probeFile>>;
|
|
39
|
+
try {
|
|
40
|
+
probe = await probeFile(source);
|
|
41
|
+
} catch (e) {
|
|
42
|
+
return { ok: false, summary: e instanceof Error ? e.message : String(e) };
|
|
43
|
+
}
|
|
44
|
+
if (opts.footage && probe.kind !== "video") return { ok: false, summary: `${file} is ${probe.kind}, not a video, so it cannot be the footage.` };
|
|
45
|
+
const kind = opts.share ? UploadKindSchema.safeParse(opts.kind) : undefined;
|
|
46
|
+
if (opts.share && (!kind?.success || !opts.describe)) return { ok: false, summary: "Sharing needs --kind (image, overlay, sfx, music or clip) and --describe \"what it is\"." };
|
|
47
|
+
|
|
48
|
+
const id = `a-${randomUUID().slice(0, 8)}`;
|
|
49
|
+
const filename = basename(source).replace(/[^A-Za-z0-9._-]/g, "_");
|
|
50
|
+
const key = `assets/user/${id}-${filename}`;
|
|
51
|
+
mkdirSync(dirname(project.path(key)), { recursive: true });
|
|
52
|
+
copyFileSync(source, project.path(key));
|
|
53
|
+
const record: AssetRecord = { id, filename, key, ...probe, ...(opts.describe ? { description: opts.describe } : {}), createdAt: new Date().toISOString() };
|
|
54
|
+
project.writeJson(FILES.assetIndex, [...project.assets(), record]);
|
|
55
|
+
if (opts.footage) project.writeJson(FILES.config, { ...project.config(), footage: id });
|
|
56
|
+
|
|
57
|
+
let libraryId: string | undefined;
|
|
58
|
+
if (opts.share && kind?.success) {
|
|
59
|
+
const api = client(ctx);
|
|
60
|
+
const tags = (opts.tags ?? "").split(",").map((t) => t.trim()).filter(Boolean);
|
|
61
|
+
const { width, height, durationSec } = probe;
|
|
62
|
+
const up = await api("libraryUpload", {
|
|
63
|
+
kind: kind.data, title: filename, description: opts.describe!, tags, meta: { width, height, durationSec },
|
|
64
|
+
filename, contentType: probe.contentType, bytes: probe.bytes, shareable: true,
|
|
65
|
+
});
|
|
66
|
+
await uploadTo(up.uploadUrl, new Uint8Array(readFileSync(source)), probe.contentType);
|
|
67
|
+
libraryId = (await api("libraryCommit", { id: up.id })).item.id;
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
ok: true, data: { ...record, ...(libraryId ? { libraryId } : {}) },
|
|
71
|
+
summary: `Added ${filename} as ${id} (${probe.kind})${opts.footage ? ", set as the footage" : ""}${libraryId ? `, shared to the library as ${libraryId} (awaiting review)` : ", private to this project"}.`,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function assetsSearch(ctx: Ctx, query: string, opts: { kind?: string; limit?: string }): Promise<Result> {
|
|
76
|
+
const kind = opts.kind === undefined ? undefined : LibraryKindSchema.safeParse(opts.kind);
|
|
77
|
+
if (kind && !kind.success) return { ok: false, summary: `Unknown kind "${opts.kind}". Use one of: ${LibraryKindSchema.options.join(", ")}.` };
|
|
78
|
+
const { items } = await client(ctx)("librarySearch", { q: query, ...(kind?.success ? { kind: kind.data } : {}), ...(opts.limit ? { limit: Number(opts.limit) } : {}) });
|
|
79
|
+
return {
|
|
80
|
+
ok: true, data: { items },
|
|
81
|
+
summary: items.length
|
|
82
|
+
? items.map((i) => `${`${Math.round(i.match * 100)}%`.padStart(4)} ${i.id} ${i.kind} ${i.title}: ${i.description} [${i.tags.join(", ")}]`).join("\n")
|
|
83
|
+
: "No matches in the library. Generate it instead.",
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// The contract already limits file names to plain ones; this is the second lock, before a name becomes a path.
|
|
88
|
+
export function unsafeName(name: string, kind: string): boolean {
|
|
89
|
+
if (name !== basename(name) || name === "." || name === ".." || /[\\/]/.test(name)) return true;
|
|
90
|
+
return kind === "component" && (!FILE_NAME.test(name) || name === "Video.tsx");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function assetsPull(ctx: Ctx, id: string, opts: { scene?: string; force?: boolean }): Promise<Result> {
|
|
94
|
+
const project = openProject(ctx.cwd);
|
|
95
|
+
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\`.` };
|
|
96
|
+
if (opts.scene) {
|
|
97
|
+
// Checked before anything is downloaded.
|
|
98
|
+
const illustrations = loadPlan(project).scenes.filter((s) => s.treatment === "illustration").map((s) => s.id);
|
|
99
|
+
if (!illustrations.includes(opts.scene)) {
|
|
100
|
+
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."}` };
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const pulled = await client(ctx)("libraryPull", { id });
|
|
104
|
+
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.` };
|
|
105
|
+
if (opts.scene && pulled.item.kind !== "image") return { ok: false, summary: `${id} is ${pulled.item.kind}, not an image, so it cannot be a scene's image.` };
|
|
106
|
+
// A component is code, not media: it goes beside Video.tsx, which may only import files in src/.
|
|
107
|
+
if (pulled.item.kind === "component") {
|
|
108
|
+
const path = `src/${pulled.filename}`;
|
|
109
|
+
if (project.exists(path) && !opts.force) return { ok: false, summary: `${path} already exists. Use --force to replace it with the library version.` };
|
|
110
|
+
await download(pulled.url, project.path(path));
|
|
111
|
+
recordPull(project, id, { path, kind: "component", title: pulled.item.title, meta: pulled.item.meta });
|
|
112
|
+
const name = pulled.filename.replace(/\.tsx$/, "");
|
|
113
|
+
const example = typeof pulled.item.meta.example === "string" ? `\nExample: ${pulled.item.meta.example}` : "";
|
|
114
|
+
return { ok: true, data: { path, kind: "component" }, summary: `Pulled ${id} to ${path}. Use it with: import { ${name} } from "./${name}";${example}` };
|
|
115
|
+
}
|
|
116
|
+
const path = `assets/lib/${id}/${pulled.filename}`;
|
|
117
|
+
await download(pulled.url, project.path(path));
|
|
118
|
+
recordPull(project, id, { path, kind: pulled.item.kind, title: pulled.item.title, meta: pulled.item.meta });
|
|
119
|
+
let note: string | undefined;
|
|
120
|
+
if (opts.scene) {
|
|
121
|
+
setSceneImage(project, opts.scene, path);
|
|
122
|
+
if (project.exists(FILES.plan)) note = tryManifest(project, loadPlan(project)).note;
|
|
123
|
+
}
|
|
124
|
+
return {
|
|
125
|
+
ok: true, data: { path, kind: pulled.item.kind },
|
|
126
|
+
summary: [
|
|
127
|
+
opts.scene ? `Pulled ${id} as the image for scene ${opts.scene}.` : `Pulled ${id} to ${path}. Reference it in the composition as urls["${path}"].`,
|
|
128
|
+
...(note ? [note] : []),
|
|
129
|
+
].join("\n"),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export async function assetsVoices(ctx: Ctx): Promise<Result> {
|
|
134
|
+
const { voices } = await client(ctx)("voices", {});
|
|
135
|
+
return {
|
|
136
|
+
ok: true, data: { voices },
|
|
137
|
+
summary: voices.map((v) => `${v.id} ${v.name} (${[v.gender, v.accent, v.language].filter(Boolean).join(", ")}): ${v.description}`).join("\n"),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// One voice and pace for the whole video, as chosen in the plan. A scene already recorded is skipped unless --redo.
|
|
142
|
+
export async function assetsVoiceover(ctx: Ctx, opts: { scene?: string; all?: boolean; redo?: boolean }): Promise<Result> {
|
|
143
|
+
const project = openProject(ctx.cwd);
|
|
144
|
+
const plan = loadPlan(project);
|
|
145
|
+
if (!opts.all && !opts.scene) return { ok: false, summary: "Say which scene with --scene <id>, or use --all." };
|
|
146
|
+
const wanted = opts.all ? plan.scenes : plan.scenes.filter((s) => s.id === opts.scene);
|
|
147
|
+
if (!wanted.length) return { ok: false, summary: `No scene with id ${opts.scene}. The scenes are: ${plan.scenes.map((s) => s.id).join(", ")}.` };
|
|
148
|
+
|
|
149
|
+
const api = client(ctx);
|
|
150
|
+
const done = project.readJsonOr<Record<string, Voiceover>>(FILES.voiceovers, {});
|
|
151
|
+
const todo = wanted.filter((s) => opts.redo || !done[s.id] || voiceoverStale(done[s.id], s, plan));
|
|
152
|
+
const rerecorded = todo.filter((s) => done[s.id] && voiceoverStale(done[s.id], s, plan)).length;
|
|
153
|
+
// One at a time: the result of each is saved before the next starts, so a failure or an exhausted quota loses nothing.
|
|
154
|
+
for (const scene of todo) {
|
|
155
|
+
const speed = PACE_SPEED[plan.pace ?? "normal"];
|
|
156
|
+
const vo = await api("voiceover", { text: scene.narration, ...(plan.voiceId ? { voiceId: plan.voiceId } : {}), speed });
|
|
157
|
+
const key = `assets/vo-${scene.id}.${vo.ext}`;
|
|
158
|
+
await download(vo.url, project.path(key));
|
|
159
|
+
done[scene.id] = { key, durationSec: vo.durationSec, words: vo.words, text: scene.narration, ...(plan.voiceId ? { voiceId: plan.voiceId } : {}), speed };
|
|
160
|
+
project.writeJson(FILES.voiceovers, done);
|
|
161
|
+
ctx.log(`Recorded scene ${scene.id} (${vo.durationSec.toFixed(1)}s)`);
|
|
162
|
+
}
|
|
163
|
+
const { manifest, note } = tryManifest(project, plan);
|
|
164
|
+
const left = plan.scenes.filter((s) => !done[s.id] || voiceoverStale(done[s.id], s, plan)).map((s) => s.id);
|
|
165
|
+
const recorded = `Recorded ${todo.length} scene(s)${rerecorded ? ` (${rerecorded} re-recorded because the script or voice changed)` : ""}.`;
|
|
166
|
+
return {
|
|
167
|
+
ok: true, data: { recorded: todo.map((s) => s.id), rerecorded, voiceovers: done, totalSeconds: manifest ? manifest.totalFrames / manifest.fps : null },
|
|
168
|
+
summary: [
|
|
169
|
+
manifest
|
|
170
|
+
? `${recorded} The video runs ${(manifest.totalFrames / manifest.fps).toFixed(1)}s. Voiceover files are in assets/.${left.length ? ` Still out of date: ${left.join(", ")}. Run \`reelkit assets voiceover --all\`.` : ""}`
|
|
171
|
+
: `${recorded}${left.length ? ` Still to record: ${left.join(", ")}.` : ""}`,
|
|
172
|
+
...(note ? [note] : []),
|
|
173
|
+
].join("\n"),
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export async function assetsGenImage(ctx: Ctx, prompt: string | undefined, opts: { scene: string; redo?: boolean }): Promise<Result> {
|
|
178
|
+
const project = openProject(ctx.cwd);
|
|
179
|
+
const plan = loadPlan(project);
|
|
180
|
+
const scene = plan.scenes.find((s) => s.id === opts.scene);
|
|
181
|
+
if (!scene || scene.treatment !== "illustration" || !scene.imagePrompt) return { ok: false, summary: `Scene ${opts.scene} is not an illustration scene, so it does not take an image.` };
|
|
182
|
+
// A scene that already has its image is not charged for again, whatever prompt is given; only --redo asks for a new one.
|
|
183
|
+
const have = project.readJsonOr<Record<string, string>>(FILES.images, {})[scene.id];
|
|
184
|
+
if (have && project.exists(have) && !opts.redo) return { ok: true, data: { path: have, skipped: true }, summary: `Scene ${scene.id} already has an image at ${have}. Use --redo to generate a new one.` };
|
|
185
|
+
// Anything tied to the user's footage or uploads stays private, whatever the plan says. So does a prompt changed by hand.
|
|
186
|
+
const shareable = scene.shareable && !project.footage() && scene.userAssetIds.length === 0 && !prompt;
|
|
187
|
+
const img = await client(ctx)("images", { prompt: prompt ?? scene.imagePrompt, aspect: plan.aspect, shareable, tags: scene.imageTags });
|
|
188
|
+
const path = `assets/img-${scene.id}.${img.ext}`;
|
|
189
|
+
await download(img.url, project.path(path));
|
|
190
|
+
setSceneImage(project, scene.id, path);
|
|
191
|
+
const { note } = tryManifest(project, plan);
|
|
192
|
+
return {
|
|
193
|
+
ok: true, data: { path, libraryId: img.libraryId ?? null },
|
|
194
|
+
summary: [`Generated the image for scene ${scene.id} at ${path}${img.libraryId ? `, and shared it to the library as ${img.libraryId} (awaiting review)` : ""}.`, ...(note ? [note] : [])].join("\n"),
|
|
195
|
+
};
|
|
196
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { hostname } from "node:os";
|
|
2
|
+
import { ensureSkill } from "../agents";
|
|
3
|
+
import { ApiFailure, type Api } from "../api/client";
|
|
4
|
+
import { apiUrl, client, type Ctx, type Result } from "../context";
|
|
5
|
+
import { clearCredentials, clearPendingLogin, loadCredentials, loadPendingLogin, saveCredentials, savePendingLogin } from "../credentials";
|
|
6
|
+
|
|
7
|
+
const FINISH_WAIT_SEC = 60;
|
|
8
|
+
|
|
9
|
+
const MAX_SERVER_ERRORS = 3;
|
|
10
|
+
|
|
11
|
+
// Asks for the token until it is approved, expired or the time runs out: asks first, then waits, so an approval already given is found at once.
|
|
12
|
+
// A poll that fails on the server is tried again, but the third failure in a row ends the wait with that error's message;
|
|
13
|
+
// an answer of any kind (including "pending") starts the count again.
|
|
14
|
+
// Whatever interval the API asks for, it is never shorter than a second, and the number of tries is finite.
|
|
15
|
+
async function pollForToken(ctx: Ctx, api: Api, deviceCode: string, intervalSec: number, waitSec: number): Promise<{ status: "approved"; token: string } | { status: "expired" | "timeout" } | { status: "unreachable"; message: string }> {
|
|
16
|
+
const interval = Math.max(1, intervalSec);
|
|
17
|
+
const attempts = Math.max(1, Math.ceil(waitSec / interval));
|
|
18
|
+
let failures = 0;
|
|
19
|
+
for (let i = 0; i < attempts; i++) {
|
|
20
|
+
if (i > 0) await ctx.sleep(interval * 1000);
|
|
21
|
+
try {
|
|
22
|
+
const poll = await api("deviceToken", { deviceCode });
|
|
23
|
+
failures = 0;
|
|
24
|
+
if (poll.status !== "pending") return poll;
|
|
25
|
+
} catch (e) {
|
|
26
|
+
if (!(e instanceof ApiFailure && e.code === "server_error")) throw e;
|
|
27
|
+
if (++failures >= MAX_SERVER_ERRORS) return { status: "unreachable", message: e.message };
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return { status: "timeout" };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const done = (ctx: Ctx, token: string): Result => {
|
|
34
|
+
saveCredentials(ctx.env, { token });
|
|
35
|
+
clearPendingLogin(ctx.env);
|
|
36
|
+
ensureSkill(ctx);
|
|
37
|
+
return { ok: true, summary: "Logged in." };
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// The link that opens the login page with the code already filled in: the verification URL with `code` added to its query, keeping what
|
|
41
|
+
// it already has.
|
|
42
|
+
function completeUrl(verificationUrl: string, userCode: string): string {
|
|
43
|
+
try {
|
|
44
|
+
const u = new URL(verificationUrl);
|
|
45
|
+
u.searchParams.set("code", userCode);
|
|
46
|
+
return u.toString();
|
|
47
|
+
} catch {
|
|
48
|
+
return `${verificationUrl}${verificationUrl.includes("?") ? "&" : "?"}code=${encodeURIComponent(userCode)}`;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const sameOrigin = (a: string, b: string): boolean => {
|
|
53
|
+
try { return new URL(a).origin === new URL(b).origin; } catch { return false; }
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// `browser: false` is --no-browser; `json` is the global --json. The browser opens only for a person at a terminal: stdout is a TTY, no
|
|
57
|
+
// --json, no --no-browser and no REELKIT_NO_BROWSER=1.
|
|
58
|
+
export async function authLogin(ctx: Ctx, opts: { start?: boolean; finish?: boolean; browser?: boolean; json?: boolean } = {}): Promise<Result> {
|
|
59
|
+
if (opts.start && opts.finish) return { ok: false, summary: "Use --start or --finish, not both." };
|
|
60
|
+
const api = client(ctx);
|
|
61
|
+
if (opts.finish) return authFinish(ctx, api);
|
|
62
|
+
const start = await api("deviceStart", { label: hostname().slice(0, 80) });
|
|
63
|
+
if (opts.start) {
|
|
64
|
+
savePendingLogin(ctx.env, { deviceCode: start.deviceCode, userCode: start.userCode, verificationUrl: start.verificationUrl, intervalSec: start.intervalSec, expiresAt: Date.now() + start.expiresInSec * 1000 });
|
|
65
|
+
const verificationUrlComplete = completeUrl(start.verificationUrl, start.userCode);
|
|
66
|
+
return {
|
|
67
|
+
ok: true, data: { verificationUrl: start.verificationUrl, verificationUrlComplete, userCode: start.userCode, expiresInSec: start.expiresInSec },
|
|
68
|
+
summary: `Ask the user to open ${verificationUrlComplete} and approve the login (the code is ${start.userCode}). When they have approved it, run \`reelkit auth login --finish\`.`,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
const link = completeUrl(start.verificationUrl, start.userCode);
|
|
72
|
+
ctx.log(`Open ${link}`);
|
|
73
|
+
ctx.log(`Code: ${start.userCode}`);
|
|
74
|
+
if (ctx.isTTY && !opts.json && opts.browser !== false && ctx.env.REELKIT_NO_BROWSER !== "1" && ctx.openUrl) {
|
|
75
|
+
if (sameOrigin(start.verificationUrl, apiUrl(ctx))) {
|
|
76
|
+
// The link is already printed, so a browser that cannot be opened costs nothing.
|
|
77
|
+
try { await ctx.openUrl(link); } catch { ctx.log("Could not open a browser. Open the link above yourself."); }
|
|
78
|
+
} else {
|
|
79
|
+
ctx.log("The login page is on a different site than the API, so it was not opened automatically. Check the link before opening it.");
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const res = await pollForToken(ctx, api, start.deviceCode, start.intervalSec, start.expiresInSec);
|
|
83
|
+
if (res.status === "approved") return done(ctx, res.token);
|
|
84
|
+
if (res.status === "unreachable") return { ok: false, summary: res.message };
|
|
85
|
+
return { ok: false, summary: res.status === "expired" ? "The code expired. Run `reelkit auth login` again." : "Login timed out. Run `reelkit auth login` again." };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function authFinish(ctx: Ctx, api: Api): Promise<Result> {
|
|
89
|
+
const pending = loadPendingLogin(ctx.env);
|
|
90
|
+
if (!pending) return { ok: false, summary: "No login is in progress. Run `reelkit auth login --start` first." };
|
|
91
|
+
if (pending.expiresAt <= Date.now()) {
|
|
92
|
+
clearPendingLogin(ctx.env);
|
|
93
|
+
return { ok: false, summary: "The login code expired. Run `reelkit auth login --start` again." };
|
|
94
|
+
}
|
|
95
|
+
const res = await pollForToken(ctx, api, pending.deviceCode, pending.intervalSec, FINISH_WAIT_SEC);
|
|
96
|
+
if (res.status === "approved") return done(ctx, res.token);
|
|
97
|
+
if (res.status === "unreachable") return { ok: false, summary: res.message };
|
|
98
|
+
if (res.status === "expired") {
|
|
99
|
+
clearPendingLogin(ctx.env);
|
|
100
|
+
return { ok: false, summary: "The login code expired. Run `reelkit auth login --start` again." };
|
|
101
|
+
}
|
|
102
|
+
return { ok: false, summary: "Not approved yet. Approve the code in the browser, then run `reelkit auth login --finish` again." };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function authLogout(ctx: Ctx): Promise<Result> {
|
|
106
|
+
if (loadCredentials(ctx.env)) await client(ctx)("revoke", {}).catch(() => undefined);
|
|
107
|
+
clearCredentials(ctx.env);
|
|
108
|
+
return { ok: true, summary: "Logged out." };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export async function whoami(ctx: Ctx): Promise<Result> {
|
|
112
|
+
const me = await client(ctx)("me", {});
|
|
113
|
+
const q = me.quota;
|
|
114
|
+
return {
|
|
115
|
+
ok: true, data: me,
|
|
116
|
+
summary: `${me.handle}\nVoiceover: ${q.voiceoverChars.used}/${q.voiceoverChars.limit} characters\nImages: ${q.images.used}/${q.images.limit}\nResets: ${q.resetsAt.slice(0, 10)}\nContributions: ${me.contributions}`,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { mkdirSync, readdirSync, readFileSync, rmSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
import type { Ctx, Result } from "../context";
|
|
6
|
+
import type { AssetManifest } from "../pipeline/schema";
|
|
7
|
+
import { buildManifest, missingAssets } from "../project/manifest";
|
|
8
|
+
import { openProject, type Project } from "../project/project";
|
|
9
|
+
import { mediaUrls, serveDir } from "../project/serve";
|
|
10
|
+
import { bundleProject, disposeBundle, renderStills, renderVideo, writeEntry } from "../render/render";
|
|
11
|
+
import { FILE_NAME, MAIN_FILE, staticCheck, typecheck } from "../render/validate";
|
|
12
|
+
import { loadPlan } from "./plan";
|
|
13
|
+
|
|
14
|
+
const exec = promisify(execFile);
|
|
15
|
+
const MEDIA_PATH = /["'`](assets\/[^"'`\s]+\.(?:mp3|wav|m4a|mp4|mov|webm|png|jpe?g|webp|svg|gif))["'`]/g;
|
|
16
|
+
|
|
17
|
+
// Everything that can be known without rendering: the assets are there, the code follows the rules, the types are right.
|
|
18
|
+
function inspect(project: Project): { errors: string[]; manifest?: AssetManifest } {
|
|
19
|
+
const plan = loadPlan(project);
|
|
20
|
+
const names = readdirSync(project.path("src")).filter((f) => f.endsWith(".tsx"));
|
|
21
|
+
if (!names.includes(MAIN_FILE)) return { errors: [`src/${MAIN_FILE} has not been written.`] };
|
|
22
|
+
const errors: string[] = missingAssets(project, plan);
|
|
23
|
+
for (const name of names) {
|
|
24
|
+
if (!FILE_NAME.test(name)) { errors.push(`src/${name}: a component file must be PascalCase, such as NumberBadge.tsx.`); continue; }
|
|
25
|
+
const source = readFileSync(project.path(join("src", name)), "utf8");
|
|
26
|
+
errors.push(...staticCheck(source, name, names));
|
|
27
|
+
// Test frames do not load audio, so a sound the project does not hold would only fail at the final render.
|
|
28
|
+
for (const m of source.matchAll(MEDIA_PATH)) {
|
|
29
|
+
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.`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
if (errors.length) return { errors };
|
|
33
|
+
const typeErrors = typecheck(names.map((n) => project.path(join("src", n))));
|
|
34
|
+
if (typeErrors.length) return { errors: typeErrors };
|
|
35
|
+
const manifest = buildManifest(project, plan);
|
|
36
|
+
return manifest ? { errors: [], manifest } : { errors: ["The manifest could not be built. Run `reelkit assets voiceover --all`."] };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const failed = (errors: string[]): Result => ({ ok: false, data: { errors }, summary: `${errors.length} problem(s):\n- ${errors.join("\n- ")}` });
|
|
40
|
+
|
|
41
|
+
export async function check(ctx: Ctx): Promise<Result> {
|
|
42
|
+
const { errors } = inspect(openProject(ctx.cwd));
|
|
43
|
+
return errors.length ? failed(errors) : { ok: true, data: { errors: [] }, summary: "The composition passes. Run `reelkit preview` to see it." };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Bundles the composition and serves the project's media for as long as fn runs.
|
|
47
|
+
async function withBundle<T>(project: Project, fn: (serveUrl: string, urls: Record<string, string>) => Promise<T>): Promise<T> {
|
|
48
|
+
const server = await serveDir(project.dir);
|
|
49
|
+
let serveUrl: string | undefined;
|
|
50
|
+
try {
|
|
51
|
+
serveUrl = await bundleProject(await writeEntry(project.dir));
|
|
52
|
+
return await fn(serveUrl, mediaUrls(project, server));
|
|
53
|
+
} finally {
|
|
54
|
+
try {
|
|
55
|
+
if (serveUrl) await disposeBundle(serveUrl);
|
|
56
|
+
} finally {
|
|
57
|
+
await server.close();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const ffmpegToJpeg = (png: string, jpg: string) => exec("ffmpeg", ["-y", "-loglevel", "error", "-i", png, "-vf", "scale=540:-2", "-q:v", "5", jpg]).then(() => undefined);
|
|
63
|
+
|
|
64
|
+
export async function preview(ctx: Ctx, deps: { toJpeg?: (png: string, jpg: string) => Promise<void> } = {}): Promise<Result> {
|
|
65
|
+
const toJpeg = deps.toJpeg ?? ffmpegToJpeg;
|
|
66
|
+
const project = openProject(ctx.cwd);
|
|
67
|
+
const { errors, manifest } = inspect(project);
|
|
68
|
+
if (!manifest) return failed(errors);
|
|
69
|
+
const dir = project.path("out/preview");
|
|
70
|
+
rmSync(dir, { recursive: true, force: true });
|
|
71
|
+
mkdirSync(dir, { recursive: true });
|
|
72
|
+
const mids = manifest.scenes.map((s) => ({ sceneId: s.id, frame: s.startFrame + Math.floor(s.durationFrames / 2) }));
|
|
73
|
+
try {
|
|
74
|
+
try {
|
|
75
|
+
await withBundle(project, (serveUrl, urls) => renderStills(serveUrl, { manifest, urls }, mids.map((m) => m.frame), dir));
|
|
76
|
+
} catch (e) {
|
|
77
|
+
return failed([`Render failed: ${e instanceof Error ? e.message : String(e)}`]);
|
|
78
|
+
}
|
|
79
|
+
const frames: { sceneId: string; path: string }[] = [];
|
|
80
|
+
try {
|
|
81
|
+
for (const m of mids) {
|
|
82
|
+
// Shrunk to phone width: small enough to read cheaply, and what is unreadable here is unreadable on a phone.
|
|
83
|
+
const path = `out/preview/${m.sceneId}.jpg`;
|
|
84
|
+
await toJpeg(join(dir, `still-${m.frame}.png`), project.path(path));
|
|
85
|
+
frames.push({ sceneId: m.sceneId, path });
|
|
86
|
+
}
|
|
87
|
+
} catch {
|
|
88
|
+
return failed(["Could not write the preview frames: ffmpeg failed. Check that ffmpeg is installed (macOS: `brew install ffmpeg`) and run `reelkit preview` again."]);
|
|
89
|
+
}
|
|
90
|
+
return { ok: true, data: { frames }, summary: `One frame per scene, taken at its midpoint:\n${frames.map((f) => `${f.sceneId}: ${f.path}`).join("\n")}` };
|
|
91
|
+
} finally {
|
|
92
|
+
// The full-size stills are only inputs; none stays behind however this ends.
|
|
93
|
+
for (const f of readdirSync(dir)) if (/^still-\d+\.png$/.test(f)) rmSync(join(dir, f), { force: true });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function render(ctx: Ctx): Promise<Result> {
|
|
98
|
+
const project = openProject(ctx.cwd);
|
|
99
|
+
const { errors, manifest } = inspect(project);
|
|
100
|
+
if (!manifest) return failed(errors);
|
|
101
|
+
const path = "out/video.mp4";
|
|
102
|
+
try {
|
|
103
|
+
await withBundle(project, (serveUrl, urls) => renderVideo(serveUrl, { manifest, urls }, project.path(path)));
|
|
104
|
+
} catch (e) {
|
|
105
|
+
return failed([`Render failed: ${e instanceof Error ? e.message : String(e)}`]);
|
|
106
|
+
}
|
|
107
|
+
const seconds = manifest.totalFrames / manifest.fps;
|
|
108
|
+
return { ok: true, data: { path, seconds }, summary: `Video: ${project.path(path)} (${seconds.toFixed(1)}s, ${manifest.width}x${manifest.height})` };
|
|
109
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { existsSync, mkdirSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
import { ensureSkill } from "../agents";
|
|
6
|
+
import type { Ctx, Result } from "../context";
|
|
7
|
+
import { AspectSchema } from "../pipeline/schema";
|
|
8
|
+
import { FILES, Project } from "../project/project";
|
|
9
|
+
|
|
10
|
+
const exec = promisify(execFile);
|
|
11
|
+
const DIRS = ["assets", "refs", "src", "out"];
|
|
12
|
+
|
|
13
|
+
export async function init(ctx: Ctx, name: string | undefined, opts: { aspect?: string }): Promise<Result> {
|
|
14
|
+
const slug = name === undefined ? undefined : name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
15
|
+
if (slug === "") return { ok: false, summary: "Give the project a name with letters or digits, for example: reelkit init launch-video" };
|
|
16
|
+
const aspect = AspectSchema.safeParse(opts.aspect ?? "9:16");
|
|
17
|
+
if (!aspect.success) return { ok: false, summary: `Unknown aspect "${opts.aspect}". Use 9:16, 16:9 or 1:1.` };
|
|
18
|
+
if (Number(process.versions.node.split(".")[0]) < 20) return { ok: false, summary: `Node 20 or newer is needed; this is ${process.versions.node}. Install a newer Node.` };
|
|
19
|
+
try { await exec("ffmpeg", ["-version"]); } catch { return { ok: false, summary: "ffmpeg was not found. Install it (macOS: `brew install ffmpeg`) and run `reelkit init` again." }; }
|
|
20
|
+
|
|
21
|
+
const dir = slug ? join(ctx.cwd, slug) : ctx.cwd;
|
|
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
|
+
const project = new Project(dir);
|
|
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 }) });
|
|
26
|
+
if (!existsSync(project.path(".gitignore"))) writeFileSync(project.path(".gitignore"), ".reelkit\nout\nrefs\n");
|
|
27
|
+
ensureSkill(ctx);
|
|
28
|
+
return {
|
|
29
|
+
ok: true, data: { dir, config: project.config() },
|
|
30
|
+
summary: `Project ready in ${dir} (${project.config().aspect}).${slug ? ` Next: cd ${slug}` : ""}`,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { AGENTS, installSkill } from "../agents";
|
|
2
|
+
import type { Ctx, Result } from "../context";
|
|
3
|
+
|
|
4
|
+
export async function install(ctx: Ctx, opts: { agent?: string; force?: boolean }, extra: { source?: string } = {}): Promise<Result> {
|
|
5
|
+
const ids = AGENTS.map((a) => a.id);
|
|
6
|
+
let agents: string[] | undefined;
|
|
7
|
+
if (opts.agent === "all") agents = ids;
|
|
8
|
+
else if (opts.agent) {
|
|
9
|
+
if (!ids.includes(opts.agent)) return { ok: false, summary: `Unknown agent "${opts.agent}". Use one of: ${ids.join(", ")}, or all.` };
|
|
10
|
+
agents = [opts.agent];
|
|
11
|
+
}
|
|
12
|
+
let report;
|
|
13
|
+
try { report = installSkill(ctx.env, { agents, force: opts.force, source: extra.source }); }
|
|
14
|
+
catch (e) { return { ok: false, summary: (e instanceof Error ? e.message : String(e)).split("\n")[0] }; }
|
|
15
|
+
if (!report.installed.length && report.skipped.every((s) => s.reason === "not installed on this machine") && !agents) {
|
|
16
|
+
return { ok: false, summary: `No coding agent was found on this machine. Name one with --agent (${ids.join(", ")}).` };
|
|
17
|
+
}
|
|
18
|
+
const name = (id: string) => AGENTS.find((a) => a.id === id)!.name;
|
|
19
|
+
const lines = [
|
|
20
|
+
...report.installed.map((i) => `Installed the Reelkit skill for ${name(i.agent)}: ${i.path}`),
|
|
21
|
+
...report.skipped.filter((s) => agents || s.reason !== "not installed on this machine").map((s) => `Skipped ${name(s.agent)}: ${s.reason}`),
|
|
22
|
+
];
|
|
23
|
+
return { ok: true, data: report, summary: lines.join("\n") };
|
|
24
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { client, type Ctx, type Result } from "../context";
|
|
2
|
+
import { loadCredentials } from "../credentials";
|
|
3
|
+
import { reviewPlan } from "../pipeline/review";
|
|
4
|
+
import { ScenePlanSchema, validatePlan, type ScenePlan } from "../pipeline/schema";
|
|
5
|
+
import { FILES, issueLines, openProject, type Project } from "../project/project";
|
|
6
|
+
|
|
7
|
+
export function loadPlan(project: Project): ScenePlan {
|
|
8
|
+
if (!project.exists(FILES.plan)) throw new Error("There is no plan.json in this project. Write the plan first.");
|
|
9
|
+
const parsed = ScenePlanSchema.safeParse(project.readJson(FILES.plan));
|
|
10
|
+
if (!parsed.success) throw new Error(`plan.json is not valid. Run \`reelkit plan check\`. ${issueLines(parsed.error)[0]}`);
|
|
11
|
+
// The rules that need no network, so nothing is paid for on a plan that cannot become a video.
|
|
12
|
+
const footage = project.footage();
|
|
13
|
+
const problems = validatePlan(parsed.data, { aspect: project.config().aspect, footage, assets: project.assets().filter((a) => a.id !== footage?.id) });
|
|
14
|
+
if (problems.length) throw new Error(`plan.json has problems. Run \`reelkit plan check\`. ${problems[0]}`);
|
|
15
|
+
return parsed.data;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Hard rules (mustFix) stop the video; soft notes (shouldImprove) are advice for a better script.
|
|
19
|
+
export async function planCheck(ctx: Ctx): Promise<Result> {
|
|
20
|
+
const project = openProject(ctx.cwd);
|
|
21
|
+
if (!project.exists(FILES.plan)) throw new Error("There is no plan.json in this project. Write the plan first.");
|
|
22
|
+
let raw: unknown;
|
|
23
|
+
try {
|
|
24
|
+
raw = project.readJson(FILES.plan);
|
|
25
|
+
} catch (e) {
|
|
26
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
27
|
+
return { ok: false, data: { mustFix: [message], shouldImprove: [] }, summary: `plan.json has 1 problem(s) to fix:\n- ${message}` };
|
|
28
|
+
}
|
|
29
|
+
const parsed = ScenePlanSchema.safeParse(raw);
|
|
30
|
+
if (!parsed.success) {
|
|
31
|
+
const mustFix = issueLines(parsed.error);
|
|
32
|
+
return { ok: false, data: { mustFix, shouldImprove: [] }, summary: `plan.json has ${mustFix.length} problem(s) to fix:\n- ${mustFix.join("\n- ")}` };
|
|
33
|
+
}
|
|
34
|
+
const footage = project.footage();
|
|
35
|
+
const assets = project.assets().filter((a) => a.id !== footage?.id);
|
|
36
|
+
const voiceIds = loadCredentials(ctx.env) ? (await client(ctx)("voices", {})).voices.map((v) => v.id) : undefined;
|
|
37
|
+
const mustFix = validatePlan(parsed.data, { aspect: project.config().aspect, footage, assets, voiceIds });
|
|
38
|
+
const shouldImprove = mustFix.length ? [] : reviewPlan(parsed.data, { footage });
|
|
39
|
+
const lines = [
|
|
40
|
+
mustFix.length ? `Fix these:\n- ${mustFix.join("\n- ")}` : "The plan is valid.",
|
|
41
|
+
...(shouldImprove.length ? [`Worth improving:\n- ${shouldImprove.join("\n- ")}`] : []),
|
|
42
|
+
...(voiceIds ? [] : ["The voice was not checked because you are not logged in. Run `reelkit auth login`."]),
|
|
43
|
+
];
|
|
44
|
+
return { ok: mustFix.length === 0, data: { mustFix, shouldImprove }, summary: lines.join("\n") };
|
|
45
|
+
}
|
package/src/context.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { createClient, type Api } from "./api/client";
|
|
2
|
+
import { DEFAULT_API_URL, loadCredentials } from "./credentials";
|
|
3
|
+
|
|
4
|
+
export type Ctx = {
|
|
5
|
+
cwd: string;
|
|
6
|
+
env: Record<string, string | undefined>;
|
|
7
|
+
// Progress lines for a person watching. The final answer is the command's Result, not a log line.
|
|
8
|
+
log(line: string): void;
|
|
9
|
+
sleep(ms: number): Promise<void>;
|
|
10
|
+
// Whether a person is watching this terminal (stdout is a TTY). Tests set it explicitly.
|
|
11
|
+
isTTY: boolean;
|
|
12
|
+
// Opens a web page for the person. Absent where there is nothing to open it with (tests); never required for a command to work.
|
|
13
|
+
openUrl?: (url: string) => Promise<boolean | void>;
|
|
14
|
+
};
|
|
15
|
+
export type Result = { ok: boolean; summary: string; data?: unknown };
|
|
16
|
+
|
|
17
|
+
export const apiUrl = (ctx: Ctx) => ctx.env.REELKIT_API_URL || DEFAULT_API_URL;
|
|
18
|
+
|
|
19
|
+
export function client(ctx: Ctx): Api {
|
|
20
|
+
return createClient({ baseUrl: apiUrl(ctx), token: loadCredentials(ctx.env)?.token });
|
|
21
|
+
}
|