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.
@@ -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
- const uploads = new Map<string, { id: string; contentType: string; bytes: number; used: boolean }>();
96
+ // `sink` is where the accepted bytes go: a library item, or a cutout job.
97
+ const uploads = new Map<string, { contentType: string; bytes: number; used: boolean; sink: { has(): boolean; put(bytes: Uint8Array): void } | undefined }>();
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, { id, contentType: input.contentType, bytes: input.bytes, used: false });
178
+ uploads.set(token, {
179
+ contentType: input.contentType, bytes: input.bytes, used: false,
180
+ sink: { has: () => Boolean(store.get(id)?.file), put: (bytes) => { const s = store.get(id)!; s.file = { filename: s.filename, contentType: s.contentType, bytes }; } },
181
+ });
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
- const s = up && store.get(up.id);
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 || s.file) throw new Fail(400, "invalid_request", "This upload URL has already been used.");
317
+ if (up.used || up.sink.has()) throw new Fail(400, "invalid_request", "This upload URL has already been used.");
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
- s.file = { filename: s.filename, contentType: s.contentType, bytes: new Uint8Array(raw) };
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; },