reelkit-cli 0.1.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/LICENSE +21 -0
- package/README.md +91 -0
- package/bin/reelkit.mjs +5 -0
- package/package.json +89 -0
- package/skill/SKILL.md +101 -0
- package/skill/THIRD_PARTY.md +20 -0
- package/skill/command.md +5 -0
- package/skill/reference/asset-reuse.md +31 -0
- package/skill/reference/captions.md +57 -0
- package/skill/reference/component-authoring.md +26 -0
- package/skill/reference/kit.md +135 -0
- package/skill/reference/motion-design.md +62 -0
- package/skill/reference/remotion-composition.md +58 -0
- package/skill/reference/scene-treatments.md +35 -0
- package/skill/reference/scriptwriting.md +44 -0
- package/skill/reference/sound-design.md +43 -0
- package/src/agents.ts +103 -0
- package/src/api/client.ts +58 -0
- package/src/cli.ts +91 -0
- package/src/commands/assets.ts +196 -0
- package/src/commands/auth.ts +118 -0
- package/src/commands/build.ts +109 -0
- package/src/commands/init.ts +32 -0
- package/src/commands/install.ts +24 -0
- package/src/commands/plan.ts +45 -0
- package/src/context.ts +21 -0
- package/src/contract/index.ts +157 -0
- package/src/credentials.ts +55 -0
- package/src/open.ts +48 -0
- package/src/pipeline/review.ts +30 -0
- package/src/pipeline/schema.ts +110 -0
- package/src/pipeline/timing.ts +30 -0
- package/src/project/manifest.ts +71 -0
- package/src/project/probe.ts +54 -0
- package/src/project/project.ts +58 -0
- package/src/project/serve.ts +81 -0
- package/src/remotion/Root.tsx +29 -0
- package/src/remotion/kit/Captions.tsx +77 -0
- package/src/remotion/kit/Counter.tsx +18 -0
- package/src/remotion/kit/Entrance.tsx +53 -0
- package/src/remotion/kit/FootageLayer.tsx +10 -0
- package/src/remotion/kit/Icon.tsx +35 -0
- package/src/remotion/kit/KenBurnsImage.tsx +18 -0
- package/src/remotion/kit/Layers.tsx +46 -0
- package/src/remotion/kit/LowerThird.tsx +17 -0
- package/src/remotion/kit/SceneFrame.tsx +19 -0
- package/src/remotion/kit/ScreenOverlay.tsx +15 -0
- package/src/remotion/kit/Sfx.tsx +11 -0
- package/src/remotion/kit/TitleCard.tsx +23 -0
- package/src/remotion/kit/Voiceover.tsx +5 -0
- package/src/remotion/kit/brand-icons.ts +37 -0
- package/src/remotion/kit/docs.ts +98 -0
- package/src/remotion/kit/index.ts +20 -0
- package/src/remotion/kit/media.ts +10 -0
- package/src/remotion/kit/theme.ts +131 -0
- package/src/remotion/types.ts +2 -0
- package/src/render/component-preview.ts +65 -0
- package/src/render/deps.ts +8 -0
- package/src/render/render.ts +67 -0
- package/src/render/validate.ts +191 -0
- package/src/testing/conformance.ts +396 -0
- package/src/testing/fake-api.ts +228 -0
- package/src/testing/fixtures.ts +44 -0
- package/src/ui/CommandView.tsx +18 -0
- package/src/ui/run.tsx +20 -0
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
import { mkdtempSync, readFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
5
|
+
import { ApiFailure, createClient, download, uploadTo, type Api } from "../api/client";
|
|
6
|
+
import { isPlainText, MAX_UPLOAD_BYTES, type LibraryItem } from "../contract";
|
|
7
|
+
|
|
8
|
+
// What the suite needs from a server under test: a running instance, and the few things a person or an operator does outside the API.
|
|
9
|
+
export type Harness = {
|
|
10
|
+
baseUrl: string;
|
|
11
|
+
// A token for this user, as if they had logged in. The same id is the same user.
|
|
12
|
+
login(userId?: string): string | Promise<string>;
|
|
13
|
+
// Adds a published item (with its file) to the library. Items added later count as newer.
|
|
14
|
+
seedPublished(item: LibraryItem, file: { filename: string; contentType: string; bytes: Uint8Array }): void | Promise<void>;
|
|
15
|
+
// What the website does when a signed-in user enters the code.
|
|
16
|
+
approve(userCode: string): unknown;
|
|
17
|
+
close(): void | Promise<void>;
|
|
18
|
+
};
|
|
19
|
+
export type StartOptions = { voiceoverChars?: number; images?: number };
|
|
20
|
+
|
|
21
|
+
const PNG = new Uint8Array(Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", "base64"));
|
|
22
|
+
const DATE = /\d{4}-\d{2}-\d{2}/;
|
|
23
|
+
const failure = async (p: Promise<unknown>) => { try { await p; } catch (e) { return e as ApiFailure; } throw new Error("expected a failure"); };
|
|
24
|
+
const put = (url: string, bytes: Uint8Array, contentType: string) => fetch(url, { method: "PUT", headers: { "content-type": contentType }, body: bytes as BodyInit });
|
|
25
|
+
const item = (id: string, kind: LibraryItem["kind"], text: string, tags: string[] = []): LibraryItem => ({ id, kind, title: text, description: text, tags, meta: {}, visibility: "published" });
|
|
26
|
+
const png = (id: string, text: string, tags?: string[]) => [item(id, "image", text, tags), { filename: "p.png", contentType: "image/png", bytes: PNG }] as const;
|
|
27
|
+
const upload = (over: object = {}) => ({ kind: "image" as const, title: "Mine", description: "my cat", tags: ["cat"], meta: {}, filename: "cat.png", contentType: "image/png", bytes: PNG.length, shareable: true, ...over });
|
|
28
|
+
|
|
29
|
+
// The same black-box tests, run against every implementation of the contract (the fake API here, the real one in the API repo).
|
|
30
|
+
// Whatever one of them does that these tests notice, the other must do too.
|
|
31
|
+
export function runConformance(name: string, start: (opts: StartOptions) => Promise<Harness>) {
|
|
32
|
+
describe(`${name} conforms to the contract`, () => {
|
|
33
|
+
const dest = mkdtempSync(join(tmpdir(), "rk-shared-"));
|
|
34
|
+
let h: Harness;
|
|
35
|
+
let anon: Api;
|
|
36
|
+
beforeAll(async () => { h = await start({}); anon = createClient({ baseUrl: h.baseUrl }); });
|
|
37
|
+
afterAll(async () => { await h.close(); });
|
|
38
|
+
const as = async (userId: string) => createClient({ baseUrl: h.baseUrl, token: await h.login(userId) });
|
|
39
|
+
|
|
40
|
+
describe("the device flow", () => {
|
|
41
|
+
it("is pending until approved, hands out one working token, then the code is spent", async () => {
|
|
42
|
+
const start = await anon("deviceStart", { label: "laptop" });
|
|
43
|
+
expect(start.userCode).toBeTruthy();
|
|
44
|
+
expect(start.deviceCode).not.toBe(start.userCode);
|
|
45
|
+
expect(await anon("deviceToken", { deviceCode: start.deviceCode })).toEqual({ status: "pending" });
|
|
46
|
+
await h.approve(start.userCode);
|
|
47
|
+
const done = await anon("deviceToken", { deviceCode: start.deviceCode });
|
|
48
|
+
expect(done.status).toBe("approved");
|
|
49
|
+
if (done.status !== "approved") return;
|
|
50
|
+
expect((await createClient({ baseUrl: h.baseUrl, token: done.token })("me", {})).userId).toBeTruthy();
|
|
51
|
+
expect(await anon("deviceToken", { deviceCode: start.deviceCode })).toEqual({ status: "expired" });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("answers an unknown device code with expired", async () => {
|
|
55
|
+
expect(await anon("deviceToken", { deviceCode: "no-such-code" })).toEqual({ status: "expired" });
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("revoke ends the token", async () => {
|
|
59
|
+
const api = await as("user-revoke01");
|
|
60
|
+
await api("me", {});
|
|
61
|
+
expect(await api("revoke", {})).toEqual({ revoked: true });
|
|
62
|
+
expect((await failure(api("me", {}))).code).toBe("unauthenticated");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("refuses a missing or unknown token as unauthenticated", async () => {
|
|
66
|
+
expect((await failure(anon("me", {}))).code).toBe("unauthenticated");
|
|
67
|
+
expect((await failure(createClient({ baseUrl: h.baseUrl, token: "rk_nope" })("me", {}))).code).toBe("unauthenticated");
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("me", () => {
|
|
72
|
+
it("for a user with no activity has empty meters, a reset date and no contributions", async () => {
|
|
73
|
+
const me = await (await as("user-fresh0001"))("me", {});
|
|
74
|
+
expect(me.userId).toBe("user-fresh0001");
|
|
75
|
+
expect(me.handle.length).toBeGreaterThan(0);
|
|
76
|
+
expect(me.quota.voiceoverChars.used).toBe(0);
|
|
77
|
+
expect(me.quota.images.used).toBe(0);
|
|
78
|
+
expect(me.quota.voiceoverChars.limit).toBeGreaterThan(0);
|
|
79
|
+
expect(me.contributions).toBe(0);
|
|
80
|
+
const resets = new Date(me.quota.resetsAt);
|
|
81
|
+
expect(resets.getTime()).toBeGreaterThan(Date.now());
|
|
82
|
+
expect([resets.getUTCDate(), resets.getUTCHours(), resets.getUTCMinutes()]).toEqual([1, 0, 0]);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe("uploads", () => {
|
|
87
|
+
it("upload, PUT, commit: in review so search misses it; the owner can pull it; another user cannot pull or commit it", async () => {
|
|
88
|
+
const mine = await as("user-up00001"), other = await as("user-up00002");
|
|
89
|
+
const up = await mine("libraryUpload", upload({ title: "Zebra crossing", description: "stripes zebra" }));
|
|
90
|
+
expect((await put(up.uploadUrl, PNG, "image/png")).ok).toBe(true);
|
|
91
|
+
const { item } = await mine("libraryCommit", { id: up.id });
|
|
92
|
+
expect(item).toMatchObject({ id: up.id, kind: "image", visibility: "review" });
|
|
93
|
+
expect((await mine("me", {})).contributions).toBe(1);
|
|
94
|
+
// An item that is not published never appears, whoever searches.
|
|
95
|
+
expect((await mine("librarySearch", { q: "zebra" })).items.map((i) => i.id)).not.toContain(up.id);
|
|
96
|
+
expect((await other("librarySearch", { q: "zebra" })).items.map((i) => i.id)).not.toContain(up.id);
|
|
97
|
+
const pulled = await mine("libraryPull", { id: up.id });
|
|
98
|
+
await download(pulled.url, join(dest, "mine.png"));
|
|
99
|
+
expect(new Uint8Array(readFileSync(join(dest, "mine.png")))).toEqual(PNG);
|
|
100
|
+
expect((await failure(other("libraryPull", { id: up.id }))).code).toBe("not_found");
|
|
101
|
+
expect((await failure(other("libraryCommit", { id: up.id }))).code).toBe("not_found");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("a private upload is never searchable and nobody else can pull it", async () => {
|
|
105
|
+
const mine = await as("user-up00003"), other = await as("user-up00004");
|
|
106
|
+
const up = await mine("libraryUpload", upload({ shareable: false, title: "Hidden gecko", description: "hidden gecko" }));
|
|
107
|
+
await put(up.uploadUrl, PNG, "image/png");
|
|
108
|
+
expect((await mine("libraryCommit", { id: up.id })).item.visibility).toBe("private");
|
|
109
|
+
// Contributions are shared uploads only.
|
|
110
|
+
expect((await mine("me", {})).contributions).toBe(0);
|
|
111
|
+
expect((await mine("librarySearch", { q: "gecko" })).items.map((i) => i.id)).not.toContain(up.id);
|
|
112
|
+
expect((await failure(other("libraryPull", { id: up.id }))).code).toBe("not_found");
|
|
113
|
+
expect((await mine("libraryPull", { id: up.id })).item.id).toBe(up.id);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("an upload is not an item before commit, and committing twice counts once", async () => {
|
|
117
|
+
const mine = await as("user-up00005");
|
|
118
|
+
const up = await mine("libraryUpload", upload());
|
|
119
|
+
expect((await failure(mine("libraryCommit", { id: up.id }))).code).toBe("invalid_request");
|
|
120
|
+
await put(up.uploadUrl, PNG, "image/png");
|
|
121
|
+
expect((await failure(mine("libraryPull", { id: up.id }))).code).toBe("not_found");
|
|
122
|
+
const first = await mine("libraryCommit", { id: up.id });
|
|
123
|
+
expect(await mine("libraryCommit", { id: up.id })).toEqual(first);
|
|
124
|
+
expect((await mine("me", {})).contributions).toBe(1);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("refuses a PUT of the wrong type or length, and commit then has nothing to commit", async () => {
|
|
128
|
+
const mine = await as("user-up00006");
|
|
129
|
+
const up = await mine("libraryUpload", upload());
|
|
130
|
+
expect((await put(up.uploadUrl, PNG, "text/plain")).ok).toBe(false);
|
|
131
|
+
expect((await put(up.uploadUrl, new Uint8Array(PNG.length + 1), "image/png")).ok).toBe(false);
|
|
132
|
+
expect((await failure(mine("libraryCommit", { id: up.id }))).code).toBe("invalid_request");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("stores the file under a safe name, which is what pull returns", async () => {
|
|
136
|
+
const mine = await as("user-up00007");
|
|
137
|
+
const up = await mine("libraryUpload", upload({ filename: "my photo (1).png" }));
|
|
138
|
+
await put(up.uploadUrl, PNG, "image/png");
|
|
139
|
+
await mine("libraryCommit", { id: up.id });
|
|
140
|
+
expect((await mine("libraryPull", { id: up.id })).filename).toBe("my_photo__1_.png");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it.each([
|
|
144
|
+
["a name that starts with a symbol", { filename: "!pic.png" }],
|
|
145
|
+
["a name that starts with a dot", { filename: ".hidden.png" }],
|
|
146
|
+
["a name longer than 128 characters", { filename: `${"a".repeat(125)}.png` }],
|
|
147
|
+
["a content type that does not fit the kind", { contentType: "audio/mpeg" }],
|
|
148
|
+
["a video type for an image", { contentType: "video/mp4" }],
|
|
149
|
+
["an image type for an sfx", { kind: "sfx", contentType: "image/png" }],
|
|
150
|
+
["zero bytes", { bytes: 0 }],
|
|
151
|
+
["more than 200 MB", { bytes: MAX_UPLOAD_BYTES + 1 }],
|
|
152
|
+
["a component", { kind: "component", filename: "X.tsx", contentType: "text/plain" }],
|
|
153
|
+
])("refuses %s with invalid_request", async (_what, over) => {
|
|
154
|
+
const e = await failure((await as("user-up00008"))("libraryUpload", upload(over) as never));
|
|
155
|
+
expect(e.code).toBe("invalid_request");
|
|
156
|
+
expect(e.message.length).toBeGreaterThan(0);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("accepts the type that fits each kind and exactly 200 MB", async () => {
|
|
160
|
+
const mine = await as("user-up00009");
|
|
161
|
+
for (const [kind, contentType] of [["clip", "video/mp4"], ["overlay", "video/webm"], ["sfx", "audio/mpeg"], ["music", "audio/wav"], ["image", "image/jpeg"]] as const) {
|
|
162
|
+
await mine("libraryUpload", upload({ kind, contentType, filename: `f.${kind}` }));
|
|
163
|
+
}
|
|
164
|
+
await mine("libraryUpload", upload({ bytes: MAX_UPLOAD_BYTES }));
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
describe("the library", () => {
|
|
169
|
+
it("a seeded published item is found, pulled and downloaded byte for byte", async () => {
|
|
170
|
+
const bytes = new Uint8Array(Array.from({ length: 300 }, (_, i) => i % 251));
|
|
171
|
+
await h.seedPublished(item("sfx-seedwhoosh01", "sfx", "a fast whoosh sweep", ["whoosh"]), { filename: "whoosh.mp3", contentType: "audio/mpeg", bytes });
|
|
172
|
+
const api = await as("user-lib00001");
|
|
173
|
+
const found = await api("librarySearch", { q: "whoosh", kind: "sfx" });
|
|
174
|
+
// Which other items come back, and in what order, is up to the implementation; the kind filter and visibility are not.
|
|
175
|
+
expect(found.items.map((i) => i.id)).toContain("sfx-seedwhoosh01");
|
|
176
|
+
expect(found.items.every((i) => i.kind === "sfx" && i.visibility === "published")).toBe(true);
|
|
177
|
+
expect((await api("librarySearch", { q: "whoosh", kind: "image" })).items.every((i) => i.kind === "image")).toBe(true);
|
|
178
|
+
const pulled = await api("libraryPull", { id: "sfx-seedwhoosh01" });
|
|
179
|
+
expect(pulled.filename).toBe("whoosh.mp3");
|
|
180
|
+
await download(pulled.url, join(dest, "whoosh.mp3"));
|
|
181
|
+
expect(new Uint8Array(readFileSync(join(dest, "whoosh.mp3")))).toEqual(bytes);
|
|
182
|
+
expect((await failure(api("libraryPull", { id: "image-nothing" }))).code).toBe("not_found");
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// What the contract promises about search, and no more: how an implementation scores and which items it returns for a query is its own
|
|
186
|
+
// business (the fake counts shared words; the API asks a model). The exact results of the fake are tested with the fake.
|
|
187
|
+
it("search results carry a match from 0 to 1, are ordered by it, honour limit, and nothing unrelated is a strong match", async () => {
|
|
188
|
+
const api = await as("user-lib00002");
|
|
189
|
+
await h.seedPublished(...png("image-ord-a", "ordsearch zulu"));
|
|
190
|
+
await h.seedPublished(...png("image-ord-b", "ordsearch"));
|
|
191
|
+
await h.seedPublished(...png("image-ord-c", "ordsearch"));
|
|
192
|
+
await h.seedPublished(...png("image-ord-d", "ordsearch"));
|
|
193
|
+
const run = async (q: string, limit?: number) => (await api("librarySearch", { q, ...(limit ? { limit } : {}) })).items;
|
|
194
|
+
for (const q of ["ordsearch", "ordsearch zulu"]) {
|
|
195
|
+
const items = await run(q);
|
|
196
|
+
for (const i of items) { expect(i.match).toBeGreaterThanOrEqual(0); expect(i.match).toBeLessThanOrEqual(1); }
|
|
197
|
+
expect(items.map((i) => i.match)).toEqual([...items.map((i) => i.match)].sort((x, y) => y - x));
|
|
198
|
+
}
|
|
199
|
+
expect((await run("ordsearch", 2)).length).toBeLessThanOrEqual(2);
|
|
200
|
+
expect((await run("ordsearch", 1)).length).toBeLessThanOrEqual(1);
|
|
201
|
+
// A query that has nothing to do with anything returns nothing, or nothing with a strong match.
|
|
202
|
+
for (const i of await run("qzxv wibble unrelatedword")) expect(i.match).toBeLessThanOrEqual(0.9);
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
describe("what the contract refuses and how URLs look", () => {
|
|
207
|
+
it.each([
|
|
208
|
+
["a search with no query", (api: Api) => api("librarySearch", { q: "" })],
|
|
209
|
+
["a search limit of 0", (api: Api) => api("librarySearch", { q: "x", limit: 0 })],
|
|
210
|
+
["a search limit over 50", (api: Api) => api("librarySearch", { q: "x", limit: 51 })],
|
|
211
|
+
["a pull id with a NUL", (api: Api) => api("libraryPull", { id: "image-a\u0000b" })],
|
|
212
|
+
["a pull id that is too long", (api: Api) => api("libraryPull", { id: "a".repeat(129) })],
|
|
213
|
+
["a pull id that starts with a dash", (api: Api) => api("libraryPull", { id: "-image" })],
|
|
214
|
+
["a pull id with a slash", (api: Api) => api("libraryPull", { id: "../x" })],
|
|
215
|
+
["a commit id with a NUL", (api: Api) => api("libraryCommit", { id: "image-a\u0000b" })],
|
|
216
|
+
["a commit id that is too long", (api: Api) => api("libraryCommit", { id: "a".repeat(129) })],
|
|
217
|
+
["a device code with a NUL", (api: Api) => api("deviceToken", { deviceCode: "a\u0000b" })],
|
|
218
|
+
["a device code that is too long", (api: Api) => api("deviceToken", { deviceCode: "a".repeat(201) })],
|
|
219
|
+
["an empty device code", (api: Api) => api("deviceToken", { deviceCode: "" })],
|
|
220
|
+
["an empty voiceover text", (api: Api) => api("voiceover", { text: "" })],
|
|
221
|
+
["a voiceover text of only whitespace", (api: Api) => api("voiceover", { text: " \n\t " })],
|
|
222
|
+
["a voiceover text over 5000 characters", (api: Api) => api("voiceover", { text: "a".repeat(5001) })],
|
|
223
|
+
["a speed over 1.3", (api: Api) => api("voiceover", { text: "hi", speed: 1.4 })],
|
|
224
|
+
["an unknown aspect", (api: Api) => api("images", { prompt: "a", aspect: "4:3" as never, shareable: false, tags: [] })],
|
|
225
|
+
])("refuses %s as invalid_request", async (_what, call) => {
|
|
226
|
+
expect((await failure(call(await as("user-bad000001")))).code).toBe("invalid_request");
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("hands out file and upload URLs that do not contain the item id or file name, and a new file URL on every pull", async () => {
|
|
230
|
+
await h.seedPublished(...png("image-urlcheck001", "urlcheck"));
|
|
231
|
+
const api = await as("user-url00001");
|
|
232
|
+
const a = await api("libraryPull", { id: "image-urlcheck001" }), b = await api("libraryPull", { id: "image-urlcheck001" });
|
|
233
|
+
expect(a.url).not.toBe(b.url);
|
|
234
|
+
for (const u of [a.url, b.url]) { expect(u).not.toContain("urlcheck001"); expect(u).not.toContain("p.png"); expect((await fetch(u)).ok).toBe(true); }
|
|
235
|
+
const up = await api("libraryUpload", upload({ filename: "unique-name.png" }));
|
|
236
|
+
expect(up.uploadUrl).not.toContain(up.id);
|
|
237
|
+
expect(up.uploadUrl).not.toContain("unique-name");
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
describe("voices and generation", () => {
|
|
242
|
+
it("lists voices", async () => {
|
|
243
|
+
const { voices } = await (await as("user-gen00001"))("voices", {});
|
|
244
|
+
expect(voices.length).toBeGreaterThan(0);
|
|
245
|
+
expect(voices[0]!.id).toBeTruthy();
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("voiceover returns a file, word timings inside its length, and charges the characters to this user only", async () => {
|
|
249
|
+
const api = await as("user-gen00002"), other = await as("user-gen00003");
|
|
250
|
+
const text = "one two three four five";
|
|
251
|
+
const vo = await api("voiceover", { text });
|
|
252
|
+
expect(vo.chars).toBe(text.length);
|
|
253
|
+
expect(vo.words.map((w) => w.word)).toEqual(text.split(" "));
|
|
254
|
+
expect(vo.words.at(-1)!.endSec).toBeLessThanOrEqual(vo.durationSec + 0.01);
|
|
255
|
+
await download(vo.url, join(dest, `vo.${vo.ext}`));
|
|
256
|
+
expect(readFileSync(join(dest, `vo.${vo.ext}`)).length).toBeGreaterThan(0);
|
|
257
|
+
expect((await api("me", {})).quota.voiceoverChars.used).toBe(text.length);
|
|
258
|
+
expect((await other("me", {})).quota.voiceoverChars.used).toBe(0);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("voiceover takes Windows line endings, and counts and speaks them as plain newlines", async () => {
|
|
262
|
+
const api = await as("user-gen00006");
|
|
263
|
+
const vo = await api("voiceover", { text: "one\r\ntwo\rthree" });
|
|
264
|
+
expect(vo.chars).toBe("one\ntwo\nthree".length);
|
|
265
|
+
expect(vo.words.map((w) => w.word)).toEqual(["one", "two", "three"]);
|
|
266
|
+
expect((await api("me", {})).quota.voiceoverChars.used).toBe("one\ntwo\nthree".length);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it("generation needs a login", async () => {
|
|
270
|
+
expect((await failure(anon("voiceover", { text: "hi" }))).code).toBe("unauthenticated");
|
|
271
|
+
expect((await failure(anon("images", { prompt: "a", aspect: "9:16", shareable: false, tags: [] }))).code).toBe("unauthenticated");
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("images: a private one is only a file; a shareable one also enters the library for review, owned by the caller", async () => {
|
|
275
|
+
const api = await as("user-gen00004"), other = await as("user-gen00005");
|
|
276
|
+
const plain = await api("images", { prompt: "a quiet lake", aspect: "16:9", shareable: false, tags: [] });
|
|
277
|
+
expect(plain.libraryId).toBeUndefined();
|
|
278
|
+
await download(plain.url, join(dest, `plain.${plain.ext}`));
|
|
279
|
+
expect(readFileSync(join(dest, `plain.${plain.ext}`)).length).toBeGreaterThan(0);
|
|
280
|
+
const shared = await api("images", { prompt: "a loud volcano", aspect: "9:16", shareable: true, tags: ["volcano"] });
|
|
281
|
+
expect(shared.libraryId).toBeTruthy();
|
|
282
|
+
expect((await api("librarySearch", { q: "volcano" })).items).toEqual([]);
|
|
283
|
+
expect((await api("libraryPull", { id: shared.libraryId! })).item).toMatchObject({ kind: "image", visibility: "review", tags: expect.arrayContaining(["volcano"]) });
|
|
284
|
+
expect((await failure(other("libraryPull", { id: shared.libraryId! }))).code).toBe("not_found");
|
|
285
|
+
expect((await api("me", {})).quota.images.used).toBe(2);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("a shared image's title is the first 60 characters of the prompt, cut on a code point, never inside an emoji", async () => {
|
|
289
|
+
const api = await as("user-gen00007");
|
|
290
|
+
const prompt = "\u{1F30B}".repeat(70);
|
|
291
|
+
const shared = await api("images", { prompt, aspect: "1:1", shareable: true, tags: [] });
|
|
292
|
+
const { item } = await api("libraryPull", { id: shared.libraryId! });
|
|
293
|
+
expect(isPlainText(item.title)).toBe(true);
|
|
294
|
+
expect(Array.from(item.title)).toHaveLength(60);
|
|
295
|
+
expect(item.description).toBe(prompt);
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
describe("quota", () => {
|
|
300
|
+
it("passes exactly at the limit; one over is quota_exceeded with the reset date; other users are unaffected", async () => {
|
|
301
|
+
const t = await start({ voiceoverChars: 10, images: 1 });
|
|
302
|
+
try {
|
|
303
|
+
const api = createClient({ baseUrl: t.baseUrl, token: await t.login("user-quota001") });
|
|
304
|
+
const other = createClient({ baseUrl: t.baseUrl, token: await t.login("user-quota002") });
|
|
305
|
+
await api("voiceover", { text: "ten chars!" });
|
|
306
|
+
const e = await failure(api("voiceover", { text: "x" }));
|
|
307
|
+
expect(e.code).toBe("quota_exceeded");
|
|
308
|
+
const me = await api("me", {});
|
|
309
|
+
expect(me.quota.voiceoverChars).toEqual({ used: 10, limit: 10 });
|
|
310
|
+
expect(e.message).toContain(me.quota.resetsAt.slice(0, 10));
|
|
311
|
+
expect(e.message).toMatch(DATE);
|
|
312
|
+
// A request that does not fit is refused whole, and charged nothing.
|
|
313
|
+
const small = createClient({ baseUrl: t.baseUrl, token: await t.login("user-quota003") });
|
|
314
|
+
expect((await failure(small("voiceover", { text: "eleven char" }))).code).toBe("quota_exceeded");
|
|
315
|
+
expect((await small("me", {})).quota.voiceoverChars.used).toBe(0);
|
|
316
|
+
await other("voiceover", { text: "ten chars!" });
|
|
317
|
+
|
|
318
|
+
await api("images", { prompt: "a cat", aspect: "9:16", shareable: false, tags: [] });
|
|
319
|
+
const i = await failure(api("images", { prompt: "a dog", aspect: "9:16", shareable: false, tags: [] }));
|
|
320
|
+
expect(i.code).toBe("quota_exceeded");
|
|
321
|
+
expect(i.message).toMatch(DATE);
|
|
322
|
+
expect((await other("me", {})).quota.images.used).toBe(0);
|
|
323
|
+
await other("images", { prompt: "a bird", aspect: "9:16", shareable: false, tags: [] });
|
|
324
|
+
} finally { await t.close(); }
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
describe("the public library", () => {
|
|
329
|
+
it("needs no login, lists published items newest first in pages without repeats, and with a query leads with the best match", async () => {
|
|
330
|
+
const t = await start({});
|
|
331
|
+
try {
|
|
332
|
+
const ids = Array.from({ length: 30 }, (_, i) => `image-pub-${String(i).padStart(2, "0")}`);
|
|
333
|
+
// The first one also matches the second query word, so it leads that listing.
|
|
334
|
+
for (const [i, id] of ids.entries()) await t.seedPublished(...png(id, i === 0 ? "pubpic gold" : "pubpic"));
|
|
335
|
+
const api = createClient({ baseUrl: t.baseUrl });
|
|
336
|
+
const walk = async (q?: string) => {
|
|
337
|
+
const seen: string[] = [];
|
|
338
|
+
let first;
|
|
339
|
+
for (let page = 1; page <= 10; page++) {
|
|
340
|
+
const r = await api("publicLibrary", { ...(q ? { q } : {}), page });
|
|
341
|
+
first ??= r;
|
|
342
|
+
expect(r.page).toBe(page);
|
|
343
|
+
seen.push(...r.items.map((x) => x.id));
|
|
344
|
+
if (!r.hasMore) break;
|
|
345
|
+
}
|
|
346
|
+
return { seen, first: first! };
|
|
347
|
+
};
|
|
348
|
+
const all = await walk();
|
|
349
|
+
expect(all.first.hasMore).toBe(true);
|
|
350
|
+
expect(all.seen).toEqual([...ids].reverse());
|
|
351
|
+
expect(new Set(all.seen).size).toBe(30);
|
|
352
|
+
// With a query a server may rank by meaning (one page, each item with `match`, best first) or list by text (paged, as above).
|
|
353
|
+
// Either way: the item matching both words leads, nothing repeats, and nothing outside the published set appears.
|
|
354
|
+
const ranked = await walk("pubpic gold");
|
|
355
|
+
expect(ranked.seen[0]).toBe(ids[0]);
|
|
356
|
+
expect(new Set(ranked.seen).size).toBe(ranked.seen.length);
|
|
357
|
+
expect(ranked.seen.every((id) => ids.includes(id))).toBe(true);
|
|
358
|
+
const matches = ranked.first.items.map((x) => x.match);
|
|
359
|
+
if (matches.some((m) => m !== undefined)) {
|
|
360
|
+
expect(matches.every((m) => typeof m === "number" && m >= 0 && m <= 1)).toBe(true);
|
|
361
|
+
expect(matches).toEqual([...(matches as number[])].sort((a, b) => b - a));
|
|
362
|
+
expect(ranked.first.hasMore).toBe(false);
|
|
363
|
+
} else expect(ranked.seen).toHaveLength(30);
|
|
364
|
+
// Something in review is not public.
|
|
365
|
+
const mine = createClient({ baseUrl: t.baseUrl, token: await t.login("user-pub00001") });
|
|
366
|
+
const up = await mine("libraryUpload", upload({ title: "Pending review", description: "pubpic review" }));
|
|
367
|
+
await put(up.uploadUrl, PNG, "image/png");
|
|
368
|
+
await mine("libraryCommit", { id: up.id });
|
|
369
|
+
expect((await walk()).seen).toHaveLength(30);
|
|
370
|
+
expect((await api("publicLibrary", { q: "pubpic", kind: "sfx" })).items).toEqual([]);
|
|
371
|
+
} finally { await t.close(); }
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it("a published sound effect or image has a preview URL that serves its file; a published music item without a preview has none", async () => {
|
|
375
|
+
const t = await start({});
|
|
376
|
+
try {
|
|
377
|
+
const AUDIO = new Uint8Array([1, 2, 3, 4, 5]);
|
|
378
|
+
const audio = { filename: "a.mp3", contentType: "audio/mpeg", bytes: AUDIO };
|
|
379
|
+
await t.seedPublished(item("sfx-prevcheck001", "sfx", "prevcheck whoosh"), audio);
|
|
380
|
+
await t.seedPublished(item("music-prevcheck001", "music", "prevcheck theme"), audio);
|
|
381
|
+
await t.seedPublished(...png("image-prevcheck001", "prevcheck picture"));
|
|
382
|
+
const api = createClient({ baseUrl: t.baseUrl });
|
|
383
|
+
const by = Object.fromEntries((await api("publicLibrary", { q: "prevcheck" })).items.map((i) => [i.id, i]));
|
|
384
|
+
expect(Object.keys(by).sort()).toEqual(["image-prevcheck001", "music-prevcheck001", "sfx-prevcheck001"]);
|
|
385
|
+
const sfxUrl = by["sfx-prevcheck001"]!.previewUrl;
|
|
386
|
+
expect(sfxUrl).toBeTruthy();
|
|
387
|
+
const res = await fetch(sfxUrl!);
|
|
388
|
+
expect(res.ok).toBe(true);
|
|
389
|
+
expect(new Uint8Array(await res.arrayBuffer())).toEqual(AUDIO);
|
|
390
|
+
expect((await fetch(by["image-prevcheck001"]!.previewUrl!)).ok).toBe(true);
|
|
391
|
+
expect(by["music-prevcheck001"]!.previewUrl).toBeUndefined();
|
|
392
|
+
} finally { await t.close(); }
|
|
393
|
+
});
|
|
394
|
+
});
|
|
395
|
+
});
|
|
396
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
import type { AddressInfo } from "node:net";
|
|
4
|
+
import { MAX_UPLOAD_BYTES, routes, type ErrorCode, type LibraryItem, type RouteName, type Voice } from "../contract";
|
|
5
|
+
import { PIXEL_PNG, silentWav } from "./fixtures";
|
|
6
|
+
|
|
7
|
+
type Blob = { filename: string; contentType: string; bytes: Uint8Array };
|
|
8
|
+
// owner is the uploader's user id; a seeded item has none. An upload is not an item for anyone until it is committed.
|
|
9
|
+
type Stored = { item: LibraryItem; file?: Blob; filename: string; contentType: string; owner?: string; committed: boolean };
|
|
10
|
+
|
|
11
|
+
export const FAKE_VOICES: Voice[] = [
|
|
12
|
+
{ id: "fakewarm", name: "Warm", description: "Warm, calm narrator", gender: "female", language: "en", kind: "stock" },
|
|
13
|
+
{ id: "fakebold", name: "Bold", description: "Energetic, punchy", gender: "male", language: "en", kind: "stock" },
|
|
14
|
+
];
|
|
15
|
+
const WORD_SEC = 0.4;
|
|
16
|
+
// The kinds whose own file is its public preview: a picture or a short sound effect needs no login to look at or hear, while the full file
|
|
17
|
+
// of long media (music, overlays, clips) and a component's source do not. Any kind with a preview of its own would use that instead
|
|
18
|
+
// (the fake has none).
|
|
19
|
+
const PREVIEW_OF_ORIGINAL: readonly string[] = ["image", "sfx"];
|
|
20
|
+
const MIME_PREFIX: Record<string, string> = { image: "image/", clip: "video/", overlay: "video/", sfx: "audio/", music: "audio/" };
|
|
21
|
+
// The device-flow user of the fake is "tester"; anyone else is named from their id, as the API names a user with no profile.
|
|
22
|
+
const handleOf = (userId: string) => (userId === "u-test" ? "tester" : `user-${userId.slice(0, 8)}`);
|
|
23
|
+
const nextMonthStart = (now = new Date()) => new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1));
|
|
24
|
+
|
|
25
|
+
// The first `max` characters, counted in code points, so an emoji (a surrogate pair) is never split. The same cut as the API's.
|
|
26
|
+
function cut(text: string, max: number): string {
|
|
27
|
+
if (text.length <= max) return text;
|
|
28
|
+
let end = 0, n = 0;
|
|
29
|
+
while (end < text.length && n < max) { end += text.codePointAt(end)! > 0xffff ? 2 : 1; n++; }
|
|
30
|
+
return text.slice(0, end);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
class Fail extends Error { constructor(public status: number, public code: ErrorCode, message: string) { super(message); } }
|
|
34
|
+
|
|
35
|
+
function parseBody(raw: Buffer): unknown {
|
|
36
|
+
try { return JSON.parse(raw.toString("utf8") || "{}"); }
|
|
37
|
+
catch { throw new Fail(400, "invalid_request", "The request body is not valid JSON."); }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type FakeApi = Awaited<ReturnType<typeof startFakeApi>>;
|
|
41
|
+
|
|
42
|
+
// 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 } = {}) {
|
|
44
|
+
// token -> the user it belongs to
|
|
45
|
+
const tokens = new Map<string, string>();
|
|
46
|
+
const devices = new Map<string, { userCode: string; approved: boolean; userId: string }>();
|
|
47
|
+
const store = new Map<string, Stored>();
|
|
48
|
+
// An unguessable token -> the one blob that file URL serves.
|
|
49
|
+
const blobs = new Map<string, Blob>();
|
|
50
|
+
// 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
|
+
// 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 }>();
|
|
53
|
+
// 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 }>();
|
|
56
|
+
const meter = (userId: string | undefined) => {
|
|
57
|
+
const id = userId ?? "u-test";
|
|
58
|
+
if (!meters.has(id)) meters.set(id, { chars: 0, images: 0, uploads: 0 });
|
|
59
|
+
return meters.get(id)!;
|
|
60
|
+
};
|
|
61
|
+
const resetDate = () => nextMonthStart().toISOString();
|
|
62
|
+
const limits = { chars: opts.voiceoverCharLimit ?? 10_000, images: opts.imageLimit ?? 30 };
|
|
63
|
+
const ALNUM = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
64
|
+
const nextId = (prefix: string) => `${prefix}-${Array.from(randomBytes(12), (b) => ALNUM[b % ALNUM.length]).join("")}`;
|
|
65
|
+
const secret = () => randomBytes(16).toString("hex");
|
|
66
|
+
let origin = "";
|
|
67
|
+
// Every call makes a new URL for its own blob. The URL says nothing about the item behind it.
|
|
68
|
+
const fileUrl = (blob: Blob) => { const t = secret(); blobs.set(t, blob); return `${origin}/files/${t}`; };
|
|
69
|
+
const words = (s: string) => new Set(s.toLowerCase().match(/[a-z0-9]+/g) ?? []);
|
|
70
|
+
|
|
71
|
+
const handlers: Record<RouteName, (input: any, req: { token?: string; userId?: string }) => unknown> = {
|
|
72
|
+
deviceStart: () => {
|
|
73
|
+
const deviceCode = nextId("dev"), userCode = nextId("USER").toUpperCase();
|
|
74
|
+
devices.set(deviceCode, { userCode, approved: Boolean(opts.autoApprove), userId: "u-test" });
|
|
75
|
+
return { deviceCode, userCode, verificationUrl: `${origin}/device`, intervalSec: 1, expiresInSec: 600 };
|
|
76
|
+
},
|
|
77
|
+
deviceToken: ({ deviceCode }) => {
|
|
78
|
+
const d = devices.get(deviceCode);
|
|
79
|
+
if (!d) return { status: "expired" };
|
|
80
|
+
if (!d.approved) return { status: "pending" };
|
|
81
|
+
devices.delete(deviceCode);
|
|
82
|
+
const token = nextId("tok");
|
|
83
|
+
tokens.set(token, d.userId);
|
|
84
|
+
return { status: "approved", token };
|
|
85
|
+
},
|
|
86
|
+
revoke: (_input, { token }) => { if (token) tokens.delete(token); return { revoked: true }; },
|
|
87
|
+
me: (_input, { userId }) => {
|
|
88
|
+
const m = meter(userId);
|
|
89
|
+
return {
|
|
90
|
+
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() },
|
|
92
|
+
contributions: m.uploads,
|
|
93
|
+
};
|
|
94
|
+
},
|
|
95
|
+
// Search and the public library list published items only; nothing private or in review is ever found.
|
|
96
|
+
librarySearch: ({ q, kind, limit }) => {
|
|
97
|
+
const want = words(q);
|
|
98
|
+
// Best match first; items that match equally well come newest first (the one added last).
|
|
99
|
+
// match is the share of the query's words the item has, to two decimals.
|
|
100
|
+
const items = [...store.values()]
|
|
101
|
+
.filter((s) => s.item.visibility === "published" && (!kind || s.item.kind === kind))
|
|
102
|
+
.map((s, order) => ({ s, order, score: [...want].filter((w) => words(`${s.item.title} ${s.item.description} ${s.item.tags.join(" ")}`).has(w)).length }))
|
|
103
|
+
.filter((x) => x.score > 0).sort((a, b) => b.score - a.score || b.order - a.order).slice(0, limit ?? 8)
|
|
104
|
+
.map((x) => ({ ...x.s.item, match: Math.round((x.score / want.size) * 100) / 100 }));
|
|
105
|
+
return { items };
|
|
106
|
+
},
|
|
107
|
+
// A caller may pull an item that is published or that they uploaded themselves. Anything else does not exist for them.
|
|
108
|
+
libraryPull: ({ id }, { userId }) => {
|
|
109
|
+
const s = store.get(id);
|
|
110
|
+
if (!s?.file || !s.committed || (s.item.visibility !== "published" && s.owner !== userId)) throw new Fail(404, "not_found", `No library item with id ${id}.`);
|
|
111
|
+
usage.pulls++;
|
|
112
|
+
return { item: s.item, url: fileUrl(s.file), filename: s.filename };
|
|
113
|
+
},
|
|
114
|
+
libraryUpload: (input, { userId }) => {
|
|
115
|
+
// The server's rules for an upload: a safe file name, a content type that fits the kind, a size of 1 byte to 200 MB.
|
|
116
|
+
const filename = (input.filename as string).replace(/[^A-Za-z0-9._-]/g, "_");
|
|
117
|
+
if (!/^[A-Za-z0-9]/.test(filename) || filename.length > 128) throw new Fail(400, "invalid_request", "The filename must start with a letter or digit and be at most 128 characters. Rename the file and try again.");
|
|
118
|
+
if (!(input.contentType as string).toLowerCase().startsWith(MIME_PREFIX[input.kind])) throw new Fail(400, "invalid_request", `A ${input.kind} must have a ${MIME_PREFIX[input.kind]}* content type, not "${input.contentType}".`);
|
|
119
|
+
if (input.bytes < 1 || input.bytes > MAX_UPLOAD_BYTES) throw new Fail(400, "invalid_request", "The file must be between 1 byte and 200 MB.");
|
|
120
|
+
const id = nextId(input.kind);
|
|
121
|
+
const item: LibraryItem = { id, kind: input.kind, title: input.title, description: input.description, tags: input.tags, meta: input.meta, visibility: input.shareable ? "review" : "private" };
|
|
122
|
+
store.set(id, { item, filename, contentType: input.contentType, owner: userId, committed: false });
|
|
123
|
+
const token = secret();
|
|
124
|
+
uploads.set(token, { id, contentType: input.contentType, bytes: input.bytes, used: false });
|
|
125
|
+
return { id, uploadUrl: `${origin}/upload/${token}` };
|
|
126
|
+
},
|
|
127
|
+
libraryCommit: ({ id }, { userId }) => {
|
|
128
|
+
const s = store.get(id);
|
|
129
|
+
if (!s || s.owner !== userId) throw new Fail(404, "not_found", `No upload with id ${id}.`);
|
|
130
|
+
if (!s.file) throw new Fail(400, "invalid_request", `Nothing was uploaded for ${id}. Send the file to the upload URL first.`);
|
|
131
|
+
// Committing again returns the same item and counts once.
|
|
132
|
+
if (!s.committed) { s.committed = true; usage.uploads++; if (s.item.visibility === "review") meter(userId).uploads++; }
|
|
133
|
+
return { item: s.item };
|
|
134
|
+
},
|
|
135
|
+
voices: () => ({ voices: FAKE_VOICES }),
|
|
136
|
+
voiceover: ({ text: raw, voiceId }, { userId }) => {
|
|
137
|
+
const text = (raw as string).replace(/\r\n?/g, "\n");
|
|
138
|
+
if (voiceId !== undefined && !FAKE_VOICES.some((v) => v.id === voiceId)) throw new Fail(400, "invalid_request", "That voice is not available. Run `reelkit assets voices` to see the list.");
|
|
139
|
+
const m = meter(userId);
|
|
140
|
+
if (m.chars + text.length > limits.chars) throw new Fail(429, "quota_exceeded", `Voiceover quota used up. It resets on ${resetDate().slice(0, 10)}.`);
|
|
141
|
+
m.chars += text.length;
|
|
142
|
+
usage.chars += text.length;
|
|
143
|
+
const list = text.trim().split(/\s+/).map((word: string, i: number) => ({ word, startSec: i * WORD_SEC, endSec: (i + 1) * WORD_SEC }));
|
|
144
|
+
const durationSec = list.length * WORD_SEC;
|
|
145
|
+
return { url: fileUrl({ filename: "vo.wav", contentType: "audio/wav", bytes: silentWav(durationSec) }), ext: "wav", contentType: "audio/wav", durationSec, words: list, chars: text.length };
|
|
146
|
+
},
|
|
147
|
+
images: ({ prompt, aspect, shareable, tags }, { userId }) => {
|
|
148
|
+
const m = meter(userId);
|
|
149
|
+
if (m.images + 1 > limits.images) throw new Fail(429, "quota_exceeded", `Image quota used up. It resets on ${resetDate().slice(0, 10)}.`);
|
|
150
|
+
m.images++;
|
|
151
|
+
usage.images++;
|
|
152
|
+
const id = nextId("image");
|
|
153
|
+
const file = { filename: "image.png", contentType: "image/png", bytes: PIXEL_PNG };
|
|
154
|
+
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 });
|
|
155
|
+
return { url: fileUrl(file), ext: "png", contentType: "image/png", ...(shareable ? { libraryId: id } : {}) };
|
|
156
|
+
},
|
|
157
|
+
publicLibrary: ({ q, kind, page }) => {
|
|
158
|
+
// Published items only. Newest first; with a query, best match first and equal matches newest first.
|
|
159
|
+
const want = q ? words(q) : undefined;
|
|
160
|
+
const score = (i: LibraryItem) => (want ? [...want].filter((w) => words(`${i.title} ${i.description} ${i.tags.join(" ")}`).has(w)).length : 0);
|
|
161
|
+
const all = [...store.values()]
|
|
162
|
+
.map((s, order) => ({ s, order, score: score(s.item) }))
|
|
163
|
+
.filter((x) => x.s.item.visibility === "published" && (!kind || x.s.item.kind === kind) && (!want || x.score > 0))
|
|
164
|
+
.sort((a, b) => b.score - a.score || b.order - a.order).map((x) => x.s);
|
|
165
|
+
const p = page ?? 1, size = 24;
|
|
166
|
+
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));
|
|
167
|
+
return { items, page: p, hasMore: all.length > p * size };
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const server = createServer(async (req, res) => {
|
|
172
|
+
const send = (status: number, body: unknown) => { res.writeHead(status, { "content-type": "application/json" }).end(JSON.stringify(body)); };
|
|
173
|
+
try {
|
|
174
|
+
const url = new URL(req.url ?? "/", origin);
|
|
175
|
+
const chunks: Buffer[] = [];
|
|
176
|
+
for await (const c of req) chunks.push(c as Buffer);
|
|
177
|
+
const raw = Buffer.concat(chunks);
|
|
178
|
+
|
|
179
|
+
if (url.pathname.startsWith("/files/")) {
|
|
180
|
+
const blob = blobs.get(url.pathname.slice("/files/".length));
|
|
181
|
+
if (!blob) return send(404, { error: { code: "not_found", message: "No such file." } });
|
|
182
|
+
res.writeHead(200, { "content-type": blob.contentType, "content-length": blob.bytes.length }).end(blob.bytes);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
if (req.method === "PUT" && url.pathname.startsWith("/upload/")) {
|
|
186
|
+
const up = uploads.get(url.pathname.slice("/upload/".length));
|
|
187
|
+
const s = up && store.get(up.id);
|
|
188
|
+
if (!up || !s) return send(404, { error: { code: "not_found", message: "No such upload." } });
|
|
189
|
+
// 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.
|
|
190
|
+
// (A real server may be kinder; the contract promises clients no more than this.)
|
|
191
|
+
if (up.used || s.file) throw new Fail(400, "invalid_request", "This upload URL has already been used.");
|
|
192
|
+
up.used = true;
|
|
193
|
+
if (req.headers["content-type"] !== up.contentType) throw new Fail(400, "invalid_request", `The upload must be sent as ${up.contentType}.`);
|
|
194
|
+
if (raw.length !== up.bytes) throw new Fail(400, "invalid_request", `The upload must be exactly ${up.bytes} bytes.`);
|
|
195
|
+
s.file = { filename: s.filename, contentType: s.contentType, bytes: new Uint8Array(raw) };
|
|
196
|
+
return send(200, {});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const entry = (Object.entries(routes) as [RouteName, (typeof routes)[RouteName]][])
|
|
200
|
+
.find(([, r]) => r.method === req.method && `/api/v1${r.path}` === url.pathname);
|
|
201
|
+
if (!entry) throw new Fail(404, "not_found", `No route ${req.method} ${url.pathname}.`);
|
|
202
|
+
const [name, r] = entry;
|
|
203
|
+
const token = (req.headers.authorization ?? "").replace(/^Bearer /, "");
|
|
204
|
+
const userId = tokens.get(token);
|
|
205
|
+
if (r.auth && !userId) throw new Fail(401, "unauthenticated", "Not logged in. Run `reelkit auth login`.");
|
|
206
|
+
const input = r.method === "GET" ? Object.fromEntries(url.searchParams) : parseBody(raw);
|
|
207
|
+
const parsed = r.req.safeParse(input);
|
|
208
|
+
if (!parsed.success) throw new Fail(400, "invalid_request", parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; "));
|
|
209
|
+
send(200, handlers[name](parsed.data, { token: token || undefined, userId }));
|
|
210
|
+
} catch (e) {
|
|
211
|
+
if (e instanceof Fail) send(e.status, { error: { code: e.code, message: e.message } });
|
|
212
|
+
else send(500, { error: { code: "server_error", message: e instanceof Error ? e.message : String(e) } });
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
await new Promise<void>((r) => server.listen(0, "127.0.0.1", r));
|
|
216
|
+
origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
|
217
|
+
|
|
218
|
+
return {
|
|
219
|
+
baseUrl: `${origin}/api/v1`,
|
|
220
|
+
usage,
|
|
221
|
+
approve(userCode: string, userId = "u-test") { for (const d of devices.values()) if (d.userCode === userCode) { d.approved = true; d.userId = userId; } },
|
|
222
|
+
// A ready token, for tests that are not about logging in.
|
|
223
|
+
login(userId = "u-test") { const t = nextId("tok"); tokens.set(t, userId); return t; },
|
|
224
|
+
seed(item: LibraryItem, file: Blob) { store.set(item.id, { item, file, filename: file.filename, contentType: file.contentType, committed: true }); },
|
|
225
|
+
items: () => [...store.values()].map((s) => s.item),
|
|
226
|
+
close: () => new Promise<void>((r) => { server.closeAllConnections(); server.close(() => r()); }),
|
|
227
|
+
};
|
|
228
|
+
}
|