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.
Files changed (65) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +91 -0
  3. package/bin/reelkit.mjs +5 -0
  4. package/package.json +89 -0
  5. package/skill/SKILL.md +101 -0
  6. package/skill/THIRD_PARTY.md +20 -0
  7. package/skill/command.md +5 -0
  8. package/skill/reference/asset-reuse.md +31 -0
  9. package/skill/reference/captions.md +57 -0
  10. package/skill/reference/component-authoring.md +26 -0
  11. package/skill/reference/kit.md +135 -0
  12. package/skill/reference/motion-design.md +62 -0
  13. package/skill/reference/remotion-composition.md +58 -0
  14. package/skill/reference/scene-treatments.md +35 -0
  15. package/skill/reference/scriptwriting.md +44 -0
  16. package/skill/reference/sound-design.md +43 -0
  17. package/src/agents.ts +103 -0
  18. package/src/api/client.ts +58 -0
  19. package/src/cli.ts +91 -0
  20. package/src/commands/assets.ts +196 -0
  21. package/src/commands/auth.ts +118 -0
  22. package/src/commands/build.ts +109 -0
  23. package/src/commands/init.ts +32 -0
  24. package/src/commands/install.ts +24 -0
  25. package/src/commands/plan.ts +45 -0
  26. package/src/context.ts +21 -0
  27. package/src/contract/index.ts +157 -0
  28. package/src/credentials.ts +55 -0
  29. package/src/open.ts +48 -0
  30. package/src/pipeline/review.ts +30 -0
  31. package/src/pipeline/schema.ts +110 -0
  32. package/src/pipeline/timing.ts +30 -0
  33. package/src/project/manifest.ts +71 -0
  34. package/src/project/probe.ts +54 -0
  35. package/src/project/project.ts +58 -0
  36. package/src/project/serve.ts +81 -0
  37. package/src/remotion/Root.tsx +29 -0
  38. package/src/remotion/kit/Captions.tsx +77 -0
  39. package/src/remotion/kit/Counter.tsx +18 -0
  40. package/src/remotion/kit/Entrance.tsx +53 -0
  41. package/src/remotion/kit/FootageLayer.tsx +10 -0
  42. package/src/remotion/kit/Icon.tsx +35 -0
  43. package/src/remotion/kit/KenBurnsImage.tsx +18 -0
  44. package/src/remotion/kit/Layers.tsx +46 -0
  45. package/src/remotion/kit/LowerThird.tsx +17 -0
  46. package/src/remotion/kit/SceneFrame.tsx +19 -0
  47. package/src/remotion/kit/ScreenOverlay.tsx +15 -0
  48. package/src/remotion/kit/Sfx.tsx +11 -0
  49. package/src/remotion/kit/TitleCard.tsx +23 -0
  50. package/src/remotion/kit/Voiceover.tsx +5 -0
  51. package/src/remotion/kit/brand-icons.ts +37 -0
  52. package/src/remotion/kit/docs.ts +98 -0
  53. package/src/remotion/kit/index.ts +20 -0
  54. package/src/remotion/kit/media.ts +10 -0
  55. package/src/remotion/kit/theme.ts +131 -0
  56. package/src/remotion/types.ts +2 -0
  57. package/src/render/component-preview.ts +65 -0
  58. package/src/render/deps.ts +8 -0
  59. package/src/render/render.ts +67 -0
  60. package/src/render/validate.ts +191 -0
  61. package/src/testing/conformance.ts +396 -0
  62. package/src/testing/fake-api.ts +228 -0
  63. package/src/testing/fixtures.ts +44 -0
  64. package/src/ui/CommandView.tsx +18 -0
  65. package/src/ui/run.tsx +20 -0
