mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-10 07:56:46 -05:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8fde146370 | ||
|
|
d813cb9033 | ||
|
|
4d8dbe6aa5 |
@@ -104,3 +104,6 @@ windows/sqlite/sqlite3.lib
|
||||
crates/desktop/src-tauri/*.dll
|
||||
windows_dlls/
|
||||
|
||||
|
||||
# Portable local dev stack (script/bootstrap) - MySQL/Redis binaries, data, logs
|
||||
.devstack/
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# Loaded by `nx serve artcraft-webapp --mode fake`.
|
||||
#
|
||||
# Points the webapp at the local fake-storyteller-web stand-in instead of the
|
||||
# default dev target, which proxies through to production api.storyteller.ai.
|
||||
# Start the fake first with `nx serve fake-storyteller-web`.
|
||||
VITE_DEV_API_HOST=http://localhost:12345
|
||||
@@ -15,17 +15,23 @@ const root = ReactDOM.createRoot(
|
||||
document.getElementById("root") as HTMLElement,
|
||||
);
|
||||
|
||||
// In development, route API through the Vite dev server origin to avoid CORS
|
||||
// In development, route API calls through the Vite dev server origin to avoid
|
||||
// CORS. NB: that origin's `/v1` proxy forwards to https://api.storyteller.ai,
|
||||
// so the default dev target is PRODUCTION — local work reads and writes real
|
||||
// data and spends real credits.
|
||||
//
|
||||
// VITE_DEV_API_HOST overrides it. `nx serve artcraft-webapp --mode fake` sets
|
||||
// it to http://localhost:12345, the local fake-storyteller-web stand-in (see
|
||||
// apps/fake-storyteller-web/README.md); that is also how to point at a local
|
||||
// storyteller-web, replacing the old commented-out setDevelopment() call.
|
||||
//
|
||||
// `import.meta.env.DEV` is statically false in a production build, so this
|
||||
// block is dropped from the bundle entirely.
|
||||
if (import.meta.env.DEV) {
|
||||
try {
|
||||
StorytellerApiHostStore.getInstance().setApiSchemeAndHost(
|
||||
window.location.origin,
|
||||
import.meta.env.VITE_DEV_API_HOST || window.location.origin,
|
||||
);
|
||||
// NB: This is for Brandon to test with storyteller-web locally:
|
||||
// (disabled — it overrides the origin above and points at localhost:12345,
|
||||
// which breaks dev unless a local backend is running. Re-enable only when
|
||||
// testing against a local storyteller-web.)
|
||||
//StorytellerApiHostStore.getInstance().setDevelopment();
|
||||
} catch (e) {
|
||||
console.warn("Failed to set dev API host override", e);
|
||||
}
|
||||
|
||||
@@ -52,6 +52,11 @@ function maybeProxyCdnUrl(url: string): string {
|
||||
try {
|
||||
const parsed = new URL(url, window.location.origin);
|
||||
if (parsed.origin === window.location.origin) return url;
|
||||
// A local backend sends permissive CORS headers already, so proxying it
|
||||
// would only add a hop — and break range requests for video scrubbing.
|
||||
if (parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1") {
|
||||
return url;
|
||||
}
|
||||
return `/__cdn/${parsed.host}${parsed.pathname}${parsed.search}`;
|
||||
} catch {
|
||||
return url;
|
||||
@@ -338,6 +343,11 @@ export const useWebAppPageSceneAdapter = (
|
||||
if (width) params.push(`width=${width}`);
|
||||
if (quality) params.push(`quality=${quality}`);
|
||||
if (params.length === 0) return `${base}${path}`;
|
||||
// `/cdn-cgi/image` is Cloudflare's resizer. A local backend has no such
|
||||
// endpoint, so ask it for the original instead of a 404.
|
||||
if (base.includes("//localhost") || base.includes("//127.0.0.1")) {
|
||||
return `${base}${path}`;
|
||||
}
|
||||
return `${base}/cdn-cgi/image/${params.join(",")}${path}`;
|
||||
},
|
||||
|
||||
|
||||
@@ -42,7 +42,11 @@ function cdnProxyPlugin() {
|
||||
return;
|
||||
}
|
||||
const [, host, assetPath = '/', query = ''] = match;
|
||||
const upstream = await fetch(`https://${host}${assetPath}${query}`, {
|
||||
// A local backend serves media over http; every real CDN is https.
|
||||
const scheme = /^(localhost|127\.0\.0\.1)(:\d+)?$/.test(host ?? '')
|
||||
? 'http'
|
||||
: 'https';
|
||||
const upstream = await fetch(`${scheme}://${host}${assetPath}${query}`, {
|
||||
method: req.method === 'HEAD' ? 'HEAD' : 'GET',
|
||||
headers: req.headers.range
|
||||
? { range: String(req.headers.range) }
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
# fake-storyteller-web
|
||||
|
||||
An in-memory stand-in for the `storyteller-web` API, so the webapp can be run
|
||||
and tested without a backend.
|
||||
|
||||
It has **no database, no cache, no object storage, and no third-party clients**.
|
||||
There is no configuration that makes it talk to any of those, and no code path
|
||||
that could. It is a separate Nx project that is never built into, imported by,
|
||||
or deployed alongside anything that reaches production.
|
||||
|
||||
```
|
||||
nx serve fake-storyteller-web # http://localhost:12345
|
||||
nx serve artcraft-webapp --mode fake # http://localhost:4201, pointed at the fake
|
||||
```
|
||||
|
||||
Log in as `localdev1` / `localdev1pass`.
|
||||
|
||||
`--mode fake` loads `apps/artcraft-webapp/.env.fake`, which sets
|
||||
`VITE_DEV_API_HOST=http://localhost:12345`. **Without it, `nx serve
|
||||
artcraft-webapp` talks to production** — the dev server's `/v1` proxy forwards
|
||||
to `api.storyteller.ai`, so local work reads and writes real data and spends
|
||||
real credits. Nothing about that changed; the flag is how you opt out of it.
|
||||
|
||||
## Why this exists
|
||||
|
||||
The previous approach put development flags inside `storyteller-web` itself
|
||||
(`DEV_FAKE_GENERATION`, a filesystem bucket backend, a CDN host override). That
|
||||
works, but every one of those flags is a switch that exists in the production
|
||||
binary, and the objection was fair: a flag that exists can be flipped.
|
||||
|
||||
Here, none of it exists. The behaviour lives in a different program, in a
|
||||
different language, that production never builds.
|
||||
|
||||
## What it does
|
||||
|
||||
- **Stateful, in memory.** Uploads appear in the library, generations produce
|
||||
real media files, credits go down, folders and tags persist. Everything resets
|
||||
on restart, always to the same seeded fixture.
|
||||
- **Serves its own media.** Uploaded and generated bytes are stored in memory and
|
||||
served from `/media/...`. Every `cdn_url` the API returns points back at this
|
||||
process, so images, video, meshes and splats load in the browser with no
|
||||
bucket and no proxy.
|
||||
- **Resolves generations on a timer.** A submitted job sits `pending`, ticks its
|
||||
progress up, and lands in `complete_success` or `complete_failure` after
|
||||
`FAKE_API_RESOLVE_SECONDS` (default 6).
|
||||
- **Fails loudly on gaps.** An unimplemented route returns a 501 whose body says
|
||||
`fake-storyteller-web does not implement ...`, rather than something that
|
||||
could be mistaken for a real backend error.
|
||||
|
||||
Endpoint coverage is everything the webapp's `@storyteller/api` clients call —
|
||||
148 routes across session/auth, credits, billing, omni_gen, jobs, media files,
|
||||
uploads, projects, folders, tags, prompts, characters, API keys and referral
|
||||
codes.
|
||||
|
||||
## Prompt flags
|
||||
|
||||
Typing one of these into a prompt forces an outcome. They match the flags the
|
||||
team already uses.
|
||||
|
||||
| Flag | Effect |
|
||||
|----------------------------------------|---------------------------------------------------------|
|
||||
| `simulate_artcraft_failure [category]` | Fail after the normal delay |
|
||||
| `test_artcraft_failure [category]` | Alias of the above |
|
||||
| `artcraft_test_job_failure` | Fail immediately, with no pending phase |
|
||||
| `trigger_payment_failure` | Reject the submission with a 402 |
|
||||
| `simulate_artcraft_slow [seconds]` | Stay pending for the given number of seconds |
|
||||
|
||||
`[category]` is any frontend failure category, for example
|
||||
`simulate_artcraft_failure face_not_detected`. Omitted, it is
|
||||
`generation_failed`. The full list is at `GET /__fake/state`.
|
||||
|
||||
## Control endpoints
|
||||
|
||||
These do not exist in the real API and are namespaced so that a dependency on
|
||||
them is obvious.
|
||||
|
||||
| Endpoint | Purpose |
|
||||
|-------------------------|------------------------------------------------------|
|
||||
| `GET /__fake/state` | Record counts, resolve delay, failure category list |
|
||||
| `POST /__fake/reset` | Reset to the seeded fixture (logs everyone out) |
|
||||
| `POST /__fake/credits` | Set the logged-in user's balance |
|
||||
| `GET /_status` | Health check, same path as the real backend |
|
||||
|
||||
## Configuration
|
||||
|
||||
| Variable | Default | Meaning |
|
||||
|----------------------------|-------------|------------------------------------------|
|
||||
| `FAKE_API_PORT` | `12345` | Listen port |
|
||||
| `FAKE_API_HOST` | `127.0.0.1` | Listen address |
|
||||
| `FAKE_API_PUBLIC_BASE_URL` | derived | Origin embedded in generated media URLs |
|
||||
| `FAKE_API_RESOLVE_SECONDS` | `6` | How long a generation stays pending |
|
||||
| `FAKE_API_DEMO_CREDITS` | `100000` | Credits granted to the seeded user |
|
||||
|
||||
## Testing
|
||||
|
||||
```
|
||||
nx run fake-storyteller-web:smoke # boots the server and drives ~50 assertions
|
||||
nx run fake-storyteller-web:typecheck
|
||||
```
|
||||
|
||||
The smoke test covers login, the seeded library, media serving, the model
|
||||
catalogue, cost, a full generation lifecycle, the failure and payment triggers,
|
||||
credit deduction, upload round-tripping, and folders and tags. It exits non-zero
|
||||
on the first failure, so it is usable as a CI gate.
|
||||
|
||||
## Implementation notes
|
||||
|
||||
**No dependencies and no build step.** It runs on `node:http` with Node's native
|
||||
TypeScript stripping (`node src/main.ts`, Node 22.6+). Nothing is installed and
|
||||
nothing is compiled, so it starts instantly and works in a bare checkout.
|
||||
|
||||
Because Node strips types rather than compiling them, runtime code here must
|
||||
stay *erasable*: no TS `enum`, no `namespace`, no parameter properties. Use
|
||||
`const` objects and union types instead. The server failing to boot is the
|
||||
immediate signal if this is violated.
|
||||
|
||||
**Response shapes follow the Rust backend, not `@storyteller/api`.** This was a
|
||||
deliberate call, and it is the most important thing to know before editing.
|
||||
|
||||
## Drift between `@storyteller/api` and the real backend
|
||||
|
||||
The suggestion that prompted this project was to depend on the frontend's API
|
||||
package so the fake could not drift. That turns out to be only half possible:
|
||||
the package is not currently an accurate description of the backend, so binding
|
||||
responses to it would make the fake serve values the real API never sends, and
|
||||
hide the very bugs it should surface.
|
||||
|
||||
So the fake mirrors the Rust structs, and `src/wire/contract_checks.ts` binds
|
||||
the parts that *do* agree (`Visibility`, `JobStatus`) at compile time — if
|
||||
either side changes those, `typecheck` fails.
|
||||
|
||||
The drift found while building this is worth fixing in the package itself:
|
||||
|
||||
| Where | Drift |
|
||||
|----------------------------------------|--------------------------------------------------------------------------------------------------------|
|
||||
| `enums/MediaFileClass` | Frontend has `animation`/`character`/`prop`/`scene`; backend sends `dimensional`/`mesh`/`splat`/`project` |
|
||||
| `enums/MediaFileType` | Frontend has `mmd`/`scene`/`none` and is missing every concrete type sent (`jpg`, `png`, `mp4`, `spz`, `ply`, `scene_json`, ...) |
|
||||
| `models/MediaFile.media_links` | Declares `thumbnail_template`; backend sends `maybe_thumbnail_template`, and also `maybe_video_previews` |
|
||||
| `models/MediaFile.maybe_creator_user` | Named `maybe_creator` on `/list`, `/list_featured`, `/search_*`; absent entirely on `/list/user/:username` |
|
||||
| `models/MediaFile.maybe_creator_user` | Typed `UserInfo`; backend sends the much smaller `UserDetailsLight` |
|
||||
| `models/MediaFile.origin*` | Declared non-optional, but not sent at all by `/file/:token`, `/batch`, `/search_*`, `/mesh/list`, `/splat/list`, `/project/list` |
|
||||
| `JobsApi.ListJobs()` | Calls `/v1/jobs/batch` with no `tokens` parameter, which the real backend rejects with a 400 — so this call always fails in production |
|
||||
| `OmniGenApi` image requests | Sends `horizontal_angle`/`vertical_angle`/`zoom`; backend reads `adjust_*`, so the angle controls are silently dropped |
|
||||
| `OmniGenVideoGenerateResponse` | Omits `all_job_tokens`, so batched video jobs beyond the first are never polled |
|
||||
| `JobsApi.GetPreviewStatusByJobToken` | Reads `response.data`; the envelope is `{success, state}`, so it is always `undefined` |
|
||||
|
||||
Where the fake had to choose, it follows the backend — except for
|
||||
`/v1/jobs/batch`, where it returns an empty list instead of a 400 so that the
|
||||
client's own call pattern works.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
src/
|
||||
main.ts server bootstrap, CORS, 501 handling
|
||||
config.ts environment configuration
|
||||
auth.ts sessions, cookies, the `session` header
|
||||
http/ router, request context, multipart parser, responses
|
||||
state/ records, the store, seeding, media and asset creation
|
||||
wire/ response builders, one per real response shape
|
||||
generation/ model catalogue, cost, submission, prompt flags, resolver
|
||||
routes/ one module per API area
|
||||
scripts/smoke_test.mjs end-to-end test
|
||||
```
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "@frontend/fake-storyteller-web",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "In-memory fake of the storyteller-web API. Contains no database, cache, or third-party client. Never deployed.",
|
||||
"engines": {
|
||||
"node": ">=22.6.0"
|
||||
},
|
||||
"scripts": {
|
||||
"serve": "node --disable-warning=ExperimentalWarning src/main.ts",
|
||||
"dev": "node --watch --disable-warning=ExperimentalWarning src/main.ts"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "fake-storyteller-web",
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"projectType": "application",
|
||||
"sourceRoot": "apps/fake-storyteller-web/src",
|
||||
"tags": ["scope:dev-tooling"],
|
||||
"targets": {
|
||||
"serve": {
|
||||
"executor": "nx:run-commands",
|
||||
"options": {
|
||||
"cwd": "apps/fake-storyteller-web",
|
||||
"command": "node --disable-warning=ExperimentalWarning src/main.ts"
|
||||
}
|
||||
},
|
||||
"dev": {
|
||||
"executor": "nx:run-commands",
|
||||
"options": {
|
||||
"cwd": "apps/fake-storyteller-web",
|
||||
"command": "node --watch --disable-warning=ExperimentalWarning src/main.ts"
|
||||
}
|
||||
},
|
||||
"typecheck": {
|
||||
"executor": "nx:run-commands",
|
||||
"options": {
|
||||
"cwd": "apps/fake-storyteller-web",
|
||||
"command": "tsc --noEmit -p tsconfig.json"
|
||||
}
|
||||
},
|
||||
"smoke": {
|
||||
"executor": "nx:run-commands",
|
||||
"options": {
|
||||
"cwd": "apps/fake-storyteller-web",
|
||||
"command": "node scripts/smoke_test.mjs"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
/**
|
||||
* End-to-end smoke test.
|
||||
*
|
||||
* Boots the server on a scratch port, drives the flows the webapp depends on,
|
||||
* and exits non-zero on the first failure. Run it with
|
||||
* `nx run fake-storyteller-web:smoke` (or `node scripts/smoke_test.mjs`).
|
||||
*/
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
const PORT = Number(process.env["FAKE_API_PORT"] ?? 12456);
|
||||
const ORIGIN = `http://127.0.0.1:${PORT}`;
|
||||
const APP_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
|
||||
/** Generations resolve fast so the test does not spend its time waiting. */
|
||||
const RESOLVE_SECONDS = 2;
|
||||
const POLL_TIMEOUT_MILLIS = 20_000;
|
||||
|
||||
let failures = 0;
|
||||
let cookie = "";
|
||||
|
||||
const server = spawn(
|
||||
process.execPath,
|
||||
["--disable-warning=ExperimentalWarning", join(APP_ROOT, "src", "main.ts")],
|
||||
{
|
||||
cwd: APP_ROOT,
|
||||
env: {
|
||||
...process.env,
|
||||
FAKE_API_PORT: String(PORT),
|
||||
FAKE_API_RESOLVE_SECONDS: String(RESOLVE_SECONDS),
|
||||
},
|
||||
stdio: ["ignore", "pipe", "inherit"],
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
await waitForServer();
|
||||
await runChecks();
|
||||
} finally {
|
||||
server.kill();
|
||||
}
|
||||
|
||||
console.log(failures === 0 ? "\nAll smoke checks passed." : `\n${failures} smoke check(s) FAILED.`);
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
|
||||
async function runChecks() {
|
||||
const status = await api("GET", "/_status");
|
||||
check("health check responds", status.body.success === true);
|
||||
|
||||
const loggedOut = await api("GET", "/v1/session");
|
||||
check("logged-out session says so", loggedOut.body.logged_in === false);
|
||||
check("logged-out session sends user: null", loggedOut.body.user === null);
|
||||
|
||||
const badLogin = await api("POST", "/v1/login", {
|
||||
username_or_email: "localdev1",
|
||||
password: "wrong",
|
||||
});
|
||||
check("bad password is a 401", badLogin.status === 401);
|
||||
check("bad password uses the error_type dialect", badLogin.body.error_type === "InvalidCredentials");
|
||||
|
||||
const login = await api("POST", "/v1/login", {
|
||||
username_or_email: "localdev1",
|
||||
password: "localdev1pass",
|
||||
});
|
||||
check("login succeeds", login.body.success === true);
|
||||
check("login returns a signed session", typeof login.body.signed_session === "string");
|
||||
|
||||
const session = await api("GET", "/v1/session");
|
||||
check("session cookie authenticates", session.body.logged_in === true);
|
||||
check("session carries feature flags", Array.isArray(session.body.user?.maybe_feature_flags));
|
||||
|
||||
const credits = await api("GET", "/v1/credits/namespace/artcraft");
|
||||
const startingCredits = credits.body.sum_total_credits;
|
||||
check("credits are readable", startingCredits > 0);
|
||||
|
||||
const library = await api("GET", "/v1/media_files/list/user/localdev1");
|
||||
check("seeded library is not empty", library.body.results.length >= 6);
|
||||
check("library rows carry a media_class", library.body.results.every((row) => row.media_class !== "unknown"));
|
||||
|
||||
const firstImage = library.body.results.find((row) => row.media_class === "image");
|
||||
const asset = await fetch(firstImage.media_links.cdn_url);
|
||||
check("cdn_url serves real bytes", asset.ok && Number(asset.headers.get("content-length")) > 1000);
|
||||
check("cdn_url serves the right content type", asset.headers.get("content-type") === "image/jpeg");
|
||||
|
||||
const thumbnail = await fetch(firstImage.media_links.maybe_thumbnail_template.replace("{WIDTH}", "256"));
|
||||
check("thumbnail template resolves", thumbnail.ok);
|
||||
|
||||
const models = await api("GET", "/v1/omni_gen/models/image");
|
||||
check("image catalogue is populated", models.body.models.length > 10);
|
||||
check("catalogue lists providers", models.body.providers.length > 0);
|
||||
|
||||
const cost = await api("POST", "/v1/omni_gen/cost/image", { model: "flux_1_dev", prompt: "a cat" });
|
||||
check("cost estimate returns credits", cost.body.cost_in_credits > 0);
|
||||
|
||||
const unknownModel = await api("POST", "/v1/omni_gen/cost/image", { model: "not_a_model" });
|
||||
check("unknown model is rejected", unknownModel.status === 400);
|
||||
|
||||
await checkSuccessfulGeneration(startingCredits, cost.body.cost_in_credits);
|
||||
await checkFailureTrigger();
|
||||
await checkPaymentTrigger();
|
||||
await checkUpload();
|
||||
await checkFoldersAndTags();
|
||||
|
||||
const unimplemented = await api("GET", "/v1/definitely/not/a/real/route");
|
||||
check("unknown routes 501 loudly", unimplemented.status === 501);
|
||||
check("unknown routes name the fake", String(unimplemented.body.message).includes("fake-storyteller-web"));
|
||||
}
|
||||
|
||||
async function checkSuccessfulGeneration(startingCredits, cost) {
|
||||
const generate = await api("POST", "/v1/omni_gen/generate/image", {
|
||||
idempotency_token: randomUuid(),
|
||||
model: "flux_1_dev",
|
||||
prompt: "a lighthouse at dusk",
|
||||
});
|
||||
check("generation returns a job token", typeof generate.body.inference_job_token === "string");
|
||||
|
||||
const jobToken = generate.body.inference_job_token;
|
||||
|
||||
const pending = await api("GET", `/v1/jobs/job/${jobToken}`);
|
||||
check("job starts pending", ["pending", "started"].includes(pending.body.state.status.status));
|
||||
|
||||
const finished = await pollJob(jobToken);
|
||||
check("job completes successfully", finished.status.status === "complete_success");
|
||||
check("job reports 100% progress", finished.status.progress_percentage === 100);
|
||||
check("job carries a result media file", typeof finished.maybe_result?.entity_token === "string");
|
||||
|
||||
const resultAsset = await fetch(finished.maybe_result.media_links.cdn_url);
|
||||
check("generated media is downloadable", resultAsset.ok);
|
||||
|
||||
const afterCredits = await api("GET", "/v1/credits/namespace/artcraft");
|
||||
check(
|
||||
`credits were spent (${startingCredits} -> ${afterCredits.body.sum_total_credits})`,
|
||||
afterCredits.body.sum_total_credits === startingCredits - cost,
|
||||
);
|
||||
|
||||
const sessionJobs = await api("GET", "/v1/jobs/session");
|
||||
check("session job list includes the job", sessionJobs.body.jobs.some((job) => job.job_token === jobToken));
|
||||
check(
|
||||
"session jobs expose the wide failure field",
|
||||
sessionJobs.body.jobs.every((job) => "maybe_failure_category_updated" in job.status),
|
||||
);
|
||||
|
||||
const batch = await api("GET", `/v1/jobs/batch?tokens=${jobToken}`);
|
||||
check("batch job lookup works", batch.body.job_states.length === 1);
|
||||
|
||||
const emptyBatch = await api("GET", "/v1/jobs/batch");
|
||||
check("batch with no tokens returns an empty list", emptyBatch.body.job_states.length === 0);
|
||||
}
|
||||
|
||||
async function checkFailureTrigger() {
|
||||
const generate = await api("POST", "/v1/omni_gen/generate/image", {
|
||||
idempotency_token: randomUuid(),
|
||||
model: "flux_1_dev",
|
||||
prompt: "portrait simulate_artcraft_failure face_not_detected",
|
||||
});
|
||||
|
||||
const finished = await pollJob(generate.body.inference_job_token);
|
||||
check("failure trigger fails the job", finished.status.status === "complete_failure");
|
||||
|
||||
const sessionJobs = await api("GET", "/v1/jobs/session");
|
||||
const failed = sessionJobs.body.jobs.find((job) => job.job_token === generate.body.inference_job_token);
|
||||
check(
|
||||
"failure category is reported",
|
||||
failed.status.maybe_failure_category_updated === "face_not_detected",
|
||||
);
|
||||
}
|
||||
|
||||
async function checkPaymentTrigger() {
|
||||
const generate = await api("POST", "/v1/omni_gen/generate/image", {
|
||||
idempotency_token: randomUuid(),
|
||||
model: "flux_1_dev",
|
||||
prompt: "trigger_payment_failure",
|
||||
});
|
||||
check("payment trigger returns 402", generate.status === 402);
|
||||
}
|
||||
|
||||
async function checkUpload() {
|
||||
const pngBytes = Buffer.from(
|
||||
"89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000a49444154789c6360000002000100ffff03000006000557bfabd40000000049454e44ae426082",
|
||||
"hex",
|
||||
);
|
||||
|
||||
const form = new FormData();
|
||||
form.append("uuid_idempotency_token", randomUuid());
|
||||
form.append("maybe_title", "smoke upload");
|
||||
form.append("file", new Blob([pngBytes], { type: "image/png" }), "smoke.png");
|
||||
|
||||
const upload = await fetch(`${ORIGIN}/v1/media_files/upload/image`, {
|
||||
method: "POST",
|
||||
headers: { Cookie: cookie },
|
||||
body: form,
|
||||
});
|
||||
const uploadBody = await upload.json();
|
||||
check("upload succeeds", uploadBody.success === true);
|
||||
check("upload returns a media token", typeof uploadBody.media_file_token === "string");
|
||||
|
||||
const record = await api("GET", `/v1/media_files/file/${uploadBody.media_file_token}`);
|
||||
check("uploaded file is readable", record.body.media_file.maybe_title === "smoke upload");
|
||||
check("uploaded file is flagged as an upload", record.body.media_file.is_user_upload === true);
|
||||
|
||||
const served = await fetch(record.body.media_file.media_links.cdn_url);
|
||||
const servedBytes = Buffer.from(await served.arrayBuffer());
|
||||
check("uploaded bytes round-trip exactly", servedBytes.equals(pngBytes));
|
||||
|
||||
const listed = await api("GET", "/v1/media_files/list/user/localdev1");
|
||||
check(
|
||||
"uploaded file appears in the library",
|
||||
listed.body.results.some((row) => row.token === uploadBody.media_file_token),
|
||||
);
|
||||
}
|
||||
|
||||
async function checkFoldersAndTags() {
|
||||
const folders = await api("GET", "/v1/folders/list_all");
|
||||
check("seeded folder is listed", folders.body.folders.length >= 1);
|
||||
check("folder carries thumbnails", folders.body.folders[0].last_media_thumbnails.length > 0);
|
||||
|
||||
const created = await api("POST", "/v1/folders/create", { name: "Smoke folder" });
|
||||
check("folder creation works", created.body.folder.name === "Smoke folder");
|
||||
|
||||
const library = await api("GET", "/v1/media_files/list/user/localdev1");
|
||||
const mediaToken = library.body.results[0].token;
|
||||
|
||||
const added = await api("POST", `/v1/folders/media_files/${created.body.folder.token}/bulk_add`, {
|
||||
media_file_tokens: [mediaToken],
|
||||
});
|
||||
check("bulk add accepts the media file", added.body.accepted_media_file_tokens.length === 1);
|
||||
|
||||
const contents = await api("GET", `/v1/folders/media_files/${created.body.folder.token}`);
|
||||
check("folder contents come back", contents.body.media_files.length === 1);
|
||||
|
||||
const tags = await api("GET", "/v1/tags/list");
|
||||
check("seeded tags are listed", tags.body.tags.length >= 2);
|
||||
check("tags report a use count", tags.body.tags.every((tag) => tag.use_count > 0));
|
||||
|
||||
const tagged = await api("POST", `/v1/tags/media_file/add/${mediaToken}`, {
|
||||
maybe_tags_list: ["smoke"],
|
||||
});
|
||||
check("tagging works", tagged.body.tags.some((tag) => tag.tag_value === "smoke"));
|
||||
}
|
||||
|
||||
async function pollJob(jobToken) {
|
||||
const deadline = Date.now() + POLL_TIMEOUT_MILLIS;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const response = await api("GET", `/v1/jobs/job/${jobToken}`);
|
||||
const state = response.body.state;
|
||||
if (state.status.status === "complete_success" || state.status.status === "complete_failure") {
|
||||
return state;
|
||||
}
|
||||
await sleep(250);
|
||||
}
|
||||
|
||||
throw new Error(`job ${jobToken} did not finish within ${POLL_TIMEOUT_MILLIS}ms`);
|
||||
}
|
||||
|
||||
async function api(method, path, body) {
|
||||
const response = await fetch(`${ORIGIN}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
...(cookie === "" ? {} : { Cookie: cookie }),
|
||||
},
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
|
||||
const setCookie = response.headers.get("set-cookie");
|
||||
if (setCookie !== null && setCookie.startsWith("session=") && !setCookie.includes("Max-Age=0")) {
|
||||
cookie = setCookie.split(";")[0];
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
let parsed = {};
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch {
|
||||
parsed = { raw: text };
|
||||
}
|
||||
|
||||
return { status: response.status, body: parsed };
|
||||
}
|
||||
|
||||
function check(description, condition) {
|
||||
if (condition) {
|
||||
console.log(` ok ${description}`);
|
||||
return;
|
||||
}
|
||||
console.log(` FAIL ${description}`);
|
||||
failures += 1;
|
||||
}
|
||||
|
||||
async function waitForServer() {
|
||||
const deadline = Date.now() + 15_000;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
await fetch(`${ORIGIN}/_status`);
|
||||
return;
|
||||
} catch {
|
||||
await sleep(100);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("fake-storyteller-web did not start in time");
|
||||
}
|
||||
|
||||
function randomUuid() {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
function sleep(millis) {
|
||||
return new Promise((resolve) => setTimeout(resolve, millis));
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Session handling.
|
||||
*
|
||||
* The real backend issues an HS256 JWT, returns it as `signed_session` in the
|
||||
* body, and sets the same string as a `session` cookie. The frontend uses both:
|
||||
* the cookie normally, and a `session` header when cookies are unavailable. The
|
||||
* fake mirrors all three so no client path has to be special-cased.
|
||||
*
|
||||
* The signature is real HMAC but the secret is a constant in this file. That is
|
||||
* fine — this server has nothing to protect — and it keeps tokens inspectable.
|
||||
*/
|
||||
|
||||
import { createHmac } from "node:crypto";
|
||||
import type { RequestContext } from "./http/context.ts";
|
||||
import type { UserRecord } from "./state/entities.ts";
|
||||
import { store } from "./state/store.ts";
|
||||
import { nowIso } from "./state/clock.ts";
|
||||
import { makeToken, TOKEN_PREFIX } from "./state/tokens.ts";
|
||||
|
||||
const SESSION_COOKIE_NAME = "session";
|
||||
const VISITOR_COOKIE_NAME = "visitor";
|
||||
const SIGNING_SECRET = "fake-storyteller-web-development-only";
|
||||
const COOKIE_MAX_AGE_SECONDS = 630_720_000;
|
||||
|
||||
/** Create a session for a user and register it. Returns the signed session string. */
|
||||
export function startSession(user: UserRecord): string {
|
||||
const signedSession = signJwt({
|
||||
session_token: makeToken(TOKEN_PREFIX.appSession),
|
||||
user_token: user.userToken,
|
||||
version: "3",
|
||||
});
|
||||
|
||||
store.sessionsBySignedSession.set(signedSession, {
|
||||
signedSession,
|
||||
userToken: user.userToken,
|
||||
createdAt: nowIso(),
|
||||
});
|
||||
|
||||
return signedSession;
|
||||
}
|
||||
|
||||
export function endSession(signedSession: string | undefined): void {
|
||||
if (signedSession !== undefined) {
|
||||
store.sessionsBySignedSession.delete(signedSession);
|
||||
}
|
||||
}
|
||||
|
||||
/** The logged-in user for this request, or undefined. Cookie wins over header, as in the real backend. */
|
||||
export function currentUser(context: RequestContext): UserRecord | undefined {
|
||||
const signedSession = currentSignedSession(context);
|
||||
if (signedSession === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const session = store.sessionsBySignedSession.get(signedSession);
|
||||
if (session === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return store.usersByToken.get(session.userToken);
|
||||
}
|
||||
|
||||
export function currentSignedSession(context: RequestContext): string | undefined {
|
||||
return context.cookie(SESSION_COOKIE_NAME) ?? context.sessionHeader();
|
||||
}
|
||||
|
||||
/** `Set-Cookie` value establishing the session. Not `Secure`, because dev runs over http. */
|
||||
export function sessionCookieHeader(signedSession: string): string {
|
||||
return `${SESSION_COOKIE_NAME}=${encodeURIComponent(signedSession)}; Path=/; Max-Age=${COOKIE_MAX_AGE_SECONDS}; SameSite=Lax`;
|
||||
}
|
||||
|
||||
export function clearedSessionCookieHeader(): string {
|
||||
return `${SESSION_COOKIE_NAME}=; Path=/; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax`;
|
||||
}
|
||||
|
||||
/** The anonymous-visitor cookie the real `/v1/session` sets when one is absent. */
|
||||
export function visitorCookieHeaderIfMissing(context: RequestContext): string | undefined {
|
||||
if (context.cookie(VISITOR_COOKIE_NAME) !== undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const visitor = signJwt({ visitor_token: makeToken(TOKEN_PREFIX.appSession), version: "1" });
|
||||
return `${VISITOR_COOKIE_NAME}=${encodeURIComponent(visitor)}; Path=/; Max-Age=${COOKIE_MAX_AGE_SECONDS}; SameSite=Lax`;
|
||||
}
|
||||
|
||||
function signJwt(payload: Record<string, string>): string {
|
||||
const header = base64Url(JSON.stringify({ alg: "HS256", typ: "JWT" }));
|
||||
const body = base64Url(JSON.stringify(payload));
|
||||
const signature = createHmac("sha256", SIGNING_SECRET).update(`${header}.${body}`).digest("base64url");
|
||||
return `${header}.${body}.${signature}`;
|
||||
}
|
||||
|
||||
function base64Url(value: string): string {
|
||||
return Buffer.from(value, "utf8").toString("base64url");
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/** Server configuration. Everything has a working default; nothing is required. */
|
||||
|
||||
import { dirname, join } from "node:path";
|
||||
import { existsSync } from "node:fs";
|
||||
|
||||
/** Matches the real backend's dev port, so the webapp needs no configuration change. */
|
||||
const DEFAULT_PORT = 12345;
|
||||
|
||||
/** How long a generation job stays pending before the resolver completes it. */
|
||||
const DEFAULT_RESOLVE_SECONDS = 6;
|
||||
|
||||
export const config = {
|
||||
port: readNumber("FAKE_API_PORT", DEFAULT_PORT),
|
||||
host: process.env["FAKE_API_HOST"] ?? "127.0.0.1",
|
||||
|
||||
/**
|
||||
* Origin used to build `cdn_url` values. Media is served by this same
|
||||
* process, so it points back at itself unless overridden.
|
||||
*/
|
||||
publicBaseUrl: (process.env["FAKE_API_PUBLIC_BASE_URL"] ?? "").replace(/\/$/, ""),
|
||||
|
||||
/** Seconds a submitted generation stays `pending`. Set to 0 to resolve immediately. */
|
||||
resolveSeconds: readNumber("FAKE_API_RESOLVE_SECONDS", DEFAULT_RESOLVE_SECONDS),
|
||||
|
||||
/** Credits granted to the seeded demo user. */
|
||||
demoCredits: readNumber("FAKE_API_DEMO_CREDITS", 100_000),
|
||||
|
||||
/** Repository root, used to load fixture media out of `test_data/`. */
|
||||
repoRoot: findRepoRoot(),
|
||||
} as const;
|
||||
|
||||
/** The origin to embed in generated URLs, resolved lazily so the port is known. */
|
||||
export function publicOrigin(): string {
|
||||
if (config.publicBaseUrl.length > 0) {
|
||||
return config.publicBaseUrl;
|
||||
}
|
||||
return `http://localhost:${config.port}`;
|
||||
}
|
||||
|
||||
function readNumber(name: string, fallback: number): number {
|
||||
const raw = process.env[name];
|
||||
if (raw === undefined || raw.trim() === "") {
|
||||
return fallback;
|
||||
}
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
/** Walk up from this file until the directory holding `Cargo.toml` and `test_data/`. */
|
||||
function findRepoRoot(): string {
|
||||
let current = import.meta.dirname;
|
||||
|
||||
for (let depth = 0; depth < 10; depth += 1) {
|
||||
if (existsSync(join(current, "Cargo.toml")) && existsSync(join(current, "test_data"))) {
|
||||
return current;
|
||||
}
|
||||
const parent = dirname(current);
|
||||
if (parent === current) {
|
||||
break;
|
||||
}
|
||||
current = parent;
|
||||
}
|
||||
|
||||
return import.meta.dirname;
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* The model catalogues served by `/v1/omni_gen/models/*`.
|
||||
*
|
||||
* The real catalogues are hand-tuned per model in Rust. Reproducing every
|
||||
* capability flag verbatim would rot immediately, so the fake keeps the real
|
||||
* model ids, creators and display names — the parts the UI shows and branches
|
||||
* on — and applies one sensible capability profile per media type.
|
||||
*
|
||||
* Optional capability fields are left `undefined`, which drops them from the
|
||||
* JSON. That matches the real backend, which omits absent capabilities rather
|
||||
* than sending nulls.
|
||||
*/
|
||||
|
||||
const IMAGE_ASPECT_RATIOS = [
|
||||
"square_hd",
|
||||
"square",
|
||||
"tall_three_by_four",
|
||||
"tall_nine_by_sixteen",
|
||||
"wide_four_by_three",
|
||||
"wide_sixteen_by_nine",
|
||||
];
|
||||
|
||||
const VIDEO_ASPECT_RATIOS = ["square", "tall_nine_by_sixteen", "wide_sixteen_by_nine"];
|
||||
|
||||
const RESOLUTIONS = ["one_k", "two_k", "four_k"];
|
||||
const QUALITIES = ["low", "medium", "high"];
|
||||
|
||||
type ModelSeed = [model: string, creator: string, fullName: string];
|
||||
|
||||
const IMAGE_MODELS: ModelSeed[] = [
|
||||
["flux_1_dev", "black_forest_labs", "FLUX.1 [dev]"],
|
||||
["flux_1_schnell", "black_forest_labs", "FLUX.1 [schnell]"],
|
||||
["flux_pro_1p1", "black_forest_labs", "FLUX1.1 [pro]"],
|
||||
["flux_pro_1p1_ultra", "black_forest_labs", "FLUX1.1 [pro] ultra"],
|
||||
["flux_2_lora_angles", "black_forest_labs", "FLUX.2 Angles"],
|
||||
["gpt_image_1", "open_ai", "GPT Image 1"],
|
||||
["gpt_image_1p5", "open_ai", "GPT Image 1.5"],
|
||||
["gpt_image_2", "open_ai", "GPT Image 2"],
|
||||
["grok_imagine_image", "grok", "Grok Imagine"],
|
||||
["grok_imagine_image_q", "grok", "Grok Imagine Quick"],
|
||||
["midjourney_7", "midjourney", "Midjourney v7"],
|
||||
["midjourney_7_niji", "midjourney", "Midjourney Niji v7"],
|
||||
["midjourney_8", "midjourney", "Midjourney v8"],
|
||||
["nano_banana", "google", "Nano Banana"],
|
||||
["nano_banana_2", "google", "Nano Banana 2"],
|
||||
["nano_banana_pro", "google", "Nano Banana Pro"],
|
||||
["qwen_edit_2511_angles", "alibaba", "Qwen Edit Angles"],
|
||||
["seedream_4", "bytedance", "Seedream 4"],
|
||||
["seedream_4p5", "bytedance", "Seedream 4.5"],
|
||||
["seedream_5_lite", "bytedance", "Seedream 5 Lite"],
|
||||
["seedream_5p0_pro", "bytedance", "Seedream 5 Pro"],
|
||||
["seedream_5p0_pro_u", "bytedance", "Seedream 5 Pro Ultra"],
|
||||
];
|
||||
|
||||
const VIDEO_MODELS: ModelSeed[] = [
|
||||
["grok_video", "grok", "Grok Video"],
|
||||
["grok_imagine_video", "grok", "Grok Imagine Video"],
|
||||
["grok_imagine_video_1p5", "grok", "Grok Imagine Video 1.5"],
|
||||
["kling_1p6_pro", "kling", "Kling 1.6 Pro"],
|
||||
["kling_2p1_pro", "kling", "Kling 2.1 Pro"],
|
||||
["kling_2p1_master", "kling", "Kling 2.1 Master"],
|
||||
["kling_2p5_turbo_pro", "kling", "Kling 2.5 Turbo Pro"],
|
||||
["kling_2p6_pro", "kling", "Kling 2.6 Pro"],
|
||||
["kling_3p0_standard", "kling", "Kling 3.0 Standard"],
|
||||
["kling_3p0_pro", "kling", "Kling 3.0 Pro"],
|
||||
["happy_horse_1p0", "artcraft", "Happy Horse 1.0"],
|
||||
["seedance_1p0_lite", "bytedance", "Seedance 1.0 Lite"],
|
||||
["seedance_1p5_pro", "bytedance", "Seedance 1.5 Pro"],
|
||||
["seedance_2p0", "bytedance", "Seedance 2.0"],
|
||||
["seedance_2p0_fast", "bytedance", "Seedance 2.0 Fast"],
|
||||
["seedance_2p0_mini", "bytedance", "Seedance 2.0 Mini"],
|
||||
["sora_2", "open_ai", "Sora 2"],
|
||||
["sora_2_pro", "open_ai", "Sora 2 Pro"],
|
||||
["veo_2", "google", "Veo 2"],
|
||||
["veo_3", "google", "Veo 3"],
|
||||
["veo_3_fast", "google", "Veo 3 Fast"],
|
||||
["veo_3p1", "google", "Veo 3.1"],
|
||||
["veo_3p1_fast", "google", "Veo 3.1 Fast"],
|
||||
["veo_3p1_lite", "google", "Veo 3.1 Lite"],
|
||||
["vidu_q3", "vidu", "Vidu Q3"],
|
||||
["vidu_q3_turbo", "vidu", "Vidu Q3 Turbo"],
|
||||
];
|
||||
|
||||
const AUDIO_MODELS: ModelSeed[] = [
|
||||
["suno_music", "suno", "Suno Music"],
|
||||
["suno_remix", "suno", "Suno Remix"],
|
||||
["suno_sounds", "suno", "Suno Sounds"],
|
||||
["suno_sample", "suno", "Suno Sample"],
|
||||
["seed_audio_1p0", "bytedance", "Seed Audio 1.0"],
|
||||
];
|
||||
|
||||
const MESH_MODELS: ModelSeed[] = [
|
||||
["hunyuan_3d_2p0", "tencent", "Hunyuan3D 2.0"],
|
||||
["hunyuan_3d_2p1", "tencent", "Hunyuan3D 2.1"],
|
||||
["hunyuan_3d_3", "tencent", "Hunyuan3D 3"],
|
||||
["hunyuan_3d_3_sketch", "tencent", "Hunyuan3D 3 Sketch"],
|
||||
["hunyuan_3d_3p1_pro", "tencent", "Hunyuan3D 3.1 Pro"],
|
||||
["hunyuan_3d_3p1_rapid", "tencent", "Hunyuan3D 3.1 Rapid"],
|
||||
["tripo3d_h3p1", "tripo", "Tripo3D H3.1"],
|
||||
["meshy_v6", "meshy", "Meshy v6"],
|
||||
["rodin_2p5_fast", "deemos", "Rodin 2.5 Fast"],
|
||||
];
|
||||
|
||||
const SPLAT_MODELS: ModelSeed[] = [
|
||||
["marble_0p1_mini", "world_labs", "Marble 0.1 Mini"],
|
||||
["marble_0p1_plus", "world_labs", "Marble 0.1 Plus"],
|
||||
["marble_1p0", "world_labs", "Marble 1.0"],
|
||||
["marble_1p0_draft", "world_labs", "Marble 1.0 Draft"],
|
||||
["marble_1p1", "world_labs", "Marble 1.1"],
|
||||
["marble_1p1_plus", "world_labs", "Marble 1.1 Plus"],
|
||||
["triposplat", "tripo", "TripoSplat"],
|
||||
];
|
||||
|
||||
/** Which provider each model is routed through, for the `providers` block. */
|
||||
const PROVIDER_BY_CREATOR: Record<string, string> = {
|
||||
artcraft: "artcraft",
|
||||
grok: "grok",
|
||||
midjourney: "midjourney",
|
||||
open_ai: "sora",
|
||||
world_labs: "world_labs",
|
||||
};
|
||||
|
||||
export function imageModelsResponse(): object {
|
||||
return catalogueResponse(IMAGE_MODELS, (seed) => ({
|
||||
...baseModel(seed),
|
||||
text_prompt_supported: true,
|
||||
text_prompt_max_length: 2000,
|
||||
image_refs_supported: true,
|
||||
image_refs_max: 4,
|
||||
has_fixed_editing_aspect_ratio: false,
|
||||
aspect_ratio_options: IMAGE_ASPECT_RATIOS,
|
||||
aspect_ratio_default: "square",
|
||||
resolution_options: RESOLUTIONS,
|
||||
resolution_default: "one_k",
|
||||
quality_options: QUALITIES,
|
||||
default_quality: "high",
|
||||
batch_size_min: 1,
|
||||
batch_size_max: 4,
|
||||
batch_size_options: [1, 2, 3, 4],
|
||||
batch_size_default: 1,
|
||||
is_disabled: false,
|
||||
}));
|
||||
}
|
||||
|
||||
export function videoModelsResponse(): object {
|
||||
return catalogueResponse(VIDEO_MODELS, (seed) => ({
|
||||
...baseModel(seed),
|
||||
extra_info: "Served by fake-storyteller-web. Capabilities are representative, not exact.",
|
||||
extra_info_short: "Fake backend",
|
||||
text_to_video_supported: true,
|
||||
text_prompt_supported: true,
|
||||
text_prompt_max_length: 2000,
|
||||
starting_keyframe_supported: true,
|
||||
starting_keyframe_required: false,
|
||||
ending_keyframe_supported: true,
|
||||
image_references_supported: true,
|
||||
image_references_max: 4,
|
||||
character_references_supported: true,
|
||||
character_references_max: 2,
|
||||
show_generate_with_sound_toggle: true,
|
||||
aspect_ratio_options: VIDEO_ASPECT_RATIOS,
|
||||
aspect_ratio_default: "wide_sixteen_by_nine",
|
||||
resolution_options: ["seven_twenty_p", "ten_eighty_p"],
|
||||
resolution_default: "seven_twenty_p",
|
||||
bitrate_options: ["normal", "high"],
|
||||
bitrate_default: "normal",
|
||||
quality_options: QUALITIES,
|
||||
default_quality: "high",
|
||||
duration_seconds_min: 4,
|
||||
duration_seconds_max: 10,
|
||||
duration_seconds_options: [4, 6, 8, 10],
|
||||
duration_seconds_default: 6,
|
||||
batch_size_min: 1,
|
||||
batch_size_max: 2,
|
||||
batch_size_options: [1, 2],
|
||||
batch_size_default: 1,
|
||||
is_disabled: false,
|
||||
}));
|
||||
}
|
||||
|
||||
export function audioModelsResponse(): object {
|
||||
return catalogueResponse(AUDIO_MODELS, (seed) => ({
|
||||
...baseModel(seed),
|
||||
extra_info: "Served by fake-storyteller-web.",
|
||||
extra_info_short: "Fake backend",
|
||||
text_prompt_supported: true,
|
||||
style_prompt_supported: true,
|
||||
audio_references_supported: true,
|
||||
audio_references_max: 2,
|
||||
image_references_supported: false,
|
||||
keep_lyrics_supported: true,
|
||||
instrumental_toggle_supported: true,
|
||||
loopable_toggle_supported: true,
|
||||
bpm_supported: true,
|
||||
musical_key_supported: true,
|
||||
sample_rate_hz_options: [44_100, 48_000],
|
||||
sample_rate_hz_default: 44_100,
|
||||
speed_supported: true,
|
||||
volume_supported: true,
|
||||
pitch_supported: true,
|
||||
is_disabled: false,
|
||||
}));
|
||||
}
|
||||
|
||||
export function meshModelsResponse(): object {
|
||||
return catalogueResponse(MESH_MODELS, (seed) => ({
|
||||
...baseModel(seed),
|
||||
extra_info: "Served by fake-storyteller-web.",
|
||||
extra_info_short: "Fake backend",
|
||||
text_prompt_supported: true,
|
||||
image_input_supported: true,
|
||||
sketch_input_supported: false,
|
||||
multi_view_supported: true,
|
||||
mesh_input_supported: false,
|
||||
mesh_output_types: ["normal", "low_poly", "geometry"],
|
||||
polygon_types: ["triangle", "quad"],
|
||||
face_count_supported: true,
|
||||
pbr_supported: true,
|
||||
texture_toggle_supported: true,
|
||||
texture_quality_supported: true,
|
||||
geometry_quality_supported: true,
|
||||
is_disabled: false,
|
||||
}));
|
||||
}
|
||||
|
||||
export function splatModelsResponse(): object {
|
||||
return catalogueResponse(SPLAT_MODELS, (seed) => ({
|
||||
...baseModel(seed),
|
||||
extra_info: "Served by fake-storyteller-web.",
|
||||
extra_info_short: "Fake backend",
|
||||
text_prompt_supported: true,
|
||||
image_references_supported: true,
|
||||
image_references_max: 1,
|
||||
video_reference_supported: true,
|
||||
panorama_supported: true,
|
||||
disable_recaption_supported: true,
|
||||
is_disabled: false,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Whether a model id exists in any catalogue — used to reject unknown models like the real API. */
|
||||
export function isKnownModel(model: string): boolean {
|
||||
return [...IMAGE_MODELS, ...VIDEO_MODELS, ...AUDIO_MODELS, ...MESH_MODELS, ...SPLAT_MODELS].some(
|
||||
([modelId]) => modelId === model,
|
||||
);
|
||||
}
|
||||
|
||||
function catalogueResponse(seeds: ModelSeed[], build: (seed: ModelSeed) => object): object {
|
||||
return {
|
||||
success: true,
|
||||
models: seeds.map(build),
|
||||
providers: providerBlock(seeds),
|
||||
};
|
||||
}
|
||||
|
||||
function baseModel([model, creator, fullName]: ModelSeed): object {
|
||||
return { model, model_creator: creator, full_name: fullName };
|
||||
}
|
||||
|
||||
function providerBlock(seeds: ModelSeed[]): object[] {
|
||||
const byProvider = new Map<string, { model: string }[]>();
|
||||
|
||||
for (const [model, creator] of seeds) {
|
||||
const provider = PROVIDER_BY_CREATOR[creator] ?? "fal";
|
||||
const models = byProvider.get(provider) ?? [];
|
||||
models.push({ model });
|
||||
byProvider.set(provider, models);
|
||||
}
|
||||
|
||||
return [...byProvider].map(([provider, models]) => ({ provider, models }));
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Prompt-driven test triggers.
|
||||
*
|
||||
* Typing one of these words into a prompt forces a specific outcome, which is
|
||||
* how a developer exercises failure paths without a real provider. The flag
|
||||
* names match the ones the team already uses.
|
||||
*
|
||||
* simulate_artcraft_failure [category] fail after the normal delay
|
||||
* test_artcraft_failure [category] alias of the above
|
||||
* artcraft_test_job_failure fail immediately, no pending phase
|
||||
* trigger_payment_failure reject the submission with 402
|
||||
* simulate_artcraft_slow [seconds] stay pending for the given seconds
|
||||
*
|
||||
* `category` is any `FrontendFailureCategory` value, e.g.
|
||||
* `simulate_artcraft_failure face_not_detected`. Omitted, it is
|
||||
* `generation_failed`.
|
||||
*/
|
||||
|
||||
/** Every failure category the frontend knows how to render. */
|
||||
export const FAILURE_CATEGORIES = [
|
||||
"face_not_detected",
|
||||
"no_foreground_subject_detected",
|
||||
"format_not_supported",
|
||||
"keep_alive_elapsed",
|
||||
"not_yet_implemented",
|
||||
"retryable_worker_error",
|
||||
"model_rules_violation",
|
||||
"rule_bans_user_image",
|
||||
"rule_bans_user_image_with_faces",
|
||||
"rule_bans_user_text_prompt",
|
||||
"rule_bans_user_content",
|
||||
"rule_bans_generated_video",
|
||||
"rule_bans_generated_audio",
|
||||
"rule_bans_generated_content",
|
||||
"filesize_too_large",
|
||||
"image_dimensions_too_small",
|
||||
"image_dimensions_too_large",
|
||||
"generation_failed",
|
||||
] as const;
|
||||
|
||||
export type FailureCategory = (typeof FAILURE_CATEGORIES)[number];
|
||||
|
||||
const DEFAULT_FAILURE_CATEGORY: FailureCategory = "generation_failed";
|
||||
|
||||
const FAILURE_FLAGS = ["simulate_artcraft_failure", "test_artcraft_failure"];
|
||||
const IMMEDIATE_FAILURE_FLAG = "artcraft_test_job_failure";
|
||||
const PAYMENT_FAILURE_FLAG = "trigger_payment_failure";
|
||||
const SLOW_FLAG = "simulate_artcraft_slow";
|
||||
|
||||
export interface PromptTriggers {
|
||||
/** Reject the submission outright with a 402, before a job is created. */
|
||||
rejectWithPaymentRequired: boolean;
|
||||
/** Complete the job as a failure rather than a success. */
|
||||
failWithCategory: FailureCategory | undefined;
|
||||
/** Skip the pending phase entirely and land in a terminal state immediately. */
|
||||
resolveImmediately: boolean;
|
||||
/** Override how long the job stays pending, in seconds. */
|
||||
overrideResolveSeconds: number | undefined;
|
||||
}
|
||||
|
||||
export function readPromptTriggers(prompt: string | undefined): PromptTriggers {
|
||||
const text = (prompt ?? "").toLowerCase();
|
||||
|
||||
const immediate = text.includes(IMMEDIATE_FAILURE_FLAG);
|
||||
const delayedFailureFlag = FAILURE_FLAGS.find((flag) => text.includes(flag));
|
||||
|
||||
return {
|
||||
rejectWithPaymentRequired: text.includes(PAYMENT_FAILURE_FLAG),
|
||||
failWithCategory:
|
||||
immediate || delayedFailureFlag !== undefined
|
||||
? readCategoryAfter(text, delayedFailureFlag ?? IMMEDIATE_FAILURE_FLAG)
|
||||
: undefined,
|
||||
resolveImmediately: immediate,
|
||||
overrideResolveSeconds: readSecondsAfter(text, SLOW_FLAG),
|
||||
};
|
||||
}
|
||||
|
||||
/** The word following a flag, when it names a known failure category. */
|
||||
function readCategoryAfter(text: string, flag: string): FailureCategory {
|
||||
const nextWord = wordAfter(text, flag);
|
||||
if (nextWord === undefined) {
|
||||
return DEFAULT_FAILURE_CATEGORY;
|
||||
}
|
||||
|
||||
const match = FAILURE_CATEGORIES.find((category) => category === nextWord);
|
||||
return match ?? DEFAULT_FAILURE_CATEGORY;
|
||||
}
|
||||
|
||||
function readSecondsAfter(text: string, flag: string): number | undefined {
|
||||
if (!text.includes(flag)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const nextWord = wordAfter(text, flag);
|
||||
if (nextWord === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const seconds = Number.parseInt(nextWord, 10);
|
||||
return Number.isFinite(seconds) && seconds >= 0 ? seconds : undefined;
|
||||
}
|
||||
|
||||
function wordAfter(text: string, flag: string): string | undefined {
|
||||
const flagIndex = text.indexOf(flag);
|
||||
if (flagIndex < 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const remainder = text.slice(flagIndex + flag.length);
|
||||
const words = remainder.split(/[^a-z0-9_]+/).filter((word) => word.length > 0);
|
||||
return words[0];
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* The background resolver.
|
||||
*
|
||||
* Submitted jobs sit `pending`, tick their progress upward, then land in
|
||||
* `complete_success` or `complete_failure`. This is what makes the fake usable
|
||||
* for the flow the team actually needs to exercise: submit, watch a spinner for
|
||||
* a few seconds, then get a result or a specific failure.
|
||||
*/
|
||||
|
||||
import { nowIso } from "../state/clock.ts";
|
||||
import type { JobRecord, MediaFileRecord } from "../state/entities.ts";
|
||||
import { createMediaFile, createMediaFileFromFixture } from "../state/media_factory.ts";
|
||||
import { store } from "../state/store.ts";
|
||||
import { makeGlbBytes, makePlyBytes, makeWavBytes } from "../state/synthetic_assets.ts";
|
||||
import { readPromptTriggers } from "./prompt_flags.ts";
|
||||
|
||||
const TICK_INTERVAL_MILLIS = 500;
|
||||
|
||||
/** Progress stops here until the job actually completes, so 100% always means done. */
|
||||
const MAX_PENDING_PROGRESS = 95;
|
||||
|
||||
export function startGenerationResolver(): void {
|
||||
const timer = setInterval(tick, TICK_INTERVAL_MILLIS);
|
||||
timer.unref();
|
||||
}
|
||||
|
||||
function tick(): void {
|
||||
const now = Date.now();
|
||||
|
||||
for (const job of store.jobsByToken.values()) {
|
||||
if (job.status !== "pending" && job.status !== "started") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (now >= job.resolveAtMillis) {
|
||||
finishJob(job);
|
||||
continue;
|
||||
}
|
||||
|
||||
advanceProgress(job, now);
|
||||
}
|
||||
}
|
||||
|
||||
function advanceProgress(job: JobRecord, now: number): void {
|
||||
const startedAtMillis = Date.parse(job.createdAt);
|
||||
const totalMillis = Math.max(1, job.resolveAtMillis - startedAtMillis);
|
||||
const fraction = Math.min(1, Math.max(0, (now - startedAtMillis) / totalMillis));
|
||||
|
||||
job.status = "started";
|
||||
job.progressPercentage = Math.floor(fraction * MAX_PENDING_PROGRESS);
|
||||
job.updatedAt = nowIso();
|
||||
}
|
||||
|
||||
function finishJob(job: JobRecord): void {
|
||||
const triggers = readPromptTriggers(promptTextFor(job));
|
||||
|
||||
if (triggers.failWithCategory !== undefined) {
|
||||
job.status = "complete_failure";
|
||||
job.progressPercentage = 0;
|
||||
job.maybeFailureCategory = triggers.failWithCategory;
|
||||
job.maybeFailureMessage = `fake-storyteller-web: forced failure (${triggers.failWithCategory}).`;
|
||||
job.updatedAt = nowIso();
|
||||
console.log(`[fake-api] job ${job.jobToken} failed on purpose (${triggers.failWithCategory})`);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = createResultMediaFile(job);
|
||||
|
||||
job.status = "complete_success";
|
||||
job.progressPercentage = 100;
|
||||
job.maybeResultMediaFileToken = result.token;
|
||||
job.maybeSuccessfullyCompletedAt = nowIso();
|
||||
job.updatedAt = job.maybeSuccessfullyCompletedAt;
|
||||
console.log(`[fake-api] job ${job.jobToken} completed -> ${result.token}`);
|
||||
}
|
||||
|
||||
function createResultMediaFile(job: JobRecord): MediaFileRecord {
|
||||
const shared = {
|
||||
maybeCreatorUserToken: job.maybeCreatorUserToken,
|
||||
maybePromptToken: job.maybePromptToken,
|
||||
maybeBatchToken: job.maybeBatchToken,
|
||||
maybeOriginModelType: job.maybeModelType,
|
||||
maybeTitle: job.maybeRawInferenceText?.slice(0, 80),
|
||||
originCategory: "inference" as const,
|
||||
isUserUpload: false,
|
||||
};
|
||||
|
||||
switch (job.inferenceCategory) {
|
||||
case "video_generation":
|
||||
return createMediaFileFromFixture("video", {
|
||||
...shared,
|
||||
mediaClass: "video",
|
||||
mediaType: "mp4",
|
||||
bucketPrefix: "video_",
|
||||
extension: ".mp4",
|
||||
originProductCategory: "video_gen",
|
||||
maybeDurationMillis: 5_000,
|
||||
});
|
||||
|
||||
case "audio_generation":
|
||||
return createMediaFile({
|
||||
...shared,
|
||||
bytes: makeWavBytes(),
|
||||
mediaClass: "audio",
|
||||
mediaType: "wav",
|
||||
bucketPrefix: "audio_",
|
||||
extension: ".wav",
|
||||
originProductCategory: "unknown",
|
||||
maybeDurationMillis: 2_000,
|
||||
});
|
||||
|
||||
case "object_generation":
|
||||
return createMediaFile({
|
||||
...shared,
|
||||
bytes: makeGlbBytes(),
|
||||
mediaClass: "mesh",
|
||||
mediaType: "glb",
|
||||
bucketPrefix: "engine_",
|
||||
extension: ".glb",
|
||||
originProductCategory: "world_gen",
|
||||
});
|
||||
|
||||
case "splat_generation":
|
||||
return createMediaFile({
|
||||
...shared,
|
||||
bytes: makePlyBytes(),
|
||||
mediaClass: "splat",
|
||||
mediaType: "ply",
|
||||
bucketPrefix: "splat_",
|
||||
extension: ".ply",
|
||||
originProductCategory: "world_gen",
|
||||
});
|
||||
|
||||
default:
|
||||
return createMediaFileFromFixture("image", {
|
||||
...shared,
|
||||
mediaClass: "image",
|
||||
mediaType: "jpg",
|
||||
bucketPrefix: "image_",
|
||||
extension: ".jpg",
|
||||
originProductCategory: "image_gen",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function promptTextFor(job: JobRecord): string | undefined {
|
||||
if (job.maybeRawInferenceText !== undefined) {
|
||||
return job.maybeRawInferenceText;
|
||||
}
|
||||
if (job.maybePromptToken === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return store.promptsByToken.get(job.maybePromptToken)?.maybePositivePrompt;
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* Cost estimation and job submission, shared by all five `omni_gen` media types.
|
||||
*
|
||||
* Costs are deterministic functions of the request rather than a price table,
|
||||
* so a test can predict the credit balance after a generation without knowing
|
||||
* anything about real pricing.
|
||||
*/
|
||||
|
||||
import { config } from "../config.ts";
|
||||
import { nowIso } from "../state/clock.ts";
|
||||
import type { InferenceCategory, JobRecord, PromptRecord, UserRecord } from "../state/entities.ts";
|
||||
import { store } from "../state/store.ts";
|
||||
import { makeToken, TOKEN_PREFIX } from "../state/tokens.ts";
|
||||
import { readPromptTriggers } from "./prompt_flags.ts";
|
||||
|
||||
export type GenerationKind = "image" | "video" | "audio" | "mesh" | "splat";
|
||||
|
||||
/** Base price per media type, before batch and duration multipliers. */
|
||||
const BASE_COST_CREDITS: Record<GenerationKind, number> = {
|
||||
image: 34,
|
||||
video: 210,
|
||||
audio: 60,
|
||||
mesh: 104,
|
||||
splat: 150,
|
||||
};
|
||||
|
||||
const INFERENCE_CATEGORY: Record<GenerationKind, InferenceCategory> = {
|
||||
image: "image_generation",
|
||||
video: "video_generation",
|
||||
audio: "audio_generation",
|
||||
mesh: "object_generation",
|
||||
splat: "splat_generation",
|
||||
};
|
||||
|
||||
const UUID_PATTERN = /^[0-9a-f]{32}$|^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
export interface GenerateRequestBody {
|
||||
idempotency_token?: string;
|
||||
model?: string;
|
||||
prompt?: string;
|
||||
negative_prompt?: string;
|
||||
style_prompt?: string;
|
||||
resolution?: string;
|
||||
aspect_ratio?: string;
|
||||
quality?: string;
|
||||
bitrate?: string;
|
||||
duration_seconds?: number;
|
||||
image_batch_count?: number;
|
||||
video_batch_count?: number;
|
||||
generate_audio?: boolean;
|
||||
image_media_tokens?: string[];
|
||||
reference_image_media_tokens?: string[];
|
||||
}
|
||||
|
||||
export interface CostBreakdown {
|
||||
cost_in_credits: number;
|
||||
cost_in_usd_cents: number;
|
||||
is_free: boolean;
|
||||
is_unlimited: boolean;
|
||||
is_rate_limited: boolean;
|
||||
has_watermark: boolean;
|
||||
failures_are_refunded: boolean;
|
||||
}
|
||||
|
||||
export function estimateCost(kind: GenerationKind, body: GenerateRequestBody): CostBreakdown {
|
||||
const credits = BASE_COST_CREDITS[kind] * batchCount(kind, body) * durationMultiplier(kind, body);
|
||||
|
||||
return {
|
||||
cost_in_credits: credits,
|
||||
cost_in_usd_cents: Math.ceil(credits / 3),
|
||||
is_free: false,
|
||||
is_unlimited: false,
|
||||
is_rate_limited: false,
|
||||
has_watermark: false,
|
||||
failures_are_refunded: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function batchCount(kind: GenerationKind, body: GenerateRequestBody): number {
|
||||
const requested = kind === "video" ? body.video_batch_count : body.image_batch_count;
|
||||
return Math.min(Math.max(requested ?? 1, 1), 4);
|
||||
}
|
||||
|
||||
/** Whether this idempotency token has already been spent. */
|
||||
export function isReplayedIdempotencyToken(token: string | undefined): boolean {
|
||||
return token !== undefined && store.usedIdempotencyTokens.has(token);
|
||||
}
|
||||
|
||||
export function isValidIdempotencyToken(token: string | undefined): boolean {
|
||||
return token !== undefined && UUID_PATTERN.test(token);
|
||||
}
|
||||
|
||||
export interface SubmittedGeneration {
|
||||
promptToken: string;
|
||||
jobTokens: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Record the prompt, spend the credits, and queue one job per batch item. The
|
||||
* resolver thread finishes them later; nothing here contacts a provider.
|
||||
*/
|
||||
export function submitGeneration(
|
||||
kind: GenerationKind,
|
||||
body: GenerateRequestBody,
|
||||
user: UserRecord,
|
||||
): SubmittedGeneration {
|
||||
const cost = estimateCost(kind, body);
|
||||
spendCredits(user, cost.cost_in_credits);
|
||||
|
||||
if (body.idempotency_token !== undefined) {
|
||||
store.usedIdempotencyTokens.add(body.idempotency_token);
|
||||
}
|
||||
|
||||
const prompt = recordPrompt(kind, body);
|
||||
const triggers = readPromptTriggers(body.prompt);
|
||||
|
||||
const resolveSeconds = triggers.resolveImmediately
|
||||
? 0
|
||||
: triggers.overrideResolveSeconds ?? config.resolveSeconds;
|
||||
const resolveAtMillis = Date.now() + resolveSeconds * 1000;
|
||||
|
||||
const count = batchCount(kind, body);
|
||||
const maybeBatchToken = count > 1 ? makeToken(TOKEN_PREFIX.batchGeneration) : undefined;
|
||||
const jobTokens: string[] = [];
|
||||
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const timestamp = nowIso();
|
||||
const job: JobRecord = {
|
||||
jobToken: makeToken(TOKEN_PREFIX.inferenceJob),
|
||||
inferenceCategory: INFERENCE_CATEGORY[kind],
|
||||
status: "pending",
|
||||
progressPercentage: 0,
|
||||
maybePromptToken: prompt.token,
|
||||
maybeModelType: body.model,
|
||||
maybeModelTitle: body.model,
|
||||
maybeRawInferenceText: body.prompt,
|
||||
maybeCreatorUserToken: user.userToken,
|
||||
maybeBatchToken,
|
||||
maybeResultMediaFileToken: undefined,
|
||||
maybeFailureCategory: undefined,
|
||||
maybeFailureMessage: undefined,
|
||||
maybeSuccessfullyCompletedAt: undefined,
|
||||
resolveAtMillis,
|
||||
isDismissed: false,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
|
||||
store.jobsByToken.set(job.jobToken, job);
|
||||
jobTokens.push(job.jobToken);
|
||||
}
|
||||
|
||||
return { promptToken: prompt.token, jobTokens };
|
||||
}
|
||||
|
||||
export function hasEnoughCredits(user: UserRecord, cost: number): boolean {
|
||||
return user.monthlyCredits + user.bankedCredits >= cost;
|
||||
}
|
||||
|
||||
/** Spend monthly credits first, then banked, mirroring the real wallet order. */
|
||||
function spendCredits(user: UserRecord, cost: number): void {
|
||||
const fromMonthly = Math.min(user.monthlyCredits, cost);
|
||||
user.monthlyCredits -= fromMonthly;
|
||||
user.bankedCredits = Math.max(0, user.bankedCredits - (cost - fromMonthly));
|
||||
}
|
||||
|
||||
function recordPrompt(kind: GenerationKind, body: GenerateRequestBody): PromptRecord {
|
||||
const prompt: PromptRecord = {
|
||||
token: makeToken(TOKEN_PREFIX.prompt),
|
||||
promptType: `${kind}_generation`,
|
||||
maybePositivePrompt: body.prompt,
|
||||
maybeNegativePrompt: body.negative_prompt,
|
||||
maybeModelType: body.model,
|
||||
maybeModelClass: kind,
|
||||
maybeGenerationProvider: "artcraft",
|
||||
maybeAspectRatio: body.aspect_ratio,
|
||||
maybeResolution: body.resolution,
|
||||
maybeBatchCount: batchCount(kind, body),
|
||||
maybeDurationSeconds: body.duration_seconds,
|
||||
maybeStyleName: undefined,
|
||||
maybeGenerateAudio: body.generate_audio,
|
||||
contextImageMediaTokens: [
|
||||
...(body.image_media_tokens ?? []),
|
||||
...(body.reference_image_media_tokens ?? []),
|
||||
],
|
||||
createdAt: nowIso(),
|
||||
};
|
||||
|
||||
store.promptsByToken.set(prompt.token, prompt);
|
||||
return prompt;
|
||||
}
|
||||
|
||||
function durationMultiplier(kind: GenerationKind, body: GenerateRequestBody): number {
|
||||
if (kind !== "video") {
|
||||
return 1;
|
||||
}
|
||||
return Math.max(1, Math.round((body.duration_seconds ?? 6) / 6));
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/** Everything a handler needs about the incoming request, already buffered. */
|
||||
|
||||
import type { IncomingHttpHeaders } from "node:http";
|
||||
import { parseMultipart, type MultipartForm } from "./multipart.ts";
|
||||
|
||||
export class RequestContext {
|
||||
readonly method: string;
|
||||
readonly pathname: string;
|
||||
readonly query: URLSearchParams;
|
||||
readonly headers: IncomingHttpHeaders;
|
||||
readonly body: Buffer;
|
||||
|
||||
/** Path parameters captured by the matched route pattern, e.g. `:token`. */
|
||||
params: Readonly<Record<string, string>>;
|
||||
|
||||
private cachedCookies: Record<string, string> | undefined;
|
||||
private cachedForm: MultipartForm | undefined;
|
||||
|
||||
constructor(args: {
|
||||
method: string;
|
||||
pathname: string;
|
||||
query: URLSearchParams;
|
||||
headers: IncomingHttpHeaders;
|
||||
body: Buffer;
|
||||
}) {
|
||||
this.method = args.method;
|
||||
this.pathname = args.pathname;
|
||||
this.query = args.query;
|
||||
this.headers = args.headers;
|
||||
this.body = args.body;
|
||||
this.params = {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the body as JSON. Returns an empty object for an empty body so
|
||||
* handlers can destructure without guarding — a fake backend should not 400
|
||||
* on a shape mismatch that the real one tolerates.
|
||||
*/
|
||||
json<T>(): Partial<T> {
|
||||
if (this.body.length === 0) {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
return JSON.parse(this.body.toString("utf8")) as Partial<T>;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse the body as `multipart/form-data`. Cached, since parsing copies buffers. */
|
||||
form(): MultipartForm {
|
||||
if (this.cachedForm === undefined) {
|
||||
this.cachedForm = parseMultipart(this.body, this.headers["content-type"]);
|
||||
}
|
||||
return this.cachedForm;
|
||||
}
|
||||
|
||||
/** A single query parameter, or undefined when absent or blank. */
|
||||
queryValue(name: string): string | undefined {
|
||||
const value = this.query.get(name);
|
||||
return value === null || value === "" ? undefined : value;
|
||||
}
|
||||
|
||||
/** A query parameter parsed as a positive integer, or undefined. */
|
||||
queryNumber(name: string): number | undefined {
|
||||
const raw = this.queryValue(name);
|
||||
if (raw === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
|
||||
cookie(name: string): string | undefined {
|
||||
return this.cookies()[name];
|
||||
}
|
||||
|
||||
/** The `session` header the frontend sends when cookies are unavailable. */
|
||||
sessionHeader(): string | undefined {
|
||||
const value = this.headers["session"];
|
||||
return typeof value === "string" && value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
private cookies(): Record<string, string> {
|
||||
if (this.cachedCookies !== undefined) {
|
||||
return this.cachedCookies;
|
||||
}
|
||||
|
||||
const jar: Record<string, string> = {};
|
||||
const header = this.headers.cookie;
|
||||
if (typeof header === "string") {
|
||||
for (const pair of header.split(";")) {
|
||||
const separator = pair.indexOf("=");
|
||||
if (separator > 0) {
|
||||
jar[pair.slice(0, separator).trim()] = decodeURIComponent(pair.slice(separator + 1).trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.cachedCookies = jar;
|
||||
return jar;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Minimal `multipart/form-data` parser.
|
||||
*
|
||||
* The upload endpoints only need field values and file bytes, so this walks the
|
||||
* boundary markers directly rather than pulling in a streaming parser. Bodies
|
||||
* are already buffered in memory — this is a fake backend, uploads are small,
|
||||
* and nothing here is load bearing for production.
|
||||
*/
|
||||
|
||||
const CRLF = "\r\n";
|
||||
const HEADER_TERMINATOR = Buffer.from(CRLF + CRLF);
|
||||
|
||||
export interface UploadedFile {
|
||||
fieldName: string;
|
||||
fileName: string;
|
||||
contentType: string;
|
||||
bytes: Buffer;
|
||||
}
|
||||
|
||||
export class MultipartForm {
|
||||
private readonly fields: Map<string, string>;
|
||||
private readonly files: Map<string, UploadedFile>;
|
||||
|
||||
constructor(fields: Map<string, string>, files: Map<string, UploadedFile>) {
|
||||
this.fields = fields;
|
||||
this.files = files;
|
||||
}
|
||||
|
||||
/** Text value of a form field, or undefined when absent. */
|
||||
field(name: string): string | undefined {
|
||||
return this.fields.get(name);
|
||||
}
|
||||
|
||||
/** All text fields, for endpoints that forward an open-ended blob of settings. */
|
||||
allFields(): Record<string, string> {
|
||||
return Object.fromEntries(this.fields);
|
||||
}
|
||||
|
||||
/** The file uploaded under `name`, or undefined. */
|
||||
file(name: string): UploadedFile | undefined {
|
||||
return this.files.get(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* The first uploaded file under any field name. Upload endpoints disagree on
|
||||
* whether the part is called `file`, `video`, `image`, or the asset kind, so
|
||||
* handlers that only care that *a* file arrived use this.
|
||||
*/
|
||||
anyFile(): UploadedFile | undefined {
|
||||
for (const file of this.files.values()) {
|
||||
return file;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse a buffered request body. Returns an empty form if `contentType` is not multipart. */
|
||||
export function parseMultipart(body: Buffer, contentType: string | undefined): MultipartForm {
|
||||
const fields = new Map<string, string>();
|
||||
const files = new Map<string, UploadedFile>();
|
||||
|
||||
const boundary = readBoundary(contentType);
|
||||
if (boundary === undefined || body.length === 0) {
|
||||
return new MultipartForm(fields, files);
|
||||
}
|
||||
|
||||
for (const part of splitParts(body, boundary)) {
|
||||
const headerEnd = part.indexOf(HEADER_TERMINATOR);
|
||||
if (headerEnd < 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const headerText = part.subarray(0, headerEnd).toString("utf8");
|
||||
const content = part.subarray(headerEnd + HEADER_TERMINATOR.length);
|
||||
|
||||
const fieldName = readHeaderParameter(headerText, "name");
|
||||
if (fieldName === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const fileName = readHeaderParameter(headerText, "filename");
|
||||
if (fileName === undefined) {
|
||||
fields.set(fieldName, content.toString("utf8"));
|
||||
continue;
|
||||
}
|
||||
|
||||
files.set(fieldName, {
|
||||
fieldName,
|
||||
fileName,
|
||||
contentType: readContentType(headerText) ?? "application/octet-stream",
|
||||
bytes: content,
|
||||
});
|
||||
}
|
||||
|
||||
return new MultipartForm(fields, files);
|
||||
}
|
||||
|
||||
function splitParts(body: Buffer, boundary: string): Buffer[] {
|
||||
const delimiter = Buffer.from(`--${boundary}`);
|
||||
const parts: Buffer[] = [];
|
||||
|
||||
let cursor = body.indexOf(delimiter);
|
||||
while (cursor >= 0) {
|
||||
const partStart = cursor + delimiter.length;
|
||||
|
||||
// The delimiter after the final part is `--boundary--`.
|
||||
if (body.subarray(partStart, partStart + 2).toString("latin1") === "--") {
|
||||
break;
|
||||
}
|
||||
|
||||
const next = body.indexOf(delimiter, partStart);
|
||||
const partEnd = next < 0 ? body.length : next;
|
||||
const part = body.subarray(partStart, partEnd);
|
||||
|
||||
// Each part is preceded by a CRLF and followed by the CRLF before the next
|
||||
// delimiter; trim both so file bytes come out byte-exact.
|
||||
parts.push(trimSurroundingCrlf(part));
|
||||
|
||||
if (next < 0) {
|
||||
break;
|
||||
}
|
||||
cursor = next;
|
||||
}
|
||||
|
||||
return parts;
|
||||
}
|
||||
|
||||
function trimSurroundingCrlf(part: Buffer): Buffer {
|
||||
let start = 0;
|
||||
let end = part.length;
|
||||
if (part.subarray(0, 2).toString("latin1") === CRLF) {
|
||||
start = 2;
|
||||
}
|
||||
if (part.subarray(end - 2, end).toString("latin1") === CRLF) {
|
||||
end -= 2;
|
||||
}
|
||||
return part.subarray(start, end);
|
||||
}
|
||||
|
||||
function readBoundary(contentType: string | undefined): string | undefined {
|
||||
if (contentType === undefined || !contentType.toLowerCase().includes("multipart/form-data")) {
|
||||
return undefined;
|
||||
}
|
||||
const match = /boundary=(?:"([^"]+)"|([^;]+))/i.exec(contentType);
|
||||
const boundary = match?.[1] ?? match?.[2];
|
||||
return boundary?.trim();
|
||||
}
|
||||
|
||||
function readHeaderParameter(headerText: string, parameter: string): string | undefined {
|
||||
const pattern = new RegExp(`${parameter}=(?:"([^"]*)"|([^;\\r\\n]+))`, "i");
|
||||
const match = pattern.exec(headerText);
|
||||
const value = match?.[1] ?? match?.[2];
|
||||
return value?.trim();
|
||||
}
|
||||
|
||||
function readContentType(headerText: string): string | undefined {
|
||||
const match = /content-type:\s*([^;\r\n]+)/i.exec(headerText);
|
||||
return match?.[1]?.trim();
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Response helpers.
|
||||
*
|
||||
* Handlers normally return a plain object, which is serialized as a 200 JSON
|
||||
* body. Return an `HttpResult` when the status, headers, or a binary body
|
||||
* matter.
|
||||
*/
|
||||
|
||||
export type HeaderMap = Record<string, string | string[]>;
|
||||
|
||||
/** An explicit status/header/body triple, for when a bare JSON 200 is not enough. */
|
||||
export class HttpResult {
|
||||
readonly status: number;
|
||||
readonly body: unknown;
|
||||
readonly headers: HeaderMap;
|
||||
|
||||
constructor(status: number, body: unknown, headers: HeaderMap = {}) {
|
||||
this.status = status;
|
||||
this.body = body;
|
||||
this.headers = headers;
|
||||
}
|
||||
|
||||
/** Return a copy with `header` added. Used to attach cookies to an existing result. */
|
||||
withHeader(name: string, value: string | string[]): HttpResult {
|
||||
return new HttpResult(this.status, this.body, { ...this.headers, [name]: value });
|
||||
}
|
||||
}
|
||||
|
||||
/** 200 with an arbitrary JSON payload. */
|
||||
export function ok<T extends object>(payload: T): HttpResult {
|
||||
return new HttpResult(200, payload);
|
||||
}
|
||||
|
||||
/** 200 with the `{ success: true, ... }` envelope the real backend uses. */
|
||||
export function success<T extends object>(payload: T = {} as T): HttpResult {
|
||||
return new HttpResult(200, { success: true, ...payload });
|
||||
}
|
||||
|
||||
/** Binary response, for serving stored media bytes back to the browser. */
|
||||
export function bytes(payload: Buffer, contentType: string, headers: HeaderMap = {}): HttpResult {
|
||||
return new HttpResult(200, payload, { "Content-Type": contentType, ...headers });
|
||||
}
|
||||
|
||||
/**
|
||||
* The real backend's error envelope. Callers read a mix of `error_type`,
|
||||
* `error_code`, `error_code_str` and `message` depending on the endpoint and
|
||||
* how old it is, so the fake always emits all four.
|
||||
*/
|
||||
export function failure(status: number, errorCode: string, message: string): HttpResult {
|
||||
return new HttpResult(status, {
|
||||
success: false,
|
||||
error_type: errorCode,
|
||||
error_code: errorCode,
|
||||
error_code_str: errorCode,
|
||||
message,
|
||||
});
|
||||
}
|
||||
|
||||
export function badRequest(message: string, errorCode = "BadRequest"): HttpResult {
|
||||
return failure(400, errorCode, message);
|
||||
}
|
||||
|
||||
export function unauthorized(message = "You must be logged in."): HttpResult {
|
||||
return failure(401, "NotLoggedIn", message);
|
||||
}
|
||||
|
||||
export function notFound(message = "Not found."): HttpResult {
|
||||
return failure(404, "NotFound", message);
|
||||
}
|
||||
|
||||
export function paymentRequired(message: string): HttpResult {
|
||||
return failure(402, "InsufficientCredits", message);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Tiny path router.
|
||||
*
|
||||
* Patterns are literal segments plus `:name` captures and a trailing `*name`
|
||||
* that swallows the rest of the path (used by the media file server).
|
||||
*/
|
||||
|
||||
import type { RequestContext } from "./context.ts";
|
||||
import type { HttpResult } from "./respond.ts";
|
||||
|
||||
export type HandlerResult = HttpResult | object | undefined;
|
||||
export type RouteHandler = (ctx: RequestContext) => Promise<HandlerResult> | HandlerResult;
|
||||
|
||||
export interface RouteMatch {
|
||||
handler: RouteHandler;
|
||||
params: Record<string, string>;
|
||||
}
|
||||
|
||||
interface Route {
|
||||
method: string;
|
||||
segments: string[];
|
||||
handler: RouteHandler;
|
||||
}
|
||||
|
||||
export class Router {
|
||||
private readonly routes: Route[] = [];
|
||||
|
||||
get(pattern: string, handler: RouteHandler): void {
|
||||
this.add("GET", pattern, handler);
|
||||
}
|
||||
|
||||
post(pattern: string, handler: RouteHandler): void {
|
||||
this.add("POST", pattern, handler);
|
||||
}
|
||||
|
||||
put(pattern: string, handler: RouteHandler): void {
|
||||
this.add("PUT", pattern, handler);
|
||||
}
|
||||
|
||||
patch(pattern: string, handler: RouteHandler): void {
|
||||
this.add("PATCH", pattern, handler);
|
||||
}
|
||||
|
||||
delete(pattern: string, handler: RouteHandler): void {
|
||||
this.add("DELETE", pattern, handler);
|
||||
}
|
||||
|
||||
/** Resolve a request to a handler, or undefined when nothing matches. */
|
||||
match(method: string, pathname: string): RouteMatch | undefined {
|
||||
const requested = splitPath(pathname);
|
||||
|
||||
for (const route of this.routes) {
|
||||
if (route.method !== method) {
|
||||
continue;
|
||||
}
|
||||
const params = matchSegments(route.segments, requested);
|
||||
if (params !== undefined) {
|
||||
return { handler: route.handler, params };
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Whether any method is registered at this path — lets the server answer 405 instead of 404. */
|
||||
allowedMethods(pathname: string): string[] {
|
||||
const requested = splitPath(pathname);
|
||||
const allowed = new Set<string>();
|
||||
|
||||
for (const route of this.routes) {
|
||||
if (matchSegments(route.segments, requested) !== undefined) {
|
||||
allowed.add(route.method);
|
||||
}
|
||||
}
|
||||
|
||||
return [...allowed];
|
||||
}
|
||||
|
||||
/** Number of registered routes, reported at boot so coverage regressions are visible. */
|
||||
get size(): number {
|
||||
return this.routes.length;
|
||||
}
|
||||
|
||||
private add(method: string, pattern: string, handler: RouteHandler): void {
|
||||
this.routes.push({ method, segments: splitPath(pattern), handler });
|
||||
}
|
||||
}
|
||||
|
||||
function matchSegments(pattern: string[], requested: string[]): Record<string, string> | undefined {
|
||||
const params: Record<string, string> = {};
|
||||
|
||||
for (let index = 0; index < pattern.length; index += 1) {
|
||||
const segment = pattern[index]!;
|
||||
|
||||
if (segment.startsWith("*")) {
|
||||
params[segment.slice(1)] = requested.slice(index).join("/");
|
||||
return params;
|
||||
}
|
||||
|
||||
const value = requested[index];
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (segment.startsWith(":")) {
|
||||
params[segment.slice(1)] = decodeURIComponent(value);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (segment !== value) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return pattern.length === requested.length ? params : undefined;
|
||||
}
|
||||
|
||||
function splitPath(path: string): string[] {
|
||||
return path.split("/").filter((segment) => segment.length > 0);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* fake-storyteller-web
|
||||
*
|
||||
* An in-memory stand-in for the storyteller-web API, used to run the webapp
|
||||
* without a backend. It has no database, no cache, and no third-party clients,
|
||||
* and it is never built or deployed as part of the production image.
|
||||
*
|
||||
* Start it with `nx serve fake-storyteller-web`, then point the webapp at
|
||||
* `http://localhost:12345` (its existing development default).
|
||||
*/
|
||||
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
||||
import { config } from "./config.ts";
|
||||
import { RequestContext } from "./http/context.ts";
|
||||
import { HttpResult, failure } from "./http/respond.ts";
|
||||
import type { Router } from "./http/router.ts";
|
||||
import { buildRouter } from "./routes/index.ts";
|
||||
import { startGenerationResolver } from "./generation/resolver.ts";
|
||||
import { seedState } from "./state/seed.ts";
|
||||
|
||||
/** Refuse bodies larger than this rather than buffering without limit. */
|
||||
const MAX_BODY_BYTES = 256 * 1024 * 1024;
|
||||
|
||||
const router = buildRouter();
|
||||
|
||||
seedState();
|
||||
startGenerationResolver();
|
||||
|
||||
const server = createServer((request, response) => {
|
||||
handleRequest(router, request, response).catch((error: unknown) => {
|
||||
writeResult(response, internalError(error), request);
|
||||
});
|
||||
});
|
||||
|
||||
server.listen(config.port, config.host, () => {
|
||||
console.log(`fake-storyteller-web listening on http://${config.host}:${config.port}`);
|
||||
console.log(` routes registered: ${router.size}`);
|
||||
console.log(` media served from: ${config.repoRoot}`);
|
||||
console.log(` generations resolve in: ${config.resolveSeconds}s`);
|
||||
console.log(" NOTE: all data is in memory and is lost on restart.");
|
||||
});
|
||||
|
||||
async function handleRequest(
|
||||
activeRouter: Router,
|
||||
request: IncomingMessage,
|
||||
response: ServerResponse,
|
||||
): Promise<void> {
|
||||
const method = request.method ?? "GET";
|
||||
const url = new URL(request.url ?? "/", `http://${request.headers.host ?? "localhost"}`);
|
||||
|
||||
if (method === "OPTIONS") {
|
||||
writeResult(response, new HttpResult(204, undefined), request);
|
||||
return;
|
||||
}
|
||||
|
||||
const match = activeRouter.match(method, url.pathname);
|
||||
if (match === undefined) {
|
||||
writeResult(response, unroutedResult(activeRouter, method, url.pathname), request);
|
||||
return;
|
||||
}
|
||||
|
||||
const context = new RequestContext({
|
||||
method,
|
||||
pathname: url.pathname,
|
||||
query: url.searchParams,
|
||||
headers: request.headers,
|
||||
body: await readBody(request),
|
||||
});
|
||||
context.params = match.params;
|
||||
|
||||
const handlerResult = await match.handler(context);
|
||||
const result =
|
||||
handlerResult instanceof HttpResult
|
||||
? handlerResult
|
||||
: new HttpResult(200, handlerResult ?? { success: true });
|
||||
|
||||
writeResult(response, result, request);
|
||||
}
|
||||
|
||||
function readBody(request: IncomingMessage): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
let total = 0;
|
||||
|
||||
request.on("data", (chunk: Buffer) => {
|
||||
total += chunk.length;
|
||||
if (total > MAX_BODY_BYTES) {
|
||||
reject(new Error(`Request body exceeded ${MAX_BODY_BYTES} bytes`));
|
||||
request.destroy();
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
request.on("end", () => resolve(Buffer.concat(chunks)));
|
||||
request.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function writeResult(response: ServerResponse, result: HttpResult, request: IncomingMessage): void {
|
||||
const headers: Record<string, string | string[]> = {
|
||||
...corsHeaders(request),
|
||||
...result.headers,
|
||||
};
|
||||
|
||||
let payload: Buffer | undefined;
|
||||
if (Buffer.isBuffer(result.body)) {
|
||||
payload = result.body;
|
||||
} else if (result.body !== undefined) {
|
||||
payload = Buffer.from(JSON.stringify(result.body));
|
||||
headers["Content-Type"] ??= "application/json";
|
||||
}
|
||||
|
||||
if (payload !== undefined) {
|
||||
headers["Content-Length"] = String(payload.length);
|
||||
}
|
||||
|
||||
response.writeHead(result.status, headers);
|
||||
response.end(payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Permissive CORS that reflects the caller's origin, because the webapp runs on
|
||||
* a different port and sends credentialed requests (a wildcard origin is not
|
||||
* allowed alongside `Allow-Credentials`).
|
||||
*/
|
||||
function corsHeaders(request: IncomingMessage): Record<string, string> {
|
||||
const origin = request.headers.origin;
|
||||
return {
|
||||
"Access-Control-Allow-Origin": typeof origin === "string" ? origin : "*",
|
||||
"Access-Control-Allow-Credentials": "true",
|
||||
"Access-Control-Allow-Methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type, Accept, Authorization, session, Session",
|
||||
"Access-Control-Expose-Headers": "Content-Length, Content-Type",
|
||||
"Access-Control-Max-Age": "86400",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A deliberately loud 404/405.
|
||||
*
|
||||
* The whole point of this server is to make gaps obvious, so an unimplemented
|
||||
* endpoint names itself rather than looking like an ordinary backend error.
|
||||
*/
|
||||
function unroutedResult(activeRouter: Router, method: string, pathname: string): HttpResult {
|
||||
const allowed = activeRouter.allowedMethods(pathname);
|
||||
|
||||
if (allowed.length > 0) {
|
||||
console.warn(`[fake-api] 405 ${method} ${pathname} (allowed: ${allowed.join(", ")})`);
|
||||
return failure(
|
||||
405,
|
||||
"MethodNotAllowed",
|
||||
`fake-storyteller-web: ${pathname} exists but not for ${method} (allowed: ${allowed.join(", ")}).`,
|
||||
).withHeader("Allow", allowed.join(", "));
|
||||
}
|
||||
|
||||
console.warn(`[fake-api] 501 ${method} ${pathname} -- not implemented`);
|
||||
return failure(
|
||||
501,
|
||||
"NotImplementedInFake",
|
||||
`fake-storyteller-web does not implement ${method} ${pathname}. ` +
|
||||
"This endpoint exists in the real backend but has no fake yet -- add one in apps/fake-storyteller-web/src/routes.",
|
||||
);
|
||||
}
|
||||
|
||||
function internalError(error: unknown): HttpResult {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error("[fake-api] handler threw:", error);
|
||||
return failure(500, "InternalError", `fake-storyteller-web handler failed: ${message}`);
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* `/v1/api_keys` and `/v1/user_referral_codes` — settings-page resources.
|
||||
*
|
||||
* Both use a response envelope that returns the created record's fields at the
|
||||
* top level rather than nested, and both parse error bodies on 4xx, so the
|
||||
* shapes here matter more than usual.
|
||||
*/
|
||||
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { currentUser } from "../auth.ts";
|
||||
import type { RequestContext } from "../http/context.ts";
|
||||
import { HttpResult, failure, notFound, success, unauthorized } from "../http/respond.ts";
|
||||
import type { Router } from "../http/router.ts";
|
||||
import { nowIso } from "../state/clock.ts";
|
||||
import type { ApiKeyRecord, ReferralCodeRecord } from "../state/entities.ts";
|
||||
import { sortNewestFirst, store } from "../state/store.ts";
|
||||
import { makeToken, TOKEN_PREFIX } from "../state/tokens.ts";
|
||||
|
||||
const LOCAL_IP = "127.0.0.1";
|
||||
|
||||
export function registerAccountRoutes(router: Router): void {
|
||||
// Literal paths must be registered before `:token`, which would otherwise capture them.
|
||||
router.get("/v1/api_keys/list", listApiKeys);
|
||||
router.post("/v1/api_keys/create", createApiKey);
|
||||
router.get("/v1/api_keys/:token", getApiKey);
|
||||
router.put("/v1/api_keys/:token", updateApiKey);
|
||||
router.delete("/v1/api_keys/:token", deleteApiKey);
|
||||
|
||||
router.get("/v1/user_referral_codes/list", listReferralCodes);
|
||||
router.post("/v1/user_referral_codes/create", createReferralCode);
|
||||
router.delete("/v1/user_referral_codes/code/:token", deleteReferralCode);
|
||||
}
|
||||
|
||||
function listApiKeys(context: RequestContext): HttpResult {
|
||||
const user = currentUser(context);
|
||||
if (user === undefined) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
const owned = sortNewestFirst(
|
||||
[...store.apiKeysByToken.values()].filter((key) => key.ownerUserToken === user.userToken),
|
||||
);
|
||||
|
||||
const offset = context.queryNumber("offset") ?? 0;
|
||||
const limit = context.queryNumber("limit") ?? owned.length;
|
||||
|
||||
return success({ api_keys: owned.slice(offset, offset + limit).map(apiKeyPayload) });
|
||||
}
|
||||
|
||||
function getApiKey(context: RequestContext): HttpResult {
|
||||
const key = store.apiKeysByToken.get(context.params["token"] ?? "");
|
||||
if (key === undefined) {
|
||||
return notFound();
|
||||
}
|
||||
return success({ api_key: apiKeyPayload(key) });
|
||||
}
|
||||
|
||||
function createApiKey(context: RequestContext): HttpResult {
|
||||
const user = currentUser(context);
|
||||
if (user === undefined) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
const body = context.json<{ name: string; maybe_description: string | null }>();
|
||||
if ((body.name ?? "").trim().length === 0) {
|
||||
return failure(400, "BadInput", "name is required");
|
||||
}
|
||||
|
||||
const timestamp = nowIso();
|
||||
const key: ApiKeyRecord = {
|
||||
token: makeToken(TOKEN_PREFIX.apiKey),
|
||||
apiKey: `sk_fake_${randomBytes(24).toString("hex")}`,
|
||||
name: body.name ?? "",
|
||||
maybeDescription: body.maybe_description ?? undefined,
|
||||
ownerUserToken: user.userToken,
|
||||
maybeDeletedAt: undefined,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
|
||||
store.apiKeysByToken.set(key.token, key);
|
||||
|
||||
// The full secret is returned once, at creation, exactly like the real API.
|
||||
return success({ api_key_token: key.token, api_key: key.apiKey });
|
||||
}
|
||||
|
||||
function updateApiKey(context: RequestContext): HttpResult {
|
||||
const key = store.apiKeysByToken.get(context.params["token"] ?? "");
|
||||
if (key === undefined) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const body = context.json<{ maybe_description: string | null }>();
|
||||
key.maybeDescription = body.maybe_description ?? undefined;
|
||||
key.updatedAt = nowIso();
|
||||
return success();
|
||||
}
|
||||
|
||||
/** Soft delete, because the list endpoint returns deleted rows and the UI filters them. */
|
||||
function deleteApiKey(context: RequestContext): HttpResult {
|
||||
const key = store.apiKeysByToken.get(context.params["token"] ?? "");
|
||||
if (key === undefined) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
key.maybeDeletedAt = nowIso();
|
||||
key.updatedAt = key.maybeDeletedAt;
|
||||
return success();
|
||||
}
|
||||
|
||||
function listReferralCodes(context: RequestContext): HttpResult {
|
||||
const user = currentUser(context);
|
||||
if (user === undefined) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
const owned = sortNewestFirst(
|
||||
[...store.referralCodesByToken.values()].filter((code) => code.ownerUserToken === user.userToken),
|
||||
);
|
||||
|
||||
return success({ referral_codes: owned.map(referralCodePayload) });
|
||||
}
|
||||
|
||||
function createReferralCode(context: RequestContext): HttpResult {
|
||||
const user = currentUser(context);
|
||||
if (user === undefined) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
const body = context.json<{ code: string }>();
|
||||
const code = (body.code ?? "").trim();
|
||||
if (code.length === 0) {
|
||||
return failure(400, "BadInput", "code is required");
|
||||
}
|
||||
|
||||
for (const existing of store.referralCodesByToken.values()) {
|
||||
if (existing.code.toLowerCase() === code.toLowerCase()) {
|
||||
return failure(400, "BadInput", "that referral code is already taken");
|
||||
}
|
||||
}
|
||||
|
||||
const timestamp = nowIso();
|
||||
const record: ReferralCodeRecord = {
|
||||
token: makeToken(TOKEN_PREFIX.appSession),
|
||||
code,
|
||||
ownerUserToken: user.userToken,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
|
||||
store.referralCodesByToken.set(record.token, record);
|
||||
|
||||
return success({ token: record.token, code: record.code, code_lowercase: record.code.toLowerCase() });
|
||||
}
|
||||
|
||||
function deleteReferralCode(context: RequestContext): HttpResult {
|
||||
const token = context.params["token"] ?? "";
|
||||
if (!store.referralCodesByToken.has(token)) {
|
||||
return notFound();
|
||||
}
|
||||
store.referralCodesByToken.delete(token);
|
||||
return success();
|
||||
}
|
||||
|
||||
function apiKeyPayload(key: ApiKeyRecord): object {
|
||||
return {
|
||||
token: key.token,
|
||||
truncated_api_key: `${key.apiKey.slice(0, 12)}...`,
|
||||
name: key.name,
|
||||
maybe_description: key.maybeDescription ?? null,
|
||||
owner_user_token: key.ownerUserToken,
|
||||
ip_address_creation: LOCAL_IP,
|
||||
ip_address_update: LOCAL_IP,
|
||||
created_at: key.createdAt,
|
||||
updated_at: key.updatedAt,
|
||||
maybe_deleted_at: key.maybeDeletedAt ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function referralCodePayload(record: ReferralCodeRecord): object {
|
||||
return {
|
||||
token: record.token,
|
||||
code: record.code,
|
||||
code_lowercase: record.code.toLowerCase(),
|
||||
created_at: record.createdAt,
|
||||
updated_at: record.updatedAt,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Credits, subscriptions, and Stripe checkout.
|
||||
*
|
||||
* There is no Stripe to redirect to, so the checkout endpoints return a URL
|
||||
* pointing back at this server, which explains itself when opened. Grant
|
||||
* credits with `POST /__fake/credits` instead of going through a checkout.
|
||||
*/
|
||||
|
||||
import { currentUser } from "../auth.ts";
|
||||
import { publicOrigin } from "../config.ts";
|
||||
import type { RequestContext } from "../http/context.ts";
|
||||
import { HttpResult, failure, success, unauthorized } from "../http/respond.ts";
|
||||
import type { Router } from "../http/router.ts";
|
||||
|
||||
const VALID_PLANS = new Set(["artcraft_basic", "artcraft_pro", "artcraft_max"]);
|
||||
const VALID_CADENCES = new Set(["monthly", "yearly"]);
|
||||
|
||||
export function registerBillingRoutes(router: Router): void {
|
||||
router.get("/v1/credits/namespace/:namespace", getCredits);
|
||||
router.get("/v1/billing/active_subscriptions", listActiveSubscriptions);
|
||||
|
||||
router.post("/v1/stripe_artcraft/checkout/subscription", subscriptionCheckout);
|
||||
router.post("/v1/stripe_artcraft/user_signup_subscription_checkout", signupSubscriptionCheckout);
|
||||
router.post("/v1/stripe_artcraft/checkout/credits_pack", creditsPackCheckout);
|
||||
router.post("/v1/stripe_artcraft/portal/switch_plan", switchPlan);
|
||||
router.post("/v1/stripe_artcraft/portal/manage_plan", managePlan);
|
||||
router.post("/v1/stripe_artcraft/portal/cancel_plan", managePlan);
|
||||
router.post("/v1/stripe_artcraft/portal/update_payment_method", managePlan);
|
||||
}
|
||||
|
||||
function getCredits(context: RequestContext): HttpResult {
|
||||
const namespace = context.params["namespace"] ?? "";
|
||||
if (namespace !== "artcraft" && namespace !== "fakeyou") {
|
||||
return failure(400, "BadInput", `unknown payments namespace: ${namespace}`);
|
||||
}
|
||||
|
||||
const user = currentUser(context);
|
||||
if (user === undefined) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
return success({
|
||||
free_credits: 0,
|
||||
monthly_credits: user.monthlyCredits,
|
||||
banked_credits: user.bankedCredits,
|
||||
sum_total_credits: user.monthlyCredits + user.bankedCredits,
|
||||
});
|
||||
}
|
||||
|
||||
function listActiveSubscriptions(context: RequestContext): HttpResult {
|
||||
const user = currentUser(context);
|
||||
if (user === undefined) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
const subscriptions = user.subscriptionSlug === undefined
|
||||
? []
|
||||
: [{ namespace: "artcraft", product_slug: user.subscriptionSlug }];
|
||||
|
||||
return success({ maybe_loyalty_program: null, active_subscriptions: subscriptions });
|
||||
}
|
||||
|
||||
function subscriptionCheckout(context: RequestContext): HttpResult {
|
||||
const body = context.json<{ plan: string; cadence: string }>();
|
||||
|
||||
const checked = checkPlan(body.plan, body.cadence);
|
||||
if (!checked.ok) {
|
||||
return checked.error;
|
||||
}
|
||||
|
||||
return success({ stripe_checkout_redirect_url: explainerUrl("subscription", checked.plan) });
|
||||
}
|
||||
|
||||
function signupSubscriptionCheckout(context: RequestContext): HttpResult {
|
||||
const body = context.json<{ plan: string; cadence: string }>();
|
||||
|
||||
const checked = checkPlan(body.plan, body.cadence);
|
||||
if (!checked.ok) {
|
||||
return checked.error;
|
||||
}
|
||||
|
||||
return success({
|
||||
stripe_checkout_redirect_url: explainerUrl("subscription", checked.plan),
|
||||
generated_user: null,
|
||||
session: null,
|
||||
});
|
||||
}
|
||||
|
||||
function creditsPackCheckout(context: RequestContext): HttpResult {
|
||||
const body = context.json<{ credits_pack: string }>();
|
||||
if (body.credits_pack === undefined) {
|
||||
return failure(400, "BadInput", "no credits pack supplied");
|
||||
}
|
||||
return success({ stripe_checkout_redirect_url: explainerUrl("credits_pack", body.credits_pack) });
|
||||
}
|
||||
|
||||
function switchPlan(context: RequestContext): HttpResult {
|
||||
const body = context.json<{ plan: string; cadence: string }>();
|
||||
|
||||
const checked = checkPlan(body.plan, body.cadence);
|
||||
if (!checked.ok) {
|
||||
return checked.error;
|
||||
}
|
||||
|
||||
const user = currentUser(context);
|
||||
if (user !== undefined) {
|
||||
user.subscriptionSlug = checked.plan;
|
||||
}
|
||||
|
||||
return success({ stripe_portal_url: explainerUrl("switch_plan", checked.plan) });
|
||||
}
|
||||
|
||||
function managePlan(): HttpResult {
|
||||
return success({ stripe_portal_url: explainerUrl("manage_plan", "") });
|
||||
}
|
||||
|
||||
type CheckedPlan = { ok: true; plan: string } | { ok: false; error: HttpResult };
|
||||
|
||||
function checkPlan(plan: string | undefined, cadence: string | undefined): CheckedPlan {
|
||||
if (plan === undefined) {
|
||||
return { ok: false, error: failure(400, "BadInput", "no plan supplied") };
|
||||
}
|
||||
if (!VALID_PLANS.has(plan)) {
|
||||
return { ok: false, error: failure(400, "BadInput", `unknown plan: ${plan}`) };
|
||||
}
|
||||
if (cadence === undefined) {
|
||||
return { ok: false, error: failure(400, "BadInput", "no cadence supplied") };
|
||||
}
|
||||
if (!VALID_CADENCES.has(cadence)) {
|
||||
return { ok: false, error: failure(400, "BadInput", `unknown cadence: ${cadence}`) };
|
||||
}
|
||||
return { ok: true, plan };
|
||||
}
|
||||
|
||||
function explainerUrl(flow: string, detail: string): string {
|
||||
const query = new URLSearchParams({ flow, detail });
|
||||
return `${publicOrigin()}/__fake/checkout?${query}`;
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* `/v1/character` — reusable character references for video generation.
|
||||
*
|
||||
* Creating a character enqueues a job in the real backend, so the fake does the
|
||||
* same: the job resolves like any other and the character becomes usable once
|
||||
* it finishes.
|
||||
*/
|
||||
|
||||
import { currentUser } from "../auth.ts";
|
||||
import type { RequestContext } from "../http/context.ts";
|
||||
import { HttpResult, failure, notFound, success, unauthorized } from "../http/respond.ts";
|
||||
import type { Router } from "../http/router.ts";
|
||||
import { config } from "../config.ts";
|
||||
import { nowIso } from "../state/clock.ts";
|
||||
import type { CharacterRecord } from "../state/entities.ts";
|
||||
import { sortNewestFirst, store } from "../state/store.ts";
|
||||
import { makeToken, TOKEN_PREFIX } from "../state/tokens.ts";
|
||||
import { mediaLinks } from "../wire/media.ts";
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
export function registerCharacterRoutes(router: Router): void {
|
||||
router.post("/v1/character/create", createCharacter);
|
||||
router.post("/v1/character/edit", editCharacter);
|
||||
router.get("/v1/characters/session", listSessionCharacters);
|
||||
router.get("/v1/character/:characterToken", getCharacter);
|
||||
router.delete("/v1/character/:characterToken", deleteCharacter);
|
||||
}
|
||||
|
||||
function createCharacter(context: RequestContext): HttpResult {
|
||||
const user = currentUser(context);
|
||||
if (user === undefined) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
const body = context.json<{
|
||||
image_media_token: string;
|
||||
model: string;
|
||||
uuid_idempotency_token: string;
|
||||
character_name: string;
|
||||
character_description: string | null;
|
||||
}>();
|
||||
|
||||
if (body.image_media_token === undefined || body.character_name === undefined) {
|
||||
return failure(400, "BadInput", "image_media_token and character_name are required");
|
||||
}
|
||||
|
||||
const character: CharacterRecord = {
|
||||
token: makeToken(TOKEN_PREFIX.character),
|
||||
name: body.character_name,
|
||||
maybeDescription: body.character_description ?? undefined,
|
||||
maybeAvatarMediaFileToken: body.image_media_token,
|
||||
maybeFullImageMediaFileToken: body.image_media_token,
|
||||
models: body.model === undefined ? [] : [body.model],
|
||||
ownerUserToken: user.userToken,
|
||||
isUserCreated: true,
|
||||
createdAt: nowIso(),
|
||||
};
|
||||
|
||||
store.charactersByToken.set(character.token, character);
|
||||
|
||||
const jobToken = makeToken(TOKEN_PREFIX.inferenceJob);
|
||||
const timestamp = nowIso();
|
||||
store.jobsByToken.set(jobToken, {
|
||||
jobToken,
|
||||
inferenceCategory: "character_generation",
|
||||
status: "pending",
|
||||
progressPercentage: 0,
|
||||
maybePromptToken: undefined,
|
||||
maybeModelType: body.model,
|
||||
maybeModelTitle: body.model,
|
||||
maybeRawInferenceText: body.character_name,
|
||||
maybeCreatorUserToken: user.userToken,
|
||||
maybeBatchToken: undefined,
|
||||
maybeResultMediaFileToken: undefined,
|
||||
maybeFailureCategory: undefined,
|
||||
maybeFailureMessage: undefined,
|
||||
maybeSuccessfullyCompletedAt: undefined,
|
||||
resolveAtMillis: Date.now() + config.resolveSeconds * 1000,
|
||||
isDismissed: false,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
|
||||
return success({ inference_job_token: jobToken });
|
||||
}
|
||||
|
||||
function editCharacter(context: RequestContext): HttpResult {
|
||||
const body = context.json<{
|
||||
token: string;
|
||||
updated_name: string | null;
|
||||
updated_description: string | null;
|
||||
}>();
|
||||
|
||||
const character = store.charactersByToken.get(body.token ?? "");
|
||||
if (character === undefined) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
if (body.updated_name != null) {
|
||||
character.name = body.updated_name;
|
||||
}
|
||||
if (body.updated_description !== undefined) {
|
||||
character.maybeDescription = body.updated_description ?? undefined;
|
||||
}
|
||||
|
||||
return success();
|
||||
}
|
||||
|
||||
function getCharacter(context: RequestContext): HttpResult {
|
||||
const character = store.charactersByToken.get(context.params["characterToken"] ?? "");
|
||||
if (character === undefined) {
|
||||
return notFound();
|
||||
}
|
||||
return success({ character: characterPayload(character) });
|
||||
}
|
||||
|
||||
function deleteCharacter(context: RequestContext): HttpResult {
|
||||
const token = context.params["characterToken"] ?? "";
|
||||
if (!store.charactersByToken.has(token)) {
|
||||
return notFound();
|
||||
}
|
||||
store.charactersByToken.delete(token);
|
||||
return success();
|
||||
}
|
||||
|
||||
/**
|
||||
* `next_cursor` must be null on the last page — the client loops until it is
|
||||
* falsy, so returning a stale cursor here spins forever.
|
||||
*/
|
||||
function listSessionCharacters(context: RequestContext): HttpResult {
|
||||
const user = currentUser(context);
|
||||
if (user === undefined) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
const owned = sortNewestFirst(
|
||||
[...store.charactersByToken.values()].filter(
|
||||
(character) => character.ownerUserToken === user.userToken,
|
||||
),
|
||||
);
|
||||
|
||||
const offset = context.queryNumber("cursor") ?? 0;
|
||||
const page = owned.slice(offset, offset + PAGE_SIZE);
|
||||
const nextOffset = offset + PAGE_SIZE;
|
||||
|
||||
return success({
|
||||
characters: page.map(characterPayload),
|
||||
next_cursor: nextOffset < owned.length ? nextOffset : null,
|
||||
});
|
||||
}
|
||||
|
||||
function characterPayload(character: CharacterRecord): object {
|
||||
return {
|
||||
token: character.token,
|
||||
name: character.name,
|
||||
maybe_description: character.maybeDescription ?? null,
|
||||
maybe_avatar: characterMediaLinks(character.maybeAvatarMediaFileToken),
|
||||
maybe_full_image: characterMediaLinks(character.maybeFullImageMediaFileToken),
|
||||
models: character.models,
|
||||
is_user_created: character.isUserCreated,
|
||||
};
|
||||
}
|
||||
|
||||
function characterMediaLinks(mediaFileToken: string | undefined): object | null {
|
||||
if (mediaFileToken === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const record = store.mediaFilesByToken.get(mediaFileToken);
|
||||
if (record === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const links = mediaLinks(record);
|
||||
return {
|
||||
cdn_url: links.cdn_url,
|
||||
maybe_thumbnail_template: links.maybe_thumbnail_template,
|
||||
maybe_video_previews: null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Control endpoints that the real backend does not have.
|
||||
*
|
||||
* Everything here is namespaced under `/__fake` (plus the real `/_status`
|
||||
* health check) so it is obvious at a glance that a caller has become dependent
|
||||
* on the fake rather than on the API.
|
||||
*/
|
||||
|
||||
import { currentUser } from "../auth.ts";
|
||||
import { config, publicOrigin } from "../config.ts";
|
||||
import type { RequestContext } from "../http/context.ts";
|
||||
import { HttpResult, failure, success } from "../http/respond.ts";
|
||||
import type { Router } from "../http/router.ts";
|
||||
import { objectCount } from "../state/assets.ts";
|
||||
import { seedState } from "../state/seed.ts";
|
||||
import { store } from "../state/store.ts";
|
||||
import { FAILURE_CATEGORIES } from "../generation/prompt_flags.ts";
|
||||
|
||||
export function registerControlRoutes(router: Router): void {
|
||||
router.get("/_status", () => success({ service: "fake-storyteller-web" }));
|
||||
router.get("/__fake/state", describeState);
|
||||
router.post("/__fake/reset", resetState);
|
||||
router.post("/__fake/credits", grantCredits);
|
||||
router.get("/__fake/checkout", explainCheckout);
|
||||
}
|
||||
|
||||
function describeState(): HttpResult {
|
||||
return success({
|
||||
users: store.usersByToken.size,
|
||||
sessions: store.sessionsBySignedSession.size,
|
||||
media_files: store.mediaFilesByToken.size,
|
||||
stored_objects: objectCount(),
|
||||
jobs: store.jobsByToken.size,
|
||||
prompts: store.promptsByToken.size,
|
||||
folders: store.foldersByToken.size,
|
||||
tags: store.tagsByToken.size,
|
||||
characters: store.charactersByToken.size,
|
||||
resolve_seconds: config.resolveSeconds,
|
||||
failure_categories: FAILURE_CATEGORIES,
|
||||
});
|
||||
}
|
||||
|
||||
/** Return to the seeded fixture. Logs everyone out, since sessions are cleared too. */
|
||||
function resetState(): HttpResult {
|
||||
seedState();
|
||||
return success({ message: "fake-storyteller-web state reset to the seeded fixture" });
|
||||
}
|
||||
|
||||
function grantCredits(context: RequestContext): HttpResult {
|
||||
const user = currentUser(context);
|
||||
if (user === undefined) {
|
||||
return failure(401, "NotLoggedIn", "log in before granting credits");
|
||||
}
|
||||
|
||||
const body = context.json<{ banked_credits: number; monthly_credits: number }>();
|
||||
user.bankedCredits = body.banked_credits ?? user.bankedCredits;
|
||||
user.monthlyCredits = body.monthly_credits ?? user.monthlyCredits;
|
||||
|
||||
return success({
|
||||
banked_credits: user.bankedCredits,
|
||||
monthly_credits: user.monthlyCredits,
|
||||
sum_total_credits: user.bankedCredits + user.monthlyCredits,
|
||||
});
|
||||
}
|
||||
|
||||
/** Where the fake's Stripe checkout URLs land, so a click explains itself. */
|
||||
function explainCheckout(context: RequestContext): HttpResult {
|
||||
const flow = context.queryValue("flow") ?? "unknown";
|
||||
const detail = context.queryValue("detail") ?? "";
|
||||
|
||||
const page = `<!doctype html>
|
||||
<meta charset="utf-8">
|
||||
<title>fake-storyteller-web checkout</title>
|
||||
<style>body{font:16px/1.6 system-ui;margin:4rem auto;max-width:34rem;padding:0 1rem}code{background:#8883;padding:.1em .3em;border-radius:.2em}</style>
|
||||
<h1>There is no Stripe here</h1>
|
||||
<p>The webapp is pointed at <code>fake-storyteller-web</code>, which has no payment provider.</p>
|
||||
<p>Requested flow: <code>${escapeHtml(flow)}</code>${detail ? ` (<code>${escapeHtml(detail)}</code>)` : ""}</p>
|
||||
<p>To change your credit balance, post to <code>${publicOrigin()}/__fake/credits</code> with
|
||||
<code>{"banked_credits": 100000}</code> while logged in.</p>`;
|
||||
|
||||
return new HttpResult(200, Buffer.from(page, "utf8"), { "Content-Type": "text/html; charset=utf-8" });
|
||||
}
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """);
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
/**
|
||||
* `/v1/folders` — library organisation.
|
||||
*
|
||||
* Folder membership is an ordered token list on the folder record, so bulk
|
||||
* add/move/remove can report the counts the UI displays.
|
||||
*/
|
||||
|
||||
import { currentUser } from "../auth.ts";
|
||||
import type { RequestContext } from "../http/context.ts";
|
||||
import { HttpResult, notFound, success, unauthorized } from "../http/respond.ts";
|
||||
import type { Router } from "../http/router.ts";
|
||||
import { nowIso } from "../state/clock.ts";
|
||||
import type { FolderRecord } from "../state/entities.ts";
|
||||
import { sortNewestFirst, store } from "../state/store.ts";
|
||||
import { makeToken, TOKEN_PREFIX } from "../state/tokens.ts";
|
||||
import { folderMediaFilePayload, folderThumbnailPayload } from "../wire/media.ts";
|
||||
import { clampPageSize, decodeCursor, nextCursorOnly } from "../wire/pagination.ts";
|
||||
|
||||
/** Folder cards show up to this many recent thumbnails. */
|
||||
const FOLDER_THUMBNAIL_COUNT = 4;
|
||||
|
||||
export function registerFolderRoutes(router: Router): void {
|
||||
router.post("/v1/folders/create", createFolder);
|
||||
router.get("/v1/folders/list_all", listAllFolders);
|
||||
router.get("/v1/folders/folder/:folderToken", getFolder);
|
||||
router.delete("/v1/folders/folder/:folderToken", deleteFolder);
|
||||
router.put("/v1/folders/folder/:folderToken/color_code", setColorCode);
|
||||
router.put("/v1/folders/folder/:folderToken/cover_image", setCoverImage);
|
||||
router.put("/v1/folders/folder/:folderToken/rename", renameFolder);
|
||||
router.put("/v1/folders/folder/:folderToken/star", starFolder);
|
||||
|
||||
router.get("/v1/folders/subfolders/:folderToken", listSubfolders);
|
||||
router.post("/v1/folders/subfolders/:folderToken/bulk_add", bulkAddSubfolders);
|
||||
router.post("/v1/folders/subfolders/:folderToken/bulk_remove", bulkRemoveSubfolders);
|
||||
|
||||
router.get("/v1/folders/media_files/:folderToken", listFolderMedia);
|
||||
router.get("/v1/folders/media_files_without_folder", listMediaWithoutFolder);
|
||||
router.post("/v1/folders/media_files/:folderToken/bulk_add", bulkAddMedia);
|
||||
router.post("/v1/folders/media_files/:folderToken/bulk_move", bulkMoveMedia);
|
||||
router.post("/v1/folders/media_files/:folderToken/bulk_remove", bulkRemoveMedia);
|
||||
}
|
||||
|
||||
function createFolder(context: RequestContext): HttpResult {
|
||||
const user = currentUser(context);
|
||||
if (user === undefined) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
const body = context.json<{
|
||||
name: string;
|
||||
maybe_parent_folder_token: string | null;
|
||||
maybe_color_code: string | null;
|
||||
}>();
|
||||
|
||||
const timestamp = nowIso();
|
||||
const folder: FolderRecord = {
|
||||
token: makeToken(TOKEN_PREFIX.folder),
|
||||
name: body.name ?? "Untitled folder",
|
||||
ownerUserToken: user.userToken,
|
||||
maybeParentFolderToken: body.maybe_parent_folder_token ?? undefined,
|
||||
maybeColorCode: body.maybe_color_code ?? undefined,
|
||||
maybeCustomCoverMediaFileToken: undefined,
|
||||
hasStar: false,
|
||||
mediaFileTokens: [],
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
|
||||
store.foldersByToken.set(folder.token, folder);
|
||||
return success({ folder: folderPayload(folder) });
|
||||
}
|
||||
|
||||
function listAllFolders(context: RequestContext): HttpResult {
|
||||
const user = currentUser(context);
|
||||
if (user === undefined) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
const owned = sortNewestFirst(
|
||||
[...store.foldersByToken.values()].filter((folder) => folder.ownerUserToken === user.userToken),
|
||||
);
|
||||
|
||||
const { page, maybeCursor } = sliceWithCursor(owned, context);
|
||||
return success({ folders: page.map(folderPayload), maybe_cursor: maybeCursor });
|
||||
}
|
||||
|
||||
function getFolder(context: RequestContext): HttpResult {
|
||||
const folder = findFolder(context);
|
||||
if (folder === undefined) {
|
||||
return notFound();
|
||||
}
|
||||
return success({ folder: folderPayload(folder) });
|
||||
}
|
||||
|
||||
function deleteFolder(context: RequestContext): HttpResult {
|
||||
const folder = findFolder(context);
|
||||
if (folder === undefined) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
store.foldersByToken.delete(folder.token);
|
||||
for (const other of store.foldersByToken.values()) {
|
||||
if (other.maybeParentFolderToken === folder.token) {
|
||||
other.maybeParentFolderToken = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return success();
|
||||
}
|
||||
|
||||
function setColorCode(context: RequestContext): HttpResult {
|
||||
const folder = findFolder(context);
|
||||
if (folder === undefined) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const body = context.json<{ maybe_color_code: string | null }>();
|
||||
folder.maybeColorCode = body.maybe_color_code ?? undefined;
|
||||
folder.updatedAt = nowIso();
|
||||
return success();
|
||||
}
|
||||
|
||||
function setCoverImage(context: RequestContext): HttpResult {
|
||||
const folder = findFolder(context);
|
||||
if (folder === undefined) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const body = context.json<{ maybe_media_file_token: string | null }>();
|
||||
folder.maybeCustomCoverMediaFileToken = body.maybe_media_file_token ?? undefined;
|
||||
folder.updatedAt = nowIso();
|
||||
|
||||
return success({ maybe_resolved_cover_media_file_token: folder.maybeCustomCoverMediaFileToken ?? null });
|
||||
}
|
||||
|
||||
function renameFolder(context: RequestContext): HttpResult {
|
||||
const folder = findFolder(context);
|
||||
if (folder === undefined) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const body = context.json<{ new_name: string }>();
|
||||
if (body.new_name !== undefined) {
|
||||
folder.name = body.new_name;
|
||||
folder.updatedAt = nowIso();
|
||||
}
|
||||
|
||||
return success();
|
||||
}
|
||||
|
||||
function starFolder(context: RequestContext): HttpResult {
|
||||
const folder = findFolder(context);
|
||||
if (folder === undefined) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const body = context.json<{ has_star: boolean }>();
|
||||
folder.hasStar = body.has_star ?? false;
|
||||
folder.updatedAt = nowIso();
|
||||
return success();
|
||||
}
|
||||
|
||||
function listSubfolders(context: RequestContext): HttpResult {
|
||||
const folder = findFolder(context);
|
||||
if (folder === undefined) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const children = sortNewestFirst(
|
||||
[...store.foldersByToken.values()].filter((child) => child.maybeParentFolderToken === folder.token),
|
||||
);
|
||||
|
||||
const { page, maybeCursor } = sliceWithCursor(children, context);
|
||||
return success({ subfolders: page.map(folderPayload), maybe_cursor: maybeCursor });
|
||||
}
|
||||
|
||||
function bulkAddSubfolders(context: RequestContext): HttpResult {
|
||||
const folder = findFolder(context);
|
||||
if (folder === undefined) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const body = context.json<{ subfolder_tokens: string[] }>();
|
||||
const accepted: string[] = [];
|
||||
|
||||
for (const token of body.subfolder_tokens ?? []) {
|
||||
const child = store.foldersByToken.get(token);
|
||||
if (child === undefined || child.token === folder.token) {
|
||||
continue;
|
||||
}
|
||||
child.maybeParentFolderToken = folder.token;
|
||||
child.updatedAt = nowIso();
|
||||
accepted.push(token);
|
||||
}
|
||||
|
||||
return success({ accepted_subfolder_tokens: accepted });
|
||||
}
|
||||
|
||||
function bulkRemoveSubfolders(context: RequestContext): HttpResult {
|
||||
const folder = findFolder(context);
|
||||
if (folder === undefined) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const body = context.json<{ subfolder_tokens: string[] }>();
|
||||
let removed = 0;
|
||||
|
||||
for (const token of body.subfolder_tokens ?? []) {
|
||||
const child = store.foldersByToken.get(token);
|
||||
if (child?.maybeParentFolderToken !== folder.token) {
|
||||
continue;
|
||||
}
|
||||
child.maybeParentFolderToken = undefined;
|
||||
child.updatedAt = nowIso();
|
||||
removed += 1;
|
||||
}
|
||||
|
||||
return success({ removed_count: removed });
|
||||
}
|
||||
|
||||
function listFolderMedia(context: RequestContext): HttpResult {
|
||||
const folder = findFolder(context);
|
||||
if (folder === undefined) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const records = folder.mediaFileTokens
|
||||
.map((token) => store.mediaFilesByToken.get(token))
|
||||
.filter((record) => record !== undefined);
|
||||
|
||||
const { page, maybeCursor } = sliceWithCursor(records, context);
|
||||
return success({
|
||||
media_files: page.map((record) => folderMediaFilePayload(record, folder.updatedAt)),
|
||||
maybe_cursor: maybeCursor,
|
||||
});
|
||||
}
|
||||
|
||||
function listMediaWithoutFolder(context: RequestContext): HttpResult {
|
||||
const user = currentUser(context);
|
||||
if (user === undefined) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
const filed = new Set<string>();
|
||||
for (const folder of store.foldersByToken.values()) {
|
||||
for (const token of folder.mediaFileTokens) {
|
||||
filed.add(token);
|
||||
}
|
||||
}
|
||||
|
||||
const filterMediaClass = context.queryValue("filter_media_class");
|
||||
const unfiled = sortNewestFirst(
|
||||
[...store.mediaFilesByToken.values()].filter(
|
||||
(record) =>
|
||||
record.maybeCreatorUserToken === user.userToken &&
|
||||
!filed.has(record.token) &&
|
||||
(filterMediaClass === undefined || record.mediaClass === filterMediaClass),
|
||||
),
|
||||
);
|
||||
|
||||
const { page, maybeCursor } = sliceWithCursor(unfiled, context);
|
||||
return success({
|
||||
media_files: page.map((record) => folderMediaFilePayload(record)),
|
||||
maybe_cursor: maybeCursor,
|
||||
});
|
||||
}
|
||||
|
||||
function bulkAddMedia(context: RequestContext): HttpResult {
|
||||
const folder = findFolder(context);
|
||||
if (folder === undefined) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const body = context.json<{ media_file_tokens: string[] }>();
|
||||
const accepted = addMediaToFolder(folder, body.media_file_tokens ?? []);
|
||||
return success({ accepted_media_file_tokens: accepted });
|
||||
}
|
||||
|
||||
function bulkMoveMedia(context: RequestContext): HttpResult {
|
||||
const destination = findFolder(context);
|
||||
if (destination === undefined) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const body = context.json<{ media_file_tokens: string[]; source_folder: string }>();
|
||||
const tokens = body.media_file_tokens ?? [];
|
||||
|
||||
const accepted = addMediaToFolder(destination, tokens);
|
||||
|
||||
let removedFromSource = 0;
|
||||
const source = store.foldersByToken.get(body.source_folder ?? "");
|
||||
if (source !== undefined) {
|
||||
const before = source.mediaFileTokens.length;
|
||||
source.mediaFileTokens = source.mediaFileTokens.filter((token) => !tokens.includes(token));
|
||||
removedFromSource = before - source.mediaFileTokens.length;
|
||||
source.updatedAt = nowIso();
|
||||
}
|
||||
|
||||
return success({
|
||||
accepted_media_file_tokens: accepted,
|
||||
added_to_destination_count: accepted.length,
|
||||
removed_from_source_count: removedFromSource,
|
||||
});
|
||||
}
|
||||
|
||||
function bulkRemoveMedia(context: RequestContext): HttpResult {
|
||||
const folder = findFolder(context);
|
||||
if (folder === undefined) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const body = context.json<{ media_file_tokens: string[] }>();
|
||||
const tokens = new Set(body.media_file_tokens ?? []);
|
||||
|
||||
const before = folder.mediaFileTokens.length;
|
||||
folder.mediaFileTokens = folder.mediaFileTokens.filter((token) => !tokens.has(token));
|
||||
folder.updatedAt = nowIso();
|
||||
|
||||
return success({ removed_count: before - folder.mediaFileTokens.length });
|
||||
}
|
||||
|
||||
function addMediaToFolder(folder: FolderRecord, tokens: string[]): string[] {
|
||||
const accepted: string[] = [];
|
||||
|
||||
for (const token of tokens) {
|
||||
if (!store.mediaFilesByToken.has(token) || folder.mediaFileTokens.includes(token)) {
|
||||
continue;
|
||||
}
|
||||
folder.mediaFileTokens.push(token);
|
||||
accepted.push(token);
|
||||
}
|
||||
|
||||
folder.updatedAt = nowIso();
|
||||
return accepted;
|
||||
}
|
||||
|
||||
function folderPayload(folder: FolderRecord): object {
|
||||
const thumbnails = folder.mediaFileTokens
|
||||
.slice(-FOLDER_THUMBNAIL_COUNT)
|
||||
.map((token) => store.mediaFilesByToken.get(token))
|
||||
.filter((record) => record !== undefined)
|
||||
.map(folderThumbnailPayload);
|
||||
|
||||
const customCover = folder.maybeCustomCoverMediaFileToken === undefined
|
||||
? undefined
|
||||
: store.mediaFilesByToken.get(folder.maybeCustomCoverMediaFileToken);
|
||||
|
||||
return {
|
||||
token: folder.token,
|
||||
name: folder.name,
|
||||
owner_user_token: folder.ownerUserToken,
|
||||
maybe_parent_folder_token: folder.maybeParentFolderToken ?? null,
|
||||
last_media_thumbnails: thumbnails,
|
||||
maybe_custom_cover_thumbnail: customCover === undefined ? null : folderThumbnailPayload(customCover),
|
||||
maybe_color_code: folder.maybeColorCode ?? null,
|
||||
has_star: folder.hasStar,
|
||||
is_orphaned:
|
||||
folder.maybeParentFolderToken !== undefined && !store.foldersByToken.has(folder.maybeParentFolderToken),
|
||||
created_at: folder.createdAt,
|
||||
updated_at: folder.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function findFolder(context: RequestContext): FolderRecord | undefined {
|
||||
return store.foldersByToken.get(context.params["folderToken"] ?? "");
|
||||
}
|
||||
|
||||
/** Shared cursor slicing for the `maybe_cursor` style used across folders and tags. */
|
||||
function sliceWithCursor<T>(records: T[], context: RequestContext): { page: T[]; maybeCursor: string | null } {
|
||||
const pageSize = clampPageSize(context.queryNumber("limit") ?? context.queryNumber("page_size"));
|
||||
const offset = decodeCursor(context.queryValue("cursor"));
|
||||
|
||||
return {
|
||||
page: records.slice(offset, offset + pageSize),
|
||||
maybeCursor: nextCursorOnly(records, offset, pageSize),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/** Route registration. Order matters where a literal path shares a shape with a `:param` one. */
|
||||
|
||||
import { Router } from "../http/router.ts";
|
||||
import { registerAccountRoutes } from "./account.ts";
|
||||
import { registerBillingRoutes } from "./billing.ts";
|
||||
import { registerCharacterRoutes } from "./characters.ts";
|
||||
import { registerControlRoutes } from "./control.ts";
|
||||
import { registerFolderRoutes } from "./folders.ts";
|
||||
import { registerJobRoutes } from "./jobs.ts";
|
||||
import { registerLegacyRoutes } from "./legacy.ts";
|
||||
import { registerMediaFileRoutes } from "./media_files.ts";
|
||||
import { registerMediaServingRoutes } from "./media_serving.ts";
|
||||
import { registerMediaUploadRoutes } from "./media_upload.ts";
|
||||
import { registerOmniGenRoutes } from "./omni_gen.ts";
|
||||
import { registerPromptRoutes } from "./prompts.ts";
|
||||
import { registerSessionRoutes } from "./session.ts";
|
||||
import { registerTagRoutes } from "./tags.ts";
|
||||
|
||||
export function buildRouter(): Router {
|
||||
const router = new Router();
|
||||
|
||||
registerControlRoutes(router);
|
||||
registerMediaServingRoutes(router);
|
||||
|
||||
registerSessionRoutes(router);
|
||||
registerBillingRoutes(router);
|
||||
registerOmniGenRoutes(router);
|
||||
registerJobRoutes(router);
|
||||
|
||||
// Uploads register `/v1/media_files/upload/...`, which must be matched before
|
||||
// the reading routes' `/v1/media_files/:something` patterns.
|
||||
registerMediaUploadRoutes(router);
|
||||
registerMediaFileRoutes(router);
|
||||
|
||||
registerFolderRoutes(router);
|
||||
registerTagRoutes(router);
|
||||
registerPromptRoutes(router);
|
||||
registerCharacterRoutes(router);
|
||||
registerAccountRoutes(router);
|
||||
registerLegacyRoutes(router);
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* `/v1/jobs` — polling generation progress.
|
||||
*
|
||||
* One deliberate divergence from the real backend: `GET /v1/jobs/batch`
|
||||
* requires a `tokens` query parameter server-side, but `JobsApi.ListJobs()`
|
||||
* calls it with none and always gets a 400. The fake returns an empty list
|
||||
* instead, so the client's own call pattern works.
|
||||
*/
|
||||
|
||||
import { currentUser } from "../auth.ts";
|
||||
import type { RequestContext } from "../http/context.ts";
|
||||
import { HttpResult, notFound, success, unauthorized } from "../http/respond.ts";
|
||||
import type { Router } from "../http/router.ts";
|
||||
import { nowIso } from "../state/clock.ts";
|
||||
import { sortNewestFirst, store } from "../state/store.ts";
|
||||
import { jobPreviewPayload, jobStatePayload, sessionJobPayload } from "../wire/jobs.ts";
|
||||
|
||||
export function registerJobRoutes(router: Router): void {
|
||||
router.get("/v1/jobs/job/:token", getJob);
|
||||
router.delete("/v1/jobs/job/:token", deleteJob);
|
||||
router.get("/v1/jobs/batch", batchGetJobs);
|
||||
router.get("/v1/jobs/session", listSessionJobs);
|
||||
router.post("/v1/jobs/session/dismiss_finished", dismissFinishedJobs);
|
||||
router.get("/v1/workflows/preview_status/:token", getPreviewStatus);
|
||||
|
||||
router.get("/v1/model_inference/job_status/:token", getJob);
|
||||
router.delete("/v1/model_inference/job/:token", deleteJob);
|
||||
router.get("/v1/model_inference/queue_length", getQueueLength);
|
||||
}
|
||||
|
||||
function getJob(context: RequestContext): HttpResult {
|
||||
const job = store.jobsByToken.get(context.params["token"] ?? "");
|
||||
if (job === undefined) {
|
||||
return notFound();
|
||||
}
|
||||
return success({ state: jobStatePayload(job) });
|
||||
}
|
||||
|
||||
function deleteJob(context: RequestContext): HttpResult {
|
||||
const token = context.params["token"] ?? "";
|
||||
if (!store.jobsByToken.has(token)) {
|
||||
return notFound();
|
||||
}
|
||||
store.jobsByToken.delete(token);
|
||||
return success();
|
||||
}
|
||||
|
||||
function batchGetJobs(context: RequestContext): HttpResult {
|
||||
const requested = context.query.getAll("tokens").flatMap((value) => value.split(","));
|
||||
const wanted = new Set(requested.filter((token) => token.length > 0 && token !== "None"));
|
||||
|
||||
const states = [...wanted]
|
||||
.map((token) => store.jobsByToken.get(token))
|
||||
.filter((job) => job !== undefined)
|
||||
.map((job) => jobStatePayload(job));
|
||||
|
||||
return success({ job_states: states });
|
||||
}
|
||||
|
||||
function listSessionJobs(context: RequestContext): HttpResult {
|
||||
const user = currentUser(context);
|
||||
if (user === undefined) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
const owned = [...store.jobsByToken.values()].filter(
|
||||
(job) => job.maybeCreatorUserToken === user.userToken && !job.isDismissed,
|
||||
);
|
||||
|
||||
return success({ jobs: sortNewestFirst(owned).map(sessionJobPayload) });
|
||||
}
|
||||
|
||||
function dismissFinishedJobs(context: RequestContext): HttpResult {
|
||||
const user = currentUser(context);
|
||||
if (user === undefined) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
for (const job of store.jobsByToken.values()) {
|
||||
if (job.maybeCreatorUserToken !== user.userToken) {
|
||||
continue;
|
||||
}
|
||||
if (job.status === "complete_success" || job.status === "complete_failure") {
|
||||
job.isDismissed = true;
|
||||
job.updatedAt = nowIso();
|
||||
}
|
||||
}
|
||||
|
||||
return success();
|
||||
}
|
||||
|
||||
function getPreviewStatus(context: RequestContext): HttpResult {
|
||||
const job = store.jobsByToken.get(context.params["token"] ?? "");
|
||||
if (job === undefined) {
|
||||
return new HttpResult(404, { success: false, error_message: "job not found" });
|
||||
}
|
||||
return success({ state: jobPreviewPayload(job) });
|
||||
}
|
||||
|
||||
function getQueueLength(): HttpResult {
|
||||
let pending = 0;
|
||||
for (const job of store.jobsByToken.values()) {
|
||||
if (job.status === "pending" || job.status === "started") {
|
||||
pending += 1;
|
||||
}
|
||||
}
|
||||
return success({ pending_job_count: pending });
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Endpoints with a client in `@storyteller/api` but no live webapp consumer:
|
||||
* weights, TTS, the video studio workflow, bookmarks, and ratings.
|
||||
*
|
||||
* They are implemented as empty-but-correctly-shaped responses. That is cheap,
|
||||
* and it means a page that starts calling one of them gets an empty list rather
|
||||
* than the 501 an unimplemented route would return — which would look like a
|
||||
* bug in the page rather than a gap in the fake.
|
||||
*/
|
||||
|
||||
import { currentUser } from "../auth.ts";
|
||||
import type { RequestContext } from "../http/context.ts";
|
||||
import { HttpResult, success, unauthorized } from "../http/respond.ts";
|
||||
import type { Router } from "../http/router.ts";
|
||||
import { config } from "../config.ts";
|
||||
import { nowIso } from "../state/clock.ts";
|
||||
import { store } from "../state/store.ts";
|
||||
import { makeToken, TOKEN_PREFIX } from "../state/tokens.ts";
|
||||
|
||||
export function registerLegacyRoutes(router: Router): void {
|
||||
router.get("/v1/weights/list", listWeightsWithCursor);
|
||||
router.get("/v1/weights/list_featured", listWeightsWithCursor);
|
||||
router.get("/v1/weights/list_pinned", () => success({ results: [] }));
|
||||
router.get("/v1/weights/by_user/:username", listWeightsWithPages);
|
||||
router.post("/v1/weights/search", () => success({ weights: [] }));
|
||||
router.get("/v1/weights/weight/:weightToken", () => success());
|
||||
router.post("/v1/weights/weight/:weightToken", () => success());
|
||||
router.delete("/v1/weights/weight/:weightToken", () => success());
|
||||
router.post("/v1/weights/weight/:weightToken/cover_image", () => success());
|
||||
|
||||
router.post("/v1/tts/inference", (context) => enqueueStubJob(context, "text_to_speech"));
|
||||
router.post("/v1/workflows/enqueue_studio", (context) => enqueueStubJob(context, "workflow"));
|
||||
|
||||
router.get("/v1/user_bookmarks/batch", () => success({ bookmarks: [] }));
|
||||
router.post("/v1/user_bookmarks/create", createBookmark);
|
||||
router.delete("/v1/user_bookmarks/delete/:entityToken", () => success());
|
||||
|
||||
router.get("/v1/user_rating/batch", () => success({ ratings: [] }));
|
||||
router.post("/v1/user_rating/rate", () => success({ new_positive_rating_count_for_entity: 1 }));
|
||||
router.get("/v1/user_rating/view/:entityType/:entityToken", () => success({ maybe_rating_value: null }));
|
||||
}
|
||||
|
||||
function listWeightsWithCursor(): HttpResult {
|
||||
return success({
|
||||
results: [],
|
||||
pagination: { maybe_next: null, maybe_previous: null, cursor_is_reversed: false },
|
||||
});
|
||||
}
|
||||
|
||||
function listWeightsWithPages(): HttpResult {
|
||||
return success({ results: [], pagination: { current: 0, total_page_count: 1 } });
|
||||
}
|
||||
|
||||
function createBookmark(): HttpResult {
|
||||
return success({
|
||||
new_bookmark_count_for_entity: 1,
|
||||
user_bookmark_token: makeToken(TOKEN_PREFIX.userBookmark),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue a job that the resolver will complete like any other, so callers that
|
||||
* poll for a result get one instead of hanging.
|
||||
*/
|
||||
function enqueueStubJob(context: RequestContext, category: "text_to_speech" | "workflow"): HttpResult {
|
||||
const user = currentUser(context);
|
||||
if (user === undefined) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
const jobToken = makeToken(TOKEN_PREFIX.inferenceJob);
|
||||
const timestamp = nowIso();
|
||||
|
||||
store.jobsByToken.set(jobToken, {
|
||||
jobToken,
|
||||
inferenceCategory: category,
|
||||
status: "pending",
|
||||
progressPercentage: 0,
|
||||
maybePromptToken: undefined,
|
||||
maybeModelType: undefined,
|
||||
maybeModelTitle: undefined,
|
||||
maybeRawInferenceText: undefined,
|
||||
maybeCreatorUserToken: user.userToken,
|
||||
maybeBatchToken: undefined,
|
||||
maybeResultMediaFileToken: undefined,
|
||||
maybeFailureCategory: undefined,
|
||||
maybeFailureMessage: undefined,
|
||||
maybeSuccessfullyCompletedAt: undefined,
|
||||
resolveAtMillis: Date.now() + config.resolveSeconds * 1000,
|
||||
isDismissed: false,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
|
||||
return success({ inference_job_token: jobToken, inference_job_token_type: "inference_job" });
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
/**
|
||||
* `/v1/media_files` — reading and editing the library.
|
||||
*
|
||||
* Each listing endpoint has its own response shape in the real backend (see
|
||||
* `src/wire/media.ts`), so the handlers here mostly filter and paginate, then
|
||||
* hand off to the matching builder.
|
||||
*/
|
||||
|
||||
import { currentUser } from "../auth.ts";
|
||||
import type { RequestContext } from "../http/context.ts";
|
||||
import { HttpResult, failure, notFound, success, unauthorized } from "../http/respond.ts";
|
||||
import type { Router } from "../http/router.ts";
|
||||
import { nowIso } from "../state/clock.ts";
|
||||
import type { MediaFileRecord, Visibility } from "../state/entities.ts";
|
||||
import { mediaFilesForUser, sortNewestFirst, store } from "../state/store.ts";
|
||||
import {
|
||||
batchListMediaFilePayload,
|
||||
batchMediaFilePayload,
|
||||
getMediaFilePayload,
|
||||
mediaFileListItemPayload,
|
||||
projectMediaFilePayload,
|
||||
searchMediaFilePayload,
|
||||
sessionMediaFilePayload,
|
||||
userMediaFileListItemPayload,
|
||||
} from "../wire/media.ts";
|
||||
import { paginateByCursor, paginateByIndex } from "../wire/pagination.ts";
|
||||
|
||||
const VALID_VISIBILITIES = new Set<Visibility>(["public", "hidden", "private"]);
|
||||
|
||||
export function registerMediaFileRoutes(router: Router): void {
|
||||
router.get("/v1/media_files/file/:token", getMediaFile);
|
||||
router.delete("/v1/media_files/file/:token", deleteMediaFile);
|
||||
router.get("/v1/media_files/batch", batchGetMediaFiles);
|
||||
router.get("/v1/media_files/batch/:batchToken", listByBatchToken);
|
||||
router.get("/v1/media_files/list", listMediaFiles);
|
||||
router.get("/v1/media_files/list_featured", listFeatured);
|
||||
router.get("/v1/media_files/list/user/:username", listUserMediaFiles);
|
||||
router.get("/v1/media_files/mesh/list", (context) => listSessionByClass(context, "mesh"));
|
||||
router.get("/v1/media_files/splat/list", (context) => listSessionByClass(context, "splat"));
|
||||
router.get("/v1/media_files/project/list", listProjects);
|
||||
router.get("/v1/media_files/search_featured", (context) => search(context, false));
|
||||
router.get("/v1/media_files/search_session", (context) => search(context, true));
|
||||
router.post("/v1/media_files/rename/:token", renameMediaFile);
|
||||
router.post("/v1/media_files/cover_image/:token", setCoverImage);
|
||||
router.post("/v1/media_files/visibility/:token", setVisibility);
|
||||
}
|
||||
|
||||
function getMediaFile(context: RequestContext): HttpResult {
|
||||
const record = store.mediaFilesByToken.get(context.params["token"] ?? "");
|
||||
if (record === undefined) {
|
||||
return notFound();
|
||||
}
|
||||
return success({ media_file: getMediaFilePayload(record) });
|
||||
}
|
||||
|
||||
function deleteMediaFile(context: RequestContext): HttpResult {
|
||||
const token = context.params["token"] ?? "";
|
||||
if (!store.mediaFilesByToken.has(token)) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
store.mediaFilesByToken.delete(token);
|
||||
detachFromFoldersAndTags(token);
|
||||
return success();
|
||||
}
|
||||
|
||||
function batchGetMediaFiles(context: RequestContext): HttpResult {
|
||||
const requested = context.query.getAll("tokens").flatMap((value) => value.split(","));
|
||||
const wanted = new Set(requested.filter((token) => token.length > 0));
|
||||
|
||||
const media = [...wanted]
|
||||
.map((token) => store.mediaFilesByToken.get(token))
|
||||
.filter((record) => record !== undefined)
|
||||
.map((record) => batchMediaFilePayload(record));
|
||||
|
||||
return success({ media_files: media });
|
||||
}
|
||||
|
||||
function listByBatchToken(context: RequestContext): HttpResult {
|
||||
const batchToken = context.params["batchToken"] ?? "";
|
||||
const records = sortNewestFirst(
|
||||
[...store.mediaFilesByToken.values()].filter((record) => record.maybeBatchToken === batchToken),
|
||||
);
|
||||
|
||||
const page = paginateByIndex(records, {
|
||||
pageIndex: context.queryNumber("page_index"),
|
||||
pageSize: context.queryNumber("page_size"),
|
||||
});
|
||||
|
||||
return success({
|
||||
results: page.results.map(batchListMediaFilePayload),
|
||||
pagination: page.pagination,
|
||||
});
|
||||
}
|
||||
|
||||
function listMediaFiles(context: RequestContext): HttpResult {
|
||||
const visible = applyFilters(
|
||||
context,
|
||||
[...store.mediaFilesByToken.values()].filter((record) => record.creatorSetVisibility === "public"),
|
||||
);
|
||||
|
||||
const page = paginateByCursor(sortNewestFirst(visible), {
|
||||
cursor: context.queryValue("cursor"),
|
||||
pageSize: context.queryNumber("page_size"),
|
||||
cursorIsReversed: context.queryValue("cursor_is_reversed") === "true",
|
||||
});
|
||||
|
||||
return success({
|
||||
results: page.results.map(mediaFileListItemPayload),
|
||||
pagination: page.pagination,
|
||||
});
|
||||
}
|
||||
|
||||
function listFeatured(context: RequestContext): HttpResult {
|
||||
const visible = applyFilters(
|
||||
context,
|
||||
[...store.mediaFilesByToken.values()].filter((record) => record.creatorSetVisibility === "public"),
|
||||
);
|
||||
|
||||
const page = paginateByCursor(sortNewestFirst(visible), {
|
||||
cursor: context.queryValue("cursor"),
|
||||
pageSize: context.queryNumber("page_size"),
|
||||
cursorIsReversed: context.queryValue("cursor_is_reversed") === "true",
|
||||
});
|
||||
|
||||
return success({
|
||||
results: page.results.map(mediaFileListItemPayload),
|
||||
pagination: page.pagination,
|
||||
});
|
||||
}
|
||||
|
||||
function listUserMediaFiles(context: RequestContext): HttpResult {
|
||||
const user = store.usersByUsername.get((context.params["username"] ?? "").toLowerCase());
|
||||
if (user === undefined) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const owned = applyFilters(context, mediaFilesForUser(user.userToken));
|
||||
const page = paginateByIndex(owned, {
|
||||
pageIndex: context.queryNumber("page_index"),
|
||||
pageSize: context.queryNumber("page_size"),
|
||||
});
|
||||
|
||||
return success({
|
||||
results: page.results.map(userMediaFileListItemPayload),
|
||||
pagination: page.pagination,
|
||||
});
|
||||
}
|
||||
|
||||
function listSessionByClass(context: RequestContext, mediaClass: "mesh" | "splat"): HttpResult {
|
||||
const user = currentUser(context);
|
||||
if (user === undefined) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
const owned = mediaFilesForUser(user.userToken).filter((record) => record.mediaClass === mediaClass);
|
||||
const page = paginateByCursor(owned, {
|
||||
cursor: context.queryValue("cursor"),
|
||||
pageSize: context.queryNumber("page_size") ?? 100,
|
||||
cursorIsReversed: context.queryValue("cursor_is_reversed") === "true",
|
||||
});
|
||||
|
||||
return success({
|
||||
results: page.results.map(sessionMediaFilePayload),
|
||||
pagination: page.pagination,
|
||||
});
|
||||
}
|
||||
|
||||
function listProjects(context: RequestContext): HttpResult {
|
||||
const user = currentUser(context);
|
||||
if (user === undefined) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
const filterProjectType = context.queryValue("filter_project_type");
|
||||
const owned = mediaFilesForUser(user.userToken).filter(
|
||||
(record) =>
|
||||
record.mediaClass === "project" &&
|
||||
(filterProjectType === undefined || record.maybeProjectType === filterProjectType),
|
||||
);
|
||||
|
||||
const page = paginateByCursor(owned, {
|
||||
cursor: context.queryValue("cursor"),
|
||||
pageSize: context.queryNumber("page_size") ?? 100,
|
||||
cursorIsReversed: context.queryValue("cursor_is_reversed") === "true",
|
||||
});
|
||||
|
||||
return success({
|
||||
results: page.results.map(projectMediaFilePayload),
|
||||
pagination: page.pagination,
|
||||
});
|
||||
}
|
||||
|
||||
function search(context: RequestContext, sessionOnly: boolean): HttpResult {
|
||||
const searchTerm = context.queryValue("search_term");
|
||||
if (searchTerm === undefined) {
|
||||
return failure(400, "BadInput", "search_term is required");
|
||||
}
|
||||
|
||||
let candidates = [...store.mediaFilesByToken.values()];
|
||||
if (sessionOnly) {
|
||||
const user = currentUser(context);
|
||||
if (user === undefined) {
|
||||
return unauthorized();
|
||||
}
|
||||
candidates = mediaFilesForUser(user.userToken);
|
||||
}
|
||||
|
||||
const needle = searchTerm.toLowerCase();
|
||||
const matched = applyFilters(context, candidates).filter((record) =>
|
||||
`${record.maybeTitle ?? ""} ${record.maybeOriginalFilename ?? ""}`.toLowerCase().includes(needle),
|
||||
);
|
||||
|
||||
return success({ results: sortNewestFirst(matched).map(searchMediaFilePayload) });
|
||||
}
|
||||
|
||||
function renameMediaFile(context: RequestContext): HttpResult {
|
||||
const record = store.mediaFilesByToken.get(context.params["token"] ?? "");
|
||||
if (record === undefined) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const body = context.json<{ name: string | null }>();
|
||||
const name = body.name ?? undefined;
|
||||
record.maybeTitle = name === undefined || name.length === 0 ? undefined : name;
|
||||
record.updatedAt = nowIso();
|
||||
return success();
|
||||
}
|
||||
|
||||
function setCoverImage(context: RequestContext): HttpResult {
|
||||
const record = store.mediaFilesByToken.get(context.params["token"] ?? "");
|
||||
if (record === undefined) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const body = context.json<{ cover_image_media_file_token: string | null }>();
|
||||
record.maybeCoverImageMediaFileToken = body.cover_image_media_file_token ?? undefined;
|
||||
record.updatedAt = nowIso();
|
||||
return success();
|
||||
}
|
||||
|
||||
function setVisibility(context: RequestContext): HttpResult {
|
||||
const record = store.mediaFilesByToken.get(context.params["token"] ?? "");
|
||||
if (record === undefined) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const body = context.json<{ creator_set_visibility: Visibility | null }>();
|
||||
const visibility = body.creator_set_visibility ?? undefined;
|
||||
if (visibility !== undefined && !VALID_VISIBILITIES.has(visibility)) {
|
||||
return failure(400, "BadInput", `unknown visibility: ${visibility}`);
|
||||
}
|
||||
|
||||
record.creatorSetVisibility = visibility ?? "public";
|
||||
record.updatedAt = nowIso();
|
||||
return success();
|
||||
}
|
||||
|
||||
/** Apply the `filter_media_classes` / `filter_media_type` query filters shared by the list endpoints. */
|
||||
function applyFilters(context: RequestContext, records: MediaFileRecord[]): MediaFileRecord[] {
|
||||
const classes = commaSeparated(context.queryValue("filter_media_classes"));
|
||||
const types = commaSeparated(context.queryValue("filter_media_type"));
|
||||
|
||||
return records.filter((record) => {
|
||||
if (classes !== undefined && !classes.has(record.mediaClass)) {
|
||||
return false;
|
||||
}
|
||||
if (types !== undefined && !types.has(record.mediaType)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function commaSeparated(value: string | undefined): Set<string> | undefined {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const entries = value.split(",").map((entry) => entry.trim()).filter((entry) => entry.length > 0);
|
||||
return entries.length === 0 ? undefined : new Set(entries);
|
||||
}
|
||||
|
||||
function detachFromFoldersAndTags(mediaFileToken: string): void {
|
||||
for (const folder of store.foldersByToken.values()) {
|
||||
folder.mediaFileTokens = folder.mediaFileTokens.filter((token) => token !== mediaFileToken);
|
||||
}
|
||||
for (const tag of store.tagsByToken.values()) {
|
||||
tag.mediaFileTokens.delete(mediaFileToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* `/media/*` — serving stored bytes back.
|
||||
*
|
||||
* This is the fake's CDN. Every `cdn_url` the API hands out points here, so
|
||||
* uploads and generation results load in the browser with no bucket, no proxy,
|
||||
* and no external host involved.
|
||||
*/
|
||||
|
||||
import type { RequestContext } from "../http/context.ts";
|
||||
import { HttpResult, bytes, notFound } from "../http/respond.ts";
|
||||
import type { Router } from "../http/router.ts";
|
||||
import { getObject } from "../state/assets.ts";
|
||||
|
||||
export function registerMediaServingRoutes(router: Router): void {
|
||||
// The thumbnail route must come first: the catch-all below would swallow it.
|
||||
router.get("/media/thumb/:width/*path", serveThumbnail);
|
||||
router.get("/media/*path", serveObject);
|
||||
}
|
||||
|
||||
function serveObject(context: RequestContext): HttpResult {
|
||||
return serve(context.params["path"] ?? "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Thumbnails come from `maybe_thumbnail_template`, which the frontend expands
|
||||
* by substituting a width. The fake ignores the width and serves the original —
|
||||
* correct enough for layout, and it keeps one copy of the bytes.
|
||||
*/
|
||||
function serveThumbnail(context: RequestContext): HttpResult {
|
||||
return serve(context.params["path"] ?? "");
|
||||
}
|
||||
|
||||
function serve(bucketPath: string): HttpResult {
|
||||
const object = getObject(bucketPath);
|
||||
if (object === undefined) {
|
||||
return notFound(`fake-storyteller-web has no stored object at /media/${bucketPath}`);
|
||||
}
|
||||
|
||||
return bytes(object.bytes, object.contentType, {
|
||||
"Cache-Control": "no-store",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
/**
|
||||
* `/v1/media_files/upload/*` — multipart uploads.
|
||||
*
|
||||
* Uploaded bytes are kept in memory and served straight back from `/media`, so
|
||||
* an uploaded file is immediately visible in the library and loadable in the
|
||||
* viewer, exactly as it would be through the real bucket.
|
||||
*
|
||||
* These handlers use the upload-specific error envelope (`{"BadInput": "..."}`)
|
||||
* rather than the shared one, because that is what the frontend reads here.
|
||||
*/
|
||||
|
||||
import { currentUser } from "../auth.ts";
|
||||
import type { RequestContext } from "../http/context.ts";
|
||||
import { HttpResult, success } from "../http/respond.ts";
|
||||
import type { Router } from "../http/router.ts";
|
||||
import type { MultipartForm } from "../http/multipart.ts";
|
||||
import { contentTypeForExtension, extensionOf, putObject } from "../state/assets.ts";
|
||||
import { nowIso } from "../state/clock.ts";
|
||||
import type { MediaClass, MediaType, ProjectType, Visibility } from "../state/entities.ts";
|
||||
import { classifyUpload, createMediaFile } from "../state/media_factory.ts";
|
||||
import { store } from "../state/store.ts";
|
||||
import { makeToken, TOKEN_PREFIX } from "../state/tokens.ts";
|
||||
import { isValidIdempotencyToken } from "../generation/submit.ts";
|
||||
|
||||
/** Which media type each project path segment produces. */
|
||||
const PROJECT_MEDIA_TYPES: Record<string, { mediaType: MediaType; extension: string }> = {
|
||||
scene_3d: { mediaType: "scene_json", extension: ".scn.json" },
|
||||
mood_board: { mediaType: "mood_json", extension: ".mood.json" },
|
||||
editor_2d: { mediaType: "editor_json", extension: ".editor.json" },
|
||||
video_timeline: { mediaType: "timeline_json", extension: ".timeline.json" },
|
||||
};
|
||||
|
||||
export function registerMediaUploadRoutes(router: Router): void {
|
||||
router.post("/v1/media_files/upload/image", (context) =>
|
||||
upload(context, { mediaClass: "image", mediaType: "png", bucketPrefix: "image_", fallbackExtension: ".png" }),
|
||||
);
|
||||
router.post("/v1/media_files/upload/audio", (context) =>
|
||||
upload(context, { mediaClass: "audio", mediaType: "wav", bucketPrefix: "audio_", fallbackExtension: ".wav" }),
|
||||
);
|
||||
router.post("/v1/media_files/upload/new_video", (context) =>
|
||||
upload(context, { mediaClass: "video", mediaType: "mp4", bucketPrefix: "video_", fallbackExtension: ".mp4" }),
|
||||
);
|
||||
router.post("/v1/media_files/upload/spz", (context) =>
|
||||
upload(context, { mediaClass: "splat", mediaType: "spz", bucketPrefix: "splat_", fallbackExtension: ".spz" }),
|
||||
);
|
||||
router.post("/v1/media_files/upload/new_engine_asset", (context) => upload(context, { inferFromFilename: true }));
|
||||
router.post("/v1/media_files/upload/pmx", (context) =>
|
||||
upload(context, { mediaClass: "dimensional", mediaType: "pmx", bucketPrefix: "engine_", fallbackExtension: ".pmx" }),
|
||||
);
|
||||
router.post("/v1/media_files/upload", (context) => upload(context, { inferFromFilename: true, useShortFieldNames: true }));
|
||||
|
||||
router.post("/v1/media_files/upload/new_scene", (context) =>
|
||||
upload(context, { mediaClass: "project", mediaType: "scene_json", bucketPrefix: "project_", fallbackExtension: ".scn.json", projectType: "scene_3d" }),
|
||||
);
|
||||
router.post("/v1/media_files/upload/saved_scene/:token", updateSavedScene);
|
||||
router.post("/v1/media_files/upload/studio_shot", (context) =>
|
||||
upload(context, { mediaClass: "image", mediaType: "png", bucketPrefix: "image_", fallbackExtension: ".png" }),
|
||||
);
|
||||
|
||||
router.post("/v1/media_files/upload/scene_snapshot", uploadSceneSnapshot);
|
||||
router.post("/v1/image_studio/scene_snapshot", uploadSceneSnapshot);
|
||||
|
||||
router.post("/v1/media_files/upload/project/:projectType/new", createProject);
|
||||
router.post("/v1/media_files/upload/project/:projectType/update/:token", updateProject);
|
||||
|
||||
router.post("/v1/conversion/enqueue_fbx_to_gltf", enqueueFbxToGltf);
|
||||
}
|
||||
|
||||
interface UploadOptions {
|
||||
mediaClass?: MediaClass;
|
||||
mediaType?: MediaType;
|
||||
bucketPrefix?: string;
|
||||
fallbackExtension?: string;
|
||||
projectType?: ProjectType;
|
||||
/** Derive class and type from the uploaded filename instead of fixed values. */
|
||||
inferFromFilename?: boolean;
|
||||
/** The generic `/upload` endpoint uses `title`/`visibility` rather than `maybe_*`. */
|
||||
useShortFieldNames?: boolean;
|
||||
}
|
||||
|
||||
function upload(context: RequestContext, options: UploadOptions): HttpResult {
|
||||
const user = currentUser(context);
|
||||
if (user === undefined) {
|
||||
return uploadError("NotAuthorized");
|
||||
}
|
||||
|
||||
const form = context.form();
|
||||
|
||||
const idempotencyError = checkIdempotency(form);
|
||||
if (idempotencyError !== undefined) {
|
||||
return idempotencyError;
|
||||
}
|
||||
|
||||
const file = form.file("file") ?? form.anyFile();
|
||||
if (file === undefined) {
|
||||
return uploadError("BadInput", "no file part in the multipart body");
|
||||
}
|
||||
|
||||
const inferred = classifyUpload(file.fileName);
|
||||
const mediaClass = options.inferFromFilename ? inferred.mediaClass : options.mediaClass ?? inferred.mediaClass;
|
||||
const mediaType = options.inferFromFilename ? inferred.mediaType : options.mediaType ?? inferred.mediaType;
|
||||
const bucketPrefix = options.inferFromFilename ? inferred.bucketPrefix : options.bucketPrefix ?? inferred.bucketPrefix;
|
||||
const extension = extensionOf(file.fileName) || options.fallbackExtension || "";
|
||||
|
||||
const titleField = options.useShortFieldNames ? "title" : "maybe_title";
|
||||
const visibilityField = options.useShortFieldNames ? "visibility" : "maybe_visibility";
|
||||
|
||||
const record = createMediaFile({
|
||||
bytes: file.bytes,
|
||||
mediaClass,
|
||||
mediaType,
|
||||
bucketPrefix,
|
||||
extension,
|
||||
maybeCreatorUserToken: user.userToken,
|
||||
maybeTitle: form.field(titleField),
|
||||
maybeOriginalFilename: file.fileName,
|
||||
maybePromptToken: form.field("maybe_prompt_token"),
|
||||
maybeBatchToken: form.field("maybe_batch_token"),
|
||||
maybeProjectType: options.projectType,
|
||||
maybeEngineCategory: form.field("engine_category"),
|
||||
maybeAnimationType: form.field("maybe_animation_type"),
|
||||
maybeDurationMillis: numberField(form, "maybe_duration_millis"),
|
||||
maybeSceneSourceMediaFileToken: form.field("maybe_scene_source_media_file_token"),
|
||||
originCategory: "upload",
|
||||
originProductCategory: "unknown",
|
||||
isUserUpload: true,
|
||||
isIntermediateSystemFile: form.field("is_intermediate_system_file") === "true",
|
||||
creatorSetVisibility: visibilityOf(form.field(visibilityField)),
|
||||
});
|
||||
|
||||
return success({ media_file_token: record.token });
|
||||
}
|
||||
|
||||
function uploadSceneSnapshot(context: RequestContext): HttpResult {
|
||||
const user = currentUser(context);
|
||||
if (user === undefined) {
|
||||
return uploadError("NotAuthorized");
|
||||
}
|
||||
|
||||
const form = context.form();
|
||||
const file = form.file("snapshot") ?? form.file("file") ?? form.anyFile();
|
||||
if (file === undefined) {
|
||||
return uploadError("BadInput", "no snapshot part in the multipart body");
|
||||
}
|
||||
|
||||
const record = createMediaFile({
|
||||
bytes: file.bytes,
|
||||
mediaClass: "image",
|
||||
mediaType: "png",
|
||||
bucketPrefix: "image_",
|
||||
extension: extensionOf(file.fileName) || ".png",
|
||||
maybeCreatorUserToken: user.userToken,
|
||||
maybeTitle: form.field("maybe_title"),
|
||||
maybeOriginalFilename: file.fileName,
|
||||
maybeSceneSourceMediaFileToken: form.field("scene_media_token") ?? form.field("maybe_scene_source_media_file_token"),
|
||||
originCategory: "studio",
|
||||
originProductCategory: "image_studio",
|
||||
isUserUpload: false,
|
||||
isIntermediateSystemFile: true,
|
||||
});
|
||||
|
||||
return success({ snapshot_media_token: record.token, media_file_token: record.token });
|
||||
}
|
||||
|
||||
function createProject(context: RequestContext): HttpResult {
|
||||
const user = currentUser(context);
|
||||
if (user === undefined) {
|
||||
return uploadError("NotAuthorized");
|
||||
}
|
||||
|
||||
const projectType = context.params["projectType"] ?? "";
|
||||
const shape = PROJECT_MEDIA_TYPES[projectType];
|
||||
if (shape === undefined) {
|
||||
return uploadError("BadInput", `unknown project type: ${projectType}`);
|
||||
}
|
||||
|
||||
const form = context.form();
|
||||
|
||||
const idempotencyError = checkIdempotency(form);
|
||||
if (idempotencyError !== undefined) {
|
||||
return idempotencyError;
|
||||
}
|
||||
|
||||
const file = form.file("file") ?? form.anyFile();
|
||||
if (file === undefined) {
|
||||
return uploadError("BadInput", "no file part in the multipart body");
|
||||
}
|
||||
|
||||
const record = createMediaFile({
|
||||
bytes: file.bytes,
|
||||
mediaClass: "project",
|
||||
mediaType: shape.mediaType,
|
||||
bucketPrefix: "project_",
|
||||
extension: shape.extension,
|
||||
maybeCreatorUserToken: user.userToken,
|
||||
maybeTitle: form.field("maybe_title"),
|
||||
maybeProjectType: projectType as ProjectType,
|
||||
originCategory: "studio",
|
||||
originProductCategory: "studio",
|
||||
isUserUpload: false,
|
||||
creatorSetVisibility: visibilityOf(form.field("maybe_visibility")),
|
||||
});
|
||||
|
||||
return success({ media_file_token: record.token });
|
||||
}
|
||||
|
||||
function updateProject(context: RequestContext): HttpResult {
|
||||
const user = currentUser(context);
|
||||
if (user === undefined) {
|
||||
return uploadError("NotAuthorized");
|
||||
}
|
||||
|
||||
const token = context.params["token"] ?? "";
|
||||
const record = store.mediaFilesByToken.get(token);
|
||||
if (record === undefined) {
|
||||
return uploadError("BadInput", `unknown media file: ${token}`);
|
||||
}
|
||||
|
||||
const projectType = context.params["projectType"] ?? "";
|
||||
if (record.maybeProjectType !== undefined && record.maybeProjectType !== projectType) {
|
||||
return uploadError("BadInput", `media file is not a ${projectType} project`);
|
||||
}
|
||||
|
||||
return replaceBytes(context, token);
|
||||
}
|
||||
|
||||
function updateSavedScene(context: RequestContext): HttpResult {
|
||||
const user = currentUser(context);
|
||||
if (user === undefined) {
|
||||
return uploadError("NotAuthorized");
|
||||
}
|
||||
return replaceBytes(context, context.params["token"] ?? "");
|
||||
}
|
||||
|
||||
/** Overwrite an existing media file's bytes in place, keeping its token and URL. */
|
||||
function replaceBytes(context: RequestContext, token: string): HttpResult {
|
||||
const record = store.mediaFilesByToken.get(token);
|
||||
if (record === undefined) {
|
||||
return uploadError("BadInput", `unknown media file: ${token}`);
|
||||
}
|
||||
|
||||
const file = context.form().file("file") ?? context.form().anyFile();
|
||||
if (file === undefined) {
|
||||
return uploadError("BadInput", "no file part in the multipart body");
|
||||
}
|
||||
|
||||
// Re-storing under the same bucket path keeps every already-issued URL valid.
|
||||
putObject(record.bucketPath, file.bytes, contentTypeForExtension(extensionOf(record.bucketPath)));
|
||||
record.updatedAt = nowIso();
|
||||
|
||||
return success({ media_file_token: record.token });
|
||||
}
|
||||
|
||||
function enqueueFbxToGltf(context: RequestContext): HttpResult {
|
||||
const user = currentUser(context);
|
||||
if (user === undefined) {
|
||||
return uploadError("NotAuthorized");
|
||||
}
|
||||
|
||||
const body = context.json<{ media_file_token: string; uuid_idempotency_token: string }>();
|
||||
if (body.media_file_token === undefined) {
|
||||
return uploadError("BadInput", "media_file_token is required");
|
||||
}
|
||||
|
||||
const jobToken = makeToken(TOKEN_PREFIX.inferenceJob);
|
||||
const timestamp = nowIso();
|
||||
|
||||
store.jobsByToken.set(jobToken, {
|
||||
jobToken,
|
||||
inferenceCategory: "format_conversion",
|
||||
status: "pending",
|
||||
progressPercentage: 0,
|
||||
maybePromptToken: undefined,
|
||||
maybeModelType: undefined,
|
||||
maybeModelTitle: undefined,
|
||||
maybeRawInferenceText: undefined,
|
||||
maybeCreatorUserToken: user.userToken,
|
||||
maybeBatchToken: undefined,
|
||||
maybeResultMediaFileToken: undefined,
|
||||
maybeFailureCategory: undefined,
|
||||
maybeFailureMessage: undefined,
|
||||
maybeSuccessfullyCompletedAt: undefined,
|
||||
resolveAtMillis: Date.now() + 2_000,
|
||||
isDismissed: false,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
|
||||
return success({ inference_job_token: jobToken });
|
||||
}
|
||||
|
||||
function checkIdempotency(form: MultipartForm): HttpResult | undefined {
|
||||
const token = form.field("uuid_idempotency_token");
|
||||
if (!isValidIdempotencyToken(token)) {
|
||||
return uploadError("BadInput", "invalid idempotency token");
|
||||
}
|
||||
if (store.usedIdempotencyTokens.has(token!)) {
|
||||
return uploadError("BadInput", "repeated idempotency token");
|
||||
}
|
||||
store.usedIdempotencyTokens.add(token!);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** The upload endpoints' own error envelope, which the frontend reads as `response.BadInput`. */
|
||||
function uploadError(kind: "BadInput" | "NotAuthorized" | "ServerError" | "RateLimited", detail?: string): HttpResult {
|
||||
const status = kind === "NotAuthorized" ? 401 : kind === "RateLimited" ? 429 : kind === "ServerError" ? 500 : 400;
|
||||
const body = detail === undefined ? kind : { [kind]: detail };
|
||||
return new HttpResult(status, body);
|
||||
}
|
||||
|
||||
function visibilityOf(value: string | undefined): Visibility {
|
||||
return value === "hidden" || value === "private" ? value : "public";
|
||||
}
|
||||
|
||||
function numberField(form: MultipartForm, name: string): number | undefined {
|
||||
const raw = form.field(name);
|
||||
if (raw === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* `/v1/omni_gen` — model catalogues, cost estimates, and generation submission.
|
||||
*
|
||||
* `cost/*` is anonymous; `generate/*` requires a session, spends credits, and
|
||||
* queues jobs for the resolver. No provider is contacted, and there is no code
|
||||
* path here that could.
|
||||
*/
|
||||
|
||||
import { currentUser } from "../auth.ts";
|
||||
import type { RequestContext } from "../http/context.ts";
|
||||
import { HttpResult, failure, paymentRequired, unauthorized } from "../http/respond.ts";
|
||||
import type { Router } from "../http/router.ts";
|
||||
import {
|
||||
audioModelsResponse,
|
||||
imageModelsResponse,
|
||||
isKnownModel,
|
||||
meshModelsResponse,
|
||||
splatModelsResponse,
|
||||
videoModelsResponse,
|
||||
} from "../generation/catalogue.ts";
|
||||
import { readPromptTriggers } from "../generation/prompt_flags.ts";
|
||||
import {
|
||||
estimateCost,
|
||||
hasEnoughCredits,
|
||||
isReplayedIdempotencyToken,
|
||||
isValidIdempotencyToken,
|
||||
submitGeneration,
|
||||
type GenerateRequestBody,
|
||||
type GenerationKind,
|
||||
} from "../generation/submit.ts";
|
||||
|
||||
const CATALOGUES: Record<GenerationKind, () => object> = {
|
||||
image: imageModelsResponse,
|
||||
video: videoModelsResponse,
|
||||
audio: audioModelsResponse,
|
||||
mesh: meshModelsResponse,
|
||||
splat: splatModelsResponse,
|
||||
};
|
||||
|
||||
/** Only the image response omits `all_job_tokens`, matching the real handlers. */
|
||||
const KINDS_WITH_ALL_JOB_TOKENS = new Set<GenerationKind>(["video", "audio", "mesh", "splat"]);
|
||||
|
||||
export function registerOmniGenRoutes(router: Router): void {
|
||||
for (const kind of Object.keys(CATALOGUES) as GenerationKind[]) {
|
||||
router.get(`/v1/omni_gen/models/${kind}`, () => new HttpResult(200, CATALOGUES[kind]()));
|
||||
router.post(`/v1/omni_gen/cost/${kind}`, (context) => getCost(kind, context));
|
||||
router.post(`/v1/omni_gen/generate/${kind}`, (context) => generate(kind, context));
|
||||
}
|
||||
}
|
||||
|
||||
function getCost(kind: GenerationKind, context: RequestContext): HttpResult {
|
||||
const body = readBody(context);
|
||||
|
||||
const modelError = validateModel(body);
|
||||
if (modelError !== undefined) {
|
||||
return modelError;
|
||||
}
|
||||
|
||||
return new HttpResult(200, { success: true, ...estimateCost(kind, body) });
|
||||
}
|
||||
|
||||
function generate(kind: GenerationKind, context: RequestContext): HttpResult {
|
||||
const user = currentUser(context);
|
||||
if (user === undefined) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
const body = readBody(context);
|
||||
|
||||
const modelError = validateModel(body);
|
||||
if (modelError !== undefined) {
|
||||
return modelError;
|
||||
}
|
||||
|
||||
if (!isValidIdempotencyToken(body.idempotency_token)) {
|
||||
return failure(400, "BadInput", "invalid idempotency token");
|
||||
}
|
||||
if (isReplayedIdempotencyToken(body.idempotency_token)) {
|
||||
return failure(400, "BadInput", "repeated idempotency token");
|
||||
}
|
||||
|
||||
if (readPromptTriggers(body.prompt).rejectWithPaymentRequired) {
|
||||
return paymentRequired(
|
||||
"fake-storyteller-web: payment failure forced by the trigger_payment_failure prompt flag.",
|
||||
);
|
||||
}
|
||||
|
||||
const cost = estimateCost(kind, body);
|
||||
if (!hasEnoughCredits(user, cost.cost_in_credits)) {
|
||||
return paymentRequired(
|
||||
`Not enough credits: ${cost.cost_in_credits} required, ${user.monthlyCredits + user.bankedCredits} available.`,
|
||||
);
|
||||
}
|
||||
|
||||
const submitted = submitGeneration(kind, body, user);
|
||||
const primaryToken = submitted.jobTokens[0];
|
||||
|
||||
const payload: Record<string, unknown> = { success: true, inference_job_token: primaryToken };
|
||||
if (KINDS_WITH_ALL_JOB_TOKENS.has(kind)) {
|
||||
payload["all_job_tokens"] = submitted.jobTokens;
|
||||
}
|
||||
|
||||
return new HttpResult(200, payload);
|
||||
}
|
||||
|
||||
function validateModel(body: GenerateRequestBody): HttpResult | undefined {
|
||||
if (body.model === undefined) {
|
||||
return failure(400, "BadInput", "no model supplied");
|
||||
}
|
||||
if (!isKnownModel(body.model)) {
|
||||
return failure(400, "BadInput", `unknown model: ${body.model}`);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function readBody(context: RequestContext): GenerateRequestBody {
|
||||
return context.json<GenerateRequestBody>();
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* `/v1/prompts` — the settings a generation was submitted with.
|
||||
*
|
||||
* The frontend's "recreate" flow reads these back to repopulate the prompt box,
|
||||
* so the record has to round-trip whatever was submitted.
|
||||
*/
|
||||
|
||||
import type { RequestContext } from "../http/context.ts";
|
||||
import { HttpResult, notFound, success } from "../http/respond.ts";
|
||||
import type { Router } from "../http/router.ts";
|
||||
import type { PromptRecord } from "../state/entities.ts";
|
||||
import { store } from "../state/store.ts";
|
||||
import { mediaLinks } from "../wire/media.ts";
|
||||
|
||||
export function registerPromptRoutes(router: Router): void {
|
||||
router.get("/v1/prompts/:token", getPrompt);
|
||||
router.get("/v1/prompt/batch", batchGetPrompts);
|
||||
}
|
||||
|
||||
function getPrompt(context: RequestContext): HttpResult {
|
||||
const prompt = store.promptsByToken.get(context.params["token"] ?? "");
|
||||
if (prompt === undefined) {
|
||||
return notFound();
|
||||
}
|
||||
return success({ prompt: promptPayload(prompt) });
|
||||
}
|
||||
|
||||
function batchGetPrompts(context: RequestContext): HttpResult {
|
||||
const requested = context.query.getAll("tokens").flatMap((value) => value.split(","));
|
||||
const wanted = new Set(requested.filter((token) => token.length > 0));
|
||||
|
||||
const prompts = [...wanted]
|
||||
.map((token) => store.promptsByToken.get(token))
|
||||
.filter((prompt) => prompt !== undefined)
|
||||
.map(promptPayload);
|
||||
|
||||
return success({ prompts });
|
||||
}
|
||||
|
||||
function promptPayload(prompt: PromptRecord): object {
|
||||
return {
|
||||
token: prompt.token,
|
||||
prompt_type: prompt.promptType,
|
||||
created_at: prompt.createdAt,
|
||||
lcm_disabled: false,
|
||||
lipsync_enabled: false,
|
||||
use_cinematic: false,
|
||||
used_face_detailer: false,
|
||||
used_upscaler: false,
|
||||
maybe_aspect_ratio: prompt.maybeAspectRatio ?? null,
|
||||
maybe_batch_count: prompt.maybeBatchCount ?? null,
|
||||
maybe_context_images: contextImagesPayload(prompt),
|
||||
maybe_duration_seconds: prompt.maybeDurationSeconds ?? null,
|
||||
maybe_frame_skip: null,
|
||||
maybe_generate_audio: prompt.maybeGenerateAudio ?? null,
|
||||
maybe_generation_mode: null,
|
||||
maybe_generation_provider: prompt.maybeGenerationProvider ?? null,
|
||||
maybe_global_ipa_image_token: null,
|
||||
maybe_inference_duration_millis: null,
|
||||
maybe_model_class: prompt.maybeModelClass ?? null,
|
||||
maybe_model_type: prompt.maybeModelType ?? null,
|
||||
maybe_moderator_fields: null,
|
||||
maybe_negative_prompt: prompt.maybeNegativePrompt ?? null,
|
||||
maybe_positive_prompt: prompt.maybePositivePrompt ?? null,
|
||||
maybe_resolution: prompt.maybeResolution ?? null,
|
||||
maybe_strength: null,
|
||||
maybe_style_name: prompt.maybeStyleName ?? null,
|
||||
maybe_travel_prompt: null,
|
||||
};
|
||||
}
|
||||
|
||||
function contextImagesPayload(prompt: PromptRecord): object[] | null {
|
||||
const images = prompt.contextImageMediaTokens
|
||||
.map((token) => store.mediaFilesByToken.get(token))
|
||||
.filter((record) => record !== undefined)
|
||||
.map((record) => ({
|
||||
media_token: record.token,
|
||||
semantic: "reference",
|
||||
media_links: mediaLinks(record),
|
||||
}));
|
||||
|
||||
return images.length === 0 ? null : images;
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
/**
|
||||
* Authentication, session, and account management.
|
||||
*
|
||||
* These endpoints use three different error dialects in the real backend and
|
||||
* the frontend reads a different field from each, so they are reproduced
|
||||
* exactly: login sends `error_type` + `error_message`, create_account sends
|
||||
* `error_type` + `error_fields`, password reset sends `kind` + `message`, and
|
||||
* everything else uses the shared `error_code` envelope.
|
||||
*/
|
||||
|
||||
import {
|
||||
clearedSessionCookieHeader,
|
||||
currentSignedSession,
|
||||
currentUser,
|
||||
endSession,
|
||||
sessionCookieHeader,
|
||||
startSession,
|
||||
visitorCookieHeaderIfMissing,
|
||||
} from "../auth.ts";
|
||||
import type { RequestContext } from "../http/context.ts";
|
||||
import { HttpResult, failure, notFound, success, unauthorized } from "../http/respond.ts";
|
||||
import type { Router } from "../http/router.ts";
|
||||
import { nowIso } from "../state/clock.ts";
|
||||
import type { UserRecord } from "../state/entities.ts";
|
||||
import { md5, registerUser } from "../state/seed.ts";
|
||||
import { store } from "../state/store.ts";
|
||||
import { makeToken, TOKEN_PREFIX } from "../state/tokens.ts";
|
||||
import { sessionUserPayload, userProfilePayload } from "../wire/users.ts";
|
||||
import { config } from "../config.ts";
|
||||
|
||||
const RESERVED_USERNAMES = new Set(["demo", "admin", "test", "dev", "root", "artcraft"]);
|
||||
|
||||
export function registerSessionRoutes(router: Router): void {
|
||||
for (const prefix of ["/v1", ""]) {
|
||||
router.get(`${prefix}/session`, getSession);
|
||||
router.post(`${prefix}/login`, login);
|
||||
router.post(`${prefix}/logout`, logout);
|
||||
router.post(`${prefix}/create_account`, createAccount);
|
||||
}
|
||||
|
||||
router.get("/v1/session_token", getSessionToken);
|
||||
router.post("/v1/accounts/google_sso", googleSso);
|
||||
router.get("/v1/user/:username/profile", getProfile);
|
||||
router.post("/v1/user/change_password", changePassword);
|
||||
router.post("/v1/user/edit_email", editEmail);
|
||||
router.post("/v1/user/edit_username", editUsername);
|
||||
router.post("/v1/web_referrals/record", recordWebReferral);
|
||||
router.post("/v1/password_reset/request", requestPasswordReset);
|
||||
router.post("/v1/password_reset/redeem", redeemPasswordReset);
|
||||
}
|
||||
|
||||
function getSession(context: RequestContext): HttpResult {
|
||||
const user = currentUser(context);
|
||||
const payload = user === undefined
|
||||
? { success: true, logged_in: false, user: null }
|
||||
: { success: true, logged_in: true, user: sessionUserPayload(user) };
|
||||
|
||||
const result = new HttpResult(200, payload);
|
||||
const visitorCookie = visitorCookieHeaderIfMissing(context);
|
||||
return visitorCookie === undefined ? result : result.withHeader("Set-Cookie", visitorCookie);
|
||||
}
|
||||
|
||||
function login(context: RequestContext): HttpResult {
|
||||
const body = context.json<{ username_or_email: string; password: string }>();
|
||||
const user = findUserByUsernameOrEmail(body.username_or_email);
|
||||
|
||||
if (user === undefined || user.password !== body.password) {
|
||||
return new HttpResult(401, {
|
||||
success: false,
|
||||
error_type: "InvalidCredentials",
|
||||
error_message: "invalid credentials",
|
||||
});
|
||||
}
|
||||
|
||||
const signedSession = startSession(user);
|
||||
return new HttpResult(200, { success: true, signed_session: signedSession }).withHeader(
|
||||
"Set-Cookie",
|
||||
sessionCookieHeader(signedSession),
|
||||
);
|
||||
}
|
||||
|
||||
function logout(context: RequestContext): HttpResult {
|
||||
endSession(currentSignedSession(context));
|
||||
return success().withHeader("Set-Cookie", clearedSessionCookieHeader());
|
||||
}
|
||||
|
||||
function createAccount(context: RequestContext): HttpResult {
|
||||
const body = context.json<{
|
||||
username: string;
|
||||
password: string;
|
||||
password_confirmation: string;
|
||||
email_address: string;
|
||||
}>();
|
||||
|
||||
const username = (body.username ?? "").trim();
|
||||
const email = (body.email_address ?? "").trim();
|
||||
|
||||
if (username.length < 3) {
|
||||
return badInput("BadInput", { username: "username is too short" });
|
||||
}
|
||||
if (RESERVED_USERNAMES.has(username.toLowerCase())) {
|
||||
return badInput("UsernameReserved", { username: "username is reserved" });
|
||||
}
|
||||
if (store.usersByUsername.has(username.toLowerCase())) {
|
||||
return badInput("UsernameTaken", { username: "username is taken" });
|
||||
}
|
||||
if (!email.includes("@")) {
|
||||
return badInput("BadInput", { email_address: "invalid email address" });
|
||||
}
|
||||
if (body.password !== body.password_confirmation) {
|
||||
return badInput("BadInput", { password: "passwords do not match" });
|
||||
}
|
||||
if ((body.password ?? "").length < 8) {
|
||||
return badInput("BadInput", { password: "password is too short" });
|
||||
}
|
||||
|
||||
const user: UserRecord = {
|
||||
userToken: makeToken(TOKEN_PREFIX.user),
|
||||
username: username.toLowerCase(),
|
||||
displayName: username,
|
||||
emailAddress: email,
|
||||
password: body.password ?? "",
|
||||
gravatarHash: md5(email),
|
||||
featureFlags: ["explore_media", "studio", "upload_3d", "referrals", "api_key"],
|
||||
defaultAvatar: { image_index: 4, color_index: 2 },
|
||||
bankedCredits: config.demoCredits,
|
||||
monthlyCredits: 0,
|
||||
subscriptionSlug: undefined,
|
||||
createdAt: nowIso(),
|
||||
};
|
||||
|
||||
registerUser(user);
|
||||
const signedSession = startSession(user);
|
||||
|
||||
return new HttpResult(200, { success: true, signed_session: signedSession }).withHeader(
|
||||
"Set-Cookie",
|
||||
sessionCookieHeader(signedSession),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* There is no Google to talk to, so any credential logs in the demo user. This
|
||||
* keeps the SSO button working rather than dead-ending a developer who clicks it.
|
||||
*/
|
||||
function googleSso(context: RequestContext): HttpResult {
|
||||
const body = context.json<{ google_credential: string }>();
|
||||
if ((body.google_credential ?? "").length === 0) {
|
||||
return failure(400, "BadInput", "google_credential is required");
|
||||
}
|
||||
|
||||
const user = [...store.usersByToken.values()][0];
|
||||
if (user === undefined) {
|
||||
return failure(500, "InternalError", "fake-storyteller-web has no seeded user to sign in as");
|
||||
}
|
||||
|
||||
const signedSession = startSession(user);
|
||||
return new HttpResult(200, {
|
||||
success: true,
|
||||
signed_session: signedSession,
|
||||
username_not_yet_customized: false,
|
||||
maybe_user_display_name: user.displayName,
|
||||
}).withHeader("Set-Cookie", sessionCookieHeader(signedSession));
|
||||
}
|
||||
|
||||
function getSessionToken(context: RequestContext): HttpResult {
|
||||
return new HttpResult(200, { maybe_signed_session: currentSignedSession(context) ?? null });
|
||||
}
|
||||
|
||||
function getProfile(context: RequestContext): HttpResult {
|
||||
const user = store.usersByUsername.get((context.params["username"] ?? "").toLowerCase());
|
||||
if (user === undefined) {
|
||||
return notFound();
|
||||
}
|
||||
return success({ user: userProfilePayload(user) });
|
||||
}
|
||||
|
||||
function changePassword(context: RequestContext): HttpResult {
|
||||
const user = currentUser(context);
|
||||
if (user === undefined) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
const body = context.json<{ password: string; password_confirmation: string }>();
|
||||
if (body.password !== body.password_confirmation) {
|
||||
return failure(400, "BadInput", "the passwords do not match");
|
||||
}
|
||||
if ((body.password ?? "").length < 8) {
|
||||
return failure(400, "BadInput", "password is too short");
|
||||
}
|
||||
|
||||
user.password = body.password ?? "";
|
||||
return success();
|
||||
}
|
||||
|
||||
function editEmail(context: RequestContext): HttpResult {
|
||||
const user = currentUser(context);
|
||||
if (user === undefined) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
const body = context.json<{ email_address: string }>();
|
||||
if (!(body.email_address ?? "").includes("@")) {
|
||||
return failure(400, "BadInput", "bad email: invalid email address");
|
||||
}
|
||||
|
||||
user.emailAddress = body.email_address ?? "";
|
||||
user.gravatarHash = md5(user.emailAddress);
|
||||
return success();
|
||||
}
|
||||
|
||||
function editUsername(context: RequestContext): HttpResult {
|
||||
const user = currentUser(context);
|
||||
if (user === undefined) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
const body = context.json<{ display_name: string }>();
|
||||
const displayName = (body.display_name ?? "").trim();
|
||||
if (displayName.length < 3) {
|
||||
return failure(400, "BadInput", "bad username: username is too short");
|
||||
}
|
||||
|
||||
const existing = store.usersByUsername.get(displayName.toLowerCase());
|
||||
if (existing !== undefined && existing.userToken !== user.userToken) {
|
||||
return failure(400, "BadInput", "username is taken");
|
||||
}
|
||||
|
||||
store.usersByUsername.delete(user.username);
|
||||
user.username = displayName.toLowerCase();
|
||||
user.displayName = displayName;
|
||||
store.usersByUsername.set(user.username, user);
|
||||
return success();
|
||||
}
|
||||
|
||||
function recordWebReferral(): HttpResult {
|
||||
return success();
|
||||
}
|
||||
|
||||
/** Always succeeds, matching the real handler's anti-enumeration behaviour. */
|
||||
function requestPasswordReset(): HttpResult {
|
||||
return success();
|
||||
}
|
||||
|
||||
/**
|
||||
* There is no email to read a token out of, so the fake accepts any non-empty
|
||||
* reset token and logs the user in as the demo user.
|
||||
*/
|
||||
function redeemPasswordReset(context: RequestContext): HttpResult {
|
||||
const body = context.json<{
|
||||
reset_token: string;
|
||||
new_password: string;
|
||||
new_password_validation: string;
|
||||
}>();
|
||||
|
||||
if (body.new_password !== body.new_password_validation) {
|
||||
return new HttpResult(400, {
|
||||
success: false,
|
||||
kind: "PasswordsDoNotMatch",
|
||||
message: "The passwords do not match.",
|
||||
});
|
||||
}
|
||||
|
||||
const user = [...store.usersByToken.values()][0];
|
||||
if ((body.reset_token ?? "").length === 0 || user === undefined) {
|
||||
return new HttpResult(400, {
|
||||
success: false,
|
||||
kind: "InvalidRedemption",
|
||||
message:
|
||||
"The redemption code is invalid, has already been redeemed, or has been replaced by a newer code.",
|
||||
});
|
||||
}
|
||||
|
||||
user.password = body.new_password ?? "";
|
||||
const signedSession = startSession(user);
|
||||
return new HttpResult(200, { success: true, signed_session: signedSession }).withHeader(
|
||||
"Set-Cookie",
|
||||
sessionCookieHeader(signedSession),
|
||||
);
|
||||
}
|
||||
|
||||
function findUserByUsernameOrEmail(usernameOrEmail: string | undefined): UserRecord | undefined {
|
||||
const needle = (usernameOrEmail ?? "").trim().toLowerCase();
|
||||
if (needle.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (needle.includes("@")) {
|
||||
for (const user of store.usersByToken.values()) {
|
||||
if (user.emailAddress.toLowerCase() === needle) {
|
||||
return user;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return store.usersByUsername.get(needle);
|
||||
}
|
||||
|
||||
function badInput(errorType: string, errorFields: Record<string, string>): HttpResult {
|
||||
return new HttpResult(400, { success: false, error_type: errorType, error_fields: errorFields });
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* `/v1/tags` — free-text tagging of media files.
|
||||
*
|
||||
* Tags are stored per owner and deduplicated by lowercased value, so adding an
|
||||
* existing tag reuses its token and bumps `use_count` rather than creating a
|
||||
* duplicate — the behaviour the library UI assumes.
|
||||
*/
|
||||
|
||||
import { currentUser } from "../auth.ts";
|
||||
import type { RequestContext } from "../http/context.ts";
|
||||
import { HttpResult, notFound, success, unauthorized } from "../http/respond.ts";
|
||||
import type { Router } from "../http/router.ts";
|
||||
import { nowIso } from "../state/clock.ts";
|
||||
import type { TagRecord, UserRecord } from "../state/entities.ts";
|
||||
import { sortNewestFirst, store } from "../state/store.ts";
|
||||
import { makeToken, TOKEN_PREFIX } from "../state/tokens.ts";
|
||||
import { folderMediaFilePayload } from "../wire/media.ts";
|
||||
import { clampPageSize, decodeCursor, nextCursorOnly } from "../wire/pagination.ts";
|
||||
|
||||
export function registerTagRoutes(router: Router): void {
|
||||
router.get("/v1/tags/list", listTags);
|
||||
router.put("/v1/tags/rename/:tagToken", renameTag);
|
||||
router.delete("/v1/tags/:tagToken", deleteTag);
|
||||
router.post("/v1/tags/bulk_add", bulkAddTags);
|
||||
router.post("/v1/tags/bulk_set", bulkSetTags);
|
||||
|
||||
router.get("/v1/tags/media_file/list/:mediaFileToken", listTagsForMediaFile);
|
||||
router.post("/v1/tags/media_file/add/:mediaFileToken", addTagsToMediaFile);
|
||||
router.post("/v1/tags/media_file/set/:mediaFileToken", setTagsOnMediaFile);
|
||||
router.post("/v1/tags/media_file/clear/:mediaFileToken", clearTagsOnMediaFile);
|
||||
|
||||
router.get("/v1/tags/media_files/list_tagged", (context) => listByTagged(context, true));
|
||||
router.get("/v1/tags/media_files/list_untagged", (context) => listByTagged(context, false));
|
||||
router.get("/v1/tags/media_files/with_tag/:tagToken", listMediaWithTag);
|
||||
router.post("/v1/tags/media_files/bulk_list_tags", bulkListTags);
|
||||
}
|
||||
|
||||
function listTags(context: RequestContext): HttpResult {
|
||||
const user = currentUser(context);
|
||||
if (user === undefined) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
const owned = sortNewestFirst(tagsOwnedBy(user));
|
||||
const pageSize = clampPageSize(context.queryNumber("limit"));
|
||||
const offset = decodeCursor(context.queryValue("cursor"));
|
||||
|
||||
return success({
|
||||
tags: owned.slice(offset, offset + pageSize).map(tagPayload),
|
||||
maybe_cursor: nextCursorOnly(owned, offset, pageSize),
|
||||
});
|
||||
}
|
||||
|
||||
function renameTag(context: RequestContext): HttpResult {
|
||||
const tag = store.tagsByToken.get(context.params["tagToken"] ?? "");
|
||||
if (tag === undefined) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const body = context.json<{ new_tag_value: string }>();
|
||||
if (body.new_tag_value !== undefined) {
|
||||
tag.tagValue = body.new_tag_value;
|
||||
}
|
||||
|
||||
return success({ tag: tagPayload(tag) });
|
||||
}
|
||||
|
||||
function deleteTag(context: RequestContext): HttpResult {
|
||||
const tag = store.tagsByToken.get(context.params["tagToken"] ?? "");
|
||||
if (tag === undefined) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const removedLinkCount = tag.mediaFileTokens.size;
|
||||
store.tagsByToken.delete(tag.tagToken);
|
||||
return success({ removed_link_count: removedLinkCount });
|
||||
}
|
||||
|
||||
function bulkAddTags(context: RequestContext): HttpResult {
|
||||
const user = currentUser(context);
|
||||
if (user === undefined) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
const body = context.json<{ media_file_tokens: string[]; maybe_tags_list: string[] }>();
|
||||
const accepted = knownMediaTokens(body.media_file_tokens ?? []);
|
||||
const tags = (body.maybe_tags_list ?? []).map((value) => findOrCreateTag(user, value));
|
||||
|
||||
for (const tag of tags) {
|
||||
for (const token of accepted) {
|
||||
tag.mediaFileTokens.add(token);
|
||||
}
|
||||
}
|
||||
|
||||
return success({ accepted_media_file_tokens: accepted, tags: tags.map(tagPayload) });
|
||||
}
|
||||
|
||||
function bulkSetTags(context: RequestContext): HttpResult {
|
||||
const user = currentUser(context);
|
||||
if (user === undefined) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
const body = context.json<{ media_file_tokens: string[]; maybe_tags_list: string[] }>();
|
||||
const accepted = knownMediaTokens(body.media_file_tokens ?? []);
|
||||
|
||||
const removedCount = detachTags(user, accepted);
|
||||
const tags = (body.maybe_tags_list ?? []).map((value) => findOrCreateTag(user, value));
|
||||
|
||||
for (const tag of tags) {
|
||||
for (const token of accepted) {
|
||||
tag.mediaFileTokens.add(token);
|
||||
}
|
||||
}
|
||||
|
||||
return success({
|
||||
accepted_media_file_tokens: accepted,
|
||||
tags: tags.map(tagPayload),
|
||||
removed_count: removedCount,
|
||||
});
|
||||
}
|
||||
|
||||
function listTagsForMediaFile(context: RequestContext): HttpResult {
|
||||
const mediaFileToken = context.params["mediaFileToken"] ?? "";
|
||||
const tags = [...store.tagsByToken.values()].filter((tag) => tag.mediaFileTokens.has(mediaFileToken));
|
||||
return success({ tags: tags.map(tagPayload) });
|
||||
}
|
||||
|
||||
function addTagsToMediaFile(context: RequestContext): HttpResult {
|
||||
const user = currentUser(context);
|
||||
if (user === undefined) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
const mediaFileToken = context.params["mediaFileToken"] ?? "";
|
||||
const body = context.json<{ maybe_tags_list: string[] }>();
|
||||
|
||||
for (const value of body.maybe_tags_list ?? []) {
|
||||
findOrCreateTag(user, value).mediaFileTokens.add(mediaFileToken);
|
||||
}
|
||||
|
||||
return listTagsForMediaFile(context);
|
||||
}
|
||||
|
||||
function setTagsOnMediaFile(context: RequestContext): HttpResult {
|
||||
const user = currentUser(context);
|
||||
if (user === undefined) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
const mediaFileToken = context.params["mediaFileToken"] ?? "";
|
||||
const removedCount = detachTags(user, [mediaFileToken]);
|
||||
|
||||
const body = context.json<{ maybe_tags_list: string[] }>();
|
||||
const tags = (body.maybe_tags_list ?? []).map((value) => findOrCreateTag(user, value));
|
||||
for (const tag of tags) {
|
||||
tag.mediaFileTokens.add(mediaFileToken);
|
||||
}
|
||||
|
||||
return success({ tags: tags.map(tagPayload), removed_count: removedCount });
|
||||
}
|
||||
|
||||
function clearTagsOnMediaFile(context: RequestContext): HttpResult {
|
||||
const user = currentUser(context);
|
||||
if (user === undefined) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
const removedCount = detachTags(user, [context.params["mediaFileToken"] ?? ""]);
|
||||
return success({ removed_count: removedCount });
|
||||
}
|
||||
|
||||
function listByTagged(context: RequestContext, tagged: boolean): HttpResult {
|
||||
const user = currentUser(context);
|
||||
if (user === undefined) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
const taggedTokens = new Set<string>();
|
||||
for (const tag of tagsOwnedBy(user)) {
|
||||
for (const token of tag.mediaFileTokens) {
|
||||
taggedTokens.add(token);
|
||||
}
|
||||
}
|
||||
|
||||
const records = sortNewestFirst(
|
||||
[...store.mediaFilesByToken.values()].filter(
|
||||
(record) => record.maybeCreatorUserToken === user.userToken && taggedTokens.has(record.token) === tagged,
|
||||
),
|
||||
);
|
||||
|
||||
const pageSize = clampPageSize(context.queryNumber("limit"));
|
||||
const offset = decodeCursor(context.queryValue("cursor"));
|
||||
|
||||
return success({
|
||||
media_files: records.slice(offset, offset + pageSize).map((record) => folderMediaFilePayload(record)),
|
||||
maybe_cursor: nextCursorOnly(records, offset, pageSize),
|
||||
});
|
||||
}
|
||||
|
||||
function listMediaWithTag(context: RequestContext): HttpResult {
|
||||
const tag = store.tagsByToken.get(context.params["tagToken"] ?? "");
|
||||
if (tag === undefined) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const records = sortNewestFirst(
|
||||
[...tag.mediaFileTokens]
|
||||
.map((token) => store.mediaFilesByToken.get(token))
|
||||
.filter((record) => record !== undefined),
|
||||
);
|
||||
|
||||
const pageSize = clampPageSize(context.queryNumber("limit"));
|
||||
const offset = decodeCursor(context.queryValue("cursor"));
|
||||
|
||||
return success({
|
||||
media_files: records.slice(offset, offset + pageSize).map((record) => folderMediaFilePayload(record)),
|
||||
maybe_cursor: nextCursorOnly(records, offset, pageSize),
|
||||
});
|
||||
}
|
||||
|
||||
function bulkListTags(context: RequestContext): HttpResult {
|
||||
const body = context.json<{ media_file_tokens: string[] }>();
|
||||
|
||||
const entries = (body.media_file_tokens ?? []).map((mediaFileToken) => ({
|
||||
media_file_token: mediaFileToken,
|
||||
tags: [...store.tagsByToken.values()]
|
||||
.filter((tag) => tag.mediaFileTokens.has(mediaFileToken))
|
||||
.map(tagPayload),
|
||||
}));
|
||||
|
||||
return success({ media_files: entries });
|
||||
}
|
||||
|
||||
function findOrCreateTag(user: UserRecord, value: string): TagRecord {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
|
||||
for (const tag of tagsOwnedBy(user)) {
|
||||
if (tag.tagValue.toLowerCase() === normalized) {
|
||||
return tag;
|
||||
}
|
||||
}
|
||||
|
||||
const tag: TagRecord = {
|
||||
tagToken: makeToken(TOKEN_PREFIX.tag),
|
||||
tagValue: value.trim(),
|
||||
ownerUserToken: user.userToken,
|
||||
mediaFileTokens: new Set(),
|
||||
createdAt: nowIso(),
|
||||
};
|
||||
|
||||
store.tagsByToken.set(tag.tagToken, tag);
|
||||
return tag;
|
||||
}
|
||||
|
||||
/** Remove these media files from every tag the user owns; returns how many links went away. */
|
||||
function detachTags(user: UserRecord, mediaFileTokens: string[]): number {
|
||||
let removed = 0;
|
||||
|
||||
for (const tag of tagsOwnedBy(user)) {
|
||||
for (const token of mediaFileTokens) {
|
||||
if (tag.mediaFileTokens.delete(token)) {
|
||||
removed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
function tagsOwnedBy(user: UserRecord): TagRecord[] {
|
||||
return [...store.tagsByToken.values()].filter((tag) => tag.ownerUserToken === user.userToken);
|
||||
}
|
||||
|
||||
function knownMediaTokens(tokens: string[]): string[] {
|
||||
return tokens.filter((token) => store.mediaFilesByToken.has(token));
|
||||
}
|
||||
|
||||
function tagPayload(tag: TagRecord): object {
|
||||
return {
|
||||
tag_token: tag.tagToken,
|
||||
tag_value: tag.tagValue,
|
||||
tag_value_lowercase: tag.tagValue.toLowerCase(),
|
||||
use_count: tag.mediaFileTokens.size,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* In-memory object store standing in for the media bucket + CDN.
|
||||
*
|
||||
* Uploaded bytes and generation results both land here, keyed by a bucket path
|
||||
* shaped like the real one, and are served straight back out of memory at
|
||||
* `/media/<path>`. Nothing touches disk except the one-time fixture load.
|
||||
*/
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { config, publicOrigin } from "../config.ts";
|
||||
|
||||
/** Fixtures shipped in the repo, reused as the visible content of fake media. */
|
||||
const FIXTURES = {
|
||||
image: join("test_data", "image", "juno.jpg"),
|
||||
imageAlternate: join("test_data", "image", "mochi.jpg"),
|
||||
video: join("test_data", "video", "mp4", "golden_sun_garoh.mp4"),
|
||||
} as const;
|
||||
|
||||
export type FixtureName = keyof typeof FIXTURES;
|
||||
|
||||
export interface StoredObject {
|
||||
bytes: Buffer;
|
||||
contentType: string;
|
||||
}
|
||||
|
||||
const objects = new Map<string, StoredObject>();
|
||||
const fixtureCache = new Map<FixtureName, Buffer>();
|
||||
|
||||
/** Store bytes at a bucket path and return that path. */
|
||||
export function putObject(bucketPath: string, bytes: Buffer, contentType: string): string {
|
||||
objects.set(normalize(bucketPath), { bytes, contentType });
|
||||
return bucketPath;
|
||||
}
|
||||
|
||||
export function getObject(bucketPath: string): StoredObject | undefined {
|
||||
return objects.get(normalize(bucketPath));
|
||||
}
|
||||
|
||||
export function objectCount(): number {
|
||||
return objects.size;
|
||||
}
|
||||
|
||||
export function clearObjects(): void {
|
||||
objects.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a bucket path with the same hash fan-out the real backend uses:
|
||||
* `<h0>/<h1>/<h2>/<h3>/<h4>/<hash>/<prefix><hash><extension>`.
|
||||
*/
|
||||
export function makeBucketPath(hash: string, prefix: string, extension: string): string {
|
||||
const fanOut = hash.slice(0, 5).split("").join("/");
|
||||
return `${fanOut}/${hash}/${prefix}${hash}${extension}`;
|
||||
}
|
||||
|
||||
/** Absolute URL the frontend should fetch this object from. */
|
||||
export function bucketPathToCdnUrl(bucketPath: string): string {
|
||||
return `${publicOrigin()}/media/${normalize(bucketPath)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The thumbnail template the frontend expands by replacing `{WIDTH}`. The fake
|
||||
* ignores the requested width and serves the full-size object.
|
||||
*/
|
||||
export function bucketPathToThumbnailTemplate(bucketPath: string): string {
|
||||
return `${publicOrigin()}/media/thumb/{WIDTH}/${normalize(bucketPath)}`;
|
||||
}
|
||||
|
||||
/** Read a repo fixture, cached after the first load. */
|
||||
export function fixtureBytes(name: FixtureName): Buffer {
|
||||
const cached = fixtureCache.get(name);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const bytes = readFileSync(join(config.repoRoot, FIXTURES[name]));
|
||||
fixtureCache.set(name, bytes);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/** Best-effort content type from a file extension, for serving stored bytes back. */
|
||||
export function contentTypeForExtension(extension: string): string {
|
||||
const known: Record<string, string> = {
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".webp": "image/webp",
|
||||
".gif": "image/gif",
|
||||
".svg": "image/svg+xml",
|
||||
".mp4": "video/mp4",
|
||||
".webm": "video/webm",
|
||||
".mov": "video/quicktime",
|
||||
".mp3": "audio/mpeg",
|
||||
".wav": "audio/wav",
|
||||
".glb": "model/gltf-binary",
|
||||
".gltf": "model/gltf+json",
|
||||
".fbx": "application/octet-stream",
|
||||
".spz": "application/octet-stream",
|
||||
".ply": "application/octet-stream",
|
||||
".pmx": "application/octet-stream",
|
||||
".json": "application/json",
|
||||
".zip": "application/zip",
|
||||
};
|
||||
|
||||
return known[extension.toLowerCase()] ?? "application/octet-stream";
|
||||
}
|
||||
|
||||
/** File extension including the dot, or an empty string. */
|
||||
export function extensionOf(fileName: string): string {
|
||||
const dot = fileName.lastIndexOf(".");
|
||||
return dot > 0 ? fileName.slice(dot) : "";
|
||||
}
|
||||
|
||||
function normalize(bucketPath: string): string {
|
||||
return bucketPath.replace(/^\/+/, "");
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/** Timestamps in the RFC3339 form the Rust backend emits. */
|
||||
|
||||
export function nowIso(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
export function isoFromMillis(millis: number): string {
|
||||
return new Date(millis).toISOString();
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* The in-memory record types.
|
||||
*
|
||||
* These are storage records, not wire types — each one feeds several different
|
||||
* response shapes, because the real backend emits a different subset of media
|
||||
* file fields on almost every endpoint. Response construction lives in
|
||||
* `src/wire/`.
|
||||
*/
|
||||
|
||||
export type MediaClass = "unknown" | "audio" | "image" | "video" | "dimensional" | "mesh" | "splat" | "project";
|
||||
|
||||
export type MediaType =
|
||||
| "audio" | "image" | "video"
|
||||
| "bvh" | "fbx" | "obj" | "ply" | "glb" | "gltf" | "spz"
|
||||
| "scene_ron" | "scene_json" | "mood_json" | "timeline_json" | "editor_json"
|
||||
| "pmd" | "vmd" | "pmx" | "csv"
|
||||
| "jpg" | "png" | "gif" | "mp4" | "wav" | "mp3" | "webp" | "webm" | "mov"
|
||||
| "opus" | "ogg" | "aac" | "m4a" | "flac" | "json";
|
||||
|
||||
export type ProjectType = "scene_3d" | "mood_board" | "workflow" | "video_timeline" | "editor_2d";
|
||||
|
||||
export type Visibility = "public" | "hidden" | "private";
|
||||
|
||||
export type OriginCategory =
|
||||
| "inference" | "inference3rd" | "processed" | "upload" | "device_api" | "studio" | "story_engine";
|
||||
|
||||
export type ProductCategory =
|
||||
| "unknown" | "face_animator" | "face_fusion" | "face_mirror" | "vst" | "image_studio"
|
||||
| "studio" | "tts" | "voice_conversion" | "zs_voice" | "mocap" | "image_gen" | "video_gen"
|
||||
| "video_edit" | "world_gen" | "video_filter" | "workflow";
|
||||
|
||||
export type JobStatus =
|
||||
| "pending" | "started" | "complete_success" | "complete_failure"
|
||||
| "attempt_failed" | "dead" | "cancelled_by_user" | "cancelled_by_system";
|
||||
|
||||
export type InferenceCategory =
|
||||
| "image_generation" | "video_generation" | "audio_generation" | "object_generation"
|
||||
| "splat_generation" | "character_generation" | "text_to_speech" | "voice_conversion"
|
||||
| "lipsync_animation" | "format_conversion" | "workflow";
|
||||
|
||||
export interface UserRecord {
|
||||
userToken: string;
|
||||
username: string;
|
||||
displayName: string;
|
||||
emailAddress: string;
|
||||
password: string;
|
||||
gravatarHash: string;
|
||||
featureFlags: string[];
|
||||
defaultAvatar: { image_index: number; color_index: number };
|
||||
bankedCredits: number;
|
||||
monthlyCredits: number;
|
||||
subscriptionSlug: string | undefined;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface SessionRecord {
|
||||
signedSession: string;
|
||||
userToken: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface MediaFileRecord {
|
||||
token: string;
|
||||
mediaClass: MediaClass;
|
||||
mediaType: MediaType;
|
||||
/** Path within the object store, also the URL path under `/media/`. */
|
||||
bucketPath: string;
|
||||
maybeProjectType: ProjectType | undefined;
|
||||
maybeCreatorUserToken: string | undefined;
|
||||
maybeTitle: string | undefined;
|
||||
maybeOriginalFilename: string | undefined;
|
||||
maybePromptToken: string | undefined;
|
||||
maybeBatchToken: string | undefined;
|
||||
maybeStyleName: string | undefined;
|
||||
maybeDurationMillis: number | undefined;
|
||||
maybeEngineCategory: string | undefined;
|
||||
maybeAnimationType: string | undefined;
|
||||
maybeEngineExtension: string | undefined;
|
||||
maybeSceneSourceMediaFileToken: string | undefined;
|
||||
maybeCoverImageMediaFileToken: string | undefined;
|
||||
maybeTextTranscript: string | undefined;
|
||||
creatorSetVisibility: Visibility;
|
||||
isUserUpload: boolean;
|
||||
isIntermediateSystemFile: boolean;
|
||||
isFeatured: boolean;
|
||||
originCategory: OriginCategory;
|
||||
originProductCategory: ProductCategory;
|
||||
maybeOriginModelType: string | undefined;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface JobRecord {
|
||||
jobToken: string;
|
||||
inferenceCategory: InferenceCategory;
|
||||
status: JobStatus;
|
||||
progressPercentage: number;
|
||||
maybePromptToken: string | undefined;
|
||||
maybeModelType: string | undefined;
|
||||
maybeModelTitle: string | undefined;
|
||||
maybeRawInferenceText: string | undefined;
|
||||
maybeCreatorUserToken: string | undefined;
|
||||
maybeBatchToken: string | undefined;
|
||||
maybeResultMediaFileToken: string | undefined;
|
||||
maybeFailureCategory: string | undefined;
|
||||
maybeFailureMessage: string | undefined;
|
||||
maybeSuccessfullyCompletedAt: string | undefined;
|
||||
/** Epoch millis at which the resolver should finish this job. */
|
||||
resolveAtMillis: number;
|
||||
isDismissed: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface PromptRecord {
|
||||
token: string;
|
||||
promptType: string;
|
||||
maybePositivePrompt: string | undefined;
|
||||
maybeNegativePrompt: string | undefined;
|
||||
maybeModelType: string | undefined;
|
||||
maybeModelClass: string | undefined;
|
||||
maybeGenerationProvider: string | undefined;
|
||||
maybeAspectRatio: string | undefined;
|
||||
maybeResolution: string | undefined;
|
||||
maybeBatchCount: number | undefined;
|
||||
maybeDurationSeconds: number | undefined;
|
||||
maybeStyleName: string | undefined;
|
||||
maybeGenerateAudio: boolean | undefined;
|
||||
contextImageMediaTokens: string[];
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface FolderRecord {
|
||||
token: string;
|
||||
name: string;
|
||||
ownerUserToken: string;
|
||||
maybeParentFolderToken: string | undefined;
|
||||
maybeColorCode: string | undefined;
|
||||
maybeCustomCoverMediaFileToken: string | undefined;
|
||||
hasStar: boolean;
|
||||
mediaFileTokens: string[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface TagRecord {
|
||||
tagToken: string;
|
||||
tagValue: string;
|
||||
ownerUserToken: string;
|
||||
mediaFileTokens: Set<string>;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface CharacterRecord {
|
||||
token: string;
|
||||
name: string;
|
||||
maybeDescription: string | undefined;
|
||||
maybeAvatarMediaFileToken: string | undefined;
|
||||
maybeFullImageMediaFileToken: string | undefined;
|
||||
models: string[];
|
||||
ownerUserToken: string;
|
||||
isUserCreated: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface ApiKeyRecord {
|
||||
token: string;
|
||||
apiKey: string;
|
||||
name: string;
|
||||
maybeDescription: string | undefined;
|
||||
ownerUserToken: string;
|
||||
maybeDeletedAt: string | undefined;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ReferralCodeRecord {
|
||||
token: string;
|
||||
code: string;
|
||||
ownerUserToken: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Creating media files.
|
||||
*
|
||||
* Uploads and finished generations both come through here so that a generated
|
||||
* image and an uploaded one are indistinguishable to the rest of the server —
|
||||
* same bucket layout, same URL shape, same record fields.
|
||||
*/
|
||||
|
||||
import {
|
||||
contentTypeForExtension,
|
||||
fixtureBytes,
|
||||
makeBucketPath,
|
||||
putObject,
|
||||
type FixtureName,
|
||||
} from "./assets.ts";
|
||||
import { nowIso } from "./clock.ts";
|
||||
import type {
|
||||
MediaClass,
|
||||
MediaFileRecord,
|
||||
MediaType,
|
||||
OriginCategory,
|
||||
ProductCategory,
|
||||
ProjectType,
|
||||
Visibility,
|
||||
} from "./entities.ts";
|
||||
import { store } from "./store.ts";
|
||||
import { makeToken, TOKEN_PREFIX } from "./tokens.ts";
|
||||
|
||||
export interface CreateMediaFileArgs {
|
||||
bytes: Buffer;
|
||||
mediaClass: MediaClass;
|
||||
mediaType: MediaType;
|
||||
/** Filename prefix inside the bucket, e.g. `image_`, matching the real layout. */
|
||||
bucketPrefix: string;
|
||||
extension: string;
|
||||
maybeCreatorUserToken: string | undefined;
|
||||
originCategory: OriginCategory;
|
||||
originProductCategory: ProductCategory;
|
||||
isUserUpload: boolean;
|
||||
maybeTitle?: string | undefined;
|
||||
maybeOriginalFilename?: string | undefined;
|
||||
maybePromptToken?: string | undefined;
|
||||
maybeBatchToken?: string | undefined;
|
||||
maybeProjectType?: ProjectType | undefined;
|
||||
maybeEngineCategory?: string | undefined;
|
||||
maybeAnimationType?: string | undefined;
|
||||
maybeDurationMillis?: number | undefined;
|
||||
maybeSceneSourceMediaFileToken?: string | undefined;
|
||||
maybeOriginModelType?: string | undefined;
|
||||
creatorSetVisibility?: Visibility | undefined;
|
||||
isIntermediateSystemFile?: boolean | undefined;
|
||||
}
|
||||
|
||||
export function createMediaFile(args: CreateMediaFileArgs): MediaFileRecord {
|
||||
const token = makeToken(TOKEN_PREFIX.mediaFile);
|
||||
const hash = token.slice(TOKEN_PREFIX.mediaFile.length);
|
||||
const bucketPath = makeBucketPath(hash, args.bucketPrefix, args.extension);
|
||||
|
||||
putObject(bucketPath, args.bytes, contentTypeForExtension(args.extension));
|
||||
writeVideoThumbnails(bucketPath, args.mediaClass, args.mediaType);
|
||||
|
||||
const timestamp = nowIso();
|
||||
const record: MediaFileRecord = {
|
||||
token,
|
||||
mediaClass: args.mediaClass,
|
||||
mediaType: args.mediaType,
|
||||
bucketPath,
|
||||
maybeProjectType: args.maybeProjectType,
|
||||
maybeCreatorUserToken: args.maybeCreatorUserToken,
|
||||
maybeTitle: args.maybeTitle,
|
||||
maybeOriginalFilename: args.maybeOriginalFilename,
|
||||
maybePromptToken: args.maybePromptToken,
|
||||
maybeBatchToken: args.maybeBatchToken,
|
||||
maybeStyleName: undefined,
|
||||
maybeDurationMillis: args.maybeDurationMillis,
|
||||
maybeEngineCategory: args.maybeEngineCategory,
|
||||
maybeAnimationType: args.maybeAnimationType,
|
||||
maybeEngineExtension: undefined,
|
||||
maybeSceneSourceMediaFileToken: args.maybeSceneSourceMediaFileToken,
|
||||
maybeCoverImageMediaFileToken: undefined,
|
||||
maybeTextTranscript: undefined,
|
||||
creatorSetVisibility: args.creatorSetVisibility ?? "public",
|
||||
isUserUpload: args.isUserUpload,
|
||||
isIntermediateSystemFile: args.isIntermediateSystemFile ?? false,
|
||||
isFeatured: false,
|
||||
originCategory: args.originCategory,
|
||||
originProductCategory: args.originProductCategory,
|
||||
maybeOriginModelType: args.maybeOriginModelType,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
|
||||
store.mediaFilesByToken.set(token, record);
|
||||
return record;
|
||||
}
|
||||
|
||||
/** Media type and class inferred from an uploaded file's extension. */
|
||||
export function classifyUpload(fileName: string): { mediaClass: MediaClass; mediaType: MediaType; bucketPrefix: string } {
|
||||
const extension = fileName.slice(fileName.lastIndexOf(".")).toLowerCase();
|
||||
|
||||
const table: Record<string, { mediaClass: MediaClass; mediaType: MediaType; bucketPrefix: string }> = {
|
||||
".jpg": { mediaClass: "image", mediaType: "jpg", bucketPrefix: "image_" },
|
||||
".jpeg": { mediaClass: "image", mediaType: "jpg", bucketPrefix: "image_" },
|
||||
".png": { mediaClass: "image", mediaType: "png", bucketPrefix: "image_" },
|
||||
".gif": { mediaClass: "image", mediaType: "gif", bucketPrefix: "image_" },
|
||||
".webp": { mediaClass: "image", mediaType: "webp", bucketPrefix: "image_" },
|
||||
".mp4": { mediaClass: "video", mediaType: "mp4", bucketPrefix: "video_" },
|
||||
".webm": { mediaClass: "video", mediaType: "webm", bucketPrefix: "video_" },
|
||||
".mov": { mediaClass: "video", mediaType: "mov", bucketPrefix: "video_" },
|
||||
".mp3": { mediaClass: "audio", mediaType: "mp3", bucketPrefix: "audio_" },
|
||||
".wav": { mediaClass: "audio", mediaType: "wav", bucketPrefix: "audio_" },
|
||||
".glb": { mediaClass: "mesh", mediaType: "glb", bucketPrefix: "engine_" },
|
||||
".gltf": { mediaClass: "mesh", mediaType: "gltf", bucketPrefix: "engine_" },
|
||||
".fbx": { mediaClass: "dimensional", mediaType: "fbx", bucketPrefix: "engine_" },
|
||||
".obj": { mediaClass: "dimensional", mediaType: "obj", bucketPrefix: "engine_" },
|
||||
".ply": { mediaClass: "splat", mediaType: "ply", bucketPrefix: "engine_" },
|
||||
".spz": { mediaClass: "splat", mediaType: "spz", bucketPrefix: "splat_" },
|
||||
".pmx": { mediaClass: "dimensional", mediaType: "pmx", bucketPrefix: "engine_" },
|
||||
".bvh": { mediaClass: "dimensional", mediaType: "bvh", bucketPrefix: "engine_" },
|
||||
".json": { mediaClass: "project", mediaType: "json", bucketPrefix: "project_" },
|
||||
};
|
||||
|
||||
return table[extension] ?? { mediaClass: "unknown", mediaType: "json", bucketPrefix: "file_" };
|
||||
}
|
||||
|
||||
/** Create a media file whose visible content is one of the repo fixtures. */
|
||||
export function createMediaFileFromFixture(
|
||||
fixture: FixtureName,
|
||||
args: Omit<CreateMediaFileArgs, "bytes">,
|
||||
): MediaFileRecord {
|
||||
return createMediaFile({ ...args, bytes: fixtureBytes(fixture) });
|
||||
}
|
||||
|
||||
/**
|
||||
* Videos are served with `-thumb.jpg` / `-thumb.gif` siblings, because
|
||||
* `media_links.maybe_video_previews` points at them. Both get image bytes so
|
||||
* the browser renders something.
|
||||
*/
|
||||
function writeVideoThumbnails(bucketPath: string, mediaClass: MediaClass, mediaType: MediaType): void {
|
||||
if (mediaClass !== "video" && mediaType !== "mp4") {
|
||||
return;
|
||||
}
|
||||
|
||||
const still = fixtureBytes("image");
|
||||
putObject(`${bucketPath}-thumb.jpg`, still, "image/jpeg");
|
||||
putObject(`${bucketPath}-thumb.gif`, still, "image/gif");
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* The fixture the server starts from.
|
||||
*
|
||||
* A fresh process always contains the same demo user, credits, gallery and
|
||||
* folder, so "log in and look at the library" works immediately and tests can
|
||||
* rely on what they find.
|
||||
*/
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import { config } from "../config.ts";
|
||||
import { nowIso } from "./clock.ts";
|
||||
import type { FolderRecord, MediaFileRecord, TagRecord, UserRecord } from "./entities.ts";
|
||||
import { createMediaFileFromFixture } from "./media_factory.ts";
|
||||
import { clearStore, store } from "./store.ts";
|
||||
import { makeToken, TOKEN_PREFIX } from "./tokens.ts";
|
||||
|
||||
export const DEMO_USERNAME = "localdev1";
|
||||
export const DEMO_PASSWORD = "localdev1pass";
|
||||
const DEMO_EMAIL = "localdev1@example.com";
|
||||
|
||||
/** Titles for the seeded gallery, so the library is not a wall of identical rows. */
|
||||
const SEED_IMAGE_TITLES = [
|
||||
"Juno at the lake",
|
||||
"Mochi in the studio",
|
||||
"Golden hour portrait",
|
||||
"Concept sheet, front view",
|
||||
"Reference plate 04",
|
||||
"Lighting test, warm key",
|
||||
];
|
||||
|
||||
export function seedState(): void {
|
||||
clearStore();
|
||||
|
||||
const user = seedDemoUser();
|
||||
const images = seedGallery(user);
|
||||
seedVideo(user);
|
||||
seedFolder(user, images);
|
||||
seedTags(user, images);
|
||||
|
||||
console.log(`[fake-api] seeded ${DEMO_USERNAME} / ${DEMO_PASSWORD} with ${config.demoCredits} credits`);
|
||||
console.log(`[fake-api] seeded ${store.mediaFilesByToken.size} media files`);
|
||||
}
|
||||
|
||||
function seedDemoUser(): UserRecord {
|
||||
const user: UserRecord = {
|
||||
userToken: makeToken(TOKEN_PREFIX.user),
|
||||
username: DEMO_USERNAME,
|
||||
displayName: "LocalDev1",
|
||||
emailAddress: DEMO_EMAIL,
|
||||
password: DEMO_PASSWORD,
|
||||
gravatarHash: md5(DEMO_EMAIL),
|
||||
featureFlags: ["explore_media", "studio", "upload_3d", "video_style_transfer", "referrals", "api_key"],
|
||||
defaultAvatar: { image_index: 16, color_index: 5 },
|
||||
bankedCredits: config.demoCredits,
|
||||
monthlyCredits: 0,
|
||||
subscriptionSlug: "artcraft_pro",
|
||||
createdAt: nowIso(),
|
||||
};
|
||||
|
||||
registerUser(user);
|
||||
return user;
|
||||
}
|
||||
|
||||
/** Register a user in both lookup maps. Also used by the create-account handler. */
|
||||
export function registerUser(user: UserRecord): void {
|
||||
store.usersByToken.set(user.userToken, user);
|
||||
store.usersByUsername.set(user.username.toLowerCase(), user);
|
||||
}
|
||||
|
||||
export function md5(value: string): string {
|
||||
return createHash("md5").update(value.trim().toLowerCase()).digest("hex");
|
||||
}
|
||||
|
||||
function seedGallery(user: UserRecord): MediaFileRecord[] {
|
||||
return SEED_IMAGE_TITLES.map((title, index) =>
|
||||
createMediaFileFromFixture(index % 2 === 0 ? "image" : "imageAlternate", {
|
||||
mediaClass: "image",
|
||||
mediaType: "jpg",
|
||||
bucketPrefix: "image_",
|
||||
extension: ".jpg",
|
||||
maybeCreatorUserToken: user.userToken,
|
||||
maybeTitle: title,
|
||||
maybeOriginalFilename: `${title.toLowerCase().replaceAll(/[^a-z0-9]+/g, "_")}.jpg`,
|
||||
originCategory: "upload",
|
||||
originProductCategory: "image_gen",
|
||||
isUserUpload: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function seedVideo(user: UserRecord): MediaFileRecord {
|
||||
return createMediaFileFromFixture("video", {
|
||||
mediaClass: "video",
|
||||
mediaType: "mp4",
|
||||
bucketPrefix: "video_",
|
||||
extension: ".mp4",
|
||||
maybeCreatorUserToken: user.userToken,
|
||||
maybeTitle: "Garoh, golden sun",
|
||||
maybeOriginalFilename: "golden_sun_garoh.mp4",
|
||||
maybeDurationMillis: 8_000,
|
||||
originCategory: "upload",
|
||||
originProductCategory: "video_gen",
|
||||
isUserUpload: true,
|
||||
});
|
||||
}
|
||||
|
||||
function seedFolder(user: UserRecord, images: MediaFileRecord[]): FolderRecord {
|
||||
const timestamp = nowIso();
|
||||
const folder: FolderRecord = {
|
||||
token: makeToken(TOKEN_PREFIX.folder),
|
||||
name: "References",
|
||||
ownerUserToken: user.userToken,
|
||||
maybeParentFolderToken: undefined,
|
||||
maybeColorCode: "#7c5cff",
|
||||
maybeCustomCoverMediaFileToken: undefined,
|
||||
hasStar: true,
|
||||
mediaFileTokens: images.slice(0, 3).map((image) => image.token),
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
|
||||
store.foldersByToken.set(folder.token, folder);
|
||||
return folder;
|
||||
}
|
||||
|
||||
function seedTags(user: UserRecord, images: MediaFileRecord[]): void {
|
||||
const assignments: [string, MediaFileRecord[]][] = [
|
||||
["character", images.slice(0, 2)],
|
||||
["lighting", images.slice(4, 6)],
|
||||
];
|
||||
|
||||
for (const [value, taggedImages] of assignments) {
|
||||
const tag: TagRecord = {
|
||||
tagToken: makeToken(TOKEN_PREFIX.tag),
|
||||
tagValue: value,
|
||||
ownerUserToken: user.userToken,
|
||||
mediaFileTokens: new Set(taggedImages.map((image) => image.token)),
|
||||
createdAt: nowIso(),
|
||||
};
|
||||
store.tagsByToken.set(tag.tagToken, tag);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* The whole server's state.
|
||||
*
|
||||
* One module-level object holding plain Maps. It is process-local and lost on
|
||||
* restart, which is the point: every run starts from the same seeded fixture,
|
||||
* so a developer or a test can rely on what is there.
|
||||
*/
|
||||
|
||||
import type {
|
||||
ApiKeyRecord,
|
||||
CharacterRecord,
|
||||
FolderRecord,
|
||||
JobRecord,
|
||||
MediaFileRecord,
|
||||
PromptRecord,
|
||||
ReferralCodeRecord,
|
||||
SessionRecord,
|
||||
TagRecord,
|
||||
UserRecord,
|
||||
} from "./entities.ts";
|
||||
import { clearObjects } from "./assets.ts";
|
||||
import { resetTokenSequence } from "./tokens.ts";
|
||||
|
||||
export interface FakeStore {
|
||||
usersByToken: Map<string, UserRecord>;
|
||||
usersByUsername: Map<string, UserRecord>;
|
||||
sessionsBySignedSession: Map<string, SessionRecord>;
|
||||
mediaFilesByToken: Map<string, MediaFileRecord>;
|
||||
jobsByToken: Map<string, JobRecord>;
|
||||
promptsByToken: Map<string, PromptRecord>;
|
||||
foldersByToken: Map<string, FolderRecord>;
|
||||
tagsByToken: Map<string, TagRecord>;
|
||||
charactersByToken: Map<string, CharacterRecord>;
|
||||
apiKeysByToken: Map<string, ApiKeyRecord>;
|
||||
referralCodesByToken: Map<string, ReferralCodeRecord>;
|
||||
/** Idempotency tokens already spent, so replays are rejected like the real API. */
|
||||
usedIdempotencyTokens: Set<string>;
|
||||
}
|
||||
|
||||
export const store: FakeStore = {
|
||||
usersByToken: new Map(),
|
||||
usersByUsername: new Map(),
|
||||
sessionsBySignedSession: new Map(),
|
||||
mediaFilesByToken: new Map(),
|
||||
jobsByToken: new Map(),
|
||||
promptsByToken: new Map(),
|
||||
foldersByToken: new Map(),
|
||||
tagsByToken: new Map(),
|
||||
charactersByToken: new Map(),
|
||||
apiKeysByToken: new Map(),
|
||||
referralCodesByToken: new Map(),
|
||||
usedIdempotencyTokens: new Set(),
|
||||
};
|
||||
|
||||
/** Drop everything, including stored bytes, and restart the token sequence. */
|
||||
export function clearStore(): void {
|
||||
for (const collection of [
|
||||
store.usersByToken,
|
||||
store.usersByUsername,
|
||||
store.sessionsBySignedSession,
|
||||
store.mediaFilesByToken,
|
||||
store.jobsByToken,
|
||||
store.promptsByToken,
|
||||
store.foldersByToken,
|
||||
store.tagsByToken,
|
||||
store.charactersByToken,
|
||||
store.apiKeysByToken,
|
||||
store.referralCodesByToken,
|
||||
]) {
|
||||
collection.clear();
|
||||
}
|
||||
|
||||
store.usedIdempotencyTokens.clear();
|
||||
clearObjects();
|
||||
resetTokenSequence();
|
||||
}
|
||||
|
||||
/** Media files owned by a user, newest first. */
|
||||
export function mediaFilesForUser(userToken: string | undefined): MediaFileRecord[] {
|
||||
const owned: MediaFileRecord[] = [];
|
||||
for (const record of store.mediaFilesByToken.values()) {
|
||||
if (record.maybeCreatorUserToken === userToken) {
|
||||
owned.push(record);
|
||||
}
|
||||
}
|
||||
return sortNewestFirst(owned);
|
||||
}
|
||||
|
||||
/** Sort any record set by `createdAt`, newest first — the default order everywhere in the UI. */
|
||||
export function sortNewestFirst<T extends { createdAt: string }>(records: T[]): T[] {
|
||||
return [...records].sort((left, right) => right.createdAt.localeCompare(left.createdAt));
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Synthesized result assets.
|
||||
*
|
||||
* The repo ships image and video fixtures, but nothing for audio, mesh or
|
||||
* splat results. Rather than hand back a broken file and let the viewer fail in
|
||||
* a confusing way, these build small well-formed assets in the formats the
|
||||
* frontend actually loads.
|
||||
*/
|
||||
|
||||
const SAMPLE_RATE_HZ = 44_100;
|
||||
const TONE_SECONDS = 2;
|
||||
const TONE_HZ = 440;
|
||||
const TONE_AMPLITUDE = 0.12;
|
||||
|
||||
const SPLAT_POINT_COUNT = 512;
|
||||
|
||||
/** 16-bit mono PCM WAV holding a quiet tone, so waveform UIs render something. */
|
||||
export function makeWavBytes(): Buffer {
|
||||
const sampleCount = SAMPLE_RATE_HZ * TONE_SECONDS;
|
||||
const dataBytes = sampleCount * 2;
|
||||
const buffer = Buffer.alloc(44 + dataBytes);
|
||||
|
||||
buffer.write("RIFF", 0, "ascii");
|
||||
buffer.writeUInt32LE(36 + dataBytes, 4);
|
||||
buffer.write("WAVE", 8, "ascii");
|
||||
buffer.write("fmt ", 12, "ascii");
|
||||
buffer.writeUInt32LE(16, 16); // PCM header size
|
||||
buffer.writeUInt16LE(1, 20); // PCM
|
||||
buffer.writeUInt16LE(1, 22); // mono
|
||||
buffer.writeUInt32LE(SAMPLE_RATE_HZ, 24);
|
||||
buffer.writeUInt32LE(SAMPLE_RATE_HZ * 2, 28); // byte rate
|
||||
buffer.writeUInt16LE(2, 32); // block align
|
||||
buffer.writeUInt16LE(16, 34); // bits per sample
|
||||
buffer.write("data", 36, "ascii");
|
||||
buffer.writeUInt32LE(dataBytes, 40);
|
||||
|
||||
for (let index = 0; index < sampleCount; index += 1) {
|
||||
const value = Math.sin((2 * Math.PI * TONE_HZ * index) / SAMPLE_RATE_HZ) * TONE_AMPLITUDE;
|
||||
buffer.writeInt16LE(Math.round(value * 32_767), 44 + index * 2);
|
||||
}
|
||||
|
||||
return buffer;
|
||||
}
|
||||
|
||||
/** A valid single-triangle GLB, enough for a 3D viewer to load and frame something. */
|
||||
export function makeGlbBytes(): Buffer {
|
||||
const vertices = new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]);
|
||||
const binary = Buffer.from(vertices.buffer);
|
||||
|
||||
const gltf = {
|
||||
asset: { version: "2.0", generator: "fake-storyteller-web" },
|
||||
scene: 0,
|
||||
scenes: [{ nodes: [0] }],
|
||||
nodes: [{ mesh: 0 }],
|
||||
meshes: [{ primitives: [{ attributes: { POSITION: 0 } }] }],
|
||||
accessors: [
|
||||
{
|
||||
bufferView: 0,
|
||||
componentType: 5126, // FLOAT
|
||||
count: 3,
|
||||
type: "VEC3",
|
||||
min: [0, 0, 0],
|
||||
max: [1, 1, 0],
|
||||
},
|
||||
],
|
||||
bufferViews: [{ buffer: 0, byteOffset: 0, byteLength: binary.length }],
|
||||
buffers: [{ byteLength: binary.length }],
|
||||
};
|
||||
|
||||
const jsonChunk = padChunk(Buffer.from(JSON.stringify(gltf), "utf8"), 0x20);
|
||||
const binaryChunk = padChunk(binary, 0x00);
|
||||
|
||||
const header = Buffer.alloc(12);
|
||||
header.write("glTF", 0, "ascii");
|
||||
header.writeUInt32LE(2, 4);
|
||||
header.writeUInt32LE(12 + 8 + jsonChunk.length + 8 + binaryChunk.length, 8);
|
||||
|
||||
return Buffer.concat([
|
||||
header,
|
||||
chunkHeader(jsonChunk.length, 0x4e4f_534a),
|
||||
jsonChunk,
|
||||
chunkHeader(binaryChunk.length, 0x004e_4942),
|
||||
binaryChunk,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* A binary little-endian PLY in the 3D Gaussian Splatting layout, holding a
|
||||
* cloud of points in a small cube.
|
||||
*/
|
||||
export function makePlyBytes(): Buffer {
|
||||
const properties = [
|
||||
"x", "y", "z",
|
||||
"nx", "ny", "nz",
|
||||
"f_dc_0", "f_dc_1", "f_dc_2",
|
||||
"opacity",
|
||||
"scale_0", "scale_1", "scale_2",
|
||||
"rot_0", "rot_1", "rot_2", "rot_3",
|
||||
];
|
||||
|
||||
const header =
|
||||
"ply\n" +
|
||||
"format binary_little_endian 1.0\n" +
|
||||
`element vertex ${SPLAT_POINT_COUNT}\n` +
|
||||
properties.map((property) => `property float ${property}\n`).join("") +
|
||||
"end_header\n";
|
||||
|
||||
const body = Buffer.alloc(SPLAT_POINT_COUNT * properties.length * 4);
|
||||
let offset = 0;
|
||||
const write = (value: number): void => {
|
||||
body.writeFloatLE(value, offset);
|
||||
offset += 4;
|
||||
};
|
||||
|
||||
for (let index = 0; index < SPLAT_POINT_COUNT; index += 1) {
|
||||
const angle = (index / SPLAT_POINT_COUNT) * Math.PI * 2;
|
||||
const radius = 0.5 + 0.25 * Math.sin(index * 0.37);
|
||||
|
||||
write(Math.cos(angle) * radius);
|
||||
write(Math.sin(index * 0.11) * 0.4);
|
||||
write(Math.sin(angle) * radius);
|
||||
|
||||
write(0); write(1); write(0); // normals, unused by splat renderers
|
||||
|
||||
write(0.6); write(0.4); write(0.9); // spherical-harmonic DC term (colour)
|
||||
write(4); // opacity, pre-sigmoid
|
||||
write(-4); write(-4); write(-4); // log-scale
|
||||
write(1); write(0); write(0); write(0); // rotation quaternion
|
||||
}
|
||||
|
||||
return Buffer.concat([Buffer.from(header, "ascii"), body]);
|
||||
}
|
||||
|
||||
function chunkHeader(length: number, type: number): Buffer {
|
||||
const header = Buffer.alloc(8);
|
||||
header.writeUInt32LE(length, 0);
|
||||
header.writeUInt32LE(type, 4);
|
||||
return header;
|
||||
}
|
||||
|
||||
/** GLB chunks must be four-byte aligned; JSON pads with spaces, binary with zeroes. */
|
||||
function padChunk(chunk: Buffer, padByte: number): Buffer {
|
||||
const remainder = chunk.length % 4;
|
||||
if (remainder === 0) {
|
||||
return chunk;
|
||||
}
|
||||
return Buffer.concat([chunk, Buffer.alloc(4 - remainder, padByte)]);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Token generation, mirroring `crates/schema/public/tokens`.
|
||||
*
|
||||
* Tokens are Crockford base32 with a Stripe-like prefix, and the prefix counts
|
||||
* toward the total length. Some frontend code branches on the prefix, so the
|
||||
* fake uses the real ones.
|
||||
*
|
||||
* The generator is a seeded PRNG rather than `Math.random`, so a fresh server
|
||||
* always produces the same tokens in the same order. That keeps snapshot-style
|
||||
* tests stable across runs.
|
||||
*/
|
||||
|
||||
const CROCKFORD_LOWER = "0123456789abcdefghjkmnpqrstvwxyz";
|
||||
const DEFAULT_TOKEN_LENGTH = 32;
|
||||
const DEFAULT_SEED = 0x5f37_59df;
|
||||
|
||||
export const TOKEN_PREFIX = {
|
||||
apiKey: "api_key_",
|
||||
appSession: "app_session_",
|
||||
batchGeneration: "batch_g_",
|
||||
character: "character_",
|
||||
folder: "folder_",
|
||||
inferenceJob: "jinf_",
|
||||
mediaFile: "m_",
|
||||
mediaUpload: "mu_",
|
||||
modelWeight: "weight_",
|
||||
prompt: "prompt_",
|
||||
tag: "tag_",
|
||||
user: "user_",
|
||||
userBookmark: "ub_",
|
||||
wallet: "wallet_",
|
||||
} as const;
|
||||
|
||||
export type TokenPrefix = (typeof TOKEN_PREFIX)[keyof typeof TOKEN_PREFIX];
|
||||
|
||||
/** User tokens are 18 characters total; everything else is 32. */
|
||||
const TOKEN_LENGTH_OVERRIDES: Partial<Record<TokenPrefix, number>> = {
|
||||
[TOKEN_PREFIX.user]: 18,
|
||||
};
|
||||
|
||||
let nextRandom = mulberry32(DEFAULT_SEED);
|
||||
|
||||
/** Generate a token with the given prefix, e.g. `m_00f2h...`. */
|
||||
export function makeToken(prefix: TokenPrefix): string {
|
||||
const totalLength = TOKEN_LENGTH_OVERRIDES[prefix] ?? DEFAULT_TOKEN_LENGTH;
|
||||
const entropyLength = Math.max(1, totalLength - prefix.length);
|
||||
|
||||
let entropy = "";
|
||||
for (let index = 0; index < entropyLength; index += 1) {
|
||||
entropy += CROCKFORD_LOWER[Math.floor(nextRandom() * CROCKFORD_LOWER.length)];
|
||||
}
|
||||
|
||||
return prefix + entropy;
|
||||
}
|
||||
|
||||
/** A bare uuid-shaped identifier, for the idempotency and request ids the API echoes back. */
|
||||
export function makeUuid(): string {
|
||||
const hex = "0123456789abcdef";
|
||||
let out = "";
|
||||
for (let index = 0; index < 32; index += 1) {
|
||||
if (index === 8 || index === 12 || index === 16 || index === 20) {
|
||||
out += "-";
|
||||
}
|
||||
out += hex[Math.floor(nextRandom() * hex.length)];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Restart the token stream. Called by the state reset endpoint so tests replay identically. */
|
||||
export function resetTokenSequence(seed = DEFAULT_SEED): void {
|
||||
nextRandom = mulberry32(seed);
|
||||
}
|
||||
|
||||
function mulberry32(seed: number): () => number {
|
||||
let state = seed >>> 0;
|
||||
return () => {
|
||||
state = (state + 0x6d2b_79f5) >>> 0;
|
||||
let drawn = Math.imul(state ^ (state >>> 15), 1 | state);
|
||||
drawn = (drawn + Math.imul(drawn ^ (drawn >>> 7), 61 | drawn)) ^ drawn;
|
||||
return ((drawn ^ (drawn >>> 14)) >>> 0) / 4_294_967_296;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Compile-time checks against `@storyteller/api`.
|
||||
*
|
||||
* The fake exists to behave like the real Rust backend, so its response shapes
|
||||
* are modelled on the Rust structs rather than on this package. Where the two
|
||||
* agree, though, binding them together is free insurance: if the frontend
|
||||
* changes one of these enums, `nx typecheck fake-storyteller-web` fails instead
|
||||
* of the fake quietly serving a value the app no longer understands.
|
||||
*
|
||||
* These are type-only imports, so nothing from `@storyteller/api` is loaded at
|
||||
* runtime — which matters, because its barrel touches `window` on import and
|
||||
* would crash under Node.
|
||||
*
|
||||
* DELIBERATELY NOT CHECKED: `MediaFileClass` and `MediaFileType`. Both are
|
||||
* stale in `@storyteller/api` relative to the backend, and locking to them
|
||||
* would force the fake to serve wrong values:
|
||||
*
|
||||
* MediaFileClass frontend has animation/character/prop/scene;
|
||||
* the backend emits dimensional/mesh/splat/project.
|
||||
* MediaFileType frontend has mmd/scene/none and is missing every concrete
|
||||
* type the backend actually sends (jpg, png, mp4, spz, ply,
|
||||
* scene_json, ...).
|
||||
*
|
||||
* Fixing those enums in `@storyteller/api` is tracked separately; see this
|
||||
* app's README for the full drift list.
|
||||
*/
|
||||
|
||||
import type { JobStatus as FrontendJobStatus } from "@storyteller/api/enums/Job.js";
|
||||
import type { Visibility as FrontendVisibility } from "@storyteller/api/enums/Visibility.js";
|
||||
import type { JobStatus, Visibility } from "../state/entities.ts";
|
||||
|
||||
/** True only when both types accept exactly the same values. */
|
||||
type Exact<Left, Right> = [Left] extends [Right] ? ([Right] extends [Left] ? true : false) : false;
|
||||
|
||||
type Assert<Condition extends true> = Condition;
|
||||
|
||||
/**
|
||||
* `${Enum}` widens a TypeScript string enum to the union of its literal values,
|
||||
* which is what a plain JSON payload actually contains.
|
||||
*/
|
||||
export type VisibilityMatchesFrontend = Assert<Exact<`${FrontendVisibility}`, Visibility>>;
|
||||
|
||||
export type JobStatusMatchesFrontend = Assert<Exact<`${FrontendJobStatus}`, JobStatus>>;
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Job response builders.
|
||||
*
|
||||
* `/v1/jobs/job/:token` and `/v1/jobs/session` return deliberately different
|
||||
* status objects in the real backend: only the session listing carries
|
||||
* `maybe_failure_category_updated`, `maybe_failure_message` and
|
||||
* `maybe_batch_token`. The generation UI reads the session variant, so the
|
||||
* difference is load bearing.
|
||||
*/
|
||||
|
||||
import type { JobRecord } from "../state/entities.ts";
|
||||
import { store } from "../state/store.ts";
|
||||
import { mediaLinks } from "./media.ts";
|
||||
|
||||
/** The narrow failure enum still sent on `maybe_failure_category`. */
|
||||
const OLD_CLIENT_FAILURE_CATEGORIES = new Set([
|
||||
"face_not_detected",
|
||||
"keep_alive_elapsed",
|
||||
"not_yet_implemented",
|
||||
"retryable_worker_error",
|
||||
]);
|
||||
|
||||
/** `GET /v1/jobs/job/:token` and `GET /v1/jobs/batch`. */
|
||||
export function jobStatePayload(job: JobRecord): object {
|
||||
return {
|
||||
job_token: job.jobToken,
|
||||
request: jobRequestPayload(job),
|
||||
status: {
|
||||
status: job.status,
|
||||
maybe_extra_status_description: null,
|
||||
maybe_assigned_worker: null,
|
||||
maybe_assigned_cluster: null,
|
||||
maybe_first_started_at: job.status === "pending" ? null : job.createdAt,
|
||||
attempt_count: job.status === "pending" ? 0 : 1,
|
||||
requires_keepalive: false,
|
||||
maybe_failure_category: oldClientFailureCategory(job),
|
||||
progress_percentage: job.progressPercentage,
|
||||
},
|
||||
maybe_result: jobResultPayload(job, false),
|
||||
created_at: job.createdAt,
|
||||
updated_at: job.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
/** `GET /v1/jobs/session` — the richer status the generation list consumes. */
|
||||
export function sessionJobPayload(job: JobRecord): object {
|
||||
return {
|
||||
job_token: job.jobToken,
|
||||
request: jobRequestPayload(job),
|
||||
status: {
|
||||
status: job.status,
|
||||
maybe_extra_status_description: null,
|
||||
maybe_assigned_worker: null,
|
||||
maybe_assigned_cluster: null,
|
||||
maybe_first_started_at: job.status === "pending" ? null : job.createdAt,
|
||||
maybe_current_execution_duration_seconds: job.status === "started" ? 1 : null,
|
||||
attempt_count: job.status === "pending" ? 0 : 1,
|
||||
requires_keepalive: false,
|
||||
maybe_failure_category: oldClientFailureCategory(job),
|
||||
maybe_failure_category_updated: job.maybeFailureCategory ?? null,
|
||||
maybe_failure_message: job.maybeFailureMessage ?? null,
|
||||
progress_percentage: job.progressPercentage,
|
||||
},
|
||||
maybe_result: jobResultPayload(job, true),
|
||||
created_at: job.createdAt,
|
||||
updated_at: job.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
/** `GET /v1/workflows/preview_status/:token` — a deprecated endpoint with an empty request object. */
|
||||
export function jobPreviewPayload(job: JobRecord): object {
|
||||
return {
|
||||
job_token: job.jobToken,
|
||||
request: {},
|
||||
status: {
|
||||
status: job.status,
|
||||
maybe_extra_status_description: null,
|
||||
maybe_assigned_worker: null,
|
||||
maybe_assigned_cluster: null,
|
||||
maybe_first_started_at: job.status === "pending" ? null : job.createdAt,
|
||||
attempt_count: job.status === "pending" ? 0 : 1,
|
||||
requires_keepalive: false,
|
||||
maybe_failure_category: oldClientFailureCategory(job),
|
||||
progress_percentage: job.progressPercentage,
|
||||
},
|
||||
maybe_result: null,
|
||||
created_at: job.createdAt,
|
||||
updated_at: job.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function jobRequestPayload(job: JobRecord): object {
|
||||
return {
|
||||
inference_category: job.inferenceCategory,
|
||||
maybe_prompt_token: job.maybePromptToken ?? null,
|
||||
maybe_model_type: job.maybeModelType ?? null,
|
||||
maybe_model_token: null,
|
||||
maybe_model_title: job.maybeModelTitle ?? null,
|
||||
maybe_raw_inference_text: job.maybeRawInferenceText ?? null,
|
||||
maybe_style_name: null,
|
||||
maybe_live_portrait_details: null,
|
||||
maybe_lipsync_details: null,
|
||||
};
|
||||
}
|
||||
|
||||
function jobResultPayload(job: JobRecord, includeBatchToken: boolean): object | null {
|
||||
if (job.maybeResultMediaFileToken === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const record = store.mediaFilesByToken.get(job.maybeResultMediaFileToken);
|
||||
if (record === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const result: Record<string, unknown> = {
|
||||
entity_type: "media_file",
|
||||
entity_token: record.token,
|
||||
};
|
||||
|
||||
if (includeBatchToken) {
|
||||
result["maybe_batch_token"] = job.maybeBatchToken ?? null;
|
||||
}
|
||||
|
||||
result["maybe_public_bucket_media_path"] = `/media/${record.bucketPath}`;
|
||||
result["media_links"] = mediaLinks(record);
|
||||
result["maybe_successfully_completed_at"] = job.maybeSuccessfullyCompletedAt ?? null;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function oldClientFailureCategory(job: JobRecord): string | null {
|
||||
if (job.maybeFailureCategory === undefined) {
|
||||
return null;
|
||||
}
|
||||
return OLD_CLIENT_FAILURE_CATEGORIES.has(job.maybeFailureCategory) ? job.maybeFailureCategory : null;
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
/**
|
||||
* Media file response builders.
|
||||
*
|
||||
* The real backend emits a different subset of fields on nearly every endpoint
|
||||
* — `/file/:token` has no `origin`, `/list` renames `maybe_creator_user` to
|
||||
* `maybe_creator`, `/list/user/:username` has no creator at all — so each
|
||||
* variant gets its own builder rather than one object trimmed after the fact.
|
||||
* Getting this wrong is exactly the kind of bug the fake exists to surface.
|
||||
*/
|
||||
|
||||
import { bucketPathToCdnUrl, bucketPathToThumbnailTemplate } from "../state/assets.ts";
|
||||
import type { MediaFileRecord, UserRecord } from "../state/entities.ts";
|
||||
import { store } from "../state/store.ts";
|
||||
|
||||
/** Extensions the real backend generates a thumbnail template for. */
|
||||
const THUMBNAILED_TYPES = new Set(["jpg", "png", "gif", "image", "webp"]);
|
||||
|
||||
export interface MediaLinks {
|
||||
cdn_url: string;
|
||||
maybe_thumbnail_template: string | null;
|
||||
maybe_video_previews: {
|
||||
still: string;
|
||||
animated: string;
|
||||
still_thumbnail_template: string;
|
||||
animated_thumbnail_template: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export function mediaLinks(record: MediaFileRecord): MediaLinks {
|
||||
const cdnUrl = bucketPathToCdnUrl(record.bucketPath);
|
||||
const isVideo = record.mediaType === "mp4" || record.mediaClass === "video";
|
||||
|
||||
return {
|
||||
cdn_url: cdnUrl,
|
||||
maybe_thumbnail_template: THUMBNAILED_TYPES.has(record.mediaType)
|
||||
? bucketPathToThumbnailTemplate(record.bucketPath)
|
||||
: null,
|
||||
maybe_video_previews: isVideo
|
||||
? {
|
||||
still: `${cdnUrl}-thumb.jpg`,
|
||||
animated: `${cdnUrl}-thumb.gif`,
|
||||
still_thumbnail_template: bucketPathToThumbnailTemplate(`${record.bucketPath}-thumb.jpg`),
|
||||
animated_thumbnail_template: bucketPathToThumbnailTemplate(`${record.bucketPath}-thumb.gif`),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** The four-field cover shape used by storyteller_web's own responses. */
|
||||
export function coverImage(record: MediaFileRecord): object {
|
||||
const cover = coverLinks(record);
|
||||
const coverRecord = record.maybeCoverImageMediaFileToken === undefined
|
||||
? undefined
|
||||
: store.mediaFilesByToken.get(record.maybeCoverImageMediaFileToken);
|
||||
|
||||
return {
|
||||
maybe_cover_image_public_bucket_path: coverRecord === undefined ? null : `/media/${coverRecord.bucketPath}`,
|
||||
maybe_cover_image_public_bucket_url: coverRecord === undefined ? null : bucketPathToCdnUrl(coverRecord.bucketPath),
|
||||
maybe_links: cover,
|
||||
default_cover: defaultCover(record.token),
|
||||
};
|
||||
}
|
||||
|
||||
/** The trimmed two-field cover shape used by mesh/splat/project lists. */
|
||||
export function coverImageLight(record: MediaFileRecord): object {
|
||||
return {
|
||||
maybe_links: coverLinks(record),
|
||||
default_cover: defaultCover(record.token),
|
||||
};
|
||||
}
|
||||
|
||||
export function userDetailsLight(user: UserRecord | undefined): object | null {
|
||||
if (user === undefined) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
user_token: user.userToken,
|
||||
username: user.username,
|
||||
display_name: user.displayName,
|
||||
gravatar_hash: user.gravatarHash,
|
||||
default_avatar: user.defaultAvatar,
|
||||
};
|
||||
}
|
||||
|
||||
export function originDetails(record: MediaFileRecord): object {
|
||||
return {
|
||||
origin_category: record.originCategory,
|
||||
product_category: record.originProductCategory,
|
||||
maybe_model: record.maybeOriginModelType === undefined ? null : { model_type: record.maybeOriginModelType },
|
||||
};
|
||||
}
|
||||
|
||||
/** `GET /v1/media_files/file/:token`. Note: no `origin*` fields on this endpoint. */
|
||||
export function getMediaFilePayload(record: MediaFileRecord): object {
|
||||
return {
|
||||
token: record.token,
|
||||
media_class: record.mediaClass,
|
||||
media_type: record.mediaType,
|
||||
maybe_engine_category: record.maybeEngineCategory ?? null,
|
||||
maybe_animation_type: record.maybeAnimationType ?? null,
|
||||
maybe_engine_extension: record.maybeEngineExtension ?? null,
|
||||
maybe_batch_token: record.maybeBatchToken ?? null,
|
||||
maybe_scene_source_media_file_token: record.maybeSceneSourceMediaFileToken ?? null,
|
||||
public_bucket_path: `/media/${record.bucketPath}`,
|
||||
public_bucket_url: bucketPathToCdnUrl(record.bucketPath),
|
||||
media_links: mediaLinks(record),
|
||||
cover_image: coverImage(record),
|
||||
maybe_model_weight_info: null,
|
||||
maybe_creator_user: creatorOf(record),
|
||||
creator_set_visibility: record.creatorSetVisibility,
|
||||
is_user_upload: record.isUserUpload,
|
||||
is_intermediate_system_file: record.isIntermediateSystemFile,
|
||||
maybe_title: record.maybeTitle ?? null,
|
||||
maybe_text_transcript: record.maybeTextTranscript ?? null,
|
||||
maybe_live_portrait_details: null,
|
||||
maybe_style_name: record.maybeStyleName ?? null,
|
||||
used_face_detailer: false,
|
||||
used_upscaler: false,
|
||||
maybe_prompt_token: record.maybePromptToken ?? null,
|
||||
maybe_original_filename: record.maybeOriginalFilename ?? null,
|
||||
maybe_duration_millis: record.maybeDurationMillis ?? null,
|
||||
is_emulated_media_file: false,
|
||||
is_featured: record.isFeatured,
|
||||
stats: entityStats(),
|
||||
created_at: record.createdAt,
|
||||
updated_at: record.updatedAt,
|
||||
maybe_moderator_fields: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** `GET /v1/media_files/batch?tokens=…`. */
|
||||
export function batchMediaFilePayload(record: MediaFileRecord): object {
|
||||
return {
|
||||
token: record.token,
|
||||
media_class: record.mediaClass,
|
||||
media_type: record.mediaType,
|
||||
maybe_engine_category: record.maybeEngineCategory ?? null,
|
||||
maybe_animation_type: record.maybeAnimationType ?? null,
|
||||
maybe_engine_extension: record.maybeEngineExtension ?? null,
|
||||
maybe_batch_token: record.maybeBatchToken ?? null,
|
||||
public_bucket_path: `/media/${record.bucketPath}`,
|
||||
public_bucket_url: bucketPathToCdnUrl(record.bucketPath),
|
||||
media_links: mediaLinks(record),
|
||||
cover_image: coverImage(record),
|
||||
maybe_model_weight_info: null,
|
||||
maybe_creator_user: creatorOf(record),
|
||||
creator_set_visibility: record.creatorSetVisibility,
|
||||
is_user_upload: record.isUserUpload,
|
||||
is_intermediate_system_file: record.isIntermediateSystemFile,
|
||||
maybe_title: record.maybeTitle ?? null,
|
||||
maybe_text_transcript: record.maybeTextTranscript ?? null,
|
||||
maybe_live_portrait_details: null,
|
||||
maybe_style_name: record.maybeStyleName ?? null,
|
||||
maybe_prompt_token: record.maybePromptToken ?? null,
|
||||
maybe_original_filename: record.maybeOriginalFilename ?? null,
|
||||
maybe_duration_millis: record.maybeDurationMillis ?? null,
|
||||
is_emulated_media_file: false,
|
||||
stats: entityStats(),
|
||||
created_at: record.createdAt,
|
||||
updated_at: record.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
/** `GET /v1/media_files/list` and `/list_featured` — creator key is `maybe_creator`. */
|
||||
export function mediaFileListItemPayload(record: MediaFileRecord): object {
|
||||
return {
|
||||
token: record.token,
|
||||
media_class: record.mediaClass,
|
||||
media_type: record.mediaType,
|
||||
maybe_engine_category: record.maybeEngineCategory ?? null,
|
||||
maybe_animation_type: record.maybeAnimationType ?? null,
|
||||
origin_category: record.originCategory,
|
||||
origin_product_category: record.originProductCategory,
|
||||
maybe_origin_model_type: record.maybeOriginModelType ?? null,
|
||||
maybe_origin_model_token: null,
|
||||
origin: originDetails(record),
|
||||
public_bucket_path: `/media/${record.bucketPath}`,
|
||||
public_bucket_url: bucketPathToCdnUrl(record.bucketPath),
|
||||
media_links: mediaLinks(record),
|
||||
cover_image: coverImage(record),
|
||||
maybe_creator: creatorOf(record),
|
||||
stats: entityStats(),
|
||||
creator_set_visibility: record.creatorSetVisibility,
|
||||
is_user_upload: record.isUserUpload,
|
||||
is_intermediate_system_file: record.isIntermediateSystemFile,
|
||||
maybe_title: record.maybeTitle ?? null,
|
||||
maybe_text_transcript: record.maybeTextTranscript ?? null,
|
||||
maybe_style_name: record.maybeStyleName ?? null,
|
||||
maybe_duration_millis: record.maybeDurationMillis ?? null,
|
||||
created_at: record.createdAt,
|
||||
updated_at: record.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
/** `GET /v1/media_files/list/user/:username` — no creator field at all. */
|
||||
export function userMediaFileListItemPayload(record: MediaFileRecord): object {
|
||||
return {
|
||||
token: record.token,
|
||||
media_class: record.mediaClass,
|
||||
media_type: record.mediaType,
|
||||
maybe_engine_category: record.maybeEngineCategory ?? null,
|
||||
maybe_animation_type: record.maybeAnimationType ?? null,
|
||||
origin: originDetails(record),
|
||||
origin_category: record.originCategory,
|
||||
origin_product_category: record.originProductCategory,
|
||||
maybe_origin_model_type: record.maybeOriginModelType ?? null,
|
||||
maybe_origin_model_token: null,
|
||||
public_bucket_path: `/media/${record.bucketPath}`,
|
||||
public_bucket_url: bucketPathToCdnUrl(record.bucketPath),
|
||||
media_links: mediaLinks(record),
|
||||
maybe_prompt_token: record.maybePromptToken ?? null,
|
||||
cover_image: coverImage(record),
|
||||
creator_set_visibility: record.creatorSetVisibility,
|
||||
is_user_upload: record.isUserUpload,
|
||||
is_intermediate_system_file: record.isIntermediateSystemFile,
|
||||
maybe_title: record.maybeTitle ?? null,
|
||||
maybe_text_transcript: record.maybeTextTranscript ?? null,
|
||||
maybe_style_name: record.maybeStyleName ?? null,
|
||||
maybe_duration_millis: record.maybeDurationMillis ?? null,
|
||||
stats: entityStats(),
|
||||
created_at: record.createdAt,
|
||||
updated_at: record.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
/** `GET /v1/media_files/{mesh,splat}/list` — the narrow session shape. */
|
||||
export function sessionMediaFilePayload(record: MediaFileRecord): object {
|
||||
return {
|
||||
token: record.token,
|
||||
media_class: record.mediaClass,
|
||||
media_type: record.mediaType,
|
||||
media_links: mediaLinks(record),
|
||||
cover_image: coverImageLight(record),
|
||||
maybe_creator_user: creatorOf(record),
|
||||
creator_set_visibility: record.creatorSetVisibility,
|
||||
maybe_prompt_token: record.maybePromptToken ?? null,
|
||||
maybe_title: record.maybeTitle ?? null,
|
||||
created_at: record.createdAt,
|
||||
updated_at: record.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
/** `GET /v1/media_files/project/list`. */
|
||||
export function projectMediaFilePayload(record: MediaFileRecord): object {
|
||||
return {
|
||||
token: record.token,
|
||||
media_class: record.mediaClass,
|
||||
project_type: record.maybeProjectType ?? null,
|
||||
media_type: record.mediaType,
|
||||
media_links: mediaLinks(record),
|
||||
cover_image: coverImageLight(record),
|
||||
maybe_creator_user: creatorOf(record),
|
||||
creator_set_visibility: record.creatorSetVisibility,
|
||||
maybe_title: record.maybeTitle ?? null,
|
||||
created_at: record.createdAt,
|
||||
updated_at: record.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
/** `GET /v1/media_files/search_{featured,session}` — no stats, no origin. */
|
||||
export function searchMediaFilePayload(record: MediaFileRecord): object {
|
||||
return {
|
||||
token: record.token,
|
||||
media_class: record.mediaClass,
|
||||
media_type: record.mediaType,
|
||||
maybe_engine_category: record.maybeEngineCategory ?? null,
|
||||
maybe_animation_type: record.maybeAnimationType ?? null,
|
||||
public_bucket_path: `/media/${record.bucketPath}`,
|
||||
public_bucket_url: bucketPathToCdnUrl(record.bucketPath),
|
||||
media_links: mediaLinks(record),
|
||||
cover_image: coverImage(record),
|
||||
maybe_creator: creatorOf(record),
|
||||
creator_set_visibility: record.creatorSetVisibility,
|
||||
is_featured: record.isFeatured,
|
||||
is_user_upload: record.isUserUpload,
|
||||
is_intermediate_system_file: record.isIntermediateSystemFile,
|
||||
maybe_title: record.maybeTitle ?? null,
|
||||
created_at: record.createdAt,
|
||||
updated_at: record.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
/** `GET /v1/media_files/batch/:batchToken`. */
|
||||
export function batchListMediaFilePayload(record: MediaFileRecord): object {
|
||||
return {
|
||||
token: record.token,
|
||||
media_class: record.mediaClass,
|
||||
media_type: record.mediaType,
|
||||
maybe_engine_category: record.maybeEngineCategory ?? null,
|
||||
maybe_animation_type: record.maybeAnimationType ?? null,
|
||||
origin: originDetails(record),
|
||||
origin_category: record.originCategory,
|
||||
origin_product_category: record.originProductCategory,
|
||||
maybe_origin_model_type: record.maybeOriginModelType ?? null,
|
||||
maybe_origin_model_token: null,
|
||||
public_bucket_path: `/media/${record.bucketPath}`,
|
||||
public_bucket_url: bucketPathToCdnUrl(record.bucketPath),
|
||||
media_links: mediaLinks(record),
|
||||
cover_image: coverImage(record),
|
||||
creator_set_visibility: record.creatorSetVisibility,
|
||||
is_user_upload: record.isUserUpload,
|
||||
is_intermediate_system_file: record.isIntermediateSystemFile,
|
||||
maybe_title: record.maybeTitle ?? null,
|
||||
maybe_text_transcript: record.maybeTextTranscript ?? null,
|
||||
maybe_style_name: record.maybeStyleName ?? null,
|
||||
maybe_duration_millis: record.maybeDurationMillis ?? null,
|
||||
stats: entityStats(),
|
||||
created_at: record.createdAt,
|
||||
updated_at: record.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The row shape shared by folder and tag media listings. `addedToFolderAt` is
|
||||
* only present on folder responses.
|
||||
*/
|
||||
export function folderMediaFilePayload(record: MediaFileRecord, addedToFolderAt?: string): object {
|
||||
const payload: Record<string, unknown> = {
|
||||
token: record.token,
|
||||
media_class: record.mediaClass,
|
||||
media_type: record.mediaType,
|
||||
maybe_prompt_token: record.maybePromptToken ?? null,
|
||||
maybe_batch_token: record.maybeBatchToken ?? null,
|
||||
media_links: mediaLinks(record),
|
||||
cover_image: coverImage(record),
|
||||
maybe_title: record.maybeTitle ?? null,
|
||||
maybe_original_filename: record.maybeOriginalFilename ?? null,
|
||||
maybe_frame_width: null,
|
||||
maybe_frame_height: null,
|
||||
maybe_duration_millis: record.maybeDurationMillis ?? null,
|
||||
creator_set_visibility: record.creatorSetVisibility,
|
||||
is_user_upload: record.isUserUpload,
|
||||
created_at: record.createdAt,
|
||||
updated_at: record.updatedAt,
|
||||
};
|
||||
|
||||
if (addedToFolderAt !== undefined) {
|
||||
payload["is_intermediate_system_file"] = record.isIntermediateSystemFile;
|
||||
payload["added_to_folder_at"] = addedToFolderAt;
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
/** A thumbnail entry, used for folder covers. */
|
||||
export function folderThumbnailPayload(record: MediaFileRecord): object {
|
||||
return {
|
||||
token: record.token,
|
||||
media_class: record.mediaClass,
|
||||
media_type: record.mediaType,
|
||||
cdn_url: bucketPathToCdnUrl(record.bucketPath),
|
||||
maybe_thumbnail_template: mediaLinks(record).maybe_thumbnail_template,
|
||||
};
|
||||
}
|
||||
|
||||
function creatorOf(record: MediaFileRecord): object | null {
|
||||
if (record.maybeCreatorUserToken === undefined) {
|
||||
return null;
|
||||
}
|
||||
return userDetailsLight(store.usersByToken.get(record.maybeCreatorUserToken));
|
||||
}
|
||||
|
||||
function coverLinks(record: MediaFileRecord): object | null {
|
||||
if (record.maybeCoverImageMediaFileToken === undefined) {
|
||||
return null;
|
||||
}
|
||||
const cover = store.mediaFilesByToken.get(record.maybeCoverImageMediaFileToken);
|
||||
if (cover === undefined) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
cdn_url: bucketPathToCdnUrl(cover.bucketPath),
|
||||
thumbnail_template: bucketPathToThumbnailTemplate(cover.bucketPath),
|
||||
};
|
||||
}
|
||||
|
||||
function entityStats(): object {
|
||||
return { positive_rating_count: 0, bookmark_count: 0 };
|
||||
}
|
||||
|
||||
/** Deterministic placeholder cover derived from the token, as the real backend does. */
|
||||
function defaultCover(token: string): { image_index: number; color_index: number } {
|
||||
return {
|
||||
image_index: hashToken(token, 25, 5),
|
||||
color_index: hashToken(token, 8, 1),
|
||||
};
|
||||
}
|
||||
|
||||
function hashToken(token: string, modulo: number, salt: number): number {
|
||||
let hash = salt >>> 0;
|
||||
for (let index = 0; index < token.length; index += 1) {
|
||||
hash = (Math.imul(hash, 31) + token.charCodeAt(index)) >>> 0;
|
||||
}
|
||||
return hash % modulo;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Pagination helpers.
|
||||
*
|
||||
* The real backend uses opaque encrypted row-id cursors. The fake uses a plain
|
||||
* offset — opaque enough for a client that only ever echoes it back, and much
|
||||
* easier to reason about when a list looks wrong.
|
||||
*/
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 25;
|
||||
|
||||
export interface PaginationCursors {
|
||||
maybe_next: string | null;
|
||||
maybe_previous: string | null;
|
||||
cursor_is_reversed: boolean;
|
||||
}
|
||||
|
||||
export interface PaginationPage {
|
||||
current: number;
|
||||
total_page_count: number;
|
||||
}
|
||||
|
||||
export interface CursorPage<T> {
|
||||
results: T[];
|
||||
pagination: PaginationCursors;
|
||||
}
|
||||
|
||||
export interface IndexedPage<T> {
|
||||
results: T[];
|
||||
pagination: PaginationPage;
|
||||
}
|
||||
|
||||
export function paginateByCursor<T>(
|
||||
records: T[],
|
||||
options: { cursor?: string; pageSize?: number; cursorIsReversed?: boolean },
|
||||
): CursorPage<T> {
|
||||
const pageSize = clampPageSize(options.pageSize);
|
||||
const offset = decodeCursor(options.cursor);
|
||||
const results = records.slice(offset, offset + pageSize);
|
||||
|
||||
const hasNext = offset + pageSize < records.length;
|
||||
const hasPrevious = offset > 0;
|
||||
|
||||
return {
|
||||
results,
|
||||
pagination: {
|
||||
maybe_next: hasNext ? encodeCursor(offset + pageSize) : null,
|
||||
maybe_previous: hasPrevious ? encodeCursor(Math.max(0, offset - pageSize)) : null,
|
||||
cursor_is_reversed: options.cursorIsReversed ?? false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function paginateByIndex<T>(
|
||||
records: T[],
|
||||
options: { pageIndex?: number; pageSize?: number },
|
||||
): IndexedPage<T> {
|
||||
const pageSize = clampPageSize(options.pageSize);
|
||||
const pageIndex = Math.max(0, options.pageIndex ?? 0);
|
||||
|
||||
return {
|
||||
results: records.slice(pageIndex * pageSize, (pageIndex + 1) * pageSize),
|
||||
pagination: {
|
||||
current: pageIndex,
|
||||
total_page_count: Math.max(1, Math.ceil(records.length / pageSize)),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Cursor for the `maybe_cursor` style used by folders and tags (a bare next cursor). */
|
||||
export function nextCursorOnly(records: unknown[], offset: number, pageSize: number): string | null {
|
||||
return offset + pageSize < records.length ? encodeCursor(offset + pageSize) : null;
|
||||
}
|
||||
|
||||
export function decodeCursor(cursor: string | undefined): number {
|
||||
if (cursor === undefined) {
|
||||
return 0;
|
||||
}
|
||||
const parsed = Number.parseInt(Buffer.from(cursor, "base64url").toString("utf8"), 10);
|
||||
return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0;
|
||||
}
|
||||
|
||||
export function encodeCursor(offset: number): string {
|
||||
return Buffer.from(String(offset), "utf8").toString("base64url");
|
||||
}
|
||||
|
||||
export function clampPageSize(pageSize: number | undefined): number {
|
||||
if (pageSize === undefined || pageSize <= 0) {
|
||||
return DEFAULT_PAGE_SIZE;
|
||||
}
|
||||
return Math.min(pageSize, 1000);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* User response builders.
|
||||
*
|
||||
* `GET /v1/session` is the single most load-bearing response in the app — most
|
||||
* of the UI keys off it — so the full permission block is reproduced rather
|
||||
* than trimmed to the fields currently read.
|
||||
*/
|
||||
|
||||
import type { UserRecord } from "../state/entities.ts";
|
||||
|
||||
export function sessionUserPayload(user: UserRecord): object {
|
||||
return {
|
||||
core_info: coreInfoPayload(user),
|
||||
user_token: user.userToken,
|
||||
username: user.username,
|
||||
display_name: user.displayName,
|
||||
email_gravatar_hash: user.gravatarHash,
|
||||
onboarding: {
|
||||
email_not_set: false,
|
||||
email_not_confirmed: false,
|
||||
password_not_set: false,
|
||||
username_not_customized: false,
|
||||
},
|
||||
can_access_studio: user.featureFlags.includes("studio"),
|
||||
maybe_feature_flags: user.featureFlags,
|
||||
fakeyou_plan: "free",
|
||||
storyteller_stream_plan: "free",
|
||||
can_use_tts: true,
|
||||
can_use_w2l: true,
|
||||
can_delete_own_tts_results: true,
|
||||
can_delete_own_w2l_results: true,
|
||||
can_delete_own_account: true,
|
||||
can_upload_tts_models: true,
|
||||
can_upload_w2l_templates: true,
|
||||
can_delete_own_tts_models: true,
|
||||
can_delete_own_w2l_templates: true,
|
||||
can_approve_w2l_templates: false,
|
||||
can_edit_other_users_profiles: false,
|
||||
can_edit_other_users_tts_models: false,
|
||||
can_edit_other_users_w2l_templates: false,
|
||||
can_delete_other_users_tts_models: false,
|
||||
can_delete_other_users_tts_results: false,
|
||||
can_delete_other_users_w2l_templates: false,
|
||||
can_delete_other_users_w2l_results: false,
|
||||
can_ban_users: false,
|
||||
can_delete_users: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function coreInfoPayload(user: UserRecord): object {
|
||||
return {
|
||||
user_token: user.userToken,
|
||||
username: user.username,
|
||||
display_name: user.displayName,
|
||||
gravatar_hash: user.gravatarHash,
|
||||
default_avatar: user.defaultAvatar,
|
||||
};
|
||||
}
|
||||
|
||||
/** `GET /v1/user/:username/profile`. */
|
||||
export function userProfilePayload(user: UserRecord): object {
|
||||
return {
|
||||
user_token: user.userToken,
|
||||
core_info: coreInfoPayload(user),
|
||||
username: user.username,
|
||||
display_name: user.displayName,
|
||||
email_gravatar_hash: user.gravatarHash,
|
||||
default_avatar_index: user.defaultAvatar.image_index,
|
||||
default_avatar_color_index: user.defaultAvatar.color_index,
|
||||
profile_markdown: "",
|
||||
profile_rendered_html: "",
|
||||
user_role_slug: "user",
|
||||
disable_gravatar: false,
|
||||
preferred_tts_result_visibility: "public",
|
||||
preferred_w2l_result_visibility: "public",
|
||||
discord_username: null,
|
||||
twitch_username: null,
|
||||
twitter_username: null,
|
||||
patreon_username: null,
|
||||
github_username: null,
|
||||
cashapp_username: null,
|
||||
website_url: null,
|
||||
badges: [],
|
||||
created_at: user.createdAt,
|
||||
maybe_moderator_fields: null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["es2023"],
|
||||
"types": ["node"],
|
||||
"module": "nodenext",
|
||||
"moduleResolution": "nodenext",
|
||||
"target": "es2023",
|
||||
"composite": false,
|
||||
"declaration": false,
|
||||
"declarationMap": false,
|
||||
"emitDeclarationOnly": false,
|
||||
"noEmit": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noUnusedLocals": true,
|
||||
"strict": true,
|
||||
"baseUrl": "../..",
|
||||
"paths": {
|
||||
"@storyteller/api/*": ["libs/api/src/lib/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
@@ -1,4 +1,21 @@
|
||||
import { StorytellerApiHostStore } from "./config/StorytellerApiHostStore.js";
|
||||
|
||||
// TODO(bt,2025-05-15): Remove this code
|
||||
const PRODUCTION_CDN_ORIGIN = "https://cdn-2.fakeyou.com";
|
||||
|
||||
/**
|
||||
* Origin that media bucket paths are resolved against.
|
||||
*
|
||||
* When the API host is a local one, media is served by that same process
|
||||
* (fake-storyteller-web, or a local backend writing to a filesystem bucket)
|
||||
* rather than by the CDN, so the API host is the correct origin. Any other
|
||||
* host — including every production and staging build — keeps the CDN.
|
||||
*/
|
||||
export function GetCdnOrigin(): string {
|
||||
return "https://cdn-2.fakeyou.com";
|
||||
const apiSchemeAndHost = StorytellerApiHostStore.getInstance().getApiSchemeAndHost();
|
||||
return isLocalHost(apiSchemeAndHost) ? apiSchemeAndHost : PRODUCTION_CDN_ORIGIN;
|
||||
}
|
||||
|
||||
function isLocalHost(url: string): boolean {
|
||||
return url.includes("//localhost") || url.includes("//127.0.0.1");
|
||||
}
|
||||
|
||||
@@ -121,6 +121,7 @@
|
||||
"apps/**/*",
|
||||
"libs/**/*",
|
||||
"!apps/artcraft-dashboard",
|
||||
"!apps/artcraft-dashboard/**"
|
||||
"!apps/artcraft-dashboard/**",
|
||||
"!apps/fake-storyteller-web"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user