reelkit-cli 0.1.3 → 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 -1
- package/package.json +1 -1
- package/skill/SKILL.md +8 -4
- package/skill/reference/clips.md +98 -0
- package/skill/reference/kit.md +8 -0
- package/skill/reference/remotion-composition.md +4 -3
- package/skill/reference/scene-treatments.md +2 -1
- package/src/cli.ts +16 -7
- package/src/commands/assets.ts +334 -15
- package/src/commands/auth.ts +1 -1
- package/src/contract/index.ts +62 -3
- package/src/pipeline/schema.ts +13 -1
- package/src/project/chromakey.ts +51 -0
- package/src/project/manifest.ts +12 -0
- package/src/project/project.ts +1 -1
- package/src/remotion/kit/ClipLayer.tsx +12 -0
- package/src/remotion/kit/KeyedClip.tsx +15 -0
- package/src/remotion/kit/docs.ts +8 -0
- package/src/remotion/kit/index.ts +2 -0
- package/src/testing/conformance.ts +227 -1
- package/src/testing/fake-api.ts +144 -13
package/src/testing/fake-api.ts
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
1
2
|
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
2
4
|
import { createServer } from "node:http";
|
|
3
5
|
import type { AddressInfo } from "node:net";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { join } from "node:path";
|
|
4
8
|
import { MAX_UPLOAD_BYTES, routes, type ErrorCode, type LibraryItem, type RouteName, type Voice } from "../contract";
|
|
5
9
|
import { PIXEL_PNG, silentWav } from "./fixtures";
|
|
6
10
|
|
|
7
11
|
type Blob = { filename: string; contentType: string; bytes: Uint8Array };
|
|
8
12
|
// owner is the uploader's user id; a seeded item has none. An upload is not an item for anyone until it is committed.
|
|
13
|
+
// A clip of ten seconds or less is previewed by its own file, like a picture or a sound effect.
|
|
14
|
+
const shortClip = (item: LibraryItem) => item.kind === "clip" && typeof item.meta.durationSec === "number" && item.meta.durationSec <= 10;
|
|
9
15
|
type Stored = { item: LibraryItem; file?: Blob; filename: string; contentType: string; owner?: string; committed: boolean };
|
|
10
16
|
|
|
11
17
|
export const FAKE_VOICES: Voice[] = [
|
|
@@ -37,10 +43,48 @@ function parseBody(raw: Buffer): unknown {
|
|
|
37
43
|
catch { throw new Fail(400, "invalid_request", "The request body is not valid JSON."); }
|
|
38
44
|
}
|
|
39
45
|
|
|
46
|
+
// A clip job: pending for its first status call, then done (or failed, when the prompt says so). `charged` is whether it still holds its reservation.
|
|
47
|
+
type ClipJob = { owner: string; prompt: string; aspect: string; greenScreen: boolean; durationSec: number; shareable: boolean; tags: string[]; asks: number; charged: boolean; libraryId?: string };
|
|
48
|
+
const CLIP_FAIL = "clip-fail";
|
|
49
|
+
// A prompt with this never finishes, so a test can see a client give up waiting.
|
|
50
|
+
const CLIP_STALL = "clip-stall";
|
|
51
|
+
|
|
52
|
+
// A real one-second MP4, made with ffmpeg. A green-screen one is pure green with a small red box, so keying it out has something to show.
|
|
53
|
+
function makeClip(greenScreen: boolean): Blob {
|
|
54
|
+
const dir = mkdtempSync(join(tmpdir(), "rk-fakeclip-"));
|
|
55
|
+
const out = join(dir, "clip.mp4");
|
|
56
|
+
try {
|
|
57
|
+
const source = greenScreen ? "color=c=0x00FF00:s=320x180:r=10:d=1,drawbox=x=120:y=50:w=80:h=80:color=0xFF0000:t=fill" : "testsrc=s=320x180:r=10:d=1";
|
|
58
|
+
execFileSync("ffmpeg", ["-v", "error", "-f", "lavfi", "-i", source, "-c:v", "libx264", "-pix_fmt", "yuv420p", "-movflags", "+faststart", out], { stdio: "pipe" });
|
|
59
|
+
return { filename: "clip.mp4", contentType: "video/mp4", bytes: new Uint8Array(readFileSync(out)) };
|
|
60
|
+
} catch (e) {
|
|
61
|
+
throw new Error(`The fake API could not make its test clip: ffmpeg is needed (${e instanceof Error ? e.message : String(e)}).`);
|
|
62
|
+
} finally { rmSync(dir, { recursive: true, force: true }); }
|
|
63
|
+
}
|
|
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
|
+
|
|
40
84
|
export type FakeApi = Awaited<ReturnType<typeof startFakeApi>>;
|
|
41
85
|
|
|
42
86
|
// An in-memory stand-in for the Reelkit API. It implements every route in the contract.
|
|
43
|
-
export async function startFakeApi(opts: { voiceoverCharLimit?: number; imageLimit?: number; autoApprove?: boolean } = {}) {
|
|
87
|
+
export async function startFakeApi(opts: { voiceoverCharLimit?: number; imageLimit?: number; clipLimit?: number; noClipProvider?: boolean; cutoutSecondsLimit?: number; noCutoutService?: boolean; autoApprove?: boolean } = {}) {
|
|
44
88
|
// token -> the user it belongs to
|
|
45
89
|
const tokens = new Map<string, string>();
|
|
46
90
|
const devices = new Map<string, { userCode: string; approved: boolean; userId: string }>();
|
|
@@ -49,17 +93,26 @@ export async function startFakeApi(opts: { voiceoverCharLimit?: number; imageLim
|
|
|
49
93
|
const blobs = new Map<string, Blob>();
|
|
50
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
|
|
51
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.
|
|
52
|
-
|
|
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 }>();
|
|
53
98
|
// Totals over every user (for tests of the CLI), and the same counts per user (what a quota is measured against).
|
|
54
|
-
const usage = { chars: 0, images: 0, pulls: 0, uploads: 0 };
|
|
55
|
-
const meters = new Map<string, { chars: number; images: 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 }>();
|
|
56
101
|
const meter = (userId: string | undefined) => {
|
|
57
102
|
const id = userId ?? "u-test";
|
|
58
|
-
if (!meters.has(id)) meters.set(id, { chars: 0, images: 0, uploads: 0 });
|
|
103
|
+
if (!meters.has(id)) meters.set(id, { chars: 0, images: 0, clips: 0, cutoutSeconds: 0, uploads: 0 });
|
|
59
104
|
return meters.get(id)!;
|
|
60
105
|
};
|
|
61
106
|
const resetDate = () => nextMonthStart().toISOString();
|
|
62
|
-
const limits = { chars: opts.voiceoverCharLimit ?? 10_000, images: opts.imageLimit ?? 30 };
|
|
107
|
+
const limits = { chars: opts.voiceoverCharLimit ?? 10_000, images: opts.imageLimit ?? 30, clips: opts.clipLimit ?? 5, cutoutSeconds: opts.cutoutSecondsLimit ?? 120 };
|
|
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;
|
|
113
|
+
// Made on first use and kept for the life of this instance.
|
|
114
|
+
const clipFiles: Partial<Record<"plain" | "green", Blob>> = {};
|
|
115
|
+
const clipFile = (green: boolean) => (clipFiles[green ? "green" : "plain"] ??= makeClip(green));
|
|
63
116
|
const ALNUM = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
64
117
|
const nextId = (prefix: string) => `${prefix}-${Array.from(randomBytes(12), (b) => ALNUM[b % ALNUM.length]).join("")}`;
|
|
65
118
|
const secret = () => randomBytes(16).toString("hex");
|
|
@@ -88,7 +141,7 @@ export async function startFakeApi(opts: { voiceoverCharLimit?: number; imageLim
|
|
|
88
141
|
const m = meter(userId);
|
|
89
142
|
return {
|
|
90
143
|
userId: userId ?? "u-test", handle: handleOf(userId ?? "u-test"),
|
|
91
|
-
quota: { voiceoverChars: { used: m.chars, limit: limits.chars }, images: { used: m.images, limit: limits.images }, 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() },
|
|
92
145
|
// What the user gave the shared library that was accepted: their own items that are published. One waiting for review does not count.
|
|
93
146
|
contributions: [...store.values()].filter((x) => x.owner === userId && x.committed && x.item.visibility === "published").length,
|
|
94
147
|
};
|
|
@@ -122,7 +175,10 @@ export async function startFakeApi(opts: { voiceoverCharLimit?: number; imageLim
|
|
|
122
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" };
|
|
123
176
|
store.set(id, { item, filename, contentType: input.contentType, owner: userId, committed: false });
|
|
124
177
|
const token = secret();
|
|
125
|
-
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
|
+
});
|
|
126
182
|
return { id, uploadUrl: `${origin}/upload/${token}` };
|
|
127
183
|
},
|
|
128
184
|
libraryCommit: ({ id }, { userId }) => {
|
|
@@ -155,6 +211,76 @@ export async function startFakeApi(opts: { voiceoverCharLimit?: number; imageLim
|
|
|
155
211
|
if (shareable) store.set(id, { item: { id, kind: "image", title: cut(prompt, 60), description: prompt, tags, meta: { aspect }, visibility: "review" }, file, filename: file.filename, contentType: file.contentType, owner: userId, committed: true });
|
|
156
212
|
return { url: fileUrl(file), ext: "png", contentType: "image/png", ...(shareable ? { libraryId: id } : {}) };
|
|
157
213
|
},
|
|
214
|
+
clipStart: (input, { userId }) => {
|
|
215
|
+
if (opts.noClipProvider) throw new Fail(400, "invalid_request", "Clip generation is not available on this server yet.");
|
|
216
|
+
const m = meter(userId);
|
|
217
|
+
if (m.clips + 1 > limits.clips) throw new Fail(429, "quota_exceeded", `Clip quota used up. It resets on ${resetDate().slice(0, 10)}.`);
|
|
218
|
+
m.clips++;
|
|
219
|
+
usage.clips++;
|
|
220
|
+
const id = nextId("clip");
|
|
221
|
+
jobs.set(id, { owner: userId ?? "u-test", prompt: input.prompt, aspect: input.aspect, greenScreen: input.greenScreen, durationSec: input.durationSec, shareable: input.shareable, tags: input.tags, asks: 0, charged: true });
|
|
222
|
+
return { id, status: "pending" };
|
|
223
|
+
},
|
|
224
|
+
clipStatus: ({ id }, { userId }) => {
|
|
225
|
+
const job = jobs.get(id);
|
|
226
|
+
if (!job || job.owner !== (userId ?? "u-test")) throw new Fail(404, "not_found", `No clip with id ${id}.`);
|
|
227
|
+
// The first ask finds it still working; from the second it has ended.
|
|
228
|
+
if (++job.asks === 1 || job.prompt.includes(CLIP_STALL)) return { id, status: "pending" };
|
|
229
|
+
if (job.prompt.includes(CLIP_FAIL)) {
|
|
230
|
+
// A failed clip is not charged: the reservation is given back, once.
|
|
231
|
+
if (job.charged) { job.charged = false; meter(userId).clips--; usage.clips--; }
|
|
232
|
+
return { id, status: "failed", message: "The video provider could not make this clip. Change the prompt and try again." };
|
|
233
|
+
}
|
|
234
|
+
const file = clipFile(job.greenScreen);
|
|
235
|
+
if (job.shareable && !job.libraryId) {
|
|
236
|
+
job.libraryId = nextId("clip");
|
|
237
|
+
const meta = { aspect: job.aspect, durationSec: job.durationSec, ...(job.greenScreen ? { greenScreen: true } : {}) };
|
|
238
|
+
store.set(job.libraryId, { item: { id: job.libraryId, kind: "clip", title: cut(job.prompt, 60), description: job.prompt, tags: job.tags, meta, visibility: "review" }, file, filename: file.filename, contentType: file.contentType, owner: userId, committed: true });
|
|
239
|
+
}
|
|
240
|
+
return {
|
|
241
|
+
id, status: "done", url: fileUrl(file), ext: "mp4", contentType: file.contentType, durationSec: job.durationSec, greenScreen: job.greenScreen,
|
|
242
|
+
...(job.libraryId ? { libraryId: job.libraryId } : {}),
|
|
243
|
+
};
|
|
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
|
+
},
|
|
158
284
|
publicLibrary: ({ q, kind, page }) => {
|
|
159
285
|
// Published items only. Newest first; with a query, best match first and equal matches newest first.
|
|
160
286
|
const want = q ? words(q) : undefined;
|
|
@@ -164,7 +290,7 @@ export async function startFakeApi(opts: { voiceoverCharLimit?: number; imageLim
|
|
|
164
290
|
.filter((x) => x.s.item.visibility === "published" && (!kind || x.s.item.kind === kind) && (!want || x.score > 0))
|
|
165
291
|
.sort((a, b) => b.score - a.score || b.order - a.order).map((x) => x.s);
|
|
166
292
|
const p = page ?? 1, size = 24;
|
|
167
|
-
const items = all.slice((p - 1) * size, p * size).map((s) => (s.file && PREVIEW_OF_ORIGINAL.includes(s.item.kind) ? { ...s.item, previewUrl: fileUrl(s.file) } : s.item));
|
|
293
|
+
const items = all.slice((p - 1) * size, p * size).map((s) => (s.file && (PREVIEW_OF_ORIGINAL.includes(s.item.kind) || shortClip(s.item)) ? { ...s.item, previewUrl: fileUrl(s.file) } : s.item));
|
|
168
294
|
return { items, page: p, hasMore: all.length > p * size };
|
|
169
295
|
},
|
|
170
296
|
};
|
|
@@ -185,15 +311,14 @@ export async function startFakeApi(opts: { voiceoverCharLimit?: number; imageLim
|
|
|
185
311
|
}
|
|
186
312
|
if (req.method === "PUT" && url.pathname.startsWith("/upload/")) {
|
|
187
313
|
const up = uploads.get(url.pathname.slice("/upload/".length));
|
|
188
|
-
|
|
189
|
-
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." } });
|
|
190
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.
|
|
191
316
|
// (A real server may be kinder; the contract promises clients no more than this.)
|
|
192
|
-
if (up.used ||
|
|
317
|
+
if (up.used || up.sink.has()) throw new Fail(400, "invalid_request", "This upload URL has already been used.");
|
|
193
318
|
up.used = true;
|
|
194
319
|
if (req.headers["content-type"] !== up.contentType) throw new Fail(400, "invalid_request", `The upload must be sent as ${up.contentType}.`);
|
|
195
320
|
if (raw.length !== up.bytes) throw new Fail(400, "invalid_request", `The upload must be exactly ${up.bytes} bytes.`);
|
|
196
|
-
|
|
321
|
+
up.sink.put(new Uint8Array(raw));
|
|
197
322
|
return send(200, {});
|
|
198
323
|
}
|
|
199
324
|
|
|
@@ -219,6 +344,12 @@ export async function startFakeApi(opts: { voiceoverCharLimit?: number; imageLim
|
|
|
219
344
|
return {
|
|
220
345
|
baseUrl: `${origin}/api/v1`,
|
|
221
346
|
usage,
|
|
347
|
+
// Changes how many clips a user may start from now on, as `clipLimit` does at the start.
|
|
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; },
|
|
222
353
|
approve(userCode: string, userId = "u-test") { for (const d of devices.values()) if (d.userCode === userCode) { d.approved = true; d.userId = userId; } },
|
|
223
354
|
// A ready token, for tests that are not about logging in.
|
|
224
355
|
login(userId = "u-test") { const t = nextId("tok"); tokens.set(t, userId); return t; },
|