reelkit-cli 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/package.json +1 -1
- package/skill/SKILL.md +1 -1
- package/skill/reference/clips.md +40 -0
- package/src/cli.ts +8 -4
- package/src/commands/assets.ts +191 -8
- package/src/commands/auth.ts +1 -1
- package/src/contract/index.ts +34 -1
- package/src/pipeline/schema.ts +4 -0
- package/src/project/chromakey.ts +40 -6
- package/src/project/manifest.ts +3 -2
- package/src/testing/conformance.ts +125 -1
- package/src/testing/fake-api.ts +81 -12
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 |
|
package/package.json
CHANGED
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.
|
package/skill/reference/clips.md
CHANGED
|
@@ -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
|
-
.
|
|
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")
|
|
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
|
|
package/src/commands/assets.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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`;
|
package/src/commands/auth.ts
CHANGED
|
@@ -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
|
}
|
package/src/contract/index.ts
CHANGED
|
@@ -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
|
package/src/pipeline/schema.ts
CHANGED
|
@@ -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>;
|
package/src/project/chromakey.ts
CHANGED
|
@@ -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
|
-
|
|
7
|
-
//
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/project/manifest.ts
CHANGED
|
@@ -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
|
|
9
|
-
|
|
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 });
|
package/src/testing/fake-api.ts
CHANGED
|
@@ -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
|
-
|
|
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, {
|
|
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
|
-
|
|
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 ||
|
|
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
|
-
|
|
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; },
|