reelkit-cli 0.2.0 → 0.3.1

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 CHANGED
@@ -53,13 +53,13 @@ reelkit render
53
53
  | `reelkit whoami` | Account, quota, contributions |
54
54
  | `reelkit install` | Install the skill into your coding agents (`--agent <id>`, `--force`) |
55
55
  | `reelkit init [name]` | Set up a video project, in a new folder named after it |
56
- | `reelkit assets upload <file>` | Add one of your own files (private unless `--share`) |
56
+ | `reelkit assets upload <file>` | Add one of your own files (private unless `--share`); `--green` or `--cutout` also makes a transparent copy of a video (`--cutout` sends it to the server) |
57
57
  | `reelkit assets search "<query>"` | Search the shared library by meaning; each result shows a match percentage |
58
58
  | `reelkit assets pull <id>` | Download a library item (a component lands in `src/`) |
59
59
  | `reelkit assets voices` | List narration voices |
60
60
  | `reelkit assets voiceover` | Record narration from `plan.json` |
61
61
  | `reelkit assets gen image` | Generate a scene's illustration |
62
- | `reelkit assets gen clip` | Generate a scene's video clip, or a green-screen one keyed to a transparent video |
62
+ | `reelkit assets gen clip` | Generate a scene's video clip, or a green-screen one keyed to a transparent video, or one with the subject cut out on the server |
63
63
  | `reelkit plan check` | Validate `plan.json` |
64
64
  | `reelkit check` | Check the composition without rendering |
65
65
  | `reelkit preview` | Two test frames per scene |
@@ -71,7 +71,7 @@ Your own files, your plan and your video stay on your machine. Illustrations tha
71
71
 
72
72
  A component you pull from the library is code, and it runs on your machine when you preview or render. The library only serves components published by Reelkit.
73
73
 
74
- The CLI talks to `https://reelkit-kohl.vercel.app/api/v1` by default. Set `REELKIT_API_URL` to point it at a different API. To use a local API while developing, set `REELKIT_API_URL=http://localhost:3000/api/v1`.
74
+ The CLI talks to `https://reelkit.cc/api/v1` by default. Set `REELKIT_API_URL` to point it at a different API. To use a local API while developing, set `REELKIT_API_URL=http://localhost:3000/api/v1`.
75
75
 
76
76
  ## Development
77
77
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reelkit-cli",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "CLI and Claude skill for making short-form video with a shared asset library.",
5
5
  "license": "MIT",
6
6
  "author": "Daniel Livshin",
@@ -78,5 +78,5 @@
78
78
  "optional": true
79
79
  }
80
80
  },
81
- "homepage": "https://reelkit-kohl.vercel.app"
81
+ "homepage": "https://reelkit.cc"
82
82
  }
package/skill/SKILL.md CHANGED
@@ -23,7 +23,7 @@ Find out what the video is about and collect what the user has. Ask for nothing
23
23
 
24
24
  For each file the user gives, look at it, then register it with a description of what it shows:
25
25
  `reelkit assets upload ./logo.png --describe "Acme logo, white wordmark on blue"`
26
- Add `--footage` for a video the motion design should be laid over. User files stay on this machine.
26
+ Add `--footage` for a video the motion design should be laid over. User files stay on this machine. A video of someone or something on a plain background can be made transparent: `--green` keys a green background locally for free, and `--cutout` removes any background on the server (the video is sent to Reelkit, so ask first; see `reference/clips.md`).
27
27
 
28
28
  ### 2. Look
29
29
  Read `reference/styles.md` and agree the video's look with the user: one of its looks, or their own. If they already described what they want, match it and confirm in one sentence. Ask anything still open in one message, each question with a default. If any on-screen text will be Hebrew, read `reference/hebrew-rtl.md` now: it changes how words may enter and how lines are written.
@@ -52,6 +52,46 @@ import { Captions, KeyedClip, SceneFrame } from "reelkit/kit";
52
52
  - A green-screen scene still has a `clip` treatment and a `clipPrompt`, with the background and the type in the composition. A library clip with the same use comes keyed too: `reelkit assets pull <id> --scene <sceneId>` writes the `.webm` beside the `.mp4` when the clip is marked green screen.
53
53
  - It costs the same as any clip and is harder to get right: at most one or two green-screen clips per video.
54
54
 
55
+ ## Your own green-screen footage
56
+ If the user filmed themselves or a product in front of a green background, register it with `reelkit assets upload ./me.mp4 --green --describe "what it shows"`. The original is kept and a copy with the green made transparent, sound included, is written beside it; the command prints the `urls[...]` path of that copy. The key colour is read from the corners of the frame, so the green does not have to be perfect, but it does have to fill the corners: a video whose corners are not green is refused. For a talking presenter, leave the copy's sound on (`muted={false}`) and do not also play the original.
57
+
58
+ ## No green screen: cut the subject out
59
+ Use this when the footage or the clip is not on a green background but the subject has to be layered over something else.
60
+ - If the background is green, use `--green` instead: it is free and runs on this machine. A cutout is the fallback.
61
+ - `reelkit assets upload ./me.mp4 --cutout --describe "what it shows"` sends the video to the Reelkit server, where a GPU model separates the subject, and writes a transparent `.webm` (sound kept) beside the original; the command prints its `urls[...]` path. It takes about a minute. If it times out, the command prints the line that continues it (`--resume <id>`); that neither uploads nor charges again.
62
+ - The video leaves the user's machine, so ask the user before cutting out their footage.
63
+ - At most 20 seconds of video per cutout, counted in whole seconds against a monthly quota; `reelkit whoami` shows what is left.
64
+ - Use the result exactly like a keyed clip: `KeyedClip` with the `.webm`, and the Layers section below applies.
65
+ - Edges are best when the subject is well lit and clearly different from the background. Hair and fast motion can shimmer: check both preview frames.
66
+ - A generated clip can be cut out in one step: `reelkit assets gen clip --scene <sceneId> --cutout` makes the clip as usual, then cuts it out and records it as `s.clipKeyedKey`. If only the cutout fails, run the same command again: it does the cutout without paying for a new clip.
67
+
68
+ ## Layers
69
+ A keyed clip is what makes real depth possible: things can sit behind the subject as well as in front. Build every such scene bottom to top, in this order, and keep to it:
70
+
71
+ 1. **Background**: a mesh, a picture, the user's screenshot, a clip.
72
+ 2. **Behind the subject**: the big title, a chart, a logo. The subject will cover part of it, which is the point: a headline that a person stands in front of reads as being in the room.
73
+ 3. **The subject**: the keyed clip.
74
+ 4. **In front of the subject**: small labels, a lower third, a callout pointing at what the subject holds.
75
+ 5. **Captions**, always last.
76
+
77
+ - Text behind the subject must stay readable with part of it hidden: make it large, keep it to one to three words, and place it so the subject covers the middle or one end, never the first letters of a word.
78
+ - Give the subject a soft contact shadow or a slightly darker patch on the background where it stands, or it floats.
79
+ - Light direction should agree: if the clip is lit from the left, put the brighter side of the background on the left.
80
+ - Keep the subject's size steady across the scene. Move the layers around it, a slow push on the background and a smaller one on the subject, to get depth.
81
+ - Check both preview frames for a green fringe on the edges and for the subject hiding something that must be read.
82
+
83
+ ```tsx
84
+ import { AbsoluteFill } from "remotion";
85
+ import { BgMesh, Captions, KeyedClip, SceneFrame, fonts } from "reelkit/kit";
86
+
87
+ <SceneFrame from={s.startFrame} durationInFrames={s.durationFrames}>
88
+ <BgMesh bg="#101014" hero="#C6F135" />
89
+ <AbsoluteFill style={{ alignItems: "center", paddingTop: "18%", fontFamily: fonts.display, fontWeight: 900, fontSize: 260, color: "#ffffff" }}>LAUNCH</AbsoluteFill>
90
+ {s.clipKeyedKey ? <KeyedClip src={urls[s.clipKeyedKey]} x={0.5} y={1} scale={0.85} /> : null}
91
+ <Captions words={s.words} />
92
+ </SceneFrame>
93
+ ```
94
+
55
95
  ## Rules
56
96
  - Search before generating, and generate once per scene. Do not regenerate to chase small improvements.
57
97
  - `--share` only for a generic clip with nothing specific to this user; the user's own files stay private.
