mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-21 01:12:40 -04:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9ee643bf6 | ||
|
|
07e9a35044 | ||
|
|
0eddb659fa | ||
|
|
17c12836ad | ||
|
|
7654e95636 | ||
|
|
1bb31f42d7 | ||
|
|
bc4b3a6c1f | ||
|
|
13b5e7b5c2 | ||
|
|
4ff5bdf475 | ||
|
|
e1b16cf2de |
+1
-1
@@ -105,5 +105,5 @@ crates/desktop/src-tauri/*.dll
|
||||
windows_dlls/
|
||||
|
||||
|
||||
# Portable local dev stack (script/bootstrap/windows) - MySQL/Redis binaries, data, logs
|
||||
# Portable local dev stack (script/bootstrap) - MySQL/Redis binaries, data, logs
|
||||
.devstack/
|
||||
|
||||
Generated
+1
-31
@@ -34,29 +34,6 @@ dependencies = [
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "actix-files"
|
||||
version = "0.6.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df8c4f30e3272d7c345f88ae0aac3848507ef5ba871f9cc2a41c8085a0f0523b"
|
||||
dependencies = [
|
||||
"actix-http",
|
||||
"actix-service",
|
||||
"actix-utils",
|
||||
"actix-web",
|
||||
"bitflags 2.11.0",
|
||||
"bytes",
|
||||
"derive_more 2.0.1",
|
||||
"futures-core",
|
||||
"http-range",
|
||||
"log",
|
||||
"mime",
|
||||
"mime_guess",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"v_htmlescape",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "actix-http"
|
||||
version = "3.12.1"
|
||||
@@ -7696,7 +7673,7 @@ version = "0.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "681030a937600a36906c185595136d26abfebb4aa9c65701cefcaf8578bb982b"
|
||||
dependencies = [
|
||||
"proc-macro-crate 3.5.0",
|
||||
"proc-macro-crate 2.0.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
@@ -11600,7 +11577,6 @@ checksum = "ef5430c8e36b713e13b48a9f709cc21e046723fe44ce34587b73a830203b533e"
|
||||
name = "storyteller-web"
|
||||
version = "0.0.1"
|
||||
dependencies = [
|
||||
"actix-files",
|
||||
"actix-http",
|
||||
"actix-multipart",
|
||||
"actix-service",
|
||||
@@ -13918,12 +13894,6 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "v_htmlescape"
|
||||
version = "0.15.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e8257fbc510f0a46eb602c10215901938b5c2a7d5e70fc11483b1d3c9b5b18c"
|
||||
|
||||
[[package]]
|
||||
name = "value-bag"
|
||||
version = "1.12.0"
|
||||
|
||||
@@ -307,7 +307,6 @@ worldlabs_consumer_client = { path = "crates/api_clients/worldlabs_consumer_clie
|
||||
# Centrally pinned versions so that we can uniformly upgrade/downgrade versions across the workspace
|
||||
# If a crate needs to use an older/newer version, the dependency should be managed in that crate's own Cargo.toml
|
||||
actix-cors = "0.7.1"
|
||||
actix-files = "0.6.9"
|
||||
actix-http = "3.12.1"
|
||||
actix-multipart = "0.7.2"
|
||||
actix-rt = "2.11.0"
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
# Local Dev Stack — Requirements & Bootstrap
|
||||
|
||||
How to run the full ArtCraft dev stack **entirely locally** — the
|
||||
`storyteller-web` backend plus the `artcraft-webapp` frontend — with no traffic
|
||||
to production servers. Scope: webapp-first; the Tauri desktop app is a later
|
||||
phase.
|
||||
How to run `storyteller-web` on your own machine against a local MySQL and
|
||||
Redis — no shared dev database, no Docker, no admin rights. This is for **work
|
||||
on the backend itself**.
|
||||
|
||||
> **Working on the frontend?** You probably want
|
||||
> `frontend/apps/fake-storyteller-web` instead — an in-memory stand-in for this
|
||||
> API that needs no database, no build, and no setup. Everything below is
|
||||
> unnecessary for webapp work. Come back here when you need the real server.
|
||||
|
||||
These scripts install and supervise infrastructure. They change no application
|
||||
behaviour, and nothing in them is compiled into any binary: the backend they
|
||||
start is the same one that ships. Integrations they cannot provide locally
|
||||
(object storage, generation providers, email) get placeholder credentials, so
|
||||
the endpoints that use them fail when exercised. Swap in a real dev credential
|
||||
for whichever one you're actually working on.
|
||||
|
||||
The automated path on **Windows** (primary platform, native — no WSL, no
|
||||
Docker, no admin rights for the stack itself):
|
||||
@@ -12,7 +23,8 @@ Docker, no admin rights for the stack itself):
|
||||
.\script\bootstrap\windows\bootstrap_dev_stack.ps1 # one-time setup, idempotent, re-run anytime
|
||||
.\script\bootstrap\windows\run_backend_dev.ps1 # terminal 1: API on http://localhost:12345
|
||||
.\script\bootstrap\windows\seed_demo_user.ps1 # terminal 2: create demo login (localdev1 / localdev1pass)
|
||||
cd frontend; npx nx dev artcraft-webapp # terminal 2: webapp on http://localhost:4201
|
||||
$env:VITE_DEV_API_HOST = "http://localhost:12345" # terminal 2: aim the webapp at your backend
|
||||
cd frontend; npx nx dev artcraft-webapp # webapp on http://localhost:4201
|
||||
.\script\bootstrap\windows\dev_stack_doctor.ps1 # health check, any time
|
||||
.\script\bootstrap\windows\stop_dev_services.ps1 # stop MySQL/Redis when done
|
||||
```
|
||||
@@ -23,7 +35,7 @@ The equivalent on **Linux** (Ubuntu 22.04+ — native, WSL2, or CI):
|
||||
./script/bootstrap/bootstrap_dev_stack.sh # one-time setup, idempotent, re-run anytime
|
||||
./script/bootstrap/run_backend_dev.sh # terminal 1: API on http://localhost:12345
|
||||
./script/bootstrap/seed_demo_user.sh # terminal 2: create demo login (localdev1 / localdev1pass)
|
||||
cd frontend && npx nx dev artcraft-webapp # terminal 2: webapp on http://localhost:4201
|
||||
cd frontend && VITE_DEV_API_HOST=http://localhost:12345 npx nx dev artcraft-webapp # terminal 2: webapp on :4201
|
||||
./script/bootstrap/dev_stack_doctor.sh # health check, any time
|
||||
```
|
||||
|
||||
@@ -32,11 +44,11 @@ black box, and corrects some stale claims in older docs.
|
||||
|
||||
## Supported platforms
|
||||
|
||||
| Platform | Status |
|
||||
|---------------------|---------------------------------------------------------------------------|
|
||||
| Windows 10/11 | Primary. `script/bootstrap/windows/*.ps1` run a **portable stack**: MySQL 8.4 (official zip) and Redis live under the gitignored `.devstack/` and run as plain user processes — no admin, no services, no WSL, no Docker. Build tools (cmake, perl, nasm, llvm) are offered via winget |
|
||||
| Ubuntu 22.04+ | Supported by `script/bootstrap/*.sh` (native, WSL2, or CI runner/container); services via apt |
|
||||
| macOS | Not covered by the scripts; follow `_docs/dev_setup_server.md` manually |
|
||||
| Platform | Status |
|
||||
|---------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| Windows 10/11 | Primary. `script/bootstrap/windows/*.ps1` run a **portable stack**: MySQL 8.4 (official zip) and Redis live under the gitignored `.devstack/` and run as plain user processes — no admin, no services, no WSL, no Docker. Build tools (cmake, perl, nasm, llvm) are offered via winget |
|
||||
| Ubuntu 22.04+ | Supported by `script/bootstrap/*.sh` (native, WSL2, or CI runner/container); services via apt |
|
||||
| macOS | Not covered by the scripts; follow `_docs/dev_setup_server.md` manually |
|
||||
|
||||
### Windows specifics
|
||||
|
||||
@@ -60,17 +72,17 @@ black box, and corrects some stale claims in older docs.
|
||||
|
||||
## Requirements matrix
|
||||
|
||||
| Requirement | Version / detail | Needed for |
|
||||
|-------------------|--------------------------------------------------|---------------------------------------------------|
|
||||
| MySQL server | 8.x series (9.x unsupported by tooling) | Backend boot (eager connect) + migrations |
|
||||
| Redis server | any recent | Backend boot (r2d2 pool connects eagerly) |
|
||||
| Rust (stable) | no pinned MSRV; Docker builds use 1.93.0 | Building `storyteller-web`, diesel_cli |
|
||||
| diesel_cli | `cargo install diesel_cli --no-default-features --features mysql,sqlite` | Running the 281 migrations |
|
||||
| Node.js | 20+ (enforced by `script/common/frontend_preflight.sh`) | Frontend (Nx 21 / Vite 6) |
|
||||
| npm | ships with Node; **pnpm must not be used** | Frontend install (`frontend/package-lock.json`) |
|
||||
| Requirement | Version / detail | Needed for |
|
||||
|-------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------|
|
||||
| MySQL server | 8.x series (9.x unsupported by tooling) | Backend boot (eager connect) + migrations |
|
||||
| Redis server | any recent | Backend boot (r2d2 pool connects eagerly) |
|
||||
| Rust (stable) | no pinned MSRV; Docker builds use 1.93.0 | Building `storyteller-web`, diesel_cli |
|
||||
| diesel_cli | `cargo install diesel_cli --no-default-features --features mysql,sqlite` | Running the 281 migrations |
|
||||
| Node.js | 20+ (enforced by `script/common/frontend_preflight.sh`) | Frontend (Nx 21 / Vite 6) |
|
||||
| npm | ships with Node; **pnpm must not be used** | Frontend install (`frontend/package-lock.json`) |
|
||||
| System libs (apt) | build-essential, cmake, libssl-dev, libclang-dev, libfontconfig1-dev, fontconfig, perl, pkg-config, ffmpeg, libmysqlclient-dev, libsqlite3-dev, curl, jq | Compiling the workspace + diesel_cli; seeding scripts |
|
||||
| Elasticsearch | optional (7.x) | Only 4 search endpoints; server boots without it |
|
||||
| Docker | **not required** — no docker-compose exists | — |
|
||||
| Elasticsearch | optional (7.x) | Only 4 search endpoints; server boots without it |
|
||||
| Docker | **not required** — no docker-compose exists | — |
|
||||
|
||||
Database identity (used consistently by docs, `.env`, and defaults):
|
||||
database `storyteller`, user `storyteller`, password `password`, host
|
||||
@@ -105,11 +117,11 @@ are cwd-relative.
|
||||
|
||||
### Hard requirements at boot
|
||||
|
||||
| Requirement | Behavior if missing |
|
||||
|----------------------------------|--------------------------------------------------------|
|
||||
| MySQL reachable via `MYSQL_URL` | Boot fails (sqlx pool connects eagerly; the DB must exist and be migrated — health-check and ban-poller threads query immediately) |
|
||||
| Redis reachable via `REDIS_0_URL` (default `redis://localhost/0`) | Boot fails (r2d2 pool connects eagerly) |
|
||||
| ~30 `get_env_string_required` vars present | Boot fails on the first absent var |
|
||||
| Requirement | Behavior if missing |
|
||||
|-------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| MySQL reachable via `MYSQL_URL` | Boot fails (sqlx pool connects eagerly; the DB must exist and be migrated — health-check and ban-poller threads query immediately) |
|
||||
| Redis reachable via `REDIS_0_URL` (default `redis://localhost/0`) | Boot fails (r2d2 pool connects eagerly) |
|
||||
| ~30 `get_env_string_required` vars present | Boot fails on the first absent var |
|
||||
|
||||
Of the required-present vars, 19 are supplied by **no** checked-in file and
|
||||
live in the generated secrets file: `ACCESS_KEY`, `SECRET_KEY`, `REGION_NAME`,
|
||||
@@ -128,34 +140,32 @@ were wrong and are corrected:
|
||||
|
||||
- **The omni_gen endpoints call providers synchronously in the HTTP handler**
|
||||
(`/v1/omni_gen/generate/{image,video}` — the path the webapp uses). With
|
||||
dummy keys the provider call fails and the submit returns HTTP 500 *after*
|
||||
billing the wallet (the image pipeline has no refund; video refunds only
|
||||
Kinovi). Only the legacy polling providers (GmiCloud, Grok, Kinovi orders,
|
||||
WorldLabs) are worker-driven. `DEV_FAKE_GENERATION` (below) exists so local
|
||||
submits never reach a real provider.
|
||||
- **Uploads would fail hard with dummy S3 creds** (every media upload
|
||||
endpoint uploads to the bucket *before* inserting the DB row, and `rust-s3`
|
||||
is built with `fail-on-err`) — which is why `LOCAL_MEDIA_ROOT` (below) also
|
||||
switches the bucket clients into a local filesystem mode: uploads write
|
||||
under `.devstack/media/` and are immediately viewable via the same `/media`
|
||||
mount. No S3/R2/MinIO needed.
|
||||
placeholder keys the provider call fails and the submit returns HTTP 500
|
||||
*after* billing the wallet (the image pipeline has no refund; video refunds
|
||||
only Kinovi). Only the legacy polling providers (GmiCloud, Grok, Kinovi
|
||||
orders, WorldLabs) are worker-driven.
|
||||
- **Uploads fail hard with placeholder S3 creds** — every media upload endpoint
|
||||
uploads to the bucket *before* inserting the DB row, and `rust-s3` is built
|
||||
with `fail-on-err`.
|
||||
|
||||
### Fully-local generation and media (`DEV_FAKE_GENERATION`)
|
||||
### What does and doesn't work locally
|
||||
|
||||
The generated secrets file enables three development-only settings (appended
|
||||
automatically on bootstrap re-runs for older stacks):
|
||||
| Area | Works with placeholder credentials? |
|
||||
|----------------------------------------------|-------------------------------------|
|
||||
| Login, sessions, account, profile | Yes |
|
||||
| Wallets, credits, billing reads | Yes |
|
||||
| Folders, tags, prompts, characters, projects | Yes |
|
||||
| Media listing (rows) | Yes — but the images 404, see below |
|
||||
| Media uploads | No — needs real R2/S3 credentials |
|
||||
| Image/video/mesh/splat generation | No — needs a real provider key |
|
||||
| Transactional email | No — needs a real Resend key |
|
||||
| Elasticsearch-backed search | No — needs a local ES + reindex |
|
||||
|
||||
| Env var | Effect |
|
||||
|-----------------------|----------------------------------------------------------------------------|
|
||||
| `DEV_FAKE_GENERATION` | omni_gen image/video submits skip the external provider and insert a normal **pending** job with a `fake_…` external id. A resolver thread inside `storyteller-web` completes each one after `DEV_FAKE_GENERATION_RESOLVE_SECS` (default 6s, poll every `DEV_FAKE_GENERATION_POLL_SECS`, default 2s) with a placeholder result copied from `test_data/`. Prompts containing `simulate_artcraft_failure` (or `test_artcraft_failure`) fail instead, optionally with a `FrontendFailureCategory` word in the prompt (e.g. `simulate_artcraft_failure face_not_detected`). Hard-disabled outside `SERVER_ENVIRONMENT=Development` |
|
||||
| `CDN_BASE_URL` | Overrides the compiled-in CDN hosts in `cdn_link.rs` for all media URLs in API responses. Must be scheme+host only (no path) — set to `http://localhost:12345` so media resolves against the backend itself |
|
||||
| `LOCAL_MEDIA_ROOT` | Directory the backend serves at `/media` (dev-only actix-files mount), mirroring the public bucket layout: object path `/media/{…}` lives at `{LOCAL_MEDIA_ROOT}/media/{…}`. Also switches all three `LegacyBucketClient`s (public/private/gc) into a **local filesystem mode**, so every media upload endpoint writes here instead of S3/R2 — uploads work with dummy cloud creds. The bootstrap points it at `.devstack/media/` |
|
||||
|
||||
With all three set, the full loop works offline: submit → pending job →
|
||||
polling (`/v1/jobs/session`) → success with a `media_files` row whose URL the
|
||||
backend itself serves — or a failed job with a real failure category. Credits
|
||||
are genuinely deducted from the seeded wallet (no refund on fake results —
|
||||
the demo wallet is large enough not to care).
|
||||
The rows-but-no-images case is the one that surprises people: `media_files`
|
||||
rows carry a bucket path, and the URL the API builds from it points at the
|
||||
shared R2 bucket, which does not have your local objects. Put real dev R2
|
||||
credentials in the secrets file if you need media to render, or use
|
||||
`fake-storyteller-web`, which serves its own bytes.
|
||||
|
||||
### Auth on localhost
|
||||
|
||||
@@ -205,12 +215,9 @@ the demo wallet is large enough not to care).
|
||||
credits, plus matching ledger rows. Idempotent: re-runs top the balance
|
||||
back up to `DEMO_CREDITS` but never lower it. (Raw SQL, because the only
|
||||
real credit-granting paths are Stripe webhooks and moderator tooling.)
|
||||
- Demo gallery: `seed_demo_user` also seeds a handful of `media_files` image
|
||||
rows (from `test_data/image/`) with their objects copied under
|
||||
`.devstack/media/`, so the library isn't empty on a fresh stack. Rows mirror
|
||||
what a real image upload writes (`media_class='image'` matters — the
|
||||
library filters on it, and the column default `'unknown'` is invisible in
|
||||
every tab).
|
||||
- Demo gallery: **not seeded**. Media rows without reachable bytes render as
|
||||
broken tiles, so seeding them locally would be worse than an empty library.
|
||||
Use `fake-storyteller-web` for a populated one.
|
||||
|
||||
## Frontend: `artcraft-webapp`
|
||||
|
||||
@@ -221,13 +228,13 @@ the demo wallet is large enough not to care).
|
||||
pre-build is needed in dev (libs resolve to TS source via the
|
||||
`"development"` export condition); production `nx build` does need lib
|
||||
builds.
|
||||
- **In dev the webapp already targets the local backend**: `src/main.tsx`
|
||||
calls `StorytellerApiHostStore.setDevelopment()` under
|
||||
`import.meta.env.DEV`, pointing all API traffic at
|
||||
`http://localhost:12345`. (The adjacent comment claiming this is disabled is
|
||||
stale — the call is live, and it bypasses the Vite `/v1` →
|
||||
`api.storyteller.ai` proxy.) So: local backend running → fully local stack;
|
||||
local backend **not** running → dev webapp is broken, not silently on prod.
|
||||
- **By default the dev webapp targets production.** `src/main.tsx` points the
|
||||
API host at the Vite dev origin, and Vite proxies `/v1` to
|
||||
`https://api.storyteller.ai`. The adjacent `setDevelopment()` call — which
|
||||
would point it at `localhost:12345` — is commented out. To aim the webapp at
|
||||
your local backend, set `VITE_DEV_API_HOST=http://localhost:12345` before
|
||||
`nx dev` (that override ships with the `fake-storyteller-web` change; until
|
||||
it lands, uncomment `setDevelopment()` locally instead).
|
||||
- Optional: `VITE_GOOGLE_CLIENT_ID` in `frontend/apps/artcraft-webapp/.env`
|
||||
enables the Google SSO button; email/password login needs nothing.
|
||||
- Other dev ports if you run more apps: website 4200, dashboard 4202 (separate
|
||||
@@ -236,16 +243,16 @@ the demo wallet is large enough not to care).
|
||||
|
||||
## What still touches production (known, deliberate seams)
|
||||
|
||||
Cleaning these up is the next phase of the infra work; the bootstrap does not
|
||||
change runtime behavior:
|
||||
The bootstrap changes no runtime behaviour, so these remain open. Each is
|
||||
closed by supplying a real credential, not by a code path:
|
||||
|
||||
| Seam | Today |
|
||||
|-----------------------------------------------|--------------------------------------------------------------|
|
||||
| Media/CDN URLs in API responses | **Closed** when `CDN_BASE_URL` + `LOCAL_MEDIA_ROOT` are set (the bootstrap default): media URLs point at the backend's own `/media` mount. Unset, the compiled-in `cdn_link.rs` constants still point at the public `pub-….r2.dev` bucket |
|
||||
| Media uploads | **Closed** when `LOCAL_MEDIA_ROOT` is set: the bucket clients run in local filesystem mode and uploads land under `.devstack/media/`. Unset (or non-Development), uploads still 500 with dummy creds |
|
||||
| Vite `/v1` proxy in webapp | **Closed**: now targets `http://localhost:12345` (it was already bypassed in dev by `setDevelopment()`, but the fallback could silently hit production). The `/__cdn` dev proxy also no longer intercepts localhost media (it assumed https and broke pagescene GLB/splat loads against a local backend) |
|
||||
| `GetCdnOrigin` (pagescene asset thumbnails) | **Closed** for local dev: returns the API host when it's localhost (the local backend serves media itself), production CDN otherwise; the pagescene adapter also skips the Cloudflare `/cdn-cgi/image` resize path on localhost. `enums/Api.ts` `API_TARGETS` and `SegmentationApi` still hardcode production hosts but are dead code (not exported/reachable from the webapp); moodboard never had a hardcoded host — it consumes `media_links` directly |
|
||||
| Elasticsearch-backed search | `search_featured` / `search_session` media files, `weights/search`, legacy `tts/search` fail without a local ES + `elasticsearch-cli` reindex (optional) |
|
||||
| Seam | Today |
|
||||
|---------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| Media/CDN URLs in API responses | The compiled-in `cdn_link.rs` constants point at the public `pub-….r2.dev` bucket, so a locally-created row's media URL resolves to an object that isn't there |
|
||||
| Media uploads | 500 with placeholder credentials; supply real dev R2/S3 keys to exercise them |
|
||||
| Generation | 500 with placeholder keys, *after* the wallet is billed; supply a real key for the provider under test |
|
||||
| Vite `/v1` proxy in webapp | Targets `https://api.storyteller.ai`, so a dev webapp with no host override reads and writes production |
|
||||
| Elasticsearch-backed search | `search_featured` / `search_session` media files, `weights/search`, legacy `tts/search` fail without a local ES + `elasticsearch-cli` reindex (optional) |
|
||||
|
||||
## CI usage
|
||||
|
||||
@@ -267,18 +274,10 @@ export MYSQL_ROOT_PASSWORD=root # GitHub Actions' preinstalled MySQL uses pass
|
||||
- `./script/bootstrap/dev_stack_doctor.sh` exits 0 with no FAILs.
|
||||
- `POST /v1/login` with the demo user returns `success: true` plus a
|
||||
`signed_session` (done automatically by `seed_demo_user.sh`).
|
||||
- Webapp at `http://localhost:4201` can log in as `localdev1`, has credits in
|
||||
the top bar, and sees the seeded images in the library.
|
||||
- A generation submit returns `{"success": true, "inference_job_token": …}`,
|
||||
shows as pending for a few seconds, then completes with a placeholder
|
||||
result (`DEV_FAKE_GENERATION`) — no external provider is contacted. A
|
||||
prompt containing `simulate_artcraft_failure` fails with a real error
|
||||
toast/category instead.
|
||||
- Uploading an image (e.g. a reference image in the prompt box) succeeds and
|
||||
the file lands under `.devstack/media/` — no S3/R2 credentials involved.
|
||||
- Nothing hits `api.storyteller.ai` or the r2.dev CDN during the above
|
||||
(verify in the browser network tab — media URLs should point at
|
||||
`http://localhost:12345/media/…`).
|
||||
- `GET /_status` returns 200, and MySQL has the full migration set applied.
|
||||
- With `VITE_DEV_API_HOST` pointed at `localhost:12345`, the webapp logs in as
|
||||
`localdev1` and shows its credits in the top bar. The library is empty and
|
||||
generation 500s — both expected, see the table above.
|
||||
|
||||
## Corrections to older docs
|
||||
|
||||
|
||||
@@ -43,6 +43,8 @@ pub enum RouterImageModel {
|
||||
Seedream5Lite,
|
||||
#[serde(rename = "seedream_5p0_pro")]
|
||||
Seedream5p0Pro,
|
||||
#[serde(rename = "seedream_5p0_pro_u")]
|
||||
Seedream5p0ProUltra,
|
||||
#[serde(rename = "qwen_edit_2511_angles")]
|
||||
QwenEdit2511Angles,
|
||||
#[serde(rename = "flux_2_lora_angles")]
|
||||
@@ -80,4 +82,9 @@ mod tests {
|
||||
fn seedream_5p0_pro_serializes_to_seedream_5p0_pro() {
|
||||
assert_round_trip(RouterImageModel::Seedream5p0Pro, "seedream_5p0_pro");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seedream_5p0_pro_ultra_serializes_to_seedream_5p0_pro_u() {
|
||||
assert_round_trip(RouterImageModel::Seedream5p0ProUltra, "seedream_5p0_pro_u");
|
||||
}
|
||||
}
|
||||
|
||||
+3
@@ -27,6 +27,7 @@ use crate::generate::generate_image::providers::artcraft::seedream_4::build::bui
|
||||
use crate::generate::generate_image::providers::artcraft::seedream_4p5::build::build_artcraft_seedream_4p5;
|
||||
use crate::generate::generate_image::providers::artcraft::seedream_5_lite::build::build_artcraft_seedream_5_lite;
|
||||
use crate::generate::generate_image::providers::artcraft::seedream_5p0_pro::build::build_artcraft_seedream_5p0_pro;
|
||||
use crate::generate::generate_image::providers::artcraft::seedream_5p0_pro_ultra::build::build_artcraft_seedream_5p0_pro_ultra;
|
||||
use crate::generate::generate_image::providers::fal::flux_1_dev::build::build_fal_flux_1_dev;
|
||||
use crate::generate::generate_image::providers::fal::flux_1_schnell::build::build_fal_flux_1_schnell;
|
||||
use crate::generate::generate_image::providers::fal::flux_2_lora_angles::build::build_fal_flux_2_lora_angles;
|
||||
@@ -122,6 +123,7 @@ impl GenerateImageRequestBuilder {
|
||||
(RouterProvider::Artcraft, RouterImageModel::Seedream4p5) => build_artcraft_seedream_4p5(self),
|
||||
(RouterProvider::Artcraft, RouterImageModel::Seedream5Lite) => build_artcraft_seedream_5_lite(self),
|
||||
(RouterProvider::Artcraft, RouterImageModel::Seedream5p0Pro) => build_artcraft_seedream_5p0_pro(self),
|
||||
(RouterProvider::Artcraft, RouterImageModel::Seedream5p0ProUltra) => build_artcraft_seedream_5p0_pro_ultra(self),
|
||||
(RouterProvider::Artcraft, RouterImageModel::QwenEdit2511Angles) => build_artcraft_qwen_edit_2511_angles(self),
|
||||
(RouterProvider::Artcraft, RouterImageModel::Flux2LoraAngles) => build_artcraft_flux_2_lora_angles(self),
|
||||
(RouterProvider::Artcraft, RouterImageModel::Midjourney7) => build_artcraft_midjourney_7(self),
|
||||
@@ -149,6 +151,7 @@ impl GenerateImageRequestBuilder {
|
||||
(RouterProvider::Seedance2Pro, RouterImageModel::Midjourney7Niji) => build_kinovi_midjourney_7_niji(self),
|
||||
(RouterProvider::Seedance2Pro, RouterImageModel::Midjourney8) => build_kinovi_midjourney_8(self),
|
||||
(RouterProvider::Seedance2Pro, RouterImageModel::Seedream5p0Pro) => build_kinovi_seedream_5p0_pro(self),
|
||||
(RouterProvider::Seedance2Pro, RouterImageModel::Seedream5p0ProUltra) => build_kinovi_seedream_5p0_pro(self),
|
||||
|
||||
_ => self.unsupported_provider_and_model(),
|
||||
}
|
||||
|
||||
+11
@@ -41,6 +41,8 @@ use crate::generate::generate_image::providers::artcraft::seedream_5_lite::cost:
|
||||
use crate::generate::generate_image::providers::artcraft::seedream_5_lite::request::ArtcraftSeedream5LiteRequestState;
|
||||
use crate::generate::generate_image::providers::artcraft::seedream_5p0_pro::cost::ArtcraftSeedream5p0ProCostState;
|
||||
use crate::generate::generate_image::providers::artcraft::seedream_5p0_pro::request::ArtcraftSeedream5p0ProRequestState;
|
||||
use crate::generate::generate_image::providers::artcraft::seedream_5p0_pro_ultra::cost::ArtcraftSeedream5p0ProUltraCostState;
|
||||
use crate::generate::generate_image::providers::artcraft::seedream_5p0_pro_ultra::request::ArtcraftSeedream5p0ProUltraRequestState;
|
||||
use crate::generate::generate_image::providers::fal::flux_1_dev::cost::FalFlux1DevCostState;
|
||||
use crate::generate::generate_image::providers::fal::flux_1_dev::request::FalFlux1DevRequestState;
|
||||
use crate::generate::generate_image::providers::fal::flux_1_schnell::cost::FalFlux1SchnellCostState;
|
||||
@@ -97,6 +99,7 @@ pub enum ImageGenerationRequest {
|
||||
ArtcraftSeedream4p5(ArtcraftSeedream4p5RequestState),
|
||||
ArtcraftSeedream5Lite(ArtcraftSeedream5LiteRequestState),
|
||||
ArtcraftSeedream5p0Pro(ArtcraftSeedream5p0ProRequestState),
|
||||
ArtcraftSeedream5p0ProUltra(ArtcraftSeedream5p0ProUltraRequestState),
|
||||
ArtcraftQwenEdit2511Angles(ArtcraftQwenEdit2511AnglesRequestState),
|
||||
ArtcraftFlux2LoraAngles(ArtcraftFlux2LoraAnglesRequestState),
|
||||
ArtcraftMidjourney7(ArtcraftMidjourney7RequestState),
|
||||
@@ -144,6 +147,7 @@ impl ImageGenerationRequest {
|
||||
Self::ArtcraftSeedream4p5(_) => RouterProvider::Artcraft,
|
||||
Self::ArtcraftSeedream5Lite(_) => RouterProvider::Artcraft,
|
||||
Self::ArtcraftSeedream5p0Pro(_) => RouterProvider::Artcraft,
|
||||
Self::ArtcraftSeedream5p0ProUltra(_) => RouterProvider::Artcraft,
|
||||
Self::ArtcraftQwenEdit2511Angles(_) => RouterProvider::Artcraft,
|
||||
Self::ArtcraftFlux2LoraAngles(_) => RouterProvider::Artcraft,
|
||||
Self::ArtcraftMidjourney7(_) => RouterProvider::Artcraft,
|
||||
@@ -218,6 +222,9 @@ impl ImageGenerationRequest {
|
||||
Self::ArtcraftSeedream5p0Pro(request) => {
|
||||
Ok(ArtcraftSeedream5p0ProCostState::from_request(request).estimate_cost())
|
||||
}
|
||||
Self::ArtcraftSeedream5p0ProUltra(request) => {
|
||||
Ok(ArtcraftSeedream5p0ProUltraCostState::from_request(request).estimate_cost())
|
||||
}
|
||||
Self::ArtcraftQwenEdit2511Angles(request) => {
|
||||
Ok(ArtcraftQwenEdit2511AnglesCostState::from_request(request).estimate_cost())
|
||||
}
|
||||
@@ -317,6 +324,10 @@ impl ImageGenerationRequest {
|
||||
let artcraft_client = client.get_artcraft_client_ref()?;
|
||||
request.send(artcraft_client).await
|
||||
}
|
||||
Self::ArtcraftSeedream5p0ProUltra(request) => {
|
||||
let artcraft_client = client.get_artcraft_client_ref()?;
|
||||
request.send(artcraft_client).await
|
||||
}
|
||||
Self::ArtcraftQwenEdit2511Angles(request) => {
|
||||
let artcraft_client = client.get_artcraft_client_ref()?;
|
||||
request.send(artcraft_client).await
|
||||
|
||||
+1
@@ -20,3 +20,4 @@ pub mod seedream_4;
|
||||
pub mod seedream_4p5;
|
||||
pub mod seedream_5_lite;
|
||||
pub mod seedream_5p0_pro;
|
||||
pub mod seedream_5p0_pro_ultra;
|
||||
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
use enums::common::generation::common_image_model::CommonImageModel as CommonImageModelEnum;
|
||||
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder;
|
||||
use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest;
|
||||
use crate::generate::generate_image::image_generation_request::ImageGenerationRequest;
|
||||
use crate::generate::generate_image::providers::artcraft::build_common::build_artcraft_omni_image_request;
|
||||
use crate::generate::generate_image::providers::artcraft::seedream_5p0_pro_ultra::request::ArtcraftSeedream5p0ProUltraRequestState;
|
||||
|
||||
pub fn build_artcraft_seedream_5p0_pro_ultra(
|
||||
builder: GenerateImageRequestBuilder,
|
||||
) -> Result<ImageGenerationDraftOrRequest, ArtcraftRouterError> {
|
||||
let request = build_artcraft_omni_image_request(builder, CommonImageModelEnum::Seedream5p0ProUltra)?;
|
||||
Ok(ImageGenerationDraftOrRequest::Request(
|
||||
ImageGenerationRequest::ArtcraftSeedream5p0ProUltra(ArtcraftSeedream5p0ProUltraRequestState { request }),
|
||||
))
|
||||
}
|
||||
+208
@@ -0,0 +1,208 @@
|
||||
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
|
||||
|
||||
use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate;
|
||||
use crate::generate::generate_image::providers::artcraft::seedream_5p0_pro_ultra::request::ArtcraftSeedream5p0ProUltraRequestState;
|
||||
|
||||
/// Customer-facing price per image at 1k resolution (the default), in USD cents.
|
||||
const USD_CENTS_PER_IMAGE_1K: u64 = 18;
|
||||
|
||||
/// Customer-facing price per image at 2k resolution, in USD cents.
|
||||
const USD_CENTS_PER_IMAGE_2K: u64 = 30;
|
||||
|
||||
/// Cost state for Artcraft Seedream 5.0 Pro Ultra. Pricing is per image and
|
||||
/// resolution-dependent:
|
||||
///
|
||||
/// 1K (default) → 18¢, 2K → 30¢. 3K/4K fall back to 2K pricing; 0.5K and
|
||||
/// legacy video resolutions (480p/720p/1080p) fall back to 1K.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ArtcraftSeedream5p0ProUltraCostState {
|
||||
pub resolution: Option<CommonResolutionEnum>,
|
||||
pub num_images: u16,
|
||||
}
|
||||
|
||||
impl ArtcraftSeedream5p0ProUltraCostState {
|
||||
pub fn from_request(request: &ArtcraftSeedream5p0ProUltraRequestState) -> Self {
|
||||
Self {
|
||||
resolution: request.request.resolution,
|
||||
num_images: request.request.image_batch_count.unwrap_or(1),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn estimate_cost(&self) -> ImageGenerationCostEstimate {
|
||||
let cost_per_image: u64 = cost_per_image_in_cents(self.resolution);
|
||||
let cost_in_usd_cents = cost_per_image * self.num_images as u64;
|
||||
ImageGenerationCostEstimate {
|
||||
cost_in_credits: Some(cost_in_usd_cents),
|
||||
cost_in_usd_cents: Some(cost_in_usd_cents),
|
||||
is_free: false,
|
||||
is_unlimited: false,
|
||||
is_rate_limited: false,
|
||||
has_watermark: false,
|
||||
failures_are_refunded: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn cost_per_image_in_cents(resolution: Option<CommonResolutionEnum>) -> u64 {
|
||||
use CommonResolutionEnum as R;
|
||||
match resolution {
|
||||
None
|
||||
| Some(R::HalfK)
|
||||
| Some(R::OneK)
|
||||
| Some(R::FourEightyP)
|
||||
| Some(R::SevenTwentyP)
|
||||
| Some(R::TenEightyP) => USD_CENTS_PER_IMAGE_1K,
|
||||
Some(R::TwoK) | Some(R::ThreeK) | Some(R::FourK) => USD_CENTS_PER_IMAGE_2K,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::api::router_image_model::RouterImageModel;
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
|
||||
use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder;
|
||||
|
||||
fn estimate(resolution: Option<RouterResolution>, image_batch_count: u16) -> ImageGenerationCostEstimate {
|
||||
let builder = GenerateImageRequestBuilder {
|
||||
model: RouterImageModel::Seedream5p0ProUltra,
|
||||
provider: RouterProvider::Artcraft,
|
||||
prompt: Some("an anime girl riding a dinosaur".to_string()),
|
||||
image_inputs: None,
|
||||
resolution,
|
||||
aspect_ratio: None,
|
||||
quality: None,
|
||||
image_batch_count: Some(image_batch_count),
|
||||
horizontal_angle: None,
|
||||
vertical_angle: None,
|
||||
zoom: None,
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
|
||||
generation_mode_mismatch_strategy: None,
|
||||
idempotency_token: None,
|
||||
};
|
||||
builder.build2().unwrap().estimate_cost().unwrap()
|
||||
}
|
||||
|
||||
fn cost_cents(resolution: Option<RouterResolution>, image_batch_count: u16) -> u64 {
|
||||
estimate(resolution, image_batch_count).cost_in_usd_cents.unwrap()
|
||||
}
|
||||
|
||||
// ── Default / 1K (18¢) ──
|
||||
|
||||
#[test]
|
||||
fn default_resolution_one_image_is_18c() { assert_eq!(cost_cents(None, 1), 18); }
|
||||
|
||||
#[test]
|
||||
fn one_k_one_image_is_18c() { assert_eq!(cost_cents(Some(RouterResolution::OneK), 1), 18); }
|
||||
|
||||
#[test]
|
||||
fn one_k_four_images_is_72c() { assert_eq!(cost_cents(Some(RouterResolution::OneK), 4), 72); }
|
||||
|
||||
// ── 2K (30¢) ──
|
||||
|
||||
#[test]
|
||||
fn two_k_one_image_is_30c() { assert_eq!(cost_cents(Some(RouterResolution::TwoK), 1), 30); }
|
||||
|
||||
#[test]
|
||||
fn two_k_four_images_is_120c() { assert_eq!(cost_cents(Some(RouterResolution::TwoK), 4), 120); }
|
||||
|
||||
// ── Fallbacks ──
|
||||
|
||||
#[test]
|
||||
fn four_k_falls_back_to_two_k_pricing() {
|
||||
assert_eq!(
|
||||
ArtcraftSeedream5p0ProUltraCostState {
|
||||
resolution: Some(CommonResolutionEnum::FourK),
|
||||
num_images: 1,
|
||||
}.estimate_cost().cost_in_usd_cents,
|
||||
Some(30),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn half_k_falls_back_to_one_k_pricing() {
|
||||
assert_eq!(
|
||||
ArtcraftSeedream5p0ProUltraCostState {
|
||||
resolution: Some(CommonResolutionEnum::HalfK),
|
||||
num_images: 1,
|
||||
}.estimate_cost().cost_in_usd_cents,
|
||||
Some(18),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Credits track USD cents exactly (1 credit = 1 cent) ──
|
||||
|
||||
#[test]
|
||||
fn credits_equal_usd_cents() {
|
||||
for batch in 1..=4u16 {
|
||||
let one_k = estimate(Some(RouterResolution::OneK), batch);
|
||||
assert_eq!(one_k.cost_in_credits, one_k.cost_in_usd_cents, "1k batch {}", batch);
|
||||
let two_k = estimate(Some(RouterResolution::TwoK), batch);
|
||||
assert_eq!(two_k.cost_in_credits, two_k.cost_in_usd_cents, "2k batch {}", batch);
|
||||
}
|
||||
}
|
||||
|
||||
// ── The price must exceed the base Seedream 5.0 Pro price ──
|
||||
|
||||
#[test]
|
||||
fn price_exceeds_base_seedream_5p0_pro_price() {
|
||||
use crate::generate::generate_image::providers::artcraft::seedream_5p0_pro::cost::ArtcraftSeedream5p0ProCostState;
|
||||
|
||||
for resolution in [None, Some(CommonResolutionEnum::OneK), Some(CommonResolutionEnum::TwoK)] {
|
||||
for num_images in [1u16, 4] {
|
||||
let ultra_cents = ArtcraftSeedream5p0ProUltraCostState { resolution, num_images }
|
||||
.estimate_cost().cost_in_usd_cents.unwrap();
|
||||
let base_cents = ArtcraftSeedream5p0ProCostState { resolution, num_images }
|
||||
.estimate_cost().cost_in_usd_cents.unwrap();
|
||||
assert!(
|
||||
ultra_cents > base_cents,
|
||||
"ultra price {}¢ must exceed base price {}¢ ({:?} batch {})",
|
||||
ultra_cents, base_cents, resolution, num_images,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── The Artcraft price must cover the Kinovi provider cost ──
|
||||
|
||||
#[test]
|
||||
fn price_covers_kinovi_cost() {
|
||||
use seedance2pro_client::generate::image::generate_seedream_5p0_pro::{
|
||||
KinoviSeedream5p0ProBatchCount, KinoviSeedream5p0ProResolution,
|
||||
};
|
||||
use crate::generate::generate_image::providers::kinovi::seedream_5p0_pro::cost::KinoviSeedream5p0ProCostState;
|
||||
|
||||
let cases = [
|
||||
(RouterResolution::OneK, KinoviSeedream5p0ProResolution::OneK, 1u16, KinoviSeedream5p0ProBatchCount::One),
|
||||
(RouterResolution::OneK, KinoviSeedream5p0ProResolution::OneK, 4u16, KinoviSeedream5p0ProBatchCount::Four),
|
||||
(RouterResolution::TwoK, KinoviSeedream5p0ProResolution::TwoK, 1u16, KinoviSeedream5p0ProBatchCount::One),
|
||||
(RouterResolution::TwoK, KinoviSeedream5p0ProResolution::TwoK, 4u16, KinoviSeedream5p0ProBatchCount::Four),
|
||||
];
|
||||
for (router_res, kinovi_res, batch, kinovi_batch) in cases {
|
||||
let artcraft_cents = cost_cents(Some(router_res), batch);
|
||||
let kinovi_cents = KinoviSeedream5p0ProCostState {
|
||||
resolution: kinovi_res,
|
||||
batch_count: kinovi_batch,
|
||||
}.estimate_cost().cost_in_usd_cents.unwrap();
|
||||
assert!(
|
||||
artcraft_cents > kinovi_cents,
|
||||
"artcraft price {}¢ must exceed kinovi cost {}¢ ({:?} batch {})",
|
||||
artcraft_cents, kinovi_cents, router_res, batch,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Flags ──
|
||||
|
||||
#[test]
|
||||
fn cost_flags_are_correct() {
|
||||
let cost = ArtcraftSeedream5p0ProUltraCostState { resolution: None, num_images: 1 }.estimate_cost();
|
||||
assert!(!cost.is_free);
|
||||
assert!(!cost.is_unlimited);
|
||||
assert!(!cost.is_rate_limited);
|
||||
assert!(!cost.has_watermark);
|
||||
assert_eq!(cost.failures_are_refunded, None);
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
pub mod build;
|
||||
pub mod cost;
|
||||
pub mod request;
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_image_cost_and_generate_request::OmniGenImageCostAndGenerateRequest;
|
||||
|
||||
use crate::client::router_artcraft_client::RouterArtcraftClient;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::generate::generate_image::generate_image_response::GenerateImageResponse;
|
||||
use crate::generate::generate_image::providers::artcraft::request_common::send_artcraft_omni_image_request;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ArtcraftSeedream5p0ProUltraRequestState {
|
||||
pub request: OmniGenImageCostAndGenerateRequest,
|
||||
}
|
||||
|
||||
impl ArtcraftSeedream5p0ProUltraRequestState {
|
||||
pub async fn send(&self, client: &RouterArtcraftClient) -> Result<GenerateImageResponse, ArtcraftRouterError> {
|
||||
send_artcraft_omni_image_request(&self.request, client).await
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
use std::error::Error;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::anyhow;
|
||||
@@ -18,26 +18,11 @@ use errors::AnyhowResult;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct LegacyBucketClient {
|
||||
backend: LegacyBucketBackend,
|
||||
bucket: Bucket,
|
||||
/// If set, put all files under this root path.
|
||||
optional_bucket_root: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
enum LegacyBucketBackend {
|
||||
S3 {
|
||||
bucket: Bucket,
|
||||
},
|
||||
/// Development-only: objects live on the local filesystem under `root`,
|
||||
/// with the rooted object path (e.g. "/media/a/b/…/file.jpg") mapped
|
||||
/// segment-by-segment beneath it — the same layout the dev server's
|
||||
/// static /media mount serves. Lets the local dev stack run every upload
|
||||
/// endpoint without S3/R2 credentials.
|
||||
LocalDir {
|
||||
root: PathBuf,
|
||||
bucket_name: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum LegacyBucketClientError {
|
||||
@@ -55,6 +40,10 @@ impl Error for LegacyBucketClientError {}
|
||||
|
||||
|
||||
impl LegacyBucketClient {
|
||||
pub fn bucket_name(&self) -> String {
|
||||
self.bucket.name().to_string()
|
||||
}
|
||||
|
||||
pub fn create(
|
||||
access_key: &str,
|
||||
secret_key: &str,
|
||||
@@ -96,34 +85,15 @@ impl LegacyBucketClient {
|
||||
let optional_bucket_root = optional_bucket_root.map(|s| s.to_string());
|
||||
|
||||
Ok(Self {
|
||||
backend: LegacyBucketBackend::S3 {
|
||||
bucket: *bucket, // NB: We don't need to keep this boxed on the heap.
|
||||
},
|
||||
bucket: *bucket, // NB: We don't need to keep this boxed on the heap.
|
||||
optional_bucket_root,
|
||||
})
|
||||
}
|
||||
|
||||
/// Development-only: a "bucket" backed by a local directory (see
|
||||
/// `LegacyBucketBackend::LocalDir`). `bucket_name` is only reported back
|
||||
/// via `bucket_name()`; no cloud service is ever contacted.
|
||||
pub fn create_local(
|
||||
local_root: impl Into<PathBuf>,
|
||||
bucket_name: &str,
|
||||
optional_bucket_root: Option<&str>,
|
||||
) -> Self {
|
||||
Self {
|
||||
backend: LegacyBucketBackend::LocalDir {
|
||||
root: local_root.into(),
|
||||
bucket_name: bucket_name.to_string(),
|
||||
},
|
||||
optional_bucket_root: optional_bucket_root.map(|s| s.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn bucket_name(&self) -> String {
|
||||
match &self.backend {
|
||||
LegacyBucketBackend::S3 { bucket } => bucket.name().to_string(),
|
||||
LegacyBucketBackend::LocalDir { bucket_name, .. } => bucket_name.clone(),
|
||||
fn get_rooted_object_name(&self, object_name: &str) -> String {
|
||||
match &self.optional_bucket_root {
|
||||
None => object_name.to_string(),
|
||||
Some(root) => format!("{}/{}", root, object_name),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,14 +103,7 @@ impl LegacyBucketClient {
|
||||
let object_name = self.get_rooted_object_name(object_name);
|
||||
debug!("Rooted filename for bucket: {}", object_name);
|
||||
|
||||
let bucket = match &self.backend {
|
||||
LegacyBucketBackend::S3 { bucket } => bucket,
|
||||
LegacyBucketBackend::LocalDir { root, .. } => {
|
||||
return write_local_object(root, &object_name, bytes).await;
|
||||
},
|
||||
};
|
||||
|
||||
let response = bucket.put_object(&object_name, bytes).await?;
|
||||
let response = self.bucket.put_object(&object_name, bytes).await?;
|
||||
|
||||
let body_bytes = response.bytes();
|
||||
let code = response.status_code();
|
||||
@@ -161,17 +124,7 @@ impl LegacyBucketClient {
|
||||
info!("Filename for bucket: {}", object_name);
|
||||
let object_name = self.get_rooted_object_name(object_name);
|
||||
info!("Rooted filename for bucket: {}", object_name);
|
||||
|
||||
let bucket = match &self.backend {
|
||||
LegacyBucketBackend::S3 { bucket } => bucket,
|
||||
LegacyBucketBackend::LocalDir { root, .. } => {
|
||||
// Content type is derived from the file extension when the dev
|
||||
// static mount serves it back; nothing to store here.
|
||||
return write_local_object(root, &object_name, bytes).await;
|
||||
},
|
||||
};
|
||||
|
||||
let response = bucket.put_object_with_content_type(&object_name, bytes, content_type).await?;
|
||||
let response = self.bucket.put_object_with_content_type(&object_name, bytes, content_type).await?;
|
||||
let body_bytes = response.bytes();
|
||||
let code = response.status_code();
|
||||
info!("upload code: {}", code);
|
||||
@@ -192,17 +145,10 @@ impl LegacyBucketClient {
|
||||
let object_name = self.get_rooted_object_name(object_name);
|
||||
info!("Rooted filename for bucket: {}", object_name);
|
||||
|
||||
let bucket = match &self.backend {
|
||||
LegacyBucketBackend::S3 { bucket } => bucket,
|
||||
LegacyBucketBackend::LocalDir { root, .. } => {
|
||||
return write_local_object(root, &object_name, bytes).await;
|
||||
},
|
||||
};
|
||||
|
||||
let response = bucket.put_object_with_content_type(&object_name, bytes, content_type).await;
|
||||
let response = self.bucket.put_object_with_content_type(&object_name, bytes, content_type).await;
|
||||
|
||||
if let Err(err) = &response {
|
||||
error!("S3 Upload Error for bucket name {}: {:?}", bucket.name, err);
|
||||
error!("S3 Upload Error for bucket name {}: {:?}", &self.bucket.name, err);
|
||||
}
|
||||
|
||||
let response = response?;
|
||||
@@ -258,23 +204,13 @@ impl LegacyBucketClient {
|
||||
|
||||
info!("Uploading with content type...");
|
||||
|
||||
#[allow(deprecated)]
|
||||
self.upload_file_with_content_type(&object_path_str, &buffer, content_type).await
|
||||
}
|
||||
|
||||
pub async fn download_file(&self, path: &str) -> anyhow::Result<Vec<u8>> {
|
||||
info!("downloading from bucket: {}", path);
|
||||
|
||||
let bucket = match &self.backend {
|
||||
LegacyBucketBackend::S3 { bucket } => bucket,
|
||||
LegacyBucketBackend::LocalDir { root, .. } => {
|
||||
let disk_path = local_object_disk_path(root, path)?;
|
||||
return tokio::fs::read(&disk_path).await
|
||||
.map_err(|err| anyhow!("File not found in local bucket ({}): {}", disk_path.display(), err));
|
||||
},
|
||||
};
|
||||
|
||||
let response = bucket.get_object(path).await?;
|
||||
let response = self.bucket.get_object(path).await?;
|
||||
|
||||
let bytes = response.bytes();
|
||||
let code = response.status_code();
|
||||
@@ -298,34 +234,24 @@ impl LegacyBucketClient {
|
||||
.map(|s| s.to_string())
|
||||
.ok_or(anyhow!("could not convert object path to string"))?;
|
||||
|
||||
let bucket = match &self.backend {
|
||||
LegacyBucketBackend::S3 { bucket } => bucket,
|
||||
LegacyBucketBackend::LocalDir { root, .. } => {
|
||||
let disk_path = local_object_disk_path(root, &object_path_str)?;
|
||||
tokio::fs::copy(&disk_path, filesystem_path.as_ref()).await
|
||||
.map_err(|err| anyhow!("File not found in local bucket ({}): {}", disk_path.display(), err))?;
|
||||
return Ok(());
|
||||
},
|
||||
};
|
||||
|
||||
info!("creating file for bucket download: {:?}", filesystem_path.as_ref());
|
||||
|
||||
let mut output_file = File::create(filesystem_path).await?;
|
||||
|
||||
let result = bucket.get_object_to_writer(&object_path_str, &mut output_file).await;
|
||||
let result = self.bucket.get_object_to_writer(&object_path_str, &mut output_file).await;
|
||||
|
||||
info!("downloading from bucket (named '{}'), path: {}", bucket.name, &object_path_str);
|
||||
info!("downloading from bucket (named '{}'), path: {}", &self.bucket.name, &object_path_str);
|
||||
|
||||
let status_code = match result {
|
||||
Ok(status_code) => status_code,
|
||||
Err(err) => {
|
||||
return bail!("Error downloading from bucket (named '{}'): {:?}", bucket.name, err)
|
||||
return bail!("Error downloading from bucket (named '{}'): {:?}", &self.bucket.name, err)
|
||||
}
|
||||
};
|
||||
|
||||
match status_code {
|
||||
404 => {
|
||||
error!("File not found in bucket (named '{}'), path: {}", bucket.name, &object_path_str);
|
||||
error!("File not found in bucket (named '{}'), path: {}", &self.bucket.name, &object_path_str);
|
||||
bail!("File not found in bucket: {}", &object_path_str)
|
||||
},
|
||||
_ => {
|
||||
@@ -335,70 +261,4 @@ impl LegacyBucketClient {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_rooted_object_name(&self, object_name: &str) -> String {
|
||||
match &self.optional_bucket_root {
|
||||
None => object_name.to_string(),
|
||||
Some(root) => format!("{}/{}", root, object_name),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a rooted object path (e.g. "/media/a/b/…/file.jpg") to a disk path
|
||||
/// under `root`, segment by segment. This is the single source of truth for
|
||||
/// the local-dir layout — the dev server's static /media mount and the fake
|
||||
/// generation resolver must agree with it (disk = root + rooted path).
|
||||
pub fn local_object_disk_path(root: &Path, rooted_object_path: &str) -> anyhow::Result<PathBuf> {
|
||||
let mut path = root.to_path_buf();
|
||||
for segment in rooted_object_path.split('/').filter(|s| !s.is_empty()) {
|
||||
// Object names can come from user-controlled archives (e.g. PMX zip
|
||||
// entries); never let them escape the root.
|
||||
if segment == ".." || segment.contains('\\') {
|
||||
bail!("refusing suspicious object path segment: {}", rooted_object_path);
|
||||
}
|
||||
path.push(segment);
|
||||
}
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
async fn write_local_object(root: &Path, rooted_object_path: &str, bytes: &[u8]) -> anyhow::Result<()> {
|
||||
let disk_path = local_object_disk_path(root, rooted_object_path)?;
|
||||
if let Some(parent) = disk_path.parent() {
|
||||
tokio::fs::create_dir_all(parent).await?;
|
||||
}
|
||||
tokio::fs::write(&disk_path, bytes).await?;
|
||||
info!("Wrote local bucket object: {}", disk_path.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
mod local_object_disk_path_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn maps_rooted_path_under_root() {
|
||||
let path = local_object_disk_path(Path::new("/data"), "/media/a/b/file.jpg").unwrap();
|
||||
assert_eq!(path, Path::new("/data").join("media").join("a").join("b").join("file.jpg"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_leading_slash_and_empty_segments() {
|
||||
let with_slash = local_object_disk_path(Path::new("/data"), "/media//x").unwrap();
|
||||
let without_slash = local_object_disk_path(Path::new("/data"), "media/x").unwrap();
|
||||
assert_eq!(with_slash, without_slash);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_parent_dir_traversal() {
|
||||
assert!(local_object_disk_path(Path::new("/data"), "/media/../../etc/passwd").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_backslash_segments() {
|
||||
assert!(local_object_disk_path(Path::new("/data"), "/media/a\\b").is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -55,6 +55,7 @@ fn image_model_to_common_model_type(model: RouterImageModel) -> Option<CommonMod
|
||||
RouterImageModel::Seedream4p5 => Some(CommonModelType::Seedream4p5),
|
||||
RouterImageModel::Seedream5Lite => Some(CommonModelType::Seedream5Lite),
|
||||
RouterImageModel::Seedream5p0Pro => Some(CommonModelType::Seedream5p0Pro),
|
||||
RouterImageModel::Seedream5p0ProUltra => Some(CommonModelType::Seedream5p0ProUltra),
|
||||
RouterImageModel::QwenEdit2511Angles => Some(CommonModelType::QwenEdit2511Angles),
|
||||
RouterImageModel::Flux2LoraAngles => Some(CommonModelType::Flux2LoraAngles),
|
||||
RouterImageModel::GrokImagineImage => Some(CommonModelType::GrokImagineImage),
|
||||
|
||||
+8
-2
@@ -83,7 +83,8 @@ pub async fn insert_generic_inference_job_for_seedance2pro_queue_with_apriori_jo
|
||||
Some(CommonModelType::Midjourney7) |
|
||||
Some(CommonModelType::Midjourney7Niji) |
|
||||
Some(CommonModelType::Midjourney8) |
|
||||
Some(CommonModelType::Seedream5p0Pro) => true,
|
||||
Some(CommonModelType::Seedream5p0Pro) |
|
||||
Some(CommonModelType::Seedream5p0ProUltra) => true,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
@@ -111,7 +112,12 @@ pub async fn insert_generic_inference_job_for_seedance2pro_queue_with_apriori_jo
|
||||
InferenceJobExternalThirdParty::Seedance2ProAlt,
|
||||
InferenceJobProductCategory::Seedance2ProVideoAlt,
|
||||
),
|
||||
(KinoviVersion::BytePlusUltra, _) => (
|
||||
(KinoviVersion::BytePlusUltra, true) => (
|
||||
InferenceJobType::Seedance2ProBytePlusUltraQueue,
|
||||
InferenceJobExternalThirdParty::Seedance2ProBytePlusUltra,
|
||||
InferenceJobProductCategory::Seedance2ProImageBytePlusUltra,
|
||||
),
|
||||
(KinoviVersion::BytePlusUltra, false) => (
|
||||
InferenceJobType::Seedance2ProBytePlusUltraQueue,
|
||||
InferenceJobExternalThirdParty::Seedance2ProBytePlusUltra,
|
||||
InferenceJobProductCategory::Seedance2ProVideoBytePlusUltra,
|
||||
|
||||
-64
@@ -1,64 +0,0 @@
|
||||
use sqlx::{MySqlPool, Row};
|
||||
|
||||
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
|
||||
use tokens::tokens::prompts::PromptToken;
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
/// A pending job created by the DEV_FAKE_GENERATION short-circuit
|
||||
/// (external_third_party_id starts with `fake_`), joined with its prompt text
|
||||
/// so the dev resolver can honor the synthetic-failure prompt triggers.
|
||||
pub struct PendingFakeGenerationJob {
|
||||
pub job_token: InferenceJobToken,
|
||||
/// e.g. "image_generation", "video_generation".
|
||||
pub inference_category: String,
|
||||
pub maybe_prompt_token: Option<PromptToken>,
|
||||
pub maybe_positive_prompt: Option<String>,
|
||||
pub maybe_creator_user_token: Option<UserToken>,
|
||||
}
|
||||
|
||||
/// List pending fake-generation jobs older than `min_age_seconds`, oldest
|
||||
/// first. Development-only: production never writes `fake_` external ids.
|
||||
///
|
||||
/// NB: runtime query (not the compile-time macro) because this dev-only SELECT
|
||||
/// isn't in the sqlx offline cache.
|
||||
pub async fn list_pending_fake_generation_jobs(
|
||||
mysql_pool: &MySqlPool,
|
||||
min_age_seconds: u32,
|
||||
limit: u32,
|
||||
) -> Result<Vec<PendingFakeGenerationJob>, sqlx::Error> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
j.token AS job_token,
|
||||
j.inference_category,
|
||||
j.maybe_prompt_token,
|
||||
j.maybe_creator_user_token,
|
||||
p.maybe_positive_prompt
|
||||
FROM generic_inference_jobs AS j
|
||||
LEFT JOIN prompts AS p
|
||||
ON p.token = j.maybe_prompt_token
|
||||
WHERE j.status = 'pending'
|
||||
AND j.maybe_external_third_party_id LIKE 'fake\_%'
|
||||
AND j.created_at <= NOW() - INTERVAL ? SECOND
|
||||
ORDER BY j.created_at ASC
|
||||
LIMIT ?
|
||||
"#)
|
||||
.bind(min_age_seconds)
|
||||
.bind(limit)
|
||||
.fetch_all(mysql_pool)
|
||||
.await?;
|
||||
|
||||
let jobs = rows.into_iter()
|
||||
.map(|row| PendingFakeGenerationJob {
|
||||
job_token: InferenceJobToken::new(row.get::<String, _>("job_token")),
|
||||
inference_category: row.get::<String, _>("inference_category"),
|
||||
maybe_prompt_token: row.get::<Option<String>, _>("maybe_prompt_token")
|
||||
.map(PromptToken::new),
|
||||
maybe_positive_prompt: row.get::<Option<String>, _>("maybe_positive_prompt"),
|
||||
maybe_creator_user_token: row.get::<Option<String>, _>("maybe_creator_user_token")
|
||||
.map(UserToken::new),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(jobs)
|
||||
}
|
||||
@@ -8,7 +8,6 @@ pub mod job_status;
|
||||
pub mod kill_generic_inference_jobs;
|
||||
pub mod list_user_jobs_for_moderation;
|
||||
pub mod kill_jobs_in_development;
|
||||
pub mod list_pending_fake_generation_jobs;
|
||||
pub mod list_session_jobs;
|
||||
pub mod mark_generic_inference_job_cancelled_by_user;
|
||||
pub mod mark_generic_inference_job_successfully_done_by_token;
|
||||
|
||||
+11
-1
@@ -72,6 +72,10 @@ pub enum InferenceJobProductCategory {
|
||||
#[serde(rename = "seedance2pro_video_bpu")]
|
||||
Seedance2ProVideoBytePlusUltra,
|
||||
|
||||
/// Seedance 2 Pro / Kinovi (BytePlus Ultra account): image generation.
|
||||
#[serde(rename = "seedance2pro_image_bpu")]
|
||||
Seedance2ProImageBytePlusUltra,
|
||||
|
||||
// =============== WORLD LABS ===============
|
||||
|
||||
/// World Labs: Gaussian Splat Generation
|
||||
@@ -183,6 +187,7 @@ impl InferenceJobProductCategory {
|
||||
Self::Seedance2ProAudio => "seedance2pro_audio",
|
||||
Self::Seedance2ProVideoAlt => "seedance2pro_video_alt",
|
||||
Self::Seedance2ProVideoBytePlusUltra => "seedance2pro_video_bpu",
|
||||
Self::Seedance2ProImageBytePlusUltra => "seedance2pro_image_bpu",
|
||||
Self::WorldlabsSplat => "worldlabs_splat",
|
||||
Self::TtsGptSoVits => "tts_gpt_so_vits",
|
||||
Self::TtsStyleTts2 => "tts_style_tts2",
|
||||
@@ -226,6 +231,7 @@ impl InferenceJobProductCategory {
|
||||
"seedance2pro_audio" => Ok(Self::Seedance2ProAudio),
|
||||
"seedance2pro_video_alt" => Ok(Self::Seedance2ProVideoAlt),
|
||||
"seedance2pro_video_bpu" => Ok(Self::Seedance2ProVideoBytePlusUltra),
|
||||
"seedance2pro_image_bpu" => Ok(Self::Seedance2ProImageBytePlusUltra),
|
||||
"worldlabs_splat" => Ok(Self::WorldlabsSplat),
|
||||
"tts_gpt_so_vits" => Ok(Self::TtsGptSoVits),
|
||||
"tts_style_tts2" => Ok(Self::TtsStyleTts2),
|
||||
@@ -272,6 +278,7 @@ impl InferenceJobProductCategory {
|
||||
Self::Seedance2ProAudio,
|
||||
Self::Seedance2ProVideoAlt,
|
||||
Self::Seedance2ProVideoBytePlusUltra,
|
||||
Self::Seedance2ProImageBytePlusUltra,
|
||||
Self::WorldlabsSplat,
|
||||
Self::TtsGptSoVits,
|
||||
Self::TtsStyleTts2,
|
||||
@@ -324,6 +331,7 @@ mod tests {
|
||||
assert_serialization(InferenceJobProductCategory::Seedance2ProAudio, "seedance2pro_audio");
|
||||
assert_serialization(InferenceJobProductCategory::Seedance2ProVideoAlt, "seedance2pro_video_alt");
|
||||
assert_serialization(InferenceJobProductCategory::Seedance2ProVideoBytePlusUltra, "seedance2pro_video_bpu");
|
||||
assert_serialization(InferenceJobProductCategory::Seedance2ProImageBytePlusUltra, "seedance2pro_image_bpu");
|
||||
assert_serialization(InferenceJobProductCategory::WorldlabsSplat, "worldlabs_splat");
|
||||
assert_serialization(InferenceJobProductCategory::TtsGptSoVits, "tts_gpt_so_vits");
|
||||
assert_serialization(InferenceJobProductCategory::TtsStyleTts2, "tts_style_tts2");
|
||||
@@ -366,6 +374,7 @@ mod tests {
|
||||
assert_eq!(InferenceJobProductCategory::Seedance2ProAudio.to_str(), "seedance2pro_audio");
|
||||
assert_eq!(InferenceJobProductCategory::Seedance2ProVideoAlt.to_str(), "seedance2pro_video_alt");
|
||||
assert_eq!(InferenceJobProductCategory::Seedance2ProVideoBytePlusUltra.to_str(), "seedance2pro_video_bpu");
|
||||
assert_eq!(InferenceJobProductCategory::Seedance2ProImageBytePlusUltra.to_str(), "seedance2pro_image_bpu");
|
||||
assert_eq!(InferenceJobProductCategory::WorldlabsSplat.to_str(), "worldlabs_splat");
|
||||
assert_eq!(InferenceJobProductCategory::TtsGptSoVits.to_str(), "tts_gpt_so_vits");
|
||||
assert_eq!(InferenceJobProductCategory::TtsStyleTts2.to_str(), "tts_style_tts2");
|
||||
@@ -407,6 +416,7 @@ mod tests {
|
||||
assert_eq!(InferenceJobProductCategory::from_str("seedance2pro_audio").unwrap(), InferenceJobProductCategory::Seedance2ProAudio);
|
||||
assert_eq!(InferenceJobProductCategory::from_str("seedance2pro_video_alt").unwrap(), InferenceJobProductCategory::Seedance2ProVideoAlt);
|
||||
assert_eq!(InferenceJobProductCategory::from_str("seedance2pro_video_bpu").unwrap(), InferenceJobProductCategory::Seedance2ProVideoBytePlusUltra);
|
||||
assert_eq!(InferenceJobProductCategory::from_str("seedance2pro_image_bpu").unwrap(), InferenceJobProductCategory::Seedance2ProImageBytePlusUltra);
|
||||
assert_eq!(InferenceJobProductCategory::from_str("worldlabs_splat").unwrap(), InferenceJobProductCategory::WorldlabsSplat);
|
||||
assert_eq!(InferenceJobProductCategory::from_str("tts_gpt_so_vits").unwrap(), InferenceJobProductCategory::TtsGptSoVits);
|
||||
assert_eq!(InferenceJobProductCategory::from_str("tts_style_tts2").unwrap(), InferenceJobProductCategory::TtsStyleTts2);
|
||||
@@ -434,7 +444,7 @@ mod tests {
|
||||
#[test]
|
||||
fn all_variants() {
|
||||
// Static check
|
||||
const EXPECTED_COUNT : usize = 38;
|
||||
const EXPECTED_COUNT : usize = 39;
|
||||
|
||||
assert_eq!(InferenceJobProductCategory::all_variants().len(), EXPECTED_COUNT);
|
||||
assert_eq!(InferenceJobProductCategory::iter().len(), EXPECTED_COUNT);
|
||||
|
||||
@@ -129,6 +129,8 @@ pub enum InferenceModelType {
|
||||
Seedream5Lite,
|
||||
#[serde(rename = "seedream_5p0_pro")]
|
||||
Seedream5p0Pro,
|
||||
#[serde(rename = "seedream_5p0_pro_u")]
|
||||
Seedream5p0ProUltra,
|
||||
#[serde(rename = "midjourney")]
|
||||
Midjourney,
|
||||
#[serde(rename = "midjourney_v6")]
|
||||
@@ -337,6 +339,7 @@ impl InferenceModelType {
|
||||
Self::Seedream4p5 => "seedream_4p5",
|
||||
Self::Seedream5Lite => "seedream_5_lite",
|
||||
Self::Seedream5p0Pro => "seedream_5p0_pro",
|
||||
Self::Seedream5p0ProUltra => "seedream_5p0_pro_u",
|
||||
Self::Midjourney => "midjourney",
|
||||
Self::MidjourneyV6 => "midjourney_v6",
|
||||
Self::MidjourneyV6p1 => "midjourney_v6p1",
|
||||
@@ -466,6 +469,7 @@ impl InferenceModelType {
|
||||
"seedream_4p5" => Ok(Self::Seedream4p5),
|
||||
"seedream_5_lite" => Ok(Self::Seedream5Lite),
|
||||
"seedream_5p0_pro" => Ok(Self::Seedream5p0Pro),
|
||||
"seedream_5p0_pro_u" => Ok(Self::Seedream5p0ProUltra),
|
||||
"midjourney" => Ok(Self::Midjourney),
|
||||
"midjourney_v6" => Ok(Self::MidjourneyV6),
|
||||
"midjourney_v6p1" => Ok(Self::MidjourneyV6p1),
|
||||
@@ -599,6 +603,7 @@ impl InferenceModelType {
|
||||
Self::Seedream4p5,
|
||||
Self::Seedream5Lite,
|
||||
Self::Seedream5p0Pro,
|
||||
Self::Seedream5p0ProUltra,
|
||||
Self::Midjourney,
|
||||
Self::MidjourneyV6,
|
||||
Self::MidjourneyV6p1,
|
||||
@@ -715,6 +720,7 @@ impl InferenceModelType {
|
||||
CommonModelType::Seedream4p5 => Self::Seedream4p5,
|
||||
CommonModelType::Seedream5Lite => Self::Seedream5Lite,
|
||||
CommonModelType::Seedream5p0Pro => Self::Seedream5p0Pro,
|
||||
CommonModelType::Seedream5p0ProUltra => Self::Seedream5p0ProUltra,
|
||||
CommonModelType::Midjourney => Self::Midjourney,
|
||||
CommonModelType::MidjourneyV6 => Self::MidjourneyV6,
|
||||
CommonModelType::MidjourneyV6p1 => Self::MidjourneyV6p1,
|
||||
|
||||
@@ -49,6 +49,8 @@ pub enum CommonImageModel {
|
||||
Seedream5Lite,
|
||||
#[serde(rename = "seedream_5p0_pro")]
|
||||
Seedream5p0Pro,
|
||||
#[serde(rename = "seedream_5p0_pro_u")]
|
||||
Seedream5p0ProUltra,
|
||||
#[serde(rename = "qwen_edit_2511_angles")]
|
||||
QwenEdit2511Angles,
|
||||
#[serde(rename = "flux_2_lora_angles")]
|
||||
@@ -78,6 +80,7 @@ impl CommonImageModel {
|
||||
Self::Seedream4p5 => CommonModelType::Seedream4p5,
|
||||
Self::Seedream5Lite => CommonModelType::Seedream5Lite,
|
||||
Self::Seedream5p0Pro => CommonModelType::Seedream5p0Pro,
|
||||
Self::Seedream5p0ProUltra => CommonModelType::Seedream5p0ProUltra,
|
||||
Self::QwenEdit2511Angles => CommonModelType::QwenEdit2511Angles,
|
||||
Self::Flux2LoraAngles => CommonModelType::Flux2LoraAngles,
|
||||
}
|
||||
@@ -115,6 +118,7 @@ mod tests {
|
||||
assert_serialization(CommonImageModel::Seedream4p5, "seedream_4p5");
|
||||
assert_serialization(CommonImageModel::Seedream5Lite, "seedream_5_lite");
|
||||
assert_serialization(CommonImageModel::Seedream5p0Pro, "seedream_5p0_pro");
|
||||
assert_serialization(CommonImageModel::Seedream5p0ProUltra, "seedream_5p0_pro_u");
|
||||
assert_serialization(CommonImageModel::QwenEdit2511Angles, "qwen_edit_2511_angles");
|
||||
assert_serialization(CommonImageModel::Flux2LoraAngles, "flux_2_lora_angles");
|
||||
}
|
||||
@@ -141,6 +145,7 @@ mod tests {
|
||||
(CommonImageModel::Seedream4p5, CommonModelType::Seedream4p5),
|
||||
(CommonImageModel::Seedream5Lite, CommonModelType::Seedream5Lite),
|
||||
(CommonImageModel::Seedream5p0Pro, CommonModelType::Seedream5p0Pro),
|
||||
(CommonImageModel::Seedream5p0ProUltra, CommonModelType::Seedream5p0ProUltra),
|
||||
(CommonImageModel::QwenEdit2511Angles, CommonModelType::QwenEdit2511Angles),
|
||||
(CommonImageModel::Flux2LoraAngles, CommonModelType::Flux2LoraAngles),
|
||||
];
|
||||
@@ -169,7 +174,7 @@ mod tests {
|
||||
#[test]
|
||||
fn variant_count() {
|
||||
use strum::IntoEnumIterator;
|
||||
assert_eq!(CommonImageModel::iter().len(), 21);
|
||||
assert_eq!(CommonImageModel::iter().len(), 22);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -85,6 +85,9 @@ pub enum CommonModelType {
|
||||
/// Seedream 5.0 Pro as routed via kinovi
|
||||
#[serde(rename = "seedream_5p0_pro")]
|
||||
Seedream5p0Pro,
|
||||
/// Seedream 5.0 Pro as routed via the kinovi ultra account
|
||||
#[serde(rename = "seedream_5p0_pro_u")]
|
||||
Seedream5p0ProUltra,
|
||||
|
||||
/// Midjourney without distinguishing a model type or version
|
||||
#[serde(rename = "midjourney")]
|
||||
@@ -291,6 +294,7 @@ impl CommonModelType {
|
||||
Self::Seedream4p5 => "seedream_4p5",
|
||||
Self::Seedream5Lite => "seedream_5_lite",
|
||||
Self::Seedream5p0Pro => "seedream_5p0_pro",
|
||||
Self::Seedream5p0ProUltra => "seedream_5p0_pro_u",
|
||||
Self::Midjourney => "midjourney",
|
||||
Self::MidjourneyV6 => "midjourney_v6",
|
||||
Self::MidjourneyV6p1 => "midjourney_v6p1",
|
||||
@@ -403,6 +407,7 @@ impl CommonModelType {
|
||||
"seedream_4p5" => Ok(Self::Seedream4p5),
|
||||
"seedream_5_lite" => Ok(Self::Seedream5Lite),
|
||||
"seedream_5p0_pro" => Ok(Self::Seedream5p0Pro),
|
||||
"seedream_5p0_pro_u" => Ok(Self::Seedream5p0ProUltra),
|
||||
"midjourney" => Ok(Self::Midjourney),
|
||||
"midjourney_v6" => Ok(Self::MidjourneyV6),
|
||||
"midjourney_v6p1" => Ok(Self::MidjourneyV6p1),
|
||||
@@ -519,6 +524,7 @@ impl CommonModelType {
|
||||
Self::Seedream4p5,
|
||||
Self::Seedream5Lite,
|
||||
Self::Seedream5p0Pro,
|
||||
Self::Seedream5p0ProUltra,
|
||||
Self::Midjourney,
|
||||
Self::MidjourneyV6,
|
||||
Self::MidjourneyV6p1,
|
||||
@@ -632,6 +638,7 @@ impl CommonModelType {
|
||||
Self::Seedream4p5 => CommonModelClass::Image,
|
||||
Self::Seedream5Lite => CommonModelClass::Image,
|
||||
Self::Seedream5p0Pro => CommonModelClass::Image,
|
||||
Self::Seedream5p0ProUltra => CommonModelClass::Image,
|
||||
Self::Midjourney => CommonModelClass::Image,
|
||||
Self::MidjourneyV6 => CommonModelClass::Image,
|
||||
Self::MidjourneyV6p1 => CommonModelClass::Image,
|
||||
@@ -755,6 +762,7 @@ mod tests {
|
||||
assert_serialization(CommonModelType::Seedream4p5, "seedream_4p5");
|
||||
assert_serialization(CommonModelType::Seedream5Lite, "seedream_5_lite");
|
||||
assert_serialization(CommonModelType::Seedream5p0Pro, "seedream_5p0_pro");
|
||||
assert_serialization(CommonModelType::Seedream5p0ProUltra, "seedream_5p0_pro_u");
|
||||
assert_serialization(CommonModelType::Midjourney, "midjourney");
|
||||
assert_serialization(CommonModelType::MidjourneyV6, "midjourney_v6");
|
||||
assert_serialization(CommonModelType::MidjourneyV6p1, "midjourney_v6p1");
|
||||
@@ -861,6 +869,7 @@ mod tests {
|
||||
assert_eq!(CommonModelType::Seedream4p5.to_str(), "seedream_4p5");
|
||||
assert_eq!(CommonModelType::Seedream5Lite.to_str(), "seedream_5_lite");
|
||||
assert_eq!(CommonModelType::Seedream5p0Pro.to_str(), "seedream_5p0_pro");
|
||||
assert_eq!(CommonModelType::Seedream5p0ProUltra.to_str(), "seedream_5p0_pro_u");
|
||||
assert_eq!(CommonModelType::Midjourney.to_str(), "midjourney");
|
||||
assert_eq!(CommonModelType::MidjourneyV6.to_str(), "midjourney_v6");
|
||||
assert_eq!(CommonModelType::MidjourneyV6p1.to_str(), "midjourney_v6p1");
|
||||
@@ -971,6 +980,7 @@ mod tests {
|
||||
assert_eq!(CommonModelType::from_str("seedream_4p5").unwrap(), CommonModelType::Seedream4p5);
|
||||
assert_eq!(CommonModelType::from_str("seedream_5_lite").unwrap(), CommonModelType::Seedream5Lite);
|
||||
assert_eq!(CommonModelType::from_str("seedream_5p0_pro").unwrap(), CommonModelType::Seedream5p0Pro);
|
||||
assert_eq!(CommonModelType::from_str("seedream_5p0_pro_u").unwrap(), CommonModelType::Seedream5p0ProUltra);
|
||||
assert_eq!(CommonModelType::from_str("midjourney").unwrap(), CommonModelType::Midjourney);
|
||||
assert_eq!(CommonModelType::from_str("midjourney_v6").unwrap(), CommonModelType::MidjourneyV6);
|
||||
assert_eq!(CommonModelType::from_str("midjourney_v6p1").unwrap(), CommonModelType::MidjourneyV6p1);
|
||||
@@ -1052,7 +1062,7 @@ mod tests {
|
||||
#[test]
|
||||
fn all_variants() {
|
||||
let mut variants = CommonModelType::all_variants();
|
||||
assert_eq!(variants.len(), 97);
|
||||
assert_eq!(variants.len(), 98);
|
||||
// Image models
|
||||
assert_eq!(variants.pop_first(), Some(CommonModelType::Flux1Dev));
|
||||
assert_eq!(variants.pop_first(), Some(CommonModelType::Flux1Schnell));
|
||||
@@ -1080,6 +1090,7 @@ mod tests {
|
||||
assert_eq!(variants.pop_first(), Some(CommonModelType::Seedream4p5));
|
||||
assert_eq!(variants.pop_first(), Some(CommonModelType::Seedream5Lite));
|
||||
assert_eq!(variants.pop_first(), Some(CommonModelType::Seedream5p0Pro));
|
||||
assert_eq!(variants.pop_first(), Some(CommonModelType::Seedream5p0ProUltra));
|
||||
assert_eq!(variants.pop_first(), Some(CommonModelType::Midjourney));
|
||||
assert_eq!(variants.pop_first(), Some(CommonModelType::MidjourneyV6));
|
||||
assert_eq!(variants.pop_first(), Some(CommonModelType::MidjourneyV6p1));
|
||||
|
||||
@@ -75,23 +75,23 @@ curl -s -c /tmp/cookies.txt -X POST http://localhost:12345/v1/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username_or_email":"newuser4","password":"newuser4"}'
|
||||
|
||||
# 2. Generate video (Seedance 2.0 Fast):
|
||||
# 2. Generate video (GmiCloud Seedance 2.0 Fast Global):
|
||||
# Requires a UUID-format idempotency_token.
|
||||
curl -s -b /tmp/cookies.txt -X POST http://localhost:12345/v1/omni_gen/generate/video \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"model": "seedance_2p0_fast",
|
||||
"model": "seedance_2p0_fast_g",
|
||||
"prompt": "a corgi running through a field",
|
||||
"duration_seconds": 5,
|
||||
"aspect_ratio": "wide_sixteen_by_nine",
|
||||
"idempotency_token": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
|
||||
}'
|
||||
|
||||
# 3. Generate video (Seedance 2.0, 720p):
|
||||
# 3. Generate video (GmiCloud Seedance 2.0 Global, 720p):
|
||||
curl -s -b /tmp/cookies.txt -X POST http://localhost:12345/v1/omni_gen/generate/video \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"model": "seedance_2p0",
|
||||
"model": "seedance_2p0_g",
|
||||
"prompt": "a corgi running through a field",
|
||||
"duration_seconds": 5,
|
||||
"resolution": "seven_twenty_p",
|
||||
|
||||
@@ -167,7 +167,6 @@ tempfile.workspace = true
|
||||
# https://github.com/durch/rust-s3/pull/254
|
||||
rust-s3.workspace = true
|
||||
|
||||
actix-files.workspace = true
|
||||
actix-http.workspace = true
|
||||
actix-multipart.workspace = true
|
||||
actix-service.workspace = true
|
||||
|
||||
@@ -470,6 +470,37 @@ fn build_omni_gen_image_models() -> Vec<OmniGenImageModelDetails> {
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
models.push(OmniGenImageModelDetails {
|
||||
model: CommonImageModel::Seedream5p0ProUltra,
|
||||
model_creator: Some(ModelCreator::Bytedance),
|
||||
full_name: Some("Seedream 5.0 Pro Ultra".to_string()),
|
||||
text_prompt_supported: Some(true),
|
||||
image_refs_supported: Some(true),
|
||||
image_refs_max: Some(14),
|
||||
aspect_ratio_options: Some(vec![
|
||||
CommonAspectRatio::Auto,
|
||||
CommonAspectRatio::WideTwentyOneByNine,
|
||||
CommonAspectRatio::WideSixteenByNine,
|
||||
CommonAspectRatio::WideThreeByTwo,
|
||||
CommonAspectRatio::WideFourByThree,
|
||||
CommonAspectRatio::Square,
|
||||
CommonAspectRatio::TallThreeByFour,
|
||||
CommonAspectRatio::TallTwoByThree,
|
||||
CommonAspectRatio::TallNineBySixteen,
|
||||
]),
|
||||
aspect_ratio_default: Some(CommonAspectRatio::Square),
|
||||
aspect_ratio_default_when_editing: Some(CommonAspectRatio::Auto),
|
||||
resolution_options: Some(vec![
|
||||
CommonResolution::OneK,
|
||||
CommonResolution::TwoK,
|
||||
]),
|
||||
resolution_default: Some(CommonResolution::TwoK),
|
||||
batch_size_min: Some(1),
|
||||
batch_size_max: Some(8),
|
||||
batch_size_default: Some(1),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
models
|
||||
}
|
||||
|
||||
@@ -519,6 +550,10 @@ fn build_omni_gen_image_model_providers() -> Vec<OmniGenImageModelProviderDetail
|
||||
model: CommonImageModel::Seedream5p0Pro,
|
||||
overrides: None,
|
||||
},
|
||||
OmniGenImageProviderModelDetails {
|
||||
model: CommonImageModel::Seedream5p0ProUltra,
|
||||
overrides: None,
|
||||
},
|
||||
OmniGenImageProviderModelDetails {
|
||||
model: CommonImageModel::NanoBanana,
|
||||
overrides: None,
|
||||
|
||||
@@ -95,6 +95,7 @@ use artcraft_api_defs::moderation::debug_logs::debug_log_entry::*;
|
||||
use artcraft_api_defs::moderation::debug_logs::moderation_list_all_debug_logs::*;
|
||||
use artcraft_api_defs::moderation::debug_logs::moderation_list_debug_logs_for_token::*;
|
||||
use artcraft_api_defs::moderation::debug_logs::moderation_list_debug_logs_for_user::*;
|
||||
use crate::http_server::endpoints::moderation::dashboards::moderator_list_databox_dashboards_handler::*;
|
||||
use crate::http_server::endpoints::moderation::jobs::moderation_get_job_by_token_handler::*;
|
||||
use crate::http_server::endpoints::moderation::user_feature_flags::moderator_edit_user_feature_flags_handler::*;
|
||||
use crate::http_server::endpoints::moderation::user_feature_flags::moderator_list_all_available_user_feature_flags_handler::*;
|
||||
@@ -640,6 +641,7 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
|
||||
crate::http_server::endpoints::omni_api::job_status::batch_get_job_status_handler::omni_api_batch_get_job_status_handler,
|
||||
// Moderation
|
||||
crate::http_server::endpoints::moderation::alerts::moderation_send_alert_handler::moderation_send_alert_handler,
|
||||
crate::http_server::endpoints::moderation::dashboards::moderator_list_databox_dashboards_handler::moderator_list_databox_dashboards_handler,
|
||||
crate::http_server::endpoints::moderation::info::moderator_token_info_handler::moderator_get_token_info_handler,
|
||||
crate::http_server::endpoints::moderation::user::moderator_list_subscribing_users_by_signup_date::moderator_list_subscribing_users_by_signup_date_handler,
|
||||
crate::http_server::endpoints::moderation::user::moderator_list_users_by_signup_date::moderator_list_users_by_signup_date_handler,
|
||||
@@ -724,6 +726,8 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
|
||||
crate::http_server::endpoints::image_studio::update_gpt_image_job_status_handler::update_gpt_image_job_status_handler,
|
||||
),
|
||||
components(schemas(
|
||||
DataboxDashboardEntry,
|
||||
ListDataboxDashboardsResponse,
|
||||
ModeratorGetUserSpendSummaryResponse,
|
||||
UserSpendSummaryView,
|
||||
ModeratorReengagementListResponse,
|
||||
|
||||
@@ -9,34 +9,6 @@ const STORYTELLER_CDN_STR: &str = "https://cdn-2.fakeyou.com";
|
||||
const FAKEYOU_DEVELOPMENT_CDN_STR: &str = "https://pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev";
|
||||
const STORYTELLER_DEVELOPMENT_CDN_STR: &str = "https://pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev";
|
||||
|
||||
// Runtime override for the CDN base, taking precedence over every constant
|
||||
// above for all domains and environments. The fully-local dev stack sets
|
||||
// CDN_BASE_URL=http://localhost:12345 so media URLs resolve against the
|
||||
// backend's own static /media mount (see add_service_routes) instead of the
|
||||
// shared r2.dev bucket. Unset (production, and dev-against-remote) keeps the
|
||||
// compiled-in hosts.
|
||||
//
|
||||
// The override must be scheme+host only: some consumers append the rooted
|
||||
// object path with Url::set_path, which would silently drop a path component
|
||||
// in the base, so a base with a path is rejected loudly instead.
|
||||
// Leaked once so get_cdn_host can stay &'static str.
|
||||
static CDN_BASE_URL_OVERRIDE_STR: Lazy<Option<&'static str>> = Lazy::new(|| {
|
||||
let base = std::env::var("CDN_BASE_URL")
|
||||
.ok()
|
||||
.map(|s| s.trim().trim_end_matches('/').to_string())
|
||||
.filter(|s| !s.is_empty())?;
|
||||
let url = Url::parse(&base).expect("CDN_BASE_URL must be a valid absolute URL");
|
||||
if url.path() != "/" && !url.path().is_empty() {
|
||||
panic!("CDN_BASE_URL must not include a path component (got '{}'): \
|
||||
media URL builders overwrite the path", url.path());
|
||||
}
|
||||
Some(&*Box::leak(base.into_boxed_str()))
|
||||
});
|
||||
|
||||
static CDN_BASE_URL_OVERRIDE: Lazy<Option<Url>> = Lazy::new(|| {
|
||||
CDN_BASE_URL_OVERRIDE_STR.map(|s| Url::parse(s).expect("validated above"))
|
||||
});
|
||||
|
||||
|
||||
const FAKEYOU_CDN: Lazy<Url> = Lazy::new(|| Url::parse(FAKEYOU_CDN_STR)
|
||||
.expect("should never fail"));
|
||||
@@ -53,9 +25,6 @@ const STORYTELLER_DEVELOPMENT_CDN: Lazy<Url> = Lazy::new(|| Url::parse(STORYTELL
|
||||
|
||||
// TODO(bt,2025-01-31): Perhaps this should be config driven and configurable at runtime instead of hardcoded.
|
||||
pub fn get_cdn_host(media_domain: MediaDomain, server_environment: ServerEnvironment) -> &'static str {
|
||||
if let Some(base) = *CDN_BASE_URL_OVERRIDE_STR {
|
||||
return base;
|
||||
}
|
||||
match (media_domain, server_environment) {
|
||||
(MediaDomain::FakeYou, ServerEnvironment::Development) => FAKEYOU_DEVELOPMENT_CDN_STR,
|
||||
(MediaDomain::FakeYou, ServerEnvironment::Production) => FAKEYOU_CDN_STR,
|
||||
@@ -65,9 +34,6 @@ pub fn get_cdn_host(media_domain: MediaDomain, server_environment: ServerEnviron
|
||||
}
|
||||
|
||||
pub fn new_cdn_url(media_domain: MediaDomain, server_environment: ServerEnvironment) -> Url {
|
||||
if let Some(url) = CDN_BASE_URL_OVERRIDE.as_ref() {
|
||||
return url.clone();
|
||||
}
|
||||
match (media_domain, server_environment) {
|
||||
(MediaDomain::FakeYou, ServerEnvironment::Development) => FAKEYOU_DEVELOPMENT_CDN.clone(),
|
||||
(MediaDomain::FakeYou, ServerEnvironment::Production) => FAKEYOU_CDN.clone(),
|
||||
|
||||
+1
@@ -142,6 +142,7 @@ fn map_image_model(model: CommonImageModel) -> Result<RouterImageModel, HandlerE
|
||||
CommonImageModel::Seedream4p5 => RouterImageModel::Seedream4p5,
|
||||
CommonImageModel::Seedream5Lite => RouterImageModel::Seedream5Lite,
|
||||
CommonImageModel::Seedream5p0Pro => RouterImageModel::Seedream5p0Pro,
|
||||
CommonImageModel::Seedream5p0ProUltra => RouterImageModel::Seedream5p0ProUltra,
|
||||
CommonImageModel::GrokImagineImage => RouterImageModel::GrokImagineImage,
|
||||
CommonImageModel::GrokImagineImageQuality => RouterImageModel::GrokImagineImageQuality,
|
||||
CommonImageModel::QwenEdit2511Angles => RouterImageModel::QwenEdit2511Angles,
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
pub mod moderator_list_databox_dashboards_handler;
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_web::web::Json;
|
||||
use actix_web::{web, HttpRequest};
|
||||
use serde::Serialize;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::user_lookup::user_session::require_moderator::require_moderator;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct ListDataboxDashboardsResponse {
|
||||
pub success: bool,
|
||||
|
||||
/// Configured Databox datawalls, in display order.
|
||||
pub databoards: Vec<DataboxDashboardEntry>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct DataboxDashboardEntry {
|
||||
/// Human-readable dashboard name.
|
||||
pub name: String,
|
||||
|
||||
/// Databox datawall id.
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
/// List the Databox metrics dashboards configured for the admin dashboard.
|
||||
/// Dashboards whose env vars aren't set are omitted.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
tag = "Moderation",
|
||||
path = "/v1/moderation/dashboards/databox",
|
||||
responses(
|
||||
(status = 200, description = "Success", body = ListDataboxDashboardsResponse),
|
||||
(status = 401, description = "Not authorized", body = CommonWebError),
|
||||
(status = 500, description = "Server error", body = CommonWebError),
|
||||
),
|
||||
)]
|
||||
pub async fn moderator_list_databox_dashboards_handler(
|
||||
http_request: HttpRequest,
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<Json<ListDataboxDashboardsResponse>, CommonWebError> {
|
||||
|
||||
require_moderator(&http_request, &server_state.session_checker, &server_state.mysql_pool).await?;
|
||||
|
||||
let databox = &server_state.dashboards.databox;
|
||||
|
||||
let databoards = [
|
||||
("DAUs", databox.daus_id.as_ref()),
|
||||
("Daily Generations", databox.daily_generations_id.as_ref()),
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(|(name, maybe_id)| {
|
||||
maybe_id.map(|id| DataboxDashboardEntry {
|
||||
name: name.to_string(),
|
||||
id: id.clone(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(ListDataboxDashboardsResponse {
|
||||
success: true,
|
||||
databoards,
|
||||
}))
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod alerts;
|
||||
pub mod dashboards;
|
||||
pub mod debug_logs;
|
||||
pub mod info;
|
||||
pub mod ip_bans;
|
||||
|
||||
+16
-4
@@ -13,6 +13,7 @@ use enums::by_table::debug_logs::debug_log_type::DebugLogType;
|
||||
use enums::by_table::prompt_context_items::prompt_context_semantic_type::PromptContextSemanticType;
|
||||
use enums::by_table::prompts::prompt_type::PromptType;
|
||||
use enums::common::generation::common_generation_mode::CommonGenerationMode;
|
||||
use enums::common::generation::common_image_model::CommonImageModel;
|
||||
use enums::common::generation::common_model_type::CommonModelType;
|
||||
use enums::common::generation_provider::GenerationProvider;
|
||||
use enums::common::platform_type::PlatformType;
|
||||
@@ -37,6 +38,7 @@ use crate::http_server::endpoints::omni_api::generate::image::insert_db_job::ins
|
||||
use crate::http_server::endpoints::omni_api::generate::image::insert_db_job::insert_seedance2pro_jobs::{insert_seedance2pro_jobs, InsertSeedance2proJobsArgs};
|
||||
use crate::http_server::endpoints::omni_api::generate::image::insert_db_job::shared_job_args::SharedJobArgs;
|
||||
use crate::http_server::endpoints::omni_api::generate::image::pipeline_v2::run_pipeline_v2::{run_pipeline_v2, RunPipelineV2Args};
|
||||
use crate::http_server::endpoints::omni_api::shared_utils::kinovi_account::KinoviAccount;
|
||||
use crate::http_server::user_lookup::api_keys::require_api_key_user::require_api_key_user;
|
||||
use crate::http_server::user_lookup::user_session::session_utils::lookup::user_session_feature_flags::UserSessionFeatureFlags;
|
||||
use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format;
|
||||
@@ -135,6 +137,14 @@ pub async fn omni_api_image_generate_handler(
|
||||
|
||||
let router_builder = hydrate_to_router_request(&request)?;
|
||||
|
||||
// ==================== PIPELINE DISPATCH ==================== //
|
||||
|
||||
let kinovi_account = match request.model {
|
||||
Some(CommonImageModel::Seedream5p0ProUltra) => KinoviAccount::BytePlusUltra,
|
||||
// Everything else goes through Volcengine
|
||||
_ => KinoviAccount::Volcengine,
|
||||
};
|
||||
|
||||
// ==================== DEBUG LOG: HTTP REQUEST ==================== //
|
||||
|
||||
if let Err(err) = insert_debug_log(InsertDebugLogArgs {
|
||||
@@ -170,6 +180,7 @@ pub async fn omni_api_image_generate_handler(
|
||||
server_state: &server_state,
|
||||
user_token,
|
||||
resolved_media: &resolved_media,
|
||||
kinovi_account,
|
||||
debug_log_context: &debug_log_context,
|
||||
mysql_connection,
|
||||
}).await;
|
||||
@@ -289,10 +300,11 @@ pub async fn omni_api_image_generate_handler(
|
||||
GenerateImageResponse::Seedance2Pro(payload) => {
|
||||
info!("Inserting seedance2pro image job(s) with token: {:?}", pipeline_result.apriori_job_token);
|
||||
|
||||
// The image-side omni pipeline always dispatches Midjourney via the
|
||||
// Volcengine Kinovi account today. If we ever route to BytePlus /
|
||||
// BytePlus Ultra here, mirror the video-side `kinovi_account` knob.
|
||||
let kinovi_version = KinoviVersion::Volcengine;
|
||||
let kinovi_version = match kinovi_account {
|
||||
KinoviAccount::Volcengine => KinoviVersion::Volcengine,
|
||||
KinoviAccount::BytePlus => KinoviVersion::BytePlus,
|
||||
KinoviAccount::BytePlusUltra => KinoviVersion::BytePlusUltra,
|
||||
};
|
||||
|
||||
let result = insert_seedance2pro_jobs(InsertSeedance2proJobsArgs {
|
||||
primary_order_id: &payload.order_id,
|
||||
|
||||
+19
-11
@@ -21,6 +21,7 @@ use crate::http_server::endpoints::generate::common::generation_debug_logs::{
|
||||
insert_provider_request_debug_log, provider_request_debug_log_type, GenerationDebugLogContext,
|
||||
};
|
||||
use crate::http_server::endpoints::omni_api::generate::image::pipeline_result::ImagePipelineResult;
|
||||
use crate::http_server::endpoints::omni_api::shared_utils::kinovi_account::KinoviAccount;
|
||||
use crate::http_server::endpoints::omni_api::shared_utils::map_seedance2pro_router_error::map_router_error_to_web_error;
|
||||
use crate::state::server_state::ServerState;
|
||||
use crate::util::lookup::lookup_media_files_as_cdn_url_list_and_map::MediaFilesAsCdnUrlListAndMap;
|
||||
@@ -30,6 +31,7 @@ pub struct RunPipelineV2Args<'a> {
|
||||
pub server_state: &'a ServerState,
|
||||
pub user_token: &'a UserToken,
|
||||
pub resolved_media: &'a MediaFilesAsCdnUrlListAndMap,
|
||||
pub kinovi_account: KinoviAccount,
|
||||
pub debug_log_context: &'a GenerationDebugLogContext<'a>,
|
||||
/// The handler's open connection. The pipeline uses it for its remaining
|
||||
/// pre-request DB writes (billing, outbound-request debug log) and releases
|
||||
@@ -49,6 +51,7 @@ pub async fn run_pipeline_v2(
|
||||
server_state,
|
||||
user_token,
|
||||
resolved_media,
|
||||
kinovi_account,
|
||||
debug_log_context,
|
||||
mut mysql_connection,
|
||||
} = args;
|
||||
@@ -97,7 +100,7 @@ pub async fn run_pipeline_v2(
|
||||
// across the external generation call below.
|
||||
drop(mysql_connection);
|
||||
|
||||
let response = finalize_and_generate(draft_or_request, server_state, resolved_media).await?;
|
||||
let response = finalize_and_generate(draft_or_request, server_state, resolved_media, kinovi_account).await?;
|
||||
|
||||
Ok(ImagePipelineResult {
|
||||
apriori_job_token,
|
||||
@@ -118,15 +121,17 @@ fn build_execution_request(
|
||||
})
|
||||
}
|
||||
|
||||
/// Route each image model to the provider that fulfils it. Midjourney is
|
||||
/// served via the Seedance2Pro/Kinovi (Volcengine) backend; everything else
|
||||
/// flows through Fal.
|
||||
/// Route each image model to the provider that fulfils it. Midjourney and
|
||||
/// Seedream 5.0 Pro (both variants) are served via the Seedance2Pro/Kinovi
|
||||
/// backend; everything else flows through Fal. Which Kinovi account fulfils
|
||||
/// a Kinovi request is the handler's `kinovi_account` choice.
|
||||
fn provider_for_model(model: RouterImageModel) -> RouterProvider {
|
||||
match model {
|
||||
RouterImageModel::Midjourney7
|
||||
| RouterImageModel::Midjourney7Niji
|
||||
| RouterImageModel::Midjourney8
|
||||
| RouterImageModel::Seedream5p0Pro => RouterProvider::Seedance2Pro,
|
||||
| RouterImageModel::Seedream5p0Pro
|
||||
| RouterImageModel::Seedream5p0ProUltra => RouterProvider::Seedance2Pro,
|
||||
_ => RouterProvider::Fal,
|
||||
}
|
||||
}
|
||||
@@ -172,9 +177,10 @@ async fn finalize_and_generate(
|
||||
draft_or_request: ImageGenerationDraftOrRequest,
|
||||
server_state: &ServerState,
|
||||
resolved_media: &MediaFilesAsCdnUrlListAndMap,
|
||||
kinovi_account: KinoviAccount,
|
||||
) -> Result<GenerateImageResponse, CommonWebError> {
|
||||
let provider = draft_or_request.get_provider();
|
||||
let client = build_router_client(provider, server_state)?;
|
||||
let client = build_router_client(provider, server_state, kinovi_account)?;
|
||||
|
||||
// Inline the draft finalization so the draft context can borrow `client`.
|
||||
let request: ImageGenerationRequest = match draft_or_request {
|
||||
@@ -204,6 +210,7 @@ async fn finalize_and_generate(
|
||||
fn build_router_client(
|
||||
provider: RouterProvider,
|
||||
server_state: &ServerState,
|
||||
kinovi_account: KinoviAccount,
|
||||
) -> Result<RouterClient, CommonWebError> {
|
||||
match provider {
|
||||
RouterProvider::Fal => {
|
||||
@@ -214,11 +221,12 @@ fn build_router_client(
|
||||
Ok(RouterClient::Fal(fal_client))
|
||||
},
|
||||
RouterProvider::Seedance2Pro => {
|
||||
// Midjourney image generation always uses the Volcengine account.
|
||||
// Per-request account selection (like the video flow's
|
||||
// `KinoviAccount` enum) isn't needed here yet — if it ever is, plumb
|
||||
// a parallel knob through `RunPipelineV2Args`.
|
||||
let cookies = server_state.inference_providers.seedance2pro.cookies_volcengine.clone();
|
||||
let seedance2pro = &server_state.inference_providers.seedance2pro;
|
||||
let cookies = match kinovi_account {
|
||||
KinoviAccount::Volcengine => seedance2pro.cookies_volcengine.clone(),
|
||||
KinoviAccount::BytePlus => seedance2pro.cookies_byteplus.clone(),
|
||||
KinoviAccount::BytePlusUltra => seedance2pro.cookies_byteplus_ultra.clone(),
|
||||
};
|
||||
let session = Seedance2ProSession::from_cookies_string(cookies);
|
||||
Ok(RouterClient::Seedance2Pro(RouterSeedance2ProClient::new(session)))
|
||||
},
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ use artcraft_router::client::router_seedance2pro_client::RouterSeedance2ProClien
|
||||
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::endpoints::omni_api::generate::video::kinovi_account::KinoviAccount;
|
||||
use crate::http_server::endpoints::omni_api::shared_utils::kinovi_account::KinoviAccount;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
pub fn build_router_client(
|
||||
|
||||
-1
@@ -4,4 +4,3 @@ pub mod helpers;
|
||||
pub mod ingest_url_inputs;
|
||||
pub mod pipeline_v2;
|
||||
pub mod insert_db_job;
|
||||
pub mod kinovi_account;
|
||||
|
||||
+1
-1
@@ -43,7 +43,7 @@ use crate::http_server::endpoints::omni_api::generate::video::insert_db_job::ins
|
||||
use crate::http_server::endpoints::omni_api::generate::video::insert_db_job::insert_grok_api_job::{insert_grok_api_job, InsertGrokApiJobArgs};
|
||||
use crate::http_server::endpoints::omni_api::generate::video::insert_db_job::insert_seedance2pro_jobs::{insert_seedance2pro_jobs, InsertSeedance2proJobsArgs};
|
||||
use crate::http_server::endpoints::omni_api::generate::video::insert_db_job::shared_job_args::SharedJobArgs;
|
||||
use crate::http_server::endpoints::omni_api::generate::video::kinovi_account::KinoviAccount;
|
||||
use crate::http_server::endpoints::omni_api::shared_utils::kinovi_account::KinoviAccount;
|
||||
use crate::http_server::endpoints::omni_api::generate::video::pipeline_v2::run_pipeline_v2::{run_pipeline_v2, RunPipelineV2Args};
|
||||
use crate::http_server::endpoints::omni_api::shared_utils::video::validate_video_request::validate_video_request;
|
||||
use crate::http_server::user_lookup::user_session::session_utils::lookup::user_session_feature_flags::UserSessionFeatureFlags;
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ use crate::http_server::endpoints::omni_api::generate::video::helpers::build_rou
|
||||
use crate::http_server::endpoints::omni_api::generate::video::helpers::pipeline_result::PipelineResult;
|
||||
use crate::http_server::endpoints::omni_api::generate::video::helpers::resolve_media_tokens_to_urls::resolve_media_tokens_to_urls;
|
||||
use mysql_queries::queries::generic_inference::common::job_cost_estimates::JobCostEstimates;
|
||||
use crate::http_server::endpoints::omni_api::generate::video::kinovi_account::KinoviAccount;
|
||||
use crate::http_server::endpoints::omni_api::shared_utils::kinovi_account::KinoviAccount;
|
||||
use crate::http_server::endpoints::omni_api::shared_utils::map_seedance2pro_router_error::map_router_error_to_web_error;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
|
||||
+1
@@ -1,4 +1,5 @@
|
||||
pub mod check_url_inputs;
|
||||
pub mod kinovi_account;
|
||||
pub mod map_seedance2pro_router_error;
|
||||
pub mod media_uploads;
|
||||
pub mod video;
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ use crate::http_server::endpoints::omni_gen::generate::audio::insert_db_job::ins
|
||||
use crate::http_server::endpoints::omni_gen::generate::audio::insert_db_job::insert_seedance2pro_job::{insert_seedance2pro_job, InsertSeedance2proJobArgs};
|
||||
use crate::http_server::endpoints::omni_gen::generate::audio::pipeline_v2::run_pipeline_v2::{run_pipeline_v2, RunPipelineV2Args};
|
||||
use crate::http_server::endpoints::omni_gen::generate::video::insert_db_job::shared_job_args::SharedJobArgs;
|
||||
use crate::http_server::endpoints::omni_gen::generate::video::kinovi_account::KinoviAccount;
|
||||
use crate::http_server::endpoints::omni_gen::shared_utils::kinovi_account::KinoviAccount;
|
||||
use crate::http_server::endpoints::omni_gen::shared_utils::audio::validate_audio_request::validate_audio_request;
|
||||
use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format;
|
||||
use crate::http_server::web_utils::get_request_platform_type::get_request_platform_type;
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ use crate::http_server::endpoints::omni_gen::generate::audio::helpers::pipeline_
|
||||
use crate::http_server::endpoints::omni_gen::generate::audio::helpers::resolve_media_tokens_to_urls::resolve_media_tokens_to_urls;
|
||||
use crate::http_server::endpoints::omni_gen::generate::video::helpers::bill_wallet::bill_wallet;
|
||||
use crate::http_server::endpoints::omni_gen::generate::video::helpers::build_router_client::build_router_client;
|
||||
use crate::http_server::endpoints::omni_gen::generate::video::kinovi_account::KinoviAccount;
|
||||
use crate::http_server::endpoints::omni_gen::shared_utils::kinovi_account::KinoviAccount;
|
||||
use crate::http_server::endpoints::omni_gen::shared_utils::map_seedance2pro_router_error::map_router_error_to_web_error;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
|
||||
+16
-4
@@ -12,6 +12,7 @@ use enums::by_table::debug_logs::debug_log_type::DebugLogType;
|
||||
use enums::by_table::prompt_context_items::prompt_context_semantic_type::PromptContextSemanticType;
|
||||
use enums::by_table::prompts::prompt_type::PromptType;
|
||||
use enums::common::generation::common_generation_mode::CommonGenerationMode;
|
||||
use enums::common::generation::common_image_model::CommonImageModel;
|
||||
use enums::common::generation::common_model_type::CommonModelType;
|
||||
use enums::common::generation_provider::GenerationProvider;
|
||||
use http_server_common::request::get_request_ip::get_request_ip;
|
||||
@@ -34,6 +35,7 @@ use crate::http_server::endpoints::omni_gen::generate::image::insert_db_job::ins
|
||||
use crate::http_server::endpoints::omni_gen::generate::image::insert_db_job::insert_seedance2pro_jobs::{insert_seedance2pro_jobs, InsertSeedance2proJobsArgs};
|
||||
use crate::http_server::endpoints::omni_gen::generate::image::insert_db_job::shared_job_args::SharedJobArgs;
|
||||
use crate::http_server::endpoints::omni_gen::generate::image::pipeline_v2::run_pipeline_v2::{run_pipeline_v2, RunPipelineV2Args};
|
||||
use crate::http_server::endpoints::omni_gen::shared_utils::kinovi_account::KinoviAccount;
|
||||
use crate::http_server::user_lookup::user_session::session_utils::lookup::user_session_feature_flags::UserSessionFeatureFlags;
|
||||
use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format;
|
||||
use crate::http_server::web_utils::get_request_platform_type::get_request_platform_type;
|
||||
@@ -130,6 +132,14 @@ pub async fn omni_gen_image_generate_handler(
|
||||
|
||||
let router_builder = hydrate_to_router_request(&request)?;
|
||||
|
||||
// ==================== PIPELINE DISPATCH ==================== //
|
||||
|
||||
let kinovi_account = match request.model {
|
||||
Some(CommonImageModel::Seedream5p0ProUltra) => KinoviAccount::BytePlusUltra,
|
||||
// Everything else goes through Volcengine
|
||||
_ => KinoviAccount::Volcengine,
|
||||
};
|
||||
|
||||
// ==================== DEBUG LOG: HTTP REQUEST ==================== //
|
||||
|
||||
let ip_address = get_request_ip(&http_request);
|
||||
@@ -168,6 +178,7 @@ pub async fn omni_gen_image_generate_handler(
|
||||
server_state: &server_state,
|
||||
user_token,
|
||||
resolved_media: &resolved_media,
|
||||
kinovi_account,
|
||||
debug_log_context: &debug_log_context,
|
||||
mysql_connection,
|
||||
}).await;
|
||||
@@ -286,10 +297,11 @@ pub async fn omni_gen_image_generate_handler(
|
||||
GenerateImageResponse::Seedance2Pro(payload) => {
|
||||
info!("Inserting seedance2pro image job(s) with token: {:?}", pipeline_result.apriori_job_token);
|
||||
|
||||
// The image-side omni pipeline always dispatches Midjourney via the
|
||||
// Volcengine Kinovi account today. If we ever route to BytePlus /
|
||||
// BytePlus Ultra here, mirror the video-side `kinovi_account` knob.
|
||||
let kinovi_version = KinoviVersion::Volcengine;
|
||||
let kinovi_version = match kinovi_account {
|
||||
KinoviAccount::Volcengine => KinoviVersion::Volcengine,
|
||||
KinoviAccount::BytePlus => KinoviVersion::BytePlus,
|
||||
KinoviAccount::BytePlusUltra => KinoviVersion::BytePlusUltra,
|
||||
};
|
||||
|
||||
let result = insert_seedance2pro_jobs(InsertSeedance2proJobsArgs {
|
||||
primary_order_id: &payload.order_id,
|
||||
|
||||
+19
-27
@@ -7,14 +7,13 @@ use artcraft_router::client::router_client::RouterClient;
|
||||
use artcraft_router::client::router_fal_client::RouterFalClient;
|
||||
use artcraft_router::client::router_seedance2pro_client::RouterSeedance2ProClient;
|
||||
use artcraft_router::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder;
|
||||
use artcraft_router::generate::generate_image::generate_image_response::{FalImageResponsePayload, GenerateImageResponse};
|
||||
use artcraft_router::generate::generate_image::generate_image_response::GenerateImageResponse;
|
||||
use artcraft_router::generate::generate_image::image_generation_draft_context::ImageGenerationDraftContext;
|
||||
use artcraft_router::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest;
|
||||
use artcraft_router::generate::generate_image::image_generation_request::ImageGenerationRequest;
|
||||
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
|
||||
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
|
||||
use tokens::tokens::users::UserToken;
|
||||
use uuid_utils::uuid::generate_random_uuid;
|
||||
|
||||
use crate::billing::wallets::attempt_wallet_deduction::attempt_wallet_deduction_else_common_web_error;
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
@@ -22,6 +21,7 @@ use crate::http_server::endpoints::generate::common::generation_debug_logs::{
|
||||
insert_provider_request_debug_log, provider_request_debug_log_type, GenerationDebugLogContext,
|
||||
};
|
||||
use crate::http_server::endpoints::omni_gen::generate::image::pipeline_result::ImagePipelineResult;
|
||||
use crate::http_server::endpoints::omni_gen::shared_utils::kinovi_account::KinoviAccount;
|
||||
use crate::http_server::endpoints::omni_gen::shared_utils::map_seedance2pro_router_error::map_router_error_to_web_error;
|
||||
use crate::state::server_state::ServerState;
|
||||
use crate::util::lookup::lookup_media_files_as_cdn_url_list_and_map::MediaFilesAsCdnUrlListAndMap;
|
||||
@@ -31,6 +31,7 @@ pub struct RunPipelineV2Args<'a> {
|
||||
pub server_state: &'a ServerState,
|
||||
pub user_token: &'a UserToken,
|
||||
pub resolved_media: &'a MediaFilesAsCdnUrlListAndMap,
|
||||
pub kinovi_account: KinoviAccount,
|
||||
pub debug_log_context: &'a GenerationDebugLogContext<'a>,
|
||||
/// The handler's open connection. The pipeline uses it for its remaining
|
||||
/// pre-request DB writes (billing, outbound-request debug log) and releases
|
||||
@@ -50,6 +51,7 @@ pub async fn run_pipeline_v2(
|
||||
server_state,
|
||||
user_token,
|
||||
resolved_media,
|
||||
kinovi_account,
|
||||
debug_log_context,
|
||||
mut mysql_connection,
|
||||
} = args;
|
||||
@@ -98,7 +100,7 @@ pub async fn run_pipeline_v2(
|
||||
// across the external generation call below.
|
||||
drop(mysql_connection);
|
||||
|
||||
let response = finalize_and_generate(draft_or_request, server_state, resolved_media).await?;
|
||||
let response = finalize_and_generate(draft_or_request, server_state, resolved_media, kinovi_account).await?;
|
||||
|
||||
Ok(ImagePipelineResult {
|
||||
apriori_job_token,
|
||||
@@ -120,14 +122,16 @@ fn build_execution_request(
|
||||
}
|
||||
|
||||
/// Route each image model to the provider that fulfils it. Midjourney and
|
||||
/// Seedream 5.0 Pro are served via the Seedance2Pro/Kinovi (Volcengine)
|
||||
/// backend; everything else flows through Fal.
|
||||
/// Seedream 5.0 Pro (both variants) are served via the Seedance2Pro/Kinovi
|
||||
/// backend; everything else flows through Fal. Which Kinovi account fulfils
|
||||
/// a Kinovi request is the handler's `kinovi_account` choice.
|
||||
fn provider_for_model(model: RouterImageModel) -> RouterProvider {
|
||||
match model {
|
||||
RouterImageModel::Midjourney7
|
||||
| RouterImageModel::Midjourney7Niji
|
||||
| RouterImageModel::Midjourney8
|
||||
| RouterImageModel::Seedream5p0Pro => RouterProvider::Seedance2Pro,
|
||||
| RouterImageModel::Seedream5p0Pro
|
||||
| RouterImageModel::Seedream5p0ProUltra => RouterProvider::Seedance2Pro,
|
||||
_ => RouterProvider::Fal,
|
||||
}
|
||||
}
|
||||
@@ -173,24 +177,10 @@ async fn finalize_and_generate(
|
||||
draft_or_request: ImageGenerationDraftOrRequest,
|
||||
server_state: &ServerState,
|
||||
resolved_media: &MediaFilesAsCdnUrlListAndMap,
|
||||
kinovi_account: KinoviAccount,
|
||||
) -> Result<GenerateImageResponse, CommonWebError> {
|
||||
// Dev-only: skip the external provider entirely and hand back a synthetic
|
||||
// Fal enqueue. The handler then inserts a normal pending job (external id
|
||||
// `fake_…`), which the dev fake-job resolver thread later completes.
|
||||
if server_state.dev_fake_generation {
|
||||
let fake_request_id = format!("fake_{}", generate_random_uuid());
|
||||
info!("DEV_FAKE_GENERATION: skipping image provider call, request_id={}", fake_request_id);
|
||||
return Ok(GenerateImageResponse::Fal(FalImageResponsePayload {
|
||||
request_id: Some(fake_request_id),
|
||||
gateway_request_id: None,
|
||||
maybe_status_url: None,
|
||||
maybe_response_url: None,
|
||||
maybe_outbound_request: None,
|
||||
}));
|
||||
}
|
||||
|
||||
let provider = draft_or_request.get_provider();
|
||||
let client = build_router_client(provider, server_state)?;
|
||||
let client = build_router_client(provider, server_state, kinovi_account)?;
|
||||
|
||||
// Inline the draft finalization so the draft context can borrow `client`.
|
||||
let request: ImageGenerationRequest = match draft_or_request {
|
||||
@@ -220,6 +210,7 @@ async fn finalize_and_generate(
|
||||
fn build_router_client(
|
||||
provider: RouterProvider,
|
||||
server_state: &ServerState,
|
||||
kinovi_account: KinoviAccount,
|
||||
) -> Result<RouterClient, CommonWebError> {
|
||||
match provider {
|
||||
RouterProvider::Fal => {
|
||||
@@ -230,11 +221,12 @@ fn build_router_client(
|
||||
Ok(RouterClient::Fal(fal_client))
|
||||
},
|
||||
RouterProvider::Seedance2Pro => {
|
||||
// Midjourney image generation always uses the Volcengine account.
|
||||
// Per-request account selection (like the video flow's
|
||||
// `KinoviAccount` enum) isn't needed here yet — if it ever is, plumb
|
||||
// a parallel knob through `RunPipelineV2Args`.
|
||||
let cookies = server_state.inference_providers.seedance2pro.cookies_volcengine.clone();
|
||||
let seedance2pro = &server_state.inference_providers.seedance2pro;
|
||||
let cookies = match kinovi_account {
|
||||
KinoviAccount::Volcengine => seedance2pro.cookies_volcengine.clone(),
|
||||
KinoviAccount::BytePlus => seedance2pro.cookies_byteplus.clone(),
|
||||
KinoviAccount::BytePlusUltra => seedance2pro.cookies_byteplus_ultra.clone(),
|
||||
};
|
||||
let session = Seedance2ProSession::from_cookies_string(cookies);
|
||||
Ok(RouterClient::Seedance2Pro(RouterSeedance2ProClient::new(session)))
|
||||
},
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@ use crate::http_server::endpoints::omni_gen::generate::mesh::helpers::pipeline_r
|
||||
use crate::http_server::endpoints::omni_gen::generate::mesh::helpers::resolve_media_tokens_to_urls::resolve_media_tokens_to_urls;
|
||||
use crate::http_server::endpoints::omni_gen::generate::video::helpers::bill_wallet::bill_wallet;
|
||||
use crate::http_server::endpoints::omni_gen::generate::video::helpers::build_router_client::build_router_client;
|
||||
use crate::http_server::endpoints::omni_gen::generate::video::kinovi_account::KinoviAccount;
|
||||
use crate::http_server::endpoints::omni_gen::shared_utils::kinovi_account::KinoviAccount;
|
||||
use crate::http_server::endpoints::omni_gen::shared_utils::map_seedance2pro_router_error::map_router_error_to_web_error;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ use crate::http_server::endpoints::omni_gen::generate::splat::helpers::pipeline_
|
||||
use crate::http_server::endpoints::omni_gen::generate::splat::helpers::resolve_media_tokens_to_urls::resolve_media_tokens_to_urls;
|
||||
use crate::http_server::endpoints::omni_gen::generate::video::helpers::bill_wallet::bill_wallet;
|
||||
use crate::http_server::endpoints::omni_gen::generate::video::helpers::build_router_client::build_router_client;
|
||||
use crate::http_server::endpoints::omni_gen::generate::video::kinovi_account::KinoviAccount;
|
||||
use crate::http_server::endpoints::omni_gen::shared_utils::kinovi_account::KinoviAccount;
|
||||
use crate::http_server::endpoints::omni_gen::shared_utils::splat::map_worldlabs_router_error::map_worldlabs_router_error;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
|
||||
use worldlabs_api_client::credentials::world_labs_api_creds::WorldLabsApiCreds;
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::endpoints::omni_gen::generate::video::kinovi_account::KinoviAccount;
|
||||
use crate::http_server::endpoints::omni_gen::shared_utils::kinovi_account::KinoviAccount;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
pub fn build_router_client(
|
||||
|
||||
-1
@@ -2,4 +2,3 @@ pub mod omni_gen_video_generate_handler;
|
||||
pub mod helpers;
|
||||
pub mod pipeline_v2;
|
||||
pub mod insert_db_job;
|
||||
pub mod kinovi_account;
|
||||
|
||||
+1
-1
@@ -40,7 +40,7 @@ use crate::http_server::endpoints::omni_gen::generate::video::insert_db_job::ins
|
||||
use crate::http_server::endpoints::omni_gen::generate::video::insert_db_job::insert_grok_api_job::{insert_grok_api_job, InsertGrokApiJobArgs};
|
||||
use crate::http_server::endpoints::omni_gen::generate::video::insert_db_job::insert_seedance2pro_jobs::{insert_seedance2pro_jobs, InsertSeedance2proJobsArgs};
|
||||
use crate::http_server::endpoints::omni_gen::generate::video::insert_db_job::shared_job_args::SharedJobArgs;
|
||||
use crate::http_server::endpoints::omni_gen::generate::video::kinovi_account::KinoviAccount;
|
||||
use crate::http_server::endpoints::omni_gen::shared_utils::kinovi_account::KinoviAccount;
|
||||
use crate::http_server::endpoints::omni_gen::generate::video::pipeline_v2::run_pipeline_v2::{run_pipeline_v2, RunPipelineV2Args};
|
||||
use crate::http_server::endpoints::omni_gen::shared_utils::video::validate_video_request::validate_video_request;
|
||||
use crate::http_server::user_lookup::user_session::session_utils::lookup::user_session_feature_flags::UserSessionFeatureFlags;
|
||||
|
||||
+2
-19
@@ -6,13 +6,12 @@ use log::{error, info, warn};
|
||||
use artcraft_router::api::router_video_model::RouterVideoModel;
|
||||
use artcraft_router::api::router_provider::RouterProvider;
|
||||
use artcraft_router::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use artcraft_router::generate::generate_video::generate_video_response::{FalVideoResponsePayload, GenerateVideoResponse};
|
||||
use artcraft_router::generate::generate_video::generate_video_response::GenerateVideoResponse;
|
||||
use artcraft_router::generate::generate_video::video_generation_draft_context::VideoGenerationDraftContext;
|
||||
use artcraft_router::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
use tokens::tokens::characters::CharacterToken;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
use tokens::tokens::users::UserToken;
|
||||
use uuid_utils::uuid::generate_random_uuid;
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::endpoint_helpers::refund_wallet_after_api_failure::refund_wallet_after_api_failure;
|
||||
@@ -24,7 +23,7 @@ use crate::http_server::endpoints::omni_gen::generate::video::helpers::build_rou
|
||||
use crate::http_server::endpoints::omni_gen::generate::video::helpers::pipeline_result::PipelineResult;
|
||||
use crate::http_server::endpoints::omni_gen::generate::video::helpers::resolve_media_tokens_to_urls::resolve_media_tokens_to_urls;
|
||||
use mysql_queries::queries::generic_inference::common::job_cost_estimates::JobCostEstimates;
|
||||
use crate::http_server::endpoints::omni_gen::generate::video::kinovi_account::KinoviAccount;
|
||||
use crate::http_server::endpoints::omni_gen::shared_utils::kinovi_account::KinoviAccount;
|
||||
use crate::http_server::endpoints::omni_gen::shared_utils::map_seedance2pro_router_error::map_router_error_to_web_error;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
@@ -221,22 +220,6 @@ async fn upload_and_generate(
|
||||
kinovi_account: KinoviAccount,
|
||||
) -> Result<GenerateVideoResponse, CommonWebError> {
|
||||
|
||||
// Dev-only: skip the external provider entirely (including draft media
|
||||
// uploads) and hand back a synthetic Fal enqueue. The handler then inserts
|
||||
// a normal pending job (external id `fake_…`), which the dev fake-job
|
||||
// resolver thread later completes.
|
||||
if server_state.dev_fake_generation {
|
||||
let fake_request_id = format!("fake_{}", generate_random_uuid());
|
||||
info!("DEV_FAKE_GENERATION: skipping video provider call, request_id={}", fake_request_id);
|
||||
return Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload {
|
||||
request_id: Some(fake_request_id),
|
||||
gateway_request_id: None,
|
||||
maybe_status_url: None,
|
||||
maybe_response_url: None,
|
||||
maybe_outbound_request: None,
|
||||
}));
|
||||
}
|
||||
|
||||
let provider = draft_or_request.get_provider();
|
||||
let client = build_router_client(provider, server_state, kinovi_account)?;
|
||||
|
||||
|
||||
+1
@@ -1,4 +1,5 @@
|
||||
pub mod audio;
|
||||
pub mod kinovi_account;
|
||||
pub mod map_router_cost_error;
|
||||
pub mod map_seedance2pro_router_error;
|
||||
pub mod mesh;
|
||||
|
||||
@@ -4,7 +4,6 @@ use actix_web::dev::{ServiceRequest, ServiceResponse};
|
||||
use actix_web::error::Error;
|
||||
use actix_web::App;
|
||||
|
||||
use log::{info, warn};
|
||||
use server_environment::ServerEnvironment;
|
||||
|
||||
use crate::http_server::routes::application_routes::add_application_routes::add_application_routes;
|
||||
@@ -23,45 +22,13 @@ pub fn add_routes<T, B> (app: App<T>, server_environment: ServerEnvironment) ->
|
||||
>,
|
||||
{
|
||||
let mut app = app;
|
||||
|
||||
|
||||
app = add_legacy_routes(app); // various legacy routes, mostly deprecated
|
||||
app = add_application_routes(app); // Primary product service area routes
|
||||
app = add_local_media_route(app, server_environment); // Dev-only static media (fully-local stack)
|
||||
app = add_service_routes(app); // Essential service routes (status, health, info, etc.)
|
||||
|
||||
|
||||
app
|
||||
}
|
||||
|
||||
/// Dev-only: serve media files from a local directory at /media, mirroring the
|
||||
/// public bucket's layout, so a fully-local stack (CDN_BASE_URL pointed at this
|
||||
/// server) can render seeded and fake-generated media without any cloud bucket.
|
||||
/// LOCAL_MEDIA_ROOT is the bucket root: an object with rooted path
|
||||
/// /media/{...} is read from {LOCAL_MEDIA_ROOT}/media/{...}.
|
||||
/// No-op in production and when LOCAL_MEDIA_ROOT is unset.
|
||||
fn add_local_media_route<T, B> (app: App<T>, server_environment: ServerEnvironment) -> App<T>
|
||||
where
|
||||
B: MessageBody,
|
||||
T: ServiceFactory<
|
||||
ServiceRequest,
|
||||
Config = (),
|
||||
Response = ServiceResponse<B>,
|
||||
Error = Error,
|
||||
InitError = (),
|
||||
>,
|
||||
{
|
||||
if !server_environment.is_development() {
|
||||
return app;
|
||||
}
|
||||
let root = match std::env::var("LOCAL_MEDIA_ROOT") {
|
||||
Ok(root) if !root.trim().is_empty() => root,
|
||||
_ => return app,
|
||||
};
|
||||
let media_dir = std::path::Path::new(&root).join("media");
|
||||
if let Err(err) = std::fs::create_dir_all(&media_dir) {
|
||||
// Fall back to remote CDN behavior rather than refusing to boot.
|
||||
warn!("LOCAL_MEDIA_ROOT set but '{}' is not creatable ({}); /media not mounted", media_dir.display(), err);
|
||||
return app;
|
||||
}
|
||||
info!("Dev local media: serving {} at /media", media_dir.display());
|
||||
app.service(actix_files::Files::new("/media", media_dir))
|
||||
}
|
||||
|
||||
|
||||
|
||||
+7
@@ -35,6 +35,7 @@ use crate::http_server::endpoints::moderation::user::moderator_list_users_by_sig
|
||||
use crate::http_server::endpoints::moderation::user::moderator_user_lookup_by_stripe_customer_id_handler::moderator_user_lookup_by_stripe_customer_id_handler;
|
||||
use crate::http_server::endpoints::moderation::user::moderator_user_lookup_handler::moderator_user_lookup_handler;
|
||||
use crate::http_server::endpoints::moderation::alerts::moderation_send_alert_handler::moderation_send_alert_handler;
|
||||
use crate::http_server::endpoints::moderation::dashboards::moderator_list_databox_dashboards_handler::moderator_list_databox_dashboards_handler;
|
||||
use crate::http_server::endpoints::moderation::staff_audit_logs::moderator_list_staff_audit_logs_handler::moderator_list_staff_audit_logs_handler;
|
||||
use crate::http_server::endpoints::moderation::debug_logs::moderation_list_all_debug_logs_handler::moderation_list_all_debug_logs_handler;
|
||||
use crate::http_server::endpoints::moderation::debug_logs::moderation_list_debug_logs_for_token_handler::moderation_list_debug_logs_for_token_handler;
|
||||
@@ -106,6 +107,12 @@ pub fn add_moderator_routes<T, B> (app: App<T>) -> App<T>
|
||||
.route(web::head().to(|| HttpResponse::Ok()))
|
||||
)
|
||||
)
|
||||
.service(web::scope("/dashboards")
|
||||
.service(web::resource("/databox")
|
||||
.route(web::get().to(moderator_list_databox_dashboards_handler))
|
||||
.route(web::head().to(|| HttpResponse::Ok()))
|
||||
)
|
||||
)
|
||||
.service(web::scope("/debug_logs")
|
||||
.service(web::resource("/list/{token}")
|
||||
.route(web::get().to(moderation_list_debug_logs_for_token_handler))
|
||||
|
||||
@@ -55,7 +55,6 @@ use crate::startup::setup_disabled_endpoints::read_disabled_endpoints;
|
||||
use crate::startup::setup_metrics::build_metrics;
|
||||
use crate::state::server_state::ServerState;
|
||||
use crate::threads::db_health_checker_thread::db_health_checker_thread::db_health_checker_thread;
|
||||
use crate::threads::dev_fake_generation_resolver_thread::dev_fake_generation_resolver_thread;
|
||||
use crate::threads::poll_ip_banlist_thread::poll_ip_bans;
|
||||
use crate::threads::poll_model_token_info_thread::poll_model_token_info_thread;
|
||||
|
||||
@@ -149,16 +148,6 @@ async fn main() -> AnyhowResult<()> {
|
||||
poll_model_token_info_thread(model_token_info_cache_clone, mysql_pool_clone).await;
|
||||
});
|
||||
|
||||
if server_state.dev_fake_generation {
|
||||
info!("Spawning dev fake-generation resolver thread (DEV_FAKE_GENERATION=true).");
|
||||
|
||||
let mysql_pool_clone = server_state.mysql_pool.clone();
|
||||
|
||||
tokio_runtime.spawn(async {
|
||||
dev_fake_generation_resolver_thread(mysql_pool_clone).await;
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== Metrics worker ==================== //
|
||||
|
||||
let (metrics_collector, maybe_metrics_worker) =
|
||||
|
||||
@@ -9,7 +9,7 @@ use cloud_storage::legacy_bucket_client::LegacyBucketClient;
|
||||
use elasticsearch::http::transport::Transport;
|
||||
use elasticsearch::Elasticsearch;
|
||||
use errors::AnyhowResult;
|
||||
use log::{info, warn};
|
||||
use log::info;
|
||||
use memory_caching::arc_ttl_sieve::ArcTtlSieve;
|
||||
use memory_caching::single_item_ttl_cache::SingleItemTtlCache;
|
||||
use mysql_queries::mediators::badge_granter::BadgeGranter;
|
||||
@@ -34,6 +34,7 @@ use crate::startup::setup_bans::{
|
||||
load_static_container_ip_bans, load_troll_user_token_bans,
|
||||
};
|
||||
use crate::startup::setup_inference_providers::setup_inference_providers;
|
||||
use crate::startup::setup_dashboards::setup_dashboards;
|
||||
use crate::startup::setup_seedance_video_bucket::setup_seedance_video_bucket;
|
||||
use crate::startup::setup_static_feature_flags::setup_static_feature_flags;
|
||||
use crate::startup::setup_stripe_artcraft::setup_stripe_artcraft;
|
||||
@@ -124,38 +125,20 @@ pub async fn setup_dependencies(server_hostname: &str) -> AnyhowResult<SetupResu
|
||||
let s3_compatible_endpoint_url = easyenv::get_env_string_or_default("S3_COMPATIBLE_ENDPOINT_URL", "https://storage.googleapis.com");
|
||||
let bucket_timeout = easyenv::get_env_duration_seconds_or_default("BUCKET_TIMEOUT_SECONDS", Duration::from_secs(60 * 5));
|
||||
|
||||
// NB: read here (before the bucket clients) rather than at first use below —
|
||||
// the fully-local dev storage mode is gated on the environment.
|
||||
let server_environment = ServerEnvironment::from_str(&easyenv::get_env_string_required("SERVER_ENVIRONMENT")?)
|
||||
.ok_or(anyhow!("invalid server environment"))?;
|
||||
let private_bucket_client = LegacyBucketClient::create(
|
||||
&access_key, &secret_key, ®ion_name, &private_bucket_name,
|
||||
&s3_compatible_endpoint_url, None, Some(bucket_timeout),
|
||||
)?;
|
||||
|
||||
// Dev-only: when LOCAL_MEDIA_ROOT is set (the fully-local dev stack), back
|
||||
// all bucket "uploads" with the local filesystem instead of S3/R2. Objects
|
||||
// land under the same directory the dev server serves at /media, so
|
||||
// uploaded media is immediately viewable via the CDN_BASE_URL override.
|
||||
// Production and dev-against-real-buckets are unaffected (env-gated).
|
||||
let maybe_local_media_root = if server_environment.is_development() {
|
||||
std::env::var("LOCAL_MEDIA_ROOT").ok().filter(|root| !root.trim().is_empty())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(root) = maybe_local_media_root.as_deref() {
|
||||
warn!("Local filesystem storage mode is ON: bucket uploads write under {} (LOCAL_MEDIA_ROOT).", root);
|
||||
}
|
||||
let public_bucket_client = LegacyBucketClient::create(
|
||||
&access_key, &secret_key, ®ion_name, &public_bucket_name,
|
||||
&s3_compatible_endpoint_url, None, Some(bucket_timeout),
|
||||
)?;
|
||||
|
||||
let make_bucket_client = |bucket_name: &str| -> AnyhowResult<LegacyBucketClient> {
|
||||
match maybe_local_media_root.as_deref() {
|
||||
Some(root) => Ok(LegacyBucketClient::create_local(root, bucket_name, None)),
|
||||
None => LegacyBucketClient::create(
|
||||
&access_key, &secret_key, ®ion_name, bucket_name,
|
||||
&s3_compatible_endpoint_url, None, Some(bucket_timeout),
|
||||
),
|
||||
}
|
||||
};
|
||||
|
||||
let private_bucket_client = make_bucket_client(&private_bucket_name)?;
|
||||
let public_bucket_client = make_bucket_client(&public_bucket_name)?;
|
||||
let auto_gc_bucket_client = make_bucket_client(&gc_enabled_public_bucket_name)?;
|
||||
let auto_gc_bucket_client = LegacyBucketClient::create(
|
||||
&access_key, &secret_key, ®ion_name, &gc_enabled_public_bucket_name,
|
||||
&s3_compatible_endpoint_url, None, Some(bucket_timeout),
|
||||
)?;
|
||||
|
||||
// In-Memory Caches
|
||||
let voice_list_cache_ttl = easyenv::get_env_duration_seconds_or_default("VOICE_LIST_CACHE_TTL_SECONDS", Duration::from_secs(60));
|
||||
@@ -193,6 +176,9 @@ pub async fn setup_dependencies(server_hostname: &str) -> AnyhowResult<SetupResu
|
||||
|
||||
let health_check_status = HealthCheckStatus::new();
|
||||
|
||||
let server_environment = ServerEnvironment::from_str(&easyenv::get_env_string_required("SERVER_ENVIRONMENT")?)
|
||||
.ok_or(anyhow!("invalid server environment"))?;
|
||||
|
||||
let (pager, pager_worker, paging_flags) = build_pager(server_environment, server_hostname);
|
||||
|
||||
let service_feature_flags = setup_static_feature_flags(paging_flags)?;
|
||||
@@ -207,15 +193,6 @@ pub async fn setup_dependencies(server_hostname: &str) -> AnyhowResult<SetupResu
|
||||
|
||||
let inference_providers = setup_inference_providers()?;
|
||||
|
||||
// Dev-only fake generation (see the field docs on ServerState). The
|
||||
// environment gate is enforced here so the flag can never arm in production
|
||||
// even if the env var leaks into a deployed config.
|
||||
let dev_fake_generation = server_environment.is_development()
|
||||
&& easyenv::get_env_bool_or_default("DEV_FAKE_GENERATION", false);
|
||||
if dev_fake_generation {
|
||||
warn!("DEV_FAKE_GENERATION is ON: omni_gen requests will not contact real providers.");
|
||||
}
|
||||
|
||||
let resend_api_key = easyenv::get_env_string_required("RESEND_API_KEY")?;
|
||||
|
||||
let startup_time = Utc::now();
|
||||
@@ -237,7 +214,6 @@ pub async fn setup_dependencies(server_hostname: &str) -> AnyhowResult<SetupResu
|
||||
hostname: server_hostname.to_string(),
|
||||
startup_time,
|
||||
server_environment,
|
||||
dev_fake_generation,
|
||||
flags: service_feature_flags,
|
||||
third_party_url_redirector,
|
||||
health_check_status,
|
||||
@@ -287,6 +263,7 @@ pub async fn setup_dependencies(server_hostname: &str) -> AnyhowResult<SetupResu
|
||||
},
|
||||
temp_dir_creator: ScopedTempDirCreator::auto_setup(),
|
||||
google_sign_in_cert: GoogleSignInCert::new(),
|
||||
dashboards: setup_dashboards(),
|
||||
};
|
||||
|
||||
Ok(SetupResult {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod build_dependencies;
|
||||
pub mod setup_bans;
|
||||
pub mod setup_dashboards;
|
||||
pub mod setup_metrics;
|
||||
pub mod setup_pager;
|
||||
pub mod setup_seedance_video_bucket;
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
//! Optional setup for the admin dashboard's embedded metrics dashboards.
|
||||
//!
|
||||
//! Reads the `DATABOX_DASHBOARD_*` env vars. Each is independent and
|
||||
//! optional — a missing var means that dashboard is simply not offered to
|
||||
//! the admin dashboard. Never blocks startup.
|
||||
|
||||
use std::env;
|
||||
|
||||
use log::info;
|
||||
|
||||
use crate::state::server_state::{Dashboards, DataboxDashboards};
|
||||
|
||||
/// Build the dashboard configuration from the environment.
|
||||
pub fn setup_dashboards() -> Dashboards {
|
||||
let daus_id = read_non_empty_env("DATABOX_DASHBOARD_DAUS");
|
||||
let daily_generations_id = read_non_empty_env("DATABOX_DASHBOARD_DAILY_GENERATIONS");
|
||||
|
||||
info!(
|
||||
"Databox dashboards configured: daus={}, daily_generations={}",
|
||||
daus_id.is_some(),
|
||||
daily_generations_id.is_some(),
|
||||
);
|
||||
|
||||
Dashboards {
|
||||
databox: DataboxDashboards {
|
||||
daus_id,
|
||||
daily_generations_id,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Read an env var, treating unset or whitespace-only as absent.
|
||||
fn read_non_empty_env(name: &str) -> Option<String> {
|
||||
match env::var(name) {
|
||||
Ok(value) if !value.trim().is_empty() => Some(value.trim().to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -57,13 +57,6 @@ pub struct ServerState {
|
||||
/// Knowing if we're in production will allow us to turn off development-only functionalities.
|
||||
pub server_environment: ServerEnvironment,
|
||||
|
||||
/// Dev-only (DEV_FAKE_GENERATION=true, forced off outside Development):
|
||||
/// omni_gen pipelines skip the external provider call and insert a pending
|
||||
/// job with a synthetic `fake_` external id, which the dev fake-job
|
||||
/// resolver thread later completes or fails. Lets a local stack with dummy
|
||||
/// provider secrets exercise the full submit -> pending -> resolve flow.
|
||||
pub dev_fake_generation: bool,
|
||||
|
||||
/// Feature flags will allow us to restart the service with different conditions embedded in the code.
|
||||
pub flags: StaticFeatureFlags,
|
||||
|
||||
@@ -121,6 +114,10 @@ pub struct ServerState {
|
||||
pub google_sign_in_cert: GoogleSignInCert,
|
||||
|
||||
pub temp_dir_creator: ScopedTempDirCreator,
|
||||
|
||||
/// Embedded metrics dashboards for the admin dashboard, configured from
|
||||
/// optional env vars at startup.
|
||||
pub dashboards: Dashboards,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -283,3 +280,17 @@ pub struct TrollBans {
|
||||
pub struct ResendData {
|
||||
pub api_key: String,
|
||||
}
|
||||
|
||||
/// Embedded metrics dashboards surfaced to admins/mods.
|
||||
#[derive(Clone)]
|
||||
pub struct Dashboards {
|
||||
pub databox: DataboxDashboards,
|
||||
}
|
||||
|
||||
/// Databox datawall IDs. Each is `None` when its env var isn't configured,
|
||||
/// in which case the dashboard is simply omitted from the moderation API.
|
||||
#[derive(Clone)]
|
||||
pub struct DataboxDashboards {
|
||||
pub daus_id: Option<String>,
|
||||
pub daily_generations_id: Option<String>,
|
||||
}
|
||||
|
||||
-194
@@ -1,194 +0,0 @@
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
use cloud_storage::legacy_bucket_client::local_object_disk_path;
|
||||
use log::{error, info};
|
||||
use sqlx::MySqlPool;
|
||||
|
||||
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
|
||||
use bucket_paths::path_conventions::video_thumbnail_suffixes::{VIDEO_ANIMATED_GIF_THUMBNAIL_SUFFIX, VIDEO_STATIC_JPG_THUMBNAIL_SUFFIX};
|
||||
use enums::by_table::generic_inference_jobs::inference_result_type::InferenceResultType;
|
||||
use enums::by_table::media_files::media_file_class::MediaFileClass;
|
||||
use enums::by_table::media_files::media_file_origin_category::MediaFileOriginCategory;
|
||||
use enums::by_table::media_files::media_file_origin_product_category::MediaFileOriginProductCategory;
|
||||
use enums::by_table::media_files::media_file_type::MediaFileType;
|
||||
use enums::common::generation_provider::GenerationProvider;
|
||||
use errors::AnyhowResult;
|
||||
use hashing::sha256::sha256_hash_bytes::sha256_hash_bytes;
|
||||
use mysql_queries::queries::generic_inference::job::mark_job_failed_by_token::{mark_job_failed_by_token, MarkJobFailedByTokenArgs};
|
||||
use mysql_queries::queries::generic_inference::web::list_pending_fake_generation_jobs::{list_pending_fake_generation_jobs, PendingFakeGenerationJob};
|
||||
use mysql_queries::queries::generic_inference::web::mark_generic_inference_job_successfully_done_by_token_with_executor::{
|
||||
mark_generic_inference_job_successfully_done_by_token_with_executor,
|
||||
MarkGenericInferenceJobSuccessfullyDoneByTokenWithExecutorArgs,
|
||||
};
|
||||
use mysql_queries::queries::media_files::create::insert_builder::media_file_insert_builder::MediaFileInsertBuilder;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
use crate::http_server::endpoints::generate::common::job_failure_test::test_synthetic_failure_reason;
|
||||
|
||||
/// Placeholder result assets, relative to the repo root (the server's working
|
||||
/// directory — its config search path already depends on that).
|
||||
const FAKE_IMAGE_RESULT_SOURCE: &str = "test_data/image/juno.jpg";
|
||||
const FAKE_VIDEO_RESULT_SOURCE: &str = "test_data/video/mp4/golden_sun_garoh.mp4";
|
||||
|
||||
const JOBS_PER_TICK: u32 = 25;
|
||||
|
||||
/// Dev-only companion to DEV_FAKE_GENERATION (see ServerState): polls for
|
||||
/// pending jobs whose external id is `fake_…` and, once they are older than
|
||||
/// DEV_FAKE_GENERATION_RESOLVE_SECS, resolves them the way a real provider
|
||||
/// callback would — a `media_files` row backed by a placeholder asset under
|
||||
/// LOCAL_MEDIA_ROOT for success, or a synthetic failure when the prompt
|
||||
/// contains the `simulate_artcraft_failure` / `test_artcraft_failure`
|
||||
/// triggers (with an optional FrontendFailureCategory word, same as the
|
||||
/// legacy handlers).
|
||||
pub async fn dev_fake_generation_resolver_thread(mysql_pool: MySqlPool) {
|
||||
let resolve_after = easyenv::get_env_duration_seconds_or_default(
|
||||
"DEV_FAKE_GENERATION_RESOLVE_SECS", Duration::from_secs(6));
|
||||
|
||||
let poll_interval = easyenv::get_env_duration_seconds_or_default(
|
||||
"DEV_FAKE_GENERATION_POLL_SECS", Duration::from_secs(2));
|
||||
|
||||
info!("Dev fake-generation resolver running: jobs resolve after ~{}s (poll every {}s).",
|
||||
resolve_after.as_secs(), poll_interval.as_secs());
|
||||
|
||||
loop {
|
||||
tokio::time::sleep(poll_interval).await;
|
||||
|
||||
let jobs = match list_pending_fake_generation_jobs(
|
||||
&mysql_pool,
|
||||
resolve_after.as_secs() as u32,
|
||||
JOBS_PER_TICK,
|
||||
).await {
|
||||
Ok(jobs) => jobs,
|
||||
Err(err) => {
|
||||
error!("Dev fake-generation resolver failed to list pending jobs: {:?}", err);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
for job in jobs {
|
||||
let job_token = job.job_token.clone();
|
||||
if let Err(err) = resolve_job(&mysql_pool, &job, resolve_after).await {
|
||||
error!("Dev fake-generation resolver failed on job {}: {:?}", job_token.as_str(), err);
|
||||
// Fail the job so the frontend sees a terminal state instead of an
|
||||
// eternally-pending spinner (and so we don't retry it every tick).
|
||||
let mark_result = mark_job_failed_by_token(MarkJobFailedByTokenArgs {
|
||||
pool: &mysql_pool,
|
||||
job_token: &job_token,
|
||||
maybe_public_failure_reason: Some("Dev fake generation could not produce a result."),
|
||||
internal_debugging_failure_reason: &format!("dev fake resolver error: {:?}", err),
|
||||
maybe_frontend_failure_category: None,
|
||||
}).await;
|
||||
if let Err(mark_err) = mark_result {
|
||||
error!("Also failed to mark job {} failed: {:?}", job_token.as_str(), mark_err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn resolve_job(
|
||||
mysql_pool: &MySqlPool,
|
||||
job: &PendingFakeGenerationJob,
|
||||
simulated_duration: Duration,
|
||||
) -> AnyhowResult<()> {
|
||||
let prompt = job.maybe_positive_prompt.as_deref().unwrap_or("");
|
||||
|
||||
if let Some(failure) = test_synthetic_failure_reason(prompt) {
|
||||
mark_job_failed_by_token(MarkJobFailedByTokenArgs {
|
||||
pool: mysql_pool,
|
||||
job_token: &job.job_token,
|
||||
maybe_public_failure_reason: failure.frontend_failure_message.as_deref(),
|
||||
internal_debugging_failure_reason: "DEV_FAKE_GENERATION synthetic failure (prompt trigger)",
|
||||
maybe_frontend_failure_category: Some(failure.frontend_failure_category),
|
||||
}).await?;
|
||||
info!("Dev fake-generation: job {} failed synthetically ({:?}).",
|
||||
job.job_token.as_str(), failure.frontend_failure_category);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let media_token = insert_fake_result_media(mysql_pool, job).await?;
|
||||
|
||||
mark_generic_inference_job_successfully_done_by_token_with_executor(
|
||||
MarkGenericInferenceJobSuccessfullyDoneByTokenWithExecutorArgs {
|
||||
executor: mysql_pool,
|
||||
token: &job.job_token,
|
||||
maybe_entity_type: Some(InferenceResultType::MediaFile),
|
||||
maybe_entity_token: Some(media_token.as_str()),
|
||||
total_job_duration: Some(simulated_duration),
|
||||
inference_duration: Some(simulated_duration),
|
||||
},
|
||||
).await?;
|
||||
|
||||
info!("Dev fake-generation: job {} completed with media file {}.",
|
||||
job.job_token.as_str(), media_token.as_str());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Copy a placeholder asset to a fresh bucket path under LOCAL_MEDIA_ROOT and
|
||||
/// insert the `media_files` row, mirroring what the Fal webhook success path
|
||||
/// does with a real provider result (minus the R2 upload).
|
||||
async fn insert_fake_result_media(
|
||||
mysql_pool: &MySqlPool,
|
||||
job: &PendingFakeGenerationJob,
|
||||
) -> AnyhowResult<MediaFileToken> {
|
||||
let is_video = job.inference_category.contains("video");
|
||||
|
||||
let (source_path, extension, media_class, media_type, mime_type, product_category) = if is_video {
|
||||
(FAKE_VIDEO_RESULT_SOURCE, ".mp4", MediaFileClass::Video, MediaFileType::Mp4,
|
||||
"video/mp4", MediaFileOriginProductCategory::VideoGeneration)
|
||||
} else {
|
||||
(FAKE_IMAGE_RESULT_SOURCE, ".jpg", MediaFileClass::Image, MediaFileType::Jpg,
|
||||
"image/jpeg", MediaFileOriginProductCategory::ImageGeneration)
|
||||
};
|
||||
|
||||
let media_root = std::env::var("LOCAL_MEDIA_ROOT")
|
||||
.ok()
|
||||
.filter(|root| !root.trim().is_empty())
|
||||
.ok_or_else(|| anyhow::anyhow!(
|
||||
"LOCAL_MEDIA_ROOT must be set for DEV_FAKE_GENERATION results to be viewable"))?;
|
||||
|
||||
let file_bytes = tokio::fs::read(source_path).await
|
||||
.map_err(|err| anyhow::anyhow!(
|
||||
"cannot read placeholder asset {} (server must run from the repo root): {}", source_path, err))?;
|
||||
|
||||
let bucket_path = MediaFileBucketPath::generate_new(Some("fake_"), Some(extension));
|
||||
let disk_path = local_object_disk_path(Path::new(&media_root), &bucket_path.get_full_object_path_str())?;
|
||||
|
||||
if let Some(parent) = disk_path.parent() {
|
||||
tokio::fs::create_dir_all(parent).await?;
|
||||
}
|
||||
tokio::fs::write(&disk_path, &file_bytes).await?;
|
||||
|
||||
if is_video {
|
||||
// The video thumbnail job never runs locally, so drop the thumbnail
|
||||
// siblings the gallery expects next to the object. The animated "gif" is
|
||||
// really JPEG bytes — browsers decode <img> content by magic bytes, and a
|
||||
// still preview is plenty for dev.
|
||||
let thumb_bytes = tokio::fs::read(FAKE_IMAGE_RESULT_SOURCE).await?;
|
||||
let base = disk_path.to_string_lossy().to_string();
|
||||
tokio::fs::write(format!("{base}{VIDEO_STATIC_JPG_THUMBNAIL_SUFFIX}"), &thumb_bytes).await?;
|
||||
tokio::fs::write(format!("{base}{VIDEO_ANIMATED_GIF_THUMBNAIL_SUFFIX}"), &thumb_bytes).await?;
|
||||
}
|
||||
|
||||
let checksum = sha256_hash_bytes(&file_bytes)?;
|
||||
|
||||
let media_token = MediaFileInsertBuilder::new()
|
||||
.checksum_sha2(&checksum)
|
||||
.creator_ip_address("127.0.0.1")
|
||||
.file_size_bytes(file_bytes.len() as u64)
|
||||
.maybe_creator_user(job.maybe_creator_user_token.as_ref())
|
||||
.maybe_generation_provider(Some(GenerationProvider::Artcraft))
|
||||
.maybe_prompt_token(job.maybe_prompt_token.as_ref())
|
||||
.media_file_class(media_class)
|
||||
.media_file_origin_category(MediaFileOriginCategory::Inference)
|
||||
.media_file_origin_product_category(product_category)
|
||||
.media_file_type(media_type)
|
||||
.mime_type(mime_type)
|
||||
.public_bucket_directory_hash(&bucket_path)
|
||||
.insert_pool(mysql_pool)
|
||||
.await
|
||||
.map_err(|err| anyhow::anyhow!("failed to insert fake result media file: {:?}", err))?;
|
||||
|
||||
Ok(media_token)
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
pub mod db_health_checker_thread;
|
||||
pub mod dev_fake_generation_resolver_thread;
|
||||
pub mod poll_ip_banlist_thread;
|
||||
pub mod poll_model_token_info_thread;
|
||||
pub mod poll_model_token_info_thread;
|
||||
@@ -57,6 +57,12 @@ export interface UserReferralListItem {
|
||||
maybe_referral_code_token: string | null;
|
||||
}
|
||||
|
||||
/** A Databox datawall configured server-side for the admin home page. */
|
||||
export interface DataboxDashboard {
|
||||
name: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
/** Which table the customer id was found in. */
|
||||
export type ModeratorStripeCustomerIdSource = "customer_link" | "subscription";
|
||||
|
||||
@@ -475,6 +481,25 @@ export class ModerationApi extends ApiManager {
|
||||
}));
|
||||
}
|
||||
|
||||
// Databox metrics dashboards configured server-side, in display order.
|
||||
// Dashboards without a configured id are omitted by the server.
|
||||
public async ListDataboxDashboards(): Promise<
|
||||
ApiResponse<DataboxDashboard[]>
|
||||
> {
|
||||
const endpoint = `${this.getApiSchemeAndHost()}/v1/moderation/dashboards/databox`;
|
||||
return await this.get<{
|
||||
success: boolean;
|
||||
databoards: DataboxDashboard[];
|
||||
error_message?: string;
|
||||
}>({ endpoint })
|
||||
.then((response) => ({
|
||||
success: response.success,
|
||||
data: response.databoards || [],
|
||||
errorMessage: response.error_message,
|
||||
}))
|
||||
.catch((err) => ({ success: false, errorMessage: err.message }));
|
||||
}
|
||||
|
||||
public async SendAlert(params: {
|
||||
title?: string | null;
|
||||
description?: string | null;
|
||||
|
||||
@@ -120,9 +120,7 @@ export function MediaCard({
|
||||
{/* Top-left: media class + upload indicator */}
|
||||
<div className="absolute top-1.5 left-1.5 z-10 flex items-center gap-1 flex-wrap">
|
||||
<span className="px-2 py-1 rounded-md bg-black/40 text-[10px] font-medium text-white backdrop-blur-sm uppercase">
|
||||
{mediaClass === "dimensional" || mediaClass === "mesh" || mediaClass === "splat"
|
||||
? "3D"
|
||||
: mediaClass || "Unknown"}
|
||||
{mediaClass === "dimensional" ? "3D" : mediaClass || "Unknown"}
|
||||
</span>
|
||||
{isUserUpload && (
|
||||
<span
|
||||
|
||||
@@ -1,78 +1,31 @@
|
||||
import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts";
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
type ChartConfig,
|
||||
} from "@/components/ui/chart";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { IconChartBar, IconDashboard, IconWorld } from "@tabler/icons-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { IconDashboard } from "@tabler/icons-react";
|
||||
import { ModerationApi, type DataboxDashboard } from "@/api/ModerationApi";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { usePageTitle } from "@/hooks/usePageTitle";
|
||||
|
||||
const activeUsersData = [
|
||||
{ date: "02/05", activeUsers: 812 },
|
||||
{ date: "02/06", activeUsers: 945 },
|
||||
{ date: "02/07", activeUsers: 1023 },
|
||||
{ date: "02/08", activeUsers: 876 },
|
||||
{ date: "02/09", activeUsers: 654 },
|
||||
{ date: "02/10", activeUsers: 598 },
|
||||
{ date: "02/11", activeUsers: 901 },
|
||||
{ date: "02/12", activeUsers: 1102 },
|
||||
{ date: "02/13", activeUsers: 1045 },
|
||||
{ date: "02/14", activeUsers: 978 },
|
||||
{ date: "02/15", activeUsers: 1134 },
|
||||
{ date: "02/16", activeUsers: 756 },
|
||||
{ date: "02/17", activeUsers: 689 },
|
||||
{ date: "02/18", activeUsers: 1023 },
|
||||
{ date: "02/19", activeUsers: 1187 },
|
||||
{ date: "02/20", activeUsers: 1056 },
|
||||
{ date: "02/21", activeUsers: 998 },
|
||||
{ date: "02/22", activeUsers: 1210 },
|
||||
{ date: "02/23", activeUsers: 834 },
|
||||
{ date: "02/24", activeUsers: 712 },
|
||||
{ date: "02/25", activeUsers: 967 },
|
||||
{ date: "02/26", activeUsers: 1089 },
|
||||
{ date: "02/27", activeUsers: 1145 },
|
||||
{ date: "02/28", activeUsers: 1034 },
|
||||
{ date: "03/01", activeUsers: 1267 },
|
||||
{ date: "03/02", activeUsers: 890 },
|
||||
{ date: "03/03", activeUsers: 756 },
|
||||
{ date: "03/04", activeUsers: 1123 },
|
||||
{ date: "03/05", activeUsers: 1198 },
|
||||
{ date: "03/06", activeUsers: 704 },
|
||||
];
|
||||
|
||||
const countryData = [
|
||||
{ country: "United States", activeUsers: 704 },
|
||||
{ country: "India", activeUsers: 105 },
|
||||
{ country: "Canada", activeUsers: 100 },
|
||||
{ country: "China", activeUsers: 74 },
|
||||
{ country: "Brazil", activeUsers: 59 },
|
||||
{ country: "Argentina", activeUsers: 57 },
|
||||
{ country: "Australia", activeUsers: 51 },
|
||||
];
|
||||
|
||||
const activeUsersConfig = {
|
||||
activeUsers: {
|
||||
label: "Active Users",
|
||||
color: "hsl(221, 83%, 53%)",
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
const countryConfig = {
|
||||
activeUsers: {
|
||||
label: "Active Users",
|
||||
color: "hsl(142, 71%, 45%)",
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
const totalActiveUsers = activeUsersData.reduce(
|
||||
(sum, d) => sum + d.activeUsers,
|
||||
0,
|
||||
);
|
||||
function databoxEmbedUrl(id: string): string {
|
||||
return `https://app.databox.com/datawall/${encodeURIComponent(id)}?i`;
|
||||
}
|
||||
|
||||
export function DashboardHome() {
|
||||
usePageTitle("Dashboard");
|
||||
const [databoards, setDataboards] = useState<DataboxDashboard[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const api = new ModerationApi();
|
||||
api.ListDataboxDashboards().then((resp) => {
|
||||
if (cancelled) return;
|
||||
setDataboards(resp.success && resp.data ? resp.data : []);
|
||||
setIsLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
@@ -83,87 +36,27 @@ export function DashboardHome() {
|
||||
<p className="text-muted-foreground">Welcome to the admin dashboard.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-5">
|
||||
{/* Active Users Over Time */}
|
||||
<Card className="lg:col-span-3">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<IconChartBar className="size-4 text-muted-foreground" />
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Active Users (DUMMY CONTENT RIGHT NOW)
|
||||
</CardTitle>
|
||||
</div>
|
||||
<span className="text-2xl font-bold tabular-nums">
|
||||
{totalActiveUsers.toLocaleString()}
|
||||
</span>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer
|
||||
config={activeUsersConfig}
|
||||
className="aspect-auto h-[300px] w-full"
|
||||
{isLoading ? (
|
||||
<Skeleton className="w-full rounded-xl" style={{ paddingTop: "63%" }} />
|
||||
) : (
|
||||
databoards.map((databoard) => (
|
||||
<div key={databoard.id} className="flex flex-col gap-2">
|
||||
<h2 className="text-lg font-semibold">{databoard.name}</h2>
|
||||
<div
|
||||
className="relative w-full overflow-hidden rounded-xl border"
|
||||
style={{ paddingTop: "63%" }}
|
||||
>
|
||||
<BarChart data={activeUsersData}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
interval="preserveStartEnd"
|
||||
/>
|
||||
<YAxis tickLine={false} axisLine={false} tickMargin={8} />
|
||||
<ChartTooltip
|
||||
content={<ChartTooltipContent hideLabel={false} />}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="activeUsers"
|
||||
fill="var(--color-activeUsers)"
|
||||
radius={[4, 4, 0, 0]}
|
||||
/>
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Users by Country */}
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<IconWorld className="size-4 text-muted-foreground" />
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Users by Country (DUMMY CONTENT RIGHT NOW)
|
||||
</CardTitle>
|
||||
<iframe
|
||||
src={databoxEmbedUrl(databoard.id)}
|
||||
title={databoard.name}
|
||||
className="absolute inset-0 h-full w-full"
|
||||
frameBorder="0"
|
||||
allowFullScreen
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer
|
||||
config={countryConfig}
|
||||
className="aspect-auto h-[300px] w-full"
|
||||
>
|
||||
<BarChart data={countryData} layout="vertical">
|
||||
<CartesianGrid horizontal={false} />
|
||||
<XAxis type="number" tickLine={false} axisLine={false} />
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="country"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
width={100}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={<ChartTooltipContent hideLabel={false} />}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="activeUsers"
|
||||
fill="var(--color-activeUsers)"
|
||||
radius={[0, 4, 4, 0]}
|
||||
/>
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,9 +26,8 @@ const MEDIA_CLASS_OPTIONS = [
|
||||
{ value: "", label: "All" },
|
||||
{ value: "image", label: "Image" },
|
||||
{ value: "video", label: "Video" },
|
||||
// "dimensional" is the deprecated pre-split class; rows persist until the
|
||||
// backfill lands, so the 3D filter matches all three classes.
|
||||
{ value: "mesh,splat,dimensional", label: "3D" },
|
||||
{ value: "mesh", label: "Meshes" },
|
||||
{ value: "splat", label: "Splats" },
|
||||
] as const;
|
||||
|
||||
const PAGE_SIZE = 24;
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
import { toast } from "../toast/toast";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import { useCharactersStore } from "./characters-store";
|
||||
import { useCharactersStore } from "@storyteller/ui-promptbox";
|
||||
|
||||
interface CharactersModalProps {
|
||||
isOpen: boolean;
|
||||
|
||||
@@ -10,6 +10,11 @@ import {
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { UploaderStates } from "@storyteller/common";
|
||||
import {
|
||||
AUDIO_FILE_ACCEPT,
|
||||
AUDIO_FILE_TYPE_ERROR,
|
||||
isAudioFile,
|
||||
} from "@storyteller/ui-promptbox";
|
||||
import { toast } from "../toast/toast";
|
||||
import { AddButton } from "./ImagePromptRow";
|
||||
import type { RefVideo, RefAudio } from "./types";
|
||||
@@ -137,6 +142,10 @@ export const MediaReferenceRow = ({
|
||||
const baseAudios = [...referenceAudios];
|
||||
|
||||
const file = files[0];
|
||||
if (!isAudioFile(file)) {
|
||||
toast.error(AUDIO_FILE_TYPE_ERROR);
|
||||
return;
|
||||
}
|
||||
const duration = await getAudioDuration(file);
|
||||
|
||||
if (duration <= 0) {
|
||||
@@ -208,7 +217,7 @@ export const MediaReferenceRow = ({
|
||||
type="file"
|
||||
ref={audioInputRef}
|
||||
className="hidden"
|
||||
accept="audio/*"
|
||||
accept={AUDIO_FILE_ACCEPT}
|
||||
onChange={handleAudioUpload}
|
||||
/>
|
||||
<div
|
||||
|
||||
@@ -1,693 +0,0 @@
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faUserGroup } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { faVideo, faMusic } from "@fortawesome/pro-regular-svg-icons";
|
||||
import type { MentionItem } from "./types";
|
||||
|
||||
interface MentionTextareaProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
mentionItems: MentionItem[];
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
/** Inline style applied to the editable element (e.g. a maxHeight cap or
|
||||
* resize override for the fullscreen modal). */
|
||||
style?: React.CSSProperties;
|
||||
onKeyDown?: (e: React.KeyboardEvent<HTMLDivElement>) => void;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
disabled?: boolean;
|
||||
colorMap: Record<string, string>;
|
||||
}
|
||||
|
||||
interface MentionState {
|
||||
isOpen: boolean;
|
||||
triggerIndex: number;
|
||||
query: string;
|
||||
activeIndex: number;
|
||||
}
|
||||
|
||||
function escapeRegex(s: string): string {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function escapeHTML(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cursor helpers for contentEditable
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function getCaretOffset(el: HTMLElement): number {
|
||||
try {
|
||||
const sel = window.getSelection();
|
||||
if (!sel?.rangeCount || !sel.anchorNode || !el.contains(sel.anchorNode)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const anchorNode = sel.anchorNode;
|
||||
const anchorOffset = sel.anchorOffset;
|
||||
let offset = 0;
|
||||
|
||||
if (anchorNode === el) {
|
||||
for (let i = 0; i < anchorOffset; i++) {
|
||||
const child = el.childNodes[i];
|
||||
if (!child) break;
|
||||
if (child.nodeType === Node.TEXT_NODE) {
|
||||
offset += child.textContent?.length ?? 0;
|
||||
} else if (child.nodeName === "BR") {
|
||||
offset += 1;
|
||||
} else {
|
||||
offset += child.textContent?.length ?? 0;
|
||||
}
|
||||
}
|
||||
return offset;
|
||||
}
|
||||
|
||||
function countBefore(parent: Node): boolean {
|
||||
for (const child of Array.from(parent.childNodes)) {
|
||||
if (child === anchorNode) {
|
||||
if (child.nodeType === Node.TEXT_NODE) {
|
||||
offset += anchorOffset;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (child.contains(anchorNode)) {
|
||||
return countBefore(child);
|
||||
}
|
||||
if (child.nodeType === Node.TEXT_NODE) {
|
||||
offset += child.textContent?.length ?? 0;
|
||||
} else if (child.nodeName === "BR") {
|
||||
offset += 1;
|
||||
} else {
|
||||
offset += child.textContent?.length ?? 0;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
countBefore(el);
|
||||
return offset;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function setCaretOffset(el: HTMLElement, offset: number) {
|
||||
try {
|
||||
let remaining = offset;
|
||||
|
||||
function findPosition(parent: Node): boolean {
|
||||
for (let i = 0; i < parent.childNodes.length; i++) {
|
||||
const child = parent.childNodes[i];
|
||||
if (child.nodeType === Node.TEXT_NODE) {
|
||||
const len = child.textContent?.length ?? 0;
|
||||
if (remaining <= len) {
|
||||
const sel = window.getSelection();
|
||||
const range = document.createRange();
|
||||
range.setStart(child, remaining);
|
||||
range.collapse(true);
|
||||
sel?.removeAllRanges();
|
||||
sel?.addRange(range);
|
||||
return true;
|
||||
}
|
||||
remaining -= len;
|
||||
} else if (child.nodeName === "BR") {
|
||||
if (remaining === 0) {
|
||||
const sel = window.getSelection();
|
||||
const range = document.createRange();
|
||||
range.setStart(parent, i);
|
||||
range.collapse(true);
|
||||
sel?.removeAllRanges();
|
||||
sel?.addRange(range);
|
||||
return true;
|
||||
}
|
||||
remaining -= 1;
|
||||
} else if (child.nodeType === Node.ELEMENT_NODE) {
|
||||
if (findPosition(child)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!findPosition(el)) {
|
||||
const sel = window.getSelection();
|
||||
if (sel) {
|
||||
sel.selectAllChildren(el);
|
||||
sel.collapseToEnd();
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// DOM changed during caret restore
|
||||
}
|
||||
}
|
||||
|
||||
function scrollCaretIntoView(el: HTMLElement) {
|
||||
try {
|
||||
const sel = window.getSelection();
|
||||
if (!sel?.rangeCount || !el.contains(sel.anchorNode)) return;
|
||||
|
||||
const range = sel.getRangeAt(0).cloneRange();
|
||||
range.collapse(false);
|
||||
|
||||
const span = document.createElement("span");
|
||||
span.textContent = "\u200B";
|
||||
range.insertNode(span);
|
||||
|
||||
const spanRect = span.getBoundingClientRect();
|
||||
const elRect = el.getBoundingClientRect();
|
||||
|
||||
if (spanRect.bottom > elRect.bottom) {
|
||||
el.scrollTop += spanRect.bottom - elRect.bottom;
|
||||
} else if (spanRect.top < elRect.top) {
|
||||
el.scrollTop -= elRect.top - spanRect.top;
|
||||
}
|
||||
|
||||
const parent = span.parentNode;
|
||||
if (parent) {
|
||||
const next = span.nextSibling;
|
||||
parent.removeChild(span);
|
||||
|
||||
const restored = document.createRange();
|
||||
if (next) {
|
||||
restored.setStartBefore(next);
|
||||
} else if (parent.lastChild) {
|
||||
restored.setStartAfter(parent.lastChild);
|
||||
} else {
|
||||
restored.selectNodeContents(parent);
|
||||
}
|
||||
restored.collapse(true);
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(restored);
|
||||
}
|
||||
} catch {
|
||||
// DOM changed during measurement
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mention Dropdown
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function MentionDropdown({
|
||||
items,
|
||||
activeIndex,
|
||||
onSelect,
|
||||
onHover,
|
||||
position,
|
||||
}: {
|
||||
items: MentionItem[];
|
||||
activeIndex: number;
|
||||
onSelect: (item: MentionItem) => void;
|
||||
onHover: (index: number) => void;
|
||||
position: { left: number; bottom: number };
|
||||
}) {
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const list = listRef.current;
|
||||
if (!list) return;
|
||||
const activeItem = list.children[1]?.children[activeIndex] as HTMLElement;
|
||||
activeItem?.scrollIntoView({ block: "nearest" });
|
||||
}, [activeIndex]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={listRef}
|
||||
className="absolute z-50 mb-1 w-64 max-h-72 overflow-y-auto rounded-lg border border-white/10 bg-[#2a2a2e] shadow-lg backdrop-blur-xl"
|
||||
style={{ left: position.left, bottom: position.bottom }}
|
||||
>
|
||||
<div className="px-3 py-1.5 text-[11px] font-semibold uppercase tracking-wider text-white/50">
|
||||
Mentions
|
||||
</div>
|
||||
<div>
|
||||
{items.map((item, i) => (
|
||||
<button
|
||||
key={item.label}
|
||||
type="button"
|
||||
className={twMerge(
|
||||
"flex w-full items-center gap-2.5 px-3 py-2 text-sm text-white transition-colors cursor-pointer",
|
||||
i === activeIndex ? "bg-white/10" : "hover:bg-white/5",
|
||||
)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
onSelect(item);
|
||||
}}
|
||||
onMouseEnter={() => onHover(i)}
|
||||
>
|
||||
<div className="h-8 w-8 flex-shrink-0 overflow-hidden rounded-md border border-white/20 flex items-center justify-center bg-black/20">
|
||||
{(item.type === "character" || item.type === "image") && item.preview ? (
|
||||
<img
|
||||
src={item.preview}
|
||||
alt={item.label}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : item.type === "character" ? (
|
||||
<FontAwesomeIcon
|
||||
icon={faUserGroup}
|
||||
className="h-3.5 w-3.5 text-white/60"
|
||||
/>
|
||||
) : item.type === "video" && item.preview ? (
|
||||
<video
|
||||
src={item.preview}
|
||||
muted
|
||||
preload="metadata"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<FontAwesomeIcon
|
||||
icon={item.type === "video" ? faVideo : faMusic}
|
||||
className="h-3.5 w-3.5 text-white/60"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<span className="font-medium">{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MentionTextarea Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const MentionTextarea = forwardRef<HTMLDivElement, MentionTextareaProps>(
|
||||
function MentionTextarea(
|
||||
{
|
||||
value,
|
||||
onChange,
|
||||
mentionItems,
|
||||
placeholder,
|
||||
className,
|
||||
style,
|
||||
onKeyDown: externalOnKeyDown,
|
||||
onFocus,
|
||||
onBlur,
|
||||
disabled,
|
||||
colorMap,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const editorRef = useRef<HTMLDivElement>(null);
|
||||
useImperativeHandle(ref, () => editorRef.current!, []);
|
||||
const isInternalUpdate = useRef(false);
|
||||
const isComposing = useRef(false);
|
||||
const pendingCaret = useRef<number | null>(null);
|
||||
|
||||
const [mentionState, setMentionState] = useState<MentionState>({
|
||||
isOpen: false,
|
||||
triggerIndex: -1,
|
||||
query: "",
|
||||
activeIndex: 0,
|
||||
});
|
||||
|
||||
const [dropdownPos, setDropdownPos] = useState<{
|
||||
left: number;
|
||||
bottom: number;
|
||||
}>({ left: 0, bottom: 0 });
|
||||
|
||||
const filteredItems = useMemo(() => {
|
||||
if (!mentionState.isOpen) return [];
|
||||
return mentionItems.filter((item) =>
|
||||
mentionState.query
|
||||
? item.label.toLowerCase().includes(mentionState.query.toLowerCase())
|
||||
: true,
|
||||
);
|
||||
}, [mentionItems, mentionState.isOpen, mentionState.query]);
|
||||
|
||||
// Build a regex that matches any known mention label. Case-insensitive so
|
||||
// a typed `@image1` still highlights when the canonical label is `@Image1`.
|
||||
// Sort longest-first so `@Pumpkin Head` matches before `@Pumpkin`.
|
||||
const mentionRegex = useMemo(() => {
|
||||
const labels = mentionItems.map((item) => item.label);
|
||||
if (labels.length === 0) return null;
|
||||
const sorted = [...labels].sort((a, b) => b.length - a.length);
|
||||
const pattern = sorted.map((l) => escapeRegex(l)).join("|");
|
||||
return new RegExp(`(${pattern})`, "gi");
|
||||
}, [mentionItems]);
|
||||
|
||||
// Case-insensitive colorMap lookup: "@image1" finds "@Image1".
|
||||
const lowerColorMap = useMemo(() => {
|
||||
const m: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(colorMap)) {
|
||||
m[k.toLowerCase()] = v;
|
||||
}
|
||||
return m;
|
||||
}, [colorMap]);
|
||||
|
||||
// Build innerHTML with colored @mentions inline
|
||||
const buildHTML = useCallback(
|
||||
(text: string): string => {
|
||||
if (!text) return "";
|
||||
if (!mentionRegex) {
|
||||
let html = escapeHTML(text);
|
||||
html = html.replace(/\n/g, "<br>");
|
||||
if (html.endsWith("<br>")) html += "<br>";
|
||||
return html;
|
||||
}
|
||||
|
||||
let html = "";
|
||||
let lastIndex = 0;
|
||||
const regex = new RegExp(mentionRegex);
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
const fullMatch = match[0];
|
||||
const color = lowerColorMap[fullMatch.toLowerCase()];
|
||||
|
||||
if (match.index > lastIndex) {
|
||||
html += escapeHTML(text.slice(lastIndex, match.index));
|
||||
}
|
||||
|
||||
if (color) {
|
||||
html += `<span style="color:${color}">${escapeHTML(fullMatch)}</span>`;
|
||||
} else {
|
||||
html += escapeHTML(fullMatch);
|
||||
}
|
||||
|
||||
lastIndex = match.index + fullMatch.length;
|
||||
}
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
html += escapeHTML(text.slice(lastIndex));
|
||||
}
|
||||
|
||||
html = html.replace(/\n/g, "<br>");
|
||||
if (html.endsWith("<br>")) {
|
||||
html += "<br>";
|
||||
}
|
||||
|
||||
return html;
|
||||
},
|
||||
[lowerColorMap, mentionRegex],
|
||||
);
|
||||
|
||||
// Sync DOM when value changes from parent
|
||||
useEffect(() => {
|
||||
if (isInternalUpdate.current) {
|
||||
isInternalUpdate.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const el = editorRef.current;
|
||||
if (!el) return;
|
||||
|
||||
// This effect re-runs whenever `buildHTML`'s identity changes (the parent
|
||||
// recomputing `colorMap`/`mentionItems`), not only when `value` genuinely
|
||||
// changes from outside. If the DOM already shows this exact text while the
|
||||
// user is focused, skip the innerHTML rewrite entirely: reassigning
|
||||
// innerHTML under a live caret detaches it on iOS Safari, which makes the
|
||||
// cursor disappear mid-typing. A pending caret (mention insert) must still
|
||||
// resync, and an unfocused editor is safe to recolor.
|
||||
if (pendingCaret.current === null && document.activeElement === el) {
|
||||
let domText = el.innerText;
|
||||
if (domText.endsWith("\n")) domText = domText.slice(0, -1);
|
||||
if (domText === value) return;
|
||||
}
|
||||
|
||||
const sel = window.getSelection();
|
||||
if (document.activeElement === el && sel && !sel.isCollapsed) return;
|
||||
|
||||
try {
|
||||
const caret = pendingCaret.current ?? getCaretOffset(el);
|
||||
el.innerHTML = buildHTML(value);
|
||||
if (pendingCaret.current !== null) {
|
||||
setCaretOffset(el, pendingCaret.current);
|
||||
pendingCaret.current = null;
|
||||
} else if (document.activeElement === el) {
|
||||
setCaretOffset(el, caret);
|
||||
}
|
||||
} catch {
|
||||
el.innerHTML = buildHTML(value);
|
||||
}
|
||||
}, [value, buildHTML]);
|
||||
|
||||
// Get pixel coordinates of a text offset relative to the wrapper
|
||||
const getOffsetRect = useCallback((charOffset: number) => {
|
||||
try {
|
||||
const el = editorRef.current;
|
||||
if (!el) return null;
|
||||
|
||||
const saved = getCaretOffset(el);
|
||||
setCaretOffset(el, charOffset);
|
||||
const sel = window.getSelection();
|
||||
if (!sel?.rangeCount) {
|
||||
setCaretOffset(el, saved);
|
||||
return null;
|
||||
}
|
||||
const range = sel.getRangeAt(0);
|
||||
const rect = range.getBoundingClientRect();
|
||||
const wrapperRect = el.parentElement!.getBoundingClientRect();
|
||||
setCaretOffset(el, saved);
|
||||
|
||||
return {
|
||||
left: rect.left - wrapperRect.left,
|
||||
bottom: wrapperRect.bottom - rect.top,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Detect @mention trigger from cursor position. The `@` is a valid mention
|
||||
// trigger when it follows anything that isn't a word character — that covers
|
||||
// whitespace, punctuation, CJK/Chinese text, emoji, line starts, etc. Only
|
||||
// reject it when it's sitting mid-word after an alphanumeric/underscore.
|
||||
const detectMention = useCallback(
|
||||
(text: string, cursorPos: number) => {
|
||||
const textBefore = text.slice(0, cursorPos);
|
||||
const lastAt = textBefore.lastIndexOf("@");
|
||||
if (
|
||||
lastAt !== -1 &&
|
||||
(lastAt === 0 || !/[A-Za-z0-9_]/.test(text[lastAt - 1]))
|
||||
) {
|
||||
const query = text.slice(lastAt, cursorPos);
|
||||
if (!query.includes("\n")) {
|
||||
const pos = getOffsetRect(lastAt);
|
||||
if (pos) setDropdownPos(pos);
|
||||
setMentionState({
|
||||
isOpen: true,
|
||||
triggerIndex: lastAt,
|
||||
query,
|
||||
activeIndex: 0,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
setMentionState((prev) =>
|
||||
prev.isOpen ? { ...prev, isOpen: false } : prev,
|
||||
);
|
||||
},
|
||||
[getOffsetRect],
|
||||
);
|
||||
|
||||
const handleInput = useCallback(() => {
|
||||
if (isComposing.current) return;
|
||||
const el = editorRef.current;
|
||||
if (!el) return;
|
||||
|
||||
try {
|
||||
let text = el.innerText;
|
||||
if (text.endsWith("\n")) {
|
||||
text = text.slice(0, -1);
|
||||
}
|
||||
|
||||
const caret = getCaretOffset(el);
|
||||
const html = buildHTML(text);
|
||||
if (el.innerHTML !== html) {
|
||||
el.innerHTML = html;
|
||||
setCaretOffset(el, caret);
|
||||
}
|
||||
|
||||
isInternalUpdate.current = true;
|
||||
onChange(text);
|
||||
detectMention(text, caret);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
scrollCaretIntoView(el);
|
||||
});
|
||||
} catch {
|
||||
const text = el.innerText?.replace(/\n$/, "") ?? "";
|
||||
isInternalUpdate.current = true;
|
||||
onChange(text);
|
||||
}
|
||||
}, [onChange, buildHTML, detectMention]);
|
||||
|
||||
const handleCompositionStart = useCallback(() => {
|
||||
isComposing.current = true;
|
||||
}, []);
|
||||
|
||||
const handleCompositionEnd = useCallback(() => {
|
||||
isComposing.current = false;
|
||||
handleInput();
|
||||
}, [handleInput]);
|
||||
|
||||
// Select a mention from the dropdown
|
||||
const handleSelect = useCallback(
|
||||
(item: MentionItem) => {
|
||||
const el = editorRef.current;
|
||||
if (!el) return;
|
||||
|
||||
// Use the known mention bounds — `getCaretOffset` can return 0 when the
|
||||
// dropdown click collapses the selection out of the editor, which would
|
||||
// leave the typed query in the prompt alongside the inserted label.
|
||||
const queryEnd =
|
||||
mentionState.triggerIndex + mentionState.query.length;
|
||||
const before = value.slice(0, mentionState.triggerIndex);
|
||||
const after = value.slice(queryEnd);
|
||||
const mention = `${item.label} `;
|
||||
const newValue = before + mention + after;
|
||||
|
||||
pendingCaret.current = before.length + mention.length;
|
||||
|
||||
setMentionState({
|
||||
isOpen: false,
|
||||
triggerIndex: -1,
|
||||
query: "",
|
||||
activeIndex: 0,
|
||||
});
|
||||
|
||||
onChange(newValue);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
el.focus();
|
||||
});
|
||||
},
|
||||
[value, mentionState.triggerIndex, mentionState.query, onChange],
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (mentionState.isOpen && filteredItems.length > 0) {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setMentionState((prev) => ({
|
||||
...prev,
|
||||
activeIndex: Math.min(
|
||||
prev.activeIndex + 1,
|
||||
filteredItems.length - 1,
|
||||
),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setMentionState((prev) => ({
|
||||
...prev,
|
||||
activeIndex: Math.max(prev.activeIndex - 1, 0),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
if (e.key === "Enter" || e.key === "Tab") {
|
||||
e.preventDefault();
|
||||
handleSelect(filteredItems[mentionState.activeIndex]);
|
||||
return;
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
setMentionState((prev) => ({ ...prev, isOpen: false }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
document.execCommand("insertLineBreak");
|
||||
handleInput();
|
||||
scrollCaretIntoView(editorRef.current!);
|
||||
return;
|
||||
}
|
||||
|
||||
externalOnKeyDown?.(e);
|
||||
},
|
||||
[
|
||||
mentionState.isOpen,
|
||||
mentionState.activeIndex,
|
||||
filteredItems,
|
||||
handleSelect,
|
||||
externalOnKeyDown,
|
||||
handleInput,
|
||||
],
|
||||
);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
const el = editorRef.current;
|
||||
if (!el) return;
|
||||
const sel = window.getSelection();
|
||||
if (sel && !sel.isCollapsed) return;
|
||||
detectMention(value, getCaretOffset(el));
|
||||
}, [value, detectMention]);
|
||||
|
||||
const handlePaste = useCallback(
|
||||
(e: React.ClipboardEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
const text = e.clipboardData.getData("text/plain");
|
||||
document.execCommand("insertText", false, text);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative flex-1 min-w-0 pb-[7px]">
|
||||
{!value && placeholder && (
|
||||
<div
|
||||
className={twMerge(
|
||||
className,
|
||||
"absolute inset-0 pointer-events-none text-white/50 z-[1]",
|
||||
)}
|
||||
>
|
||||
{placeholder}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={editorRef}
|
||||
contentEditable={!disabled}
|
||||
onInput={handleInput}
|
||||
onCompositionStart={handleCompositionStart}
|
||||
onCompositionEnd={handleCompositionEnd}
|
||||
onKeyDown={handleKeyDown}
|
||||
onClick={handleClick}
|
||||
onPaste={handlePaste}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
style={style}
|
||||
className={twMerge(
|
||||
className,
|
||||
"outline-none whitespace-pre-wrap break-words overflow-y-auto resize-y",
|
||||
)}
|
||||
/>
|
||||
|
||||
{mentionState.isOpen && filteredItems.length > 0 && (
|
||||
<MentionDropdown
|
||||
items={filteredItems}
|
||||
activeIndex={mentionState.activeIndex}
|
||||
onSelect={handleSelect}
|
||||
onHover={(i) =>
|
||||
setMentionState((prev) => ({ ...prev, activeIndex: i }))
|
||||
}
|
||||
position={dropdownPos}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -20,21 +20,22 @@ import { GenerateIconButton } from "@storyteller/ui-button";
|
||||
import { Tooltip } from "@storyteller/ui-tooltip";
|
||||
import {
|
||||
KeyframeCards,
|
||||
MentionTextarea,
|
||||
PromptClearAllButton,
|
||||
ReferenceDeck,
|
||||
buildMentionColorMap,
|
||||
getMentionColor,
|
||||
useDeckMedia,
|
||||
type DeckAddAction,
|
||||
type DeckItem,
|
||||
} from "@storyteller/ui-promptbox";
|
||||
import { arrayMove } from "@dnd-kit/sortable";
|
||||
import { MentionTextarea } from "./MentionTextarea";
|
||||
import {
|
||||
PromptBoxDropOverlay,
|
||||
usePromptBoxDrop,
|
||||
type DroppedFiles,
|
||||
} from "./PromptBoxDropZone";
|
||||
import { toast } from "../toast/toast";
|
||||
import { getMentionColor, buildMentionColorMap } from "./mention-colors";
|
||||
import { uploadImage } from "./upload-image";
|
||||
import { uploadVideo, uploadAudio } from "./upload-media";
|
||||
import type { RefImage, RefVideo, RefAudio, MentionItem } from "./types";
|
||||
@@ -114,6 +115,14 @@ interface PromptBoxProps {
|
||||
// @-mention support (enables colored prompt overlay + autocomplete)
|
||||
mentionItems?: MentionItem[];
|
||||
|
||||
// Records which character token a mention name refers to (dropdown pick or
|
||||
// chip-menu replace) — needed because several characters can share a name.
|
||||
onMentionSelect?: (item: MentionItem) => void;
|
||||
|
||||
// name (without "@") -> character token; picks which character's thumbnail
|
||||
// renders in a mention chip when labels collide.
|
||||
mentionSelections?: Record<string, string>;
|
||||
|
||||
// Soft prompt-length limit from the model API (`text_prompt_max_length`).
|
||||
// Undefined = unlimited (no counter). The limit is not enforced here; the
|
||||
// page's submit handler blocks generation when over.
|
||||
@@ -162,6 +171,8 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
|
||||
modelSelector,
|
||||
secondaryPromptRow,
|
||||
mentionItems,
|
||||
onMentionSelect,
|
||||
mentionSelections,
|
||||
maxPromptLength,
|
||||
},
|
||||
ref,
|
||||
@@ -789,10 +800,13 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
|
||||
mentionItems={mentionItems}
|
||||
placeholder={placeholder}
|
||||
className={twMerge(
|
||||
"promptbox-scrollbar min-h-[2.5em] w-full pr-8 text-base-fg placeholder-base-fg/60",
|
||||
"promptbox-scrollbar min-h-[2.5em] w-full resize-y pr-8 text-base-fg placeholder-base-fg/60",
|
||||
isExpanded ? "max-h-[500px]" : "max-h-[5.5em]",
|
||||
)}
|
||||
colorMap={mentionColorMap}
|
||||
enterToGenerate={enterToGenerate}
|
||||
onMentionSelect={onMentionSelect}
|
||||
selectedTokens={mentionSelections}
|
||||
onKeyDown={(e) => {
|
||||
if (
|
||||
e.key === "Enter" &&
|
||||
@@ -1009,6 +1023,9 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
|
||||
className="promptbox-scrollbar h-full min-h-0 w-full overflow-y-auto text-base-fg placeholder-base-fg/60"
|
||||
style={{ resize: "none" }}
|
||||
colorMap={mentionColorMap}
|
||||
enterToGenerate={enterToGenerate}
|
||||
onMentionSelect={onMentionSelect}
|
||||
selectedTokens={mentionSelections}
|
||||
onKeyDown={(e) => {
|
||||
if (
|
||||
e.key === "Enter" &&
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
export interface StoredCharacter {
|
||||
character_token: string;
|
||||
name: string;
|
||||
avatar_image_url?: string;
|
||||
}
|
||||
|
||||
interface CharactersStore {
|
||||
characters: StoredCharacter[];
|
||||
loaded: boolean;
|
||||
setCharacters: (characters: StoredCharacter[]) => void;
|
||||
updateCharacter: (token: string, updates: Partial<StoredCharacter>) => void;
|
||||
removeCharacter: (token: string) => void;
|
||||
setLoaded: (loaded: boolean) => void;
|
||||
}
|
||||
|
||||
export const useCharactersStore = create<CharactersStore>()((set) => ({
|
||||
characters: [],
|
||||
loaded: false,
|
||||
setCharacters: (characters) => set({ characters }),
|
||||
updateCharacter: (token, updates) =>
|
||||
set((state) => ({
|
||||
characters: state.characters.map((c) =>
|
||||
c.character_token === token ? { ...c, ...updates } : c,
|
||||
),
|
||||
})),
|
||||
removeCharacter: (token) =>
|
||||
set((state) => ({
|
||||
characters: state.characters.filter((c) => c.character_token !== token),
|
||||
})),
|
||||
setLoaded: (loaded) => set({ loaded }),
|
||||
}));
|
||||
@@ -2,8 +2,8 @@ export { PromptBox } from "./PromptBox";
|
||||
export { ImagePromptRow, AddButton } from "./ImagePromptRow";
|
||||
export { MediaReferenceRow } from "./MediaReferenceRow";
|
||||
export { CharactersModal } from "./CharactersModal";
|
||||
export { useCharactersStore } from "./characters-store";
|
||||
export type { StoredCharacter } from "./characters-store";
|
||||
export { useCharactersStore } from "@storyteller/ui-promptbox";
|
||||
export type { StoredCharacter } from "@storyteller/ui-promptbox";
|
||||
export type { RefImage, RefVideo, RefAudio, MentionItem } from "./types";
|
||||
export {
|
||||
getAudioDurationFromUrl,
|
||||
|
||||
+13
-3
@@ -1,9 +1,11 @@
|
||||
import { type ReactNode, useMemo } from "react";
|
||||
import { GenerateButton } from "@storyteller/ui-button";
|
||||
import { MentionTextarea } from "../MentionTextarea";
|
||||
import { buildMentionColorMap } from "../mention-colors";
|
||||
import {
|
||||
MentionTextarea,
|
||||
buildMentionColorMap,
|
||||
type MentionItem,
|
||||
} from "@storyteller/ui-promptbox";
|
||||
import { useMobileCreateTabs } from "../../generation-gallery/mobile-create-tabs";
|
||||
import type { MentionItem } from "../types";
|
||||
|
||||
interface MobilePromptFormProps {
|
||||
prompt: string;
|
||||
@@ -14,6 +16,8 @@ interface MobilePromptFormProps {
|
||||
submitLabel?: string;
|
||||
credits?: number | null;
|
||||
mentionItems?: MentionItem[];
|
||||
onMentionSelect?: (item: MentionItem) => void;
|
||||
mentionSelections?: Record<string, string>;
|
||||
// Whether tapping Create will actually start a generation (logged in, prompt
|
||||
// present, no missing required input). Gates the auto-switch to History.
|
||||
autoAdvance?: boolean;
|
||||
@@ -38,6 +42,8 @@ export function MobilePromptForm({
|
||||
submitLabel = "Create",
|
||||
credits,
|
||||
mentionItems,
|
||||
onMentionSelect,
|
||||
mentionSelections,
|
||||
autoAdvance = true,
|
||||
banner,
|
||||
inputModeSelector,
|
||||
@@ -79,6 +85,10 @@ export function MobilePromptForm({
|
||||
colorMap={colorMap}
|
||||
placeholder={placeholder}
|
||||
className="min-h-[120px] max-h-[40vh] w-full text-md text-base-fg"
|
||||
// Mobile has no Enter-submit; Enter always inserts a newline.
|
||||
enterToGenerate={false}
|
||||
onMentionSelect={onMentionSelect}
|
||||
selectedTokens={mentionSelections}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -23,8 +23,4 @@ export interface RefAudio {
|
||||
duration: number;
|
||||
}
|
||||
|
||||
export interface MentionItem {
|
||||
label: string;
|
||||
type: "image" | "video" | "audio" | "character";
|
||||
preview?: string;
|
||||
}
|
||||
export type { MentionItem } from "@storyteller/ui-promptbox";
|
||||
|
||||
@@ -6,6 +6,7 @@ import App from "./app/app";
|
||||
import { StorytellerApiHostStore, UsersApi } from "@storyteller/api";
|
||||
import { captureLandingContext, getReferrer } from "@storyteller/common";
|
||||
import { setOmniGenErrorNotifier } from "@storyteller/omni-gen";
|
||||
import { setToastDelegate } from "@storyteller/ui-toaster";
|
||||
import { toast } from "./components/toast/toast";
|
||||
|
||||
const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID;
|
||||
@@ -20,11 +21,11 @@ if (import.meta.env.DEV) {
|
||||
StorytellerApiHostStore.getInstance().setApiSchemeAndHost(
|
||||
window.location.origin,
|
||||
);
|
||||
// NB: This overrides the origin above and points all API traffic at the
|
||||
// local storyteller-web on http://localhost:12345. Dev REQUIRES the local
|
||||
// backend (see _docs/dev_setup_local_stack.md) — without it the webapp is
|
||||
// broken in dev, not silently on production.
|
||||
StorytellerApiHostStore.getInstance().setDevelopment();
|
||||
// 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);
|
||||
}
|
||||
@@ -33,6 +34,15 @@ if (import.meta.env.DEV) {
|
||||
// Surface omni model/generation outages through this app's toast component.
|
||||
setOmniGenErrorNotifier((message) => toast.error(message));
|
||||
|
||||
// Shared libs (promptbox deck, gallery modal, …) fire react-hot-toast toasts,
|
||||
// but this app renders its own ToastContainer and never mounts the
|
||||
// react-hot-toast container — route those toasts here so limit/validation
|
||||
// errors (e.g. "audio too long") actually show up.
|
||||
setToastDelegate({
|
||||
success: (message) => toast.success(message),
|
||||
error: (message) => toast.error(message),
|
||||
});
|
||||
|
||||
// Persist landing context (referral username, landing URL, referrer) to apex-
|
||||
// domain cookies so attribution survives the getartcraft.com →
|
||||
// app.getartcraft.com hop. First visit wins.
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
type RefVideo,
|
||||
type RefAudio,
|
||||
type MentionItem,
|
||||
type StoredCharacter,
|
||||
} from "../../components/prompt-box";
|
||||
import {
|
||||
GenerationGallery,
|
||||
@@ -443,6 +444,19 @@ export default function CreateVideo() {
|
||||
const storeSetCharacters = useCharactersStore((s) => s.setCharacters);
|
||||
const storeSetLoaded = useCharactersStore((s) => s.setLoaded);
|
||||
|
||||
// Mentions are plain text: with several characters sharing a name,
|
||||
// "@Robot" alone can't identify one. Records which token the user actually
|
||||
// picked (dropdown, modal, or chip-menu replace), keyed by name.
|
||||
const [mentionSelections, setMentionSelections] = useState<
|
||||
Record<string, string>
|
||||
>({});
|
||||
|
||||
const handleMentionSelect = useCallback((item: MentionItem) => {
|
||||
if (item.type !== "character" || !item.token) return;
|
||||
const name = item.label.replace(/^@/, "");
|
||||
setMentionSelections((prev) => ({ ...prev, [name]: item.token! }));
|
||||
}, []);
|
||||
|
||||
// Load characters on mount if not already loaded
|
||||
useEffect(() => {
|
||||
if (charactersLoaded) return;
|
||||
@@ -456,6 +470,7 @@ export default function CreateVideo() {
|
||||
character_token: c.token,
|
||||
name: c.name,
|
||||
avatar_image_url: c.maybe_avatar?.cdn_url,
|
||||
full_image_url: c.maybe_full_image?.cdn_url,
|
||||
})),
|
||||
);
|
||||
}
|
||||
@@ -601,6 +616,8 @@ export default function CreateVideo() {
|
||||
label: `@${char.name}`,
|
||||
type: "character" as const,
|
||||
preview: char.avatar_image_url,
|
||||
token: char.character_token,
|
||||
fullPreview: char.full_image_url ?? char.avatar_image_url,
|
||||
}));
|
||||
return [...refItems, ...charItems];
|
||||
}, [
|
||||
@@ -1207,27 +1224,35 @@ export default function CreateVideo() {
|
||||
.filter((t): t is string => typeof t === "string" && t.length > 0)
|
||||
: undefined;
|
||||
|
||||
// Extract character tokens from @-mentions in the prompt. Match longest
|
||||
// names first and require a non-word boundary after so `@Bob` doesn't
|
||||
// false-match inside `@Bobby`, and only pick up characters that still
|
||||
// exist in the current store (stale names are ignored).
|
||||
const mentionedCharacters = (() => {
|
||||
// Extract character tokens from @-mentions in the prompt, resolving to
|
||||
// exactly one token per mentioned name. Match longest names first and
|
||||
// require a non-word boundary after so `@Bob` doesn't false-match inside
|
||||
// `@Bobby`, and only pick up characters that still exist in the current
|
||||
// store (stale names are ignored). Several characters can share a name;
|
||||
// prefer the user's explicit pick (mentionSelections), else the newest
|
||||
// (store is newest-first).
|
||||
const mentionedTokens = (() => {
|
||||
if (activeCharacters.length === 0) return [];
|
||||
const sorted = [...activeCharacters].sort(
|
||||
(a, b) => b.name.length - a.name.length,
|
||||
);
|
||||
const matched = new Set<string>();
|
||||
for (const c of sorted) {
|
||||
const escaped = c.name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const regex = new RegExp(`@${escaped}(?![\\w])`);
|
||||
if (regex.test(prompt)) matched.add(c.character_token);
|
||||
const byName = new Map<string, StoredCharacter[]>();
|
||||
for (const c of activeCharacters) {
|
||||
byName.set(c.name, [...(byName.get(c.name) ?? []), c]);
|
||||
}
|
||||
return activeCharacters.filter((c) => matched.has(c.character_token));
|
||||
const names = [...byName.keys()].sort((a, b) => b.length - a.length);
|
||||
const tokens: string[] = [];
|
||||
for (const name of names) {
|
||||
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
if (!new RegExp(`@${escaped}(?![\\w])`).test(prompt)) continue;
|
||||
const candidates = byName.get(name)!;
|
||||
const chosen =
|
||||
candidates.find(
|
||||
(c) => c.character_token === mentionSelections[name],
|
||||
) ?? candidates[0];
|
||||
tokens.push(chosen.character_token);
|
||||
}
|
||||
return tokens;
|
||||
})();
|
||||
const referenceCharacterTokens =
|
||||
mentionedCharacters.length > 0
|
||||
? mentionedCharacters.map((c) => c.character_token)
|
||||
: undefined;
|
||||
mentionedTokens.length > 0 ? mentionedTokens : undefined;
|
||||
|
||||
const baseParams = {
|
||||
prompt: prompt.trim(),
|
||||
@@ -1402,6 +1427,8 @@ export default function CreateVideo() {
|
||||
credits={estimatedCredits}
|
||||
placeholder={promptPlaceholder}
|
||||
mentionItems={mentionItems.length > 0 ? mentionItems : undefined}
|
||||
onMentionSelect={handleMentionSelect}
|
||||
mentionSelections={mentionSelections}
|
||||
autoAdvance={loggedIn && !!prompt.trim() && !isGenerating && !needsImage}
|
||||
banner={
|
||||
requiresImageInput ? (
|
||||
@@ -1727,6 +1754,8 @@ export default function CreateVideo() {
|
||||
})
|
||||
}
|
||||
mentionItems={mentionItems.length > 0 ? mentionItems : undefined}
|
||||
onMentionSelect={handleMentionSelect}
|
||||
mentionSelections={mentionSelections}
|
||||
videoRefsSupported={supportsVideoRefs}
|
||||
referenceVideos={referenceVideos}
|
||||
onReferenceVideosChange={setReferenceVideos}
|
||||
@@ -1956,6 +1985,10 @@ export default function CreateVideo() {
|
||||
const spaceBefore =
|
||||
prompt.length > 0 && !prompt.endsWith(" ") ? " " : "";
|
||||
setPrompt(prompt + spaceBefore + mention + " ");
|
||||
setMentionSelections((prev) => ({
|
||||
...prev,
|
||||
[character.name]: character.token,
|
||||
}));
|
||||
setIsCharactersModalOpen(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -47,15 +47,11 @@ const apiHost = () =>
|
||||
// through the Vite `/__cdn` proxy so they aren't blocked by CORS — the CDN only
|
||||
// sends CORS headers for the production origin. In production this is a no-op:
|
||||
// the asset is fetched directly from its real URL.
|
||||
// Localhost media (the fully-local dev stack's backend serving /media) is
|
||||
// fetched directly: the dev backend sends CORS headers for any localhost
|
||||
// origin, and the proxy would break it anyway (it assumes https).
|
||||
function maybeProxyCdnUrl(url: string): string {
|
||||
if (!import.meta.env.DEV) return url;
|
||||
try {
|
||||
const parsed = new URL(url, window.location.origin);
|
||||
if (parsed.origin === window.location.origin) return url;
|
||||
if (parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1") return url;
|
||||
return `/__cdn/${parsed.host}${parsed.pathname}${parsed.search}`;
|
||||
} catch {
|
||||
return url;
|
||||
@@ -338,11 +334,6 @@ export const useWebAppPageSceneAdapter = (
|
||||
const path = bucketPath?.startsWith("/")
|
||||
? bucketPath
|
||||
: `/${bucketPath ?? ""}`;
|
||||
// The fully-local dev backend serves media itself and has no
|
||||
// Cloudflare /cdn-cgi/image resizer — serve the original.
|
||||
if (base.includes("localhost") || base.includes("127.0.0.1")) {
|
||||
return `${base}${path}`;
|
||||
}
|
||||
const params: string[] = [];
|
||||
if (width) params.push(`width=${width}`);
|
||||
if (quality) params.push(`quality=${quality}`);
|
||||
|
||||
@@ -55,15 +55,15 @@
|
||||
{
|
||||
"path": "../../libs/components/slider-v2/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/toaster/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/omni-gen/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/pricing-modal/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/promptbox/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/model-selector/tsconfig.lib.json"
|
||||
},
|
||||
@@ -73,15 +73,18 @@
|
||||
{
|
||||
"path": "../../libs/components/pagescene/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/gallery-modal/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/lightbox-modal/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/gallery-modal/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/switch/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/promptbox/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/audio-player/tsconfig.lib.json"
|
||||
},
|
||||
|
||||
@@ -20,15 +20,15 @@
|
||||
{
|
||||
"path": "../../libs/components/slider-v2"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/toaster"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/omni-gen"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/pricing-modal"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/promptbox"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/model-selector"
|
||||
},
|
||||
@@ -38,15 +38,21 @@
|
||||
{
|
||||
"path": "../../libs/components/pagescene"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/gallery-modal"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/lightbox-modal"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/gallery-modal"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/switch"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/promptbox"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/audio-player"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/viewer-3d"
|
||||
},
|
||||
@@ -68,9 +74,6 @@
|
||||
{
|
||||
"path": "../../libs/components/generation-list"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/audio-player"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/tab-selector"
|
||||
},
|
||||
|
||||
@@ -42,12 +42,7 @@ function cdnProxyPlugin() {
|
||||
return;
|
||||
}
|
||||
const [, host, assetPath = '/', query = ''] = match;
|
||||
// Localhost upstreams (the fully-local dev backend) are plain
|
||||
// HTTP; everything else (real CDNs) is HTTPS. The web adapter
|
||||
// fetches localhost directly and never proxies it, but don't let
|
||||
// a stray localhost request die on a TLS handshake.
|
||||
const scheme = /^(localhost|127\.0\.0\.1)(:\d+)?$/.test(host) ? 'http' : 'https';
|
||||
const upstream = await fetch(`${scheme}://${host}${assetPath}${query}`, {
|
||||
const upstream = await fetch(`https://${host}${assetPath}${query}`, {
|
||||
method: req.method === 'HEAD' ? 'HEAD' : 'GET',
|
||||
headers: req.headers.range
|
||||
? { range: String(req.headers.range) }
|
||||
@@ -98,14 +93,14 @@ export default defineConfig(() => ({
|
||||
port: 4201,
|
||||
host: 'localhost',
|
||||
proxy: {
|
||||
// Fallback only: main.tsx calls setDevelopment() in DEV, which points
|
||||
// all API traffic at http://localhost:12345 directly, bypassing this
|
||||
// proxy entirely. Target the local backend too so nothing can silently
|
||||
// fall back to production traffic during local dev.
|
||||
// Forward API calls to production API to avoid CORS during local dev
|
||||
'/v1': {
|
||||
target: 'http://localhost:12345',
|
||||
target: 'https://api.storyteller.ai',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
secure: true,
|
||||
headers: {
|
||||
Origin: 'https://api.storyteller.ai',
|
||||
},
|
||||
},
|
||||
// NB: `/__cdn` asset proxying lives in `cdnProxyPlugin` above — it
|
||||
// needs per-request host routing, which `server.proxy` can't do.
|
||||
|
||||
@@ -20,6 +20,48 @@ import {
|
||||
getAudioDuration,
|
||||
} from "./upload-media";
|
||||
|
||||
// iOS Safari has no system audio library, so a bare `accept="audio/*"` falls
|
||||
// back to the Photos picker (photos and videos only). Concrete MIME types and
|
||||
// extensions make iOS open the Files picker filtered to audio instead.
|
||||
const AUDIO_FILE_ACCEPT = [
|
||||
"audio/mpeg",
|
||||
"audio/wav",
|
||||
"audio/x-wav",
|
||||
"audio/mp4",
|
||||
"audio/x-m4a",
|
||||
"audio/aac",
|
||||
"audio/ogg",
|
||||
"audio/opus",
|
||||
"audio/flac",
|
||||
"audio/webm",
|
||||
".mp3",
|
||||
".wav",
|
||||
".m4a",
|
||||
".aac",
|
||||
".ogg",
|
||||
".oga",
|
||||
".opus",
|
||||
".flac",
|
||||
].join(",");
|
||||
|
||||
const AUDIO_FILE_EXTENSIONS = new Set([
|
||||
"mp3",
|
||||
"wav",
|
||||
"m4a",
|
||||
"aac",
|
||||
"ogg",
|
||||
"oga",
|
||||
"opus",
|
||||
"flac",
|
||||
]);
|
||||
|
||||
// Some platforms report .m4a as `video/mp4`, so check the extension too.
|
||||
const isAudioFile = (file: File): boolean => {
|
||||
if (file.type.startsWith("audio/")) return true;
|
||||
const extension = file.name.split(".").pop()?.toLowerCase() ?? "";
|
||||
return AUDIO_FILE_EXTENSIONS.has(extension);
|
||||
};
|
||||
|
||||
interface MediaReferenceRowProps {
|
||||
referenceVideos: RefVideo[];
|
||||
onReferenceVideosChange: (videos: RefVideo[]) => void;
|
||||
@@ -133,6 +175,10 @@ export const MediaReferenceRow = ({
|
||||
const baseAudios = [...referenceAudios];
|
||||
|
||||
const file = files[0];
|
||||
if (!isAudioFile(file)) {
|
||||
toast.error("Please choose an audio file (MP3, WAV, M4A, FLAC…)");
|
||||
return;
|
||||
}
|
||||
const duration = await getAudioDuration(file);
|
||||
|
||||
if (duration <= 0) {
|
||||
@@ -204,7 +250,7 @@ export const MediaReferenceRow = ({
|
||||
type="file"
|
||||
ref={audioInputRef}
|
||||
className="hidden"
|
||||
accept="audio/*"
|
||||
accept={AUDIO_FILE_ACCEPT}
|
||||
onChange={handleAudioUpload}
|
||||
/>
|
||||
<div
|
||||
|
||||
+17
-5
@@ -7,6 +7,7 @@ import {
|
||||
UploadModalImage,
|
||||
UploadModalSplat,
|
||||
} from "@storyteller/ui-upload-modal";
|
||||
import { isPromptBoxDropZoneActive } from "@storyteller/ui-promptbox";
|
||||
import { FilterEngineCategories } from "../../enums";
|
||||
|
||||
type ModalType = "3d" | "image" | "splat" | null;
|
||||
@@ -23,6 +24,15 @@ function isAnyModalOpen(): boolean {
|
||||
return !!document.querySelector("[data-radix-dialog-content]");
|
||||
}
|
||||
|
||||
/**
|
||||
* Whole-app drops stand down while a prompt box owns them. On the create
|
||||
* pages the prompt box is the only drop target (matching the webapp), so a
|
||||
* drop anywhere else must do nothing rather than open the upload modal.
|
||||
*/
|
||||
function isGlobalDropSuppressed(): boolean {
|
||||
return isAnyModalOpen() || isPromptBoxDropZoneActive();
|
||||
}
|
||||
|
||||
const isTauri = typeof window !== "undefined" && "__TAURI__" in window;
|
||||
|
||||
export function GlobalFileDropHandler() {
|
||||
@@ -50,12 +60,14 @@ export function GlobalFileDropHandler() {
|
||||
const payload = event.payload;
|
||||
|
||||
if (payload.type === "enter") {
|
||||
if (!isAnyModalOpen()) setIsDragging(true);
|
||||
if (!isGlobalDropSuppressed()) setIsDragging(true);
|
||||
} else if (payload.type === "over") {
|
||||
// overlay stays visible
|
||||
// The prompt box can mount mid-drag (or the pointer can move
|
||||
// onto it), so keep re-checking rather than trusting "enter".
|
||||
if (isGlobalDropSuppressed()) setIsDragging(false);
|
||||
} else if (payload.type === "drop") {
|
||||
setIsDragging(false);
|
||||
if (isAnyModalOpen()) return;
|
||||
if (isGlobalDropSuppressed()) return;
|
||||
|
||||
if (payload.paths.length === 0) return;
|
||||
|
||||
@@ -117,7 +129,7 @@ export function GlobalFileDropHandler() {
|
||||
const handleDragEnter = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
if (!e.dataTransfer?.types.includes("Files")) return;
|
||||
if (isAnyModalOpen()) return;
|
||||
if (isGlobalDropSuppressed()) return;
|
||||
dragCounter.current++;
|
||||
setIsDragging(true);
|
||||
};
|
||||
@@ -135,7 +147,7 @@ export function GlobalFileDropHandler() {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
dragCounter.current = 0;
|
||||
if (isAnyModalOpen()) return;
|
||||
if (isGlobalDropSuppressed()) return;
|
||||
|
||||
const allFiles = Array.from(e.dataTransfer.files);
|
||||
if (allFiles.length === 0) return;
|
||||
|
||||
@@ -697,3 +697,36 @@ img {
|
||||
background-size: 200% 100%;
|
||||
animation: generation-shimmer 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Prompt box drag & drop overlay (mirrors the webapp's styles.css) */
|
||||
@keyframes promptbox-drop-overlay-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.promptbox-drop-overlay {
|
||||
animation: promptbox-drop-overlay-in 0.15s ease-out both;
|
||||
}
|
||||
|
||||
@keyframes promptbox-drop-chip-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
translate: 0 6px;
|
||||
scale: 0.9;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
translate: 0 0;
|
||||
scale: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.promptbox-drop-chip {
|
||||
animation: promptbox-drop-chip-in 0.25s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
|
||||
@@ -1,16 +1,4 @@
|
||||
import { StorytellerApiHostStore } from "./config/StorytellerApiHostStore";
|
||||
|
||||
// TODO(bt,2025-05-15): Remove this code
|
||||
//
|
||||
// Legacy CDN origin for callers that rebuild media URLs from raw bucket
|
||||
// paths instead of using the `media_links` the API returns (pagescene's
|
||||
// asset thumbnails are the last live consumer). When the API host is a
|
||||
// local dev backend, that backend serves media itself (CDN_BASE_URL +
|
||||
// /media mount), so the CDN origin IS the API origin.
|
||||
export function GetCdnOrigin(): string {
|
||||
const apiHost = StorytellerApiHostStore.getInstance().getApiSchemeAndHost();
|
||||
if (apiHost.includes("localhost") || apiHost.includes("127.0.0.1")) {
|
||||
return apiHost;
|
||||
}
|
||||
return "https://cdn-2.fakeyou.com";
|
||||
}
|
||||
|
||||
@@ -105,7 +105,13 @@ export const GalleryCard = memo(function GalleryCard({
|
||||
: isAudio
|
||||
? faMusic
|
||||
: faImage;
|
||||
const mediaLabel = isVideo ? "Video" : is3D ? "3D" : isAudio ? "Audio" : "Image";
|
||||
const mediaLabel = isVideo
|
||||
? "Video"
|
||||
: is3D
|
||||
? "3D"
|
||||
: isAudio
|
||||
? "Audio"
|
||||
: "Image";
|
||||
const modelDisplayName = item.modelId
|
||||
? getModelDisplayName(item.modelId)
|
||||
: null;
|
||||
@@ -172,7 +178,10 @@ export const GalleryCard = memo(function GalleryCard({
|
||||
outside the card's rounded corners without being clipped. */}
|
||||
<div
|
||||
className="absolute inset-0 overflow-hidden rounded-[inherit]"
|
||||
style={{ contentVisibility: "auto", containIntrinsicSize: "auto 200px" }}
|
||||
style={{
|
||||
contentVisibility: "auto",
|
||||
containIntrinsicSize: "auto 200px",
|
||||
}}
|
||||
>
|
||||
{isAudio ? (
|
||||
<div className="flex h-full flex-col bg-gradient-to-br from-white/[0.08] via-white/[0.04] to-white/[0.02] p-3">
|
||||
@@ -222,7 +231,7 @@ export const GalleryCard = memo(function GalleryCard({
|
||||
)}
|
||||
|
||||
{/* Hover overlay with media type + model badges and quick actions */}
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex items-end justify-between gap-2 bg-gradient-to-t from-black/70 to-transparent px-2 pb-2 pt-6 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex items-end justify-between gap-2 bg-gradient-to-t rounded-b-lg from-black/70 to-transparent px-2 pb-2 pt-6 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<div className="pointer-events-auto flex min-w-0 flex-wrap items-center gap-1.5">
|
||||
<div className="flex items-center gap-1.5 rounded-lg bg-black/60 px-2.5 py-1 text-xs font-medium text-white/90">
|
||||
<FontAwesomeIcon icon={mediaIcon} className="text-[10px]" />
|
||||
|
||||
@@ -66,6 +66,8 @@ if (typeof window !== "undefined" && !window.__modalEscListenerInstalled) {
|
||||
window.__modalEscListenerInstalled = true;
|
||||
window.addEventListener("keydown", (e: KeyboardEvent) => {
|
||||
if (e.key !== "Escape" && e.key !== "Esc") return;
|
||||
// An open outside-safe panel (mention autocomplete etc.) claims Escape.
|
||||
if (document.querySelector(OUTSIDE_SAFE_SELECTOR)) return;
|
||||
const top = getTopEscapableModal();
|
||||
if (top) {
|
||||
e.preventDefault();
|
||||
@@ -77,6 +79,12 @@ if (typeof window !== "undefined" && !window.__modalEscListenerInstalled) {
|
||||
// Simple global z-index tracker for stacked modals
|
||||
let modalZCounter = 70;
|
||||
|
||||
// Body-portaled companions of modal content (e.g. the prompt box's mention
|
||||
// autocomplete, which portals to <body> for fixed positioning) mark their
|
||||
// root with this attribute. Pointer-downs inside them must not count as
|
||||
// outside clicks, and while one is open it claims Escape before the modal.
|
||||
const OUTSIDE_SAFE_SELECTOR = "[data-modal-outside-safe]";
|
||||
|
||||
const AnimatedBackdrop = ({
|
||||
styles,
|
||||
backdropClassName,
|
||||
@@ -1004,12 +1012,23 @@ export const Modal = ({
|
||||
? {}
|
||||
: ({ "aria-describedby": undefined } as any))}
|
||||
onPointerDownOutside={(e) => {
|
||||
if (!closeOnOutsideClick || allowBackgroundInteraction) {
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (
|
||||
!closeOnOutsideClick ||
|
||||
allowBackgroundInteraction ||
|
||||
target?.closest?.(OUTSIDE_SAFE_SELECTOR)
|
||||
) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
onEscapeKeyDown={(e) => {
|
||||
if (!closeOnEsc) {
|
||||
// Radix listens on document in the capture phase, so an
|
||||
// open outside-safe panel must be checked here too — its
|
||||
// own Escape handling runs later in the bubble phase.
|
||||
if (
|
||||
!closeOnEsc ||
|
||||
document.querySelector(OUTSIDE_SAFE_SELECTOR)
|
||||
) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from "./lib/common/AspectRatioIcon";
|
||||
export * from "./lib/common/audioFiles";
|
||||
export * from "./lib/PromptBox2D";
|
||||
export * from "./lib/PromptBox3D";
|
||||
export * from "./lib/PromptBoxEdit";
|
||||
@@ -10,6 +11,9 @@ export * from "./lib/PromptFullscreenModal";
|
||||
export * from "./lib/PromptFullscreenButton";
|
||||
export * from "./lib/PromptClearAllButton";
|
||||
export * from "./lib/promptStore";
|
||||
export * from "./lib/MentionTextarea";
|
||||
export * from "./lib/MentionChipMenu";
|
||||
export * from "./lib/mentionColors";
|
||||
export * from "./lib/PromptBoxErrorBoundary";
|
||||
export * from "./lib/common/AudioTuningPopover";
|
||||
export * from "./lib/common/SoundsSettingsPopover";
|
||||
@@ -19,3 +23,4 @@ export * from "./lib/deck/DeckCard";
|
||||
export * from "./lib/deck/ReferenceDeck";
|
||||
export * from "./lib/deck/KeyframeCards";
|
||||
export * from "./lib/deck/useDeckMedia";
|
||||
export * from "./lib/deck/usePromptBoxDrop";
|
||||
|
||||
@@ -25,6 +25,11 @@ import { RefImage, RefVideo, RefAudio } from "./promptStore";
|
||||
import { toast } from "@storyteller/ui-toaster";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { UploaderStates } from "@storyteller/common";
|
||||
import {
|
||||
AUDIO_FILE_ACCEPT,
|
||||
AUDIO_FILE_TYPE_ERROR,
|
||||
isAudioFile,
|
||||
} from "./common/audioFiles";
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
@@ -500,13 +505,18 @@ export const ImagePromptRow = ({
|
||||
const files = Array.from(event.target.files || []);
|
||||
if (files.length === 0) return;
|
||||
|
||||
const audioFiles = files.filter(isAudioFile);
|
||||
if (audioFiles.length < files.length) {
|
||||
toast.error(AUDIO_FILE_TYPE_ERROR);
|
||||
}
|
||||
|
||||
const availableSlots = Math.max(0, maxAudioCount - referenceAudios.length);
|
||||
if (availableSlots <= 0) {
|
||||
if (audioFiles.length === 0 || availableSlots <= 0) {
|
||||
if (audioFileInputRef.current) audioFileInputRef.current.value = "";
|
||||
return;
|
||||
}
|
||||
|
||||
const filesToProcess = files.slice(0, availableSlots);
|
||||
const filesToProcess = audioFiles.slice(0, availableSlots);
|
||||
|
||||
for (const file of filesToProcess) {
|
||||
const duration = await getAudioDuration(file);
|
||||
@@ -819,7 +829,7 @@ export const ImagePromptRow = ({
|
||||
type="file"
|
||||
ref={audioFileInputRef}
|
||||
className="hidden"
|
||||
accept="audio/*"
|
||||
accept={AUDIO_FILE_ACCEPT}
|
||||
onChange={handleAudioFileUpload}
|
||||
multiple={maxAudioCount > 1}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
faArrowsRotate,
|
||||
faChevronLeft,
|
||||
faEye,
|
||||
faTrashCan,
|
||||
faUser,
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import type { MentionItem } from "./MentionTextarea";
|
||||
|
||||
const VIEWPORT_MARGIN = 8;
|
||||
const ANCHOR_GAP = 4;
|
||||
|
||||
const MENU_ROW =
|
||||
"flex w-full items-center gap-2 rounded-md px-2 py-2 text-sm text-base-fg transition-colors hover:bg-ui-controls/60 cursor-pointer";
|
||||
|
||||
export interface MentionChipMenuProps {
|
||||
/** Viewport-space rect of the clicked chip. */
|
||||
anchorRect: DOMRect;
|
||||
/** Canonical mention label including the "@", e.g. "@robot cartoon". */
|
||||
currentLabel: string;
|
||||
/** Avatar of the currently attached character, shown in the menu header. */
|
||||
currentPreview?: string;
|
||||
/** Characters offered as replacements (current one excluded by the caller). */
|
||||
replaceItems: MentionItem[];
|
||||
onReplace: (item: MentionItem) => void;
|
||||
onPreview: () => void;
|
||||
onRemove: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Floating menu for an inline character-mention chip: Replace / Preview /
|
||||
* Remove, with the Replace action swapping to a second "Back" panel listing
|
||||
* the other available characters.
|
||||
*
|
||||
* Portaled to document.body with fixed positioning — the promptbox `.glass`
|
||||
* container (backdrop-blur) is a containing block that would trap
|
||||
* `position: fixed` descendants, so the menu must not render inside it.
|
||||
*/
|
||||
export function MentionChipMenu({
|
||||
anchorRect,
|
||||
currentLabel,
|
||||
currentPreview,
|
||||
replaceItems,
|
||||
onReplace,
|
||||
onPreview,
|
||||
onRemove,
|
||||
onClose,
|
||||
}: MentionChipMenuProps) {
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const [view, setView] = useState<"menu" | "replace">("menu");
|
||||
const [placement, setPlacement] = useState<{
|
||||
left: number;
|
||||
top: number;
|
||||
flippedAbove: boolean;
|
||||
} | null>(null);
|
||||
// Drives the same 75ms fade/slide-in our PopoverMenu panels use.
|
||||
const [entered, setEntered] = useState(false);
|
||||
|
||||
const currentName = currentLabel.replace(/^@/, "");
|
||||
|
||||
// Position below the chip, flipped above when there is no room, clamped
|
||||
// horizontally into the viewport. Re-measured when the view swaps (the
|
||||
// replace panel is taller than the menu).
|
||||
useLayoutEffect(() => {
|
||||
const panel = panelRef.current;
|
||||
if (!panel) return;
|
||||
const width = panel.offsetWidth;
|
||||
const height = panel.offsetHeight;
|
||||
|
||||
let top = anchorRect.bottom + ANCHOR_GAP;
|
||||
let flippedAbove = false;
|
||||
if (top + height > window.innerHeight - VIEWPORT_MARGIN) {
|
||||
top = anchorRect.top - height - ANCHOR_GAP;
|
||||
flippedAbove = true;
|
||||
}
|
||||
top = Math.max(VIEWPORT_MARGIN, top);
|
||||
|
||||
const left = Math.max(
|
||||
VIEWPORT_MARGIN,
|
||||
Math.min(anchorRect.left, window.innerWidth - width - VIEWPORT_MARGIN),
|
||||
);
|
||||
|
||||
setPlacement({ left, top, flippedAbove });
|
||||
}, [anchorRect, view, replaceItems.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!placement || entered) return;
|
||||
const raf = requestAnimationFrame(() => setEntered(true));
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [placement, entered]);
|
||||
|
||||
useEffect(() => {
|
||||
const handlePointerDown = (e: PointerEvent) => {
|
||||
const panel = panelRef.current;
|
||||
if (panel && !panel.contains(e.target as Node)) onClose();
|
||||
};
|
||||
const handleScroll = (e: Event) => {
|
||||
const panel = panelRef.current;
|
||||
if (panel && e.target instanceof Node && panel.contains(e.target)) return;
|
||||
onClose();
|
||||
};
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("pointerdown", handlePointerDown, true);
|
||||
window.addEventListener("scroll", handleScroll, true);
|
||||
window.addEventListener("resize", onClose);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", handlePointerDown, true);
|
||||
window.removeEventListener("scroll", handleScroll, true);
|
||||
window.removeEventListener("resize", onClose);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
const handleRowKeyActivate = useCallback(
|
||||
(e: React.PointerEvent) => e.preventDefault(),
|
||||
[],
|
||||
);
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={panelRef}
|
||||
// Marks clicks in this body-portaled panel as not-outside for the
|
||||
// focus-mode modal (see OUTSIDE_SAFE_SELECTOR in @storyteller/ui-modal).
|
||||
data-modal-outside-safe=""
|
||||
// Body-portaled, so the focus-mode modal's scroll lock
|
||||
// (react-remove-scroll) would preventDefault wheel/touch events over
|
||||
// this panel — stop propagation so the Replace list stays scrollable.
|
||||
onWheel={(e) => e.stopPropagation()}
|
||||
onTouchMove={(e) => e.stopPropagation()}
|
||||
className={twMerge(
|
||||
"fixed z-[9999] w-56 rounded-lg border border-ui-panel-border bg-ui-panel p-1 shadow-xl",
|
||||
"transform-gpu transition duration-75 ease-out",
|
||||
entered
|
||||
? "translate-y-0 opacity-100"
|
||||
: placement?.flippedAbove
|
||||
? "-translate-y-1 opacity-0"
|
||||
: "translate-y-1 opacity-0",
|
||||
)}
|
||||
style={{
|
||||
// Re-enable interaction under the modal's body-wide pointer-events lock.
|
||||
pointerEvents: "auto",
|
||||
...(placement
|
||||
? { left: placement.left, top: placement.top }
|
||||
: {
|
||||
left: anchorRect.left,
|
||||
top: anchorRect.bottom + ANCHOR_GAP,
|
||||
visibility: "hidden",
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{view === "menu" ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2 px-2 py-1.5">
|
||||
<ChipAvatar preview={currentPreview} name={currentName} />
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium text-base-fg">
|
||||
{currentName}
|
||||
</div>
|
||||
<div className="text-[11px] text-base-fg/50">Character</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="my-1 border-t border-ui-panel-border" />
|
||||
<button type="button" className={MENU_ROW} onClick={() => setView("replace")}>
|
||||
<FontAwesomeIcon icon={faArrowsRotate} className="h-3.5 w-3.5 opacity-60" />
|
||||
<span className="flex-1 text-left">Replace</span>
|
||||
<span className="text-xs text-base-fg/40">{replaceItems.length}</span>
|
||||
</button>
|
||||
<button type="button" className={MENU_ROW} onClick={onPreview}>
|
||||
<FontAwesomeIcon icon={faEye} className="h-3.5 w-3.5 opacity-60" />
|
||||
<span className="flex-1 text-left">Preview</span>
|
||||
</button>
|
||||
<div className="my-1 border-t border-ui-panel-border" />
|
||||
<button
|
||||
type="button"
|
||||
className={twMerge(MENU_ROW, "text-red-500 hover:bg-red-500/10")}
|
||||
onClick={onRemove}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrashCan} className="h-3.5 w-3.5 opacity-60" />
|
||||
<span className="flex-1 text-left">Remove</span>
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-2 px-2 py-1.5">
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-6 w-6 items-center justify-center rounded-md text-base-fg/60 transition-colors hover:bg-ui-controls/60 hover:text-base-fg"
|
||||
onClick={() => setView("menu")}
|
||||
aria-label="Back"
|
||||
>
|
||||
<FontAwesomeIcon icon={faChevronLeft} className="h-3 w-3" />
|
||||
</button>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-base-fg">Replace</div>
|
||||
<div className="truncate text-[11px] text-base-fg/50">
|
||||
Currently {currentName}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="my-1 border-t border-ui-panel-border" />
|
||||
<div className="max-h-64 overflow-y-auto">
|
||||
{replaceItems.length === 0 && (
|
||||
<div className="px-2 py-3 text-center text-xs text-base-fg/50">
|
||||
No other characters
|
||||
</div>
|
||||
)}
|
||||
{replaceItems.map((item, i) => (
|
||||
<button
|
||||
key={item.token ?? `${item.label}-${i}`}
|
||||
type="button"
|
||||
className={MENU_ROW}
|
||||
onPointerDown={handleRowKeyActivate}
|
||||
onClick={() => onReplace(item)}
|
||||
>
|
||||
<ChipAvatar preview={item.preview} name={item.label} />
|
||||
<span className="min-w-0 flex-1 truncate text-left">
|
||||
{item.label.replace(/^@/, "")}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
function ChipAvatar({ preview, name }: { preview?: string; name: string }) {
|
||||
return (
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center overflow-hidden rounded-md border border-white/10 bg-black/20">
|
||||
{preview ? (
|
||||
<img src={preview} alt={name} className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faUser} className="h-3.5 w-3.5 text-base-fg/60" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -41,7 +41,15 @@ import { PromptFullscreenButton } from "./PromptFullscreenButton";
|
||||
import { PromptClearAllButton } from "./PromptClearAllButton";
|
||||
import { gtagEvent } from "@storyteller/google-analytics";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { AudioReferenceRow } from "./common/AudioReferenceRow";
|
||||
import {
|
||||
AudioReferenceRow,
|
||||
type AudioReferenceRowHandle,
|
||||
} from "./common/AudioReferenceRow";
|
||||
import {
|
||||
PromptBoxDropOverlay,
|
||||
usePromptBoxDrop,
|
||||
type DroppedFiles,
|
||||
} from "./deck/usePromptBoxDrop";
|
||||
import { AudioTuningPopover } from "./common/AudioTuningPopover";
|
||||
import { SoundsSettingsPopover } from "./common/SoundsSettingsPopover";
|
||||
import { StylePromptRow } from "./common/StylePromptRow";
|
||||
@@ -282,6 +290,29 @@ export const PromptBoxAudio = ({
|
||||
setReferenceImages(images);
|
||||
};
|
||||
|
||||
// Drag & drop / paste onto the box bounds. Uploads run through the
|
||||
// reference row's own plumbing so limits, duration caps, and the
|
||||
// audio/image mutual exclusion all behave exactly as they do for the
|
||||
// row's own file pickers.
|
||||
const referenceRowRef = useRef<AudioReferenceRowHandle>(null);
|
||||
|
||||
const handleDroppedFiles = ({ images, audios }: DroppedFiles) => {
|
||||
if (audios.length > 0) {
|
||||
void referenceRowRef.current?.addAudioFiles(audios);
|
||||
} else if (images.length > 0) {
|
||||
// One image ref max, and it can't coexist with audio — so a mixed drop
|
||||
// resolves to the audio above and the image is ignored.
|
||||
void referenceRowRef.current?.addImageFile(images[0]!);
|
||||
}
|
||||
};
|
||||
|
||||
const drop = usePromptBoxDrop({
|
||||
acceptsImages: imageRefsSupported,
|
||||
acceptsVideos: false,
|
||||
acceptsAudio: audioRefsSupported,
|
||||
onDropFiles: handleDroppedFiles,
|
||||
});
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setPrompt(e.target.value);
|
||||
};
|
||||
@@ -486,6 +517,7 @@ export const PromptBoxAudio = ({
|
||||
const referenceRow: ReactNode =
|
||||
audioRefsSupported || imageRefsSupported ? (
|
||||
<AudioReferenceRow
|
||||
ref={referenceRowRef}
|
||||
referenceAudios={referenceAudios}
|
||||
onReferenceAudiosChange={handleReferenceAudiosChange}
|
||||
maxAudioCount={audioRefsSupported ? maxAudioRefs : 0}
|
||||
@@ -512,7 +544,14 @@ export const PromptBoxAudio = ({
|
||||
? "ring-1 ring-primary border-primary"
|
||||
: "ring-1 ring-transparent",
|
||||
)}
|
||||
{...drop.dropZoneProps}
|
||||
>
|
||||
<PromptBoxDropOverlay
|
||||
dragState={drop.dragState}
|
||||
acceptsImages={imageRefsSupported}
|
||||
acceptsVideos={false}
|
||||
acceptsAudio={audioRefsSupported}
|
||||
/>
|
||||
{referenceRow}
|
||||
|
||||
<div className="flex justify-center gap-2">
|
||||
|
||||
@@ -34,6 +34,11 @@ import { ResolutionPicker } from "./common/ResolutionPicker";
|
||||
import { QualityPicker } from "./common/QualityPicker";
|
||||
import { ReferenceDeck } from "./deck/ReferenceDeck";
|
||||
import { useDeckMedia } from "./deck/useDeckMedia";
|
||||
import {
|
||||
PromptBoxDropOverlay,
|
||||
usePromptBoxDrop,
|
||||
type DroppedFiles,
|
||||
} from "./deck/usePromptBoxDrop";
|
||||
import { DeckAddAction, DeckItem } from "./deck/deckTypes";
|
||||
|
||||
interface PromptBoxImageProps {
|
||||
@@ -132,6 +137,28 @@ export const PromptBoxImage = ({
|
||||
ownGalleryModal: true,
|
||||
});
|
||||
|
||||
// Drag & drop / paste onto the box bounds. Images are the only reference
|
||||
// kind this box takes, and only when the model can use them at all.
|
||||
const dropAcceptsImages = !!selectedModel?.canUseImagePrompt;
|
||||
|
||||
const handleDroppedFiles = ({ images }: DroppedFiles) => {
|
||||
if (images.length === 0) return;
|
||||
if (deck.availableImageSlots <= 0) {
|
||||
toast.error(
|
||||
`Max ${maxImagePromptCount} image reference${maxImagePromptCount === 1 ? "" : "s"}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
deck.processImageFiles(images, "start");
|
||||
};
|
||||
|
||||
const drop = usePromptBoxDrop({
|
||||
acceptsImages: dropAcceptsImages,
|
||||
acceptsVideos: false,
|
||||
acceptsAudio: false,
|
||||
onDropFiles: handleDroppedFiles,
|
||||
});
|
||||
|
||||
const deckItems: DeckItem[] = useMemo(
|
||||
() => [
|
||||
...referenceImages.map((img, i) => ({
|
||||
@@ -437,7 +464,14 @@ export const PromptBoxImage = ({
|
||||
? "ring-1 ring-primary border-primary"
|
||||
: "ring-1 ring-transparent",
|
||||
)}
|
||||
{...drop.dropZoneProps}
|
||||
>
|
||||
<PromptBoxDropOverlay
|
||||
dragState={drop.dragState}
|
||||
acceptsImages={dropAcceptsImages}
|
||||
acceptsVideos={false}
|
||||
acceptsAudio={false}
|
||||
/>
|
||||
<div className="flex justify-center gap-2">
|
||||
{renderReferenceDeck()}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useRef, useEffect, useMemo } from "react";
|
||||
import { useState, useRef, useEffect, useMemo, useCallback } from "react";
|
||||
import { useSignals } from "@preact/signals-react/runtime";
|
||||
import { JobContextType } from "@storyteller/common";
|
||||
import { PopoverMenu, PopoverItem } from "@storyteller/ui-popover";
|
||||
@@ -35,6 +35,11 @@ import type { UploadImageFn } from "./ImagePromptRow";
|
||||
import { ReferenceDeck } from "./deck/ReferenceDeck";
|
||||
import { KeyframeCards } from "./deck/KeyframeCards";
|
||||
import { useDeckMedia } from "./deck/useDeckMedia";
|
||||
import {
|
||||
PromptBoxDropOverlay,
|
||||
usePromptBoxDrop,
|
||||
type DroppedFiles,
|
||||
} from "./deck/usePromptBoxDrop";
|
||||
import { DeckAddAction, DeckItem } from "./deck/deckTypes";
|
||||
import { AspectRatioIcon } from "./common/AspectRatioIcon";
|
||||
import { VideoGenerationCountPicker } from "./common/VideoGenerationCountPicker";
|
||||
@@ -174,6 +179,13 @@ export const PromptBoxVideo = ({
|
||||
useFullscreenPrompt();
|
||||
const [isCharactersModalOpen, setIsCharactersModalOpen] = useState(false);
|
||||
|
||||
// Mentions are plain text: with several characters sharing a name,
|
||||
// "@Robot" alone can't identify one. Records which token the user actually
|
||||
// picked (dropdown, modal, or chip-menu replace), keyed by name.
|
||||
const [mentionSelections, setMentionSelections] = useState<
|
||||
Record<string, string>
|
||||
>({});
|
||||
|
||||
// Characters store for @-mentions
|
||||
const storedCharacters = useCharactersStore((s) => s.characters);
|
||||
const charactersLoaded = useCharactersStore((s) => s.loaded);
|
||||
@@ -193,6 +205,7 @@ export const PromptBoxVideo = ({
|
||||
character_token: c.token,
|
||||
name: c.name,
|
||||
avatar_image_url: c.maybe_avatar?.cdn_url,
|
||||
full_image_url: c.maybe_full_image?.cdn_url,
|
||||
})),
|
||||
);
|
||||
}
|
||||
@@ -498,6 +511,52 @@ export const PromptBoxVideo = ({
|
||||
ownGalleryModal: true,
|
||||
});
|
||||
|
||||
// Drag & drop / paste onto the box bounds: files route to the reference
|
||||
// kind their MIME matches, gated on what the model supports. Keyframe mode
|
||||
// renders no video/audio deck, so those kinds only land in reference mode
|
||||
// where the user can see (and remove) them.
|
||||
const dropAcceptsVideos = isReferenceMode && maxVideoCount > 0;
|
||||
const dropAcceptsAudio = isReferenceMode && maxAudioCount > 0;
|
||||
|
||||
const handleDroppedFiles = ({ images, videos, audios }: DroppedFiles) => {
|
||||
if (images.length > 0) {
|
||||
if (!isReferenceMode) {
|
||||
// Fill the empty keyframe slots in order: first frame, then last.
|
||||
const queue = [...images];
|
||||
const firstOpen =
|
||||
referenceImages.length === 0 && deck.uploadingImages.length === 0;
|
||||
const lastOpen =
|
||||
!!selectedModel?.endFrame && !endFrameImage && !deck.uploadingEnd;
|
||||
if (firstOpen) deck.processImageFiles([queue.shift()!], "start");
|
||||
if (lastOpen && queue.length > 0) {
|
||||
deck.processImageFiles([queue.shift()!], "end");
|
||||
}
|
||||
if (!firstOpen && !lastOpen) {
|
||||
toast.error(
|
||||
selectedModel?.endFrame
|
||||
? "First and last frames are already set"
|
||||
: "The first frame is already set",
|
||||
);
|
||||
}
|
||||
} else if (deck.availableImageSlots <= 0) {
|
||||
toast.error(
|
||||
`Max ${maxImageCount} image reference${maxImageCount === 1 ? "" : "s"}`,
|
||||
);
|
||||
} else {
|
||||
deck.processImageFiles(images, "start");
|
||||
}
|
||||
}
|
||||
if (videos.length > 0) void deck.processVideoFiles(videos);
|
||||
if (audios.length > 0) void deck.processAudioFiles(audios);
|
||||
};
|
||||
|
||||
const drop = usePromptBoxDrop({
|
||||
acceptsImages: maxImageCount > 0,
|
||||
acceptsVideos: dropAcceptsVideos,
|
||||
acceptsAudio: dropAcceptsAudio,
|
||||
onDropFiles: handleDroppedFiles,
|
||||
});
|
||||
|
||||
// Mixed deck items ordered images → videos → audios: the @ImageN/@VideoN/
|
||||
// @AudioN mention labels are index-derived per type, so this ordering (and
|
||||
// image-only reordering) is load-bearing.
|
||||
@@ -837,6 +896,8 @@ export const PromptBoxVideo = ({
|
||||
label: `@${char.name}`,
|
||||
type: "character" as const,
|
||||
preview: char.avatar_image_url,
|
||||
token: char.character_token,
|
||||
fullPreview: char.full_image_url ?? char.avatar_image_url,
|
||||
})),
|
||||
],
|
||||
[
|
||||
@@ -848,6 +909,14 @@ export const PromptBoxVideo = ({
|
||||
],
|
||||
);
|
||||
|
||||
// Record which token a mention name refers to when the user picks a
|
||||
// character explicitly (dropdown pick or chip-menu replace).
|
||||
const handleMentionSelect = useCallback((item: MentionItem) => {
|
||||
if (item.type !== "character" || !item.token) return;
|
||||
const name = item.label.replace(/^@/, "");
|
||||
setMentionSelections((prev) => ({ ...prev, [name]: item.token! }));
|
||||
}, []);
|
||||
|
||||
// Build label → color map for inline mention highlighting
|
||||
const mentionColorMap = useMemo(() => {
|
||||
const map: Record<string, string> = {};
|
||||
@@ -1051,25 +1120,33 @@ export const PromptBoxVideo = ({
|
||||
);
|
||||
}
|
||||
|
||||
// Extract character tokens from @-mentions in prompt.
|
||||
// Extract character tokens from @-mentions in prompt, resolving to
|
||||
// exactly one token per mentioned name. Several characters can share a
|
||||
// name; prefer the user's explicit pick (mentionSelections), else the
|
||||
// newest (store is newest-first).
|
||||
// Use a word-boundary regex so `@Bob` doesn't match inside `@Bob2`.
|
||||
const mentionedCharacters = (() => {
|
||||
const mentionedTokens = (() => {
|
||||
if (activeCharacters.length === 0) return [];
|
||||
const sorted = [...activeCharacters].sort(
|
||||
(a, b) => b.name.length - a.name.length,
|
||||
);
|
||||
const matched = new Set<string>();
|
||||
for (const c of sorted) {
|
||||
const escaped = c.name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const regex = new RegExp(`@${escaped}(?!\\w)`);
|
||||
if (regex.test(prompt)) matched.add(c.character_token);
|
||||
const byName = new Map<string, StoredCharacter[]>();
|
||||
for (const c of activeCharacters) {
|
||||
byName.set(c.name, [...(byName.get(c.name) ?? []), c]);
|
||||
}
|
||||
return activeCharacters.filter((c) => matched.has(c.character_token));
|
||||
const names = [...byName.keys()].sort((a, b) => b.length - a.length);
|
||||
const tokens: string[] = [];
|
||||
for (const name of names) {
|
||||
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
if (!new RegExp(`@${escaped}(?!\\w)`).test(prompt)) continue;
|
||||
const candidates = byName.get(name)!;
|
||||
const chosen =
|
||||
candidates.find(
|
||||
(c) => c.character_token === mentionSelections[name],
|
||||
) ?? candidates[0];
|
||||
tokens.push(chosen.character_token);
|
||||
}
|
||||
return tokens;
|
||||
})();
|
||||
if (mentionedCharacters.length > 0) {
|
||||
request.reference_character_tokens = mentionedCharacters.map(
|
||||
(c) => c.character_token,
|
||||
);
|
||||
if (mentionedTokens.length > 0) {
|
||||
request.reference_character_tokens = mentionedTokens;
|
||||
}
|
||||
|
||||
// Pass duration if model supports it
|
||||
@@ -1265,7 +1342,15 @@ export const PromptBoxVideo = ({
|
||||
? "ring-1 ring-primary border-primary"
|
||||
: "ring-1 ring-transparent",
|
||||
)}
|
||||
{...drop.dropZoneProps}
|
||||
>
|
||||
<PromptBoxDropOverlay
|
||||
dragState={drop.dragState}
|
||||
acceptsImages={maxImageCount > 0}
|
||||
acceptsVideos={dropAcceptsVideos}
|
||||
acceptsAudio={dropAcceptsAudio}
|
||||
keyframeMode={!isReferenceMode}
|
||||
/>
|
||||
{selectedModel?.textToVideoSupported === false && (
|
||||
<div className="mb-2 flex items-center gap-1.5 rounded-md bg-ui-controls/60 px-2.5 py-1.5 text-xs text-base-fg/70">
|
||||
<FontAwesomeIcon
|
||||
@@ -1278,7 +1363,7 @@ export const PromptBoxVideo = ({
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="relative flex justify-center gap-2">
|
||||
<div className="relative flex justify-center gap-3">
|
||||
{isReferenceMode ? renderReferenceDeck() : renderKeyframeCards()}
|
||||
<div className="promptbox-resize-wrap relative flex-1 min-w-0">
|
||||
{hasAnyMentionables ? (
|
||||
@@ -1288,6 +1373,8 @@ export const PromptBoxVideo = ({
|
||||
onChange={setPrompt}
|
||||
mentionItems={allMentionItems}
|
||||
colorMap={mentionColorMap}
|
||||
onMentionSelect={handleMentionSelect}
|
||||
selectedTokens={mentionSelections}
|
||||
placeholder={
|
||||
isReferenceMode
|
||||
? "Use @Image1, @Video1, @Audio1... to reference uploads in prompt..."
|
||||
@@ -1506,6 +1593,10 @@ export const PromptBoxVideo = ({
|
||||
const spaceBefore =
|
||||
prompt.length > 0 && !prompt.endsWith(" ") ? " " : "";
|
||||
setPrompt(prompt + spaceBefore + mention + " ");
|
||||
setMentionSelections((prev) => ({
|
||||
...prev,
|
||||
[character.name]: character.token,
|
||||
}));
|
||||
setIsCharactersModalOpen(false);
|
||||
requestAnimationFrame(() => {
|
||||
const el = mentionEditorRef.current;
|
||||
@@ -1549,6 +1640,8 @@ export const PromptBoxVideo = ({
|
||||
onChange={setPrompt}
|
||||
mentionItems={allMentionItems}
|
||||
colorMap={mentionColorMap}
|
||||
onMentionSelect={handleMentionSelect}
|
||||
selectedTokens={mentionSelections}
|
||||
placeholder={
|
||||
isReferenceMode
|
||||
? "Use @Image1, @Video1, @Audio1... to reference uploads in prompt..."
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
faFolderOpen,
|
||||
@@ -13,6 +19,18 @@ import { toast } from "@storyteller/ui-toaster";
|
||||
import { UploaderStates } from "@storyteller/common";
|
||||
import type { UploadMediaFn } from "@storyteller/api";
|
||||
import type { RefAudio, RefImage } from "../promptStore";
|
||||
import {
|
||||
AUDIO_FILE_ACCEPT,
|
||||
AUDIO_FILE_TYPE_ERROR,
|
||||
isAudioFile,
|
||||
} from "./audioFiles";
|
||||
|
||||
/** Lets the prompt box push dropped/pasted files through the same upload
|
||||
* path the row's own file inputs use. */
|
||||
export interface AudioReferenceRowHandle {
|
||||
addAudioFiles: (files: File[]) => Promise<void>;
|
||||
addImageFile: (file: File) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface AudioReferenceRowProps {
|
||||
referenceAudios: RefAudio[];
|
||||
@@ -36,20 +54,26 @@ export interface AudioReferenceRowProps {
|
||||
// remix/sample source or Seed Audio refs), plus an optional image reference
|
||||
// for models that support one. Rendered inside the glass card, above the
|
||||
// prompt textarea.
|
||||
export function AudioReferenceRow({
|
||||
referenceAudios,
|
||||
onReferenceAudiosChange,
|
||||
maxAudioCount,
|
||||
maxAudioRefDuration,
|
||||
uploadAudio,
|
||||
onPickAudioFromLibrary,
|
||||
audioRequired = false,
|
||||
imageSupported = false,
|
||||
referenceImages = [],
|
||||
onReferenceImagesChange,
|
||||
uploadImage,
|
||||
className = "",
|
||||
}: AudioReferenceRowProps) {
|
||||
export const AudioReferenceRow = forwardRef<
|
||||
AudioReferenceRowHandle,
|
||||
AudioReferenceRowProps
|
||||
>(function AudioReferenceRow(
|
||||
{
|
||||
referenceAudios,
|
||||
onReferenceAudiosChange,
|
||||
maxAudioCount,
|
||||
maxAudioRefDuration,
|
||||
uploadAudio,
|
||||
onPickAudioFromLibrary,
|
||||
audioRequired = false,
|
||||
imageSupported = false,
|
||||
referenceImages = [],
|
||||
onReferenceImagesChange,
|
||||
uploadImage,
|
||||
className = "",
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const audioInputRef = useRef<HTMLInputElement>(null);
|
||||
const imageInputRef = useRef<HTMLInputElement>(null);
|
||||
const [isUploadingAudio, setIsUploadingAudio] = useState(false);
|
||||
@@ -61,14 +85,16 @@ export function AudioReferenceRow({
|
||||
const referenceImagesRef = useRef(referenceImages);
|
||||
referenceImagesRef.current = referenceImages;
|
||||
|
||||
const handleAudioFileUpload = async (
|
||||
event: React.ChangeEvent<HTMLInputElement>,
|
||||
) => {
|
||||
const files = Array.from(event.target.files || []);
|
||||
const processAudioFiles = async (files: File[]) => {
|
||||
if (files.length === 0) return;
|
||||
|
||||
const audioFiles = files.filter(isAudioFile);
|
||||
if (audioFiles.length < files.length) {
|
||||
toast.error(AUDIO_FILE_TYPE_ERROR);
|
||||
}
|
||||
|
||||
const availableSlots = Math.max(0, maxAudioCount - referenceAudios.length);
|
||||
const filesToProcess = files.slice(0, availableSlots);
|
||||
const filesToProcess = audioFiles.slice(0, availableSlots);
|
||||
|
||||
for (const file of filesToProcess) {
|
||||
const duration = await getAudioFileDuration(file);
|
||||
@@ -113,15 +139,17 @@ export function AudioReferenceRow({
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleAudioFileUpload = async (
|
||||
event: React.ChangeEvent<HTMLInputElement>,
|
||||
) => {
|
||||
await processAudioFiles(Array.from(event.target.files || []));
|
||||
if (audioInputRef.current) audioInputRef.current.value = "";
|
||||
};
|
||||
|
||||
const handleImageFileUpload = async (
|
||||
event: React.ChangeEvent<HTMLInputElement>,
|
||||
) => {
|
||||
const file = (event.target.files || [])[0];
|
||||
if (!file || !uploadImage) return;
|
||||
const processImageFile = async (file: File) => {
|
||||
if (!uploadImage) return;
|
||||
|
||||
setIsUploadingImage(true);
|
||||
await uploadImage({
|
||||
@@ -146,10 +174,26 @@ export function AudioReferenceRow({
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleImageFileUpload = async (
|
||||
event: React.ChangeEvent<HTMLInputElement>,
|
||||
) => {
|
||||
const file = (event.target.files || [])[0];
|
||||
if (file) await processImageFile(file);
|
||||
if (imageInputRef.current) imageInputRef.current.value = "";
|
||||
};
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
addAudioFiles: processAudioFiles,
|
||||
addImageFile: processImageFile,
|
||||
}),
|
||||
// Recreated each render so the handle always closes over fresh props
|
||||
// (slot counts, current refs) rather than mount-time values.
|
||||
);
|
||||
|
||||
const removeAudio = (id: string) => {
|
||||
onReferenceAudiosChange(referenceAudios.filter((a) => a.id !== id));
|
||||
if (audioInputRef.current) audioInputRef.current.value = "";
|
||||
@@ -160,7 +204,7 @@ export function AudioReferenceRow({
|
||||
<input
|
||||
ref={audioInputRef}
|
||||
type="file"
|
||||
accept="audio/*"
|
||||
accept={AUDIO_FILE_ACCEPT}
|
||||
className="hidden"
|
||||
multiple={maxAudioCount > 1}
|
||||
onChange={handleAudioFileUpload}
|
||||
@@ -270,7 +314,7 @@ export function AudioReferenceRow({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
function AudioRefTile({
|
||||
audio,
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
// iOS Safari has no system audio library, so a bare `accept="audio/*"` makes
|
||||
// the file input fall back to the Photos picker (photos and videos only) —
|
||||
// on a phone the only thing users could "attach as audio" was a video.
|
||||
// Listing concrete audio MIME types and extensions instead makes iOS open the
|
||||
// Files picker filtered to audio; desktop browsers filter identically.
|
||||
export const AUDIO_FILE_ACCEPT = [
|
||||
"audio/mpeg",
|
||||
"audio/wav",
|
||||
"audio/x-wav",
|
||||
"audio/mp4",
|
||||
"audio/x-m4a",
|
||||
"audio/aac",
|
||||
"audio/ogg",
|
||||
"audio/opus",
|
||||
"audio/flac",
|
||||
"audio/webm",
|
||||
"audio/aiff",
|
||||
"audio/x-aiff",
|
||||
".mp3",
|
||||
".wav",
|
||||
".m4a",
|
||||
".aac",
|
||||
".ogg",
|
||||
".oga",
|
||||
".opus",
|
||||
".flac",
|
||||
".weba",
|
||||
".aiff",
|
||||
".aif",
|
||||
].join(",");
|
||||
|
||||
export const AUDIO_FILE_TYPE_ERROR =
|
||||
"Please choose an audio file (MP3, WAV, M4A, FLAC…)";
|
||||
|
||||
const AUDIO_FILE_EXTENSIONS = new Set([
|
||||
"mp3",
|
||||
"wav",
|
||||
"m4a",
|
||||
"aac",
|
||||
"ogg",
|
||||
"oga",
|
||||
"opus",
|
||||
"flac",
|
||||
"weba",
|
||||
"aiff",
|
||||
"aif",
|
||||
]);
|
||||
|
||||
// Guards the audio-ref paths against non-audio picks (Android "any file"
|
||||
// pickers, drag-and-drop, older iOS). Checks the extension as well as the
|
||||
// MIME type because some platforms report .m4a as `video/mp4`.
|
||||
export function isAudioFile(file: File): boolean {
|
||||
if (file.type.startsWith("audio/")) return true;
|
||||
const extension = file.name.split(".").pop()?.toLowerCase() ?? "";
|
||||
return AUDIO_FILE_EXTENSIONS.has(extension);
|
||||
}
|
||||
@@ -174,6 +174,11 @@ export const ReferenceDeck = ({
|
||||
|
||||
const handleRootEnter = () => {
|
||||
clearCloseTimer();
|
||||
// The add menu is body-portaled, so hovering it counts as leaving the
|
||||
// root and decays `hovered` (the panel stays up via addMenuOpen). Coming
|
||||
// back onto the open panel must re-arm `hovered`, or it would collapse
|
||||
// under the cursor the moment the menu closes.
|
||||
if (expanded) setHovered(true);
|
||||
};
|
||||
|
||||
const handleRootLeave = () => {
|
||||
@@ -215,6 +220,11 @@ export const ReferenceDeck = ({
|
||||
interactive={true}
|
||||
position="top"
|
||||
delay={100}
|
||||
// Body-portaled: rendered inline, the menu is trapped in the
|
||||
// promptbox's .glass stacking context and paints under the fixed
|
||||
// z-30 sidebar no matter its own z-index.
|
||||
portal
|
||||
zIndex={9999}
|
||||
className="-mb-0.5 border border-ui-controls-border bg-ui-controls p-1.5 text-base-fg"
|
||||
closeOnClick={true}
|
||||
onOpenChange={holdPanel ? setAddMenuOpen : undefined}
|
||||
|
||||
@@ -3,6 +3,11 @@ import { GalleryItem, GalleryModal } from "@storyteller/ui-gallery-modal";
|
||||
import { downloadFileFromUrl, type UploadMediaFn } from "@storyteller/api";
|
||||
import { toast } from "@storyteller/ui-toaster";
|
||||
import { UploaderStates } from "@storyteller/common";
|
||||
import {
|
||||
AUDIO_FILE_ACCEPT,
|
||||
AUDIO_FILE_TYPE_ERROR,
|
||||
isAudioFile,
|
||||
} from "../common/audioFiles";
|
||||
|
||||
/** Minimal structural shapes so both apps' ref types fit. */
|
||||
export interface DeckRefLike {
|
||||
@@ -368,12 +373,18 @@ export function useDeckMedia<
|
||||
};
|
||||
|
||||
const processAudioFiles = async (files: File[]) => {
|
||||
const audioFiles = files.filter(isAudioFile);
|
||||
if (audioFiles.length < files.length) {
|
||||
toast.error(AUDIO_FILE_TYPE_ERROR, { id: "audio-ref-type" });
|
||||
}
|
||||
if (audioFiles.length === 0) return;
|
||||
|
||||
const availableSlots = Math.max(0, maxAudios - referenceAudios.length);
|
||||
if (availableSlots <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const filesToProcess = files.slice(0, availableSlots);
|
||||
const filesToProcess = audioFiles.slice(0, availableSlots);
|
||||
|
||||
for (const file of filesToProcess) {
|
||||
const duration = await getAudioDuration(file);
|
||||
@@ -441,8 +452,12 @@ export function useDeckMedia<
|
||||
if (files.length === 0) return;
|
||||
|
||||
const images = files.filter((f) => f.type.startsWith("image/"));
|
||||
const videos = files.filter((f) => f.type.startsWith("video/"));
|
||||
const audios = files.filter((f) => f.type.startsWith("audio/"));
|
||||
// isAudioFile also claims .m4a files that platforms report as video/mp4,
|
||||
// so exclude them from the video bucket.
|
||||
const audios = files.filter(isAudioFile);
|
||||
const videos = files.filter(
|
||||
(f) => f.type.startsWith("video/") && !isAudioFile(f),
|
||||
);
|
||||
|
||||
if (images.length > 0) processImageFiles(images, "start");
|
||||
if (videos.length > 0 && setReferenceVideos) processVideoFiles(videos);
|
||||
@@ -453,7 +468,7 @@ export function useDeckMedia<
|
||||
const anyUploadAccept = [
|
||||
...(maxImages > 0 ? ["image/*"] : []),
|
||||
...(setReferenceVideos ? ["video/mp4", ".mp4"] : []),
|
||||
...(setReferenceAudios ? ["audio/*"] : []),
|
||||
...(setReferenceAudios ? [AUDIO_FILE_ACCEPT] : []),
|
||||
].join(",");
|
||||
|
||||
const handleGalleryClose = () => {
|
||||
@@ -681,7 +696,7 @@ export function useDeckMedia<
|
||||
type="file"
|
||||
ref={audioFileInputRef}
|
||||
className="hidden"
|
||||
accept="audio/*"
|
||||
accept={AUDIO_FILE_ACCEPT}
|
||||
onChange={handleAudioFileUpload}
|
||||
multiple={maxAudios > 1}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,488 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faImages, faMusic, faVideo } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { toast } from "@storyteller/ui-toaster";
|
||||
import { IsDesktopApp } from "@storyteller/tauri-utils";
|
||||
|
||||
// ── Types ───────────────────────────────────────────────────────────────
|
||||
|
||||
type DropKind = "image" | "video" | "audio";
|
||||
|
||||
/** "accept" / "reject" reflect whether the hovered payload contains at
|
||||
* least one file the current model can take as a reference. */
|
||||
export type DropDragState = "idle" | "accept" | "reject";
|
||||
|
||||
export interface DroppedFiles {
|
||||
images: File[];
|
||||
videos: File[];
|
||||
audios: File[];
|
||||
}
|
||||
|
||||
export interface UsePromptBoxDropOptions {
|
||||
acceptsImages: boolean;
|
||||
acceptsVideos: boolean;
|
||||
acceptsAudio: boolean;
|
||||
onDropFiles: (files: DroppedFiles) => void;
|
||||
}
|
||||
|
||||
interface PromptBoxDropOverlayProps {
|
||||
dragState: DropDragState;
|
||||
acceptsImages: boolean;
|
||||
acceptsVideos: boolean;
|
||||
acceptsAudio: boolean;
|
||||
/** Video keyframe mode: images fill the start/end frame slots. */
|
||||
keyframeMode?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Live count of mounted prompt boxes that own file drops. The desktop's
|
||||
* global drop handler reads this to stand down: on a create page the prompt
|
||||
* box is the only drop target, so a drop anywhere else does nothing.
|
||||
*/
|
||||
let activeDropZoneCount = 0;
|
||||
const dropZoneCountListeners = new Set<(count: number) => void>();
|
||||
|
||||
const adjustActiveDropZoneCount = (delta: number) => {
|
||||
activeDropZoneCount = Math.max(0, activeDropZoneCount + delta);
|
||||
dropZoneCountListeners.forEach((listener) => listener(activeDropZoneCount));
|
||||
};
|
||||
|
||||
export const isPromptBoxDropZoneActive = () => activeDropZoneCount > 0;
|
||||
|
||||
export const subscribeToPromptBoxDropZones = (
|
||||
listener: (count: number) => void,
|
||||
) => {
|
||||
dropZoneCountListeners.add(listener);
|
||||
return () => {
|
||||
dropZoneCountListeners.delete(listener);
|
||||
};
|
||||
};
|
||||
|
||||
// Tauri hands us OS paths, not File objects. Extensions are all we get to
|
||||
// route by, so they mirror the MIME buckets the browser path uses.
|
||||
const IMAGE_EXTENSIONS = ["png", "jpg", "jpeg", "webp", "gif", "bmp", "avif"];
|
||||
const VIDEO_EXTENSIONS = ["mp4", "mov", "webm", "mkv", "avi", "m4v"];
|
||||
const AUDIO_EXTENSIONS = ["mp3", "wav", "ogg", "flac", "aac", "m4a", "opus"];
|
||||
|
||||
const MIME_BY_EXTENSION: Record<string, string> = {
|
||||
png: "image/png",
|
||||
jpg: "image/jpeg",
|
||||
jpeg: "image/jpeg",
|
||||
webp: "image/webp",
|
||||
gif: "image/gif",
|
||||
bmp: "image/bmp",
|
||||
avif: "image/avif",
|
||||
mp4: "video/mp4",
|
||||
mov: "video/quicktime",
|
||||
webm: "video/webm",
|
||||
mkv: "video/x-matroska",
|
||||
avi: "video/x-msvideo",
|
||||
m4v: "video/x-m4v",
|
||||
mp3: "audio/mpeg",
|
||||
wav: "audio/wav",
|
||||
ogg: "audio/ogg",
|
||||
flac: "audio/flac",
|
||||
aac: "audio/aac",
|
||||
m4a: "audio/mp4",
|
||||
opus: "audio/opus",
|
||||
};
|
||||
|
||||
const kindOfMime = (mime: string): DropKind | null =>
|
||||
mime.startsWith("image/")
|
||||
? "image"
|
||||
: mime.startsWith("video/")
|
||||
? "video"
|
||||
: mime.startsWith("audio/")
|
||||
? "audio"
|
||||
: null;
|
||||
|
||||
const extensionOf = (path: string) =>
|
||||
path.split(/[/\\]/).pop()?.split(".").pop()?.toLowerCase() ?? "";
|
||||
|
||||
const kindOfPath = (path: string): DropKind | null => {
|
||||
const ext = extensionOf(path);
|
||||
if (IMAGE_EXTENSIONS.includes(ext)) return "image";
|
||||
if (VIDEO_EXTENSIONS.includes(ext)) return "video";
|
||||
if (AUDIO_EXTENSIONS.includes(ext)) return "audio";
|
||||
return null;
|
||||
};
|
||||
|
||||
const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
|
||||
|
||||
const listKinds = (labels: string[]) =>
|
||||
labels.length <= 1
|
||||
? (labels[0] ?? "")
|
||||
: `${labels.slice(0, -1).join(", ")} or ${labels[labels.length - 1]}`;
|
||||
|
||||
/**
|
||||
* Makes the prompt box a drop target for reference media, and catches
|
||||
* clipboard pastes of files anywhere on the page (focus-independent). Files
|
||||
* are routed by MIME type; kinds the current model doesn't take are
|
||||
* rejected with a toast instead of silently vanishing.
|
||||
*
|
||||
* Two transports, one behaviour. In the browser this is plain HTML5 drag
|
||||
* events on the returned `dropZoneProps`. Under Tauri the webview never sees
|
||||
* those events (the OS drag is captured natively), so the same states are
|
||||
* driven from `onDragDropEvent` and the cursor position is hit-tested
|
||||
* against the element `dropZoneProps.ref` is attached to.
|
||||
*/
|
||||
export function usePromptBoxDrop({
|
||||
acceptsImages,
|
||||
acceptsVideos,
|
||||
acceptsAudio,
|
||||
onDropFiles,
|
||||
}: UsePromptBoxDropOptions) {
|
||||
const [dragState, setDragState] = useState<DropDragState>("idle");
|
||||
const zoneRef = useRef<HTMLDivElement>(null);
|
||||
const enabled = acceptsImages || acceptsVideos || acceptsAudio;
|
||||
const isDesktop = IsDesktopApp();
|
||||
|
||||
const acceptsKind = useCallback(
|
||||
(kind: DropKind | null) =>
|
||||
kind === "image"
|
||||
? acceptsImages
|
||||
: kind === "video"
|
||||
? acceptsVideos
|
||||
: kind === "audio"
|
||||
? acceptsAudio
|
||||
: false,
|
||||
[acceptsImages, acceptsVideos, acceptsAudio],
|
||||
);
|
||||
|
||||
// Announce ownership of drops for as long as an enabled box is mounted, so
|
||||
// the desktop's global drop handler stays out of the way on this page.
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
adjustActiveDropZoneCount(1);
|
||||
return () => adjustActiveDropZoneCount(-1);
|
||||
}, [enabled]);
|
||||
|
||||
const routeFiles = (files: File[]) => {
|
||||
const accepted: DroppedFiles = { images: [], videos: [], audios: [] };
|
||||
const rejectedKinds = new Set<DropKind>();
|
||||
let unknownCount = 0;
|
||||
for (const file of files) {
|
||||
const kind = kindOfMime(file.type) ?? kindOfPath(file.name);
|
||||
if (kind === null) {
|
||||
unknownCount++;
|
||||
} else if (!acceptsKind(kind)) {
|
||||
rejectedKinds.add(kind);
|
||||
} else if (kind === "image") {
|
||||
accepted.images.push(file);
|
||||
} else if (kind === "video") {
|
||||
accepted.videos.push(file);
|
||||
} else {
|
||||
accepted.audios.push(file);
|
||||
}
|
||||
}
|
||||
|
||||
const anyAccepted =
|
||||
accepted.images.length > 0 ||
|
||||
accepted.videos.length > 0 ||
|
||||
accepted.audios.length > 0;
|
||||
if (anyAccepted) onDropFiles(accepted);
|
||||
|
||||
// Mode-neutral wording: a kind can be off because the model lacks it or
|
||||
// because the current input mode (e.g. keyframes) doesn't show it.
|
||||
if (rejectedKinds.size > 0) {
|
||||
toast.error(
|
||||
`${capitalize(listKinds([...rejectedKinds]))} references aren't available here`,
|
||||
);
|
||||
} else if (unknownCount > 0 && !anyAccepted) {
|
||||
toast.error(
|
||||
`Only ${listKinds(acceptedKindLabels(acceptsImages, acceptsVideos, acceptsAudio))} files can be added here`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Async listeners (Tauri, paste) re-read the freshest router through a ref
|
||||
// so they can stay subscribed once instead of resubscribing per render.
|
||||
const routeFilesRef = useRef(routeFiles);
|
||||
useEffect(() => {
|
||||
routeFilesRef.current = routeFiles;
|
||||
});
|
||||
|
||||
const acceptsKindRef = useRef(acceptsKind);
|
||||
useEffect(() => {
|
||||
acceptsKindRef.current = acceptsKind;
|
||||
}, [acceptsKind]);
|
||||
|
||||
// ── Browser transport ────────────────────────────────────────────────
|
||||
|
||||
// While the box is on screen, a stray drop outside it must not navigate
|
||||
// the browser to the file (which would blow away the user's session).
|
||||
useEffect(() => {
|
||||
if (!enabled || isDesktop) return;
|
||||
const preventNavigation = (e: DragEvent) => {
|
||||
if (e.dataTransfer?.types.includes("Files")) e.preventDefault();
|
||||
};
|
||||
// Drops that land outside the box still need the overlay dismissed.
|
||||
const clearDragState = () => setDragState("idle");
|
||||
window.addEventListener("dragover", preventNavigation);
|
||||
window.addEventListener("drop", preventNavigation);
|
||||
window.addEventListener("drop", clearDragState);
|
||||
window.addEventListener("dragend", clearDragState);
|
||||
return () => {
|
||||
window.removeEventListener("dragover", preventNavigation);
|
||||
window.removeEventListener("drop", preventNavigation);
|
||||
window.removeEventListener("drop", clearDragState);
|
||||
window.removeEventListener("dragend", clearDragState);
|
||||
};
|
||||
}, [enabled, isDesktop]);
|
||||
|
||||
// MIME types are unreliable mid-drag (browsers may report empty strings),
|
||||
// so unknown types count as acceptable until the drop resolves them.
|
||||
const inspectDrag = useCallback(
|
||||
(dt: DataTransfer): DropDragState => {
|
||||
const fileItems = Array.from(dt.items).filter((i) => i.kind === "file");
|
||||
if (fileItems.length === 0) return "accept";
|
||||
const ok = fileItems.some(
|
||||
(i) => i.type === "" || acceptsKind(kindOfMime(i.type)),
|
||||
);
|
||||
return ok ? "accept" : "reject";
|
||||
},
|
||||
[acceptsKind],
|
||||
);
|
||||
|
||||
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
if (!enabled || !e.dataTransfer.types.includes("Files")) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
setDragState(inspectDrag(e.dataTransfer));
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
if (dragState === "idle") return;
|
||||
// Child elements fire enter/leave pairs constantly; only reset when the
|
||||
// pointer actually exits the drop target's bounds.
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
if (
|
||||
e.clientX < rect.left ||
|
||||
e.clientX >= rect.right ||
|
||||
e.clientY < rect.top ||
|
||||
e.clientY >= rect.bottom
|
||||
) {
|
||||
setDragState("idle");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
if (!enabled || !e.dataTransfer.types.includes("Files")) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragState("idle");
|
||||
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
if (files.length > 0) routeFiles(files);
|
||||
};
|
||||
|
||||
// ── Tauri transport ──────────────────────────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !isDesktop) return;
|
||||
let unlisten: (() => void) | undefined;
|
||||
let disposed = false;
|
||||
|
||||
// Tauri reports positions in physical pixels; CSS coordinates need the
|
||||
// display's scale factor divided back out.
|
||||
const isInsideZone = (position: { x: number; y: number }) => {
|
||||
const zone = zoneRef.current;
|
||||
if (!zone) return false;
|
||||
const scale = window.devicePixelRatio || 1;
|
||||
const x = position.x / scale;
|
||||
const y = position.y / scale;
|
||||
const rect = zone.getBoundingClientRect();
|
||||
return (
|
||||
x >= rect.left && x < rect.right && y >= rect.top && y < rect.bottom
|
||||
);
|
||||
};
|
||||
|
||||
const setup = async () => {
|
||||
try {
|
||||
const { getCurrentWebviewWindow } = await import(
|
||||
"@tauri-apps/api/webviewWindow"
|
||||
);
|
||||
const { convertFileSrc } = await import("@tauri-apps/api/core");
|
||||
const appWindow = getCurrentWebviewWindow();
|
||||
|
||||
const stop = await appWindow.onDragDropEvent(async (event) => {
|
||||
const payload = event.payload;
|
||||
|
||||
if (payload.type === "enter" || payload.type === "over") {
|
||||
// Paths aren't provided while hovering, so the overlay can only
|
||||
// promise "accept" until the drop resolves the real files.
|
||||
setDragState(isInsideZone(payload.position) ? "accept" : "idle");
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.type !== "drop") {
|
||||
setDragState("idle");
|
||||
return;
|
||||
}
|
||||
|
||||
setDragState("idle");
|
||||
if (!isInsideZone(payload.position)) return;
|
||||
if (payload.paths.length === 0) return;
|
||||
|
||||
// Only read the files this box can actually take; unsupported ones
|
||||
// still reach routeFiles (as zero-byte stand-ins) so it can raise
|
||||
// the same "not available here" toast the browser path does.
|
||||
const readable = payload.paths.filter(
|
||||
(path) => kindOfPath(path) !== null,
|
||||
);
|
||||
const unsupported = payload.paths.filter(
|
||||
(path) => kindOfPath(path) === null,
|
||||
);
|
||||
|
||||
const files = await Promise.all(
|
||||
readable.map(async (path) => {
|
||||
const fileName = path.split(/[/\\]/).pop() ?? "file";
|
||||
const type = MIME_BY_EXTENSION[extensionOf(path)] ?? "";
|
||||
try {
|
||||
const response = await fetch(convertFileSrc(path));
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const blob = await response.blob();
|
||||
return new File([blob], fileName, { type });
|
||||
} catch (err) {
|
||||
console.error("[PromptBoxDrop] file read failed:", err);
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
if (disposed) return;
|
||||
|
||||
const readFiles = files.filter((file): file is File => file !== null);
|
||||
const failedCount = readable.length - readFiles.length;
|
||||
if (failedCount > 0) {
|
||||
toast.error(
|
||||
`Couldn't read ${failedCount} file${failedCount > 1 ? "s" : ""}`,
|
||||
);
|
||||
}
|
||||
|
||||
const placeholders = unsupported.map(
|
||||
(path) => new File([], path.split(/[/\\]/).pop() ?? "file"),
|
||||
);
|
||||
const allFiles = [...readFiles, ...placeholders];
|
||||
if (allFiles.length > 0) routeFilesRef.current(allFiles);
|
||||
});
|
||||
|
||||
if (disposed) stop();
|
||||
else unlisten = stop;
|
||||
} catch (err) {
|
||||
console.error("[PromptBoxDrop] setup failed:", err);
|
||||
}
|
||||
};
|
||||
|
||||
void setup();
|
||||
return () => {
|
||||
disposed = true;
|
||||
unlisten?.();
|
||||
};
|
||||
}, [enabled, isDesktop]);
|
||||
|
||||
// ── Paste-to-add ─────────────────────────────────────────────────────
|
||||
|
||||
// A copied image (or media file) pasted anywhere on the page lands in the
|
||||
// deck, whether or not the textarea is focused.
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const handlePaste = (e: ClipboardEvent) => {
|
||||
const files = Array.from(e.clipboardData?.files ?? []);
|
||||
// Plain text pastes carry no files — leave them entirely alone.
|
||||
if (files.length === 0) return;
|
||||
// Guard against double-adding if more than one box ever mounts.
|
||||
const marked = e as ClipboardEvent & { promptBoxHandled?: boolean };
|
||||
if (marked.promptBoxHandled) return;
|
||||
marked.promptBoxHandled = true;
|
||||
// No preventDefault: any text alongside the file still pastes into
|
||||
// whichever field is focused.
|
||||
routeFilesRef.current(files);
|
||||
};
|
||||
window.addEventListener("paste", handlePaste);
|
||||
return () => window.removeEventListener("paste", handlePaste);
|
||||
}, [enabled]);
|
||||
|
||||
return {
|
||||
dragState,
|
||||
dropZoneProps: {
|
||||
ref: zoneRef,
|
||||
// Under Tauri these never fire, but leaving them attached keeps a
|
||||
// single code path and costs nothing.
|
||||
onDragEnter: handleDragOver,
|
||||
onDragOver: handleDragOver,
|
||||
onDragLeave: handleDragLeave,
|
||||
onDrop: handleDrop,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-bleed dashed overlay shown while files hover the prompt box, listing
|
||||
* the reference kinds the current model takes (red when none match).
|
||||
*/
|
||||
export function PromptBoxDropOverlay({
|
||||
dragState,
|
||||
acceptsImages,
|
||||
acceptsVideos,
|
||||
acceptsAudio,
|
||||
keyframeMode,
|
||||
}: PromptBoxDropOverlayProps) {
|
||||
if (dragState === "idle") return null;
|
||||
|
||||
const kinds = [
|
||||
...(acceptsImages
|
||||
? [{ icon: faImages, label: keyframeMode ? "Frames" : "Images" }]
|
||||
: []),
|
||||
...(acceptsVideos ? [{ icon: faVideo, label: "Video" }] : []),
|
||||
...(acceptsAudio ? [{ icon: faMusic, label: "Audio" }] : []),
|
||||
];
|
||||
const rejected = dragState === "reject";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={twMerge(
|
||||
"promptbox-drop-overlay pointer-events-none absolute inset-0 z-40 flex flex-col items-center justify-center gap-2 rounded-2xl border-2 border-dashed bg-[#161618]/85 backdrop-blur-sm",
|
||||
rejected ? "border-red-400/80" : "border-primary",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{kinds.map((kind, i) => (
|
||||
<div
|
||||
key={kind.label}
|
||||
className="promptbox-drop-chip flex h-9 w-9 items-center justify-center rounded-lg bg-white/10 text-base-fg/90"
|
||||
style={{ animationDelay: `${i * 50}ms` }}
|
||||
>
|
||||
<FontAwesomeIcon icon={kind.icon} className="text-sm" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-base-fg">
|
||||
{rejected
|
||||
? "That file type isn't supported"
|
||||
: keyframeMode && !acceptsVideos && !acceptsAudio
|
||||
? "Drop to set your frames"
|
||||
: "Drop to add references"}
|
||||
</div>
|
||||
<div className="-mt-1 text-xs text-base-fg/60">
|
||||
{rejected
|
||||
? `Accepts ${listKinds(acceptedKindLabels(acceptsImages, acceptsVideos, acceptsAudio))} files`
|
||||
: kinds.map((kind) => kind.label).join(" · ")}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function acceptedKindLabels(
|
||||
acceptsImages: boolean,
|
||||
acceptsVideos: boolean,
|
||||
acceptsAudio: boolean,
|
||||
): string[] {
|
||||
return [
|
||||
...(acceptsImages ? ["image"] : []),
|
||||
...(acceptsVideos ? ["video"] : []),
|
||||
...(acceptsAudio ? ["audio"] : []),
|
||||
];
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import type { MentionItem } from "./types";
|
||||
import type { MentionItem } from "./MentionTextarea";
|
||||
|
||||
// ── @-mention color palette ─────────────────────────────────────────────
|
||||
|
||||
@@ -340,6 +340,7 @@ export interface StoredCharacter {
|
||||
character_token: string;
|
||||
name: string;
|
||||
avatar_image_url?: string;
|
||||
full_image_url?: string;
|
||||
}
|
||||
|
||||
interface CharactersStore {
|
||||
|
||||
@@ -1,4 +1,40 @@
|
||||
import toast, { Toaster as RHToaster } from "react-hot-toast";
|
||||
import rhToast, { Toaster as RHToaster } from "react-hot-toast";
|
||||
|
||||
// Host apps that render their own toast UI instead of mounting <Toaster />
|
||||
// (e.g. the webapp) register a delegate here. Shared-lib `toast.success` /
|
||||
// `toast.error` calls are forwarded to it — without this they dispatch into
|
||||
// react-hot-toast, which renders nothing when its container isn't mounted.
|
||||
export interface ToastDelegate {
|
||||
success: (message: string) => void;
|
||||
error: (message: string) => void;
|
||||
}
|
||||
|
||||
let toastDelegate: ToastDelegate | null = null;
|
||||
|
||||
export function setToastDelegate(delegate: ToastDelegate | null) {
|
||||
toastDelegate = delegate;
|
||||
}
|
||||
|
||||
const toast: typeof rhToast = Object.assign(
|
||||
((...args: Parameters<typeof rhToast>) => rhToast(...args)) as typeof rhToast,
|
||||
rhToast,
|
||||
{
|
||||
success: ((message, opts) => {
|
||||
if (toastDelegate && typeof message === "string") {
|
||||
toastDelegate.success(message);
|
||||
return "";
|
||||
}
|
||||
return rhToast.success(message, opts);
|
||||
}) as typeof rhToast.success,
|
||||
error: ((message, opts) => {
|
||||
if (toastDelegate && typeof message === "string") {
|
||||
toastDelegate.error(message);
|
||||
return "";
|
||||
}
|
||||
return rhToast.error(message, opts);
|
||||
}) as typeof rhToast.error,
|
||||
},
|
||||
);
|
||||
|
||||
interface ToasterProps {
|
||||
position?: "top-right" | "top-left" | "bottom-right" | "bottom-left";
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
import React, { useState, useRef, useEffect, useReducer } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Transition } from "@headlessui/react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
@@ -25,6 +26,16 @@ interface TooltipProps {
|
||||
* when the tooltip is inside an overflow-hidden / overflow-auto container.
|
||||
*/
|
||||
strategy?: "absolute" | "fixed";
|
||||
/**
|
||||
* Render the tooltip in a document.body portal (implies fixed positioning).
|
||||
* Use when the trigger sits inside its own stacking context (backdrop-blur
|
||||
* "glass" surfaces, transformed ancestors) that would otherwise paint the
|
||||
* tooltip below overlays like the sidebar — no zIndex can escape that from
|
||||
* inside. Pair with a high `zIndex`; the portal root is marked
|
||||
* `data-modal-outside-safe` so clicks in it don't count as outside clicks
|
||||
* for the shared Modal.
|
||||
*/
|
||||
portal?: boolean;
|
||||
}
|
||||
|
||||
export const Tooltip = ({
|
||||
@@ -41,6 +52,7 @@ export const Tooltip = ({
|
||||
zIndex = 10,
|
||||
disabled = false,
|
||||
strategy = "absolute",
|
||||
portal = false,
|
||||
}: TooltipProps) => {
|
||||
const [isShowing, setIsShowing] = useState(false);
|
||||
const triggerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -184,16 +196,29 @@ export const Tooltip = ({
|
||||
estWidth: number,
|
||||
padding: number,
|
||||
): React.CSSProperties => {
|
||||
const offset = getContainingBlockOffset();
|
||||
// A body portal has no transformed/filtered ancestor to compensate for.
|
||||
const offset = portal ? { x: 0, y: 0 } : getContainingBlockOffset();
|
||||
const GAP = 10;
|
||||
|
||||
switch (position) {
|
||||
case "top":
|
||||
case "top": {
|
||||
const centerX = rect.left + rect.width / 2;
|
||||
const halfWidth = estWidth / 2;
|
||||
let translateX = "-50%";
|
||||
|
||||
if (centerX - halfWidth < padding) {
|
||||
const diff = padding - (centerX - halfWidth);
|
||||
translateX = `calc(-50% + ${diff}px)`;
|
||||
} else if (centerX + halfWidth > vw - padding) {
|
||||
const diff = centerX + halfWidth - (vw - padding);
|
||||
translateX = `calc(-50% - ${diff}px)`;
|
||||
}
|
||||
return {
|
||||
top: rect.top - offset.y - GAP,
|
||||
left: rect.left + rect.width / 2 - offset.x,
|
||||
transform: "translate(-50%, -100%)",
|
||||
left: centerX - offset.x,
|
||||
transform: `translate(${translateX}, -100%)`,
|
||||
};
|
||||
}
|
||||
case "bottom": {
|
||||
const centerX = rect.left + rect.width / 2;
|
||||
const halfWidth = estWidth / 2;
|
||||
@@ -240,7 +265,7 @@ export const Tooltip = ({
|
||||
if (currentWidth > 0) estWidth = currentWidth;
|
||||
}
|
||||
|
||||
return strategy === "fixed"
|
||||
return strategy === "fixed" || portal
|
||||
? getFixedStyle(rect, vw, estWidth, padding)
|
||||
: getAbsoluteStyle(rect, vw, estWidth, padding);
|
||||
};
|
||||
@@ -288,51 +313,38 @@ export const Tooltip = ({
|
||||
};
|
||||
}, []);
|
||||
|
||||
const isFixed = strategy === "fixed";
|
||||
const isFixed = strategy === "fixed" || portal;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={triggerRef}
|
||||
onPointerEnter={() => {
|
||||
setIsHoveringTrigger(true);
|
||||
if (!checkForOpenPopovers() && !disabled) {
|
||||
setIsShowing(true);
|
||||
}
|
||||
}}
|
||||
onPointerLeave={() => {
|
||||
setIsHoveringTrigger(false);
|
||||
if (!interactive) {
|
||||
setIsShowing(false);
|
||||
}
|
||||
}}
|
||||
onPointerCancel={() => {
|
||||
setIsHoveringTrigger(false);
|
||||
setIsHoveringTooltip(false);
|
||||
setIsShowing(false);
|
||||
}}
|
||||
onBlur={() => {
|
||||
setIsHoveringTrigger(false);
|
||||
setIsHoveringTooltip(false);
|
||||
setIsShowing(false);
|
||||
}}
|
||||
onClick={handleClick}
|
||||
className="relative"
|
||||
// A portaled tooltip positions off a snapshot of the trigger's viewport
|
||||
// rect — re-render on scroll/resize while showing so it tracks the trigger
|
||||
// instead of floating where it used to be.
|
||||
const [, bumpReposition] = useReducer((n: number) => n + 1, 0);
|
||||
useEffect(() => {
|
||||
if (!portal || !isShowing) return;
|
||||
window.addEventListener("scroll", bumpReposition, true);
|
||||
window.addEventListener("resize", bumpReposition);
|
||||
return () => {
|
||||
window.removeEventListener("scroll", bumpReposition, true);
|
||||
window.removeEventListener("resize", bumpReposition);
|
||||
};
|
||||
}, [portal, isShowing]);
|
||||
|
||||
const tooltipPanel = (
|
||||
<Transition
|
||||
show={isShowing}
|
||||
enter={twMerge(
|
||||
"transition ease-out duration-200",
|
||||
delay ? `delay-[${delay}ms]` : "delay-[300ms]",
|
||||
)}
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="transition ease-in duration-150"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
{children}
|
||||
<Transition
|
||||
show={isShowing}
|
||||
enter={twMerge(
|
||||
"transition ease-out duration-200",
|
||||
delay ? `delay-[${delay}ms]` : "delay-[300ms]",
|
||||
)}
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="transition ease-in duration-150"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div
|
||||
ref={setTooltipRef}
|
||||
{...(portal ? { "data-modal-outside-safe": "" } : {})}
|
||||
onPointerEnter={() => interactive && setIsHoveringTooltip(true)}
|
||||
onPointerLeave={() => interactive && setIsHoveringTooltip(false)}
|
||||
onClick={() => {
|
||||
@@ -378,7 +390,39 @@ export const Tooltip = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Transition>
|
||||
</Transition>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={triggerRef}
|
||||
onPointerEnter={() => {
|
||||
setIsHoveringTrigger(true);
|
||||
if (!checkForOpenPopovers() && !disabled) {
|
||||
setIsShowing(true);
|
||||
}
|
||||
}}
|
||||
onPointerLeave={() => {
|
||||
setIsHoveringTrigger(false);
|
||||
if (!interactive) {
|
||||
setIsShowing(false);
|
||||
}
|
||||
}}
|
||||
onPointerCancel={() => {
|
||||
setIsHoveringTrigger(false);
|
||||
setIsHoveringTooltip(false);
|
||||
setIsShowing(false);
|
||||
}}
|
||||
onBlur={() => {
|
||||
setIsHoveringTrigger(false);
|
||||
setIsHoveringTooltip(false);
|
||||
setIsShowing(false);
|
||||
}}
|
||||
onClick={handleClick}
|
||||
className="relative"
|
||||
>
|
||||
{children}
|
||||
{portal ? createPortal(tooltipPanel, document.body) : tooltipPanel}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Generated
+88
-168
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user