@@ -0,0 +1,157 @@
1
+ // The rules a server of this API must follow. The CLI and the fake API (src/testing/fake-api.ts) rely on every one.
2
+ //
3
+ // - Every route with `auth: true` takes `Authorization: Bearer <token>`.
4
+ // - Errors are `{ "error": { "code", "message" } }` and always use a non-2xx status: 401 unauthenticated, 429 quota_exceeded,
5
+ // 404 not_found, 400 invalid_request, 500 server_error. The client reads `error.code` from the body.
6
+ // A `quota_exceeded` message says when the limit resets or how long to wait (a date for a monthly quota, "wait a minute" for a throttle).
7
+ // - Pull returns an item only if it is published or belongs to the caller; anything else is not_found.
8
+ // Commit works only for the user who uploaded the item.
9
+ // - Text sent to the server is bounded and plain: no control characters (C0 other than tab, newline and carriage return, DEL) and no lone surrogates,
10
+ // within the length of its field; anything else is 400 invalid_request before any paid service is called.
11
+ // - A voiceover `text` has its line endings normalised by the server (`\r\n` and a lone `\r` become `\n`) before it is counted for the quota
12
+ // and before it is spoken; `chars` in the answer is the normalised length.
13
+ // - A voiceover `text` must contain at least one non-space character: whitespace alone is 400 invalid_request.
14
+ // - A library item `id` (pull, commit) matches `^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$`; a `deviceCode` is 1 to 200 characters of plain text.
15
+ // - A `voiceId` is 1 to 64 letters and digits and must be one of the voices the server lists (`voices`), otherwise 400 invalid_request.
16
+ // - Components are added to the library only by the owner's scripts, never through this API (UploadKindSchema has no `component`).
17
+ // - Upload is a single `PUT` to `uploadUrl` with exactly the declared content type and byte length, and no auth header.
18
+ // Anything else is refused with a non-2xx status and stores nothing. That refusal comes from the storage service, so its body
19
+ // is not an API error and a client must not parse it.
20
+ // - A client sends one `PUT` per upload URL, then commits. If the `PUT` fails, is refused or its outcome is unknown, the client starts a new
21
+ // upload (`libraryUpload`): it may not rely on a URL working a second time. A server may keep an upload URL usable until it expires
22
+ // (a presigned URL on object storage is), so a second `PUT` can succeed there; the fake API does not allow it.
23
+ // - On every server, whatever is sent to an upload URL, at any time, can never change an object that is being served:
24
+ // the upload goes to a staging object, and commit moves it to where it is served. Commit succeeds only for an object that was stored
25
+ // with the declared content type and byte length (the `PUT` enforces the type, commit checks the length); otherwise it is 400 invalid_request.
26
+ // - An upload is a library item only after commit: before that it is not found by pull (`not_found`) or search, even for its uploader.
27
+ // Committing the same item again returns the same item and counts as one contribution.
28
+ // - Upload rules, all `invalid_request`: the content type must fit the kind (image: `image/*`; clip and overlay: `video/*`; sfx and music:
29
+ // `audio/*`) and `bytes` is from 1 to 209,715,200 (200 MB). A server may rewrite the file name to a safe one (every character outside
30
+ // `A-Za-z0-9._-` becomes `_`); a name that then does not start with a letter or digit, or is longer than 128 characters, is refused.
31
+ // `pull` returns the server's file name, which may differ from the one uploaded.
32
+ // - Ordering: search results are ordered by `match`, highest first: a number from 0 to 1 (the server rounds it to two decimals), the probability
33
+ // that the item is good enough to reuse for the query. A server finds candidates by meaning and may judge them with a model, so `match` is not
34
+ // a share of words; the order of results with the same `match` is not specified. The public listing is newest first; with a query it is best
35
+ // match first, equal matches newest first. Paging continues the same order, with no item twice. `page` is from 1 to 10000.
36
+ // - A server may add tags (and a description when the uploader gave none) to an item that is shared, after upload or generation; the tags the
37
+ // uploader gave stay in it, first.
38
+ // - Upload URLs and file URLs must be unguessable (random or signed; never derived from an item id or a counter) and scoped to one object:
39
+ // a file URL serves one file, an upload URL accepts uploads for one object only.
40
+ // - The public listing's `previewUrl` is a preview that needs no login. An item with a preview of its own gets that one; otherwise an `image`
41
+ // or an `sfx` gets a URL of its own file (a picture and a short sound need no login to look at or hear), and every other kind (music,
42
+ // overlay, clip, component), whose full file is long media or code, gets no `previewUrl`.
43
+ // - Every returned URL needs no headers and stays valid for at least five minutes.
44
+ // - The generation routes (`voiceover`, `images`) are synchronous: they return the URL of the finished file.
45
+ // - `durationSec` is the decoded audio length; `words` are in seconds from the start of that audio.
46
+ // - Search returns published items only, and leaves out items that are not matches at all; the default `limit` is 8.
47
+ import { z } from "zod";
48
+ import { AspectSchema, WordTimingSchema } from "../pipeline/schema";
49
+
50
+ // Text that is safe to store and to pass to a provider: at most `max` characters, no control characters (C0 except tab, newline and
51
+ // carriage return, DEL)
52
+ // and no lone surrogates.
53
+ const CONTROL = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/;
54
+ const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
55
+ export const isPlainText = (s: string): boolean => !CONTROL.test(s) && !LONE_SURROGATE.test(s);
56
+ export const text = (max: number) => z.string().max(max).refine(isPlainText, "must be plain text without control characters");
57
+
58
+ // A library item id: letters and digits first, then letters, digits, dot, underscore and dash; at most 128 characters.
59
+ const ItemId = z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/);
60
+
61
+ const MAX_META_BYTES = 4000;
62
+ const plainValues = (v: unknown, depth = 0): boolean => {
63
+ if (typeof v === "string") return isPlainText(v);
64
+ if (depth > 20) return false;
65
+ if (Array.isArray(v)) return v.every((x) => plainValues(x, depth + 1));
66
+ if (v && typeof v === "object") return Object.entries(v).every(([k, x]) => isPlainText(k) && plainValues(x, depth + 1));
67
+ return true;
68
+ };
69
+ const Meta = z.record(z.string(), z.unknown()).refine((m) => new TextEncoder().encode(JSON.stringify(m)).length <= MAX_META_BYTES, `must be at most ${MAX_META_BYTES} bytes`)
70
+ .refine((m) => plainValues(m), "must not contain control characters");
71
+
72
+ // The most a single upload may be: 200 MB.
73
+ export const MAX_UPLOAD_BYTES = 209_715_200;
74
+
75
+ export const ErrorCodeSchema = z.enum(["unauthenticated", "quota_exceeded", "not_found", "invalid_request", "server_error"]);
76
+ export type ErrorCode = z.infer<typeof ErrorCodeSchema>;
77
+ // The code is any string here: a code this version does not know still carries a message worth showing (the client maps it to server_error).
78
+ export const ApiErrorSchema = z.object({ error: z.object({ code: z.string(), message: z.string() }) });
79
+
80
+ export const LibraryKindSchema = z.enum(["image", "overlay", "sfx", "music", "component", "clip"]);
81
+ export type LibraryKind = z.infer<typeof LibraryKindSchema>;
82
+ // What a user may upload: everything except components, which are code and come only from Reelkit.
83
+ export const UploadKindSchema = LibraryKindSchema.exclude(["component"]);
84
+ export type UploadKind = z.infer<typeof UploadKindSchema>;
85
+
86
+ export const LibraryItemSchema = z.object({
87
+ id: z.string(),
88
+ kind: LibraryKindSchema,
89
+ title: z.string(),
90
+ description: z.string(),
91
+ tags: z.array(z.string()),
92
+ // width, height, durationSec, aspect, props, source, licence: whatever applies to the kind.
93
+ meta: z.record(z.string(), z.unknown()),
94
+ visibility: z.enum(["private", "review", "published"]),
95
+ });
96
+ export type LibraryItem = z.infer<typeof LibraryItemSchema>;
97
+
98
+ export const VoiceSchema = z.object({
99
+ id: z.string(), name: z.string(), description: z.string(),
100
+ gender: z.string().optional(), accent: z.string().optional(), language: z.string().optional(), useCase: z.string().optional(),
101
+ kind: z.enum(["stock", "cloned", "designed"]),
102
+ });
103
+ export type Voice = z.infer<typeof VoiceSchema>;
104
+
105
+ const Meter = z.object({ used: z.number(), limit: z.number() });
106
+ export const MeSchema = z.object({
107
+ userId: z.string(), handle: z.string(),
108
+ quota: z.object({ voiceoverChars: Meter, images: Meter, resetsAt: z.string() }),
109
+ contributions: z.number(),
110
+ });
111
+
112
+ const Empty = z.object({});
113
+ const route = <Q extends z.ZodType, S extends z.ZodType>(method: "GET" | "POST", path: string, auth: boolean, req: Q, res: S) => ({ method, path, auth, req, res });
114
+
115
+ export const routes = {
116
+ deviceStart: route("POST", "/auth/device", false,
117
+ z.object({ label: text(80) }),
118
+ z.object({ deviceCode: z.string(), userCode: z.string(), verificationUrl: z.string(), intervalSec: z.number().min(1), expiresInSec: z.number().positive() })),
119
+ deviceToken: route("POST", "/auth/token", false,
120
+ z.object({ deviceCode: text(200).min(1) }),
121
+ z.discriminatedUnion("status", [
122
+ z.object({ status: z.literal("pending") }),
123
+ z.object({ status: z.literal("expired") }),
124
+ z.object({ status: z.literal("approved"), token: z.string() }),
125
+ ])),
126
+ revoke: route("POST", "/auth/revoke", true, Empty, z.object({ revoked: z.boolean() })),
127
+ me: route("GET", "/me", true, Empty, MeSchema),
128
+ librarySearch: route("GET", "/library/search", true,
129
+ z.object({ q: text(200).min(1), kind: LibraryKindSchema.optional(), limit: z.coerce.number().int().min(1).max(50).optional() }),
130
+ z.object({ items: z.array(LibraryItemSchema.extend({ match: z.number().min(0).max(1) })) })),
131
+ libraryPull: route("POST", "/library/pull", true,
132
+ z.object({ id: ItemId }),
133
+ z.object({ item: LibraryItemSchema, url: z.string(), filename: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/) })),
134
+ libraryUpload: route("POST", "/library/upload", true,
135
+ z.object({
136
+ kind: UploadKindSchema, title: text(200).min(1), description: text(2000), tags: z.array(text(40)).max(20),
137
+ meta: Meta, filename: text(200).min(1), contentType: text(100), bytes: z.number().int().positive().max(MAX_UPLOAD_BYTES),
138
+ shareable: z.boolean(),
139
+ }),
140
+ z.object({ id: z.string(), uploadUrl: z.string() })),
141
+ libraryCommit: route("POST", "/library/upload/commit", true, z.object({ id: ItemId }), z.object({ item: LibraryItemSchema })),
142
+ voices: route("GET", "/voices", true, Empty, z.object({ voices: z.array(VoiceSchema) })),
143
+ voiceover: route("POST", "/voiceover", true,
144
+ z.object({ text: text(5000).refine((t) => /\S/.test(t), "must contain a non-space character"), voiceId: z.string().regex(/^[A-Za-z0-9]{1,64}$/).optional(), speed: z.number().min(0.7).max(1.3).optional() }),
145
+ z.object({ url: z.string(), ext: z.enum(["mp3", "wav", "m4a"]), contentType: z.string(), durationSec: z.number().positive(), words: z.array(WordTimingSchema), chars: z.number() })),
146
+ images: route("POST", "/images", true,
147
+ z.object({ prompt: text(2000).min(1), aspect: AspectSchema, shareable: z.boolean(), tags: z.array(text(40)).max(12) }),
148
+ // libraryId is set when the image was also added to the shared library.
149
+ z.object({ url: z.string(), ext: z.enum(["png", "jpg", "jpeg", "webp"]), contentType: z.string(), libraryId: z.string().optional() })),
150
+ publicLibrary: route("GET", "/public/library", false,
151
+ z.object({ q: text(200).optional(), kind: LibraryKindSchema.optional(), page: z.coerce.number().int().min(1).max(10000).optional() }),
152
+ // With `q` the items are the best matches by meaning, best first, each with `match` (0..1), in one page (`hasMore` false). Under heavy
153
+ // use, or when ranking fails, the server answers the plain text listing instead (no `match`), so a client must accept both.
154
+ z.object({ items: z.array(LibraryItemSchema.extend({ previewUrl: z.string().optional(), match: z.number().min(0).max(1).optional() })), page: z.number(), hasMore: z.boolean() })),
155
+ };
156
+ export type Routes = typeof routes;
157
+ export type RouteName = keyof Routes;
@@ -0,0 +1,55 @@
1
+ import { chmodSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ type Env = Record<string, string | undefined>;
6
+ export const DEFAULT_API_URL = "https://reelkit-kohl.vercel.app/api/v1";
7
+
8
+ export const configDir = (env: Env) => env.REELKIT_CONFIG_DIR || join(homedir(), ".config", "reelkit");
9
+ export const credentialsPath = (env: Env) => join(configDir(env), "credentials.json");
10
+
11
+ export function loadCredentials(env: Env): { token: string } | undefined {
12
+ // A file that cannot be read or parsed counts as not logged in, so logging in again can replace it.
13
+ try {
14
+ const parsed = JSON.parse(readFileSync(credentialsPath(env), "utf8")) as { token?: unknown } | null;
15
+ return typeof parsed?.token === "string" && parsed.token ? { token: parsed.token } : undefined;
16
+ } catch {
17
+ return undefined;
18
+ }
19
+ }
20
+
21
+ export function saveCredentials(env: Env, creds: { token: string }): void {
22
+ mkdirSync(configDir(env), { recursive: true, mode: 0o700 });
23
+ writeFileSync(credentialsPath(env), JSON.stringify(creds, null, 2), { mode: 0o600 });
24
+ // writeFileSync only applies the mode when it creates the file.
25
+ chmodSync(credentialsPath(env), 0o600);
26
+ }
27
+
28
+ export function clearCredentials(env: Env): void {
29
+ rmSync(credentialsPath(env), { force: true });
30
+ }
31
+
32
+ // A login started with `auth login --start` and not yet finished.
33
+ export type PendingLogin = { deviceCode: string; userCode: string; verificationUrl: string; intervalSec: number; expiresAt: number };
34
+ export const pendingLoginPath = (env: Env) => join(configDir(env), "pending-login.json");
35
+
36
+ export function loadPendingLogin(env: Env): PendingLogin | undefined {
37
+ try {
38
+ const p = JSON.parse(readFileSync(pendingLoginPath(env), "utf8")) as Partial<PendingLogin>;
39
+ if (!p.deviceCode || typeof p.expiresAt !== "number") return undefined;
40
+ // A file from before the interval was kept, or a damaged one, polls once a second.
41
+ return { ...(p as PendingLogin), intervalSec: typeof p.intervalSec === "number" && Number.isFinite(p.intervalSec) ? p.intervalSec : 1 };
42
+ } catch {
43
+ return undefined;
44
+ }
45
+ }
46
+
47
+ export function savePendingLogin(env: Env, pending: PendingLogin): void {
48
+ mkdirSync(configDir(env), { recursive: true, mode: 0o700 });
49
+ writeFileSync(pendingLoginPath(env), JSON.stringify(pending, null, 2), { mode: 0o600 });
50
+ chmodSync(pendingLoginPath(env), 0o600);
51
+ }
52
+
53
+ export function clearPendingLogin(env: Env): void {
54
+ rmSync(pendingLoginPath(env), { force: true });
55
+ }
package/src/open.ts ADDED
@@ -0,0 +1,48 @@
1
+ import { spawn as nodeSpawn } from "node:child_process";
2
+
3
+ // The part of child_process.spawn this module uses, so a test can pass a recorder and never start a process.
4
+ export type SpawnFn = (cmd: string, args: string[], opts: { detached: true; stdio: "ignore"; windowsHide?: true }) => { unref(): void; on(event: "error", listener: (e: Error) => void): unknown };
5
+
6
+ // What starts the default browser on each platform. The URL is one argument, never part of a command string, and never goes through a shell.
7
+ // Windows uses rundll32 rather than `cmd /c start`: cmd expands %NAME% in its command line, which would put the person's environment
8
+ // variables into an address the server chose.
9
+ export function openCommand(url: string, platform: NodeJS.Platform): { cmd: string; args: string[] } {
10
+ if (platform === "darwin") return { cmd: "open", args: [url] };
11
+ if (platform === "win32") return { cmd: "rundll32", args: ["url.dll,FileProtocolHandler", url] };
12
+ return { cmd: "xdg-open", args: [url] };
13
+ }
14
+
15
+ const isLoopback = (host: string) => host === "localhost" || host === "127.0.0.1" || host === "[::1]" || host.endsWith(".localhost");
16
+
17
+ // The normalised address if it is safe to hand to a browser launcher, otherwise null. The address may come from a server, so: https (http
18
+ // only for this machine), no credentials, no control characters, and no `%` that is not a valid percent-encoding.
19
+ export function openableUrl(url: string): string | null {
20
+ // eslint-disable-next-line no-control-regex
21
+ const control = /[\u0000-\u001f\u007f]/;
22
+ if (control.test(url)) return null;
23
+ let u: URL;
24
+ try { u = new URL(url); } catch { return null; }
25
+ if (u.protocol !== "http:" && u.protocol !== "https:") return null;
26
+ if (u.username || u.password) return null;
27
+ if (u.protocol === "http:" && !isLoopback(u.hostname)) return null;
28
+ if (control.test(u.href) || /%(?![0-9a-fA-F]{2})/.test(u.href)) return null;
29
+ return u.href;
30
+ }
31
+
32
+ // Opens a web page in the person's browser. Resolves true if it tried to open it and false if it refused (the address is not one that is
33
+ // safe to open, see openableUrl). A refusal is not an error, and neither is a missing command (a server, a container, no desktop): the
34
+ // caller has already printed the address, so the person can open it themselves.
35
+ export async function openInBrowser(url: string, opts: { platform?: NodeJS.Platform; spawn?: SpawnFn } = {}): Promise<boolean> {
36
+ const href = openableUrl(url);
37
+ if (href === null) return false;
38
+ const platform = opts.platform ?? process.platform;
39
+ const { cmd, args } = openCommand(href, platform);
40
+ const spawn = opts.spawn ?? (nodeSpawn as unknown as SpawnFn);
41
+ try {
42
+ const child = spawn(cmd, args, platform === "win32" ? { detached: true, stdio: "ignore", windowsHide: true } : { detached: true, stdio: "ignore" });
43
+ // A missing command is reported later as an error event; handled, it is not a crash.
44
+ child.on("error", () => undefined);
45
+ child.unref();
46
+ } catch { /* the same: nothing to open it with */ }
47
+ return true;
48
+ }
@@ -0,0 +1,30 @@
1
+ import { WORDS_PER_SEC, type AssetRecord, type ScenePlan } from "./schema";
2
+
3
+ const words = (s: string) => s.trim().split(/\s+/).filter(Boolean).length;
4
+
5
+ // Soft quality checks on a plan that already passes the hard rules in validatePlan.
6
+ // These are things worth improving in the script; they never fail a video on their own.
7
+ export function reviewPlan(plan: ScenePlan, ctx: { footage?: AssetRecord }): string[] {
8
+ const issues: string[] = [];
9
+ const total = plan.scenes.reduce((n, s) => n + words(s.narration), 0);
10
+ const seconds = total / WORDS_PER_SEC;
11
+
12
+ if (!ctx.footage) {
13
+ if (seconds < 20) issues.push(`The script runs about ${seconds.toFixed(0)}s (${total} words). Aim for 30 to 60 seconds.`);
14
+ if (seconds > 70) issues.push(`The script runs about ${seconds.toFixed(0)}s (${total} words). Cut it to 60 seconds or less.`);
15
+ const illustrations = plan.scenes.filter((s) => s.treatment === "illustration").length;
16
+ if (illustrations > Math.ceil(plan.scenes.length / 2))
17
+ issues.push(`${illustrations} of ${plan.scenes.length} scenes are illustrations. Use illustrations for at most half the scenes.`);
18
+ }
19
+
20
+ if (words(plan.scenes[0].narration) > 30) issues.push(`The hook (scene ${plan.scenes[0].id}) is ${words(plan.scenes[0].narration)} words. Keep the first scene under 30 words.`);
21
+
22
+ for (const s of plan.scenes) {
23
+ const n = words(s.narration);
24
+ if (!ctx.footage && n < 5) issues.push(`Scene ${s.id} has only ${n} words of narration; give it one full sentence.`);
25
+ if (n > 45) issues.push(`Scene ${s.id} has ${n} words of narration; split it or cut it to under 45.`);
26
+ if (s.onScreenText.length > 3) issues.push(`Scene ${s.id} has ${s.onScreenText.length} on-screen text items; use at most 3.`);
27
+ for (const t of s.onScreenText) if (words(t) > 7) issues.push(`Scene ${s.id}: on-screen text "${t}" is too long; keep each item to 7 words or fewer.`);
28
+ }
29
+ return issues;
30
+ }
@@ -0,0 +1,110 @@
1
+ import { z } from "zod";
2
+
3
+ export const WORDS_PER_SEC = 2.5;
4
+
5
+ export const AspectSchema = z.enum(["9:16", "16:9", "1:1"]);
6
+ export type Aspect = z.infer<typeof AspectSchema>;
7
+
8
+ export const SceneSchema = z.object({
9
+ id: z.string().regex(/^[a-z0-9-]+$/).describe("short unique id, lowercase letters, digits, dashes"),
10
+ narration: z.string().min(1).describe("the words spoken in this scene"),
11
+ treatment: z.enum(["motion-graphic", "illustration", "footage-overlay"]),
12
+ onScreenText: z.array(z.string()),
13
+ imagePrompt: z.string().nullable().describe("required when treatment is illustration, otherwise null"),
14
+ shareable: z.boolean().describe("true only when imagePrompt is fully generic: no brand, product, person or detail specific to this user. Such images are saved to a library shared by all users."),
15
+ imageTags: z.array(z.string()).describe("3 to 6 short tags describing the illustration; empty when imagePrompt is null"),
16
+ userAssetIds: z.array(z.string()),
17
+ notes: z.string().describe("visual direction for whoever writes the composition"),
18
+ });
19
+ export type Scene = z.infer<typeof SceneSchema>;
20
+
21
+ export const ScenePlanSchema = z.object({
22
+ title: z.string(),
23
+ aspect: AspectSchema,
24
+ mode: z.enum(["motion", "footage"]),
25
+ // Optional only so plans stored before voices existed still load; a new plan must choose one.
26
+ voiceId: z.string().optional().describe("id of the narration voice, one of the ids from `reelkit assets voices`, chosen to suit the idea, audience and language"),
27
+ pace: z.enum(["slow", "normal", "fast"]).optional().describe("speaking pace: slow for calm or emotional, normal by default, fast for high-energy"),
28
+ scenes: z.array(SceneSchema).min(3).max(8),
29
+ });
30
+
31
+ export const PACE_SPEED = { slow: 0.92, normal: 1, fast: 1.1 } as const;
32
+ export type ScenePlan = z.infer<typeof ScenePlanSchema>;
33
+
34
+ export const AssetRecordSchema = z.object({
35
+ id: z.string(),
36
+ filename: z.string(),
37
+ key: z.string(),
38
+ kind: z.enum(["video", "image", "audio"]),
39
+ contentType: z.string(),
40
+ bytes: z.number(),
41
+ width: z.number().optional(),
42
+ height: z.number().optional(),
43
+ durationSec: z.number().optional(),
44
+ // What the file shows, as given at upload, so the plan can place it where it is relevant.
45
+ description: z.string().optional(),
46
+ createdAt: z.string(),
47
+ });
48
+ export type AssetRecord = z.infer<typeof AssetRecordSchema>;
49
+
50
+ export const WordTimingSchema = z.object({ word: z.string(), startSec: z.number(), endSec: z.number() });
51
+ export type WordTiming = z.infer<typeof WordTimingSchema>;
52
+
53
+ export const ManifestSceneSchema = z.object({
54
+ id: z.string(),
55
+ startFrame: z.number(),
56
+ durationFrames: z.number(),
57
+ voiceoverKey: z.string().optional(),
58
+ words: z.array(WordTimingSchema),
59
+ imageKey: z.string().optional(),
60
+ userAssetKeys: z.array(z.string()),
61
+ });
62
+ export type ManifestScene = z.infer<typeof ManifestSceneSchema>;
63
+
64
+ export const AssetManifestSchema = z.object({
65
+ fps: z.number(),
66
+ width: z.number(),
67
+ height: z.number(),
68
+ totalFrames: z.number(),
69
+ footageKey: z.string().optional(),
70
+ scenes: z.array(ManifestSceneSchema),
71
+ });
72
+ export type AssetManifest = z.infer<typeof AssetManifestSchema>;
73
+
74
+ const wordCount = (s: string) => s.trim().split(/\s+/).filter(Boolean).length;
75
+
76
+ export function validatePlan(
77
+ plan: ScenePlan,
78
+ ctx: { aspect: Aspect; footage?: AssetRecord; assets: AssetRecord[]; voiceIds?: string[] },
79
+ ): string[] {
80
+ const errors: string[] = [];
81
+ if (ctx.voiceIds) {
82
+ if (!plan.voiceId) errors.push("Choose a narration voice: set voiceId to one of the ids from `reelkit assets voices`.");
83
+ else if (!ctx.voiceIds.includes(plan.voiceId)) errors.push(`voiceId ${plan.voiceId} is not one of the available voices.`);
84
+ }
85
+ const ids = plan.scenes.map((s) => s.id);
86
+ if (new Set(ids).size !== ids.length) errors.push("Duplicate scene ids.");
87
+
88
+ if (ctx.footage) {
89
+ if (plan.mode !== "footage") errors.push('mode must be "footage" because footage was provided.');
90
+ if (plan.scenes.some((s) => s.treatment !== "footage-overlay"))
91
+ errors.push('Every scene must use treatment "footage-overlay" in footage mode.');
92
+ const budget = Math.floor((ctx.footage.durationSec ?? 0) * WORDS_PER_SEC);
93
+ const total = plan.scenes.reduce((n, s) => n + wordCount(s.narration), 0);
94
+ if (total > budget) errors.push(`Narration is ${total} words; the footage allows at most ${budget} words.`);
95
+ } else {
96
+ if (plan.mode !== "motion") errors.push('mode must be "motion" because no footage was provided.');
97
+ if (plan.scenes.some((s) => s.treatment === "footage-overlay"))
98
+ errors.push('treatment "footage-overlay" is not allowed without footage.');
99
+ if (plan.aspect !== ctx.aspect) errors.push(`aspect must be ${ctx.aspect}.`);
100
+ }
101
+
102
+ const allowed = new Set(ctx.assets.map((a) => a.id));
103
+ for (const s of plan.scenes) {
104
+ if (s.treatment === "illustration" && !s.imagePrompt)
105
+ errors.push(`Scene ${s.id}: imagePrompt is required for an illustration.`);
106
+ for (const id of s.userAssetIds)
107
+ if (!allowed.has(id)) errors.push(`Scene ${s.id}: unknown user asset ${id}.`);
108
+ }
109
+ return errors;
110
+ }
@@ -0,0 +1,30 @@
1
+ import type { Aspect, AssetRecord } from "./schema";
2
+
3
+ export const FPS = 30;
4
+ export const SCENE_PADDING_SEC = 0.4;
5
+
6
+ export const secondsToFrames = (sec: number, fps = FPS) => Math.ceil(sec * fps - 1e-9);
7
+
8
+ // padSec is the breathing room after each scene's narration.
9
+ export function layoutScenes(durationsSec: number[], fps = FPS, padSec = SCENE_PADDING_SEC) {
10
+ let cursor = 0;
11
+ const scenes = durationsSec.map((d) => {
12
+ const durationFrames = secondsToFrames(d + padSec, fps);
13
+ const scene = { startFrame: cursor, durationFrames };
14
+ cursor += durationFrames;
15
+ return scene;
16
+ });
17
+ return { scenes, totalFrames: cursor };
18
+ }
19
+
20
+ const even = (n: number) => Math.max(2, Math.floor(n / 2) * 2);
21
+
22
+ export function dimensionsFor(aspect: Aspect, footage?: AssetRecord) {
23
+ if (footage?.width && footage?.height) {
24
+ const scale = Math.min(1, 1920 / Math.max(footage.width, footage.height));
25
+ return { width: even(footage.width * scale), height: even(footage.height * scale) };
26
+ }
27
+ if (aspect === "16:9") return { width: 1920, height: 1080 };
28
+ if (aspect === "1:1") return { width: 1080, height: 1080 };
29
+ return { width: 1080, height: 1920 };
30
+ }
@@ -0,0 +1,71 @@
1
+ import { PACE_SPEED, type AssetManifest, type ScenePlan, type WordTiming } from "../pipeline/schema";
2
+ import { dimensionsFor, FPS, layoutScenes } from "../pipeline/timing";
3
+ import { FILES, type Project } from "./project";
4
+
5
+ // What a scene was recorded from is stored with it, so a changed script or voice is noticed.
6
+ export type Voiceover = { key: string; durationSec: number; words: WordTiming[]; text?: string; voiceId?: string; speed?: number };
7
+
8
+ // True when the stored recording no longer matches what the plan asks for. An entry from before this was tracked counts as different.
9
+ export function voiceoverStale(vo: Voiceover, scene: ScenePlan["scenes"][number], plan: ScenePlan): boolean {
10
+ return vo.text !== scene.narration || vo.voiceId !== plan.voiceId || vo.speed !== PACE_SPEED[plan.pace ?? "normal"];
11
+ }
12
+
13
+ // Lays the scenes out on the timeline from the real voiceover lengths. Paths are relative to the project folder.
14
+ // Returns undefined, and writes nothing, until every scene has its voiceover.
15
+ export function buildManifest(project: Project, plan: ScenePlan): AssetManifest | undefined {
16
+ const voiceovers = project.readJsonOr<Record<string, Voiceover>>(FILES.voiceovers, {});
17
+ if (plan.scenes.some((s) => !voiceovers[s.id])) return undefined;
18
+ const images = project.readJsonOr<Record<string, string>>(FILES.images, {});
19
+ const footage = project.footage();
20
+ const byId = new Map(project.assets().map((a) => [a.id, a]));
21
+
22
+ const layout = layoutScenes(plan.scenes.map((s) => voiceovers[s.id].durationSec));
23
+ let totalFrames = layout.totalFrames;
24
+ if (footage) {
25
+ const footageFrames = Math.floor((footage.durationSec ?? 0) * FPS);
26
+ if (layout.totalFrames > footageFrames) {
27
+ throw new Error(`The voiceover (${(layout.totalFrames / FPS).toFixed(1)}s) is longer than the footage (${(footageFrames / FPS).toFixed(1)}s). Use a longer clip or shorten the script.`);
28
+ }
29
+ totalFrames = footageFrames;
30
+ }
31
+
32
+ const manifest: AssetManifest = {
33
+ fps: FPS,
34
+ ...dimensionsFor(plan.aspect, footage),
35
+ totalFrames,
36
+ ...(footage ? { footageKey: footage.key } : {}),
37
+ scenes: plan.scenes.map((scene, i) => ({
38
+ id: scene.id,
39
+ startFrame: layout.scenes[i].startFrame,
40
+ durationFrames: layout.scenes[i].durationFrames,
41
+ voiceoverKey: voiceovers[scene.id].key,
42
+ words: voiceovers[scene.id].words,
43
+ ...(scene.treatment === "illustration" && images[scene.id] ? { imageKey: images[scene.id] } : {}),
44
+ userAssetKeys: scene.userAssetIds.map((id) => {
45
+ const a = byId.get(id);
46
+ if (!a) throw new Error(`Scene ${scene.id} references unknown asset ${id}. Add it with \`reelkit assets upload\`.`);
47
+ return a.key;
48
+ }),
49
+ })),
50
+ };
51
+ project.writeJson(FILES.manifest, manifest);
52
+ return manifest;
53
+ }
54
+
55
+ // The gate before building: everything the plan asks for must be on disk.
56
+ export function missingAssets(project: Project, plan: ScenePlan): string[] {
57
+ const voiceovers = project.readJsonOr<Record<string, Voiceover>>(FILES.voiceovers, {});
58
+ const images = project.readJsonOr<Record<string, string>>(FILES.images, {});
59
+ const problems: string[] = [];
60
+ for (const s of plan.scenes) {
61
+ if (!voiceovers[s.id]) problems.push(`Scene ${s.id} has no voiceover.`);
62
+ else if (voiceoverStale(voiceovers[s.id], s, plan)) problems.push(`Scene ${s.id}'s voiceover is out of date. Run \`reelkit assets voiceover --all\`.`);
63
+ else if (!project.exists(voiceovers[s.id].key)) problems.push(`Missing file: ${voiceovers[s.id].key}`);
64
+ // Only an illustration scene uses an image; one left over from an earlier plan is not needed.
65
+ if (s.treatment === "illustration") {
66
+ if (!images[s.id]) problems.push(`Scene ${s.id} has no image.`);
67
+ else if (!project.exists(images[s.id])) problems.push(`Missing file: ${images[s.id]}`);
68
+ }
69
+ }
70
+ return problems;
71
+ }
@@ -0,0 +1,54 @@
1
+ import { execFile } from "node:child_process";
2
+ import { stat } from "node:fs/promises";
3
+ import { basename, extname } from "node:path";
4
+ import { promisify } from "node:util";
5
+
6
+ const run = promisify(execFile);
7
+
8
+ const IMAGE_EXT: Record<string, string> = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp", ".gif": "image/gif", ".svg": "image/svg+xml" };
9
+ const VIDEO_EXT: Record<string, string> = { ".mp4": "video/mp4", ".mov": "video/quicktime", ".webm": "video/webm" };
10
+ const AUDIO_EXT: Record<string, string> = { ".mp3": "audio/mpeg", ".wav": "audio/wav", ".m4a": "audio/mp4" };
11
+
12
+ // What an audio-only file with a video extension is called when it is uploaded.
13
+ const AUDIO_IN_VIDEO: Record<string, string> = { ".mp4": "audio/mp4", ".webm": "audio/webm", ".mov": "audio/quicktime" };
14
+
15
+ type Probed = { kind: "image" | "video" | "audio"; contentType: string; bytes: number; width?: number; height?: number; durationSec?: number };
16
+
17
+ // The kind comes from the file extension, from an allow-list. ffprobe only reads the dimensions and the length.
18
+ // Every failure is a one-line Error written for the person who gave the file.
19
+ export async function probeFile(path: string): Promise<Probed> {
20
+ const name = basename(path);
21
+ const ext = extname(path).toLowerCase();
22
+ const kind = IMAGE_EXT[ext] ? "image" : VIDEO_EXT[ext] ? "video" : AUDIO_EXT[ext] ? "audio" : undefined;
23
+ if (!kind) throw new Error(`${name} is not a supported file type. Use an image (png, jpg, webp, gif, svg), a video (mp4, mov, webm) or audio (mp3, wav, m4a).`);
24
+ const contentType = IMAGE_EXT[ext] ?? VIDEO_EXT[ext] ?? AUDIO_EXT[ext];
25
+ const bytes = await stat(path).then((s) => s.size, () => { throw new Error(`${name} does not exist. Check the path.`); });
26
+ if (ext === ".svg") return { kind, contentType, bytes };
27
+
28
+ const unreadable = new Error(`${name} could not be read. It may be damaged. Try exporting it again.`);
29
+ let info: { streams?: { codec_type?: string; width?: number; height?: number }[]; format?: { duration?: string } };
30
+ try {
31
+ const { stdout } = await run("ffprobe", ["-v", "error", "-print_format", "json", "-show_format", "-show_streams", path]);
32
+ info = JSON.parse(stdout);
33
+ } catch (e) {
34
+ if ((e as NodeJS.ErrnoException)?.code === "ENOENT") throw new Error("ffprobe was not found. Install ffmpeg (macOS: `brew install ffmpeg`) and try again.");
35
+ throw unreadable;
36
+ }
37
+ const v = info.streams?.find((s) => s.codec_type === "video");
38
+ const a = info.streams?.find((s) => s.codec_type === "audio");
39
+ const duration = info.format?.duration ? Number(info.format.duration) : undefined;
40
+ // ffprobe guesses from the extension and can "succeed" on junk with a 0x0 picture.
41
+ const sized = v && (v.width ?? 0) > 0 && (v.height ?? 0) > 0;
42
+ if (kind === "image") {
43
+ if (!sized) throw unreadable;
44
+ return { kind, contentType, bytes, width: v!.width, height: v!.height };
45
+ }
46
+ if (kind === "video") {
47
+ // A recording with sound and no picture is audio, not a damaged video.
48
+ if (!sized && a) return { kind: "audio", contentType: AUDIO_IN_VIDEO[ext], bytes, durationSec: duration };
49
+ if (!sized) throw unreadable;
50
+ return { kind, contentType, bytes, width: v!.width, height: v!.height, durationSec: duration };
51
+ }
52
+ if (!a) throw unreadable;
53
+ return { kind, contentType, bytes, durationSec: duration };
54
+ }
@@ -0,0 +1,58 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname, join, resolve, sep } from "node:path";
3
+ import { z } from "zod";
4
+ import { AspectSchema, type AssetRecord } from "../pipeline/schema";
5
+
6
+ export const FILES = {
7
+ config: "reelkit.json", plan: "plan.json", manifest: "manifest.json",
8
+ assetIndex: "assets/index.json", voiceovers: "assets/voiceovers.json", images: "assets/images.json", library: "assets/library.json",
9
+ } as const;
10
+
11
+ // "path: message", or just the message for a problem at the root of the file.
12
+ export const issueLines = (e: { issues: { path: PropertyKey[]; message: string }[] }) =>
13
+ e.issues.map((i) => (i.path.length ? `${i.path.map(String).join(".")}: ${i.message}` : i.message));
14
+
15
+ export const ProjectConfigSchema = z.object({ aspect: AspectSchema, name: z.string().optional(), footage: z.string().optional() });
16
+ export type ProjectConfig = z.infer<typeof ProjectConfigSchema>;
17
+
18
+ // One video's working folder. Every command reads and writes its state here.
19
+ export class Project {
20
+ constructor(readonly dir: string) {}
21
+ // The one place a project-relative path becomes a real path, so nothing can reach outside the folder.
22
+ path(rel: string) {
23
+ const root = resolve(this.dir);
24
+ const full = resolve(root, rel);
25
+ if (full !== root && !full.startsWith(root + sep)) throw new Error(`"${rel}" is outside the project folder. Reelkit only reads and writes inside the project.`);
26
+ return join(this.dir, rel);
27
+ }
28
+ exists(rel: string) { return existsSync(this.path(rel)); }
29
+ readJson<T>(rel: string): T {
30
+ const text = readFileSync(this.path(rel), "utf8");
31
+ try {
32
+ return JSON.parse(text) as T;
33
+ } catch (e) {
34
+ throw new Error(`${rel} is not valid JSON: ${e instanceof Error ? e.message : String(e)}. Fix the file and run the command again.`);
35
+ }
36
+ }
37
+ readJsonOr<T>(rel: string, fallback: T): T { return this.exists(rel) ? this.readJson<T>(rel) : fallback; }
38
+ writeJson(rel: string, value: unknown) {
39
+ mkdirSync(dirname(this.path(rel)), { recursive: true });
40
+ writeFileSync(this.path(rel), JSON.stringify(value, null, 2) + "\n");
41
+ }
42
+ config(): ProjectConfig {
43
+ const parsed = ProjectConfigSchema.safeParse(this.readJson(FILES.config));
44
+ if (!parsed.success) throw new Error(`${FILES.config} is not valid: ${issueLines(parsed.error)[0]}. Fix it or run \`reelkit init\` in a new folder.`);
45
+ return parsed.data;
46
+ }
47
+ assets(): AssetRecord[] { return this.readJsonOr<AssetRecord[]>(FILES.assetIndex, []); }
48
+ footage(): AssetRecord | undefined {
49
+ const id = this.config().footage;
50
+ return id ? this.assets().find((a) => a.id === id) : undefined;
51
+ }
52
+ }
53
+
54
+ export function openProject(cwd: string): Project {
55
+ const p = new Project(cwd);
56
+ if (!p.exists(FILES.config)) throw new Error("Not a Reelkit project. Run `reelkit init` here first.");
57
+ return p;
58
+ }