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.
@@ -0,0 +1,51 @@
1
+ import { execFile } from "node:child_process";
2
+ import { rmSync } from "node:fs";
3
+ import { promisify } from "node:util";
4
+
5
+ const run = promisify(execFile);
6
+ const PURE_GREEN = "0x00FF00";
7
+
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> {
39
+ try {
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]);
45
+ } catch (e) {
46
+ rmSync(output, { force: true });
47
+ if (e instanceof Error && e.message === NOT_GREEN) throw e;
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.");
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.");
50
+ }
51
+ }
@@ -5,6 +5,10 @@ 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, 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 };
11
+
8
12
  // True when the stored recording no longer matches what the plan asks for. An entry from before this was tracked counts as different.
9
13
  export function voiceoverStale(vo: Voiceover, scene: ScenePlan["scenes"][number], plan: ScenePlan): boolean {
10
14
  return vo.text !== scene.narration || vo.voiceId !== plan.voiceId || vo.speed !== PACE_SPEED[plan.pace ?? "normal"];
@@ -16,6 +20,7 @@ export function buildManifest(project: Project, plan: ScenePlan): AssetManifest
16
20
  const voiceovers = project.readJsonOr<Record<string, Voiceover>>(FILES.voiceovers, {});
17
21
  if (plan.scenes.some((s) => !voiceovers[s.id])) return undefined;
18
22
  const images = project.readJsonOr<Record<string, string>>(FILES.images, {});
23
+ const clips = project.readJsonOr<Record<string, ClipRecord>>(FILES.clips, {});
19
24
  const footage = project.footage();
20
25
  const byId = new Map(project.assets().map((a) => [a.id, a]));
21
26
 
@@ -41,6 +46,7 @@ export function buildManifest(project: Project, plan: ScenePlan): AssetManifest
41
46
  voiceoverKey: voiceovers[scene.id].key,
42
47
  words: voiceovers[scene.id].words,
43
48
  ...(scene.treatment === "illustration" && images[scene.id] ? { imageKey: images[scene.id] } : {}),
49
+ ...(scene.treatment === "clip" && clips[scene.id] ? { clipKey: clips[scene.id].key, ...(clips[scene.id].keyedKey ? { clipKeyedKey: clips[scene.id].keyedKey } : {}) } : {}),
44
50
  userAssetKeys: scene.userAssetIds.map((id) => {
45
51
  const a = byId.get(id);
46
52
  if (!a) throw new Error(`Scene ${scene.id} references unknown asset ${id}. Add it with \`reelkit assets upload\`.`);
@@ -56,6 +62,7 @@ export function buildManifest(project: Project, plan: ScenePlan): AssetManifest
56
62
  export function missingAssets(project: Project, plan: ScenePlan): string[] {
57
63
  const voiceovers = project.readJsonOr<Record<string, Voiceover>>(FILES.voiceovers, {});
58
64
  const images = project.readJsonOr<Record<string, string>>(FILES.images, {});
65
+ const clips = project.readJsonOr<Record<string, ClipRecord>>(FILES.clips, {});
59
66
  const problems: string[] = [];
60
67
  for (const s of plan.scenes) {
61
68
  if (!voiceovers[s.id]) problems.push(`Scene ${s.id} has no voiceover.`);
@@ -66,6 +73,11 @@ export function missingAssets(project: Project, plan: ScenePlan): string[] {
66
73
  if (!images[s.id]) problems.push(`Scene ${s.id} has no image.`);
67
74
  else if (!project.exists(images[s.id])) problems.push(`Missing file: ${images[s.id]}`);
68
75
  }
76
+ if (s.treatment === "clip") {
77
+ const clip = clips[s.id];
78
+ if (!clip) problems.push(`Scene ${s.id} has no clip. Run \`reelkit assets gen clip --scene ${s.id}\`.`);
79
+ else for (const key of [clip.key, ...(clip.keyedKey ? [clip.keyedKey] : [])]) if (!project.exists(key)) problems.push(`Missing file: ${key}`);
80
+ }
69
81
  }
70
82
  return problems;
71
83
  }
@@ -5,7 +5,7 @@ import { AspectSchema, type AssetRecord } from "../pipeline/schema";
5
5
 
6
6
  export const FILES = {
7
7
  config: "reelkit.json", plan: "plan.json", manifest: "manifest.json",
8
- assetIndex: "assets/index.json", voiceovers: "assets/voiceovers.json", images: "assets/images.json", library: "assets/library.json",
8
+ assetIndex: "assets/index.json", voiceovers: "assets/voiceovers.json", images: "assets/images.json", clips: "assets/clips.json", library: "assets/library.json",
9
9
  } as const;
10
10
 
11
11
  // "path: message", or just the message for a problem at the root of the file.
@@ -0,0 +1,12 @@
1
+ import React from "react";
2
+ import { useMedia } from "./media";
3
+ import { AbsoluteFill, OffthreadVideo } from "remotion";
4
+
5
+ // A generated or library video clip as a scene's picture. Place it inside the scene's SceneFrame, as the bottom layer, so it starts with the scene.
6
+ // When the scene runs longer than the clip, the clip holds its last frame (it does not loop: a looped person or animal jumps back and shows the seam).
7
+ export const ClipLayer: React.FC<{ src: string; muted?: boolean; dim?: number }> = ({ src, muted = true, dim = 0 }) => (
8
+ <AbsoluteFill>
9
+ <OffthreadVideo src={useMedia(src)} muted={muted} style={{ width: "100%", height: "100%", objectFit: "cover" }} />
10
+ {dim > 0 ? <AbsoluteFill style={{ backgroundColor: `rgba(0,0,0,${dim})` }} /> : null}
11
+ </AbsoluteFill>
12
+ );
@@ -0,0 +1,15 @@
1
+ import React from "react";
2
+ import { useMedia } from "./media";
3
+ import { AbsoluteFill, OffthreadVideo } from "remotion";
4
+
5
+ // A green-screen clip with its green keyed out (the transparent .webm that `--green` makes), laid over whatever is beneath it.
6
+ // x and y are where the clip's bottom-centre sits, as fractions of the frame (x 0 is the left edge, y 1 the bottom edge); scale is the clip's
7
+ // width as a fraction of the frame width. The clip has the frame's own shape, so it is as tall as the frame is, times scale.
8
+ // Like ClipLayer it holds its last frame when the scene is longer.
9
+ export const KeyedClip: React.FC<{ src: string; x?: number; y?: number; scale?: number; muted?: boolean }> = ({ src, x = 0.5, y = 1, scale = 1, muted = true }) => (
10
+ <AbsoluteFill style={{ pointerEvents: "none" }}>
11
+ <div style={{ position: "absolute", left: `${x * 100}%`, top: `${y * 100}%`, width: `${scale * 100}%`, transform: "translate(-50%, -100%)" }}>
12
+ <OffthreadVideo src={useMedia(src)} muted={muted} transparent style={{ width: "100%", display: "block" }} />
13
+ </div>
14
+ </AbsoluteFill>
15
+ );
@@ -32,6 +32,14 @@ FootageLayer { src: string; muted?: boolean; dim?: number }
32
32
  Full-frame user footage. Place it once, outside the scenes, as the bottom layer. dim (0-1) darkens it for legibility.
33
33
  <FootageLayer src={urls[manifest.footageKey]} dim={0.25} />
34
34
 
35
+ ClipLayer { src: string; muted?: boolean; dim?: number }
36
+ Full-frame generated or library video clip as a scene's picture (treatment "clip"). Place it inside the scene's SceneFrame as the bottom layer, so it starts with the scene. When the scene is longer than the clip it holds the last frame; it never loops. dim (0-1) darkens it for legibility.
37
+ <ClipLayer src={urls[s.clipKey]} dim={0.2} />
38
+
39
+ KeyedClip { src: string; x?: number; y?: number; scale?: number; muted?: boolean }
40
+ A green-screen clip with the green keyed out (the transparent .webm, s.clipKeyedKey). Lay it over your own background or screenshot, below the captions. x and y place its bottom centre as fractions of the frame (default x 0.5, y 1: bottom centre); scale is its width as a fraction of the frame width (default 1). Holds its last frame when the scene is longer.
41
+ <KeyedClip src={urls[s.clipKeyedKey]} x={0.5} y={1} scale={0.8} />
42
+
35
43
  BgMesh { bg: string; hero: string; accent?: string }
36
44
  The bottom layer of the video: the base colour with two slow-drifting soft colour fields. Use it instead of a flat background.
37
45
  <BgMesh bg={palette.bg} hero={palette.hero} accent={palette.accent} />
@@ -1,4 +1,5 @@
1
1
  export { Captions } from "./Captions";
2
+ export { ClipLayer } from "./ClipLayer";
2
3
  export { Counter } from "./Counter";
3
4
  export { Entrance, WordReveal } from "./Entrance";
4
5
  export { FootageLayer } from "./FootageLayer";
@@ -6,6 +7,7 @@ export { brandColor, Icon } from "./Icon";
6
7
  export type { GlyphName, IconName } from "./Icon";
7
8
  export type { BrandName } from "./brand-icons";
8
9
  export { KenBurnsImage } from "./KenBurnsImage";
10
+ export { KeyedClip } from "./KeyedClip";
9
11
  export { BgMesh, Grade, Grain, Vignette } from "./Layers";
10
12
  export { LowerThird } from "./LowerThird";
11
13
  export { SceneFrame } from "./SceneFrame";
@@ -15,8 +15,16 @@ export type Harness = {
15
15
  // What the website does when a signed-in user enters the code.
16
16
  approve(userCode: string): unknown;
17
17
  close(): void | Promise<void>;
18
+ // Optional, for the clip tests. A prompt that makes a clip job fail (the fake fails any prompt containing "clip-fail"); without it the
19
+ // failing-job case is skipped.
20
+ clipFailPrompt?: string;
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
+ 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;
18
26
  };
19
- export type StartOptions = { voiceoverChars?: number; images?: number };
27
+ export type StartOptions = { voiceoverChars?: number; images?: number; clips?: number; cutoutSeconds?: number };
20
28
 
21
29
  const PNG = new Uint8Array(Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", "base64"));
22
30
  const DATE = /\d{4}-\d{2}-\d{2}/;
@@ -75,6 +83,10 @@ export function runConformance(name: string, start: (opts: StartOptions) => Prom
75
83
  expect(me.handle.length).toBeGreaterThan(0);
76
84
  expect(me.quota.voiceoverChars.used).toBe(0);
77
85
  expect(me.quota.images.used).toBe(0);
86
+ expect(me.quota.clips.used).toBe(0);
87
+ expect(me.quota.clips.limit).toBeGreaterThan(0);
88
+ expect(me.quota.cutoutSeconds.used).toBe(0);
89
+ expect(me.quota.cutoutSeconds.limit).toBeGreaterThan(0);
78
90
  expect(me.quota.voiceoverChars.limit).toBeGreaterThan(0);
79
91
  expect(me.contributions).toBe(0);
80
92
  const resets = new Date(me.quota.resetsAt);
@@ -222,6 +234,10 @@ export function runConformance(name: string, start: (opts: StartOptions) => Prom
222
234
  ["a voiceover text over 5000 characters", (api: Api) => api("voiceover", { text: "a".repeat(5001) })],
223
235
  ["a speed over 1.3", (api: Api) => api("voiceover", { text: "hi", speed: 1.4 })],
224
236
  ["an unknown aspect", (api: Api) => api("images", { prompt: "a", aspect: "4:3" as never, shareable: false, tags: [] })],
237
+ ["a clip with no prompt", (api: Api) => api("clipStart", { prompt: "", aspect: "9:16", shareable: false, tags: [] })],
238
+ ["a clip of 7 seconds", (api: Api) => api("clipStart", { prompt: "a", aspect: "9:16", durationSec: 7 as never, shareable: false, tags: [] })],
239
+ ["a clip prompt with a NUL", (api: Api) => api("clipStart", { prompt: "a\u0000b", aspect: "9:16", shareable: false, tags: [] })],
240
+ ["a clip status id with a slash", (api: Api) => api("clipStatus", { id: "../x" })],
225
241
  ])("refuses %s as invalid_request", async (_what, call) => {
226
242
  expect((await failure(call(await as("user-bad000001")))).code).toBe("invalid_request");
227
243
  });
@@ -269,6 +285,8 @@ export function runConformance(name: string, start: (opts: StartOptions) => Prom
269
285
  it("generation needs a login", async () => {
270
286
  expect((await failure(anon("voiceover", { text: "hi" }))).code).toBe("unauthenticated");
271
287
  expect((await failure(anon("images", { prompt: "a", aspect: "9:16", shareable: false, tags: [] }))).code).toBe("unauthenticated");
288
+ expect((await failure(anon("clipStart", { prompt: "a", aspect: "9:16", shareable: false, tags: [] }))).code).toBe("unauthenticated");
289
+ expect((await failure(anon("clipStatus", { id: "clip-x" }))).code).toBe("unauthenticated");
272
290
  });
273
291
 
274
292
  it("images: a private one is only a file; a shareable one also enters the library for review, owned by the caller", async () => {
@@ -296,6 +314,214 @@ export function runConformance(name: string, start: (opts: StartOptions) => Prom
296
314
  });
297
315
  });
298
316
 
317
+ describe("clips", () => {
318
+ // Asks until the job is no longer pending: at most 20 times, with the harness's pause between asks.
319
+ const settle = async (api: Api, id: string) => {
320
+ for (let i = 0; i < 20; i++) {
321
+ const st = await api("clipStatus", { id });
322
+ if (st.status !== "pending") return st;
323
+ await new Promise((r) => setTimeout(r, h.clipPollMs ?? 20));
324
+ }
325
+ throw new Error(`clip ${id} was still pending after 20 asks`);
326
+ };
327
+ const ask = { aspect: "9:16" as const, shareable: false, tags: [] as string[] };
328
+
329
+ it("start answers pending at once, the job ends done with a file that downloads, and the clip is charged to this user only", async () => {
330
+ const api = await as("user-clip00001"), other = await as("user-clip00002");
331
+ const started = await api("clipStart", { ...ask, prompt: "a dog running on a beach" });
332
+ expect(started.status).toBe("pending");
333
+ expect(started.id).toBeTruthy();
334
+ // The clip is reserved at the start, before it is finished.
335
+ expect((await api("me", {})).quota.clips.used).toBe(1);
336
+ const done = await settle(api, started.id);
337
+ expect(done).toMatchObject({ id: started.id, status: "done", ext: "mp4" });
338
+ expect(done.url).toBeTruthy();
339
+ expect(done.contentType).toMatch(/^video\//);
340
+ expect(done.message).toBeUndefined();
341
+ await download(done.url!, join(dest, "clip.mp4"));
342
+ expect(readFileSync(join(dest, "clip.mp4")).length).toBeGreaterThan(0);
343
+ expect((await api("me", {})).quota.clips.used).toBe(1);
344
+ expect((await other("me", {})).quota.clips.used).toBe(0);
345
+ });
346
+
347
+ it("a clip id belongs to its caller: another user, or an unknown id, is not_found", async () => {
348
+ const api = await as("user-clip00003"), other = await as("user-clip00004");
349
+ const started = await api("clipStart", { ...ask, prompt: "a quiet harbour at dawn" });
350
+ expect((await failure(other("clipStatus", { id: started.id }))).code).toBe("not_found");
351
+ expect((await failure(api("clipStatus", { id: "clip-nosuchjob" }))).code).toBe("not_found");
352
+ });
353
+
354
+ it("a green-screen clip is accepted and finishes like any other", async () => {
355
+ const api = await as("user-clip00005");
356
+ const started = await api("clipStart", { ...ask, prompt: "a person waving, flat green background", greenScreen: true, durationSec: 10 });
357
+ const done = await settle(api, started.id);
358
+ expect(done.status).toBe("done");
359
+ await download(done.url!, join(dest, "green.mp4"));
360
+ expect(readFileSync(join(dest, "green.mp4")).length).toBeGreaterThan(0);
361
+ });
362
+
363
+ it("a shareable clip enters the library in review, owned by the caller, and is not found by search", async () => {
364
+ const api = await as("user-clip00006"), other = await as("user-clip00007");
365
+ const started = await api("clipStart", { ...ask, prompt: "a clipshare lighthouse in a storm", shareable: true, tags: ["lighthouse"] });
366
+ const done = await settle(api, started.id);
367
+ expect(done.status).toBe("done");
368
+ expect(done.libraryId).toBeTruthy();
369
+ expect((await api("libraryPull", { id: done.libraryId! })).item).toMatchObject({ kind: "clip", visibility: "review" });
370
+ expect((await api("librarySearch", { q: "clipshare lighthouse" })).items.map((i) => i.id)).not.toContain(done.libraryId);
371
+ expect((await failure(other("libraryPull", { id: done.libraryId! }))).code).toBe("not_found");
372
+ const plain = await settle(api, (await api("clipStart", { ...ask, prompt: "a private clip" })).id);
373
+ expect(plain.libraryId).toBeUndefined();
374
+ });
375
+
376
+ it("a failed job says why in one line and is not charged", async () => {
377
+ if (!h.clipFailPrompt) return;
378
+ const api = await as("user-clip00008");
379
+ const started = await api("clipStart", { ...ask, prompt: h.clipFailPrompt });
380
+ const st = await settle(api, started.id);
381
+ expect(st.status).toBe("failed");
382
+ expect(st.message?.length).toBeGreaterThan(0);
383
+ expect(st.message).not.toContain("\n");
384
+ expect(st.url).toBeUndefined();
385
+ expect((await api("me", {})).quota.clips.used).toBe(0);
386
+ });
387
+
388
+ it("at the limit a new clip is quota_exceeded with the reset date, charged nothing; other users are unaffected", async () => {
389
+ const t = await start({ clips: 1 });
390
+ try {
391
+ const api = createClient({ baseUrl: t.baseUrl, token: await t.login("user-clipq0001") });
392
+ const other = createClient({ baseUrl: t.baseUrl, token: await t.login("user-clipq0002") });
393
+ await api("clipStart", { ...ask, prompt: "first" });
394
+ const e = await failure(api("clipStart", { ...ask, prompt: "second" }));
395
+ expect(e.code).toBe("quota_exceeded");
396
+ expect(e.message).toMatch(DATE);
397
+ const me = await api("me", {});
398
+ expect(me.quota.clips).toEqual({ used: 1, limit: 1 });
399
+ expect(e.message).toContain(me.quota.resetsAt.slice(0, 10));
400
+ expect((await other("me", {})).quota.clips.used).toBe(0);
401
+ await other("clipStart", { ...ask, prompt: "third" });
402
+ } finally { await t.close(); }
403
+ });
404
+ });
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
+
299
525
  describe("quota", () => {
300
526
  it("passes exactly at the limit; one over is quota_exceeded with the reset date; other users are unaffected", async () => {
301
527
  const t = await start({ voiceoverChars: 10, images: 1 });