package/src/cli.ts CHANGED
@@ -67,10 +67,13 @@ program.command("whoami").description("Show your account, quota and contribution
67
67
  program.command("init [name]").description("Set up a video project (in a new folder named after it, or in this folder)").option("--aspect <aspect>", "9:16, 16:9 or 1:1").action(run(init));
68
68
 
69
69
  const assets = program.command("assets").description("Find, add and generate the files a video needs");
70
- assets.command("upload <file>").description("Add one of your own files to this project (private unless --share)")
71
- .option("--describe <text>", "what the file shows").option("--footage", "use this video as the footage to overlay")
70
+ assets.command("upload <file>").description("Add one of your own files to this project (private unless --share; with --cutout the video is sent to the Reelkit server to be processed)")
71
+ .option("--describe <text>", "what the file shows").option("--footage", "use this video as the footage to overlay").option("--green", "the video is a subject on a green background: also make a copy with the green transparent (free, on this machine)")
72
+ .option("--cutout", "remove any background: the video is sent to the Reelkit server to be processed and a copy with a transparent background comes back (up to 20 seconds, counts against a monthly quota)")
73
+ .option("--resume <id>", "with --cutout: keep waiting for a cutout already started, without uploading or paying again")
72
74
  .option("--share", "also send it to the shared library").option("--kind <kind>", "library kind when sharing").option("--tags <tags>", "comma-separated tags when sharing")
73
- .action(run(assetsUpload));
75
+ // Called with the arguments named: commander adds its own command object last, which must not be taken for the function's test hooks.
76
+ .action(run((ctx, file: string, opts: Parameters<typeof assetsUpload>[2]) => assetsUpload(ctx, file, opts)));
74
77
  assets.command("search <query>").description("Search the shared library by meaning; each result shows how well it fits").option("--kind <kind>", "image, overlay, sfx, music, component or clip").option("--limit <n>", "how many results")
75
78
  .action(run(assetsSearch));
76
79
  assets.command("pull <id>").description("Download a library item into this project").option("--scene <sceneId>", "use it as this scene's image or clip").option("--force", "replace a component file that already exists")
@@ -82,7 +85,8 @@ const gen = assets.command("gen").description("Generate an asset");
82
85
  gen.command("image [prompt]").description("Generate a scene's illustration; the prompt defaults to the plan's")
83
86
  .requiredOption("--scene <sceneId>", "the scene it is for").option("--redo", "generate a new image even if the scene has one").action(run(assetsGenImage));
84
87
  gen.command("clip [prompt]").description("Generate a scene's video clip (takes minutes); the prompt defaults to the plan's clipPrompt")
85
- .requiredOption("--scene <sceneId>", "the scene it is for").option("--green", "film it on green and key the green out into a transparent .webm").option("--seconds <n>", "5 or 10 (default 5)")
88
+ .requiredOption("--scene <sceneId>", "the scene it is for").option("--green", "film it on green and key the green out into a transparent .webm")
89
+ .option("--cutout", "make the clip normally, then cut the subject out on the server (the video is sent to the Reelkit server to be processed; counts against a monthly quota)").option("--seconds <n>", "5 or 10 (default 5)")
86
90
  .option("--share", "also add it to the shared library (only a generic clip)").option("--redo", "generate a new clip even if the scene has one").option("--resume <id>", "keep waiting for a clip already started, without paying again")
87
91
  .action(run((ctx, prompt, opts) => assetsGenClip(ctx, prompt, opts)));
88
92
 
@@ -1,9 +1,9 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { copyFileSync, mkdirSync, readFileSync } from "node:fs";
2
+ import { copyFileSync, mkdirSync, readFileSync, rmSync, statSync } from "node:fs";
3
3
  import { basename, dirname, resolve } from "node:path";
4
4
  import { ApiFailure, download, uploadTo } from "../api/client";
5
5
  import { client, type Ctx, type Result } from "../context";
6
- import { LibraryKindSchema, UploadKindSchema } from "../contract";
6
+ import { LibraryKindSchema, MAX_CUTOUT_SECONDS, MAX_UPLOAD_BYTES, UploadKindSchema, type CutoutType } from "../contract";
7
7
  import { PACE_SPEED, type AssetManifest, type AssetRecord, type ScenePlan } from "../pipeline/schema";
8
8
  import { keyGreen } from "../project/chromakey";
9
9
  import { buildManifest, voiceoverStale, type ClipRecord, type Voiceover } from "../project/manifest";
@@ -31,9 +31,80 @@ export function setSceneImage(project: Project, sceneId: string, path: string) {
31
31
  project.writeJson(FILES.images, { ...project.readJsonOr<Record<string, string>>(FILES.images, {}), [sceneId]: path });
32
32
  }
33
33
 
34
+ const CUTOUT_POLL_MS = 5_000;
35
+ const CUTOUT_PROGRESS_MS = 30_000;
36
+ const CUTOUT_TIMEOUT_MS = 10 * 60_000;
37
+ const SENT_NOTE = "The video was sent to the Reelkit server to be processed there; the server deletes its copy of it when the job ends.";
38
+
39
+ // What a test may replace for a cutout: the clock the wait is timed by.
40
+ export type CutoutDeps = { now?: () => number };
41
+
42
+ type CutoutInput = { source: string; filename: string; contentType: CutoutType; bytes: number; durationSec: number };
43
+
44
+ // A path as it is typed in a command: quoted only when it has to be.
45
+ const shellArg = (s: string) => (/^[\w./~@%+=:,-]+$/.test(s) ? s : JSON.stringify(s));
46
+
47
+ // Sends a video to the server and starts its cutout. The seconds are reserved when the job is run, so this is the point where it becomes
48
+ // paid for: nothing is charged by a failure before it, and the id it returns is the only handle on the job after it.
49
+ async function startCutout(ctx: Ctx, input: CutoutInput): Promise<string> {
50
+ const api = client(ctx);
51
+ ctx.log(`Sending ${input.filename} to the Reelkit server to cut the subject out.`);
52
+ const up = await api("cutoutStart", { filename: input.filename.slice(-200), contentType: input.contentType, bytes: input.bytes, durationSec: input.durationSec });
53
+ await uploadTo(up.uploadUrl, new Uint8Array(readFileSync(input.source)), input.contentType);
54
+ await api("cutoutRun", { id: up.id });
55
+ ctx.log(`Started cutout ${up.id}. It usually takes about a minute.`);
56
+ return up.id;
57
+ }
58
+
59
+ type CutoutOutcome = { kind: "done" } | { kind: "failed"; message: string } | { kind: "timeout" } | { kind: "lost"; message: string };
60
+
61
+ // Asks every few seconds until the job ends (at most ten minutes), then saves the result at `dest`. A job that is not found or a login that
62
+ // is wrong has nothing to resume and is thrown; any other trouble may be a hiccup on a job that is paid for, so it is an outcome.
63
+ async function pollCutout(ctx: Ctx, id: string, dest: string, deps: CutoutDeps): Promise<CutoutOutcome> {
64
+ const api = client(ctx);
65
+ const now = deps.now ?? Date.now;
66
+ const started = now();
67
+ let lastLog = started;
68
+ try {
69
+ for (;;) {
70
+ const st = await api("cutoutStatus", { id });
71
+ if (st.status === "failed") return { kind: "failed", message: st.message ?? "The server gave no reason." };
72
+ if (st.status === "done") {
73
+ await download(st.url!, dest);
74
+ return { kind: "done" };
75
+ }
76
+ const elapsed = now() - started;
77
+ if (elapsed >= CUTOUT_TIMEOUT_MS) return { kind: "timeout" };
78
+ if (now() - lastLog >= CUTOUT_PROGRESS_MS) {
79
+ lastLog = now();
80
+ ctx.log(`Still cutting out the subject (${Math.round(elapsed / 1000)}s so far)...`);
81
+ }
82
+ await ctx.sleep(CUTOUT_POLL_MS);
83
+ }
84
+ } catch (e) {
85
+ if (e instanceof ApiFailure && (e.code === "not_found" || e.code === "unauthenticated")) throw e;
86
+ return { kind: "lost", message: e instanceof Error ? e.message : String(e) };
87
+ }
88
+ }
89
+
90
+ // The one line a person reads when a video cannot be cut out, or undefined when it can.
91
+ function cutoutRefusal(file: string, probe: Awaited<ReturnType<typeof probeFile>>): string | undefined {
92
+ if (probe.kind !== "video") return `${file} is ${probe.kind}, not a video, so there is nothing to cut out.`;
93
+ if (!probe.durationSec || probe.durationSec <= 0) return `${file} has no length that could be read, so it cannot be cut out. Export it again.`;
94
+ if (probe.durationSec > MAX_CUTOUT_SECONDS) return `${file} is ${Math.ceil(probe.durationSec)} seconds long and a cutout takes at most ${MAX_CUTOUT_SECONDS}. Trim it to ${MAX_CUTOUT_SECONDS} seconds or less and run the command again.`;
95
+ if (probe.bytes > MAX_UPLOAD_BYTES) return `${file} is larger than 200 MB, which is the most a cutout takes. Trim or compress it and run the command again.`;
96
+ return undefined;
97
+ }
98
+
34
99
  // Registers one of the user's own files. It stays on this machine unless --share is given.
35
- export async function assetsUpload(ctx: Ctx, file: string, opts: { describe?: string; footage?: boolean; share?: boolean; kind?: string; tags?: string }): Promise<Result> {
100
+ export async function assetsUpload(
101
+ ctx: Ctx, file: string, opts: { describe?: string; footage?: boolean; green?: boolean; cutout?: boolean; resume?: string; share?: boolean; kind?: string; tags?: string },
102
+ deps: { key?: typeof keyGreen } & CutoutDeps = {},
103
+ ): Promise<Result> {
36
104
  const project = openProject(ctx.cwd);
105
+ if (opts.green && opts.cutout) return { ok: false, summary: "Use --green or --cutout, not both: --green keys a green background on this machine for free, --cutout removes any background on the server." };
106
+ if (opts.resume && !opts.cutout) return { ok: false, summary: "--resume goes with --cutout: it keeps waiting for a cutout that was already started." };
107
+ if (opts.resume) return resumeUploadCutout(ctx, project, file, opts.resume, deps);
37
108
  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." };
38
109
  const source = resolve(ctx.cwd, file);
39
110
  let probe: Awaited<ReturnType<typeof probeFile>>;
@@ -43,15 +114,31 @@ export async function assetsUpload(ctx: Ctx, file: string, opts: { describe?: st
43
114
  return { ok: false, summary: e instanceof Error ? e.message : String(e) };
44
115
  }
45
116
  if (opts.footage && probe.kind !== "video") return { ok: false, summary: `${file} is ${probe.kind}, not a video, so it cannot be the footage.` };
117
+ if (opts.green && probe.kind !== "video") return { ok: false, summary: `${file} is ${probe.kind}, not a video, so there is no green screen to key out.` };
118
+ const refusal = opts.cutout ? cutoutRefusal(file, probe) : undefined;
119
+ if (refusal) return { ok: false, summary: refusal };
46
120
  const kind = opts.share ? UploadKindSchema.safeParse(opts.kind) : undefined;
47
121
  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\"." };
48
122
 
49
123
  const id = `a-${randomUUID().slice(0, 8)}`;
50
124
  const filename = basename(source).replace(/[^A-Za-z0-9._-]/g, "_");
51
125
  const key = `assets/user/${id}-${filename}`;
126
+ // A cutout is made on the server and paid for when it is run, so it is started before anything is registered: if the server refuses
127
+ // (quota used up, no GPU service) or the upload fails, nothing is left behind.
128
+ const cutoutId = opts.cutout ? await startCutout(ctx, { source, filename, contentType: probe.contentType as CutoutType, bytes: probe.bytes, durationSec: probe.durationSec! }) : undefined;
52
129
  mkdirSync(dirname(project.path(key)), { recursive: true });
53
130
  copyFileSync(source, project.path(key));
54
- const record: AssetRecord = { id, filename, key, ...probe, ...(opts.describe ? { description: opts.describe } : {}), createdAt: new Date().toISOString() };
131
+ // A subject filmed on green gets a second copy with the green made transparent, sound kept. Nothing is registered if it cannot be keyed.
132
+ let keyedKey: string | undefined;
133
+ if (opts.green) {
134
+ keyedKey = key.replace(/\.[^./]+$/, "") + ".webm";
135
+ try { await (deps.key ?? keyGreen)(project.path(key), project.path(keyedKey), { keepAudio: true, requireGreen: true }); }
136
+ catch (e) {
137
+ rmSync(project.path(key), { force: true });
138
+ return { ok: false, summary: e instanceof Error ? e.message : String(e) };
139
+ }
140
+ }
141
+ const record: AssetRecord = { id, filename, key, ...probe, ...(opts.describe ? { description: opts.describe } : {}), ...(keyedKey ? { keyedKey } : {}), ...(cutoutId ? { cutoutId } : {}), createdAt: new Date().toISOString() };
55
142
  project.writeJson(FILES.assetIndex, [...project.assets(), record]);
56
143
  if (opts.footage) project.writeJson(FILES.config, { ...project.config(), footage: id });
57
144
 
@@ -67,12 +154,52 @@ export async function assetsUpload(ctx: Ctx, file: string, opts: { describe?: st
67
154
  await uploadTo(up.uploadUrl, new Uint8Array(readFileSync(source)), probe.contentType);
68
155
  libraryId = (await api("libraryCommit", { id: up.id })).item.id;
69
156
  }
157
+ if (cutoutId) {
158
+ const finished = await finishUploadCutout(ctx, project, record, file, deps);
159
+ return { ...finished, data: { ...(finished.data as object), ...(libraryId ? { libraryId } : {}) }, summary: `Added ${filename} as ${id}${opts.footage ? ", set as the footage" : ""}${libraryId ? `, shared to the library as ${libraryId} (awaiting review)` : ""}. ${finished.summary}` };
160
+ }
70
161
  return {
71
162
  ok: true, data: { ...record, ...(libraryId ? { libraryId } : {}) },
72
- 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"}.`,
163
+ 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"}.${keyedKey ? ` The green is keyed out into ${keyedKey}: place it with KeyedClip as urls["${keyedKey}"].` : ""}`,
73
164
  };
74
165
  }
75
166
 
167
+ // Waits for the cutout of a registered video and saves it beside the original. The asset stays registered whatever happens.
168
+ async function finishUploadCutout(ctx: Ctx, project: Project, record: AssetRecord, file: string, deps: CutoutDeps): Promise<Result> {
169
+ const cutoutId = record.cutoutId!;
170
+ const keyedKey = record.key.replace(/\.[^./]+$/, "") + ".webm";
171
+ const save = (r: AssetRecord) => project.writeJson(FILES.assetIndex, project.assets().map((a) => (a.id === r.id ? r : a)));
172
+ const outcome = await pollCutout(ctx, cutoutId, project.path(keyedKey), deps);
173
+ const resume = `reelkit assets upload ${shellArg(file)} --cutout --resume ${cutoutId}`;
174
+ const { cutoutId: _pending, ...settled } = record;
175
+ if (outcome.kind === "done") {
176
+ const done: AssetRecord = { ...settled, keyedKey };
177
+ save(done);
178
+ const seconds = Math.ceil(record.durationSec ?? 0);
179
+ return { ok: true, data: done, summary: `The subject is cut out into ${keyedKey}: place it with KeyedClip as urls["${keyedKey}"]. It used ${seconds} second${seconds === 1 ? "" : "s"} of your monthly cutout quota. ${SENT_NOTE}` };
180
+ }
181
+ if (outcome.kind === "failed") {
182
+ save(settled);
183
+ return { ok: false, data: { ...settled, status: "failed" }, summary: `The cutout failed: ${outcome.message} You were not charged. The video stays added without a cutout; try a different video. ${SENT_NOTE}` };
184
+ }
185
+ if (outcome.kind === "timeout") {
186
+ return { ok: false, data: { ...record, status: "pending" }, summary: `The video stays added without a cutout: the cutout (id ${cutoutId}) is not ready after ${CUTOUT_TIMEOUT_MS / 60_000} minutes. It is still being made and is not lost: run \`${resume}\` to keep waiting for it; that does not upload or charge again.` };
187
+ }
188
+ return { ok: false, data: { ...record, status: "pending" }, summary: `The video stays added without a cutout: contact was lost while it was being made (${outcome.message}) The cutout (id ${cutoutId}) is not lost: run \`${resume}\`; that does not upload or charge again.` };
189
+ }
190
+
191
+ // Picks up a cutout that was started earlier: finds the asset waiting for it and only polls.
192
+ async function resumeUploadCutout(ctx: Ctx, project: Project, file: string, id: string, deps: CutoutDeps): Promise<Result> {
193
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(id)) return { ok: false, summary: `"${id}" is not a cutout id. Copy it from the earlier message.` };
194
+ const record = project.assets().find((a) => a.cutoutId === id);
195
+ if (!record) return { ok: false, summary: `No asset in this project is waiting for cutout ${id}. Copy the id from the earlier message.` };
196
+ const name = basename(file).replace(/[^A-Za-z0-9._-]/g, "_");
197
+ if (name !== record.filename) return { ok: false, summary: `Cutout ${id} is for ${record.filename}, not ${file}. Give the file it was started with.` };
198
+ ctx.log(`Waiting for cutout ${id} of ${record.filename}.`);
199
+ const finished = await finishUploadCutout(ctx, project, record, file, deps);
200
+ return { ...finished, summary: `Resumed ${record.filename} (${record.id}). ${finished.summary}` };
201
+ }
202
+
76
203
  export async function assetsSearch(ctx: Ctx, query: string, opts: { kind?: string; limit?: string }): Promise<Result> {
77
204
  const kind = opts.kind === undefined ? undefined : LibraryKindSchema.safeParse(opts.kind);
78
205
  if (kind && !kind.success) return { ok: false, summary: `Unknown kind "${opts.kind}". Use one of: ${LibraryKindSchema.options.join(", ")}.` };
@@ -230,13 +357,14 @@ const CLIP_TIMEOUT_MS = 10 * 60_000;
230
357
  // The id is the only handle on a paid job, so every way out after the start says it and how to resume.
231
358
  export async function assetsGenClip(
232
359
  ctx: Ctx, prompt: string | undefined,
233
- opts: { scene: string; green?: boolean; seconds?: string; share?: boolean; redo?: boolean; resume?: string },
360
+ opts: { scene: string; green?: boolean; cutout?: boolean; seconds?: string; share?: boolean; redo?: boolean; resume?: string },
234
361
  deps: ClipDeps = {},
235
362
  ): Promise<Result> {
236
363
  const project = openProject(ctx.cwd);
237
364
  const plan = loadPlan(project);
238
365
  const scene = plan.scenes.find((s) => s.id === opts.scene);
239
366
  if (!scene || scene.treatment !== "clip" || !scene.clipPrompt) return { ok: false, summary: `Scene ${opts.scene} is not a clip scene, so it does not take a clip. Set its treatment to "clip" and give it a clipPrompt in plan.json.` };
367
+ if (opts.green && opts.cutout) return { ok: false, summary: "Use --green or --cutout, not both: --green asks for a green background and keys it on this machine for free, --cutout removes any background on the server." };
240
368
  const seconds = opts.seconds === undefined ? 5 : Number(opts.seconds);
241
369
  if (seconds !== 5 && seconds !== 10) return { ok: false, summary: `--seconds must be 5 or 10, not "${opts.seconds}".` };
242
370
  if (opts.resume && !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(opts.resume)) return { ok: false, summary: `"${opts.resume}" is not a clip id. Copy it from the earlier message.` };
@@ -251,6 +379,12 @@ export async function assetsGenClip(
251
379
  const have = records[scene.id];
252
380
  // A scene that already has its clip is not charged for again, whatever prompt is given; only --redo asks for a new one.
253
381
  if (have && project.exists(have.key) && !opts.redo && !opts.resume) {
382
+ if (opts.cutout) {
383
+ if (have.keyedKey && project.exists(have.keyedKey)) return { ok: true, data: { path: have.key, keyedPath: have.keyedKey, skipped: true }, summary: `Scene ${scene.id} already has a clip at ${have.key} and its cutout at ${have.keyedKey}. Use --redo to generate a new clip.` };
384
+ // The clip was paid for but its cutout was not made (or is still being made): only the cutout is done, and the clip is not charged again.
385
+ const cut = await cutoutScene(ctx, project, scene.id, have, now);
386
+ return { ...cut, data: { path: have.key, ...(cut.ok ? { keyedPath: keyedKey } : {}) }, summary: cut.ok ? `Cut out scene ${scene.id}'s clip. ${cut.summary}` : cut.summary };
387
+ }
254
388
  if (opts.green && have.greenScreen && !have.keyedKey) {
255
389
  // The clip was paid for but not keyed (ffmpeg failed or --green came later): keying is free, so do it now.
256
390
  const keyed = await keyScene(project, scene.id, have, key);
@@ -271,7 +405,7 @@ export async function assetsGenClip(
271
405
  id = job.id;
272
406
  ctx.log(`Started clip ${id} for scene ${scene.id}. It usually takes a few minutes.`);
273
407
  } else ctx.log(`Waiting for clip ${id} for scene ${scene.id}.`);
274
- const resume = `reelkit assets gen clip --resume ${id} --scene ${scene.id}`;
408
+ const resume = `reelkit assets gen clip --resume ${id} --scene ${scene.id}${opts.cutout ? " --cutout" : ""}`;
275
409
 
276
410
  try {
277
411
  let lastLog = started;
@@ -293,11 +427,20 @@ export async function assetsGenClip(
293
427
  if (!keyed.ok) return keyed;
294
428
  keyedPath = keyedKey;
295
429
  }
430
+ let cutoutNote: string | undefined;
431
+ if (opts.cutout) {
432
+ // The paid clip is already saved above; if the cutout fails, running the command again does only the cutout.
433
+ const cut = await cutoutScene(ctx, project, scene.id, record, now);
434
+ const shared = st.libraryId ? `, and shared it to the library as ${st.libraryId} (awaiting review)` : "";
435
+ if (!cut.ok) return { ...cut, data: { id, path, durationSec: record.durationSec, libraryId: st.libraryId ?? null }, summary: `Generated the clip for scene ${scene.id}${shared}. ${cut.summary}` };
436
+ keyedPath = keyedKey;
437
+ cutoutNote = cut.summary;
438
+ }
296
439
  const { note } = tryManifest(project, plan);
297
440
  return {
298
441
  ok: true, data: { id, path, durationSec: record.durationSec, ...(keyedPath ? { keyedPath } : {}), libraryId: st.libraryId ?? null },
299
442
  summary: [
300
- `Generated the clip for scene ${scene.id} at ${path}${keyedPath ? `, keyed to ${keyedPath}` : ""}${st.libraryId ? `, and shared it to the library as ${st.libraryId} (awaiting review)` : ""}.`,
443
+ `Generated the clip for scene ${scene.id} at ${path}${keyedPath && !cutoutNote ? `, keyed to ${keyedPath}` : ""}${st.libraryId ? `, and shared it to the library as ${st.libraryId} (awaiting review)` : ""}.${cutoutNote ? ` ${cutoutNote}` : ""}`,
301
444
  ...(note ? [note] : []),
302
445
  ].join("\n"),
303
446
  };
@@ -319,6 +462,46 @@ export async function assetsGenClip(
319
462
  }
320
463
  }
321
464
 
465
+ // Cuts the subject out of a scene's saved clip on the server and records the transparent copy as the clip's keyed copy. The id of a job
466
+ // that is still being made is kept in the record, so running the command again waits for it instead of paying again. The clip stays
467
+ // whatever happens here, and every way out says so and how to try the cutout again.
468
+ async function cutoutScene(ctx: Ctx, project: Project, sceneId: string, record: ClipRecord, now: () => number): Promise<Result> {
469
+ const keyedKey = `assets/clip-${sceneId}.webm`;
470
+ const retry = `reelkit assets gen clip --scene ${sceneId} --cutout`;
471
+ const saved = `The clip is saved at ${record.key}`;
472
+ let id = record.cutoutId;
473
+ if (!id) {
474
+ try {
475
+ id = await startCutout(ctx, { source: project.path(record.key), filename: basename(record.key), contentType: "video/mp4", bytes: statSync(project.path(record.key)).size, durationSec: record.durationSec });
476
+ } catch (e) {
477
+ return { ok: false, summary: `${saved}, but the cutout could not be started: ${e instanceof Error ? e.message : String(e)} Run \`${retry}\` to try the cutout again; the clip is not charged again.` };
478
+ }
479
+ setSceneClip(project, sceneId, { ...record, cutoutId: id });
480
+ } else ctx.log(`Waiting for cutout ${id} of scene ${sceneId}'s clip.`);
481
+ const { cutoutId: _pending, ...settled } = record;
482
+ let outcome: CutoutOutcome;
483
+ try {
484
+ outcome = await pollCutout(ctx, id, project.path(keyedKey), { now });
485
+ } catch (e) {
486
+ if (!(e instanceof ApiFailure && e.code === "not_found")) throw e;
487
+ setSceneClip(project, sceneId, settled);
488
+ return { ok: false, summary: `${saved}, but the server no longer has cutout ${id}, so it was forgotten. Run \`${retry}\` to start a new one; the clip is not charged again.` };
489
+ }
490
+ if (outcome.kind === "done") {
491
+ setSceneClip(project, sceneId, { ...settled, keyedKey });
492
+ const seconds = Math.ceil(record.durationSec);
493
+ return { ok: true, summary: `The subject is cut out into ${keyedKey} (${seconds} second${seconds === 1 ? "" : "s"} of your monthly cutout quota). ${SENT_NOTE}` };
494
+ }
495
+ if (outcome.kind === "failed") {
496
+ setSceneClip(project, sceneId, settled);
497
+ return { ok: false, summary: `${saved}, but the cutout failed: ${outcome.message} You were not charged for the cutout. Run \`${retry}\` to try the cutout again; the clip is not charged again. ${SENT_NOTE}` };
498
+ }
499
+ if (outcome.kind === "timeout") {
500
+ return { ok: false, summary: `${saved}, but the cutout (id ${id}) is not ready after ${CUTOUT_TIMEOUT_MS / 60_000} minutes. It is still being made and is not lost: run \`${retry}\` to keep waiting for it; that does not upload or charge again. ${SENT_NOTE}` };
501
+ }
502
+ return { ok: false, summary: `${saved}, but contact was lost while the cutout (id ${id}) was being made: ${outcome.message} It is not lost: run \`${retry}\`; that does not upload or charge again.` };
503
+ }
504
+
322
505
  // Keys a scene's saved clip and records the keyed copy. A failure leaves the record as it was.
323
506
  async function keyScene(project: Project, sceneId: string, record: ClipRecord, key: NonNullable<ClipDeps["key"]>): Promise<Result> {
324
507
  const keyedKey = `assets/clip-${sceneId}.webm`;
@@ -113,6 +113,6 @@ export async function whoami(ctx: Ctx): Promise<Result> {
113
113
  const q = me.quota;
114
114
  return {
115
115
  ok: true, data: me,
116
- summary: `${me.handle}\nVoiceover: ${q.voiceoverChars.used}/${q.voiceoverChars.limit} characters\nImages: ${q.images.used}/${q.images.limit}\nClips: ${q.clips.used}/${q.clips.limit}\nResets: ${q.resetsAt.slice(0, 10)}\nContributions: ${me.contributions}`,
116
+ summary: `${me.handle}\nVoiceover: ${q.voiceoverChars.used}/${q.voiceoverChars.limit} characters\nImages: ${q.images.used}/${q.images.limit}\nClips: ${q.clips.used}/${q.clips.limit}\nCutouts: ${q.cutoutSeconds.used}/${q.cutoutSeconds.limit} seconds\nResets: ${q.resetsAt.slice(0, 10)}\nContributions: ${me.contributions}`,
117
117
  };
118
118
  }
@@ -53,6 +53,20 @@
53
53
  // - A green-screen clip (`greenScreen: true`) is a subject filmed on a flat pure green background, meant to be keyed out by the client. The server
54
54
  // stores and returns the original, green and all; it does not remove the background. A shareable clip also becomes a library item of kind
55
55
  // `clip` in review, whose `meta` may carry `greenScreen: true` and `durationSec`, and its id is returned as `libraryId`.
56
+ // - A cutout removes the background from a video, whatever it is, and answers with the subject on a transparent background. The video is sent to
57
+ // the server, a GPU model separates the subject, and a transparent `.webm` (VP9 with alpha, sound kept) comes back. It takes about a minute.
58
+ // It is a job with three calls: `cutoutStart` checks the request and answers with an id and an `uploadUrl` (charging nothing; the client sends
59
+ // the file there exactly as for a library upload); `cutoutRun` checks that the file arrived (else 400 `invalid_request`), reserves
60
+ // `ceil(durationSec)` seconds from the caller's monthly cutout quota and starts the job; the client then asks `cutoutStatus` until it is `done`
61
+ // or `failed`, with the same exactly-when rules as `clipStatus` (`url`, `ext` and `contentType` when done, `message` when failed).
62
+ // `cutoutStatus` for a job whose `cutoutRun` has not been called is 400 `invalid_request`.
63
+ // `cutoutRun` for a job already started answers its current pending state and does not charge again. A failed cutout is not charged.
64
+ // A cutout id belongs to its caller: another user's id, or an unknown one, is `not_found` for run and status.
65
+ // - The video uploaded for a cutout is used only to make the cutout, and is deleted from the server when the job ends. The result stays in
66
+ // the user's private files and is never shared. A cutout is charged in whole seconds of video (`ceil(durationSec)`), and a video longer than
67
+ // 20 seconds is refused by the request schema.
68
+ // - When the cutout quota is used up, `cutoutRun` is 429 `quota_exceeded` with the reset date. When the server has no GPU service configured,
69
+ // `cutoutStart` and `cutoutRun` are 400 `invalid_request` with the message "Background removal is not available on this server yet."
56
70
  // - `durationSec` is the decoded audio length; `words` are in seconds from the start of that audio.
57
71
  // - Search returns published items only, and leaves out items that are not matches at all; the default `limit` is 8.
58
72
  import { z } from "zod";
@@ -82,6 +96,11 @@ const Meta = z.record(z.string(), z.unknown()).refine((m) => new TextEncoder().e
82
96
 
83
97
  // The most a single upload may be: 200 MB.
84
98
  export const MAX_UPLOAD_BYTES = 209_715_200;
99
+ // The video types a cutout takes.
100
+ export const CutoutTypeSchema = z.enum(["video/mp4", "video/quicktime", "video/webm"]);
101
+ export type CutoutType = z.infer<typeof CutoutTypeSchema>;
102
+ // The longest video a cutout takes, in seconds.
103
+ export const MAX_CUTOUT_SECONDS = 20;
85
104
 
86
105
  export const ErrorCodeSchema = z.enum(["unauthenticated", "quota_exceeded", "not_found", "invalid_request", "server_error"]);
87
106
  export type ErrorCode = z.infer<typeof ErrorCodeSchema>;
@@ -117,7 +136,7 @@ export type Voice = z.infer<typeof VoiceSchema>;
117
136
  const Meter = z.object({ used: z.number(), limit: z.number() });
118
137
  export const MeSchema = z.object({
119
138
  userId: z.string(), handle: z.string(),
120
- quota: z.object({ voiceoverChars: Meter, images: Meter, clips: Meter, resetsAt: z.string() }),
139
+ quota: z.object({ voiceoverChars: Meter, images: Meter, clips: Meter, cutoutSeconds: Meter, resetsAt: z.string() }),
121
140
  // How many of the user's own items are published in the shared library. An item still in review, or sent back, is not counted.
122
141
  contributions: z.number(),
123
142
  });
@@ -174,6 +193,20 @@ export const routes = {
174
193
  greenScreen: z.boolean().optional(), libraryId: z.string().optional(),
175
194
  }).refine((r) => (r.status === "done") === (r.url !== undefined && r.ext !== undefined && r.contentType !== undefined) && (r.status === "failed") === (r.message !== undefined),
176
195
  "url, ext and contentType belong to done and message to failed")),
196
+ cutoutStart: route("POST", "/cutouts", true,
197
+ z.object({
198
+ filename: text(200), contentType: CutoutTypeSchema,
199
+ bytes: z.number().int().min(1).max(MAX_UPLOAD_BYTES), durationSec: z.number().positive().max(MAX_CUTOUT_SECONDS),
200
+ }),
201
+ z.object({ id: z.string(), uploadUrl: z.string() })),
202
+ cutoutRun: route("POST", "/cutouts/run", true, z.object({ id: ItemId }), z.object({ id: z.string(), status: z.literal("pending") })),
203
+ cutoutStatus: route("POST", "/cutouts/status", true,
204
+ z.object({ id: ItemId }),
205
+ z.object({
206
+ id: z.string(), status: z.enum(["pending", "done", "failed"]), message: z.string().optional(),
207
+ url: z.string().optional(), ext: z.enum(["webm"]).optional(), contentType: z.literal("video/webm").optional(),
208
+ }).refine((r) => (r.status === "done") === (r.url !== undefined && r.ext !== undefined && r.contentType !== undefined) && (r.status === "failed") === (r.message !== undefined),
209
+ "url, ext and contentType belong to done and message to failed")),
177
210
  publicLibrary: route("GET", "/public/library", false,
178
211
  z.object({ q: text(200).optional(), kind: LibraryKindSchema.optional(), page: z.coerce.number().int().min(1).max(10000).optional() }),
179
212
  // With `q` the items are the best matches by meaning, best first, each with `match` (0..1), in one page (`hasMore` false). Under heavy
@@ -3,7 +3,7 @@ import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
4
 
5
5
  type Env = Record<string, string | undefined>;
6
- export const DEFAULT_API_URL = "https://reelkit-kohl.vercel.app/api/v1";
6
+ export const DEFAULT_API_URL = "https://reelkit.cc/api/v1";
7
7
 
8
8
  export const configDir = (env: Env) => env.REELKIT_CONFIG_DIR || join(homedir(), ".config", "reelkit");
9
9
  export const credentialsPath = (env: Env) => join(configDir(env), "credentials.json");
@@ -46,6 +46,10 @@ export const AssetRecordSchema = z.object({
46
46
  durationSec: z.number().optional(),
47
47
  // What the file shows, as given at upload, so the plan can place it where it is relevant.
48
48
  description: z.string().optional(),
49
+ // A green-screen video also has a copy with the green made transparent, at this path.
50
+ keyedKey: z.string().optional(),
51
+ // The server cutout still being made for this video. Kept so that coming back to wait for it neither uploads nor charges again.
52
+ cutoutId: z.string().optional(),
49
53
  createdAt: z.string(),
50
54
  });
51
55
  export type AssetRecord = z.infer<typeof AssetRecordSchema>;
@@ -1,16 +1,50 @@
1
1
  import { execFile } from "node:child_process";
2
+ import { rmSync } from "node:fs";
2
3
  import { promisify } from "node:util";
3
4
 
4
5
  const run = promisify(execFile);
6
+ const PURE_GREEN = "0x00FF00";
5
7
 
6
- // Turns the flat pure green of a green-screen clip into transparency: VP9 with an alpha channel, which Remotion plays with `transparent`.
7
- // The key is on pure green with a little give for compression noise; the despill pulls the green glow off the subject's edges.
8
- // The original is left alone.
9
- export async function keyGreen(input: string, output: string): Promise<void> {
10
- const filter = "chromakey=color=0x00FF00:similarity=0.2:blend=0.1,despill=type=green:mix=0.5:expand=0,format=yuva420p";
8
+ export type KeyOptions = {
9
+ // Keep the clip's sound in the transparent copy (a person talking on green). Generated clips have none worth keeping.
10
+ keepAudio?: boolean;
11
+ // Fail when the frame's corners are not green, instead of keying on pure green anyway. For footage a person supplied.
12
+ requireGreen?: boolean;
13
+ };
14
+
15
+ const NOT_GREEN = "This video does not have a green background in its corners, so there is nothing to key out. Film the subject in front of an evenly lit green background that fills the frame.";
16
+
17
+ // The colour to key on, read from the first frame's four corners. A real green screen, filmed or generated, is never exactly
18
+ // #00FF00: it is whatever green the light and the encoder made of it, and keying on the wrong green leaves a haze. Undefined when
19
+ // the corners are not green.
20
+ async function cornerGreen(input: string): Promise<string | undefined> {
21
+ const corner = (x: string, y: string) => `crop=24:24:${x}:${y},scale=1:1:flags=area`;
22
+ const picks = ["0:0", "iw-24:0", "0:ih-24", "iw-24:ih-24"].map((c) => { const [x, y] = c.split(":") as [string, string]; return corner(x, y); });
23
+ const colours: [number, number, number][] = [];
24
+ for (const vf of picks) {
25
+ const { stdout } = await run("ffmpeg", ["-v", "error", "-i", input, "-frames:v", "1", "-vf", vf, "-pix_fmt", "rgb24", "-f", "rawvideo", "-"], { encoding: "buffer", maxBuffer: 1 << 16 });
26
+ if (stdout.length >= 3) colours.push([stdout[0]!, stdout[1]!, stdout[2]!]);
27
+ }
28
+ const green = colours.filter(([r, g, b]) => g > 60 && g > r * 1.3 && g > b * 1.3);
29
+ // Most corners must agree: a subject may reach one corner, but not three.
30
+ if (green.length < 3) return undefined;
31
+ const avg = (i: 0 | 1 | 2) => Math.round(green.reduce((sum, c) => sum + c[i], 0) / green.length);
32
+ return `0x${[avg(0), avg(1), avg(2)].map((n) => n.toString(16).padStart(2, "0")).join("")}`;
33
+ }
34
+
35
+ // Turns the green of a green-screen clip into transparency: VP9 with an alpha channel, which Remotion plays with `transparent`.
36
+ // The key is on the green found in the frame's corners, with some give for noise and uneven light; the despill pulls the green glow
37
+ // off the subject's edges. The original is left alone.
38
+ export async function keyGreen(input: string, output: string, opts: KeyOptions = {}): Promise<void> {
11
39
  try {
12
- await run("ffmpeg", ["-v", "error", "-y", "-i", input, "-vf", filter, "-c:v", "libvpx-vp9", "-pix_fmt", "yuva420p", "-b:v", "0", "-crf", "30", "-an", output]);
40
+ const found = await cornerGreen(input);
41
+ if (!found && opts.requireGreen) throw new Error(NOT_GREEN);
42
+ const filter = `chromakey=color=${found ?? PURE_GREEN}:similarity=0.16:blend=0.08,despill=type=green:mix=0.5:expand=0,format=yuva420p`;
43
+ const audio = opts.keepAudio ? ["-c:a", "libopus", "-b:a", "96k"] : ["-an"];
44
+ await run("ffmpeg", ["-v", "error", "-y", "-i", input, "-vf", filter, "-c:v", "libvpx-vp9", "-pix_fmt", "yuva420p", "-b:v", "0", "-crf", "30", ...audio, output]);
13
45
  } catch (e) {
46
+ rmSync(output, { force: true });
47
+ if (e instanceof Error && e.message === NOT_GREEN) throw e;
14
48
  if ((e as NodeJS.ErrnoException)?.code === "ENOENT") throw new Error("ffmpeg was not found. Install ffmpeg (macOS: `brew install ffmpeg`) and run the command again.");
15
49
  throw new Error("ffmpeg could not key the green out of the clip. The original clip is saved; run the command again to retry.");
16
50
  }
@@ -5,8 +5,9 @@ import { FILES, type Project } from "./project";
5
5
  // What a scene was recorded from is stored with it, so a changed script or voice is noticed.
6
6
  export type Voiceover = { key: string; durationSec: number; words: WordTiming[]; text?: string; voiceId?: string; speed?: number };
7
7
 
8
- // A scene's video clip: the original, and the transparent copy when it was filmed on green and keyed out. Stored in assets/clips.json by scene id.
9
- export type ClipRecord = { key: string; greenScreen: boolean; durationSec: number; keyedKey?: string };
8
+ // A scene's video clip: the original, and the transparent copy when it was filmed on green and keyed out, or cut out on the server (`cutoutId` is
9
+ // that cutout while it is still being made). Stored in assets/clips.json by scene id.
10
+ export type ClipRecord = { key: string; greenScreen: boolean; durationSec: number; keyedKey?: string; cutoutId?: string };
10
11
 
11
12
  // True when the stored recording no longer matches what the plan asks for. An entry from before this was tracked counts as different.
12
13
  export function voiceoverStale(vo: Voiceover, scene: ScenePlan["scenes"][number], plan: ScenePlan): boolean {
@@ -20,8 +20,11 @@ export type Harness = {
20
20
  clipFailPrompt?: string;
21
21
  // How long to wait between two asks for a clip's status, in milliseconds: a server whose fake provider needs a moment sets it. Default 20.
22
22
  clipPollMs?: number;
23
+ // Optional, for the cutout tests. A file name that makes a cutout job fail (the fake fails any name containing "cutout-fail"); without
24
+ // it the failing-job case is skipped. The cutout tests wait between status asks for `clipPollMs` too.
25
+ cutoutFailFilename?: string;
23
26
  };
24
- export type StartOptions = { voiceoverChars?: number; images?: number; clips?: number };
27
+ export type StartOptions = { voiceoverChars?: number; images?: number; clips?: number; cutoutSeconds?: number };
25
28
 
26
29
  const PNG = new Uint8Array(Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", "base64"));
27
30
  const DATE = /\d{4}-\d{2}-\d{2}/;
@@ -82,6 +85,8 @@ export function runConformance(name: string, start: (opts: StartOptions) => Prom
82
85
  expect(me.quota.images.used).toBe(0);
83
86
  expect(me.quota.clips.used).toBe(0);
84
87
  expect(me.quota.clips.limit).toBeGreaterThan(0);
88
+ expect(me.quota.cutoutSeconds.used).toBe(0);
89
+ expect(me.quota.cutoutSeconds.limit).toBeGreaterThan(0);
85
90
  expect(me.quota.voiceoverChars.limit).toBeGreaterThan(0);
86
91
  expect(me.contributions).toBe(0);
87
92
  const resets = new Date(me.quota.resetsAt);
@@ -398,6 +403,125 @@ export function runConformance(name: string, start: (opts: StartOptions) => Prom
398
403
  });
399
404
  });
400
405
 
406
+ describe("cutouts", () => {
407
+ const VIDEO = new Uint8Array([0, 0, 0, 24, 102, 116, 121, 112, 105, 115, 111, 109, ...new Array(2000).fill(7)]);
408
+ const meta = (over: Record<string, unknown> = {}) => ({ filename: "me.mp4", contentType: "video/mp4" as const, bytes: VIDEO.length, durationSec: 2.4, ...over });
409
+ const settleCutout = async (api: Api, id: string) => {
410
+ for (let i = 0; i < 20; i++) {
411
+ const st = await api("cutoutStatus", { id });
412
+ if (st.status !== "pending") return st;
413
+ await new Promise((r) => setTimeout(r, h.clipPollMs ?? 20));
414
+ }
415
+ throw new Error(`cutout ${id} was still pending after 20 asks`);
416
+ };
417
+ // Starts a job and sends its file, as a client does before asking for the run.
418
+ const uploaded = async (api: Api, over: Record<string, unknown> = {}) => {
419
+ const started = await api("cutoutStart", meta(over) as never);
420
+ await put(started.uploadUrl, VIDEO, (over.contentType as string | undefined) ?? "video/mp4");
421
+ return started;
422
+ };
423
+
424
+ it("start, upload, run: pending at once, then done with a transparent webm that downloads; charged in whole seconds to this user only", async () => {
425
+ const api = await as("user-cut000001"), other = await as("user-cut000002");
426
+ const started = await api("cutoutStart", meta());
427
+ expect(started.id).toBeTruthy();
428
+ expect(started.uploadUrl).toBeTruthy();
429
+ // Starting charges nothing.
430
+ expect((await api("me", {})).quota.cutoutSeconds.used).toBe(0);
431
+ expect((await put(started.uploadUrl, VIDEO, "video/mp4")).ok).toBe(true);
432
+ expect(await api("cutoutRun", { id: started.id })).toEqual({ id: started.id, status: "pending" });
433
+ expect((await api("me", {})).quota.cutoutSeconds.used).toBe(3);
434
+ const done = await settleCutout(api, started.id);
435
+ expect(done).toMatchObject({ id: started.id, status: "done", ext: "webm", contentType: "video/webm" });
436
+ expect(done.url).toBeTruthy();
437
+ expect(done.message).toBeUndefined();
438
+ await download(done.url!, join(dest, "cutout.webm"));
439
+ expect(readFileSync(join(dest, "cutout.webm")).length).toBeGreaterThan(0);
440
+ expect((await api("me", {})).quota.cutoutSeconds.used).toBe(3);
441
+ expect((await other("me", {})).quota.cutoutSeconds.used).toBe(0);
442
+ });
443
+
444
+ it("a run before the file arrived is invalid_request and charges nothing", async () => {
445
+ const api = await as("user-cut000003");
446
+ const started = await api("cutoutStart", meta());
447
+ const e = await failure(api("cutoutRun", { id: started.id }));
448
+ expect(e.code).toBe("invalid_request");
449
+ expect((await api("me", {})).quota.cutoutSeconds.used).toBe(0);
450
+ // The upload can still be sent and the job run afterwards.
451
+ await put(started.uploadUrl, VIDEO, "video/mp4");
452
+ expect((await api("cutoutRun", { id: started.id })).status).toBe("pending");
453
+ });
454
+
455
+ it("running a job twice answers its pending state again and charges once", async () => {
456
+ const api = await as("user-cut000004");
457
+ const started = await uploaded(api);
458
+ await api("cutoutRun", { id: started.id });
459
+ expect(await api("cutoutRun", { id: started.id })).toEqual({ id: started.id, status: "pending" });
460
+ expect((await api("me", {})).quota.cutoutSeconds.used).toBe(3);
461
+ expect((await settleCutout(api, started.id)).status).toBe("done");
462
+ expect((await api("me", {})).quota.cutoutSeconds.used).toBe(3);
463
+ });
464
+
465
+ it("a cutout id belongs to its caller: another user, or an unknown id, is not_found for run and status", async () => {
466
+ const api = await as("user-cut000005"), other = await as("user-cut000006");
467
+ const started = await uploaded(api);
468
+ await api("cutoutRun", { id: started.id });
469
+ expect((await failure(other("cutoutRun", { id: started.id }))).code).toBe("not_found");
470
+ expect((await failure(other("cutoutStatus", { id: started.id }))).code).toBe("not_found");
471
+ expect((await failure(api("cutoutRun", { id: "cutout-nosuchjob" }))).code).toBe("not_found");
472
+ expect((await failure(api("cutoutStatus", { id: "cutout-nosuchjob" }))).code).toBe("not_found");
473
+ expect((await other("me", {})).quota.cutoutSeconds.used).toBe(0);
474
+ });
475
+
476
+ it("a failed job says why in one line and is not charged", async () => {
477
+ if (!h.cutoutFailFilename) return;
478
+ const api = await as("user-cut000007");
479
+ const started = await uploaded(api, { filename: h.cutoutFailFilename });
480
+ await api("cutoutRun", { id: started.id });
481
+ expect((await api("me", {})).quota.cutoutSeconds.used).toBe(3);
482
+ const st = await settleCutout(api, started.id);
483
+ expect(st.status).toBe("failed");
484
+ expect(st.message?.length).toBeGreaterThan(0);
485
+ expect(st.message).not.toContain("\n");
486
+ expect(st.url).toBeUndefined();
487
+ expect((await api("me", {})).quota.cutoutSeconds.used).toBe(0);
488
+ });
489
+
490
+ it("at the limit a run is quota_exceeded with the reset date, charged nothing; other users are unaffected", async () => {
491
+ const t = await start({ cutoutSeconds: 3 });
492
+ try {
493
+ const api = createClient({ baseUrl: t.baseUrl, token: await t.login("user-cutq00001") });
494
+ const other = createClient({ baseUrl: t.baseUrl, token: await t.login("user-cutq00002") });
495
+ const send = async (c: Api) => { const s = await c("cutoutStart", meta({ durationSec: 2 })); await put(s.uploadUrl, VIDEO, "video/mp4"); return s; };
496
+ const first = await send(api);
497
+ await api("cutoutRun", { id: first.id });
498
+ const second = await send(api);
499
+ const e = await failure(api("cutoutRun", { id: second.id }));
500
+ expect(e.code).toBe("quota_exceeded");
501
+ expect(e.message).toMatch(DATE);
502
+ const me = await api("me", {});
503
+ expect(me.quota.cutoutSeconds).toEqual({ used: 2, limit: 3 });
504
+ expect(e.message).toContain(me.quota.resetsAt.slice(0, 10));
505
+ const third = await send(other);
506
+ expect((await other("cutoutRun", { id: third.id })).status).toBe("pending");
507
+ } finally { await t.close(); }
508
+ });
509
+
510
+ it("refuses a video over 20 seconds, a type that is not a video, a size out of range and a missing login", async () => {
511
+ const api = await as("user-cut000008");
512
+ for (const bad of [{ durationSec: 20.5 }, { durationSec: 0 }, { durationSec: -1 }, { contentType: "audio/mpeg" }, { contentType: "image/png" }, { bytes: 0 }, { bytes: MAX_UPLOAD_BYTES + 1 }, { filename: "a\u0000b.mp4" }]) {
513
+ expect((await failure(api("cutoutStart", meta(bad) as never))).code, JSON.stringify(bad)).toBe("invalid_request");
514
+ }
515
+ for (const ok of [{ durationSec: 20 }, { contentType: "video/quicktime" as const }, { contentType: "video/webm" as const }, { bytes: MAX_UPLOAD_BYTES }]) {
516
+ expect((await api("cutoutStart", meta(ok))).id).toBeTruthy();
517
+ }
518
+ expect((await failure(api("cutoutRun", { id: "../x" }))).code).toBe("invalid_request");
519
+ expect((await failure(anon("cutoutStart", meta()))).code).toBe("unauthenticated");
520
+ expect((await failure(anon("cutoutRun", { id: "cutout-x" }))).code).toBe("unauthenticated");
521
+ expect((await failure(anon("cutoutStatus", { id: "cutout-x" }))).code).toBe("unauthenticated");
522
+ });
523
+ });
524
+
401
525
  describe("quota", () => {
402
526
  it("passes exactly at the limit; one over is quota_exceeded with the reset date; other users are unaffected", async () => {
403
527
  const t = await start({ voiceoverChars: 10, images: 1 });
@@ -62,10 +62,29 @@ function makeClip(greenScreen: boolean): Blob {
62
62
  } finally { rmSync(dir, { recursive: true, force: true }); }
63
63
  }
64
64
 
65
+ // A cutout job: the file arrives at the upload URL, `run` reserves the seconds, and then it is pending for its first status call and done for
66
+ // the second (or failed, when the file name says so). `seconds` is what it holds of the quota, 0 once given back.
67
+ type CutoutJob = { owner: string; filename: string; contentType: string; bytes: number; durationSec: number; file?: Uint8Array; started: boolean; asks: number; seconds: number };
68
+ const CUTOUT_FAIL = "cutout-fail";
69
+ const NO_GPU = "Background removal is not available on this server yet.";
70
+
71
+ // A real one-second WebM, made with ffmpeg: VP9 with an alpha channel, a transparent background and an opaque box.
72
+ function makeCutout(): Blob {
73
+ const dir = mkdtempSync(join(tmpdir(), "rk-fakecut-"));
74
+ const out = join(dir, "cutout.webm");
75
+ try {
76
+ execFileSync("ffmpeg", ["-v", "error", "-f", "lavfi", "-i", "color=c=black@0:s=160x90:r=10:d=1,format=yuva420p,drawbox=x=50:y=20:w=60:h=50:color=red@1:t=fill:replace=1",
77
+ "-c:v", "libvpx-vp9", "-pix_fmt", "yuva420p", "-b:v", "0", "-crf", "35", out], { stdio: "pipe" });
78
+ return { filename: "cutout.webm", contentType: "video/webm", bytes: new Uint8Array(readFileSync(out)) };
79
+ } catch (e) {
80
+ throw new Error(`The fake API could not make its test cutout: ffmpeg is needed (${e instanceof Error ? e.message : String(e)}).`);
81
+ } finally { rmSync(dir, { recursive: true, force: true }); }
82
+ }
83
+
65
84
  export type FakeApi = Awaited<ReturnType<typeof startFakeApi>>;
66
85
 
67
86
  // An in-memory stand-in for the Reelkit API. It implements every route in the contract.
68
- export async function startFakeApi(opts: { voiceoverCharLimit?: number; imageLimit?: number; clipLimit?: number; noClipProvider?: boolean; autoApprove?: boolean } = {}) {
87
+ export async function startFakeApi(opts: { voiceoverCharLimit?: number; imageLimit?: number; clipLimit?: number; noClipProvider?: boolean; cutoutSecondsLimit?: number; noCutoutService?: boolean; autoApprove?: boolean } = {}) {
69
88
  // token -> the user it belongs to
70
89
  const tokens = new Map<string, string>();
71
90
  const devices = new Map<string, { userCode: string; approved: boolean; userId: string }>();
@@ -74,18 +93,23 @@ export async function startFakeApi(opts: { voiceoverCharLimit?: number; imageLim
74
93
  const blobs = new Map<string, Blob>();
75
94
  // An unguessable token -> the one upload it accepts. `used` is set by the first PUT, accepted or not: the fake is stricter than a
76
95
  // real server needs to be, so a client that retries a PUT, or sends a second one, fails here instead of working by luck.
77
- const uploads = new Map<string, { id: string; contentType: string; bytes: number; used: boolean }>();
96
+ // `sink` is where the accepted bytes go: a library item, or a cutout job.
97
+ const uploads = new Map<string, { contentType: string; bytes: number; used: boolean; sink: { has(): boolean; put(bytes: Uint8Array): void } | undefined }>();
78
98
  // Totals over every user (for tests of the CLI), and the same counts per user (what a quota is measured against).
79
- const usage = { chars: 0, images: 0, clips: 0, pulls: 0, uploads: 0 };
80
- const meters = new Map<string, { chars: number; images: number; clips: number; uploads: number }>();
99
+ const usage = { chars: 0, images: 0, clips: 0, cutoutSeconds: 0, pulls: 0, uploads: 0 };
100
+ const meters = new Map<string, { chars: number; images: number; clips: number; cutoutSeconds: number; uploads: number }>();
81
101
  const meter = (userId: string | undefined) => {
82
102
  const id = userId ?? "u-test";
83
- if (!meters.has(id)) meters.set(id, { chars: 0, images: 0, clips: 0, uploads: 0 });
103
+ if (!meters.has(id)) meters.set(id, { chars: 0, images: 0, clips: 0, cutoutSeconds: 0, uploads: 0 });
84
104
  return meters.get(id)!;
85
105
  };
86
106
  const resetDate = () => nextMonthStart().toISOString();
87
- const limits = { chars: opts.voiceoverCharLimit ?? 10_000, images: opts.imageLimit ?? 30, clips: opts.clipLimit ?? 5 };
107
+ const limits = { chars: opts.voiceoverCharLimit ?? 10_000, images: opts.imageLimit ?? 30, clips: opts.clipLimit ?? 5, cutoutSeconds: opts.cutoutSecondsLimit ?? 120 };
88
108
  const jobs = new Map<string, ClipJob>();
109
+ const cutouts = new Map<string, CutoutJob>();
110
+ let cutoutFile: Blob | undefined;
111
+ // While held, no cutout job finishes: every status call says pending. For a test that sees a client give up waiting and come back.
112
+ let cutoutsHeld = false;
89
113
  // Made on first use and kept for the life of this instance.
90
114
  const clipFiles: Partial<Record<"plain" | "green", Blob>> = {};
91
115
  const clipFile = (green: boolean) => (clipFiles[green ? "green" : "plain"] ??= makeClip(green));
@@ -117,7 +141,7 @@ export async function startFakeApi(opts: { voiceoverCharLimit?: number; imageLim
117
141
  const m = meter(userId);
118
142
  return {
119
143
  userId: userId ?? "u-test", handle: handleOf(userId ?? "u-test"),
120
- quota: { voiceoverChars: { used: m.chars, limit: limits.chars }, images: { used: m.images, limit: limits.images }, clips: { used: m.clips, limit: limits.clips }, resetsAt: resetDate() },
144
+ quota: { voiceoverChars: { used: m.chars, limit: limits.chars }, images: { used: m.images, limit: limits.images }, clips: { used: m.clips, limit: limits.clips }, cutoutSeconds: { used: m.cutoutSeconds, limit: limits.cutoutSeconds }, resetsAt: resetDate() },
121
145
  // What the user gave the shared library that was accepted: their own items that are published. One waiting for review does not count.
122
146
  contributions: [...store.values()].filter((x) => x.owner === userId && x.committed && x.item.visibility === "published").length,
123
147
  };
@@ -151,7 +175,10 @@ export async function startFakeApi(opts: { voiceoverCharLimit?: number; imageLim
151
175
  const item: LibraryItem = { id, kind: input.kind, title: input.title, description: input.description, tags: input.tags, meta: input.meta, visibility: input.shareable ? "review" : "private" };
152
176
  store.set(id, { item, filename, contentType: input.contentType, owner: userId, committed: false });
153
177
  const token = secret();
154
- uploads.set(token, { id, contentType: input.contentType, bytes: input.bytes, used: false });
178
+ uploads.set(token, {
179
+ contentType: input.contentType, bytes: input.bytes, used: false,
180
+ sink: { has: () => Boolean(store.get(id)?.file), put: (bytes) => { const s = store.get(id)!; s.file = { filename: s.filename, contentType: s.contentType, bytes }; } },
181
+ });
155
182
  return { id, uploadUrl: `${origin}/upload/${token}` };
156
183
  },
157
184
  libraryCommit: ({ id }, { userId }) => {
@@ -215,6 +242,45 @@ export async function startFakeApi(opts: { voiceoverCharLimit?: number; imageLim
215
242
  ...(job.libraryId ? { libraryId: job.libraryId } : {}),
216
243
  };
217
244
  },
245
+ cutoutStart: (input, { userId }) => {
246
+ if (opts.noCutoutService) throw new Fail(400, "invalid_request", NO_GPU);
247
+ const id = nextId("cutout");
248
+ const job: CutoutJob = { owner: userId ?? "u-test", filename: input.filename, contentType: input.contentType, bytes: input.bytes, durationSec: input.durationSec, started: false, asks: 0, seconds: 0 };
249
+ cutouts.set(id, job);
250
+ const token = secret();
251
+ uploads.set(token, { contentType: input.contentType, bytes: input.bytes, used: false, sink: { has: () => Boolean(job.file), put: (bytes) => { job.file = bytes; } } });
252
+ return { id, uploadUrl: `${origin}/upload/${token}` };
253
+ },
254
+ cutoutRun: ({ id }, { userId }) => {
255
+ const job = cutouts.get(id);
256
+ if (!job || job.owner !== (userId ?? "u-test")) throw new Fail(404, "not_found", `No cutout with id ${id}.`);
257
+ // A job already started just answers where it is, without a second charge.
258
+ if (job.started) return { id, status: "pending" };
259
+ if (opts.noCutoutService) throw new Fail(400, "invalid_request", NO_GPU);
260
+ if (!job.file) throw new Fail(400, "invalid_request", `Nothing was uploaded for ${id}. Send the video to the upload URL first.`);
261
+ const seconds = Math.ceil(job.durationSec);
262
+ const m = meter(userId);
263
+ if (m.cutoutSeconds + seconds > limits.cutoutSeconds) throw new Fail(429, "quota_exceeded", `Cutout quota used up. It resets on ${resetDate().slice(0, 10)}.`);
264
+ m.cutoutSeconds += seconds;
265
+ usage.cutoutSeconds += seconds;
266
+ job.started = true;
267
+ job.seconds = seconds;
268
+ return { id, status: "pending" };
269
+ },
270
+ cutoutStatus: ({ id }, { userId }) => {
271
+ const job = cutouts.get(id);
272
+ if (!job || job.owner !== (userId ?? "u-test")) throw new Fail(404, "not_found", `No cutout with id ${id}.`);
273
+ if (!job.started) throw new Fail(400, "invalid_request", `Cutout ${id} has not been run yet.`);
274
+ if (++job.asks === 1 || cutoutsHeld) return { id, status: "pending" };
275
+ // The uploaded video is deleted when the job ends, whatever the result.
276
+ job.file = undefined;
277
+ if (job.filename.includes(CUTOUT_FAIL)) {
278
+ // A failed cutout is not charged: the seconds are given back, once.
279
+ if (job.seconds) { meter(userId).cutoutSeconds -= job.seconds; usage.cutoutSeconds -= job.seconds; job.seconds = 0; }
280
+ return { id, status: "failed", message: "The video could not be processed. Try a different video." };
281
+ }
282
+ return { id, status: "done", url: fileUrl((cutoutFile ??= makeCutout())), ext: "webm", contentType: "video/webm" };
283
+ },
218
284
  publicLibrary: ({ q, kind, page }) => {
219
285
  // Published items only. Newest first; with a query, best match first and equal matches newest first.
220
286
  const want = q ? words(q) : undefined;
@@ -245,15 +311,14 @@ export async function startFakeApi(opts: { voiceoverCharLimit?: number; imageLim
245
311
  }
246
312
  if (req.method === "PUT" && url.pathname.startsWith("/upload/")) {
247
313
  const up = uploads.get(url.pathname.slice("/upload/".length));
248
- const s = up && store.get(up.id);
249
- if (!up || !s) return send(404, { error: { code: "not_found", message: "No such upload." } });
314
+ if (!up?.sink) return send(404, { error: { code: "not_found", message: "No such upload." } });
250
315
  // The URL is good for one PUT. Whether that PUT is accepted or refused, a second one is dead, and so is any PUT after commit.
251
316
  // (A real server may be kinder; the contract promises clients no more than this.)
252
- if (up.used || s.file) throw new Fail(400, "invalid_request", "This upload URL has already been used.");
317
+ if (up.used || up.sink.has()) throw new Fail(400, "invalid_request", "This upload URL has already been used.");
253
318
  up.used = true;
254
319
  if (req.headers["content-type"] !== up.contentType) throw new Fail(400, "invalid_request", `The upload must be sent as ${up.contentType}.`);
255
320
  if (raw.length !== up.bytes) throw new Fail(400, "invalid_request", `The upload must be exactly ${up.bytes} bytes.`);
256
- s.file = { filename: s.filename, contentType: s.contentType, bytes: new Uint8Array(raw) };
321
+ up.sink.put(new Uint8Array(raw));
257
322
  return send(200, {});
258
323
  }
259
324
 
@@ -281,6 +346,10 @@ export async function startFakeApi(opts: { voiceoverCharLimit?: number; imageLim
281
346
  usage,
282
347
  // Changes how many clips a user may start from now on, as `clipLimit` does at the start.
283
348
  setClipLimit(n: number) { limits.clips = n; },
349
+ // The same for the seconds of video a user may have cut out.
350
+ setCutoutLimit(n: number) { limits.cutoutSeconds = n; },
351
+ // Stops cutout jobs from finishing (true) or lets them finish again (false).
352
+ holdCutouts(hold: boolean) { cutoutsHeld = hold; },
284
353
  approve(userCode: string, userId = "u-test") { for (const d of devices.values()) if (d.userCode === userCode) { d.approved = true; d.userId = userId; } },
285
354
  // A ready token, for tests that are not about logging in.
286
355
  login(userId = "u-test") { const t = nextId("tok"); tokens.set(t, userId); return t; },