Compare commits

..
Author SHA1 Message Date
Brandon Thomas 6d20537f3a support tool 2026-07-27 10:01:38 -04:00
Brandon Thomas 029cd5a836 support tool for credits 2026-07-13 21:33:36 -04:00
Brandon Thomas ce6c652669 payment tool 2026-07-13 21:27:02 -04:00
Brandon Thomas 5858f7cc5d billing APIs 2026-07-13 21:14:01 -04:00
Brandon Thomas 99d7da8197 audit tool 2026-07-13 20:24:57 -04:00
239 changed files with 2738 additions and 17393 deletions
-5
View File
@@ -1,8 +1,3 @@
# Shell scripts and dotenv config files are executed/parsed on Linux (WSL2, CI)
# even when the repo is cloned on Windows — they must stay LF in the worktree.
*.sh text eol=lf
*.env text eol=lf
# Ignore certain code for Github language statistics
# https://github.com/github/linguist/blob/master/docs/overrides.md
old_code/trumpet-trumped.com/dictionary/cmudict-0.7b linguist-vendored
-3
View File
@@ -104,6 +104,3 @@ windows/sqlite/sqlite3.lib
crates/desktop/src-tauri/*.dll
windows_dlls/
# Portable local dev stack (script/bootstrap/windows) - MySQL/Redis binaries, data, logs
.devstack/
Generated
+1 -31
View File
@@ -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"
-1
View File
@@ -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 -1
View File
@@ -28,7 +28,7 @@ Text-to-image is great, but artists *need control*. It's important to know what
| **3D Image Compositing** | ![Video](https://github.com/user-attachments/assets/f93a616f-571d-474e-bcc0-53736de7303d) Use images (backdrops, foreground elements, props, etc.) in scenes with depth and blend them naturally together. Just a couple of images usually leads to great compositions. |
| **2D Image Compositing** | ![Video](https://github.com/user-attachments/assets/d6f99391-e496-4c62-9e37-29734ba5f899) Use images, background removal, layers, and simple drawing tools to precisely compose a scene. |
| **Image to 3D Mesh** | ![Video](https://github.com/user-attachments/assets/600a405c-e360-48c1-9b42-6e657ae6243b) It's almost impossible to lay out complicated objects or block complicated scenes; turning images into 3D helps position elements exactingly and intentionally. |
| **Character Posing** | ![Video](https://raw.githubusercontent.com/storytold/github-media/22a27373707b13f48fc56405f1f89ca143c43d5d/character-posing.webp) You can dynamically pose your characters to achieve the precise character, scene, and camera blocking before calling "action". |
| **Character Posing** | ![Video](https://github.com/user-attachments/assets/52a8e983-7c8f-42d2-be8b-25296ab9ed57) You can dynamically pose your characters to achieve the precise character, scene, and camera blocking before calling "action". |
| **Scene Blocking w/ Kit Bashing** | ![Video](https://github.com/user-attachments/assets/eef025ac-0346-4a46-a023-d48e23629eb5) Use 3D asset kits to precisely block out your scene: get the correct angles, object positions, and rich depth layering you can't with text prompting. |
| **Character Identity Transfer** | ![Video](https://github.com/user-attachments/assets/629119ee-8c76-4a83-9827-8c6c995a3ec1) Use mannequins as simple 3D ControlNets for posing any character. |
| **Background Removal** | ![Video](https://github.com/user-attachments/assets/90c65057-5531-404f-83af-b34e66e24ec1) Remove backgrounds from images to make them useful in 2D or 3D compositing. They can be props, layers, or backdrops. |
-294
View File
@@ -1,294 +0,0 @@
# 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.
The automated path on **Windows** (primary platform, native — no WSL, no
Docker, no admin rights for the stack itself):
```powershell
.\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
.\script\bootstrap\windows\dev_stack_doctor.ps1 # health check, any time
.\script\bootstrap\windows\stop_dev_services.ps1 # stop MySQL/Redis when done
```
The equivalent on **Linux** (Ubuntu 22.04+ — native, WSL2, or CI):
```bash
./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
./script/bootstrap/dev_stack_doctor.sh # health check, any time
```
This document records the underlying requirements so the scripts aren't a
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 |
### Windows specifics
- Everything stateful lives in `.devstack/` (gitignored): `mysql/` (extracted
zip), `mysql-data/`, `redis/`, `logs/`, `pids/`, `downloads/` (cached zips),
`my.ini`. Delete the directory to fully reset; re-run bootstrap to rebuild.
- Pinned services: MySQL **8.4.11** from `cdn.mysql.com` (the 8.x series is
required; only some patch versions exist on the CDN) and Redis **5.0.14.1**
(tporadowski build — old but sufficient for the backend's keepalive/TTL/
rate-limit usage; override `REDIS_ZIP_URL` to use Memurai/Garnet instead).
- The portable MySQL root user has **no password** (`--initialize-insecure`) —
local dev only, bound to 127.0.0.1.
- diesel-cli is compiled against the portable MySQL's own client library
(`MYSQLCLIENT_LIB_DIR`/`MYSQLCLIENT_VERSION`); `libmysql.dll` must be on
`PATH` to *run* it — the scripts handle both.
- Compiling the backend needs cmake, Strawberry Perl, NASM, and LLVM/libclang
(the `wreq` HTTP client builds BoringSSL, and `aws-lc-sys`/`ring` need
NASM/clang on MSVC). The bootstrap checks and offers winget installs.
- The generated secrets file additionally sets `TEMP_DIR` (the upload temp-dir
helper defaults to `/tmp`, which doesn't exist on Windows).
## 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`) |
| 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 | — |
Database identity (used consistently by docs, `.env`, and defaults):
database `storyteller`, user `storyteller`, password `password`, host
`localhost`.
## Backend: what `storyteller-web` needs to boot
Binary: `storyteller-web` in `crates/service/web/storyteller_web`
(binaries: `storyteller-web`, `docs` — Swagger UI on :8989, `docs-cli` — emits
`api.json`). Binds `0.0.0.0:12345` (`BIND_ADDRESS`), plain HTTP.
**Run it from the repo root** — the config search path and `includes/` paths
are cwd-relative.
### Config loading (corrected facts)
- Mode is selected by the `SERVER_ENVIRONMENT` **process env var only**
(default: Development). Setting it inside an env file does nothing — it is
read before any file loads.
- In Development mode, files load from
`crates/service/web/storyteller_web/config/` in this order:
`storyteller-web.common.env` → `storyteller-web.development.env` →
`storyteller-web.development-secrets.env` (gitignored; the bootstrap
generates it).
- **Precedence is first-value-wins** (dotenv never overrides an existing key):
real process env > common.env > development.env > development-secrets.env.
The secrets file therefore cannot override anything already set in
`development.env`.
- The repo-root `.env` is **NOT loaded by the server** (it passes
`ignore_legacy_dot_env_file: true`). Root `.env`'s `DATABASE_URL` feeds
diesel-cli/sqlx-cli only; the server reads **`MYSQL_URL`** (default
`mysql://storyteller:password@localhost/storyteller`).
### 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 |
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`,
`W2L_PRIVATE_DOWNLOAD_BUCKET_NAME`, `W2L_PUBLIC_DOWNLOAD_BUCKET_NAME`,
`RESEND_API_KEY`, `FAL_API_KEY`, `GMICLOUD_API_KEY`, `GROK_API_KEY`,
`BEEBLE_API_KEY`, `OPENAI_API_KEY`, `WORLDLABS_API_KEY`,
`SEEDANCE2PRO_COOKIES`, `SEEDANCE2PRO_WHITELIST_COOKIES`,
`SEEDANCE2PRO_BYTEPLUS_ULTRA_COOKIES`, `STRIPE_FAKEYOU_ACCOUNT_ID`,
`STRIPE_ARTCRAFT_ACCOUNT_ID`, `STRIPE_ARTCRAFT_SECRET_KEY`,
`STRIPE_ARTCRAFT_SECRET_WEBHOOK_KEY`.
**Placeholder values are safe at boot**: none of these services is contacted
at boot (clients are constructed offline; Elasticsearch, S3/R2, Stripe,
Resend, and Google-cert fetches are all lazy). But two earlier claims here
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.
### Fully-local generation and media (`DEV_FAKE_GENERATION`)
The generated secrets file enables three development-only settings (appended
automatically on bootstrap re-runs for older stacks):
| 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).
### Auth on localhost
- `POST /v1/login` takes `{"username_or_email": "...", "password": "..."}`;
success returns `{"success": true, "signed_session": "<jwt>"}` **and** a
`session` cookie.
- With the checked-in dev config (`COOKIE_DOMAIN=.jungle.horse`), the session
cookie is issued non-Secure and host-only (the Domain attribute is never
set), so plain-HTTP localhost login works as-is. The frontend additionally
stores `signed_session` in localStorage and sends it as a `session` header.
- Dev-mode CORS allows any `localhost`/`127.0.0.1` origin on any port, with
credentials.
- Health endpoint for scripts/CI: `GET /_status`.
### Build
- Compile with `SQLX_OFFLINE=true` (uses the checked-in `.sqlx/` cache; 483
queries). Without it, the sqlx macros open a **live DB connection at compile
time** using `DATABASE_URL` from the root `.env`. `SQLX_OFFLINE` has no
runtime effect — ignore older notes saying not to set it when running.
- First build takes several minutes; the crate has ~370 pre-existing warnings.
## Database: migrations and seed data
- 281 Diesel migrations in `_database/sql/migrations/` (`diesel.toml` at repo
root points there). Run from the repo root: `diesel migration run`.
`"Encountered unknown type for Mysql: enum"` warnings are harmless.
`migrations_squashed/` is documentation only — not executable.
- Seed data: `_database/sql/seed/sql/system_roles.sql` (roles `user`, `mod`,
`admin`) and `user_badges.sql`. **The `user` role is mandatory** — account
creation hardcodes `user_role_slug='user'`. The legacy wrapper
`bootstrap_inserts_roles_etc.sh` must run with cwd `_database/sql/` and is
not idempotent; the bootstrap script applies the same SQL files with
idempotency guards instead.
- Demo user: created through **`POST /v1/create_account`** (no captcha, no
invite code, no email-verification gate; password just needs 6+ chars). This
exercises the real signup path instead of hand-rolled SQL. Gotcha: usernames
like `demo`, `admin`, `test`, `dev`, `artcraft` are reserved
(`includes/binary_includes/usernames/reserved_usernames.txt`) — the default
demo user is `localdev1`. The Rust seeder
(`cargo run --bin dev-database-seed`) exists but hard-requires a
`.env-secrets` file and also seeds model weights, so the HTTP path is
preferred.
- Demo credits: signup creates **no wallet**, and the webapp gates the
generate button on wallet balance — so `seed_demo_user` also seeds an
`artcraft`-namespace wallet with `DEMO_CREDITS` (default 100,000) banked
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).
## Frontend: `artcraft-webapp`
- Nx workspace at repo-root `frontend/` (npm workspaces; `npm install` from
`frontend/`). The FontAwesome Pro registry token is committed in
`frontend/.npmrc` — no manual auth step.
- Serve: `npx nx dev artcraft-webapp` → `http://localhost:4201`. No lib
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.
- 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
pnpm app, excluded from Nx), video-info-website 4250, genhub 4300, Tauri
frontend 5173.
## 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:
| 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) |
## CI usage
The same bootstrap works on a fresh Ubuntu runner/container:
```bash
export MYSQL_ROOT_PASSWORD=root # GitHub Actions' preinstalled MySQL uses password auth
./script/bootstrap/bootstrap_dev_stack.sh --yes
./script/bootstrap/run_backend_dev.sh &
./script/bootstrap/seed_demo_user.sh # waits for /_status
./script/bootstrap/dev_stack_doctor.sh
```
`--yes` answers all prompts; `--skip-frontend` / `--skip-rust-build` /
`--skip-packages` slice the work for focused jobs.
## Verification checklist
- `./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/…`).
## Corrections to older docs
- `crates/service/web/storyteller_web/CLAUDE.md`: the repo-root `.env` is
*not* auto-loaded by the server, and `SQLX_OFFLINE=true` is fine (and
recommended) when running — it only affects compile-time query checking.
- `_docs/dev_setup_server.md`: `./script/dev_mysql_connect.sh` doesn't exist —
it's `./script/dev_database_connect.sh`.
- `diesel.toml`'s comment about needing an unreleased diesel CLI is stale —
any current `diesel_cli` works.
- `TESTING_INFRA_HANDOFF.md` (untracked): the server env var is `MYSQL_URL`,
not `DATABASE_URL`; migration count is 281; crate path is
`crates/service/web/storyteller_web`.
@@ -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,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;
+4 -4
View File
@@ -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
@@ -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(),
@@ -10,6 +10,7 @@ use http_server_common::request::get_request_ip::get_request_ip;
use mysql_queries::queries::api_keys::insert_api_key::{insert_api_key, InsertApiKeyArgs};
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::user_lookup::user_session::session_utils::lookup::user_session_feature_flags::UserSessionFeatureFlags;
use crate::http_server::user_lookup::user_session::require_user_session::require_user_session;
use crate::state::server_state::ServerState;
use crate::util::generate_api_key::generate_api_key;
@@ -29,6 +30,7 @@ const MAX_DESCRIPTION_LEN: usize = 512;
(status = 200, body = CreateApiKeySuccessResponse),
(status = 400, body = CommonWebError),
(status = 401, body = CommonWebError),
(status = 403, body = CommonWebError),
(status = 500, body = CommonWebError),
),
)]
@@ -44,6 +46,14 @@ pub async fn create_api_key_handler(
let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *conn).await?;
// API key creation is gated behind the `api_key` feature flag.
let feature_flags = UserSessionFeatureFlags::new(user_session.maybe_feature_flags.as_deref());
if !feature_flags.can_create_api_key() {
warn!("User {:?} attempted to create an API key without the api_key feature flag", user_session.user_token.as_str());
return Err(CommonWebError::Forbidden);
}
let name = request.name.trim().to_string();
let maybe_description = request.maybe_description.as_ref().map(|s| s.trim().to_string());
@@ -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;
@@ -174,21 +173,6 @@ async fn finalize_and_generate(
server_state: &ServerState,
resolved_media: &MediaFilesAsCdnUrlListAndMap,
) -> 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)?;
@@ -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;
@@ -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)?;
@@ -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))
}
@@ -101,6 +101,10 @@ impl UserSessionFeatureFlags {
pub fn can_use_referrals_program(&self) -> bool {
self.feature_flags.contains(&UserFeatureFlag::ReferralsProgram)
}
pub fn can_create_api_key(&self) -> bool {
self.feature_flags.contains(&UserFeatureFlag::ApiKey)
}
}
#[cfg(test)]
@@ -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;
@@ -124,38 +124,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, &region_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, &region_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, &region_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, &region_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 +175,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 +192,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 +213,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,
@@ -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,
@@ -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;
@@ -116,12 +116,8 @@ export function getMediaThumbnailUrl(
}
}
// 3D (mesh/splat, plus legacy pre-split "dimensional"): use cover image URL
if (
item.media_class === "mesh" ||
item.media_class === "splat" ||
item.media_class === "dimensional"
) {
// 3D/dimensional: use cover image URL
if (item.media_class === "dimensional") {
if ("cover_image" in item && item.cover_image) {
if (item.cover_image.maybe_cover_image_public_bucket_url) {
return item.cover_image.maybe_cover_image_public_bucket_url;
@@ -169,6 +169,60 @@ export class UsersApi extends ApiManager {
}));
}
public async RequestPasswordReset({
email,
}: {
email: string;
}): Promise<ApiResponse<null>> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/request_password_reset`;
return await this.post<
{ email: string },
{ success: boolean; error_message?: string }
>({
endpoint,
body: { email },
})
.then((response) => ({
success: response.success,
errorMessage: response.error_message,
}))
.catch((err) => ({
success: false,
errorMessage: err.message,
}));
}
public async ResetPassword({
token,
password,
passwordConfirmation,
}: {
token: string;
password: string;
passwordConfirmation: string;
}): Promise<ApiResponse<null>> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/reset_password`;
return await this.post<
{ token: string; password: string; password_confirmation: string },
{ success: boolean; error_message?: string }
>({
endpoint,
body: {
token,
password,
password_confirmation: passwordConfirmation,
},
})
.then((response) => ({
success: response.success,
errorMessage: response.error_message,
}))
.catch((err) => ({
success: false,
errorMessage: err.message,
}));
}
public async ChangePassword({
password,
passwordConfirmation,
@@ -28,8 +28,6 @@ interface MediaCardProps {
const classIcons: Record<string, typeof IconPhoto> = {
video: IconVideo,
dimensional: IconCube,
mesh: IconCube,
splat: IconCube,
};
function formatDuration(ms: number): string {
@@ -120,9 +118,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
@@ -41,15 +41,11 @@ const classLabels: Record<string, string> = {
image: "Image",
video: "Video",
dimensional: "3D",
mesh: "3D Object",
splat: "3D World",
};
const classIcons: Record<string, typeof IconPhoto> = {
video: IconVideo,
dimensional: IconCube,
mesh: IconCube,
splat: IconCube,
};
function formatModelType(model: string): string {
@@ -26,9 +26,7 @@ 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: "dimensional", label: "3D" },
] as const;
const PAGE_SIZE = 24;
@@ -76,8 +74,7 @@ export function ExploreMedia() {
const resp = await api.ListMediaFiles({
page_size: PAGE_SIZE,
cursor,
filter_media_classes:
filterMediaClasses || "image,video,mesh,splat,dimensional",
filter_media_classes: filterMediaClasses || "image,video,dimensional",
filter_media_type: filterMediaType || undefined,
include_user_uploads: includeUserUploads || undefined,
});
@@ -46,7 +46,7 @@ export function UserCreations() {
const resp = await mediaApi.ListUserMediaFiles(username, {
page_index: pageIndex,
page_size: PAGE_SIZE,
filter_media_classes: ["image", "video", "mesh", "splat", "dimensional"],
filter_media_classes: ["image", "video", "dimensional"],
});
if (cancelledRef.current) return;
@@ -84,7 +84,7 @@ export function UserCreations() {
mediaApi.ListUserMediaFiles(username, {
page_index: 0,
page_size: PAGE_SIZE,
filter_media_classes: ["image", "video", "mesh", "splat", "dimensional"],
filter_media_classes: ["image", "video", "dimensional"],
}),
]);
@@ -174,7 +174,7 @@ export function UserProfile() {
modApi.UserLookup(username),
mediaApi.ListUserMediaFiles(username, {
page_size: 24,
filter_media_classes: ["image", "video", "mesh", "splat", "dimensional"],
filter_media_classes: ["image", "video", "dimensional"],
}),
]);
@@ -3,7 +3,6 @@
"version": "0.0.1",
"private": true,
"dependencies": {
"jszip": "^3.10.1",
"react-aria": "^3.48.0",
"react-masonry-css": "^1.0.16"
}
+7 -13
View File
@@ -18,7 +18,6 @@ import CreateAudio from "../pages/create-audio";
import CreateObject from "../pages/create-object";
import CreateWorld from "../pages/create-world";
import CreateVFX from "../pages/create-vfx";
import FrameExtractor from "../pages/frame-extractor";
import PageScene from "../pages/pagescene";
import PageDraw from "../pages/pagedraw";
import VideoEditorPage from "../pages/video-editor";
@@ -161,11 +160,15 @@ export function App() {
<Route path="/create-object" element={<CreateObject />} />
<Route path="/create-world" element={<CreateWorld />} />
<Route path="/background-change" element={<CreateVFX />} />
<Route path="/frame-extractor" element={<FrameExtractor />} />
<Route path="/moodboard" element={<MoodboardPage />} />
<Route path="/edit-3d" element={<PageScene />} />
<Route path="/edit-3d/:sceneToken" element={<PageScene />} />
<Route path="/edit-image" element={<PageDraw />} />
<Route path="/video-editor" element={<VideoEditorPage />} />
<Route
path="/video-editor/:projectId"
element={<VideoEditorPage />}
/>
<Route path="/support" element={<Support />} />
<Route path="/pricing" element={<Pricing />} />
{/* Welcome is public so it stays reachable right after signup
@@ -176,21 +179,12 @@ export function App() {
{/* Protected — sign-in required (user-owned content / billing flows) */}
<Route element={<RequireAuth />}>
{/* Video projects persist server-side, so the whole editor is
sign-in gated: logged-out visits bounce to /login?from=...
and return here after auth. */}
<Route path="/video-editor" element={<VideoEditorPage />} />
<Route
path="/video-editor/:projectId"
element={<VideoEditorPage />}
/>
<Route path="/media" element={<Media />} />
<Route path="/media/:id" element={<Media />} />
<Route path="/library" element={<Library />} />
<Route path="/library/folders" element={<Library />} />
{/* `:slug` is a media-class filter (images/videos/meshes), a
folder token (prefixed `folder_`), the static `tags` tab, or
a tag token (prefixed `tag_`); the page disambiguates. */}
{/* `:slug` is a media-class filter (images/videos/meshes) OR a
folder token (prefixed `folder_`); the page disambiguates. */}
<Route path="/library/:slug" element={<Library />} />
<Route path="/referrals" element={<Referrals />} />
<Route path="/onboarding" element={<Onboarding />} />
@@ -7,11 +7,7 @@ import { TabSelector } from "@storyteller/ui-tab-selector";
import { TruchetPattern } from "@storyteller/ui-vfx";
import Seo from "../../components/seo";
import { useIsMobile } from "../ui/use-mobile";
import {
GalleryAutoplayToggle,
GallerySelectToggle,
GalleryViewToggle,
} from "./GalleryViewToggle";
import { GalleryViewToggle } from "./GalleryViewToggle";
import {
MobileCreateTabsProvider,
type MobileCreateTab,
@@ -27,12 +23,6 @@ interface CreateMediaPageShellProps {
authChecked: boolean;
// Content
hasContent: boolean;
// Video page only: playing/still toggle for video preview thumbnails,
// rendered next to the grid/list toggle on the mobile History tab.
showAutoplayToggle?: boolean;
// Multi-select + batch download toggle, rendered in the same mobile
// History-tab cluster (the desktop toggle lives in the TopBar).
showSelectToggle?: boolean;
emptyStateTitle: string;
emptyStateSubtitle: string;
// Optional CTA rendered under the empty-state subtitle (e.g. a signup button
@@ -64,8 +54,6 @@ export function CreateMediaPageShell({
description,
authChecked,
hasContent,
showAutoplayToggle = false,
showSelectToggle = false,
emptyStateTitle,
emptyStateSubtitle,
emptyStateCta,
@@ -104,26 +92,18 @@ export function CreateMediaPageShell({
<div className="flex h-full w-full flex-col bg-[#101014] text-white">
<Seo title={title} description={description} />
{/* Flex (not absolute) keeps the toggle cluster from overlapping
the tabs — the tabs stay centered via the equal flex-1 sides and
just shift left if the cluster outgrows its half. */}
<div className="flex items-center border-b border-ui-panel-border px-3 py-2">
<div className="flex-1" />
<div className="relative flex items-center justify-center border-b border-ui-panel-border px-3 py-2">
<TabSelector
tabs={MOBILE_TABS}
activeTab={mobileTab}
onTabChange={(id) => setMobileTab(id as MobileCreateTab)}
className="w-auto shrink-0"
className="w-auto"
/>
<div className="flex flex-1 items-center justify-end gap-1.5">
{mobileTab === "history" && hasContent && (
<>
{showSelectToggle && <GallerySelectToggle />}
{showAutoplayToggle && <GalleryAutoplayToggle />}
<GalleryViewToggle />
</>
)}
</div>
{mobileTab === "history" && hasContent && (
<div className="absolute right-3 top-1/2 -translate-y-1/2">
<GalleryViewToggle />
</div>
)}
</div>
<div className="min-h-0 flex-1 overflow-hidden">
@@ -1,101 +0,0 @@
import { useMemo, useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faArrowDownToLine,
faSpinnerThird,
} from "@fortawesome/pro-solid-svg-icons";
import {
SelectionActionBar,
useGallerySelectionStore,
type GalleryItem,
} from "@storyteller/ui-generation-list";
import { toast } from "../toast/toast";
import { downloadItemsAsZip } from "../../lib/download-media-zip";
import { useIsMobile } from "../ui/use-mobile";
// Floating bar shown while the generation feed is in select mode:
// "Download selected" zips the chosen items client-side. Centered within the
// content area next to the sidebar; on desktop it sits above the floating
// promptbox via the measured `bottomOffset`, on mobile (History tab — no
// promptbox) it uses a fixed offset like the library's bulk bar.
export function GallerySelectionDownloadBar({
allItems,
bottomOffset,
}: {
allItems: GalleryItem[];
bottomOffset?: number;
}) {
const isMobile = useIsMobile();
const ids = useGallerySelectionStore((s) => s.ids);
const setActive = useGallerySelectionStore((s) => s.setActive);
const [progress, setProgress] = useState<{
done: number;
total: number;
} | null>(null);
const isDownloading = progress !== null;
const selectedItems = useMemo(() => {
const byId = new Map(allItems.map((it) => [it.id, it] as const));
return Array.from(ids)
.map((id) => byId.get(id))
.filter(
(it): it is GalleryItem & { fullImage: string } => !!it?.fullImage,
);
}, [allItems, ids]);
const handleDownload = async () => {
if (isDownloading || selectedItems.length === 0) return;
setProgress({ done: 0, total: selectedItems.length });
try {
const { succeeded, failed } = await downloadItemsAsZip(
selectedItems.map((it) => ({
id: it.id,
url: it.fullImage,
mediaClass: it.mediaClass,
})),
{ onProgress: (done, total) => setProgress({ done, total }) },
);
if (failed === 0) {
toast.success(
`Downloaded ${succeeded} ${succeeded === 1 ? "file" : "files"}`,
);
} else if (succeeded > 0) {
toast.error(
`${failed} of ${succeeded + failed} files failed to download`,
);
} else {
toast.error("Could not download the selected files.");
}
if (succeeded > 0) setActive(false);
} finally {
setProgress(null);
}
};
return (
<SelectionActionBar
className={isMobile ? "bottom-20" : ""}
// Center within the content area (viewport minus the app sidebar); on
// desktop, sit above the fixed promptbox whatever its measured height.
style={{
left: "var(--ac-sidebar-offset, 0px)",
...(isMobile ? {} : { bottom: bottomOffset ?? 16 }),
}}
>
<button
type="button"
onClick={handleDownload}
disabled={isDownloading || selectedItems.length === 0}
className="flex items-center gap-2 rounded-full bg-ui-controls/60 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-ui-controls/90 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:opacity-60"
>
<FontAwesomeIcon
icon={isDownloading ? faSpinnerThird : faArrowDownToLine}
className={`text-xs ${isDownloading ? "animate-spin" : ""}`}
/>
{isDownloading && progress
? `Downloading ${progress.done}/${progress.total}…`
: "Download"}
</button>
</SelectionActionBar>
);
}
@@ -1,6 +1,2 @@
// Moved to the shared generation-list lib (used by both webapp and desktop).
export {
GalleryViewToggle,
GalleryAutoplayToggle,
GallerySelectToggle,
} from "@storyteller/ui-generation-list";
export { GalleryViewToggle } from "@storyteller/ui-generation-list";
@@ -1,9 +1,6 @@
import { useEffect, useMemo } from "react";
import { useGallerySelectionStore } from "@storyteller/ui-generation-list";
import { useGalleryViewStore } from "../../lib/gallery-view-store";
import { GenerationGalleryGrid } from "./GenerationGalleryGrid";
import { GenerationGalleryList } from "./GenerationGalleryList";
import { GallerySelectionDownloadBar } from "./GallerySelectionDownloadBar";
import type { GenerationGalleryProps } from "./types";
// Renders the generation feed in whichever layout the user picked via the
@@ -11,34 +8,9 @@ import type { GenerationGalleryProps } from "./types";
// rendering the grid directly on the create pages.
export function GenerationGallery(props: GenerationGalleryProps) {
const viewMode = useGalleryViewStore((s) => s.viewMode);
const { selectable, selectionBarBottomOffset, newlyCompletedItems, galleryItems } =
props;
// The selection store is global (like the view-mode store) — drop select
// mode when the feed unmounts so it can't leak onto other pages.
useEffect(() => {
if (!selectable) return;
return () => useGallerySelectionStore.getState().setActive(false);
}, [selectable]);
const allItems = useMemo(
() => [...newlyCompletedItems, ...galleryItems],
[newlyCompletedItems, galleryItems],
);
return (
<>
{viewMode === "list" ? (
<GenerationGalleryList {...props} />
) : (
<GenerationGalleryGrid {...props} />
)}
{selectable && (
<GallerySelectionDownloadBar
allItems={allItems}
bottomOffset={selectionBarBottomOffset}
/>
)}
</>
return viewMode === "list" ? (
<GenerationGalleryList {...props} />
) : (
<GenerationGalleryGrid {...props} />
);
}
@@ -13,7 +13,6 @@ import {
type GalleryItem,
is3DMediaClass,
type RecreateSlotContext,
useGallerySelectionStore,
} from "@storyteller/ui-generation-list";
import { useRecreateFromPromptToken } from "../../lib/recreate";
import { useGalleryItemActions } from "./useGalleryItemActions";
@@ -37,10 +36,7 @@ export function GenerationGalleryGrid({
onLoadMore,
onGalleryItemClick,
enableMakeVideo,
selectable,
}: GenerationGalleryProps) {
const selectionActive = useGallerySelectionStore((s) => s.active);
const selectedIds = useGallerySelectionStore((s) => s.ids);
return (
<GenerationGridView
inProgressJobs={inProgressJobs}
@@ -58,17 +54,10 @@ export function GenerationGalleryGrid({
renderGalleryActions={(item) => (
<CardActions item={item} enableMakeVideo={enableMakeVideo} />
)}
selectionMode={!!selectable && selectionActive}
selectedIds={selectedIds}
onToggleSelect={handleToggleSelect}
/>
);
}
function handleToggleSelect(item: GalleryItem) {
useGallerySelectionStore.getState().toggle(item.id);
}
// Recreate affordance for pending + failed cards. The pending card shows a
// hover-revealed icon button by the prompt; the failed card a labeled button
// next to Dismiss.
@@ -13,7 +13,6 @@ import {
type GalleryItem,
is3DMediaClass,
type RecreateSlotContext,
useGallerySelectionStore,
} from "@storyteller/ui-generation-list";
import { toast } from "../toast/toast";
import { useRecreateFromPromptToken } from "../../lib/recreate";
@@ -41,10 +40,7 @@ export function GenerationGalleryList({
onLoadMore,
onGalleryItemClick,
enableMakeVideo,
selectable,
}: GenerationGalleryProps) {
const selectionActive = useGallerySelectionStore((s) => s.active);
const selectedIds = useGallerySelectionStore((s) => s.ids);
return (
<GenerationListView
inProgressJobs={inProgressJobs}
@@ -67,17 +63,10 @@ export function GenerationGalleryList({
/>
)}
onCopyPromptResult={handleCopyResult}
selectionMode={!!selectable && selectionActive}
selectedIds={selectedIds}
onToggleSelect={handleToggleSelect}
/>
);
}
function handleToggleSelect(item: GalleryItem) {
useGallerySelectionStore.getState().toggle(item.id);
}
function handleCopyResult(success: boolean) {
if (success) {
toast.success("Prompt copied");
@@ -1,7 +1,7 @@
export { GenerationGallery } from "./GenerationGallery";
export { GenerationGalleryGrid } from "./GenerationGalleryGrid";
export { GenerationGalleryList } from "./GenerationGalleryList";
export { GalleryViewToggle, GalleryAutoplayToggle } from "./GalleryViewToggle";
export { GalleryViewToggle } from "./GalleryViewToggle";
export { useGalleryData, type GalleryItem } from "./useGalleryData";
export { useSessionMediaByClass } from "@storyteller/ui-generation-list";
export {
@@ -16,10 +16,4 @@ export interface GenerationGalleryProps {
onLoadMore: () => void;
onGalleryItemClick: (item: GalleryItem) => void;
enableMakeVideo?: boolean;
/** Enables the multi-select + batch download flow (select toggle in the
* TopBar, checkboxes on completed items, floating download bar). */
selectable?: boolean;
/** Pixel offset that keeps the floating download bar above the page's
* fixed prompt box (pass the measured promptbox height + gap). */
selectionBarBottomOffset?: number;
}
@@ -15,12 +15,12 @@ import {
faLink,
faPause,
faPencil,
faPhotoFilm,
faPlay,
faSpinnerThird,
faTrashCan,
faUser,
faVideo,
faWaveformLines,
faXmark,
} from "@fortawesome/pro-solid-svg-icons";
import {
@@ -34,7 +34,6 @@ import {
getProviderIconByName,
} from "@storyteller/model-list";
import { downloadMediaFile } from "../../lib/download-media";
import { TagsSection } from "../tags/TagsSection";
import {
formatAspectRatio,
formatDuration,
@@ -77,7 +76,6 @@ export interface LightboxDetailsProps {
onRecreate?: () => void;
onMakeVideo?: () => void;
onEditOnCanvas?: () => void;
onExtractFrames?: () => void;
onDelete?: () => void;
showDownloadAppCta?: boolean;
}
@@ -94,7 +92,6 @@ export function LightboxDetails({
onRecreate,
onMakeVideo,
onEditOnCanvas,
onExtractFrames,
onDelete,
showDownloadAppCta,
}: LightboxDetailsProps) {
@@ -433,8 +430,6 @@ export function LightboxDetails({
</div>
</div>
)}
<TagsSection mediaToken={mediaToken} creator={creator} />
</>
)}
</div>
@@ -497,16 +492,6 @@ export function LightboxDetails({
Edit on Canvas
</Button>
)}
{onExtractFrames && (
<Button
icon={faPhotoFilm}
className="w-full border border-ui-panel-border bg-ui-controls/40 hover:bg-ui-controls/60 text-white"
variant="secondary"
onClick={onExtractFrames}
>
Extract Frames
</Button>
)}
{onRecreate && promptData.hasToken && (
<Button
icon={faArrowRotateRight}
@@ -32,7 +32,6 @@ import {
applyMakeVideoFromImage,
applyRecreateFromMediaToken,
} from "../../lib/recreate";
import { useLightboxSoundStore } from "../../lib/lightbox-sound-store";
// ── Types ──────────────────────────────────────────────────────────────────
@@ -81,7 +80,6 @@ export function Lightbox({
showBatchCarousel = true,
}: LightboxProps) {
const navigate = useNavigate();
const soundEnabled = useLightboxSoundStore((s) => s.soundEnabled);
const [mediaLoaded, setMediaLoaded] = useState(false);
const [promptData, setPromptData] = useState<PromptData>(EMPTY_PROMPT);
const [creator, setCreator] = useState<UserInfo | null>(null);
@@ -352,12 +350,6 @@ export function Lightbox({
applyEditOnCanvasFromImage(selectedMediaToken, selectedImageUrl, navigate);
}, [selectedMediaToken, selectedImageUrl, canEditOnCanvas, navigate, onClose]);
const handleExtractFrames = useCallback(() => {
if (!selectedMediaToken) return;
onClose();
navigate(`/frame-extractor?media=${selectedMediaToken}`);
}, [selectedMediaToken, navigate, onClose]);
return (
<>
<Modal
@@ -387,7 +379,7 @@ export function Lightbox({
controls
loop
autoPlay
muted={!soundEnabled}
muted
playsInline
disablePictureInPicture
controlsList="nodownload noplaybackrate nofullscreen"
@@ -397,22 +389,6 @@ export function Lightbox({
const el = e.currentTarget;
setMediaWidth(el.videoWidth);
setMediaHeight(el.videoHeight);
// Restore the user's last volume (getState: subscribing
// would re-render on every tick of a volume drag).
el.volume = useLightboxSoundStore.getState().volume;
// If the browser blocked unmuted autoplay, fall back to
// muted playback rather than leaving the video frozen.
if (soundEnabled && el.paused) {
el.play().catch(() => {
el.muted = true;
el.play().catch(() => {});
});
}
}}
onVolumeChange={(e) => {
useLightboxSoundStore
.getState()
.setVolume(e.currentTarget.volume);
}}
ref={(el) => {
if (el) {
@@ -425,13 +401,11 @@ export function Lightbox({
</video>
) : isAudio ? (
<div className="flex h-full w-full items-center justify-center px-4 sm:px-8">
<div className="w-full max-w-xl rounded-2xl border border-white/10 bg-gradient-to-br from-white/[0.08] via-white/[0.04] to-white/[0.02] px-4 py-10 sm:px-6">
<div className="mx-auto mb-6 flex h-16 w-16 items-center justify-center rounded-full bg-white/10 ring-1 ring-white/15">
<FontAwesomeIcon
icon={faMusic}
className="text-2xl text-white/70"
/>
</div>
<div className="w-full max-w-xl rounded-2xl border border-white/10 bg-white/[0.04] px-2 py-8">
<FontAwesomeIcon
icon={faMusic}
className="mx-auto mb-5 block text-3xl text-white/20"
/>
<WaveformAudioPlayer
key={selectedImageUrl}
src={selectedImageUrl}
@@ -565,9 +539,6 @@ export function Lightbox({
? handleEditOnCanvas
: undefined
}
onExtractFrames={
isVideo && selectedMediaToken ? handleExtractFrames : undefined
}
onDelete={
selectedMediaToken
? () => setConfirmDeleteOpen(true)
@@ -89,13 +89,11 @@ export const CharactersModal = ({
setPendingCharacters((prev) => prev.filter((p) => p.name !== name));
}, []);
// Poll the server while any creation is pending (even with the modal
// closed), so the pending card gets cleaned up and — once the character is
// active — the mention store learns its real character token. The create
// response only carries an inference job token, so @-mentions must wait for
// the server list to include the character before it can be referenced.
// Poll the server while the modal is open and any creation is pending, so
// a creation that finishes while the user is on the create/edit view still
// gets cleaned up — preventing the duplicate (real + pending) card.
useEffect(() => {
if (pendingCharacters.length === 0) return;
if (!isOpen || pendingCharacters.length === 0) return;
const interval = setInterval(async () => {
try {
@@ -114,25 +112,14 @@ export const CharactersModal = ({
return true;
}),
);
if (resolved) {
const store = useCharactersStore.getState();
store.setCharacters(
res.data.map((c) => ({
character_token: c.token,
name: c.name,
avatar_image_url: c.maybe_avatar?.cdn_url,
})),
);
store.setLoaded(true);
setRefreshKey((k) => k + 1);
}
if (resolved) setRefreshKey((k) => k + 1);
} catch {
// retry next tick
}
}, POLL_INTERVAL_MS);
return () => clearInterval(interval);
}, [pendingCharacters.length]);
}, [isOpen, pendingCharacters.length]);
// Time out failed creations so the "Creating..." card never sticks forever.
useEffect(() => {
@@ -623,6 +610,7 @@ const NewCharacterView = ({
onBack: () => void;
onCreated: (pending: { name: string; previewUrl?: string }) => void;
}) => {
const addCharacterToStore = useCharactersStore((s) => s.addCharacter);
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [images, setImages] = useState<UploadedImage[]>([]);
@@ -765,6 +753,11 @@ const NewCharacterView = ({
if (res.success && res.data) {
toast.success(`Character "${name.trim()}" is being created`);
addCharacterToStore({
character_token: res.data.inference_job_token,
name: name.trim(),
avatar_image_url: uploadedImages[0]!.url,
});
onCreated({
name: name.trim(),
previewUrl: uploadedImages[0]!.url,
@@ -32,7 +32,6 @@ interface MediaReferenceRowProps {
onReferenceAudiosChange: (audios: RefAudio[]) => void;
maxAudioCount: number;
maxAudioRefDuration: number;
onPickAudioFromLibrary?: () => void;
className?: string;
}
@@ -48,7 +47,6 @@ export const MediaReferenceRow = ({
onReferenceAudiosChange,
maxAudioCount,
maxAudioRefDuration,
onPickAudioFromLibrary,
className,
}: MediaReferenceRowProps) => {
const videoInputRef = useRef<HTMLInputElement>(null);
@@ -213,7 +211,7 @@ export const MediaReferenceRow = ({
/>
<div
className={twMerge(
"glass flex flex-col sm:flex-row rounded-2xl",
"glass flex flex-col sm:flex-row rounded-2xl sm:rounded-none",
className,
)}
onMouseDown={(e) => e.stopPropagation()}
@@ -305,11 +303,7 @@ export const MediaReferenceRow = ({
</div>
)}
{canAddAudio && (
<AddButton
onUpload={() => audioInputRef.current?.click()}
onPickFromLibrary={onPickAudioFromLibrary}
title="Add audio"
/>
<AddButton onUpload={() => audioInputRef.current?.click()} />
)}
</div>
</div>
@@ -16,28 +16,12 @@ import {
faUserGroup,
faVideo,
} from "@fortawesome/pro-solid-svg-icons";
import { GenerateIconButton } from "@storyteller/ui-button";
import { GenerateButton } from "@storyteller/ui-button";
import { Tooltip } from "@storyteller/ui-tooltip";
import {
KeyframeCards,
PromptClearAllButton,
ReferenceDeck,
useDeckMedia,
type DeckAddAction,
type DeckItem,
} from "@storyteller/ui-promptbox";
import { arrayMove } from "@dnd-kit/sortable";
import { ImagePromptRow } from "./ImagePromptRow";
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";
import type { RefImage, MentionItem } from "./types";
import { useEnterToGenerateStore } from "../../lib/enter-to-generate-store";
import {
PromptFullscreenButton,
@@ -52,6 +36,7 @@ interface PromptBoxProps {
onPromptChange: (prompt: string) => void;
onSubmit: () => void;
isSubmitting: boolean;
submitLabel?: string;
placeholder?: string;
disabled?: boolean;
credits?: number | null;
@@ -76,33 +61,11 @@ interface PromptBoxProps {
// Pick from library
onPickFromLibrary?: () => void;
onPickEndFrameFromLibrary?: () => void;
onPickVideoFromLibrary?: () => void;
onPickAudioFromLibrary?: () => void;
// Clear all references (images, end frame, videos, audios)
onClearAllRefs?: () => void;
// Toolbar clear-all support. The button wipes the prompt and every
// reference the box renders; pages holding extra state outside the box
// (media reference row, secondary prompts) clear it via onClearAllExtras
// and report its presence via hasClearableExtras so the button enables.
onClearAllExtras?: () => void;
hasClearableExtras?: boolean;
// Video/audio references, rendered as cards in the reference deck (video
// page in reference mode, world page's guide video, audio page's tracks).
videoRefsSupported?: boolean;
referenceVideos?: RefVideo[];
onReferenceVideosChange?: (videos: RefVideo[]) => void;
maxVideoCount?: number;
maxVideoRefDuration?: number;
audioRefsSupported?: boolean;
referenceAudios?: RefAudio[];
onReferenceAudiosChange?: (audios: RefAudio[]) => void;
maxAudioCount?: number;
maxAudioRefDuration?: number;
// Always-visible named slot cards rendered beside the reference deck
// (object page's multi-view angles + input mesh), built from DeckSlotCard.
referenceSlots?: ReactNode;
// Media reference row (video/audio refs, rendered between image row and prompt)
mediaReferenceRow?: ReactNode;
// Model selector (rendered above the toolbar, typically hidden on desktop via lg:hidden)
modelSelector?: ReactNode;
@@ -127,6 +90,7 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
onPromptChange,
onSubmit,
isSubmitting,
submitLabel = "Generate",
placeholder = "Describe what you want...",
disabled,
credits,
@@ -143,22 +107,8 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
rightToolbar,
onPickFromLibrary,
onPickEndFrameFromLibrary,
onPickVideoFromLibrary,
onPickAudioFromLibrary,
onClearAllRefs,
onClearAllExtras,
hasClearableExtras,
videoRefsSupported,
referenceVideos = [],
onReferenceVideosChange,
maxVideoCount = 3,
maxVideoRefDuration = 30,
audioRefsSupported,
referenceAudios = [],
onReferenceAudiosChange,
maxAudioCount = 2,
maxAudioRefDuration = 30,
referenceSlots,
mediaReferenceRow,
modelSelector,
secondaryPromptRow,
mentionItems,
@@ -172,6 +122,7 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
const enterToGenerate = useEnterToGenerateStore((s) => s.enabled);
const [isFocused, setIsFocused] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
const [showImagePrompts, setShowImagePrompts] = useState(false);
const { isFullscreen, openFullscreen, closeFullscreen } =
useFullscreenPrompt();
@@ -197,386 +148,12 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
const [mentionIndex, setMentionIndex] = useState(0);
const mentionAnchorRef = useRef<number | null>(null);
const hasMentionItems = (mentionItems?.length ?? 0) > 0;
const deck = useDeckMedia({
referenceImages,
setReferenceImages: onReferenceImagesChange,
// 0 blocks image uploads (incl. via the combined picker) on pages whose
// model only takes audio/video/mesh refs.
maxImages: supportsImagePrompts ? maxImagePromptCount : 0,
setEndFrameImage: onEndFrameImageChange,
referenceVideos,
setReferenceVideos: onReferenceVideosChange,
maxVideos: maxVideoCount,
maxVideoTotalSec: maxVideoRefDuration,
referenceAudios,
setReferenceAudios: onReferenceAudiosChange,
maxAudios: maxAudioCount,
maxAudioTotalSec: maxAudioRefDuration,
uploadImage,
uploadVideo,
uploadAudio,
// Library picking stays with the page-level GalleryModals.
ownGalleryModal: false,
});
// Drag & drop onto the box bounds: files route to the reference kind
// their MIME matches, gated on what the page's 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 isKeyframeMode = !!isVideo && !isReferenceMode;
const dropAcceptsImages =
!!supportsImagePrompts && maxImagePromptCount > 0;
const dropAcceptsVideos =
!isKeyframeMode && !!videoRefsSupported && !!onReferenceVideosChange;
const dropAcceptsAudio =
!isKeyframeMode && !!audioRefsSupported && !!onReferenceAudiosChange;
const handleDroppedFiles = ({ images, videos, audios }: DroppedFiles) => {
if (images.length > 0) {
if (isKeyframeMode) {
// Fill the empty keyframe slots in order: start frame, then end.
const queue = [...images];
const startOpen =
referenceImages.length === 0 && deck.uploadingImages.length === 0;
const endOpen =
!!showEndFrameSection && !endFrameImage && !deck.uploadingEnd;
if (startOpen) deck.processImageFiles([queue.shift()!], "start");
if (endOpen && queue.length > 0) {
deck.processImageFiles([queue.shift()!], "end");
}
if (!startOpen && !endOpen) {
toast.error(
showEndFrameSection
? "Start and end frames are already set"
: "The start frame is already set",
);
}
} else if (deck.availableImageSlots <= 0) {
toast.error(
`Max ${maxImagePromptCount} image reference${maxImagePromptCount === 1 ? "" : "s"}`,
);
} else {
deck.processImageFiles(images, "start");
}
}
if (videos.length > 0) deck.processVideoFiles(videos);
if (audios.length > 0) deck.processAudioFiles(audios);
};
const drop = usePromptBoxDrop({
acceptsImages: dropAcceptsImages,
acceptsVideos: dropAcceptsVideos,
acceptsAudio: dropAcceptsAudio,
onDropFiles: handleDroppedFiles,
});
// Mixed deck items ordered images → videos → audios so the page's
// index-derived @ImageN/@VideoN/@AudioN mention labels stay aligned.
const deckItems: DeckItem[] = useMemo(
() => [
...referenceImages.map((img, i) => ({
id: img.id,
kind: "image" as const,
url: img.url,
previewUrl: img.fullUrl ?? img.url,
name: `Image ${i + 1}`,
})),
...deck.uploadingImages.map((entry, i) => ({
id: entry.id,
kind: "image" as const,
url: entry.previewUrl,
name: `Image ${referenceImages.length + i + 1}`,
uploading: true,
})),
...referenceVideos.map((video, i) => ({
id: video.id,
kind: "video" as const,
url: video.url,
name: `Video ${i + 1}`,
duration: video.duration,
})),
...(deck.uploadingVideo
? [
{
id: deck.uploadingVideo.id,
kind: "video" as const,
url: deck.uploadingVideo.previewUrl,
name: `Video ${referenceVideos.length + 1}`,
uploading: true,
},
]
: []),
...referenceAudios.map((audio, i) => ({
id: audio.id,
kind: "audio" as const,
url: audio.url,
name: `Audio ${i + 1}`,
duration: audio.duration,
})),
...(deck.uploadingAudio
? [
{
id: deck.uploadingAudio.id,
kind: "audio" as const,
name: `Audio ${referenceAudios.length + 1}`,
uploading: true,
},
]
: []),
],
[
referenceImages,
referenceVideos,
referenceAudios,
deck.uploadingImages,
deck.uploadingVideo,
deck.uploadingAudio,
],
);
const deckAddActions: DeckAddAction[] = [];
if (
const isImageRowVisible =
supportsImagePrompts &&
referenceImages.length + deck.uploadingImages.length <
maxImagePromptCount
) {
deckAddActions.push({
key: "upload-image",
label: "Upload",
group: "image",
onSelect: deck.openImageUpload,
});
if (onPickFromLibrary) {
deckAddActions.push({
key: "library-image",
label: isVideo ? "From library" : "Pick from library",
group: "image",
onSelect: onPickFromLibrary,
});
}
}
if (
videoRefsSupported &&
referenceVideos.length < maxVideoCount &&
!deck.uploadingVideo
) {
deckAddActions.push({
key: "upload-video",
label: "Upload",
group: "video",
onSelect: deck.openVideoUpload,
});
if (onPickVideoFromLibrary) {
deckAddActions.push({
key: "library-video",
label: "From library",
group: "video",
onSelect: onPickVideoFromLibrary,
});
}
}
if (
audioRefsSupported &&
referenceAudios.length < maxAudioCount &&
!deck.uploadingAudio
) {
deckAddActions.push({
key: "upload-audio",
label: "Upload",
group: "audio",
onSelect: deck.openAudioUpload,
});
if (onPickAudioFromLibrary) {
deckAddActions.push({
key: "library-audio",
label: "From library",
group: "audio",
onSelect: onPickAudioFromLibrary,
});
}
}
(isVideo || showImagePrompts || referenceImages.length > 0);
const handleRemoveDeckItem = (id: string) => {
if (referenceImages.some((img) => img.id === id)) {
onReferenceImagesChange(referenceImages.filter((img) => img.id !== id));
} else if (referenceVideos.some((video) => video.id === id)) {
onReferenceVideosChange?.(
referenceVideos.filter((video) => video.id !== id),
);
} else if (referenceAudios.some((audio) => audio.id === id)) {
onReferenceAudiosChange?.(
referenceAudios.filter((audio) => audio.id !== id),
);
}
};
const firstFrameItem: DeckItem | undefined = referenceImages[0]
? {
id: referenceImages[0].id,
kind: "image",
url: referenceImages[0].url,
previewUrl: referenceImages[0].fullUrl ?? referenceImages[0].url,
name: "First frame",
}
: deck.uploadingImages[0]
? {
id: deck.uploadingImages[0].id,
kind: "image",
url: deck.uploadingImages[0].previewUrl,
name: "First frame",
uploading: true,
}
: undefined;
const lastFrameItem: DeckItem | undefined = endFrameImage
? {
id: endFrameImage.id,
kind: "image",
url: endFrameImage.url,
previewUrl: endFrameImage.fullUrl ?? endFrameImage.url,
name: "Last frame",
}
: deck.uploadingEnd
? {
id: deck.uploadingEnd.id,
kind: "image",
url: deck.uploadingEnd.previewUrl,
name: "Last frame",
uploading: true,
}
: undefined;
const hasAttachedRefs =
referenceImages.length > 0 ||
!!endFrameImage ||
referenceVideos.length > 0 ||
referenceAudios.length > 0 ||
!!hasClearableExtras;
const hasClearableContent = prompt.length > 0 || hasAttachedRefs;
const handleClearAll = () => {
onPromptChange("");
// Prefer the page's single-shot clear (pages that keep all refs in one
// state object need it to avoid stale-closure partial updates).
if (onClearAllRefs) {
onClearAllRefs();
} else {
onReferenceImagesChange([]);
onEndFrameImageChange?.(undefined);
onReferenceVideosChange?.([]);
onReferenceAudiosChange?.([]);
}
onClearAllExtras?.();
};
const handleSwapFrames = () => {
const first = referenceImages[0];
if (!first || !endFrameImage) return;
onReferenceImagesChange([endFrameImage]);
onEndFrameImageChange?.(first);
};
// Left-of-textarea reference widget: image deck, keyframe cards, or the
// mixed deck depending on page/mode.
const renderReferenceWidget = (alwaysExpanded?: boolean) => {
if (isKeyframeMode) {
if (!supportsImagePrompts) return null;
return (
<KeyframeCards
firstFrame={firstFrameItem}
lastFrame={lastFrameItem}
showLastFrame={!!showEndFrameSection}
onFirstAddActions={[
{
key: "upload-first",
label: "Upload",
onSelect: deck.openImageUpload,
},
...(onPickFromLibrary
? [
{
key: "library-first",
label: "Pick from library",
onSelect: onPickFromLibrary,
},
]
: []),
]}
onLastAddActions={[
{
key: "upload-last",
label: "Upload",
onSelect: deck.openEndUpload,
},
...(onPickEndFrameFromLibrary
? [
{
key: "library-last",
label: "Pick from library",
onSelect: onPickEndFrameFromLibrary,
},
]
: []),
]}
onRemoveFirst={() => onReferenceImagesChange([])}
onRemoveLast={() => onEndFrameImageChange?.(undefined)}
onSwap={handleSwapFrames}
/>
);
}
if (!supportsImagePrompts && !videoRefsSupported && !audioRefsSupported) {
return null;
}
const totalVideoRefSeconds = referenceVideos.reduce(
(sum, video) => sum + video.duration,
0,
);
const totalAudioRefSeconds = referenceAudios.reduce(
(sum, audio) => sum + audio.duration,
0,
);
const groupHints: Record<string, string> = {};
if (supportsImagePrompts) {
groupHints.image = `${referenceImages.length}/${maxImagePromptCount}`;
}
// A non-finite duration cap means "no limit" — show counts only.
if (videoRefsSupported) {
groupHints.video =
`${referenceVideos.length}/${maxVideoCount}` +
(isFinite(maxVideoRefDuration)
? ` · ${totalVideoRefSeconds}/${maxVideoRefDuration}s`
: "");
}
if (audioRefsSupported) {
groupHints.audio =
`${referenceAudios.length}/${maxAudioCount}` +
(isFinite(maxAudioRefDuration)
? ` · ${totalAudioRefSeconds}/${maxAudioRefDuration}s`
: "");
}
return (
<ReferenceDeck
items={deckItems}
canAdd={deckAddActions.length > 0}
addActions={deckAddActions}
addMenuGroupHints={groupHints}
onAddClick={
supportsImagePrompts || videoRefsSupported || audioRefsSupported
? deck.openAnyUpload
: undefined
}
onRemove={handleRemoveDeckItem}
onReorderImages={(from, to) =>
onReferenceImagesChange(arrayMove(referenceImages, from, to))
}
onClearAll={onClearAllRefs}
alwaysExpanded={alwaysExpanded}
/>
);
};
const hasMentionItems = (mentionItems?.length ?? 0) > 0;
const hasAnyRowAbove = isImageRowVisible || !!mediaReferenceRow;
// Filtered mention items for autocomplete
const filteredMentionItems = useMemo(() => {
@@ -757,28 +334,80 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
});
}, [prompt, hasMentionItems, mentionRegex, mentionLabelMap, mentionItems]);
// Shared reference-image row. Inline it attaches to the top of the box
// (square bottom); the fullscreen modal passes a className to round the
// bottom corners since it sits in-flow above the controls there.
const renderImagePromptRow = (className?: string) =>
supportsImagePrompts ? (
<ImagePromptRow
maxImagePromptCount={maxImagePromptCount}
referenceImages={referenceImages}
setReferenceImages={onReferenceImagesChange}
onPickFromLibrary={onPickFromLibrary}
onClearAll={onClearAllRefs}
isVideo={isVideo}
isReferenceMode={isReferenceMode}
endFrameImage={endFrameImage}
setEndFrameImage={onEndFrameImageChange}
showEndFrameSection={showEndFrameSection}
onPickEndFrameFromLibrary={onPickEndFrameFromLibrary}
className={className}
/>
) : null;
return (
<div ref={ref} className="prompt-box-root">
<div className="relative flex flex-col">
{deck.fileInputs}
{isImageRowVisible && renderImagePromptRow()}
{/* When the media row is the topmost band (no image row above it —
e.g. the audio page), clip it to rounded top corners; mid-stack
(video's start-frame row above) it stays square. */}
{mediaReferenceRow && (
<div className={twMerge(!isImageRowVisible && "sm:rounded-t-2xl")}>
{mediaReferenceRow}
</div>
)}
<div
className={twMerge(
"glass rounded-2xl p-3 sm:p-4 !transition-all duration-200",
hasAnyRowAbove && "rounded-t-none border-t-0",
isFocused && "ring-1 ring-primary",
)}
{...drop.dropZoneProps}
>
<PromptBoxDropOverlay
dragState={drop.dragState}
acceptsImages={dropAcceptsImages}
acceptsVideos={dropAcceptsVideos}
acceptsAudio={dropAcceptsAudio}
keyframeMode={isKeyframeMode}
/>
<div className="flex gap-3">
{renderReferenceWidget()}
{referenceSlots}
{supportsImagePrompts && !isVideo && (
<Tooltip
content="Add Image"
position="top"
closeOnClick={true}
className={twMerge(isImageRowVisible && "hidden opacity-0")}
>
<button
type="button"
className={twMerge(
"flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-transparent p-0 transition-all hover:text-primary-500",
isImageRowVisible && "text-primary",
)}
onClick={() => setShowImagePrompts((prev) => !prev)}
>
<svg
width="24"
height="20"
viewBox="0 0 24 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="opacity-80 transition-all hover:opacity-100"
>
<path
d="M2.66667 2H16C16.3667 2 16.6667 2.3 16.6667 2.66667V6.1125C17.1 6.04167 17.5458 6 18 6C18.225 6 18.4458 6.00833 18.6667 6.02917V2.66667C18.6667 1.19583 17.4708 0 16 0H2.66667C1.19583 0 0 1.19583 0 2.66667V16C0 17.4708 1.19583 18.6667 2.66667 18.6667H11.5C11.0625 18.0583 10.7083 17.3875 10.4542 16.6667H2.66667C2.3 16.6667 2 16.3667 2 16V2.66667C2 2.3 2.3 2 2.66667 2ZM11.8625 7.49167C11.6833 7.1875 11.3542 7 11 7C10.6458 7 10.3167 7.1875 10.1375 7.49167L8.2 10.7833L7.48333 9.75833C7.29583 9.49167 6.99167 9.33333 6.6625 9.33333C6.33333 9.33333 6.02917 9.49167 5.84167 9.75833L3.50833 13.0917C3.29583 13.3958 3.26667 13.7958 3.44167 14.125C3.61667 14.4542 3.9625 14.6667 4.33333 14.6667H10.0292C10.0125 14.4458 10 14.225 10 14C10 11.7833 10.9 9.77917 12.3542 8.33333L11.8625 7.49583V7.49167ZM5.33333 6.66667C6.07083 6.66667 6.66667 6.07083 6.66667 5.33333C6.66667 4.59583 6.07083 4 5.33333 4C4.59583 4 4 4.59583 4 5.33333C4 6.07083 4.59583 6.66667 5.33333 6.66667ZM18 20C21.3125 20 24 17.3125 24 14C24 10.6875 21.3125 8 18 8C14.6875 8 12 10.6875 12 14C12 17.3125 14.6875 20 18 20ZM18.6667 11.3333V13.3333H20.6667C21.0333 13.3333 21.3333 13.6333 21.3333 14C21.3333 14.3667 21.0333 14.6667 20.6667 14.6667H18.6667V16.6667C18.6667 17.0333 18.3667 17.3333 18 17.3333C17.6333 17.3333 17.3333 17.0333 17.3333 16.6667V14.6667H15.3333C14.9667 14.6667 14.6667 14.3667 14.6667 14C14.6667 13.6333 14.9667 13.3333 15.3333 13.3333H17.3333V11.3333C17.3333 10.9667 17.6333 10.6667 18 10.6667C18.3667 10.6667 18.6667 10.9667 18.6667 11.3333Z"
fill="currentColor"
/>
</svg>
</button>
</Tooltip>
)}
<div className="promptbox-resize-wrap relative flex-1">
{hasMentionItems && mentionItems ? (
@@ -932,24 +561,22 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
)}
{/* Toolbar */}
<div className="mt-3.5 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="mt-2 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-wrap items-center gap-1.5 sm:gap-2">
{modelSelector}
{leftToolbar}
</div>
<div className="flex items-center gap-1.5 sm:shrink-0">
<div className="flex items-center gap-2 sm:shrink-0">
{rightToolbar}
<PromptClearAllButton
onClick={handleClearAll}
disabled={!hasClearableContent}
confirmClear={hasAttachedRefs}
/>
<GenerateIconButton
<GenerateButton
className="flex flex-1 sm:flex-none items-center justify-center border-none bg-primary px-3 text-sm text-white disabled:cursor-not-allowed disabled:opacity-50"
onClick={onSubmit}
disabled={disabled ?? (!prompt.trim() || isSubmitting)}
loading={isSubmitting}
credits={credits}
/>
>
{submitLabel}
</GenerateButton>
</div>
</div>
@@ -984,21 +611,7 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
{leftToolbar}
</>
}
imagePromptRow={
renderReferenceWidget(true) || referenceSlots ? (
<div className="flex flex-wrap items-center gap-3">
{renderReferenceWidget(true)}
{referenceSlots}
</div>
) : undefined
}
clearAllButton={
<PromptClearAllButton
onClick={handleClearAll}
disabled={!hasClearableContent}
confirmClear={hasAttachedRefs}
/>
}
imagePromptRow={renderImagePromptRow("rounded-2xl sm:rounded-b-2xl")}
>
{hasMentionItems && mentionItems ? (
<MentionTextarea
@@ -1,289 +0,0 @@
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 "../toast/toast";
// ── 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[];
}
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;
}
const kindOfMime = (mime: string): DropKind | null =>
mime.startsWith("image/")
? "image"
: mime.startsWith("video/")
? "video"
: mime.startsWith("audio/")
? "audio"
: 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.
*/
export function usePromptBoxDrop({
acceptsImages,
acceptsVideos,
acceptsAudio,
onDropFiles,
}: UsePromptBoxDropOptions) {
const [dragState, setDragState] = useState<DropDragState>("idle");
const enabled = acceptsImages || acceptsVideos || acceptsAudio;
const acceptsKind = useCallback(
(kind: DropKind | null) =>
kind === "image"
? acceptsImages
: kind === "video"
? acceptsVideos
: kind === "audio"
? acceptsAudio
: false,
[acceptsImages, acceptsVideos, acceptsAudio],
);
// 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) 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]);
// 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 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);
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`,
);
}
};
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);
};
// 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. The
// listener re-reads the freshest router through a ref so it can stay
// subscribed once.
const routeFilesRef = useRef(routeFiles);
useEffect(() => {
routeFilesRef.current = routeFiles;
});
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: {
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,4 +1,10 @@
import { ReactNode, useCallback, useEffect, useRef, useState } from "react";
import {
ReactNode,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { Modal } from "@storyteller/ui-modal";
import { Button } from "@storyteller/ui-button";
import { Tooltip } from "@storyteller/ui-tooltip";
@@ -60,8 +66,6 @@ interface PromptFullscreenModalProps {
footerControls?: ReactNode;
/** Reference-image row, always shown in focus mode. */
imagePromptRow?: ReactNode;
/** Clear-all control rendered on the footer's right side, left of Done. */
clearAllButton?: ReactNode;
}
export const PromptFullscreenModal = ({
@@ -72,7 +76,6 @@ export const PromptFullscreenModal = ({
maxPromptLength,
footerControls,
imagePromptRow,
clearAllButton,
}: PromptFullscreenModalProps) => {
const showCounter =
promptLength !== undefined && maxPromptLength !== undefined;
@@ -83,10 +86,7 @@ export const PromptFullscreenModal = ({
// Focus the editor and drop the caret at the end once the overlay opens.
useEffect(() => {
if (!isOpen) return;
const id = window.setTimeout(
() => focusEditorAtEnd(contentRef.current),
60,
);
const id = window.setTimeout(() => focusEditorAtEnd(contentRef.current), 60);
return () => window.clearTimeout(id);
}, [isOpen]);
@@ -123,18 +123,14 @@ export const PromptFullscreenModal = ({
overLimit ? "text-red-500" : "text-base-fg/40"
}`}
>
{promptLength} /{" "}
{isFinite(maxPromptLength) ? maxPromptLength : "∞"}
{promptLength} / {isFinite(maxPromptLength) ? maxPromptLength : "∞"}
</span>
</div>
)}
{imagePromptRow && <div className="shrink-0">{imagePromptRow}</div>}
<div className="flex shrink-0 items-center justify-between gap-2 mt-1">
<div className="flex shrink-0 items-center justify-between gap-2">
<div className="flex items-center gap-2">{footerControls}</div>
<div className="flex items-center gap-2">
{clearAllButton}
<Button onClick={onClose}>Done</Button>
</div>
<Button onClick={onClose}>Done</Button>
</div>
</div>
</Modal>
@@ -10,6 +10,7 @@ interface CharactersStore {
characters: StoredCharacter[];
loaded: boolean;
setCharacters: (characters: StoredCharacter[]) => void;
addCharacter: (character: StoredCharacter) => void;
updateCharacter: (token: string, updates: Partial<StoredCharacter>) => void;
removeCharacter: (token: string) => void;
setLoaded: (loaded: boolean) => void;
@@ -19,6 +20,8 @@ export const useCharactersStore = create<CharactersStore>()((set) => ({
characters: [],
loaded: false,
setCharacters: (characters) => set({ characters }),
addCharacter: (character) =>
set((state) => ({ characters: [...state.characters, character] })),
updateCharacter: (token, updates) =>
set((state) => ({
characters: state.characters.map((c) =>
@@ -5,10 +5,7 @@ export { CharactersModal } from "./CharactersModal";
export { useCharactersStore } from "./characters-store";
export type { StoredCharacter } from "./characters-store";
export type { RefImage, RefVideo, RefAudio, MentionItem } from "./types";
export {
getAudioDurationFromUrl,
getVideoDurationFromUrl,
} from "./upload-media";
export { getVideoDurationFromUrl } from "./upload-media";
export {
MobilePromptForm,
MobileSelectField,
@@ -83,16 +83,6 @@ export const getVideoDurationFromUrl = (url: string): Promise<number> =>
video.src = url;
});
// Resolves 0 when metadata can't be loaded.
export const getAudioDurationFromUrl = (url: string): Promise<number> =>
new Promise((resolve) => {
const audio = document.createElement("audio");
audio.preload = "metadata";
audio.onloadedmetadata = () => resolve(Math.round(audio.duration));
audio.onerror = () => resolve(0);
audio.src = url;
});
export const getVideoDuration = (file: File): Promise<number> => {
const url = URL.createObjectURL(file);
return getVideoDurationFromUrl(url).finally(() => URL.revokeObjectURL(url));
@@ -1,11 +1,11 @@
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { Modal } from "@storyteller/ui-modal";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faCog, faUser, faKey } from "@fortawesome/pro-solid-svg-icons";
import { Switch } from "@storyteller/ui-switch";
import { USER_FEATURE_FLAGS } from "@storyteller/api";
import { twMerge } from "tailwind-merge";
import { useEnterToGenerateStore } from "../../lib/enter-to-generate-store";
import { useLightboxSoundStore } from "../../lib/lightbox-sound-store";
import { useSession } from "../../lib/session";
import { AccountSection } from "./AccountSection";
import { ApiKeySection } from "./ApiKeySection";
@@ -17,20 +17,41 @@ interface SettingsModalProps {
onClose: () => void;
}
const TABS: { id: Tab; label: string; icon: typeof faCog }[] = [
const BASE_TABS: { id: Tab; label: string; icon: typeof faCog }[] = [
{ id: "general", label: "General", icon: faCog },
{ id: "account", label: "Account", icon: faUser },
{ id: "apiKeys", label: "API Keys", icon: faKey },
];
const API_KEYS_TAB: { id: Tab; label: string; icon: typeof faCog } = {
id: "apiKeys",
label: "API Keys",
icon: faKey,
};
export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
const { user } = useSession();
const [tab, setTab] = useState<Tab>("general");
const hasApiKeyFlag = !!user?.maybe_feature_flags?.includes(
USER_FEATURE_FLAGS.API_KEY,
);
const tabs = useMemo(
() => (hasApiKeyFlag ? [...BASE_TABS, API_KEYS_TAB] : BASE_TABS),
[hasApiKeyFlag],
);
useEffect(() => {
if (isOpen) setTab("general");
}, [isOpen]);
const activeLabel = TABS.find((t) => t.id === tab)?.label ?? "";
// If the only-conditional tab disappears (e.g. flag revoked while open),
// fall back to a visible tab.
useEffect(() => {
if (!tabs.some((t) => t.id === tab)) setTab("general");
}, [tabs, tab]);
const activeLabel = tabs.find((t) => t.id === tab)?.label ?? "";
return (
<Modal
@@ -47,7 +68,7 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
</div>
<hr className="my-2 hidden w-full border-ui-panel-border sm:block" />
<div className="flex gap-2 overflow-x-auto pe-10 sm:block sm:space-y-1 sm:overflow-visible sm:pe-0">
{TABS.map((t) => (
{tabs.map((t) => (
<button
key={t.id}
onClick={() => setTab(t.id)}
@@ -84,8 +105,6 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
function GeneralPanel() {
const enterToGenerate = useEnterToGenerateStore((s) => s.enabled);
const setEnterToGenerate = useEnterToGenerateStore((s) => s.setEnabled);
const lightboxSound = useLightboxSoundStore((s) => s.soundEnabled);
const setLightboxSound = useLightboxSoundStore((s) => s.setSoundEnabled);
return (
<div className="space-y-4 text-base-fg">
@@ -104,20 +123,6 @@ function GeneralPanel() {
offClassName="bg-white/20"
/>
</div>
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-0.5">
<p className="text-sm font-medium">Play videos with sound</p>
<p className="text-xs opacity-70">
When on (default), videos in the media viewer start unmuted so you
don't have to click unmute every time. When off, they start muted.
</p>
</div>
<Switch
enabled={lightboxSound}
setEnabled={setLightboxSound}
offClassName="bg-white/20"
/>
</div>
</div>
);
}
@@ -18,7 +18,6 @@ import {
faDownload,
faGift,
faPencil,
faPhotoFilm,
} from "@fortawesome/pro-solid-svg-icons";
import { faDiscord } from "@fortawesome/free-brands-svg-icons";
import { Button } from "@storyteller/ui-button";
@@ -79,12 +78,6 @@ const STUDIO_ITEMS_STATIC: NavItem[] = [
icon: faObjectGroup,
badge: "BETA",
},
{
label: "Frame Extract",
href: "/frame-extractor",
icon: faPhotoFilm,
badge: "NEW",
},
];
// Rewrite the "Edit 3D" item's href to point at the user's last visited
@@ -172,11 +165,7 @@ function NavMenuItem({
/>
<span className="group-data-[collapsible=icon]:hidden">{item.label}</span>
{item.badge && (
<span
className={`ml-auto px-1.5 py-0.5 text-[10px] font-semibold uppercase leading-none text-white group-data-[collapsible=icon]:hidden rounded-full ${
item.badge === "NEW" ? "bg-purple-600" : "bg-amber-600"
}`}
>
<span className="ml-auto bg-amber-600 px-1.5 py-0.5 text-[10px] font-semibold uppercase leading-none text-white group-data-[collapsible=icon]:hidden rounded-full">
{item.badge}
</span>
)}
@@ -1,17 +1,14 @@
import { useMemo, useState } from "react";
import { useMemo } from "react";
import { Link, useNavigate } from "react-router-dom";
import { AnimatePresence, motion } from "framer-motion";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faBorderAll,
faChevronRight,
faFolder,
faFolderOpen,
faPlus,
faStar,
} from "@fortawesome/pro-solid-svg-icons";
import { compareFolders } from "@storyteller/ui-gallery-modal";
import { EASE_EMPHASIS } from "../../lib/motion";
import {
SidebarGroup,
SidebarGroupContent,
@@ -21,7 +18,6 @@ import {
SidebarMenuItem,
} from "../ui/sidebar";
import { useLibraryFoldersStore } from "../../pages/library/library-folders-store";
import { LibraryTagsNav } from "./library-tags-nav";
/**
* The "Library" sidebar section: Unsorted + Folders entries, plus the user's
@@ -40,23 +36,17 @@ export function LibraryFoldersNav({
pathname === "/library/folders" ||
pathname.startsWith("/library/folder_");
const onFolderless = pathname === "/library/folderless";
const onTags =
pathname === "/library/tags" || pathname.startsWith("/library/tag_");
const onUnsorted =
!onFolders &&
!onFolderless &&
!onTags &&
(pathname === "/library" || pathname.startsWith("/library/"));
const inLibraryArea = onUnsorted || onFolders || onFolderless || onTags;
const inLibraryArea = onUnsorted || onFolders || onFolderless;
const folders = useLibraryFoldersStore((s) => s.folders);
const activeFolderId = useLibraryFoldersStore((s) => s.activeFolderId);
const openNewFolderModal = useLibraryFoldersStore((s) => s.openNewFolderModal);
const setContextMenu = useLibraryFoldersStore((s) => s.setContextMenu);
// Folders are the primary organization tool — default expanded.
const [expanded, setExpanded] = useState(true);
const rootFolders = useMemo(
() => folders.filter((f) => !f.parentId).sort(compareFolders),
[folders],
@@ -105,6 +95,18 @@ export function LibraryFoldersNav({
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
asChild
isActive={onFolders && !activeFolderId}
tooltip="Folders"
>
<Link to="/library/folders" onClick={onNavClick}>
<FontAwesomeIcon icon={faFolder} />
<span>Folders</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
asChild
@@ -117,85 +119,45 @@ export function LibraryFoldersNav({
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
asChild
isActive={onFolders && !activeFolderId}
tooltip="Folders"
>
<Link to="/library/folders" onClick={onNavClick}>
<FontAwesomeIcon icon={faFolder} />
<span>Folders</span>
</Link>
</SidebarMenuButton>
{inLibraryArea && rootFolders.length > 0 && (
<button
type="button"
onClick={() => setExpanded((v) => !v)}
aria-label={expanded ? "Collapse folders" : "Expand folders"}
className="absolute right-1 top-1/2 -translate-y-1/2 flex h-5 w-5 items-center justify-center rounded text-sidebar-foreground/50 hover:bg-sidebar-accent hover:text-sidebar-foreground transition-colors group-data-[collapsible=icon]:hidden"
>
<FontAwesomeIcon
icon={faChevronRight}
className={`text-[10px] transition-transform duration-200 ${expanded ? "rotate-90" : ""}`}
/>
</button>
)}
</SidebarMenuItem>
<AnimatePresence initial={false}>
{inLibraryArea && expanded && rootFolders.length > 0 && (
<motion.li
key="folder-rows"
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.28, ease: EASE_EMPHASIS }}
className="list-none overflow-hidden group-data-[collapsible=icon]:hidden"
{inLibraryArea &&
rootFolders.map((folder) => (
<SidebarMenuItem
key={folder.id}
className="group-data-[collapsible=icon]:hidden"
>
<ul className="flex w-full min-w-0 flex-col gap-0.5">
{rootFolders.map((folder) => (
<SidebarMenuItem key={folder.id}>
<SidebarMenuButton
isActive={activeRootId === folder.id}
tooltip={folder.name}
data-folder-id={folder.id}
onClick={() => goToFolder(folder.id)}
onContextMenu={(e) => {
e.preventDefault();
setContextMenu({
folderId: folder.id,
x: e.clientX,
y: e.clientY,
});
}}
className="pl-5 [&.folder-drag-over]:bg-primary/20 [&.folder-drag-over]:text-sidebar-foreground"
>
<FontAwesomeIcon
icon={faFolder}
className={folder.colorCode ? "" : "text-primary"}
style={
folder.colorCode
? { color: folder.colorCode }
: undefined
}
/>
<span className="truncate">{folder.name}</span>
{folder.hasStar && (
<FontAwesomeIcon
icon={faStar}
className="ml-auto text-[10px] text-amber-400"
/>
)}
</SidebarMenuButton>
</SidebarMenuItem>
))}
</ul>
</motion.li>
)}
</AnimatePresence>
<LibraryTagsNav pathname={pathname} onNavClick={onNavClick} />
<SidebarMenuButton
isActive={activeRootId === folder.id}
tooltip={folder.name}
data-folder-id={folder.id}
onClick={() => goToFolder(folder.id)}
onContextMenu={(e) => {
e.preventDefault();
setContextMenu({
folderId: folder.id,
x: e.clientX,
y: e.clientY,
});
}}
className="pl-5 [&.folder-drag-over]:bg-primary/20 [&.folder-drag-over]:text-sidebar-foreground"
>
<FontAwesomeIcon
icon={faFolder}
className={folder.colorCode ? "" : "text-primary"}
style={
folder.colorCode ? { color: folder.colorCode } : undefined
}
/>
<span className="truncate">{folder.name}</span>
{folder.hasStar && (
<FontAwesomeIcon
icon={faStar}
className="ml-auto text-[10px] text-amber-400"
/>
)}
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
@@ -1,163 +0,0 @@
import { useMemo, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { AnimatePresence, motion } from "framer-motion";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faChevronRight,
faEllipsis,
faTag,
faTags,
} from "@fortawesome/pro-solid-svg-icons";
import { SidebarMenuButton, SidebarMenuItem } from "../ui/sidebar";
import {
compareTagsByUseCount,
useLibraryTagsStore,
} from "../../pages/library/library-tags-store";
import { EASE_EMPHASIS } from "../../lib/motion";
const MAX_SIDEBAR_TAGS = 5;
/**
* The "Tags" rows of the Assets sidebar group, rendered below the folders
* list: a Tags entry plus the user's most-used tags (collapsed by default)
* while in the library area. Hidden entirely until the user has tags — the
* tag editor in the media details panel is where tags get created.
* Right-click opens the library page's tag context menu (same contract as
* the folder rows). Renders bare menu items; the parent supplies the
* `SidebarMenu` list.
*/
export function LibraryTagsNav({
pathname,
onNavClick,
}: {
pathname: string;
onNavClick: () => void;
}) {
const navigate = useNavigate();
const tags = useLibraryTagsStore((s) => s.tags);
const tagsLoaded = useLibraryTagsStore((s) => s.tagsLoaded);
const setContextMenu = useLibraryTagsStore((s) => s.setContextMenu);
// Folders are the primary organization tool — tags start collapsed.
const [expanded, setExpanded] = useState(false);
const inLibraryArea =
pathname === "/library" || pathname.startsWith("/library/");
const topTags = useMemo(
() => [...tags].sort(compareTagsByUseCount).slice(0, MAX_SIDEBAR_TAGS),
[tags],
);
if (!tagsLoaded || tags.length === 0) return null;
return (
<>
<SidebarMenuItem>
<SidebarMenuButton
asChild
isActive={pathname === "/library/tags"}
tooltip="Tags"
>
<Link to="/library/tags" onClick={onNavClick}>
<FontAwesomeIcon icon={faTags} />
<span>Tags</span>
</Link>
</SidebarMenuButton>
{inLibraryArea && topTags.length > 0 && (
<button
type="button"
onClick={() => setExpanded((v) => !v)}
aria-label={expanded ? "Collapse tags" : "Expand tags"}
className="absolute right-1 top-1/2 -translate-y-1/2 flex h-5 w-5 items-center justify-center rounded text-sidebar-foreground/50 hover:bg-sidebar-accent hover:text-sidebar-foreground transition-colors group-data-[collapsible=icon]:hidden"
>
<FontAwesomeIcon
icon={faChevronRight}
className={`text-[10px] transition-transform duration-200 ${expanded ? "rotate-90" : ""}`}
/>
</button>
)}
</SidebarMenuItem>
<AnimatePresence initial={false}>
{inLibraryArea && expanded && topTags.length > 0 && (
<motion.li
key="tag-rows"
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.28, ease: EASE_EMPHASIS }}
className="list-none overflow-hidden group-data-[collapsible=icon]:hidden"
>
<ul className="flex w-full min-w-0 flex-col gap-0.5">
{topTags.map((tag) => (
<SidebarMenuItem key={tag.token}>
<SidebarMenuButton
isActive={pathname === `/library/${tag.token}`}
tooltip={tag.value}
onClick={() => {
navigate(`/library/${tag.token}`);
onNavClick();
}}
onContextMenu={(e) => {
e.preventDefault();
setContextMenu({
tagToken: tag.token,
x: e.clientX,
y: e.clientY,
});
}}
className="pl-5"
>
<FontAwesomeIcon
icon={faTag}
className="text-violet-400"
/>
<span className="truncate">{tag.value}</span>
<span className="ml-auto text-[10px] text-sidebar-foreground/40 transition-opacity group-hover/menu-item:opacity-0">
{tag.useCount}
</span>
</SidebarMenuButton>
{/* Hover-revealed rename/delete menu (swaps with the count). */}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
const rect = e.currentTarget.getBoundingClientRect();
setContextMenu({
tagToken: tag.token,
x: rect.left,
y: rect.bottom + 4,
});
}}
aria-label={`Options for tag "${tag.value}"`}
className="absolute right-1 top-1/2 -translate-y-1/2 flex h-5 w-5 items-center justify-center rounded text-sidebar-foreground/50 opacity-0 transition-opacity group-hover/menu-item:opacity-100 hover:bg-sidebar-accent hover:text-sidebar-foreground"
>
<FontAwesomeIcon icon={faEllipsis} className="text-xs" />
</button>
</SidebarMenuItem>
))}
{tags.length > MAX_SIDEBAR_TAGS && (
<SidebarMenuItem>
<SidebarMenuButton asChild tooltip="See all tags">
<Link
to="/library/tags"
onClick={onNavClick}
className="pl-5 text-sidebar-foreground/60"
>
<FontAwesomeIcon icon={faEllipsis} />
<span>See all tags</span>
<span className="ml-auto text-[10px] text-sidebar-foreground/40">
{tags.length}
</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
)}
</ul>
</motion.li>
)}
</AnimatePresence>
</>
);
}
@@ -1,54 +0,0 @@
import { useEffect, useMemo } from "react";
import type { UserInfo } from "@storyteller/api";
import { TagsSection as SharedTagsSection } from "@storyteller/ui-lightbox-modal";
import { useSession } from "../../lib/session";
import {
compareTagsByUseCount,
useLibraryTagsStore,
} from "../../pages/library/library-tags-store";
interface TagsSectionProps {
mediaToken?: string | null;
creator?: UserInfo | null;
}
/**
* Store-wired wrapper around the shared tags editor (from
* `@storyteller/ui-lightbox-modal`, also used by the desktop gallery):
* session comes from the webapp session store, autocomplete suggestions from
* the preloaded tags store, and saves feed fresh use counts back into it so
* the sidebar and tag browser stay current.
*/
export function TagsSection({ mediaToken, creator }: TagsSectionProps) {
const { user, loggedIn } = useSession();
const storeTags = useLibraryTagsStore((s) => s.tags);
const loadTags = useLibraryTagsStore((s) => s.loadTags);
const upsertTags = useLibraryTagsStore((s) => s.upsertTags);
const currentUsername = loggedIn ? (user?.username ?? null) : null;
const isOwner =
!!currentUsername && creator?.username === currentUsername;
// Preload the user's tag list once for autocomplete.
useEffect(() => {
if (isOwner && !useLibraryTagsStore.getState().tagsLoaded) loadTags();
}, [isOwner, loadTags]);
const suggestions = useMemo(
() =>
[...storeTags]
.sort(compareTagsByUseCount)
.map((t) => ({ value: t.value, useCount: t.useCount })),
[storeTags],
);
return (
<SharedTagsSection
mediaToken={mediaToken}
creator={creator}
currentUsername={currentUsername}
suggestions={suggestions}
onSaved={upsertTags}
/>
);
}
@@ -7,22 +7,12 @@ const ROUTE_CRUMBS: Record<string, Crumb[]> = {
"/": [{ label: "Home" }],
"/create-image": [{ label: "Create", href: "/" }, { label: "Image" }],
"/create-video": [{ label: "Create", href: "/" }, { label: "Video" }],
"/create-audio": [{ label: "Create", href: "/" }, { label: "Audio" }],
"/create-object": [{ label: "Create", href: "/" }, { label: "3D Object" }],
"/create-world": [{ label: "Create", href: "/" }, { label: "3D World" }],
"/background-change": [
{ label: "Studio", href: "/" },
{ label: "Create", href: "/" },
{ label: "Background Change" },
],
"/edit-image": [{ label: "Studio", href: "/" }, { label: "Edit Image" }],
"/edit-3d": [{ label: "Studio", href: "/" }, { label: "Edit 3D" }],
"/video-editor": [{ label: "Studio", href: "/" }, { label: "Edit Video" }],
"/moodboard": [{ label: "Studio", href: "/" }, { label: "Moodboard" }],
"/frame-extractor": [
{ label: "Studio", href: "/" },
{ label: "Frame Extractor" },
],
"/referrals": [{ label: "Referrals" }],
"/media": [{ label: "Library", href: "/library" }, { label: "Media" }],
"/pricing": [{ label: "Pricing" }],
"/support": [{ label: "Support" }],
@@ -42,15 +32,13 @@ const ROUTE_CRUMBS: Record<string, Crumb[]> = {
};
function resolveCrumbs(pathname: string): Crumb[] {
// Library has sub-tabs (Unsorted / Folders / Tags) that live under /library/*
// rather than being separate top-level pages.
// Library has sub-tabs (Unsorted / Folders) that live under /library/* rather
// than being separate top-level pages.
if (pathname === "/library" || pathname.startsWith("/library/")) {
const onFolders =
pathname === "/library/folders" ||
pathname.startsWith("/library/folder_");
const onFolderless = pathname === "/library/folderless";
const onTags =
pathname === "/library/tags" || pathname.startsWith("/library/tag_");
return [
{ label: "Library", href: "/library" },
{
@@ -58,9 +46,7 @@ function resolveCrumbs(pathname: string): Crumb[] {
? "Folders"
: onFolderless
? "Unfoldered"
: onTags
? "Tags"
: "All Assets",
: "All Assets",
},
];
}
@@ -905,15 +905,6 @@ export const TaskQueue = () => {
);
prevFailedIdsRef.current = newFailedIdSet;
if (newlyFailedJobs.length > 0) {
// A failure may have triggered a server-side refund — nudge the
// credits chip now, and again once the refund has settled.
window.dispatchEvent(new Event("credits-change"));
setTimeout(() => {
window.dispatchEvent(new Event("credits-change"));
}, 2500);
}
for (const job of newlyFailedJobs) {
const parts = formatTitleParts(job);
const failureCategory =
@@ -1,10 +1,6 @@
import { Link, useLocation } from "react-router-dom";
import { SidebarTrigger, useSidebar } from "../ui/sidebar";
import {
GalleryAutoplayToggle,
GallerySelectToggle,
GalleryViewToggle,
} from "../generation-gallery/GalleryViewToggle";
import { GalleryViewToggle } from "../generation-gallery/GalleryViewToggle";
import { Breadcrumbs } from "./breadcrumbs";
import { TopBarActions } from "./TopBarActions";
@@ -18,10 +14,6 @@ export function TopBar() {
// On mobile the toggle lives next to the Generate/History tabs (see
// CreateMediaPageShell), so keep it out of the top bar there.
const showViewToggle = !isMobile && GALLERY_VIEW_ROUTES.has(pathname);
// Playing/still video previews only matter where the feed has videos.
const showAutoplayToggle = showViewToggle && pathname === "/create-video";
// Multi-select + batch download on the create feeds (image + video).
const showSelectToggle = showViewToggle;
return (
<header className="sticky top-0 z-20 flex items-center gap-3 border-b border-white/[0.06] bg-[#121212]/80 backdrop-blur-md px-3 pb-4 pt-3 sm:pt-6">
@@ -49,8 +41,6 @@ export function TopBar() {
{/* Right: gallery layout toggle (create pages) + credits / upgrade / task queue / avatar */}
<div className="ml-auto flex items-center gap-1.5 sm:gap-2">
{showSelectToggle && <GallerySelectToggle />}
{showAutoplayToggle && <GalleryAutoplayToggle />}
{showViewToggle && <GalleryViewToggle />}
<TopBarActions />
</div>
@@ -1,97 +0,0 @@
import JSZip from "jszip";
import { addCorsParam } from "@storyteller/common";
import { extensionForUrl } from "./download-media";
// How many media fetches run at once while filling the zip.
const FETCH_CONCURRENCY = 3;
export interface ZipDownloadItem {
id: string;
url: string;
mediaClass?: string | null;
}
export interface ZipDownloadResult {
succeeded: number;
failed: number;
}
// Fetches each item (same CORS/dl params as downloadMediaFile) and saves them
// all as a single zip. A single-item selection skips the zip and saves the
// file directly. Items that fail to fetch are skipped — the zip is still
// produced as long as at least one item succeeded. Callers surface the counts.
export async function downloadItemsAsZip(
items: ZipDownloadItem[],
opts?: { onProgress?: (done: number, total: number) => void },
): Promise<ZipDownloadResult> {
if (items.length === 1) {
const item = items[0];
try {
const blob = await fetchItemBlob(item);
const ext = extensionForUrl(item.url, item.mediaClass);
saveBlob(blob, `artcraft-${item.id}.${ext}`);
opts?.onProgress?.(1, 1);
return { succeeded: 1, failed: 0 };
} catch {
opts?.onProgress?.(1, 1);
return { succeeded: 0, failed: 1 };
}
}
const zip = new JSZip();
let succeeded = 0;
let failed = 0;
let done = 0;
let next = 0;
const worker = async () => {
while (next < items.length) {
const item = items[next];
next += 1;
try {
const blob = await fetchItemBlob(item);
const ext = extensionForUrl(item.url, item.mediaClass);
zip.file(`artcraft-${item.id}.${ext}`, blob);
succeeded += 1;
} catch {
failed += 1;
}
done += 1;
opts?.onProgress?.(done, items.length);
}
};
await Promise.all(
Array.from({ length: Math.min(FETCH_CONCURRENCY, items.length) }, worker),
);
if (succeeded > 0) {
// Media files are already compressed — STORE skips pointless deflate work.
const blob = await zip.generateAsync({ type: "blob", compression: "STORE" });
saveBlob(blob, `artcraft-media-${Date.now()}.zip`);
}
return { succeeded, failed };
}
async function fetchItemBlob(item: ZipDownloadItem): Promise<Blob> {
const corsUrl = addCorsParam(item.url) || item.url;
const response = await fetch(`${corsUrl}&dl=1`, { credentials: "omit" });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.blob();
}
function saveBlob(blob: Blob, filename: string) {
const blobUrl = window.URL.createObjectURL(blob);
try {
const a = document.createElement("a");
a.style.display = "none";
a.href = blobUrl;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
} finally {
window.URL.revokeObjectURL(blobUrl);
}
}
@@ -5,14 +5,10 @@ const EXT_BY_MEDIA_CLASS: Record<string, string> = {
image: "png",
video: "mp4",
audio: "mp3",
// "dimensional" is the deprecated pre-split 3D class (rows persist until
// the backfill lands); mesh/splat are its replacements.
dimensional: "glb",
mesh: "glb",
splat: "spz",
};
export function extensionForUrl(url: string, mediaClass?: string | null): string {
function extensionForUrl(url: string, mediaClass?: string | null): string {
try {
const pathname = new URL(url, window.location.href).pathname;
const match = pathname.match(/\.([a-z0-9]{2,5})$/i);
@@ -1,26 +0,0 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
// Audio behavior for lightbox videos: whether they open with sound (on by
// default, toggled in the settings modal's General tab) and the last volume
// the user picked, so each video doesn't reset to 100%.
interface LightboxSoundStore {
soundEnabled: boolean;
setSoundEnabled: (enabled: boolean) => void;
// 0..1, mirrors HTMLMediaElement.volume.
volume: number;
setVolume: (volume: number) => void;
}
export const useLightboxSoundStore = create<LightboxSoundStore>()(
persist(
(set) => ({
soundEnabled: true,
setSoundEnabled: (soundEnabled) => set({ soundEnabled }),
volume: 1,
setVolume: (volume) => set({ volume }),
}),
{ name: "artcraft-lightbox-sound" },
),
);
@@ -1,137 +0,0 @@
import type { NavigateFunction } from "react-router-dom";
import type { GalleryItem } from "@storyteller/ui-gallery-modal";
import { toast } from "../components/toast/toast";
import type { RefImage, RefVideo } from "../components/prompt-box/types";
import { useCreateImageStore } from "../pages/create-image/create-image-store";
import { useCreateVideoStore } from "../pages/create-video/create-video-store";
export type PromptDestination = "image" | "video";
const DESTINATION_ROUTES: Record<PromptDestination, string> = {
image: "/create-image",
video: "/create-video",
};
export function isImagePromptable(item: GalleryItem): boolean {
return item.mediaClass === "image" && !!(item.thumbnail || item.fullImage);
}
// Videos can only be prompted from on the video page (as reference videos,
// model permitting).
export function isVideoPromptable(item: GalleryItem): boolean {
return item.mediaClass === "video" && !!item.fullImage;
}
// Parks library media for a create page's reference deck and navigates
// there. The reference caps are per-model and only known on the receiving
// page, so the media travels via the store's pending slots and the page
// merges it (dedupe + real caps) once its model list is loaded.
export function sendToPrompt(
items: GalleryItem[],
destination: PromptDestination,
navigate: NavigateFunction,
): void {
const images = items.filter(isImagePromptable);
const videos = destination === "video" ? items.filter(isVideoPromptable) : [];
if (images.length === 0 && videos.length === 0) {
toast.error("Select at least one image to use as a reference");
return;
}
if (destination === "image") {
useCreateImageStore
.getState()
.setPendingRefImages(images.map(galleryItemToRefImage));
if (items.some(isVideoPromptable)) {
toast.success(
`Sent ${images.length} ${images.length === 1 ? "image" : "images"} — videos can't be image references`,
);
}
} else {
const store = useCreateVideoStore.getState();
if (images.length > 0) {
store.setPendingRefImages(images.map(galleryItemToRefImage));
}
if (videos.length > 0) {
store.setPendingRefVideos(videos.map(galleryItemToRefVideo));
}
}
navigate(DESTINATION_ROUTES[destination]);
}
export function galleryItemToRefImage(item: GalleryItem): RefImage {
return {
id: crypto.randomUUID(),
url: item.thumbnail || item.fullImage || "",
fullUrl: item.fullImage || undefined,
file: new File([], "library-image"),
mediaToken: item.id,
};
}
function galleryItemToRefVideo(item: GalleryItem): RefVideo {
return {
id: crypto.randomUUID(),
url: item.fullImage || "",
file: new File([], "library-video"),
mediaToken: item.id,
// 0 = unknown; the video page probes the file before adding it.
duration: item.durationMillis ? item.durationMillis / 1000 : 0,
};
}
// ── Merge (used by the receiving create pages) ─────────────────────────────
export interface MergeRefImagesResult {
next: RefImage[];
added: number;
// Incoming refs whose media token was already attached.
duplicates: number;
// Fresh refs that didn't fit under the model's cap.
overflow: number;
}
// Merges incoming reference images into an existing deck: skips refs whose
// media token is already attached, then clamps to the model's cap.
export function mergeRefImages(
existing: RefImage[],
incoming: RefImage[],
maxRefs: number,
): MergeRefImagesResult {
const existingTokens = new Set(
existing.map((ref) => ref.mediaToken).filter(Boolean),
);
const fresh = incoming.filter(
(ref) => !ref.mediaToken || !existingTokens.has(ref.mediaToken),
);
const slots = Math.max(0, maxRefs - existing.length);
const added = fresh.slice(0, slots);
return {
next: [...existing, ...added],
added: added.length,
duplicates: incoming.length - fresh.length,
overflow: fresh.length - added.length,
};
}
// Feedback for a merge. Silent when everything landed or the only skips were
// images already attached (either way, everything selected is now in the
// deck); otherwise say what was left out and why.
export function toastMergeRefImagesOutcome(
result: MergeRefImagesResult,
maxRefs: number,
): void {
if (result.overflow === 0) return;
if (result.added > 0) {
const sent = result.added + result.overflow;
toast.success(
`Added ${result.added} of ${sent} images — this model takes up to ${maxRefs} references`,
);
} else if (maxRefs === 0) {
toast.error("The selected model doesn't take image references");
} else {
toast.error(
`Couldn't add more images — this model's reference limit (${maxRefs}) is reached`,
);
}
}
+4 -4
View File
@@ -20,10 +20,10 @@ 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.
// 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);
@@ -31,8 +31,6 @@ import {
SettingsDrawer,
DrawerOptionList,
DrawerSection,
getAudioDurationFromUrl,
type RefAudio,
type RefImage,
} from "../../components/prompt-box";
import {
@@ -121,17 +119,22 @@ export default function CreateAudio() {
const ui = useCreateAudioStore((s) => s.ui);
const setUi = useCreateAudioStore((s) => s.setUi);
const selectedModel = useMemo((): OmniGenAudioModelDetails | undefined => {
if (!apiModels.length) return undefined;
if (ui.selectedModelId) {
const selectedModel = useMemo(
(): OmniGenAudioModelDetails | undefined => {
if (!apiModels.length) return undefined;
if (ui.selectedModelId) {
return (
apiModels.find((m) => m.model === ui.selectedModelId) ??
apiModels.find((m) => m.model === DEFAULT_MODEL_ID) ??
apiModels[0]
);
}
return (
apiModels.find((m) => m.model === ui.selectedModelId) ??
apiModels.find((m) => m.model === DEFAULT_MODEL_ID) ??
apiModels[0]
apiModels.find((m) => m.model === DEFAULT_MODEL_ID) ?? apiModels[0]
);
}
return apiModels.find((m) => m.model === DEFAULT_MODEL_ID) ?? apiModels[0];
}, [apiModels, ui.selectedModelId]);
},
[apiModels, ui.selectedModelId],
);
const prompt = ui.prompt;
const setPrompt = useCallback((v: string) => setUi({ prompt: v }), [setUi]);
@@ -187,10 +190,6 @@ export default function CreateAudio() {
const setReferenceImages = useCreateAudioStore((s) => s.setReferenceImages);
const [isImagePickerOpen, setIsImagePickerOpen] = useState(false);
const [pickerSelectedIds, setPickerSelectedIds] = useState<string[]>([]);
const [isAudioPickerOpen, setIsAudioPickerOpen] = useState(false);
const [audioPickerSelectedIds, setAudioPickerSelectedIds] = useState<
string[]
>([]);
const [isBeatDrawerOpen, setIsBeatDrawerOpen] = useState(false);
const [isTuningDrawerOpen, setIsTuningDrawerOpen] = useState(false);
@@ -198,10 +197,6 @@ export default function CreateAudio() {
if (isImagePickerOpen) setPickerSelectedIds([]);
}, [isImagePickerOpen]);
useEffect(() => {
if (isAudioPickerOpen) setAudioPickerSelectedIds([]);
}, [isAudioPickerOpen]);
const handlePickerSelect = useCallback(
(id: string) => {
setPickerSelectedIds((prev) => {
@@ -282,9 +277,7 @@ export default function CreateAudio() {
(audios: typeof referenceAudios) => {
if (audios.length > 0 && referenceImages.length > 0) {
setReferenceImages([]);
toast.error(
"Removed image reference — it can't be combined with audio",
);
toast.error("Removed image reference — it can't be combined with audio");
}
setReferenceAudios(audios);
},
@@ -295,82 +288,22 @@ export default function CreateAudio() {
(images: RefImage[]) => {
if (images.length > 0 && referenceAudios.length > 0) {
setReferenceAudios([]);
toast.error(
"Removed audio reference — it can't be combined with an image",
);
toast.error("Removed audio reference — it can't be combined with an image");
}
setReferenceImages(images);
},
[referenceAudios, setReferenceAudios, setReferenceImages],
);
const audioPickerMax = Math.max(1, maxAudioRefs - referenceAudios.length);
const handleAudioPickerSelect = useCallback(
(id: string) => {
setAudioPickerSelectedIds((prev) => {
if (prev.includes(id)) return prev.filter((x) => x !== id);
if (prev.length >= audioPickerMax) {
return audioPickerMax === 1 ? [id] : prev;
}
return [...prev, id];
});
},
[audioPickerMax],
);
const handleLibraryAudioSelect = useCallback(
async (items: GalleryItem[]) => {
setIsAudioPickerOpen(false);
const availableSlots = Math.max(0, maxAudioRefs - referenceAudios.length);
const picked = items.slice(0, availableSlots);
const added: RefAudio[] = [];
let total = referenceAudios.reduce((sum, a) => sum + a.duration, 0);
for (const item of picked) {
const url = item.fullImage;
if (!url) continue;
const duration =
item.durationMillis != null
? Math.round(item.durationMillis / 1000)
: await getAudioDurationFromUrl(url);
if (duration <= 0) {
toast.error("Could not read audio file");
continue;
}
if (total + duration > AUDIO_REF_MAX_DURATION_SECONDS) {
toast.error(
`Total audio duration cannot exceed ${AUDIO_REF_MAX_DURATION_SECONDS}s`,
);
continue;
}
total += duration;
added.push({
id: Math.random().toString(36).substring(7),
url,
file: new File([], "library-audio"),
mediaToken: item.id,
duration,
});
}
if (added.length > 0) {
handleReferenceAudiosChange([...referenceAudios, ...added]);
}
},
[referenceAudios, maxAudioRefs, handleReferenceAudiosChange],
);
const handleLibraryImageSelect = useCallback(
(items: GalleryItem[]) => {
const newImages: RefImage[] = items
.slice(0, maxImageRefs)
.map((item) => ({
id: Math.random().toString(36).substring(7),
url: item.thumbnail || item.fullImage || "",
fullUrl: item.fullImage || undefined,
file: new File([], "library-image"),
mediaToken: item.id,
}));
const newImages: RefImage[] = items.slice(0, maxImageRefs).map((item) => ({
id: Math.random().toString(36).substring(7),
url: item.thumbnail || item.fullImage || "",
fullUrl: item.fullImage || undefined,
file: new File([], "library-image"),
mediaToken: item.id,
}));
handleReferenceImagesChange(newImages);
setIsImagePickerOpen(false);
},
@@ -548,7 +481,6 @@ export default function CreateAudio() {
</>
);
// Mobile-only band; on desktop the audio refs live in the reference deck.
const audioReferenceRow = audioRefsSupported ? (
<MediaReferenceRow
videoSupported={false}
@@ -561,7 +493,6 @@ export default function CreateAudio() {
onReferenceAudiosChange={handleReferenceAudiosChange}
maxAudioCount={maxAudioRefs}
maxAudioRefDuration={AUDIO_REF_MAX_DURATION_SECONDS}
onPickAudioFromLibrary={() => setIsAudioPickerOpen(true)}
/>
) : undefined;
@@ -789,7 +720,7 @@ export default function CreateAudio() {
promptBox={
<div
ref={promptBoxRef}
className="animate-fade-in-up fixed bottom-2 sm:bottom-3 right-0 z-30 mx-auto max-w-6xl px-2 sm:px-4 transition-[left] duration-200 ease-linear"
className="animate-fade-in-up fixed bottom-2 sm:bottom-3 right-0 z-30 mx-auto max-w-5xl px-2 sm:px-4 transition-[left] duration-200 ease-linear"
style={{
animationDelay: "150ms",
left: "var(--ac-sidebar-offset, 0px)",
@@ -808,14 +739,7 @@ export default function CreateAudio() {
referenceImages={referenceImages}
onReferenceImagesChange={handleReferenceImagesChange}
onPickFromLibrary={() => setIsImagePickerOpen(true)}
audioRefsSupported={audioRefsSupported}
referenceAudios={referenceAudios}
onReferenceAudiosChange={handleReferenceAudiosChange}
maxAudioCount={maxAudioRefs}
maxAudioRefDuration={AUDIO_REF_MAX_DURATION_SECONDS}
onPickAudioFromLibrary={() => setIsAudioPickerOpen(true)}
onClearAllExtras={() => setStylePrompt("")}
hasClearableExtras={ui.stylePrompt.length > 0}
mediaReferenceRow={audioReferenceRow}
secondaryPromptRow={
styleSupported ? (
<StylePromptRow
@@ -874,17 +798,6 @@ export default function CreateAudio() {
forceFilter="image"
hideFilter
/>
<GalleryModal
mode="select"
isOpen={isAudioPickerOpen}
onClose={() => setIsAudioPickerOpen(false)}
selectedItemIds={audioPickerSelectedIds}
onSelectItem={handleAudioPickerSelect}
maxSelections={audioPickerMax}
onUseSelected={handleLibraryAudioSelect}
forceFilter="audio"
hideFilter
/>
<Lightbox
isOpen={lightbox.lightboxOpen}
onClose={lightbox.closeLightbox}
@@ -2,8 +2,6 @@ import { faCopy } from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { PopoverMenu, PopoverItem } from "@storyteller/ui-popover";
import { Tooltip } from "@storyteller/ui-tooltip";
import { PROMPT_TOOLBAR_ICON_BUTTON_CLASSES } from "@storyteller/ui-promptbox";
import { twMerge } from "tailwind-merge";
const DEFAULT_GENERATION_COUNT = 4;
@@ -35,10 +33,7 @@ export function buildCountItems(
return items;
}
export function countFromLabel(
label: string,
batchSizeMax?: number,
): number | null {
export function countFromLabel(label: string, batchSizeMax?: number): number | null {
const maxCount = batchSizeMax ?? DEFAULT_GENERATION_COUNT;
let count = parseInt(label, 10);
if (isNaN(count)) return null;
@@ -80,7 +75,7 @@ export const GenerationCountPicker = ({
mode="toggle"
panelTitle={panelTitle}
triggerIcon={<FontAwesomeIcon icon={faCopy} className="h-4 w-4" />}
buttonClassName={twMerge(PROMPT_TOOLBAR_ICON_BUTTON_CLASSES, "w-11")}
buttonClassName="h-9"
/>
</Tooltip>
);
@@ -35,15 +35,10 @@ type CreateImageState = {
ui: ImageUiState;
referenceImages: RefImage[];
pendingRecreate: RecreatePayload | null;
// Reference images sent from another page (library "Send to prompt").
// Consumed by the create-image page, which applies the real per-model cap.
pendingRefImages: RefImage[] | null;
setUi: (patch: Partial<ImageUiState>) => void;
setReferenceImages: (images: RefImage[]) => void;
setPendingRecreate: (payload: RecreatePayload | null) => void;
consumePendingRecreate: () => RecreatePayload | null;
setPendingRefImages: (refs: RefImage[] | null) => void;
consumePendingRefImages: () => RefImage[] | null;
startBatch: (
prompt: string,
requestedCount: number,
@@ -73,7 +68,6 @@ export const useCreateImageStore = create<CreateImageState>()(
ui: { ...DEFAULT_UI },
referenceImages: [],
pendingRecreate: null,
pendingRefImages: null,
setUi: (patch) =>
set((s) => ({ ui: { ...s.ui, ...patch } })),
@@ -88,14 +82,6 @@ export const useCreateImageStore = create<CreateImageState>()(
return payload;
},
setPendingRefImages: (refs) => set({ pendingRefImages: refs }),
consumePendingRefImages: () => {
const refs = get().pendingRefImages;
if (refs) set({ pendingRefImages: null });
return refs;
},
startBatch: (prompt, requestedCount, modelLabel) => {
const id = crypto.randomUUID();
const batch: ImageBatch = {
@@ -46,11 +46,6 @@ import {
qualityFromLabel,
} from "./components/QualityPicker";
import { useImageCostEstimate } from "../../lib/cost-estimate-api";
import {
galleryItemToRefImage,
mergeRefImages,
toastMergeRefImagesOutcome,
} from "../../lib/send-to-prompt";
import {
resolveModelOption,
resolveModelCount,
@@ -287,24 +282,6 @@ export default function CreateImage() {
// Consume a pending recreate payload (set by the lightbox Recreate button)
// and populate the promptbox fields. Does NOT trigger generation. Subscribes
// to the store so it fires even when the user is already on this route.
// Warn when a recreated generation's model is missing from the current
// model list: selectedModel silently falls back to the default model, and
// the restored prompt/settings may not be valid for it. Verified in its own
// effect because the model list may still be loading when the recreate
// payload is consumed.
const [recreateModelIdToVerify, setRecreateModelIdToVerify] = useState<
string | null
>(null);
useEffect(() => {
if (!recreateModelIdToVerify || apiModels.length === 0) return;
if (!apiModels.some((m) => m.model === recreateModelIdToVerify)) {
toast.error(
"The model used for this generation isn't available anymore. Using the default model instead.",
);
}
setRecreateModelIdToVerify(null);
}, [recreateModelIdToVerify, apiModels]);
const pendingRecreate = useCreateImageStore((s) => s.pendingRecreate);
useEffect(() => {
if (!pendingRecreate) return;
@@ -317,28 +294,8 @@ export default function CreateImage() {
...(payload.resolution ? { resolution: payload.resolution } : {}),
...(payload.modelId ? { selectedModelId: payload.modelId } : {}),
});
if (payload.modelId) setRecreateModelIdToVerify(payload.modelId);
}, [pendingRecreate, setUi]);
// Consume reference images sent from the library ("Send to prompt").
// Waits for the model list so the merge applies the real per-model cap —
// the sender doesn't know which model is selected here.
const pendingRefImages = useCreateImageStore((s) => s.pendingRefImages);
useEffect(() => {
if (!pendingRefImages || apiModels.length === 0) return;
const incoming = useCreateImageStore.getState().consumePendingRefImages();
if (!incoming || incoming.length === 0) return;
const result = mergeRefImages(referenceImages, incoming, maxImageRefs);
if (result.added > 0) setReferenceImages(result.next);
toastMergeRefImagesOutcome(result, maxImageRefs);
}, [
pendingRefImages,
apiModels,
referenceImages,
maxImageRefs,
setReferenceImages,
]);
// Resume polling for pending batches
useEffect(() => {
const cleanups = pollingCleanupsRef.current;
@@ -389,7 +346,13 @@ export default function CreateImage() {
const availableSlots = Math.max(0, maxImageRefs - referenceImages.length);
const newImages: RefImage[] = items
.slice(0, availableSlots)
.map(galleryItemToRefImage);
.map((item) => ({
id: Math.random().toString(36).substring(7),
url: item.thumbnail || item.fullImage || "",
fullUrl: item.fullImage || undefined,
file: new File([], "library-image"),
mediaToken: item.id,
}));
setReferenceImages([...referenceImages, ...newImages]);
setIsImagePickerOpen(false);
},
@@ -401,13 +364,7 @@ export default function CreateImage() {
openSignupCta();
return;
}
if (!prompt.trim() || isGenerating) return;
if (!selectedModel) {
// Models come from an API fetch; without one the submit would be a
// silent no-op, which reads as a dead button.
toast.error("Models are still loading. Try again in a moment.");
return;
}
if (!prompt.trim() || isGenerating || !selectedModel) return;
if (maxPromptLength !== undefined && prompt.length > maxPromptLength) {
toast.error(
@@ -449,13 +406,8 @@ export default function CreateImage() {
dismissBatch(batchId);
openInsufficientCredits();
} else {
// Always toast: batches that fail at enqueue never got a job token,
// so the gallery's failed-card rendering never shows them, and a
// silent failBatch here looks like the button did nothing.
if (result.errorCode != null && result.errorCode >= 500) {
toast.error(OMNI_GENERATE_OUTAGE_MESSAGE);
} else {
toast.error(result.error ?? "Failed to start generation");
}
failBatch(batchId, result.error ?? "Failed to start generation");
}
@@ -482,7 +434,6 @@ export default function CreateImage() {
);
pollingCleanupsRef.current.set(batchId, stopPolling);
} catch {
toast.error("Network error - please try again");
failBatch(batchId, "Network error - please try again");
} finally {
setIsGenerating(false);
@@ -629,7 +580,6 @@ export default function CreateImage() {
description="Generate stunning AI images with ArtCraft"
authChecked={authChecked}
hasContent={hasContent}
showSelectToggle
emptyStateTitle="Create Image"
emptyStateSubtitle="Describe anything. See it in seconds."
emptyStateCta={
@@ -662,14 +612,12 @@ export default function CreateImage() {
onLoadMore={gallery.loadMore}
onGalleryItemClick={lightbox.handleGalleryItemClick}
enableMakeVideo
selectable
selectionBarBottomOffset={promptHeight + 24}
/>
}
promptBox={
<div
ref={promptBoxRef}
className="animate-fade-in-up fixed bottom-2 sm:bottom-3 right-0 z-30 mx-auto max-w-6xl px-2 sm:px-4 transition-[left] duration-200 ease-linear"
className="animate-fade-in-up fixed bottom-2 sm:bottom-3 right-0 z-30 mx-auto max-w-5xl px-2 sm:px-4 transition-[left] duration-200 ease-linear"
style={{
animationDelay: "150ms",
left: "var(--ac-sidebar-offset, 0px)",
@@ -704,9 +652,7 @@ export default function CreateImage() {
richList
triggerIcon={
<img
src={getCreatorIconPathForModelId(
selectedModel?.model ?? "",
)}
src={getCreatorIconPathForModelId(selectedModel?.model ?? "")}
alt=""
className="h-4 w-4 icon-auto-contrast"
/>
@@ -1,4 +1,4 @@
import { HttpApiError, JobsApi, MediaFilesApi, OmniGenApi } from "@storyteller/api";
import { JobsApi, MediaFilesApi, OmniGenApi } from "@storyteller/api";
import type { OmniGenImageRequest } from "@storyteller/api";
import type { GeneratedImage } from "./create-image-store";
@@ -45,24 +45,23 @@ export async function enqueueImageGeneration(
}
return { success: false, error: "Generation failed" };
} catch (err: any) {
// ApiManager throws HttpApiError on non-2xx responses, carrying the
// server's `message` so it can be shown to the user instead of a bare
// status code.
if (err instanceof HttpApiError) {
return {
success: false,
error: err.serverMessage ?? err.message,
errorCode: err.status,
};
}
return {
success: false,
error: err.message ?? "Request failed",
errorCode: undefined,
errorCode: parseHttpStatusCode(err),
};
}
}
// Pull the HTTP status out of an ApiManager error. ApiManager throws
// `Error("HTTP error! status: 402")` on a non-2xx response (the JSON body is
// not surfaced), so callers can special-case codes like 402 Payment Required.
function parseHttpStatusCode(err: unknown): number | undefined {
const message = err instanceof Error ? err.message : String(err);
const match = /status:\s*(\d+)/.exec(message);
return match ? Number(match[1]) : undefined;
}
// ── Poll for completion ──────────────────────────────────────────────────
export async function pollJobResult(
@@ -1,170 +0,0 @@
import { useRef, useState } from "react";
import {
DeckPreviewModal,
DeckSlotCard,
type DeckItem,
} from "@storyteller/ui-promptbox";
import type { MeshInputsState } from "../create-object-store";
import type { MultiViewSlot } from "./MeshInputsRow";
import { MESH_FILE_ACCEPT, uploadMeshFile, uploadViewImage } from "./mesh-upload";
// Always-visible named slot cards (Front/Back/Left/Right angles + input mesh)
// rendered beside the reference deck in the desktop prompt box — same design
// as the video page's First/Last frame cards. The mobile form keeps the
// MeshInputsRow band; both write to the same store slice.
const VIEW_SLOTS: {
slot: MultiViewSlot;
key: keyof MeshInputsState;
label: string;
}[] = [
{ slot: "front", key: "frontImage", label: "Front" },
{ slot: "back", key: "backImage", label: "Back" },
{ slot: "left", key: "leftImage", label: "Left" },
{ slot: "right", key: "rightImage", label: "Right" },
];
interface MeshDeckSlotsProps {
showMultiView: boolean;
showMeshInput: boolean;
inputs: MeshInputsState;
setInputs: (patch: Partial<MeshInputsState>) => void;
onPickSlotFromLibrary: (slot: MultiViewSlot) => void;
}
export function MeshDeckSlots({
showMultiView,
showMeshInput,
inputs,
setInputs,
onPickSlotFromLibrary,
}: MeshDeckSlotsProps) {
const viewInputRef = useRef<HTMLInputElement>(null);
const meshInputRef = useRef<HTMLInputElement>(null);
const targetSlotRef = useRef<MultiViewSlot>("front");
const [uploadingView, setUploadingView] = useState<{
slot: MultiViewSlot;
previewUrl: string;
} | null>(null);
const [isUploadingMesh, setIsUploadingMesh] = useState(false);
const [previewItem, setPreviewItem] = useState<DeckItem | null>(null);
const handleViewFile = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (viewInputRef.current) viewInputRef.current.value = "";
if (!file) return;
const target = VIEW_SLOTS.find((v) => v.slot === targetSlotRef.current)!;
const previewUrl = URL.createObjectURL(file);
setUploadingView({ slot: target.slot, previewUrl });
const image = await uploadViewImage(target.label, file);
URL.revokeObjectURL(previewUrl);
setUploadingView(null);
if (image) setInputs({ [target.key]: image });
};
const handleMeshFile = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (meshInputRef.current) meshInputRef.current.value = "";
if (!file) return;
setIsUploadingMesh(true);
const mesh = await uploadMeshFile(file);
setIsUploadingMesh(false);
if (mesh) setInputs({ inputMesh: mesh });
};
const meshItem: DeckItem | undefined = inputs.inputMesh
? {
id: inputs.inputMesh.id,
kind: "mesh",
name: inputs.inputMesh.file?.name || "Mesh file",
}
: isUploadingMesh
? { id: "uploading-mesh", kind: "mesh", name: "Mesh file", uploading: true }
: undefined;
return (
<div className="flex shrink-0 items-center gap-1.5 self-center">
<input
type="file"
ref={viewInputRef}
className="hidden"
accept="image/*"
onChange={handleViewFile}
/>
<input
type="file"
ref={meshInputRef}
className="hidden"
accept={MESH_FILE_ACCEPT}
onChange={handleMeshFile}
/>
{showMultiView &&
VIEW_SLOTS.map(({ slot, key, label }) => {
const image = inputs[key];
const item: DeckItem | undefined = image
? {
id: image.id,
kind: "image",
url: image.url,
previewUrl: image.fullUrl ?? image.url,
name: `${label} view`,
}
: uploadingView?.slot === slot
? {
id: `uploading-${slot}`,
kind: "image",
url: uploadingView.previewUrl,
name: `${label} view`,
uploading: true,
}
: undefined;
return (
<DeckSlotCard
key={slot}
item={item}
label={label}
addActions={[
{
key: `upload-${slot}`,
label: "Upload",
onSelect: () => {
targetSlotRef.current = slot;
viewInputRef.current?.click();
},
},
{
key: `library-${slot}`,
label: "From library",
onSelect: () => onPickSlotFromLibrary(slot),
},
]}
onRemove={() => setInputs({ [key]: undefined })}
onPreview={setPreviewItem}
/>
);
})}
{showMeshInput && (
<DeckSlotCard
item={meshItem}
label="Mesh"
addActions={[
{
key: "upload-mesh",
label: "Upload mesh",
onSelect: () => meshInputRef.current?.click(),
},
]}
onRemove={() => setInputs({ inputMesh: undefined })}
onPreview={setPreviewItem}
/>
)}
<DeckPreviewModal
item={previewItem}
onClose={() => setPreviewItem(null)}
/>
</div>
);
}
@@ -6,12 +6,16 @@ import {
faCube,
faImage,
} from "@fortawesome/pro-solid-svg-icons";
import { MediaUploadApi } from "@storyteller/api";
import { FilterEngineCategories } from "@storyteller/api";
import { UploaderStates } from "@storyteller/common";
import { uploadImage } from "../../../components/prompt-box/upload-image";
import { AddButton, type RefImage } from "../../../components/prompt-box";
import { MESH_FILE_ACCEPT, uploadMeshFile, uploadViewImage } from "./mesh-upload";
// Multi-view side inputs (front/back/left/right) and the mesh-to-mesh input
// file — the mobile form band. The desktop prompt box renders the same store
// slice as reference-deck cards via useMeshDeckRefs.
// file, shown above the prompt box when the selected model supports them. Uses
// the same media upload path (and Upload / Pick-from-library affordance) as the
// primary reference image row.
export type MultiViewSlot = "front" | "back" | "left" | "right";
@@ -34,6 +38,8 @@ interface MeshInputsRowProps {
onPickSlotFromLibrary?: (slot: MultiViewSlot) => void;
}
const MESH_FILE_ACCEPT = ".glb,.gltf,.fbx,.obj";
export function MeshInputsRow({
showMultiView,
showMeshInput,
@@ -57,8 +63,9 @@ export function MeshInputsRow({
return (
// Title on the left, upload slots right-aligned + top-aligned (matches
// ImagePromptRow).
<div className="glass flex items-start gap-3 rounded-2xl px-3 py-2">
// ImagePromptRow). Flat bottom on desktop so it seams into the prompt box
// glass stacked below it; fully rounded on mobile (stands alone there).
<div className="glass flex items-start gap-3 rounded-2xl px-3 py-2 sm:rounded-t-2xl sm:rounded-b-none">
<div className="flex grow flex-col gap-1 min-w-32">
<div className="flex items-center gap-2 text-white/90">
<FontAwesomeIcon icon={titleIcon} className="h-3.5 w-3.5" />
@@ -156,14 +163,35 @@ function ImageSlot({
const inputRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
const handleUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
const handleUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (inputRef.current) inputRef.current.value = "";
if (!file) return;
setUploading(true);
const image = await uploadViewImage(label, file);
setUploading(false);
if (image) onChange(image);
const reader = new FileReader();
reader.onloadend = async () => {
await uploadImage({
title: `${label.toLowerCase()}-view-${Math.random().toString(36).substring(2, 10)}`,
assetFile: file,
progressCallback: (state) => {
if (state.status === UploaderStates.success && state.data) {
onChange({
id: Math.random().toString(36).substring(7),
url: reader.result as string,
file,
mediaToken: state.data,
});
setUploading(false);
} else if (
state.status === UploaderStates.assetError ||
state.status === UploaderStates.imageCreateError
) {
setUploading(false);
}
},
});
if (inputRef.current) inputRef.current.value = "";
};
reader.readAsDataURL(file);
};
return (
@@ -219,12 +247,31 @@ function MeshFileSlot({
const handleUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (inputRef.current) inputRef.current.value = "";
if (!file) return;
setUploading(true);
const mesh = await uploadMeshFile(file);
setUploading(false);
if (mesh) onChange(mesh);
try {
const api = new MediaUploadApi();
const response = await api.UploadNewEngineAsset({
file,
fileName: file.name || `input-mesh-${Date.now()}`,
uuid: crypto.randomUUID(),
engine_category: FilterEngineCategories.OBJECT,
maybe_title: "input_mesh",
});
if (response?.success && response.data) {
onChange({
id: Math.random().toString(36).substring(7),
url: "",
file,
mediaToken: response.data,
});
}
} catch {
// ignore — user can retry
} finally {
setUploading(false);
if (inputRef.current) inputRef.current.value = "";
}
};
return (
@@ -1,63 +0,0 @@
import { MediaUploadApi, FilterEngineCategories } from "@storyteller/api";
import { UploaderStates } from "@storyteller/common";
import { uploadImage } from "../../../components/prompt-box/upload-image";
import type { RefImage } from "../../../components/prompt-box";
// Upload paths shared by the desktop reference deck (useMeshDeckRefs) and the
// mobile MeshInputsRow band.
export const MESH_FILE_ACCEPT = ".glb,.gltf,.fbx,.obj";
const randomId = () => Math.random().toString(36).substring(7);
const readAsDataUrl = (file: File): Promise<string> =>
new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(file);
});
/** Uploads a multi-view angle image; resolves null on failure. */
export async function uploadViewImage(
label: string,
file: File,
): Promise<RefImage | null> {
const url = await readAsDataUrl(file);
return new Promise((resolve) => {
uploadImage({
title: `${label.toLowerCase()}-view-${Math.random().toString(36).substring(2, 10)}`,
assetFile: file,
progressCallback: (state) => {
if (state.status === UploaderStates.success && state.data) {
resolve({ id: randomId(), url, file, mediaToken: state.data });
} else if (
state.status === UploaderStates.assetError ||
state.status === UploaderStates.imageCreateError
) {
resolve(null);
}
},
});
});
}
/** Uploads a mesh file via the engine-asset endpoint; resolves null on failure. */
export async function uploadMeshFile(file: File): Promise<RefImage | null> {
try {
const api = new MediaUploadApi();
const response = await api.UploadNewEngineAsset({
file,
fileName: file.name || `input-mesh-${Date.now()}`,
uuid: crypto.randomUUID(),
engine_category: FilterEngineCategories.OBJECT,
maybe_title: "input_mesh",
});
if (response?.success && response.data) {
return { id: randomId(), url: "", file, mediaToken: response.data };
}
} catch {
// fall through — user can retry
}
return null;
}
@@ -4,7 +4,11 @@ import { PopoverMenu, type PopoverItem } from "@storyteller/ui-popover";
import { Tooltip } from "@storyteller/ui-tooltip";
import { Button, ToggleButton } from "@storyteller/ui-button";
import { GalleryModal, type GalleryItem } from "@storyteller/ui-gallery-modal";
import { faSparkles, faGem, faImage } from "@fortawesome/pro-solid-svg-icons";
import {
faSparkles,
faGem,
faImage,
} from "@fortawesome/pro-solid-svg-icons";
import {
PromptBox,
ImagePromptRow,
@@ -33,7 +37,6 @@ import {
QUALITY_OPTIONS,
} from "./components/MeshOptionPickers";
import { MeshInputsRow, type MultiViewSlot } from "./components/MeshInputsRow";
import { MeshDeckSlots } from "./components/MeshDeckSlots";
import { useMeshCostEstimate } from "../../lib/cost-estimate-api";
import { resolveModelOption } from "../../lib/resolve-model-setting";
import {
@@ -121,16 +124,8 @@ export default function CreateObject() {
// Settings are sticky across model switches, resolved against the current
// model's supported options at read time (see lib/resolve-model-setting).
const meshOutputType = resolveModelOption(
ui.meshOutputType,
outputTypes,
outputTypes[0],
);
const polygonType = resolveModelOption(
ui.polygonType,
polygonTypes,
polygonTypes[0],
);
const meshOutputType = resolveModelOption(ui.meshOutputType, outputTypes, outputTypes[0]);
const polygonType = resolveModelOption(ui.polygonType, polygonTypes, polygonTypes[0]);
const geometryQuality = resolveModelOption(
ui.geometryQuality,
supportsGeometryQuality ? QUALITY_OPTIONS : [],
@@ -143,9 +138,7 @@ export default function CreateObject() {
);
// Toggles default on when supported unless the user turned them off.
const enablePbr = supportsPbr ? (ui.enablePbr ?? false) : false;
const enableTexture = supportsTextureToggle
? (ui.enableTexture ?? true)
: true;
const enableTexture = supportsTextureToggle ? (ui.enableTexture ?? true) : true;
const [isGenerating, setIsGenerating] = useState(false);
const referenceImages = useCreateObjectStore((s) => s.referenceImages);
@@ -465,23 +458,12 @@ export default function CreateObject() {
const placeholder = supportsText
? "Describe the 3D object you want..."
: supportsMeshInput
? "Upload a mesh file to process"
: "Add a reference image";
? "Upload a mesh file below to process"
: "Add a reference image below";
// Desktop: multi-view angles + input mesh render as always-visible slot
// cards beside the reference deck (like the video page's keyframe cards).
const meshDeckSlots =
supportsMultiView || supportsMeshInput ? (
<MeshDeckSlots
showMultiView={supportsMultiView}
showMeshInput={supportsMeshInput}
inputs={inputs}
setInputs={setInputs}
onPickSlotFromLibrary={setLibraryTarget}
/>
) : undefined;
// Mobile-only band over the same store slice.
// Only render the extra-inputs row when the model actually supports one of its
// slots, otherwise it renders empty but still flags a "row above" the prompt
// box (flat-top visual glitch).
const meshInputsRow =
supportsMultiView || supportsMeshInput ? (
<MeshInputsRow
@@ -637,7 +619,7 @@ export default function CreateObject() {
promptBox={
<div
ref={promptBoxRef}
className="animate-fade-in-up fixed bottom-2 sm:bottom-3 right-0 z-30 mx-auto max-w-6xl px-2 sm:px-4 transition-[left] duration-200 ease-linear"
className="animate-fade-in-up fixed bottom-2 sm:bottom-3 right-0 z-30 mx-auto max-w-5xl px-2 sm:px-4 transition-[left] duration-200 ease-linear"
style={{
animationDelay: "150ms",
left: "var(--ac-sidebar-offset, 0px)",
@@ -656,24 +638,9 @@ export default function CreateObject() {
referenceImages={referenceImages}
onReferenceImagesChange={setReferenceImages}
onPickFromLibrary={() => setLibraryTarget("primary")}
onClearAllExtras={() =>
setInputs({
frontImage: undefined,
backImage: undefined,
leftImage: undefined,
rightImage: undefined,
inputMesh: undefined,
})
}
hasClearableExtras={Object.values(inputs).some(Boolean)}
referenceSlots={meshDeckSlots}
mediaReferenceRow={meshInputsRow}
modelSelector={
<Tooltip
content="Model"
position="top"
className="z-50"
closeOnClick
>
<Tooltip content="Model" position="top" className="z-50" closeOnClick>
<PopoverMenu
items={modelItems}
onSelect={handleModelChange}
@@ -683,9 +650,7 @@ export default function CreateObject() {
richList
triggerIcon={
<img
src={getCreatorIconPathForModelId(
selectedModel?.model ?? "",
)}
src={getCreatorIconPathForModelId(selectedModel?.model ?? "")}
alt=""
className="h-4 w-4 icon-auto-contrast"
/>
@@ -51,18 +51,10 @@ type CreateVideoState = {
ui: VideoUiState;
refs: VideoRefsState;
pendingRecreate: RecreatePayload | null;
// Reference media sent from another page (library "Send to prompt").
// Consumed by the create-video page, which applies the real per-model caps.
pendingRefImages: RefImage[] | null;
pendingRefVideos: RefVideo[] | null;
setUi: (patch: Partial<VideoUiState>) => void;
setRefs: (patch: Partial<VideoRefsState>) => void;
setPendingRecreate: (payload: RecreatePayload | null) => void;
consumePendingRecreate: () => RecreatePayload | null;
setPendingRefImages: (refs: RefImage[] | null) => void;
consumePendingRefImages: () => RefImage[] | null;
setPendingRefVideos: (refs: RefVideo[] | null) => void;
consumePendingRefVideos: () => RefVideo[] | null;
startBatch: (prompt: string, modelLabel: string, batchCount?: number) => string;
setBatchJobToken: (batchId: string, jobToken: string) => void;
completeBatch: (batchId: string, video: GeneratedVideo) => void;
@@ -80,7 +72,7 @@ const DEFAULT_UI: VideoUiState = {
resolution: null,
bitrate: null,
generateWithSound: false,
inputMode: "reference",
inputMode: "keyframe",
numVideos: 1,
};
@@ -98,8 +90,6 @@ export const useCreateVideoStore = create<CreateVideoState>()(
ui: { ...DEFAULT_UI },
refs: { ...DEFAULT_REFS },
pendingRecreate: null,
pendingRefImages: null,
pendingRefVideos: null,
setUi: (patch) =>
set((s) => ({ ui: { ...s.ui, ...patch } })),
@@ -115,22 +105,6 @@ export const useCreateVideoStore = create<CreateVideoState>()(
return payload;
},
setPendingRefImages: (refs) => set({ pendingRefImages: refs }),
consumePendingRefImages: () => {
const refs = get().pendingRefImages;
if (refs) set({ pendingRefImages: null });
return refs;
},
setPendingRefVideos: (refs) => set({ pendingRefVideos: refs }),
consumePendingRefVideos: () => {
const refs = get().pendingRefVideos;
if (refs) set({ pendingRefVideos: null });
return refs;
},
startBatch: (prompt, modelLabel, batchCount) => {
const id = crypto.randomUUID();
const batch: VideoBatch = {
@@ -187,21 +161,6 @@ export const useCreateVideoStore = create<CreateVideoState>()(
}),
{
name: "artcraft-video-batches",
// Bumped to 1 when reference became the default input mode: the
// migration runs once for pre-existing persisted state and resets the
// stored mode so everyone lands on the new default.
version: 1,
migrate: (persisted, version) => {
const p = (persisted ?? {}) as {
batches?: VideoBatch[];
ui?: VideoUiState;
};
const ui = { ...DEFAULT_UI, ...(p.ui ?? {}) };
if (version < 1) {
ui.inputMode = "reference";
}
return { batches: p.batches ?? [], ui };
},
// Persist prompt + lightweight settings alongside pending batches so a
// full page reload (e.g. returning from a credit top-up) keeps the
// user's draft. Reference media (refs) is excluded for the same reason
@@ -27,7 +27,6 @@ import {
SettingsDrawer,
DrawerOptionList,
DrawerSection,
getAudioDurationFromUrl,
getVideoDurationFromUrl,
type RefImage,
type RefVideo,
@@ -55,10 +54,6 @@ import {
} from "../create-image/components/AspectRatioIcon";
import { GenerationCountPicker } from "../create-image/components/GenerationCountPicker";
import { useVideoCostEstimate } from "../../lib/cost-estimate-api";
import {
mergeRefImages,
toastMergeRefImagesOutcome,
} from "../../lib/send-to-prompt";
import {
resolveModelOption,
resolveModelCount,
@@ -207,41 +202,6 @@ function buildSizePopoverItems(
}));
}
// Caps candidate reference videos against the deck's slot and total-duration
// limits, probing each file's duration when it isn't known yet. Rejections
// toast per video. Shared by the library video picker and the "Send to
// prompt" consume flow.
async function buildVideoRefsToAdd(
candidates: RefVideo[],
existing: RefVideo[],
maxRefs: number,
maxTotalDuration: number,
): Promise<RefVideo[]> {
const slots = Math.max(0, maxRefs - existing.length);
const picked = candidates.slice(0, slots);
const added: RefVideo[] = [];
let total = existing.reduce((sum, v) => sum + v.duration, 0);
for (const candidate of picked) {
const duration =
candidate.duration > 0
? candidate.duration
: await getVideoDurationFromUrl(candidate.url);
if (duration <= 0) {
toast.error("Could not read video file");
continue;
}
if (total + duration > maxTotalDuration) {
toast.error(
`Video too long — max ${maxTotalDuration}s total (${maxTotalDuration - total}s remaining)`,
);
continue;
}
total += duration;
added.push({ ...candidate, duration });
}
return added;
}
// Effective max duration for the active input mode. Some models (e.g. Grok)
// cap duration lower when image references are used — that's reference mode here.
function maxDurationForMode(
@@ -407,7 +367,6 @@ export default function CreateVideo() {
const [isImagePickerOpen, setIsImagePickerOpen] = useState(false);
const [isEndFramePickerOpen, setIsEndFramePickerOpen] = useState(false);
const [isVideoRefPickerOpen, setIsVideoRefPickerOpen] = useState(false);
const [isAudioRefPickerOpen, setIsAudioRefPickerOpen] = useState(false);
const [isCharactersModalOpen, setIsCharactersModalOpen] = useState(false);
const [isOutputDrawerOpen, setIsOutputDrawerOpen] = useState(false);
const [pickerSelectedIds, setPickerSelectedIds] = useState<string[]>([]);
@@ -417,9 +376,6 @@ export default function CreateVideo() {
const [videoRefPickerSelectedIds, setVideoRefPickerSelectedIds] = useState<
string[]
>([]);
const [audioRefPickerSelectedIds, setAudioRefPickerSelectedIds] = useState<
string[]
>([]);
useEffect(() => {
if (isImagePickerOpen) setPickerSelectedIds([]);
@@ -433,10 +389,6 @@ export default function CreateVideo() {
if (isVideoRefPickerOpen) setVideoRefPickerSelectedIds([]);
}, [isVideoRefPickerOpen]);
useEffect(() => {
if (isAudioRefPickerOpen) setAudioRefPickerSelectedIds([]);
}, [isAudioRefPickerOpen]);
// Characters store for @-mentions
const storedCharacters = useCharactersStore((s) => s.characters);
const charactersLoaded = useCharactersStore((s) => s.loaded);
@@ -611,20 +563,6 @@ export default function CreateVideo() {
activeCharacters,
]);
// Seedance 2.0 models support @-mention references in the prompt — once at
// least one ref is attached, surface that in the placeholder.
const isSeedance2Model = (selectedModel?.model ?? "").startsWith(
"seedance_2p0",
);
const hasUploadedRefs =
referenceImages.length > 0 ||
referenceVideos.length > 0 ||
referenceAudios.length > 0;
const promptPlaceholder =
isSeedance2Model && isReferenceMode && hasUploadedRefs
? "Describe your video. Use @Image1, @Video1... to reference your uploads"
: "Describe the video you want to generate...";
const modelItems = useMemo(
() => buildModelPopoverItems(apiModels, selectedModel?.model ?? ""),
[apiModels, selectedModel?.model],
@@ -709,7 +647,7 @@ export default function CreateVideo() {
selected: inputMode === "keyframe",
},
{
label: "Omni Reference",
label: "Reference",
description: "Multi-media ref",
selected: inputMode === "reference",
},
@@ -735,24 +673,6 @@ export default function CreateVideo() {
// builds its label regex from `referenceImages`/`referenceVideos`/etc.) knows
// about every `@ImageN` before the contentEditable does its DOM sync. Without
// this ordering, only the last reference's mention would end up colored.
// Warn when a recreated generation's model is missing from the current
// model list: selectedModel silently falls back to the default model, and
// the restored prompt/settings may not be valid for it. Verified in its own
// effect because the model list may still be loading when the recreate
// payload is consumed.
const [recreateModelIdToVerify, setRecreateModelIdToVerify] = useState<
string | null
>(null);
useEffect(() => {
if (!recreateModelIdToVerify || apiModels.length === 0) return;
if (!apiModels.some((m) => m.model === recreateModelIdToVerify)) {
toast.error(
"The model used for this generation isn't available anymore. Using the default model instead.",
);
}
setRecreateModelIdToVerify(null);
}, [recreateModelIdToVerify, apiModels]);
const pendingRecreate = useCreateVideoStore((s) => s.pendingRecreate);
useEffect(() => {
if (!pendingRecreate) return;
@@ -771,7 +691,6 @@ export default function CreateVideo() {
...(payload.inputMode ? { inputMode: payload.inputMode } : {}),
});
});
if (payload.modelId) setRecreateModelIdToVerify(payload.modelId);
setUi({
prompt: payload.prompt,
@@ -786,79 +705,6 @@ export default function CreateVideo() {
});
}, [pendingRecreate, setUi]);
// Consume reference images sent from the library ("Send to prompt").
// Waits for the model list so the merge applies the real per-model cap —
// the sender doesn't know which model is selected here. Reference-capable
// models take the images as references (switching into reference mode so
// they're visible); keyframe-only models take the first as the start frame.
const pendingRefImages = useCreateVideoStore((s) => s.pendingRefImages);
useEffect(() => {
if (!pendingRefImages || apiModels.length === 0) return;
const incoming = useCreateVideoStore.getState().consumePendingRefImages();
if (!incoming || incoming.length === 0) return;
const supportsRefs = !!selectedModel?.image_references_supported;
const supportsKeyframe =
!!selectedModel?.starting_keyframe_supported ||
!!selectedModel?.starting_keyframe_required;
const maxImages = supportsRefs
? (selectedModel?.image_references_max ?? 3)
: supportsKeyframe
? 1
: 0;
const result = mergeRefImages(referenceImages, incoming, maxImages);
if (result.added > 0) {
setReferenceImages(result.next);
if (supportsRefs) setUi({ inputMode: "reference" });
}
toastMergeRefImagesOutcome(result, maxImages);
}, [
pendingRefImages,
apiModels,
selectedModel,
referenceImages,
setReferenceImages,
setUi,
]);
// Consume reference videos sent from the library ("Send to prompt").
// Only some models (e.g. Seedance) take video references — anything else
// gets a toast instead of a silently hidden deck.
const pendingRefVideos = useCreateVideoStore((s) => s.pendingRefVideos);
useEffect(() => {
if (!pendingRefVideos || apiModels.length === 0) return;
const incoming = useCreateVideoStore.getState().consumePendingRefVideos();
if (!incoming || incoming.length === 0) return;
if (!supportsVideoRefs) {
toast.error(
"The selected model doesn't support video references — pick one that does (e.g. Seedance)",
);
return;
}
const existingTokens = new Set(referenceVideos.map((v) => v.mediaToken));
const fresh = incoming.filter((v) => !existingTokens.has(v.mediaToken));
void (async () => {
const added = await buildVideoRefsToAdd(
fresh,
referenceVideos,
maxVideoRefs,
maxVideoRefDuration,
);
if (added.length > 0) {
setReferenceVideos([...referenceVideos, ...added]);
setUi({ inputMode: "reference" });
}
})();
}, [
pendingRefVideos,
apiModels,
supportsVideoRefs,
referenceVideos,
maxVideoRefs,
maxVideoRefDuration,
setReferenceVideos,
setUi,
]);
useEffect(() => {
const cleanups = pollingCleanupsRef.current;
const pendingBatches = useCreateVideoStore
@@ -956,7 +802,7 @@ export default function CreateVideo() {
const handleInputModeChange = useCallback(
(item: PopoverItem) => {
const mode = item.label === "Omni Reference" ? "reference" : "keyframe";
const mode = item.label === "Reference" ? "reference" : "keyframe";
if (mode === inputMode) return;
if (mode === "reference") {
// Some models (e.g. Grok) cap duration lower in image-reference mode.
@@ -1042,68 +888,22 @@ export default function CreateVideo() {
const handleLibraryVideoSelect = useCallback(
async (items: GalleryItem[]) => {
setIsVideoRefPickerOpen(false);
const candidates: RefVideo[] = items
.filter((item) => !!item.fullImage)
.map((item) => ({
id: Math.random().toString(36).substring(7),
url: item.fullImage!,
file: new File([], "library-video"),
mediaToken: item.id,
duration: 0,
}));
const added = await buildVideoRefsToAdd(
candidates,
referenceVideos,
maxVideoRefs,
maxVideoRefDuration,
);
if (added.length > 0) {
setReferenceVideos([...referenceVideos, ...added]);
}
},
[referenceVideos, maxVideoRefs, maxVideoRefDuration, setReferenceVideos],
);
const maxAudioRefs = selectedModel?.audio_references_max ?? 2;
const maxAudioRefDurationTotal =
selectedModel?.audio_references_max_total_duration_seconds ?? 30;
const audioRefPickerMax = Math.max(1, maxAudioRefs - referenceAudios.length);
const handleAudioRefPickerSelect = useCallback(
(id: string) => {
setAudioRefPickerSelectedIds((prev) => {
if (prev.includes(id)) return prev.filter((x) => x !== id);
if (prev.length >= audioRefPickerMax) {
return audioRefPickerMax === 1 ? [id] : prev;
}
return [...prev, id];
});
},
[audioRefPickerMax],
);
const handleLibraryAudioSelect = useCallback(
async (items: GalleryItem[]) => {
setIsAudioRefPickerOpen(false);
const availableSlots = Math.max(0, maxAudioRefs - referenceAudios.length);
const availableSlots = Math.max(0, maxVideoRefs - referenceVideos.length);
const picked = items.slice(0, availableSlots);
const added: RefAudio[] = [];
let total = referenceAudios.reduce((sum, a) => sum + a.duration, 0);
const added: RefVideo[] = [];
let total = referenceVideos.reduce((sum, v) => sum + v.duration, 0);
for (const item of picked) {
const url = item.fullImage;
if (!url) continue;
const duration =
item.durationMillis != null
? Math.round(item.durationMillis / 1000)
: await getAudioDurationFromUrl(url);
const duration = await getVideoDurationFromUrl(url);
if (duration <= 0) {
toast.error("Could not read audio file");
toast.error("Could not read video file");
continue;
}
if (total + duration > maxAudioRefDurationTotal) {
if (total + duration > maxVideoRefDuration) {
toast.error(
`Audio too long — max ${maxAudioRefDurationTotal}s total (${maxAudioRefDurationTotal - total}s remaining)`,
`Video too long — max ${maxVideoRefDuration}s total (${maxVideoRefDuration - total}s remaining)`,
);
continue;
}
@@ -1111,21 +911,16 @@ export default function CreateVideo() {
added.push({
id: Math.random().toString(36).substring(7),
url,
file: new File([], "library-audio"),
file: new File([], "library-video"),
mediaToken: item.id,
duration,
});
}
if (added.length > 0) {
setReferenceAudios([...referenceAudios, ...added]);
setReferenceVideos([...referenceVideos, ...added]);
}
},
[
referenceAudios,
maxAudioRefs,
maxAudioRefDurationTotal,
setReferenceAudios,
],
[referenceVideos, maxVideoRefs, maxVideoRefDuration, setReferenceVideos],
);
const handleEndFrameLibrarySelect = useCallback((items: GalleryItem[]) => {
@@ -1152,19 +947,14 @@ export default function CreateVideo() {
);
return;
}
if (!prompt.trim() || isGeneratingRef.current) {
if (!prompt.trim() || isGeneratingRef.current || !selectedModel) {
console.log("[generate-video] blocked", {
hasPrompt: !!prompt.trim(),
isGenerating: isGeneratingRef.current,
hasModel: !!selectedModel,
});
return;
}
if (!selectedModel) {
// Models come from an API fetch; without one the submit would be a
// silent no-op, which reads as a dead button.
toast.error("Models are still loading. Try again in a moment.");
return;
}
if (maxPromptLength !== undefined && prompt.length > maxPromptLength) {
toast.error(
`Prompt exceeds the ${maxPromptLength} character limit for this model`,
@@ -1281,13 +1071,8 @@ export default function CreateVideo() {
dismissBatch(batchId);
openInsufficientCredits();
} else {
// Always toast: batches that fail at enqueue never got a job token,
// so the gallery's failed-card rendering never shows them, and a
// silent failBatch here looks like the button did nothing.
if (result.errorCode != null && result.errorCode >= 500) {
toast.error(OMNI_GENERATE_OUTAGE_MESSAGE);
} else {
toast.error(result.error ?? "Failed to start generation");
}
failBatch(batchId, result.error ?? "Failed to start generation");
}
@@ -1319,7 +1104,6 @@ export default function CreateVideo() {
}
} catch (err) {
console.error("[generate-video] unexpected error", err);
toast.error("Network error - please try again");
failBatch(batchId, "Network error - please try again");
}
@@ -1377,7 +1161,6 @@ export default function CreateVideo() {
maxAudioRefDuration={
selectedModel?.audio_references_max_total_duration_seconds ?? 30
}
onPickAudioFromLibrary={() => setIsAudioRefPickerOpen(true)}
/>
) : undefined;
@@ -1400,7 +1183,7 @@ export default function CreateVideo() {
onSubmit={handleGenerate}
isSubmitting={isGenerating}
credits={estimatedCredits}
placeholder={promptPlaceholder}
placeholder="Describe the video you want to generate..."
mentionItems={mentionItems.length > 0 ? mentionItems : undefined}
autoAdvance={loggedIn && !!prompt.trim() && !isGenerating && !needsImage}
banner={
@@ -1597,8 +1380,6 @@ export default function CreateVideo() {
description="Generate stunning AI videos with ArtCraft"
authChecked={authChecked}
hasContent={hasContent}
showAutoplayToggle
showSelectToggle
emptyStateTitle="Create Video"
emptyStateSubtitle="Describe a scene. See it in motion."
emptyStateCta={
@@ -1631,14 +1412,12 @@ export default function CreateVideo() {
isInitialLoading={gallery.isInitialLoading}
onLoadMore={gallery.loadMore}
onGalleryItemClick={lightbox.handleGalleryItemClick}
selectable
selectionBarBottomOffset={promptHeight + 24}
/>
}
promptBox={
<div
ref={promptBoxRef}
className="animate-fade-in-up fixed bottom-2 sm:bottom-3 right-0 z-30 mx-auto max-w-6xl px-2 sm:px-4 transition-[left] duration-200 ease-linear"
className="animate-fade-in-up fixed bottom-2 sm:bottom-3 right-0 z-30 mx-auto max-w-5xl px-2 sm:px-4 transition-[left] duration-200 ease-linear"
style={{
animationDelay: "150ms",
left: "var(--ac-sidebar-offset, 0px)",
@@ -1672,7 +1451,7 @@ export default function CreateVideo() {
isSubmitting={isGenerating}
credits={estimatedCredits}
maxPromptLength={maxPromptLength}
placeholder={promptPlaceholder}
placeholder="Describe the video you want to generate..."
supportsImagePrompts={supportsImagePrompts}
maxImagePromptCount={
isReferenceMode ? (selectedModel?.image_references_max ?? 3) : 1
@@ -1708,9 +1487,7 @@ export default function CreateVideo() {
richList
triggerIcon={
<img
src={getCreatorIconPathForModelId(
selectedModel?.model ?? "",
)}
src={getCreatorIconPathForModelId(selectedModel?.model ?? "")}
alt=""
className="h-4 w-4 icon-auto-contrast"
/>
@@ -1727,28 +1504,7 @@ export default function CreateVideo() {
})
}
mentionItems={mentionItems.length > 0 ? mentionItems : undefined}
videoRefsSupported={supportsVideoRefs}
referenceVideos={referenceVideos}
onReferenceVideosChange={setReferenceVideos}
maxVideoCount={maxVideoRefs}
maxVideoRefDuration={maxVideoRefDuration}
onPickVideoFromLibrary={
supportsVideoRefs
? () => setIsVideoRefPickerOpen(true)
: undefined
}
audioRefsSupported={supportsAudioRefs}
referenceAudios={referenceAudios}
onReferenceAudiosChange={setReferenceAudios}
maxAudioCount={selectedModel?.audio_references_max ?? 2}
maxAudioRefDuration={
selectedModel?.audio_references_max_total_duration_seconds ?? 30
}
onPickAudioFromLibrary={
supportsAudioRefs
? () => setIsAudioRefPickerOpen(true)
: undefined
}
mediaReferenceRow={mediaReferenceRow}
rightToolbar={
<GenerationCountPicker
batchSizeMax={selectedModel?.batch_size_max ?? 4}
@@ -1937,17 +1693,6 @@ export default function CreateVideo() {
forceFilter="video"
hideFilter
/>
<GalleryModal
mode="select"
isOpen={isAudioRefPickerOpen}
onClose={() => setIsAudioRefPickerOpen(false)}
selectedItemIds={audioRefPickerSelectedIds}
onSelectItem={handleAudioRefPickerSelect}
maxSelections={audioRefPickerMax}
onUseSelected={handleLibraryAudioSelect}
forceFilter="audio"
hideFilter
/>
<CharactersModal
isOpen={isCharactersModalOpen}
onClose={() => setIsCharactersModalOpen(false)}
@@ -1,4 +1,4 @@
import { HttpApiError, JobsApi, OmniGenApi } from "@storyteller/api";
import { JobsApi, OmniGenApi } from "@storyteller/api";
import type { OmniGenVideoRequest } from "@storyteller/api";
import type { GeneratedVideo } from "./create-video-store";
@@ -67,24 +67,23 @@ export async function enqueueVideoGeneration(
}
return { success: false, error: "Generation failed" };
} catch (err: any) {
// ApiManager throws HttpApiError on non-2xx responses, carrying the
// server's `message` (e.g. "Image is required") so it can be shown to
// the user instead of a bare status code.
if (err instanceof HttpApiError) {
return {
success: false,
error: err.serverMessage ?? err.message,
errorCode: err.status,
};
}
return {
success: false,
error: err.message ?? "Request failed",
errorCode: undefined,
errorCode: parseHttpStatusCode(err),
};
}
}
// Pull the HTTP status out of an ApiManager error. ApiManager throws
// `Error("HTTP error! status: 402")` on a non-2xx response (the JSON body is
// not surfaced), so callers can special-case codes like 402 Payment Required.
function parseHttpStatusCode(err: unknown): number | undefined {
const message = err instanceof Error ? err.message : String(err);
const match = /status:\s*(\d+)/.exec(message);
return match ? Number(match[1]) : undefined;
}
// ── Poll for completion ──────────────────────────────────────────────────
export async function pollVideoJobResult(
@@ -6,15 +6,11 @@ import {
faXmark,
faVideo,
} from "@fortawesome/pro-solid-svg-icons";
import { UploaderStates } from "@storyteller/common";
import {
uploadVideo,
getVideoDuration,
} from "../../../components/prompt-box/upload-media";
import type { RefVideo } from "../../../components/prompt-box";
import { MediaUploadApi, EIntermediateFile } from "@storyteller/api";
import type { RefVideoAsset } from "../create-world-store";
// A single optional reference video for splat generation (mobile form band;
// the desktop prompt box renders the same ref in its reference deck).
// A single optional reference video for splat generation. Uploads via the
// generic new-video endpoint and stores the returned media token.
const SLOT_CLASS =
"flex aspect-square w-14 items-center justify-center overflow-hidden rounded-lg border-2 border-dashed border-white/25 bg-white/5 transition-all hover:border-white/40 hover:bg-white/10";
@@ -23,8 +19,8 @@ export function ReferenceVideoSlot({
video,
onChange,
}: {
video?: RefVideo;
onChange: (video?: RefVideo) => void;
video?: RefVideoAsset;
onChange: (video?: RefVideoAsset) => void;
}) {
const inputRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
@@ -34,41 +30,41 @@ export function ReferenceVideoSlot({
if (!file) return;
setUploading(true);
const previewUrl = URL.createObjectURL(file);
const duration = await getVideoDuration(file);
await uploadVideo({
title: "splat_reference",
assetFile: file,
progressCallback: (state) => {
if (state.status === UploaderStates.success && state.data) {
onChange({
id: Math.random().toString(36).substring(7),
url: previewUrl,
file,
mediaToken: state.data,
duration,
});
setUploading(false);
} else if (
state.status === UploaderStates.assetError ||
state.status === UploaderStates.imageCreateError
) {
URL.revokeObjectURL(previewUrl);
setUploading(false);
}
},
});
if (inputRef.current) inputRef.current.value = "";
try {
const api = new MediaUploadApi();
const response = await api.UploadNewVideo({
blob: file,
fileName: file.name || `reference-video-${Date.now()}`,
uuid: crypto.randomUUID(),
maybe_title: "splat_reference_video",
is_intermediate_system_file: EIntermediateFile.false,
});
if (response?.success && response.data) {
onChange({
id: Math.random().toString(36).substring(7),
previewUrl,
mediaToken: response.data,
});
} else {
URL.revokeObjectURL(previewUrl);
}
} catch {
URL.revokeObjectURL(previewUrl);
} finally {
setUploading(false);
if (inputRef.current) inputRef.current.value = "";
}
};
const handleRemove = () => {
if (video?.url.startsWith("blob:")) URL.revokeObjectURL(video.url);
if (video?.previewUrl) URL.revokeObjectURL(video.previewUrl);
onChange(undefined);
};
return (
// Title on the left, upload slot right-aligned + top-aligned (matches
// ImagePromptRow).
<div className="glass flex items-start gap-3 rounded-2xl px-3 py-2">
// ImagePromptRow). Flat bottom on desktop so it seams into the prompt box.
<div className="glass flex items-start gap-3 rounded-2xl px-3 py-2 sm:rounded-t-2xl sm:rounded-b-none">
<div className="flex grow flex-col gap-1 min-w-32">
<div className="flex items-center gap-2 text-white/90">
<FontAwesomeIcon icon={faVideo} className="h-3.5 w-3.5" />
@@ -86,7 +82,7 @@ export function ReferenceVideoSlot({
{video ? (
<div className="group relative aspect-square w-14 overflow-hidden rounded-lg border-2 border-white/30">
<video
src={video.url}
src={video.previewUrl}
muted
preload="metadata"
className="h-full w-full object-cover"
@@ -1,7 +1,7 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import type { RecreatePayload } from "../../lib/recreate";
import type { RefImage, RefVideo } from "../../components/prompt-box";
import type { RefImage } from "../../components/prompt-box";
// A generated splat (3D world). `cdn_url` here is the .spz file.
export interface GeneratedAsset {
@@ -28,17 +28,23 @@ export type SplatUiState = {
disableRecaption: boolean | undefined;
};
// A stored reference video: media token + a local object-URL for preview. Not
// persisted (File handles / blob URLs aren't serializable).
export type RefVideoAsset = {
id: string;
previewUrl: string;
mediaToken: string;
};
type CreateWorldState = {
batches: SplatBatch[];
ui: SplatUiState;
referenceImages: RefImage[];
// At most one guide video. Not persisted (File handles / blob URLs aren't
// serializable).
referenceVideos: RefVideo[];
referenceVideo: RefVideoAsset | undefined;
pendingRecreate: RecreatePayload | null;
setUi: (patch: Partial<SplatUiState>) => void;
setReferenceImages: (images: RefImage[]) => void;
setReferenceVideos: (videos: RefVideo[]) => void;
setReferenceVideo: (video: RefVideoAsset | undefined) => void;
setPendingRecreate: (payload: RecreatePayload | null) => void;
consumePendingRecreate: () => RecreatePayload | null;
startBatch: (prompt: string, modelLabel: string) => string;
@@ -62,14 +68,14 @@ export const useCreateWorldStore = create<CreateWorldState>()(
batches: [],
ui: { ...DEFAULT_UI },
referenceImages: [],
referenceVideos: [],
referenceVideo: undefined,
pendingRecreate: null,
setUi: (patch) => set((s) => ({ ui: { ...s.ui, ...patch } })),
setReferenceImages: (images) => set({ referenceImages: images }),
setReferenceVideos: (videos) => set({ referenceVideos: videos }),
setReferenceVideo: (video) => set({ referenceVideo: video }),
setPendingRecreate: (payload) => set({ pendingRecreate: payload }),
@@ -113,8 +113,8 @@ export default function CreateWorld() {
const [isGenerating, setIsGenerating] = useState(false);
const referenceImages = useCreateWorldStore((s) => s.referenceImages);
const setReferenceImages = useCreateWorldStore((s) => s.setReferenceImages);
const referenceVideos = useCreateWorldStore((s) => s.referenceVideos);
const setReferenceVideos = useCreateWorldStore((s) => s.setReferenceVideos);
const referenceVideo = useCreateWorldStore((s) => s.referenceVideo);
const setReferenceVideo = useCreateWorldStore((s) => s.setReferenceVideo);
const [isImagePickerOpen, setIsImagePickerOpen] = useState(false);
const [pickerSelectedIds, setPickerSelectedIds] = useState<string[]>([]);
@@ -202,7 +202,7 @@ export default function CreateWorld() {
const estimatedCredits = useSplatCostEstimate({
model: selectedModel?.model ?? "",
referenceImageCount: referenceImages.length,
hasReferenceVideo: referenceVideos.length > 0,
hasReferenceVideo: !!referenceVideo,
isPanoramic: supportsPanorama ? isPanoramic : undefined,
disableRecaption: supportsDisableRecaption ? disableRecaption : undefined,
});
@@ -222,9 +222,7 @@ export default function CreateWorld() {
const canGenerate =
!!selectedModel &&
!isGenerating &&
(prompt.trim().length > 0 ||
referenceImages.length > 0 ||
referenceVideos.length > 0);
(prompt.trim().length > 0 || referenceImages.length > 0 || !!referenceVideo);
// ── Effects ──────────────────────────────────────────────────────────────
@@ -314,12 +312,10 @@ export default function CreateWorld() {
? referenceImageMediaTokens
: undefined,
referenceVideoMediaToken: supportsVideo
? referenceVideos[0]?.mediaToken
? referenceVideo?.mediaToken
: undefined,
isPanoramic: supportsPanorama ? isPanoramic : undefined,
disableRecaption: supportsDisableRecaption
? disableRecaption
: undefined,
disableRecaption: supportsDisableRecaption ? disableRecaption : undefined,
});
if (!result.success || !result.jobToken) {
@@ -372,7 +368,7 @@ export default function CreateWorld() {
supportsPanorama,
supportsDisableRecaption,
referenceImages,
referenceVideos,
referenceVideo,
isPanoramic,
disableRecaption,
startBatch,
@@ -398,11 +394,7 @@ export default function CreateWorld() {
</Tooltip>
)}
{supportsDisableRecaption && (
<Tooltip
content="Use prompt exactly (skip recaption)"
position="top"
closeOnClick
>
<Tooltip content="Use prompt exactly (skip recaption)" position="top" closeOnClick>
<ToggleButton
isActive={disableRecaption}
icon={faWandMagicSparkles}
@@ -415,12 +407,8 @@ export default function CreateWorld() {
</>
);
// Mobile-only band; on desktop the guide video lives in the reference deck.
const videoRow = supportsVideo ? (
<ReferenceVideoSlot
video={referenceVideos[0]}
onChange={(video) => setReferenceVideos(video ? [video] : [])}
/>
<ReferenceVideoSlot video={referenceVideo} onChange={setReferenceVideo} />
) : undefined;
// ── Mobile form ─────────────────────────────────────────────────────────
@@ -500,7 +488,7 @@ export default function CreateWorld() {
promptBox={
<div
ref={promptBoxRef}
className="animate-fade-in-up fixed bottom-2 sm:bottom-3 right-0 z-30 mx-auto max-w-6xl px-2 sm:px-4 transition-[left] duration-200 ease-linear"
className="animate-fade-in-up fixed bottom-2 sm:bottom-3 right-0 z-30 mx-auto max-w-5xl px-2 sm:px-4 transition-[left] duration-200 ease-linear"
style={{
animationDelay: "150ms",
left: "var(--ac-sidebar-offset, 0px)",
@@ -519,18 +507,9 @@ export default function CreateWorld() {
referenceImages={referenceImages}
onReferenceImagesChange={setReferenceImages}
onPickFromLibrary={() => setIsImagePickerOpen(true)}
videoRefsSupported={supportsVideo}
referenceVideos={referenceVideos}
onReferenceVideosChange={setReferenceVideos}
maxVideoCount={1}
maxVideoRefDuration={Infinity}
mediaReferenceRow={videoRow}
modelSelector={
<Tooltip
content="Model"
position="top"
className="z-50"
closeOnClick
>
<Tooltip content="Model" position="top" className="z-50" closeOnClick>
<PopoverMenu
items={modelItems}
onSelect={handleModelChange}
@@ -540,9 +519,7 @@ export default function CreateWorld() {
richList
triggerIcon={
<img
src={getCreatorIconPathForModelId(
selectedModel?.model ?? "",
)}
src={getCreatorIconPathForModelId(selectedModel?.model ?? "")}
alt=""
className="h-4 w-4 icon-auto-contrast"
/>
@@ -1,197 +0,0 @@
import { useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faCamera,
faChevronDown,
faLayerGroup,
faXmark,
} from "@fortawesome/pro-solid-svg-icons";
import { Button } from "@storyteller/ui-button";
import { EASE_EMPHASIS } from "../../lib/motion";
import { formatTime, formatTimePrecise } from "./lib/frame-actions";
interface ExtractionPanelProps {
currentTime: number;
duration: number;
resolution: { w: number; h: number } | null;
numFrames: number;
spacingMs: number;
onNumFramesChange: (value: number) => void;
onSpacingChange: (value: number) => void;
isExtracting: boolean;
progress: { done: number; total: number } | null;
onCaptureCurrent: () => void;
onExtractBurst: () => void;
onCancel: () => void;
disabled?: boolean;
}
export const ExtractionPanel = ({
currentTime,
duration,
resolution,
numFrames,
spacingMs,
onNumFramesChange,
onSpacingChange,
isExtracting,
progress,
onCaptureCurrent,
onExtractBurst,
onCancel,
disabled,
}: ExtractionPanelProps) => {
const [burstOpen, setBurstOpen] = useState(false);
// Burst timing math needs a real duration; live/streamed sources report
// Infinity and capture-current is the only mode that still makes sense.
const burstAvailable = Number.isFinite(duration) && duration > 0;
return (
<div className="flex h-fit flex-col divide-y divide-ui-divider rounded-xl border border-ui-panel-border bg-ui-panel">
<div className="space-y-3 p-4">
<div className="flex items-center justify-between text-[11px] font-semibold uppercase tracking-wider text-base-fg/50">
<span>Playhead</span>
<span className="font-mono text-xs normal-case tracking-normal text-base-fg/80">
{disabled ? "--:--.---" : formatTimePrecise(currentTime)}
</span>
</div>
<Button
variant="primary"
icon={faCamera}
onClick={onCaptureCurrent}
disabled={disabled || isExtracting}
className="w-full py-2 text-sm font-medium"
>
Capture frame
</Button>
<div className="text-center text-[11px] text-base-fg/40">
{disabled ? "Load a video to start" : "Press C or Enter to capture"}
</div>
</div>
<div>
<button
onClick={() => setBurstOpen((open) => !open)}
disabled={disabled || !burstAvailable}
className="flex w-full items-center justify-between px-4 py-3 text-sm font-medium text-base-fg/75 transition-colors hover:text-base-fg disabled:cursor-default disabled:opacity-40"
>
<span className="flex items-center gap-2">
<FontAwesomeIcon
icon={faLayerGroup}
className="text-xs text-base-fg/40"
/>
Burst capture
</span>
<FontAwesomeIcon
icon={faChevronDown}
className={`text-xs text-base-fg/40 transition-transform ${burstOpen ? "rotate-180" : ""}`}
/>
</button>
<AnimatePresence initial={false}>
{burstOpen && !disabled && burstAvailable && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.25, ease: EASE_EMPHASIS }}
className="overflow-hidden"
>
<div className="space-y-3 px-4 pb-4">
<p className="text-xs leading-relaxed text-base-fg/50">
Captures a sequence of frames from the playhead — useful for
finding the sharpest one.
</p>
<div className="grid grid-cols-2 gap-2">
<label className="block">
<span className="mb-1 block text-[11px] font-medium text-base-fg/55">
Frames
</span>
<input
type="number"
min={1}
max={50}
value={numFrames}
disabled={isExtracting}
onChange={(e) =>
onNumFramesChange(
Math.max(1, Math.min(50, parseInt(e.target.value) || 1)),
)
}
className="w-full rounded-md border border-ui-controls-border bg-ui-controls px-2.5 py-1.5 text-sm text-base-fg focus:border-primary focus:outline-none"
/>
</label>
<label className="block">
<span className="mb-1 block text-[11px] font-medium text-base-fg/55">
Spacing (ms)
</span>
<input
type="number"
min={1}
max={10000}
value={spacingMs}
disabled={isExtracting}
onChange={(e) =>
onSpacingChange(
Math.max(1, Math.min(10000, parseInt(e.target.value) || 1)),
)
}
className="w-full rounded-md border border-ui-controls-border bg-ui-controls px-2.5 py-1.5 text-sm text-base-fg focus:border-primary focus:outline-none"
/>
</label>
</div>
{isExtracting && progress ? (
<div className="flex items-center gap-2">
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-white/10">
<div
className="h-full rounded-full bg-primary transition-all"
style={{
width: `${(progress.done / progress.total) * 100}%`,
}}
/>
</div>
<span className="font-mono text-[11px] text-base-fg/60">
{progress.done}/{progress.total}
</span>
<button
onClick={onCancel}
className="flex h-6 w-6 items-center justify-center rounded-md text-base-fg/50 transition-colors hover:bg-white/10 hover:text-base-fg"
aria-label="Cancel extraction"
>
<FontAwesomeIcon icon={faXmark} className="text-xs" />
</button>
</div>
) : (
<Button
variant="action"
onClick={onExtractBurst}
disabled={isExtracting}
className="w-full py-1.5 text-sm font-medium"
>
Extract {numFrames} {numFrames === 1 ? "frame" : "frames"}
</Button>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
<div className="space-y-1.5 p-4 text-xs text-base-fg/45">
<div className="flex items-center justify-between">
<span>Duration</span>
<span className="font-mono text-base-fg/70">
{!disabled && Number.isFinite(duration) ? formatTime(duration) : "—"}
</span>
</div>
<div className="flex items-center justify-between">
<span>Resolution</span>
<span className="font-mono text-base-fg/70">
{!disabled && resolution ? `${resolution.w} × ${resolution.h}` : "—"}
</span>
</div>
</div>
</div>
);
};
@@ -1,529 +0,0 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { faArrowRotateRight } from "@fortawesome/pro-solid-svg-icons";
import { Button } from "@storyteller/ui-button";
import { GalleryItem, GalleryModal } from "@storyteller/ui-gallery-modal";
import { MediaFilesApi } from "@storyteller/api";
import { addCorsParam } from "@storyteller/common";
import Seo from "../../components/seo";
import { toast } from "../../components/toast/toast";
import { useSignupCta } from "../../components/signup-cta-modal";
import { isVideoUrl } from "../../components/lightbox/shared";
import { VideoDropZone } from "./video-drop-zone";
import { VideoScrubber } from "./video-scrubber";
import { ExtractionPanel } from "./extraction-panel";
import { FramesGrid, FrameActionState } from "./frames-grid";
import {
captureFrameAt,
extractFrames,
FrameExtractionError,
type ExtractedFrame,
} from "./lib/extract-frames";
import {
downloadFrame,
sendFrameToCreate,
uploadFrame,
} from "./lib/frame-actions";
type VideoSource =
| { kind: "local"; url: string }
| { kind: "library"; url: string; mediaToken: string };
// Tokens minted per frame: a visible library save and a hidden reference
// upload are different files server-side, but a library save also works as a
// reference, so `library` is preferred when both could apply.
interface FrameTokens {
library?: string;
intermediate?: string;
}
export default function FrameExtractor() {
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const { loggedIn, openSignupCta } = useSignupCta();
const [source, setSource] = useState<VideoSource | null>(null);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [resolution, setResolution] = useState<{ w: number; h: number } | null>(
null,
);
const [numFrames, setNumFrames] = useState(10);
const [spacingMs, setSpacingMs] = useState(100);
const [frames, setFrames] = useState<ExtractedFrame[]>([]);
const [actionState, setActionState] = useState<
Record<string, FrameActionState>
>({});
const [isExtracting, setIsExtracting] = useState(false);
const [isSavingAll, setIsSavingAll] = useState(false);
const [progress, setProgress] = useState<{
done: number;
total: number;
} | null>(null);
const [isGalleryOpen, setIsGalleryOpen] = useState(false);
const [gallerySelection, setGallerySelection] = useState<string[]>([]);
const videoRef = useRef<HTMLVideoElement>(null);
const abortRef = useRef<AbortController | null>(null);
const frameTokensRef = useRef<Map<string, FrameTokens>>(new Map());
// Frames handed off as prompt references keep their object URL alive — the
// reference deck on the create page renders it.
const handedOffUrlsRef = useRef<Set<string>>(new Set());
// ── Source loading ─────────────────────────────────────────────────────────
const resetForNewSource = useCallback(() => {
abortRef.current?.abort();
setFrames((prev) => {
prev.forEach((frame) => {
if (!handedOffUrlsRef.current.has(frame.objectUrl)) {
URL.revokeObjectURL(frame.objectUrl);
}
});
return [];
});
setActionState({});
frameTokensRef.current.clear();
setCurrentTime(0);
setDuration(0);
setResolution(null);
setProgress(null);
setIsExtracting(false);
}, []);
const setLocalSource = useCallback(
(files: FileList) => {
const file = files[0];
if (!file || !file.type.startsWith("video/")) {
toast.error("Please choose a video file");
return;
}
resetForNewSource();
setSource((prev) => {
if (prev?.kind === "local") URL.revokeObjectURL(prev.url);
return { kind: "local", url: URL.createObjectURL(file) };
});
},
[resetForNewSource],
);
const setLibrarySource = useCallback(
(url: string, mediaToken: string) => {
resetForNewSource();
setSource((prev) => {
if (prev?.kind === "local") URL.revokeObjectURL(prev.url);
return { kind: "library", url: addCorsParam(url) || url, mediaToken };
});
},
[resetForNewSource],
);
const clearSource = useCallback(() => {
resetForNewSource();
setSource((prev) => {
if (prev?.kind === "local") URL.revokeObjectURL(prev.url);
return null;
});
}, [resetForNewSource]);
// Deep link from the library: /frame-extractor?media=<token>. Consumed once
// so switching videos later doesn't resurrect the param's source.
useEffect(() => {
const token = searchParams.get("media");
if (!token) return;
setSearchParams({}, { replace: true });
(async () => {
try {
const response = await new MediaFilesApi().GetMediaFileByToken({
mediaFileToken: token,
});
const file = response.success ? response.data : null;
const url = file?.media_links?.cdn_url;
// MediaFileClass isn't exported from the @storyteller/api barrel;
// compare the string value (same approach as the video editor's
// media-source-adapter). isVideoUrl covers legacy "unknown" rows.
const isVideo =
(file?.media_class as string | null) === "video" ||
(!!url && isVideoUrl(url));
if (!url || !isVideo) {
toast.error("That media item isn't a video");
return;
}
setLibrarySource(url, token);
} catch {
toast.error("Failed to load media");
}
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Final cleanup: abort any in-flight burst and release blob URLs that no
// other page is displaying.
useEffect(() => {
return () => {
abortRef.current?.abort();
setFrames((prev) => {
prev.forEach((frame) => {
if (!handedOffUrlsRef.current.has(frame.objectUrl)) {
URL.revokeObjectURL(frame.objectUrl);
}
});
return prev;
});
setSource((prev) => {
if (prev?.kind === "local") URL.revokeObjectURL(prev.url);
return prev;
});
};
}, []);
// ── Extraction ─────────────────────────────────────────────────────────────
const reportExtractionError = useCallback(
(error: unknown) => {
if (!(error instanceof FrameExtractionError)) {
toast.error("Failed to capture frame");
return;
}
switch (error.kind) {
case "aborted":
break;
case "cors":
toast.error(
"This video can't be captured due to cross-origin protection. Download it and upload the file directly.",
);
break;
case "timeout":
toast.error("The video didn't finish loading — try again.");
break;
default:
toast.error("Couldn't read this video. Try an MP4 (H.264) file.");
}
},
[],
);
const handleCaptureCurrent = useCallback(async () => {
const video = videoRef.current;
if (!video || isExtracting) return;
setIsExtracting(true);
try {
const frame = await captureFrameAt(video, video.currentTime);
setFrames((prev) => [frame, ...prev]);
} catch (error) {
reportExtractionError(error);
} finally {
setIsExtracting(false);
}
}, [isExtracting, reportExtractionError]);
const handleExtractBurst = useCallback(async () => {
const video = videoRef.current;
if (!video || isExtracting) return;
const abort = new AbortController();
abortRef.current = abort;
setIsExtracting(true);
setProgress({ done: 0, total: numFrames });
try {
const extracted = await extractFrames(video, {
startSec: video.currentTime,
count: numFrames,
spacingMs,
signal: abort.signal,
onProgress: (done, total) => setProgress({ done, total }),
});
if (extracted.length === 0) {
toast.error("No frames could be captured before the video ended");
} else {
setFrames((prev) => [...extracted.reverse(), ...prev]);
}
} catch (error) {
reportExtractionError(error);
} finally {
setIsExtracting(false);
setProgress(null);
abortRef.current = null;
}
}, [isExtracting, numFrames, spacingMs, reportExtractionError]);
const handleCancelBurst = useCallback(() => {
abortRef.current?.abort();
}, []);
// ── Frame actions ──────────────────────────────────────────────────────────
const patchFrameState = useCallback(
(frameId: string, patch: FrameActionState) => {
setActionState((prev) => ({
...prev,
[frameId]: { ...prev[frameId], ...patch },
}));
},
[],
);
// A frame saved to the library is also a valid reference; a hidden
// reference upload never doubles as a library save.
const tokenForReference = useCallback(
async (frame: ExtractedFrame): Promise<string | null> => {
const cached = frameTokensRef.current.get(frame.id);
if (cached?.library || cached?.intermediate) {
return cached.library ?? cached.intermediate ?? null;
}
const result = await uploadFrame(frame, { intermediate: true });
if (!result.success) {
toast.error(result.error);
return null;
}
frameTokensRef.current.set(frame.id, {
...cached,
intermediate: result.mediaToken,
});
return result.mediaToken;
},
[],
);
const handleSendToCreate = useCallback(
async (frame: ExtractedFrame, destination: "image" | "video") => {
if (!loggedIn) {
openSignupCta();
return;
}
patchFrameState(frame.id, { sending: true });
const token = await tokenForReference(frame);
patchFrameState(frame.id, { sending: false });
if (!token) return;
handedOffUrlsRef.current.add(frame.objectUrl);
sendFrameToCreate(frame, token, destination, navigate);
},
[loggedIn, openSignupCta, patchFrameState, tokenForReference, navigate],
);
const saveFrameToLibrary = useCallback(
async (frame: ExtractedFrame): Promise<boolean> => {
const cached = frameTokensRef.current.get(frame.id);
if (cached?.library) return true;
const result = await uploadFrame(frame, { intermediate: false });
if (!result.success) return false;
frameTokensRef.current.set(frame.id, {
...cached,
library: result.mediaToken,
});
return true;
},
[],
);
const handleSave = useCallback(
async (frame: ExtractedFrame) => {
if (!loggedIn) {
openSignupCta();
return;
}
const state = actionState[frame.id];
if (state?.saved || state?.saving) return;
patchFrameState(frame.id, { saving: true });
const saved = await saveFrameToLibrary(frame);
patchFrameState(frame.id, { saving: false, saved });
if (saved) {
toast.success("Frame saved to library");
} else {
toast.error("Failed to save frame");
}
},
[loggedIn, openSignupCta, actionState, patchFrameState, saveFrameToLibrary],
);
const handleSaveAll = useCallback(async () => {
if (!loggedIn) {
openSignupCta();
return;
}
if (isSavingAll) return;
setIsSavingAll(true);
let failures = 0;
// Sequential on purpose — parallel uploads of dozens of PNGs invite rate
// limiting, and per-frame progress reads better anyway.
for (const frame of frames) {
if (actionState[frame.id]?.saved) continue;
patchFrameState(frame.id, { saving: true });
const saved = await saveFrameToLibrary(frame);
patchFrameState(frame.id, { saving: false, saved });
if (!saved) failures++;
}
setIsSavingAll(false);
if (failures === 0) {
toast.success("All frames saved to library");
} else {
toast.error(`Failed to save ${failures} ${failures === 1 ? "frame" : "frames"}`);
}
}, [
loggedIn,
openSignupCta,
isSavingAll,
frames,
actionState,
patchFrameState,
saveFrameToLibrary,
]);
const handleRemove = useCallback((frame: ExtractedFrame) => {
setFrames((prev) => prev.filter((f) => f.id !== frame.id));
if (!handedOffUrlsRef.current.has(frame.objectUrl)) {
URL.revokeObjectURL(frame.objectUrl);
}
frameTokensRef.current.delete(frame.id);
setActionState((prev) => {
const next = { ...prev };
delete next[frame.id];
return next;
});
}, []);
const handleClear = useCallback(() => {
setFrames((prev) => {
prev.forEach((frame) => {
if (!handedOffUrlsRef.current.has(frame.objectUrl)) {
URL.revokeObjectURL(frame.objectUrl);
}
});
return [];
});
frameTokensRef.current.clear();
setActionState({});
}, []);
// ── Library picker ─────────────────────────────────────────────────────────
const handleOpenGallery = useCallback(() => {
if (!loggedIn) {
openSignupCta();
return;
}
setIsGalleryOpen(true);
}, [loggedIn, openSignupCta]);
const handleGallerySelect = useCallback(
(id: string) => {
setGallerySelection((prev) => (prev.includes(id) ? [] : [id]));
},
[],
);
const handleGalleryUse = useCallback(
(selectedItems: GalleryItem[]) => {
const item = selectedItems[0];
if (!item?.fullImage) {
toast.error("No video selected");
return;
}
setLibrarySource(item.fullImage, item.id);
setIsGalleryOpen(false);
setGallerySelection([]);
},
[setLibrarySource],
);
const handleVideoError = useCallback(() => {
toast.error("Your browser can't play this video. Try an MP4 (H.264) file.");
clearSource();
}, [clearSource]);
// ── Render ─────────────────────────────────────────────────────────────────
return (
<div className="relative min-h-full w-full shrink-0 bg-[#101014] px-3 pb-8 sm:px-4 md:px-8 lg:px-12">
<Seo
title="Video Frame Extractor - ArtCraft"
description="Grab a still frame from any video — capture, save to your library, or use it as an image prompt reference."
/>
<div className="mx-auto max-w-[1200px] pt-6">
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<h1 className="text-lg font-semibold text-base-fg">
Frame Extractor
</h1>
{source && (
<Button
variant="action"
icon={faArrowRotateRight}
onClick={clearSource}
className="px-3 py-1.5 text-sm"
>
Switch Video
</Button>
)}
</div>
<div className="grid gap-4 lg:grid-cols-[1fr_340px]">
{source ? (
<VideoScrubber
src={source.url}
useCrossOrigin={source.kind === "library"}
videoRef={videoRef}
onTimeChange={setCurrentTime}
onDurationChange={setDuration}
onResolutionChange={setResolution}
onCaptureRequest={handleCaptureCurrent}
onVideoError={handleVideoError}
disabled={isExtracting}
/>
) : (
<VideoDropZone
onFilesSelected={setLocalSource}
onPickFromLibrary={handleOpenGallery}
/>
)}
<ExtractionPanel
currentTime={currentTime}
duration={duration}
resolution={resolution}
numFrames={numFrames}
spacingMs={spacingMs}
onNumFramesChange={setNumFrames}
onSpacingChange={setSpacingMs}
isExtracting={isExtracting}
progress={progress}
onCaptureCurrent={handleCaptureCurrent}
onExtractBurst={handleExtractBurst}
onCancel={handleCancelBurst}
disabled={!source}
/>
</div>
<FramesGrid
frames={frames}
actionState={actionState}
onUseAsImageRef={(frame) => handleSendToCreate(frame, "image")}
onUseForVideo={(frame) => handleSendToCreate(frame, "video")}
onSave={handleSave}
onDownload={downloadFrame}
onRemove={handleRemove}
onSaveAll={handleSaveAll}
isSavingAll={isSavingAll}
onClear={handleClear}
/>
</div>
</div>
<GalleryModal
mode="select"
isOpen={isGalleryOpen}
onClose={() => {
setIsGalleryOpen(false);
setGallerySelection([]);
}}
selectedItemIds={gallerySelection}
onSelectItem={handleGallerySelect}
maxSelections={1}
onUseSelected={handleGalleryUse}
forceFilter="video"
hideFilter
/>
</div>
);
}
@@ -1,188 +0,0 @@
import { AnimatePresence, motion } from "framer-motion";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faArrowDownToLine,
faCheck,
faImage,
faSave,
faSpinnerThird,
faVideo,
faXmark,
} from "@fortawesome/pro-solid-svg-icons";
import type { IconDefinition } from "@fortawesome/fontawesome-svg-core";
import { Tooltip } from "@storyteller/ui-tooltip";
import { EASE_EMPHASIS } from "../../lib/motion";
import type { ExtractedFrame } from "./lib/extract-frames";
import { formatTimePrecise } from "./lib/frame-actions";
export interface FrameActionState {
saving?: boolean;
saved?: boolean;
sending?: boolean;
}
interface FramesGridProps {
frames: ExtractedFrame[];
actionState: Record<string, FrameActionState>;
onUseAsImageRef: (frame: ExtractedFrame) => void;
onUseForVideo: (frame: ExtractedFrame) => void;
onSave: (frame: ExtractedFrame) => void;
onDownload: (frame: ExtractedFrame) => void;
onRemove: (frame: ExtractedFrame) => void;
onSaveAll: () => void;
isSavingAll: boolean;
onClear: () => void;
}
export const FramesGrid = ({
frames,
actionState,
onUseAsImageRef,
onUseForVideo,
onSave,
onDownload,
onRemove,
onSaveAll,
isSavingAll,
onClear,
}: FramesGridProps) => {
if (frames.length === 0) return null;
const hasUnsaved = frames.some((frame) => !actionState[frame.id]?.saved);
return (
<div className="rounded-xl border border-ui-panel-border bg-ui-panel p-4">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<h3 className="text-sm font-semibold text-base-fg">
Captured frames
<span className="ml-2 font-normal text-base-fg/40">
{frames.length}
</span>
</h3>
<div className="flex items-center gap-1">
{hasUnsaved && (
<button
onClick={onSaveAll}
disabled={isSavingAll}
className="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs font-medium text-base-fg/70 transition-colors hover:bg-white/5 hover:text-base-fg disabled:opacity-50"
>
<FontAwesomeIcon
icon={isSavingAll ? faSpinnerThird : faSave}
className={isSavingAll ? "animate-spin" : ""}
/>
{isSavingAll ? "Saving…" : "Save all"}
</button>
)}
<button
onClick={onClear}
className="rounded-md px-2.5 py-1.5 text-xs font-medium text-base-fg/50 transition-colors hover:bg-white/5 hover:text-base-fg"
>
Clear
</button>
</div>
</div>
<div className="grid grid-cols-2 gap-2.5 sm:grid-cols-3 lg:grid-cols-4">
<AnimatePresence initial={false}>
{frames.map((frame) => {
const state = actionState[frame.id] ?? {};
return (
<motion.div
key={frame.id}
layout
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.18, ease: EASE_EMPHASIS }}
className="group/frame overflow-hidden rounded-lg border border-ui-panel-border bg-ui-background"
>
<div className="relative aspect-video bg-black">
<img
src={frame.objectUrl}
alt={`Frame at ${formatTimePrecise(frame.timestamp)}`}
className="h-full w-full object-contain"
/>
<button
onClick={() => onRemove(frame)}
className="absolute right-1.5 top-1.5 flex h-6 w-6 items-center justify-center rounded-md bg-black/60 text-white/60 opacity-0 backdrop-blur-sm transition-all hover:text-white group-hover/frame:opacity-100"
aria-label="Remove frame"
>
<FontAwesomeIcon icon={faXmark} className="text-xs" />
</button>
<span className="absolute bottom-1.5 left-1.5 rounded bg-black/60 px-1.5 py-0.5 font-mono text-[10px] text-white/85 backdrop-blur-sm">
{formatTimePrecise(frame.timestamp)}
</span>
</div>
<div className="flex items-center justify-around border-t border-ui-panel-border/60 p-1">
<FrameAction
icon={faImage}
tooltip="Use as image reference"
onClick={() => onUseAsImageRef(frame)}
loading={state.sending}
/>
<FrameAction
icon={faVideo}
tooltip="Animate into a video"
onClick={() => onUseForVideo(frame)}
loading={state.sending}
/>
<FrameAction
icon={state.saved ? faCheck : faSave}
tooltip={state.saved ? "Saved to library" : "Save to library"}
onClick={() => onSave(frame)}
loading={state.saving}
disabled={state.saved}
success={state.saved}
/>
<FrameAction
icon={faArrowDownToLine}
tooltip="Download PNG"
onClick={() => onDownload(frame)}
/>
</div>
</motion.div>
);
})}
</AnimatePresence>
</div>
</div>
);
};
interface FrameActionProps {
icon: IconDefinition;
tooltip: string;
onClick: () => void;
loading?: boolean;
disabled?: boolean;
success?: boolean;
}
const FrameAction = ({
icon,
tooltip,
onClick,
loading,
disabled,
success,
}: FrameActionProps) => (
// fixed strategy: the card is overflow-hidden (rounded corners), which
// would clip an absolutely-positioned tooltip.
<Tooltip content={tooltip} position="top" strategy="fixed" className="z-50">
<button
onClick={onClick}
disabled={loading || disabled}
aria-label={tooltip}
className={`flex h-7 w-8 items-center justify-center rounded-md text-xs transition-colors disabled:cursor-default ${
success
? "text-green"
: "text-base-fg/60 hover:bg-white/5 hover:text-base-fg"
}`}
>
<FontAwesomeIcon
icon={loading ? faSpinnerThird : icon}
className={loading ? "animate-spin" : ""}
/>
</button>
</Tooltip>
);
@@ -1 +0,0 @@
export { default } from "./frame-extractor";
@@ -1,239 +0,0 @@
// Client-side video frame extraction: seek the <video> element, wait for the
// frame to be presented, draw it to a canvas, and encode a PNG blob. No server
// round-trips — but the video must not taint the canvas (cross-origin sources
// need CORS headers + crossOrigin="anonymous" on the element).
export interface ExtractedFrame {
id: string;
blob: Blob;
// Preview URL for the blob. Owned by the caller: revoke when the frame is
// discarded (but not while a handed-off reference still displays it).
objectUrl: string;
timestamp: number;
width: number;
height: number;
}
export type FrameErrorKind = "cors" | "timeout" | "decode" | "aborted";
export class FrameExtractionError extends Error {
kind: FrameErrorKind;
constructor(kind: FrameErrorKind, message: string) {
super(message);
this.name = "FrameExtractionError";
this.kind = kind;
}
}
export interface ExtractFramesOptions {
startSec: number;
count: number;
spacingMs: number;
onProgress?: (done: number, total: number) => void;
signal?: AbortSignal;
}
const METADATA_TIMEOUT_MS = 5000;
const SEEK_TIMEOUT_MS = 3000;
// Best-effort wait for the seeked-to frame to be presented; bounded because a
// paused video that doesn't present a new frame never fires a video-frame
// callback at all.
const PAINT_WAIT_MS = 300;
// Seeking to (almost) the current position presents no new frame; skip the
// seek entirely rather than wait on events that may never fire.
const SEEK_EPSILON_SEC = 0.001;
// Capture a single frame at the given time. Seeks the video there and leaves
// it there (the natural behavior for "capture at playhead" — the playhead
// already is at timeSec, modulo clamping).
export async function captureFrameAt(
video: HTMLVideoElement,
timeSec: number,
): Promise<ExtractedFrame> {
await ensureVideoReady(video);
const canvas = createCanvasFor(video);
const target = clampToDuration(timeSec, video.duration);
// Seek even when the playhead already sits at the target: it's cheap, and it
// guarantees the presented frame matches currentTime (a paused video that
// was never seeked can still be showing the poster frame).
await seekTo(video, target);
return drawFrame(video, canvas, target);
}
// Burst capture: `count` frames starting at `startSec`, `spacingMs` apart.
// Restores the original playhead and play state when finished, cancelled, or
// failed. Throws FrameExtractionError; on "aborted" any frames captured so
// far are lost (callers treat cancel as a full stop).
export async function extractFrames(
video: HTMLVideoElement,
{ startSec, count, spacingMs, onProgress, signal }: ExtractFramesOptions,
): Promise<ExtractedFrame[]> {
await ensureVideoReady(video);
const originalTime = video.currentTime;
const wasPlaying = !video.paused;
if (wasPlaying) video.pause();
const canvas = createCanvasFor(video);
const frames: ExtractedFrame[] = [];
try {
for (let i = 0; i < count; i++) {
if (signal?.aborted) {
throw new FrameExtractionError("aborted", "Extraction cancelled");
}
const timestamp = startSec + (i * spacingMs) / 1000;
if (timestamp > video.duration) break;
const target = clampToDuration(timestamp, video.duration);
await seekTo(video, target);
frames.push(await drawFrame(video, canvas, target));
onProgress?.(frames.length, count);
}
return frames;
} catch (error) {
frames.forEach((frame) => URL.revokeObjectURL(frame.objectUrl));
throw error;
} finally {
video.currentTime = originalTime;
if (wasPlaying) video.play().catch(() => {});
}
}
// ── Helpers ────────────────────────────────────────────────────────────────
async function ensureVideoReady(video: HTMLVideoElement): Promise<void> {
if (video.readyState >= 2 && video.videoWidth && video.videoHeight) return;
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
cleanup();
reject(
new FrameExtractionError("timeout", "Video failed to load metadata"),
);
}, METADATA_TIMEOUT_MS);
const handleLoadedData = () => {
cleanup();
resolve();
};
const handleError = () => {
cleanup();
reject(new FrameExtractionError("decode", "Video failed to load"));
};
const cleanup = () => {
clearTimeout(timeout);
video.removeEventListener("loadeddata", handleLoadedData);
video.removeEventListener("error", handleError);
};
video.addEventListener("loadeddata", handleLoadedData);
video.addEventListener("error", handleError);
});
if (!video.videoWidth || !video.videoHeight) {
throw new FrameExtractionError("decode", "Video has no visible frames");
}
}
function createCanvasFor(video: HTMLVideoElement): HTMLCanvasElement {
const canvas = document.createElement("canvas");
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
return canvas;
}
// Clamp into [0, duration - 1ms]: seeking exactly to `duration` after `ended`
// can present a black frame. Non-finite durations (live/webm streams) pass
// through — the seek target is whatever the caller had.
function clampToDuration(timeSec: number, duration: number): number {
if (!Number.isFinite(duration)) return Math.max(0, timeSec);
return Math.max(0, Math.min(timeSec, Math.max(0, duration - 0.001)));
}
async function seekTo(video: HTMLVideoElement, timeSec: number): Promise<void> {
// Already there (e.g. the first burst frame starts at the playhead): the
// current frame is already presented and drawable.
if (Math.abs(video.currentTime - timeSec) < SEEK_EPSILON_SEC) return;
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
cleanup();
reject(new FrameExtractionError("timeout", "Video seek timed out"));
}, SEEK_TIMEOUT_MS);
const handleSeeked = () => {
cleanup();
resolve();
};
const cleanup = () => {
clearTimeout(timeout);
video.removeEventListener("seeked", handleSeeked);
};
video.addEventListener("seeked", handleSeeked, { once: true });
video.currentTime = timeSec;
});
// "seeked" can fire before the new frame is actually presented (notably
// Safari). Wait for a video-frame callback when available — but bounded,
// since a paused video presents no further frames once composition settles
// and the callback would otherwise never fire.
await new Promise<void>((resolve) => {
const timeout = setTimeout(resolve, PAINT_WAIT_MS);
const done = () => {
clearTimeout(timeout);
resolve();
};
if (typeof video.requestVideoFrameCallback === "function") {
video.requestVideoFrameCallback(() => done());
} else {
requestAnimationFrame(() => requestAnimationFrame(() => done()));
}
});
}
async function drawFrame(
video: HTMLVideoElement,
canvas: HTMLCanvasElement,
timestamp: number,
): Promise<ExtractedFrame> {
const ctx = canvas.getContext("2d");
if (!ctx) {
throw new FrameExtractionError("decode", "Canvas 2D context unavailable");
}
try {
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
} catch (error) {
if (error instanceof Error && error.name === "SecurityError") {
throw new FrameExtractionError(
"cors",
"Video is cross-origin protected",
);
}
throw new FrameExtractionError("decode", "Failed to draw video frame");
}
const blob = await new Promise<Blob>((resolve, reject) => {
canvas.toBlob((result) => {
if (result) {
resolve(result);
} else {
// toBlob also returns null on a tainted canvas.
reject(new FrameExtractionError("cors", "Failed to encode frame"));
}
}, "image/png");
});
return {
id: crypto.randomUUID(),
blob,
objectUrl: URL.createObjectURL(blob),
timestamp,
width: canvas.width,
height: canvas.height,
};
}
@@ -1,92 +0,0 @@
import type { NavigateFunction } from "react-router-dom";
import { MediaUploadApi, EIntermediateFile } from "@storyteller/api";
import type { RefImage } from "../../../components/prompt-box/types";
import { useCreateImageStore } from "../../create-image/create-image-store";
import { useCreateVideoStore } from "../../create-video/create-video-store";
import type { ExtractedFrame } from "./extract-frames";
export type UploadFrameResult =
| { success: true; mediaToken: string }
| { success: false; error: string };
// Upload a captured frame as a PNG. `intermediate: false` = a visible library
// save; `intermediate: true` = a hidden helper file backing a prompt reference.
export async function uploadFrame(
frame: ExtractedFrame,
{ intermediate }: { intermediate: boolean },
): Promise<UploadFrameResult> {
try {
const response = await new MediaUploadApi().UploadImage({
uuid: crypto.randomUUID(),
blob: frame.blob,
fileName: frameFileName(frame),
maybe_title: `video-frame-${frame.timestamp}`,
is_intermediate_system_file: intermediate
? EIntermediateFile.true
: EIntermediateFile.false,
});
if (response.success && response.data) {
return { success: true, mediaToken: response.data };
}
return {
success: false,
error: response.errorMessage || "Failed to upload frame",
};
} catch {
return { success: false, error: "Failed to upload frame" };
}
}
// Park the frame as a pending prompt reference and navigate to the create
// page, which merges it under the selected model's reference cap (same
// channel as the library's "Send to prompt").
export function sendFrameToCreate(
frame: ExtractedFrame,
mediaToken: string,
destination: "image" | "video",
navigate: NavigateFunction,
): void {
const ref: RefImage = {
id: crypto.randomUUID(),
url: frame.objectUrl,
file: new File([frame.blob], frameFileName(frame), { type: "image/png" }),
mediaToken,
};
if (destination === "image") {
useCreateImageStore.getState().setPendingRefImages([ref]);
navigate("/create-image");
} else {
useCreateVideoStore.getState().setPendingRefImages([ref]);
navigate("/create-video");
}
}
export function downloadFrame(frame: ExtractedFrame): void {
const anchor = document.createElement("a");
anchor.style.display = "none";
anchor.href = frame.objectUrl;
anchor.download = frameFileName(frame);
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
}
export function formatTime(seconds: number): string {
const safe = Number.isFinite(seconds) ? seconds : 0;
const mins = Math.floor(safe / 60);
const secs = Math.floor(safe % 60);
return `${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
}
export function formatTimePrecise(seconds: number): string {
const safe = Number.isFinite(seconds) ? seconds : 0;
const millis = Math.floor((safe % 1) * 1000);
return `${formatTime(safe)}.${millis.toString().padStart(3, "0")}`;
}
function frameFileName(frame: ExtractedFrame): string {
// Colons aren't filename-safe; mm-ss.mmm keeps the timestamp readable.
return `frame-${formatTimePrecise(frame.timestamp).replace(":", "-")}.png`;
}
@@ -1,85 +0,0 @@
import { useRef, useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faFilm, faImages, faUpload } from "@fortawesome/pro-solid-svg-icons";
import { Button } from "@storyteller/ui-button";
import { twMerge } from "tailwind-merge";
// Stand-in for the video player while no video is loaded: same card footprint
// as the scrubber, accepts click-to-browse, library pick, and drag & drop.
interface VideoDropZoneProps {
onFilesSelected: (files: FileList) => void;
onPickFromLibrary: () => void;
}
export const VideoDropZone = ({
onFilesSelected,
onPickFromLibrary,
}: VideoDropZoneProps) => {
const fileInputRef = useRef<HTMLInputElement>(null);
const [isDragActive, setIsDragActive] = useState(false);
const handleFiles = (files?: FileList | null) => {
if (!files || files.length === 0) return;
onFilesSelected(files);
if (fileInputRef.current) fileInputRef.current.value = "";
};
return (
<div className="overflow-hidden rounded-xl border border-ui-panel-border bg-ui-panel p-3">
<div
className={twMerge(
"flex aspect-video flex-col items-center justify-center gap-4 rounded-lg border border-dashed border-ui-border bg-ui-background/40 p-6 text-center transition-colors",
isDragActive && "border-primary/60 bg-primary/5",
)}
onDragEnter={(e) => {
e.preventDefault();
setIsDragActive(true);
}}
onDragOver={(e) => {
e.preventDefault();
setIsDragActive(true);
}}
onDragLeave={(e) => {
e.preventDefault();
if (e.currentTarget.contains(e.relatedTarget as Node)) return;
setIsDragActive(false);
}}
onDrop={(e) => {
e.preventDefault();
setIsDragActive(false);
handleFiles(e.dataTransfer?.files);
}}
>
<input
type="file"
ref={fileInputRef}
className="hidden"
accept="video/*"
onChange={(e) => handleFiles(e.target.files)}
/>
<FontAwesomeIcon icon={faFilm} className="text-2xl text-base-fg/25" />
<div className="text-sm text-base-fg/55">
Drag &amp; drop a video, or
</div>
<div className="flex flex-wrap justify-center gap-2">
<Button
variant="primary"
icon={faUpload}
onClick={() => fileInputRef.current?.click()}
className="px-4 py-1.5 text-sm font-medium"
>
Select video
</Button>
<Button
variant="action"
icon={faImages}
onClick={onPickFromLibrary}
className="px-4 py-1.5 text-sm font-medium"
>
From library
</Button>
</div>
</div>
</div>
);
};
@@ -1,365 +0,0 @@
import { useEffect, useRef, useState } from "react";
import type { RefObject } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faPlay,
faPause,
faVolumeHigh,
faVolumeMute,
faBackwardStep,
faForwardStep,
faChevronLeft,
faChevronRight,
} from "@fortawesome/pro-solid-svg-icons";
import { Tooltip } from "@storyteller/ui-tooltip";
import { formatTime, formatTimePrecise } from "./lib/frame-actions";
// Browsers don't expose the real frame rate, so stepping assumes 30fps —
// close enough for "nudge to the sharp frame" purposes.
const FRAME_STEP_SEC = 1 / 30;
interface VideoScrubberProps {
src: string;
// Set for remote (library) sources so canvas capture isn't tainted.
useCrossOrigin: boolean;
// Parent owns the element ref — extraction needs direct access to it.
videoRef: RefObject<HTMLVideoElement | null>;
onTimeChange: (time: number) => void;
onDurationChange: (duration: number) => void;
onResolutionChange: (resolution: { w: number; h: number }) => void;
onCaptureRequest: () => void;
onVideoError: () => void;
disabled?: boolean;
}
export const VideoScrubber = ({
src,
useCrossOrigin,
videoRef,
onTimeChange,
onDurationChange,
onResolutionChange,
onCaptureRequest,
onVideoError,
disabled,
}: VideoScrubberProps) => {
const [isPlaying, setIsPlaying] = useState(false);
const [isMuted, setIsMuted] = useState(false);
const [volume, setVolume] = useState(1);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [hoverRatio, setHoverRatio] = useState<number | null>(null);
const timelineRef = useRef<HTMLDivElement>(null);
const isScrubbingRef = useRef(false);
// Mirror media element state into React (play/pause/seek can come from
// anywhere: clicks, keyboard, extraction restoring the playhead).
useEffect(() => {
const video = videoRef.current;
if (!video) return;
const handleTimeUpdate = () => {
setCurrentTime(video.currentTime);
onTimeChange(video.currentTime);
};
const handleLoadedMetadata = () => {
setDuration(video.duration);
setCurrentTime(0);
onDurationChange(video.duration);
onTimeChange(0);
if (video.videoWidth && video.videoHeight) {
onResolutionChange({ w: video.videoWidth, h: video.videoHeight });
}
};
const handleEnded = () => setIsPlaying(false);
const handlePlay = () => setIsPlaying(true);
const handlePause = () => setIsPlaying(false);
const handleVolumeChange = () => {
setVolume(video.volume);
setIsMuted(video.muted);
};
video.addEventListener("timeupdate", handleTimeUpdate);
video.addEventListener("loadedmetadata", handleLoadedMetadata);
video.addEventListener("ended", handleEnded);
video.addEventListener("play", handlePlay);
video.addEventListener("pause", handlePause);
video.addEventListener("volumechange", handleVolumeChange);
return () => {
video.removeEventListener("timeupdate", handleTimeUpdate);
video.removeEventListener("loadedmetadata", handleLoadedMetadata);
video.removeEventListener("ended", handleEnded);
video.removeEventListener("play", handlePlay);
video.removeEventListener("pause", handlePause);
video.removeEventListener("volumechange", handleVolumeChange);
};
}, [videoRef, src, onTimeChange, onDurationChange, onResolutionChange]);
// Page-level shortcuts, active whenever a video is loaded. Window-scoped
// (not focus-scoped): clicking anywhere on the page must not kill them.
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (disabledRef.current || e.defaultPrevented) return;
const target = e.target as HTMLElement | null;
if (
target &&
(target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.tagName === "SELECT" ||
target.isContentEditable)
) {
return;
}
switch (e.key) {
case "ArrowLeft":
e.preventDefault();
seekByRef.current(e.shiftKey ? -1 : -FRAME_STEP_SEC);
break;
case "ArrowRight":
e.preventDefault();
seekByRef.current(e.shiftKey ? 1 : FRAME_STEP_SEC);
break;
case " ":
e.preventDefault();
togglePlayPauseRef.current();
break;
case "Enter":
case "c":
case "C":
e.preventDefault();
onCaptureRequestRef.current();
break;
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, []);
const seekTo = (time: number) => {
const video = videoRef.current;
if (!video || !Number.isFinite(duration) || duration === 0) return;
const clamped = Math.max(0, Math.min(time, duration));
video.currentTime = clamped;
// timeupdate can lag a seek; reflect it immediately for a snappy readout.
setCurrentTime(clamped);
onTimeChange(clamped);
};
const seekBy = (deltaSec: number) => {
const video = videoRef.current;
if (!video) return;
video.pause();
seekTo(video.currentTime + deltaSec);
};
const togglePlayPause = () => {
const video = videoRef.current;
if (!video || disabled) return;
if (isPlaying) {
video.pause();
} else {
video.play().catch(() => {});
}
};
const toggleMute = () => {
const video = videoRef.current;
if (!video) return;
video.muted = !isMuted;
};
// Latest-value refs so the mount-once window key listener never calls into
// stale closures.
const disabledRef = useRef(!!disabled);
disabledRef.current = !!disabled;
const seekByRef = useRef(seekBy);
seekByRef.current = seekBy;
const togglePlayPauseRef = useRef(togglePlayPause);
togglePlayPauseRef.current = togglePlayPause;
const onCaptureRequestRef = useRef(onCaptureRequest);
onCaptureRequestRef.current = onCaptureRequest;
const ratioFromPointer = (clientX: number): number => {
const timeline = timelineRef.current;
if (!timeline) return 0;
const rect = timeline.getBoundingClientRect();
return Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
};
// One pointer interaction covers click-seek and drag-scrub, and works for
// touch via pointer capture.
const handleTimelinePointerDown = (
e: React.PointerEvent<HTMLDivElement>,
) => {
if (disabled || !Number.isFinite(duration) || duration === 0) return;
e.preventDefault();
e.currentTarget.setPointerCapture(e.pointerId);
isScrubbingRef.current = true;
videoRef.current?.pause();
seekTo(ratioFromPointer(e.clientX) * duration);
};
const handleTimelinePointerMove = (
e: React.PointerEvent<HTMLDivElement>,
) => {
if (isScrubbingRef.current) {
seekTo(ratioFromPointer(e.clientX) * duration);
} else {
setHoverRatio(ratioFromPointer(e.clientX));
}
};
const handleTimelinePointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
if (!isScrubbingRef.current) return;
isScrubbingRef.current = false;
e.currentTarget.releasePointerCapture(e.pointerId);
};
const progressPercent =
Number.isFinite(duration) && duration > 0
? (currentTime / duration) * 100
: 0;
const stepButtons: {
label: string;
tooltip: string;
icon: typeof faChevronLeft;
delta: number;
}[] = [
{ label: "-1s", tooltip: "Back 1s (Shift+←)", icon: faBackwardStep, delta: -1 },
{ label: "-1f", tooltip: "Back 1 frame (←)", icon: faChevronLeft, delta: -FRAME_STEP_SEC },
{ label: "+1f", tooltip: "Forward 1 frame (→)", icon: faChevronRight, delta: FRAME_STEP_SEC },
{ label: "+1s", tooltip: "Forward 1s (Shift+→)", icon: faForwardStep, delta: 1 },
];
return (
<div className="group/scrubber overflow-hidden rounded-xl border border-ui-panel-border bg-ui-panel">
<div className="relative aspect-video w-full bg-black">
<video
ref={videoRef}
src={src}
className="h-full w-full bg-black"
onClick={togglePlayPause}
onError={onVideoError}
preload="metadata"
playsInline
crossOrigin={useCrossOrigin ? "anonymous" : undefined}
/>
{!isPlaying && !disabled && (
<button
onClick={togglePlayPause}
className="absolute inset-0 m-auto flex h-12 w-12 items-center justify-center rounded-full bg-black/55 text-white opacity-0 backdrop-blur-sm transition-opacity group-hover/scrubber:opacity-100"
aria-label="Play"
>
<FontAwesomeIcon icon={faPlay} className="ml-0.5" />
</button>
)}
</div>
<div className={`space-y-3 p-4 ${disabled ? "pointer-events-none opacity-50" : ""}`}>
<div
ref={timelineRef}
className="group/timeline relative h-8 cursor-pointer touch-none select-none"
onPointerDown={handleTimelinePointerDown}
onPointerMove={handleTimelinePointerMove}
onPointerUp={handleTimelinePointerUp}
onPointerCancel={handleTimelinePointerUp}
onPointerLeave={() => setHoverRatio(null)}
>
<div className="absolute top-1/2 h-2 w-full -translate-y-1/2 rounded-full bg-white/10 transition-all group-hover/timeline:h-3">
<div
className="h-full rounded-full bg-primary"
style={{ width: `${progressPercent}%` }}
/>
</div>
<div
className="absolute top-1/2 h-3.5 w-3.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-white shadow"
style={{ left: `${progressPercent}%` }}
/>
{hoverRatio !== null &&
Number.isFinite(duration) &&
duration > 0 && (
<div
className="pointer-events-none absolute -top-6 -translate-x-1/2 rounded-md bg-black/80 px-2 py-0.5 font-mono text-[10px] text-white"
style={{ left: `${hoverRatio * 100}%` }}
>
{formatTimePrecise(hoverRatio * duration)}
</div>
)}
</div>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-1.5">
{stepButtons.slice(0, 2).map((step) => (
<Tooltip key={step.label} content={step.tooltip} position="top" className="z-50">
<button
onClick={() => seekBy(step.delta)}
className="flex h-8 w-8 items-center justify-center rounded-md text-white/70 transition-colors hover:bg-white/10 hover:text-white"
aria-label={step.tooltip}
>
<FontAwesomeIcon icon={step.icon} className="text-xs" />
</button>
</Tooltip>
))}
<Tooltip content="Play/Pause (Space)" position="top" className="z-50">
<button
onClick={togglePlayPause}
className="mx-0.5 flex h-9 w-9 items-center justify-center rounded-full bg-white/10 text-white transition-colors hover:bg-white/20"
aria-label="Play or pause"
>
<FontAwesomeIcon icon={isPlaying ? faPause : faPlay} />
</button>
</Tooltip>
{stepButtons.slice(2).map((step) => (
<Tooltip key={step.label} content={step.tooltip} position="top" className="z-50">
<button
onClick={() => seekBy(step.delta)}
className="flex h-8 w-8 items-center justify-center rounded-md text-white/70 transition-colors hover:bg-white/10 hover:text-white"
aria-label={step.tooltip}
>
<FontAwesomeIcon icon={step.icon} className="text-xs" />
</button>
</Tooltip>
))}
</div>
<div className="flex items-center gap-3">
<div className="hidden items-center gap-2 sm:flex">
<button
onClick={toggleMute}
className="flex h-7 w-7 items-center justify-center rounded-md text-white/60 transition-colors hover:bg-white/10 hover:text-white"
aria-label={isMuted ? "Unmute" : "Mute"}
>
<FontAwesomeIcon
icon={isMuted ? faVolumeMute : faVolumeHigh}
className="text-xs"
/>
</button>
<input
type="range"
min="0"
max="1"
step="0.01"
value={volume}
onChange={(e) => {
const video = videoRef.current;
if (video) video.volume = parseFloat(e.target.value);
}}
className="w-20 accent-primary"
aria-label="Volume"
/>
</div>
<div className="font-mono text-sm text-base-fg/80">
{formatTimePrecise(currentTime)}
<span className="text-base-fg/40"> / {formatTime(duration)}</span>
</div>
</div>
</div>
</div>
</div>
);
};
@@ -10,7 +10,6 @@ import {
faGlobe,
faFilm,
faObjectGroup,
faPhotoFilm,
faArrowRight,
} from "@fortawesome/pro-solid-svg-icons";
import type { IconDefinition } from "@fortawesome/fontawesome-svg-core";
@@ -115,16 +114,6 @@ const APPS: AppCard[] = [
iconColor: "text-indigo-300",
badge: "BETA",
},
{
label: "Frame Extractor",
description: "Grab a still frame from any video.",
href: "/frame-extractor",
icon: faPhotoFilm,
accent: "from-orange-500/20 to-orange-500/0",
iconBg: "bg-orange-500/25 border-orange-400/30",
iconColor: "text-orange-300",
badge: "NEW",
},
];
export function Home() {
@@ -1,14 +1,19 @@
import { create } from "zustand";
import { FoldersApi, MediaFilesApi, type FolderInfo } from "@storyteller/api";
import {
FoldersApi,
MediaFilesApi,
type FolderInfo,
type FolderMediaFileListItem,
} from "@storyteller/api";
import type { GalleryItem } from "@storyteller/ui-gallery-modal";
import {
mapFolderInfo,
galleryItemToCollageUrl,
mergeCollageUrls,
} from "@storyteller/ui-gallery-modal";
import { getMediaThumbnail, THUMBNAIL_SIZES } from "@storyteller/common";
import { is3DMediaClass } from "@storyteller/ui-generation-list";
import { toast } from "../../components/toast/toast";
import { errMsg, mapLeanListItemToGalleryItem } from "./library-media-map";
import { useLibraryTagsStore } from "./library-tags-store";
// ── Bulk selection store ────────────────────────────────────────────────────
// Kept in its own module store (not page state) so each gallery tile can
@@ -129,6 +134,67 @@ const FOLDER_PAGE_SIZE = 60;
const folderCursors: Record<string, string | undefined> = {};
const folderInFlight: Record<string, boolean> = {};
const getLabel = (item: any): string => {
if (item.maybe_title) return item.maybe_title;
switch (item.media_class) {
case "image":
return "Image Generation";
case "video":
return "Video Generation";
case "dimensional":
case "mesh":
return "3D Mesh";
case "splat":
return "3D World";
default:
return "Generation";
}
};
/** Map a raw user-media list row (origin_category shape) → GalleryItem (root library). */
export function mapRawToGalleryItem(item: any): GalleryItem {
const thumbnail = is3DMediaClass(item.media_class)
? (item.cover_image?.maybe_cover_image_public_bucket_url ?? null)
: getMediaThumbnail(item.media_links, item.media_class, {
size: THUMBNAIL_SIZES.LARGE,
});
return {
id: item.token,
label: getLabel(item),
thumbnail,
thumbnailUrlTemplate: item.media_links?.maybe_thumbnail_template,
fullImage: item.media_links?.cdn_url ?? null,
createdAt: item.created_at,
mediaClass: item.media_class || "image",
// The user-media list carries `origin_category`; the folderless list
// carries `is_user_upload` instead.
isUpload: item.origin_category === "upload" || item.is_user_upload === true,
batchImageToken: item.maybe_batch_token,
};
}
/** Map a `FolderMediaFileListItem` → GalleryItem (folder view; carries media_links inline). */
function mapFolderListItemToGalleryItem(
item: FolderMediaFileListItem,
): GalleryItem {
const thumbnail = is3DMediaClass(item.media_class)
? (item.cover_image?.maybe_cover_image_public_bucket_url ?? null)
: getMediaThumbnail(item.media_links, item.media_class, {
size: THUMBNAIL_SIZES.LARGE,
});
return {
id: item.token,
label: getLabel(item),
thumbnail,
thumbnailUrlTemplate: item.media_links?.maybe_thumbnail_template ?? undefined,
fullImage: item.media_links?.cdn_url ?? null,
createdAt: item.created_at,
mediaClass: item.media_class || "image",
isUpload: !!item.is_user_upload,
batchImageToken: item.maybe_batch_token ?? undefined,
};
}
// Shared API-folder → UI-folder mapper (UiFolder ≡ GalleryFolder; coalesce
// parentId so the optional GalleryFolder field satisfies UiFolder).
const mapFolder = (f: FolderInfo): UiFolder => {
@@ -136,6 +202,9 @@ const mapFolder = (f: FolderInfo): UiFolder => {
return { ...g, parentId: g.parentId ?? null };
};
const errMsg = (err: unknown) =>
err instanceof Error ? err.message : String(err);
// NB: only `collageUrls` is touched — `coverUrl` is the user's *custom* cover
// (`maybe_custom_cover_thumbnail`) and must never be derived from the collage,
// or the chip would render one full-bleed image instead of the 2×2 grid.
@@ -240,7 +309,7 @@ export const useLibraryFoldersStore = create<LibraryFoldersState>(
const nextCursor = listRes.pagination?.maybe_cursor ?? undefined;
folderCursors[folderId] = nextCursor;
// The list item carries media_links/cover, so map directly — no batch-get.
const ordered = listRes.data.map(mapLeanListItemToGalleryItem);
const ordered = listRes.data.map(mapFolderListItemToGalleryItem);
set((s) => {
const existing = reset ? [] : (s.folderMediaItems[folderId] ?? []);
const seen = new Set(existing.map((i) => i.id));
@@ -502,7 +571,7 @@ export const useLibraryFoldersStore = create<LibraryFoldersState>(
}),
);
/** Delete a media file and drop it from any cached folder / tag views. */
/** Delete a media file and drop it from any cached folder views. */
export async function deleteLibraryMedia(mediaToken: string): Promise<boolean> {
try {
const res = await mediaFilesApi.DeleteMediaFileByToken({
@@ -517,13 +586,6 @@ export async function deleteLibraryMedia(mediaToken: string): Promise<boolean> {
}
return { folderMediaItems: next };
});
useLibraryTagsStore.setState((s) => {
const next: Record<string, GalleryItem[]> = {};
for (const [k, items] of Object.entries(s.tagMediaItems)) {
next[k] = items.filter((it) => it.id !== mediaToken);
}
return { tagMediaItems: next };
});
}
return res.success;
} catch (err) {
@@ -1,77 +0,0 @@
import type {
FolderMediaFileListItem,
TagMediaFileListItem,
} from "@storyteller/api";
import type { GalleryItem } from "@storyteller/ui-gallery-modal";
import { getMediaThumbnail, THUMBNAIL_SIZES } from "@storyteller/common";
import { is3DMediaClass } from "@storyteller/ui-generation-list";
// Media-row → GalleryItem mappers shared by the library page and its folder /
// tag stores (kept out of the stores so they can't import each other).
/** Rows from the lean folder- and tag-scoped media listings (same wire shape). */
type LeanMediaListItem = FolderMediaFileListItem | TagMediaFileListItem;
const getLabel = (item: any): string => {
if (item.maybe_title) return item.maybe_title;
switch (item.media_class) {
case "image":
return "Image Generation";
case "video":
return "Video Generation";
case "dimensional":
case "mesh":
return "3D Mesh";
case "splat":
return "3D World";
default:
return "Generation";
}
};
/** Map a raw user-media list row (origin_category shape) → GalleryItem (root library). */
export function mapRawToGalleryItem(item: any): GalleryItem {
const thumbnail = is3DMediaClass(item.media_class)
? (item.cover_image?.maybe_cover_image_public_bucket_url ?? null)
: getMediaThumbnail(item.media_links, item.media_class, {
size: THUMBNAIL_SIZES.LARGE,
});
return {
id: item.token,
label: getLabel(item),
thumbnail,
thumbnailUrlTemplate: item.media_links?.maybe_thumbnail_template,
fullImage: item.media_links?.cdn_url ?? null,
createdAt: item.created_at,
mediaClass: item.media_class || "image",
// The user-media list carries `origin_category`; the folderless list
// carries `is_user_upload` instead.
isUpload: item.origin_category === "upload" || item.is_user_upload === true,
batchImageToken: item.maybe_batch_token,
};
}
/** Map a lean folder/tag list row → GalleryItem (carries media_links inline). */
export function mapLeanListItemToGalleryItem(
item: LeanMediaListItem,
): GalleryItem {
const thumbnail = is3DMediaClass(item.media_class)
? (item.cover_image?.maybe_cover_image_public_bucket_url ?? null)
: getMediaThumbnail(item.media_links, item.media_class, {
size: THUMBNAIL_SIZES.LARGE,
});
return {
id: item.token,
label: getLabel(item),
thumbnail,
thumbnailUrlTemplate: item.media_links?.maybe_thumbnail_template ?? undefined,
fullImage: item.media_links?.cdn_url ?? null,
createdAt: item.created_at,
mediaClass: item.media_class || "image",
isUpload: !!item.is_user_upload,
batchImageToken: item.maybe_batch_token ?? undefined,
};
}
export const errMsg = (err: unknown): string =>
err instanceof Error ? err.message : String(err);
@@ -1,210 +0,0 @@
import { create } from "zustand";
import { TagsApi, type TagDetails } from "@storyteller/api";
import type { GalleryItem } from "@storyteller/ui-gallery-modal";
import { toast } from "../../components/toast/toast";
import { errMsg, mapLeanListItemToGalleryItem } from "./library-media-map";
// ── Types ──────────────────────────────────────────────────────────────────
/** Tag shape the UI navigates + renders by. */
export interface UiTag {
token: string;
value: string;
/** Lowercased value — the tag's unique key within the account. */
valueLower: string;
/** How many media files currently carry this tag. */
useCount: number;
}
interface LibraryTagsState {
tags: UiTag[];
tagsLoaded: boolean;
activeTagToken: string | null;
/** Resolved media items per tag, cached so reopening is instant. */
tagMediaItems: Record<string, GalleryItem[]>;
tagContentLoading: boolean;
/** Bottom spinner while paginating the open tag's media. */
tagLoadingMore: boolean;
/** Whether the open tag has more media pages to load. */
tagHasMore: Record<string, boolean>;
// Dialog state — rendered by the library page, triggered from sidebar or page.
renameTarget: string | null;
contextMenu: { tagToken: string; x: number; y: number } | null;
loadTags: () => Promise<void>;
setActiveTag: (token: string | null) => void;
loadTagMedia: (tagToken: string, reset?: boolean) => Promise<void>;
renameTag: (tagToken: string, newValue: string) => Promise<void>;
deleteTag: (tagToken: string) => Promise<void>;
/** Merge canonical tags returned by add/set calls (fresh use counts). */
upsertTags: (tags: TagDetails[]) => void;
setRenameTarget: (tagToken: string | null) => void;
setContextMenu: (
menu: { tagToken: string; x: number; y: number } | null,
) => void;
}
// ── Singletons + mappers ────────────────────────────────────────────────────
const tagsApi = new TagsApi();
// Tag media is paginated via cursor; one scroll page at a time.
const TAG_PAGE_SIZE = 60;
// Non-reactive per-tag cursor + in-flight guard (singleton store).
const tagCursors: Record<string, string | undefined> = {};
const tagInFlight: Record<string, boolean> = {};
const mapTag = (t: TagDetails): UiTag => ({
token: t.tag_token,
value: t.tag_value,
valueLower: t.tag_value_lowercase,
useCount: t.use_count,
});
/** Sort by use count (desc), ties alphabetically — sidebar / "Most used" order. */
export const compareTagsByUseCount = (a: UiTag, b: UiTag): number =>
b.useCount - a.useCount || a.valueLower.localeCompare(b.valueLower);
// ── Store ───────────────────────────────────────────────────────────────────
export const useLibraryTagsStore = create<LibraryTagsState>((set, get) => ({
tags: [],
tagsLoaded: false,
activeTagToken: null,
tagMediaItems: {},
tagContentLoading: false,
tagLoadingMore: false,
tagHasMore: {},
renameTarget: null,
contextMenu: null,
loadTags: async () => {
try {
const all: TagDetails[] = [];
let cursor: string | undefined = undefined;
for (let page = 0; page < 50; page++) {
const res = await tagsApi.ListTags({ cursor });
if (!res.success || !res.data) break;
all.push(...res.data);
const next = res.pagination?.maybe_cursor;
if (!next) break;
cursor = next ?? undefined;
}
set({ tags: all.map(mapTag), tagsLoaded: true });
} catch (err) {
console.error("Failed to load tags:", err);
set({ tagsLoaded: true });
}
},
setActiveTag: (token) => {
set({ activeTagToken: token, contextMenu: null });
if (token) get().loadTagMedia(token, true);
},
// Resolve a tag's media one cursor page at a time. `reset` starts over.
loadTagMedia: async (tagToken, reset = false) => {
if (tagInFlight[tagToken]) return;
if (!reset && get().tagHasMore[tagToken] === false) return;
tagInFlight[tagToken] = true;
if (reset) {
tagCursors[tagToken] = undefined;
set((s) => ({
tagContentLoading: true,
tagHasMore: { ...s.tagHasMore, [tagToken]: true },
}));
} else {
set({ tagLoadingMore: true });
}
try {
const listRes = await tagsApi.ListMediaFilesWithTag({
tagToken,
cursor: reset ? undefined : tagCursors[tagToken],
limit: TAG_PAGE_SIZE,
});
if (!listRes.success || !listRes.data) return;
const nextCursor = listRes.pagination?.maybe_cursor ?? undefined;
tagCursors[tagToken] = nextCursor;
const ordered = listRes.data.map(mapLeanListItemToGalleryItem);
set((s) => {
const existing = reset ? [] : (s.tagMediaItems[tagToken] ?? []);
const seen = new Set(existing.map((i) => i.id));
const merged = [...existing, ...ordered.filter((i) => !seen.has(i.id))];
return {
tagMediaItems: { ...s.tagMediaItems, [tagToken]: merged },
tagHasMore: { ...s.tagHasMore, [tagToken]: !!nextCursor },
};
});
} catch (err) {
console.error("Failed to load tag media:", err);
} finally {
tagInFlight[tagToken] = false;
set({ tagContentLoading: false, tagLoadingMore: false });
}
},
renameTag: async (tagToken, newValue) => {
const trimmed = newValue.trim();
if (!trimmed) return;
set((s) => ({
tags: s.tags.map((t) =>
t.token === tagToken
? { ...t, value: trimmed, valueLower: trimmed.toLowerCase() }
: t,
),
}));
try {
const res = await tagsApi.RenameTag({ tagToken, newTagValue: trimmed });
if (!res.success) {
toast.error(res.errorMessage || "Failed to rename tag.");
get().loadTags();
}
} catch (err) {
toast.error(`Failed to rename tag: ${errMsg(err)}`);
get().loadTags();
}
},
deleteTag: async (tagToken) => {
// Optimistic: drop the tag and forget its media cache. Files keep living
// in the library — only the links go away.
set((s) => {
const nextMedia = { ...s.tagMediaItems };
delete nextMedia[tagToken];
const nextHasMore = { ...s.tagHasMore };
delete nextHasMore[tagToken];
return {
tags: s.tags.filter((t) => t.token !== tagToken),
tagMediaItems: nextMedia,
tagHasMore: nextHasMore,
activeTagToken:
s.activeTagToken === tagToken ? null : s.activeTagToken,
};
});
try {
const res = await tagsApi.DeleteTag({ tagToken });
if (!res.success) {
toast.error(res.errorMessage || "Failed to delete tag.");
get().loadTags();
}
} catch (err) {
toast.error(`Failed to delete tag: ${errMsg(err)}`);
get().loadTags();
}
},
upsertTags: (incoming) => {
if (incoming.length === 0) return;
set((s) => {
const byToken = new Map(s.tags.map((t) => [t.token, t]));
for (const raw of incoming) {
byToken.set(raw.tag_token, mapTag(raw));
}
return { tags: Array.from(byToken.values()) };
});
},
setRenameTarget: (tagToken) =>
set({ renameTarget: tagToken, contextMenu: null }),
setContextMenu: (menu) => set({ contextMenu: menu }),
}));
@@ -33,7 +33,6 @@ import {
} from "@storyteller/ui-action-reminder-modal";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faArrowDownToLine,
faArrowsRotate,
faBorderAll,
faCube,
@@ -49,35 +48,15 @@ import {
faPlus,
faXmark,
faStar,
faTag,
faTags,
faEllipsis,
faCloud,
faList,
faSpinnerThird,
faPhotoFilm,
} from "@fortawesome/pro-solid-svg-icons";
import { Lightbox } from "../../components/lightbox/lightbox";
import { toast } from "../../components/toast/toast";
import { downloadItemsAsZip } from "../../lib/download-media-zip";
import {
isImagePromptable,
isVideoPromptable,
sendToPrompt,
type PromptDestination,
} from "../../lib/send-to-prompt";
import {
useLibraryFoldersStore,
useLibrarySelectionStore,
mapRawToGalleryItem,
deleteLibraryMedia,
type UiFolder,
} from "./library-folders-store";
import { mapRawToGalleryItem } from "./library-media-map";
import {
compareTagsByUseCount,
useLibraryTagsStore,
type UiTag,
} from "./library-tags-store";
const PAGE_SIZE = 60;
@@ -173,30 +152,25 @@ const GRID_CLASS =
// ── Component ──────────────────────────────────────────────────────────────
export default function Library() {
// `:slug` is either a media-class filter (images/videos/meshes), a folder
// token (prefixed `folder_`), the static `tags` tab, or a tag token
// (prefixed `tag_`). `/library/folders` (static) has no slug.
// `:slug` is either a media-class filter (images/videos/meshes) or a folder
// token (prefixed `folder_`). `/library/folders` (static) has no slug.
const { slug } = useParams<{ slug?: string }>();
const { pathname } = useLocation();
const navigate = useNavigate();
const folderToken = slug?.startsWith("folder_") ? slug : undefined;
const tagToken = slug?.startsWith("tag_") ? slug : undefined;
const onTagsRoute = slug === "tags" || !!tagToken;
const filterParam = slug && !folderToken && !onTagsRoute ? slug : undefined;
const filterParam = slug && !folderToken ? slug : undefined;
const activeFilter = filterParam
? (ROUTE_TO_FILTER[filterParam] ?? "all")
: "all";
// Top-level tab derived from the route: All Assets (flat library), Folders,
// Unfoldered (files in no folder at all), or Tags.
// Top-level tab derived from the route: All Assets (flat library),
// Folders, or Unfoldered (files in no folder at all).
const onFoldersRoute = pathname === "/library/folders" || !!folderToken;
const onFolderlessRoute = slug === "folderless";
const tab: "unsorted" | "folders" | "folderless" | "tags" = onFoldersRoute
const tab: "unsorted" | "folders" | "folderless" = onFoldersRoute
? "folders"
: onFolderlessRoute
? "folderless"
: onTagsRoute
? "tags"
: "unsorted";
: "unsorted";
const [username, setUsername] = useState<string | null>(null);
const [isLoggedIn, setIsLoggedIn] = useState<boolean | null>(null);
@@ -267,39 +241,6 @@ export default function Library() {
const setRenameTarget = useLibraryFoldersStore((s) => s.setRenameTarget);
const setContextMenu = useLibraryFoldersStore((s) => s.setContextMenu);
// ── Tags store ────────────────────────────────────────────────────────────
const tags = useLibraryTagsStore((s) => s.tags);
const tagsLoaded = useLibraryTagsStore((s) => s.tagsLoaded);
const activeTagToken = useLibraryTagsStore((s) => s.activeTagToken);
const tagMediaItems = useLibraryTagsStore((s) => s.tagMediaItems);
const tagContentLoading = useLibraryTagsStore((s) => s.tagContentLoading);
const tagLoadingMore = useLibraryTagsStore((s) => s.tagLoadingMore);
const loadTags = useLibraryTagsStore((s) => s.loadTags);
const setActiveTag = useLibraryTagsStore((s) => s.setActiveTag);
const loadTagMedia = useLibraryTagsStore((s) => s.loadTagMedia);
const renameTagAction = useLibraryTagsStore((s) => s.renameTag);
const deleteTagAction = useLibraryTagsStore((s) => s.deleteTag);
const tagRenameTarget = useLibraryTagsStore((s) => s.renameTarget);
const tagContextMenu = useLibraryTagsStore((s) => s.contextMenu);
const setTagRenameTarget = useLibraryTagsStore((s) => s.setRenameTarget);
const setTagContextMenu = useLibraryTagsStore((s) => s.setContextMenu);
const activeTag = activeTagToken
? (tags.find((t) => t.token === activeTagToken) ?? null)
: null;
// All-tags view mode + sort; local state, not routes. The cloud is the
// default — size encodes use count, so the sort toggle only applies to list.
const [tagView, setTagView] = useState<"cloud" | "list">("cloud");
const [tagSort, setTagSort] = useState<"count" | "name">("count");
const sortedTags = useMemo(
() =>
tagSort === "count"
? [...tags].sort(compareTagsByUseCount)
: [...tags].sort((a, b) => a.valueLower.localeCompare(b.valueLower)),
[tags, tagSort],
);
const activeFolder = activeFolderId
? (folders.find((f) => f.id === activeFolderId) ?? null)
: null;
@@ -351,14 +292,10 @@ export default function Library() {
})();
}, []);
// Load the folder tree + tag list once we know who the user is (the tag
// list also feeds the sidebar nav and the editor's autocomplete).
// Load the folder tree once we know who the user is.
useEffect(() => {
if (username) {
loadFolders();
loadTags();
}
}, [username, loadFolders, loadTags]);
if (username) loadFolders();
}, [username, loadFolders]);
// The URL owns *which* folder is open (`/library/:token`); mirror it into the
// store. Read via getState() + inequality guard so this never loops.
@@ -369,14 +306,6 @@ export default function Library() {
}
}, [folderToken, setActiveFolder]);
// Same for the open tag (`/library/tag_…`).
useEffect(() => {
const target = tagToken ?? null;
if (useLibraryTagsStore.getState().activeTagToken !== target) {
setActiveTag(target);
}
}, [tagToken, setActiveTag]);
// ── Root media loading (library view, no folder open) ─────────────────────
const loadItems = useCallback(
async (reset = false) => {
@@ -430,7 +359,8 @@ export default function Library() {
meshSplatCursorRef.current =
response.pagination?.maybe_next ?? undefined;
setHasMore(
newItems.length >= PAGE_SIZE && !!response.pagination?.maybe_next,
newItems.length >= PAGE_SIZE &&
!!response.pagination?.maybe_next,
);
}
} else {
@@ -447,12 +377,9 @@ export default function Library() {
(item: any) =>
item.media_type !== FilterMediaType.SCENE_JSON &&
// Drop 3D-model cover screenshots the backend surfaces as
// 3D-classed items (mesh/splat, or legacy pre-split
// "dimensional") whose asset is actually a .png.
// "dimensional" items whose asset is actually a .png.
!(
(item.media_class === "dimensional" ||
item.media_class === "mesh" ||
item.media_class === "splat") &&
item.media_class === "dimensional" &&
!is3DModelUrl(item.media_links?.cdn_url)
),
)
@@ -471,22 +398,13 @@ export default function Library() {
setInitialLoading(false);
isLoadingRef.current = false;
},
[
username,
activeFilter,
pageIndex,
api,
mediaFilesApi,
foldersApi,
folderlessClass,
],
[username, activeFilter, pageIndex, api, mediaFilesApi, foldersApi, folderlessClass],
);
// Initial load + filter / tab change
useEffect(() => {
if (!username) return;
// Folder / tag views load via their own stores.
if (tab === "folders" || tab === "tags") return;
if (tab === "folders") return; // Folder views load via the folders store.
setAllItems([]);
setPageIndex(0);
meshSplatCursorRef.current = undefined;
@@ -509,9 +427,7 @@ export default function Library() {
: (scroller as HTMLElement);
const scrollBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
if (scrollBottom >= 500) return;
if (activeTagToken) {
loadTagMedia(activeTagToken, false);
} else if (activeFolderId) {
if (activeFolderId) {
loadFolderMedia(activeFolderId, false);
} else if (
(tab === "unsorted" || tab === "folderless") &&
@@ -523,22 +439,12 @@ export default function Library() {
};
scroller.addEventListener("scroll", handleScroll, { passive: true });
return () => scroller.removeEventListener("scroll", handleScroll);
}, [
activeFolderId,
activeTagToken,
tab,
hasMore,
loadItems,
loadFolderMedia,
loadTagMedia,
]);
}, [activeFolderId, tab, hasMore, loadItems, loadFolderMedia]);
// ── Drag media → folder ───────────────────────────────────────────────────
const displayItems = activeTagToken
? (tagMediaItems[activeTagToken] ?? [])
: activeFolderId
? (folderMediaItems[activeFolderId] ?? [])
: allItems;
const displayItems = activeFolderId
? (folderMediaItems[activeFolderId] ?? [])
: allItems;
const displayItemsRef = useRef(displayItems);
displayItemsRef.current = displayItems;
@@ -880,7 +786,7 @@ export default function Library() {
const handleItemDeleted = useCallback((id: string) => {
setAllItems((prev) => prev.filter((item) => item.id !== id));
useLibrarySelectionStore.getState().removeIds([id]);
// Also drop it from any cached folder/tag views (e.g. deleted via the lightbox).
// Also drop it from any cached folder views (e.g. deleted via the lightbox).
useLibraryFoldersStore.setState((s) => {
const next: Record<string, GalleryItem[]> = {};
for (const [k, items] of Object.entries(s.folderMediaItems)) {
@@ -888,13 +794,6 @@ export default function Library() {
}
return { folderMediaItems: next };
});
useLibraryTagsStore.setState((s) => {
const next: Record<string, GalleryItem[]> = {};
for (const [k, items] of Object.entries(s.tagMediaItems)) {
next[k] = items.filter((it) => it.id !== id);
}
return { tagMediaItems: next };
});
}, []);
const handleCardClick = useCallback((item: GalleryItem) => {
@@ -973,38 +872,6 @@ export default function Library() {
});
};
// ── Tag dialog handlers ───────────────────────────────────────────────────
const submitTagRename = (name: string) => {
if (tagRenameTarget) renameTagAction(tagRenameTarget, name);
setTagRenameTarget(null);
};
const confirmDeleteTag = (tagTokenToDelete: string) => {
const tag = tags.find((t) => t.token === tagTokenToDelete);
const count = tag?.useCount ?? 0;
showActionReminder({
reminderType: "default",
title: `Delete tag "${tag?.value ?? "tag"}"?`,
message: (
<p className="text-sm text-white/70">
Removes this tag from {count} file{count === 1 ? "" : "s"}. Files stay
in your library.
</p>
),
primaryActionText: "Delete",
secondaryActionText: "Cancel",
primaryActionBtnClassName: "bg-red text-white hover:bg-red/90",
onPrimaryAction: async () => {
try {
await deleteTagAction(tagTokenToDelete);
if (tagToken === tagTokenToDelete) navigate("/library/tags");
} finally {
isActionReminderOpen.value = false;
}
},
});
};
// ── Not logged in / loading auth ──────────────────────────────────────────
if (isLoggedIn === false) {
return (
@@ -1111,10 +978,7 @@ export default function Library() {
{/* Header — sticky below navbar */}
<div
data-no-marquee
// z-10 keeps the sticky header above the (static) grid tiles while
// staying below the app topbar (z-20), so topbar popovers (credits,
// avatar) aren't painted under this bar.
className="sticky top-0 z-10 -mx-3 sm:-mx-4 md:-mx-8 lg:-mx-12 px-3 sm:px-4 md:px-8 lg:px-12 pb-3 pt-3 bg-[#101014] mb-4 sm:mb-6"
className="sticky top-0 z-50 -mx-3 sm:-mx-4 md:-mx-8 lg:-mx-12 px-3 sm:px-4 md:px-8 lg:px-12 pb-3 pt-3 bg-[#101014] mb-4 sm:mb-6"
>
<div className="flex flex-col gap-6">
{/* Tabs + actions */}
@@ -1184,27 +1048,6 @@ export default function Library() {
/>
<span className="relative z-10">Unfoldered</span>
</Link>
<Link
to="/library/tags"
className={`relative flex items-center gap-2 px-3 sm:px-4 py-1 sm:py-1.5 rounded-md text-xs sm:text-sm font-medium transition-colors whitespace-nowrap ${
tab === "tags"
? "text-white"
: "text-white/60 hover:text-white"
}`}
>
{tab === "tags" && (
<motion.span
layoutId="library-tab-indicator"
className="absolute inset-0 rounded-md bg-ui-controls"
transition={{ duration: 0.32, ease: EASE_EMPHASIS }}
/>
)}
<FontAwesomeIcon
icon={faTag}
className="relative z-10 text-xs"
/>
<span className="relative z-10">Tags</span>
</Link>
</div>
{(tab === "unsorted" || tab === "folderless") && (
<button
@@ -1291,79 +1134,6 @@ export default function Library() {
New folder
</Button>
)}
{tab === "tags" && !activeTagToken && tags.length > 0 && (
<>
{tagView === "list" && (
<div className="flex items-center gap-1 bg-ui-controls/40 rounded-xl p-1 overflow-x-auto">
{(
[
["count", "Most used"],
["name", "Name"],
] as const
).map(([id, label]) => (
<button
key={id}
onClick={() => setTagSort(id)}
className={`relative px-2.5 sm:px-4 py-1 sm:py-1.5 rounded-md text-xs sm:text-sm font-medium transition-colors whitespace-nowrap ${
tagSort === id
? "text-white"
: "text-white/60 hover:text-white"
}`}
>
{tagSort === id && (
<motion.span
layoutId="library-tag-sort-indicator"
className="absolute inset-0 rounded-md bg-ui-controls"
transition={{
duration: 0.32,
ease: EASE_EMPHASIS,
}}
/>
)}
<span className="relative z-10">{label}</span>
</button>
))}
</div>
)}
<div className="flex items-center gap-1 bg-ui-controls/40 rounded-xl p-1">
{(
[
["cloud", faCloud, "Cloud"],
["list", faList, "List"],
] as const
).map(([id, icon, label]) => (
<button
key={id}
onClick={() => setTagView(id)}
title={label}
className={`relative flex items-center gap-1.5 sm:gap-2 px-2.5 sm:px-4 py-1 sm:py-1.5 rounded-md text-xs sm:text-sm font-medium transition-colors whitespace-nowrap ${
tagView === id
? "text-white"
: "text-white/60 hover:text-white"
}`}
>
{tagView === id && (
<motion.span
layoutId="library-tag-view-indicator"
className="absolute inset-0 rounded-md bg-ui-controls"
transition={{
duration: 0.32,
ease: EASE_EMPHASIS,
}}
/>
)}
<FontAwesomeIcon
icon={icon}
className="relative z-10 text-xs"
/>
<span className="relative z-10 hidden sm:inline">
{label}
</span>
</button>
))}
</div>
</>
)}
</div>
</div>
@@ -1407,42 +1177,6 @@ export default function Library() {
</button>
</div>
)}
{/* Breadcrumb (inside a tag) */}
{tab === "tags" && activeTagToken && (
<div className="flex items-center gap-1.5 flex-wrap min-w-0">
<button
onClick={() => navigate("/library/tags")}
className="text-white/50 hover:text-white text-sm transition-colors"
>
Tags
</button>
<span className="text-white/30">/</span>
<h1 className="text-lg sm:text-xl font-medium text-white truncate max-w-[16rem]">
{activeTag?.value ?? "Tag"}
</h1>
{activeTag && (
<span className="text-white/40 text-sm pt-1 ps-1.5">
{activeTag.useCount} file
{activeTag.useCount === 1 ? "" : "s"}
</span>
)}
<button
onClick={() => setTagRenameTarget(activeTagToken)}
className="h-7 w-7 flex items-center justify-center rounded-lg text-white/50 hover:text-white hover:bg-ui-controls/40 transition-colors"
title="Rename tag"
>
<FontAwesomeIcon icon={faPencil} className="text-xs" />
</button>
<button
onClick={() => confirmDeleteTag(activeTagToken)}
className="h-7 w-7 flex items-center justify-center rounded-lg text-white/50 hover:text-red hover:bg-ui-controls/40 transition-colors"
title="Delete tag"
>
<FontAwesomeIcon icon={faTrashCan} className="text-xs" />
</button>
</div>
)}
</div>
</div>
@@ -1535,105 +1269,6 @@ export default function Library() {
)}
</>
)
) : tab === "tags" && !activeTagToken ? (
/* ── Tags tab: all tags ── */
!tagsLoaded ? (
<div className="flex justify-center py-20">
<LoadingSpinner className="h-8 w-8 text-white/60" />
</div>
) : tags.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 gap-3">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-ui-controls/30">
<FontAwesomeIcon
icon={faTags}
className="text-2xl text-white/40"
/>
</div>
<p className="text-white/40 text-sm">No tags yet.</p>
<p className="text-white/30 text-xs max-w-xs text-center">
Open any image or video and add tags in the details panel —
they'll show up here.
</p>
</div>
) : tagView === "cloud" ? (
<TagCloud
tags={tags}
onOpen={(token) => navigate(`/library/${token}`)}
onMenu={(cloudTagToken, x, y) =>
setTagContextMenu({ tagToken: cloudTagToken, x, y })
}
/>
) : (
<div className="flex flex-wrap gap-2">
{sortedTags.map((t) => (
<div
key={t.token}
className="flex items-center rounded-full bg-ui-controls/40 hover:bg-ui-controls/70 transition-colors"
>
<button
type="button"
onClick={() => navigate(`/library/${t.token}`)}
onContextMenu={(e) => {
e.preventDefault();
setTagContextMenu({
tagToken: t.token,
x: e.clientX,
y: e.clientY,
});
}}
className="flex items-center gap-2 pl-4 pr-1 py-2 text-sm font-medium text-white"
>
<FontAwesomeIcon
icon={faTag}
className="text-xs text-violet-400"
/>
<span className="max-w-[14rem] truncate">{t.value}</span>
<span className="text-xs text-white/40">
{t.useCount}
</span>
</button>
<button
type="button"
onClick={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
setTagContextMenu({
tagToken: t.token,
x: rect.left,
y: rect.bottom + 4,
});
}}
aria-label={`Options for tag "${t.value}"`}
title="Rename or delete"
className="mr-1.5 flex h-6 w-6 items-center justify-center rounded-full text-white/40 hover:bg-white/10 hover:text-white transition-colors"
>
<FontAwesomeIcon icon={faEllipsis} className="text-xs" />
</button>
</div>
))}
</div>
)
) : tab === "tags" && activeTagToken ? (
/* ── Inside a tag ── */
tagContentLoading && displayItems.length === 0 ? (
<div className="flex justify-center py-20">
<LoadingSpinner className="h-8 w-8 text-white/60" />
</div>
) : displayItems.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20">
<p className="text-white/40 text-sm">
No files carry this tag.
</p>
</div>
) : (
<>
{mediaGrid}
{tagLoadingMore && (
<div className="flex justify-center py-4">
<LoadingSpinner className="h-8 w-8 text-white/60" />
</div>
)}
</>
)
) : /* ── Unsorted ── */ initialLoading && allItems.length === 0 ? (
<div>
<div
@@ -1775,18 +1410,6 @@ export default function Library() {
onClose={() => setRenameTarget(null)}
/>
{/* Rename tag dialog */}
<FolderNameDialog
isOpen={!!tagRenameTarget}
title="Rename tag"
initialValue={
tags.find((t) => t.token === tagRenameTarget)?.value ?? ""
}
confirmLabel="Rename"
onConfirm={submitTagRename}
onClose={() => setTagRenameTarget(null)}
/>
{/* Folder context menu (portaled) */}
{contextMenu &&
createPortal(
@@ -1870,133 +1493,6 @@ export default function Library() {
</>,
document.body,
)}
{/* Tag context menu (portaled) */}
{tagContextMenu &&
createPortal(
<>
<div
className="fixed inset-0 z-[9998]"
onClick={() => setTagContextMenu(null)}
onContextMenu={(e) => {
e.preventDefault();
setTagContextMenu(null);
}}
/>
<div
className="fixed z-[9999] min-w-44 rounded-lg border border-ui-panel-border bg-ui-panel p-1 shadow-xl"
style={{ left: tagContextMenu.x, top: tagContextMenu.y }}
>
<button
type="button"
className="flex w-full items-center gap-2 px-2 py-2 rounded-md hover:bg-ui-controls/60 text-sm text-base-fg"
onClick={() => setTagRenameTarget(tagContextMenu.tagToken)}
>
<FontAwesomeIcon icon={faPencil} className="w-4" />
<span>Rename</span>
</button>
<button
type="button"
className="flex w-full items-center gap-2 px-2 py-2 rounded-md hover:bg-ui-controls/60 text-sm text-red"
onClick={() => {
const token = tagContextMenu.tagToken;
setTagContextMenu(null);
confirmDeleteTag(token);
}}
>
<FontAwesomeIcon icon={faTrashCan} className="w-4" />
<span>Delete tag</span>
</button>
</div>
</>,
document.body,
)}
</div>
);
}
// ── Tag cloud ─────────────────────────────────────────────────────────────────
// The default all-tags view: font size and emphasis encode use count, and a
// deterministic hash shuffle spreads big words among small ones (a stable
// order — no reflow between renders) for the classic word-cloud look.
const hashString = (s: string): number => {
let h = 0;
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0;
return h;
};
function TagCloud({
tags,
onOpen,
onMenu,
}: {
tags: UiTag[];
onOpen: (token: string) => void;
onMenu: (tagToken: string, x: number, y: number) => void;
}) {
const words = useMemo(() => {
const counts = tags.map((t) => t.useCount);
const min = Math.min(...counts);
const span = Math.max(1, Math.max(...counts) - min);
return [...tags]
.sort((a, b) => hashString(a.token) - hashString(b.token))
.map((tag) => ({
tag,
// sqrt compresses the top end so one mega-tag doesn't dwarf the rest.
weight: Math.sqrt((tag.useCount - min) / span),
}));
}, [tags]);
return (
<div className="mx-auto flex max-w-4xl flex-wrap items-baseline justify-center gap-x-4 gap-y-2.5 sm:gap-x-5 sm:gap-y-3 px-4 py-10 sm:py-16 select-none">
{words.map(({ tag, weight }, index) => (
<motion.button
key={tag.token}
type="button"
// Staggered pop-in on mount (delay capped so huge clouds don't drag),
// then a springy lift + tilt on hover. Transitions live on the
// targets so the entrance delay never slows down hover response.
initial={{ opacity: 0, scale: 0.5 }}
animate={{
opacity: 1,
scale: 1,
transition: {
delay: Math.min(index * 0.025, 0.6),
duration: 0.3,
ease: EASE_EMPHASIS,
},
}}
whileHover={{
scale: 1.15,
y: -3,
// Alternate tilt direction per word so neighbors don't sync up.
rotate: hashString(tag.token) % 2 === 0 ? 2.5 : -2.5,
transition: { type: "spring", stiffness: 400, damping: 15 },
}}
whileTap={{ scale: 0.92 }}
onClick={() => onOpen(tag.token)}
onContextMenu={(e) => {
e.preventDefault();
onMenu(tag.token, e.clientX, e.clientY);
}}
title={`${tag.useCount} file${tag.useCount === 1 ? "" : "s"} — right-click to rename or delete`}
// vw cap keeps the biggest words from overflowing narrow screens;
// on desktop widths the px size always wins the min().
style={{
fontSize: `min(${Math.round(16 + weight * 40)}px, ${(5 + weight * 7).toFixed(1)}vw)`,
}}
className={`max-w-full truncate leading-none transition-colors hover:text-violet-400 ${
weight > 0.66
? "font-bold text-white"
: weight > 0.33
? "font-semibold text-white/70"
: "font-medium text-white/40"
}`}
>
{tag.value}
</motion.button>
))}
</div>
);
}
@@ -2068,15 +1564,6 @@ const LibraryTile = memo(function LibraryTile({
// Owns its own subscription to the selection store (and the folder popover
// state), so selection changes re-render only this small bar, never the page.
const SEND_TO_TARGETS: {
destination: PromptDestination;
label: string;
icon: typeof faImage;
}[] = [
{ destination: "image", label: "Create image", icon: faImage },
{ destination: "video", label: "Create video", icon: faVideo },
];
interface BulkSelectionBarProps {
allItems: GalleryItem[];
folders: UiFolder[];
@@ -2096,15 +1583,7 @@ function BulkSelectionBar({
}: BulkSelectionBarProps) {
const ids = useLibrarySelectionStore((s) => s.ids);
const folderMediaItems = useLibraryFoldersStore((s) => s.folderMediaItems);
const tagMediaItems = useLibraryTagsStore((s) => s.tagMediaItems);
const navigate = useNavigate();
const [popoverOpen, setPopoverOpen] = useState(false);
const [sendToOpen, setSendToOpen] = useState(false);
const [downloadProgress, setDownloadProgress] = useState<{
done: number;
total: number;
} | null>(null);
const isDownloading = downloadProgress !== null;
// Same ordering as the sidebar / folder cards: starred first, then
// alphabetical (the store list arrives in API order).
@@ -2118,15 +1597,12 @@ function BulkSelectionBar({
setPopoverOpen(false);
}, [activeFolderId]);
// Resolve selected items from everything loaded (root library + folder/tag
// Resolve selected items from everything loaded (root library + folder
// caches) — the selection survives navigation, so selected items may not be
// part of the currently displayed view.
const selectedItems = useMemo(() => {
const byId = new Map(allItems.map((it) => [it.id, it] as const));
for (const arr of [
...Object.values(folderMediaItems),
...Object.values(tagMediaItems),
]) {
for (const arr of Object.values(folderMediaItems)) {
for (const it of arr) {
if (!byId.has(it.id)) byId.set(it.id, it);
}
@@ -2134,44 +1610,10 @@ function BulkSelectionBar({
return Array.from(ids)
.map((id) => byId.get(id))
.filter((it): it is GalleryItem => !!it);
}, [allItems, folderMediaItems, tagMediaItems, ids]);
}, [allItems, folderMediaItems, ids]);
if (ids.size === 0) return null;
const clear = () => useLibrarySelectionStore.getState().clear();
const hasPromptImages = selectedItems.some(isImagePromptable);
const hasPromptVideos = selectedItems.some(isVideoPromptable);
const handleDownloadSelected = async () => {
const downloadable = selectedItems.filter(
(it): it is GalleryItem & { fullImage: string } => !!it.fullImage,
);
if (isDownloading || downloadable.length === 0) return;
setDownloadProgress({ done: 0, total: downloadable.length });
try {
const { succeeded, failed } = await downloadItemsAsZip(
downloadable.map((it) => ({
id: it.id,
url: it.fullImage,
mediaClass: it.mediaClass,
})),
{ onProgress: (done, total) => setDownloadProgress({ done, total }) },
);
if (failed === 0) {
toast.success(
`Downloaded ${succeeded} ${succeeded === 1 ? "file" : "files"}`,
);
} else if (succeeded > 0) {
toast.error(
`${failed} of ${succeeded + failed} files failed to download`,
);
} else {
toast.error("Could not download the selected files.");
}
if (succeeded > 0) clear();
} finally {
setDownloadProgress(null);
}
};
return (
<div
@@ -2198,74 +1640,6 @@ function BulkSelectionBar({
{ids.size} selected
</span>
{/* Send to prompt (only when the selection contains images or videos).
Images can go to either prompt; videos only to the video prompt. */}
{(hasPromptImages || hasPromptVideos) && (
<div className="relative">
<button
type="button"
title="Send to prompt"
onClick={() => setSendToOpen((v) => !v)}
className="flex items-center gap-2 rounded-full bg-ui-controls/60 px-3 py-1.5 text-sm font-medium text-white hover:bg-ui-controls/90 transition-colors"
>
<FontAwesomeIcon
icon={hasPromptImages ? faImage : faVideo}
className="text-xs"
/>
{/* Icon-only on phones — a fourth labeled button overflows the bar. */}
<span className="hidden sm:inline">Send to prompt</span>
</button>
{sendToOpen && (
<>
<div
className="fixed inset-0 z-[59]"
onClick={() => setSendToOpen(false)}
/>
<div className="absolute bottom-full left-0 z-[60] mb-2 w-44 rounded-lg border border-ui-panel-border bg-ui-panel p-2 shadow-xl">
<div className="px-2 py-1 text-[11px] font-semibold uppercase tracking-wider text-white/40">
Use as reference in
</div>
{SEND_TO_TARGETS.filter(
(target) => target.destination === "video" || hasPromptImages,
).map((target) => (
<button
key={target.destination}
type="button"
onClick={() => {
setSendToOpen(false);
sendToPrompt(selectedItems, target.destination, navigate);
clear();
}}
className="flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-sm text-white hover:bg-ui-controls/50 transition-colors"
>
<FontAwesomeIcon icon={target.icon} className="w-4 text-xs" />
<span>{target.label}</span>
</button>
))}
</div>
</>
)}
</div>
)}
{/* Extract frames (single selected video → frame extractor tool). */}
{selectedItems.length === 1 &&
selectedItems[0].mediaClass === "video" && (
<button
type="button"
title="Extract frames"
onClick={() => {
const token = selectedItems[0].id;
clear();
navigate(`/frame-extractor?media=${token}`);
}}
className="flex items-center gap-2 rounded-full bg-ui-controls/60 px-3 py-1.5 text-sm font-medium text-white hover:bg-ui-controls/90 transition-colors"
>
<FontAwesomeIcon icon={faPhotoFilm} className="text-xs" />
<span className="hidden sm:inline">Extract frames</span>
</button>
)}
{/* Add to folder */}
<div className="relative">
<button
@@ -2334,21 +1708,6 @@ function BulkSelectionBar({
)}
</div>
<button
type="button"
onClick={handleDownloadSelected}
disabled={isDownloading}
className="flex items-center gap-2 rounded-full bg-ui-controls/60 px-3 py-1.5 text-sm font-medium text-white hover:bg-ui-controls/90 transition-colors disabled:opacity-60"
>
<FontAwesomeIcon
icon={isDownloading ? faSpinnerThird : faArrowDownToLine}
className={`text-xs ${isDownloading ? "animate-spin" : ""}`}
/>
{isDownloading && downloadProgress
? `Downloading ${downloadProgress.done}/${downloadProgress.total}…`
: "Download"}
</button>
<button
type="button"
onClick={onDeleteSelected}
@@ -2,17 +2,14 @@ import { useState } from "react";
import { useNavigate } from "react-router-dom";
import toast from "react-hot-toast";
import { UploaderStates } from "@storyteller/common";
import { MediaFilesApi, ProjectsApi } from "@storyteller/api";
import { GalleryModal, GalleryItem } from "@storyteller/ui-gallery-modal";
import type {
MoodboardAdapter,
MoodboardPersistenceAdapter,
MoodboardReference,
MoodboardLibraryPickerProps,
} from "@storyteller/ui-moodboard";
import { uploadImage as webappUploadImage } from "../../components/prompt-box/upload-image";
import { useCreateImageStore } from "../create-image/create-image-store";
import { useSessionStore } from "../../lib/session";
import type { RefImage } from "../../components/prompt-box/types";
// Web (artcraft-webapp) implementation of the moodboard's platform seams.
@@ -103,115 +100,10 @@ const WebappLibraryPicker = ({
);
};
// Server persistence over the project-document endpoints: each board is one
// mood_board project (multipart JSON upload; save-new returns the media file
// token that links the board to its server row).
const persistence: MoodboardPersistenceAdapter = {
getUserId: () => useSessionStore.getState().user?.user_token ?? null,
subscribeAuthState: (onChange) => useSessionStore.subscribe(onChange),
saveBoard: async ({ token, name, documentJson }) => {
const api = new ProjectsApi();
const blob = new Blob([documentJson], { type: "application/json" });
const fileName = `${name || "Untitled board"}.mood.json`;
const uuid = crypto.randomUUID();
const response = token
? await api.UpdateProject({
projectType: "mood_board",
token,
blob,
fileName,
uuid,
maybe_title: name,
})
: await api.UploadNewProject({
projectType: "mood_board",
blob,
fileName,
uuid,
maybe_title: name,
});
return {
success: response.success,
token: response.data ?? token ?? undefined,
errorMessage: response.errorMessage,
};
},
listBoards: async () => {
const response = await new ProjectsApi().ListSessionProjects({
filter_project_type: "mood_board",
page_size: 100,
});
if (!response.success || !response.data) return { success: false };
return {
success: true,
boards: response.data.map((row) => ({
token: row.token,
name: row.maybe_title ?? "Untitled board",
updatedAt: row.updated_at,
})),
};
},
// The adapter contract is non-throwing; the raw fetch()es here reject on
// network failure, so wrap them — an escaped rejection would tear down
// the caller's whole hydration pass, not just this board.
loadBoard: async (token) => {
try {
const response = await new MediaFilesApi().GetMediaFileByToken({
mediaFileToken: token,
});
const cdnUrl = response.success
? response.data?.media_links?.cdn_url
: undefined;
if (!cdnUrl) return { success: false };
const documentResponse = await fetch(cdnUrl);
if (!documentResponse.ok) return { success: false };
return { success: true, documentJson: await documentResponse.text() };
} catch (error) {
console.error("[Moodboard] board document fetch failed:", error);
return { success: false };
}
},
deleteBoard: async (token) => {
try {
const response = await new MediaFilesApi().DeleteMediaFileByToken({
mediaFileToken: token,
asMod: false,
});
return response.success;
} catch (error) {
console.error("[Moodboard] board delete failed:", error);
return false;
}
},
resolveMediaUrls: async (tokens) => {
try {
const response = await new MediaFilesApi().ListMediaFilesByTokens({
mediaTokens: tokens,
});
const urlByToken: Record<string, string> = {};
for (const media of response.data ?? []) {
if (media.media_links?.cdn_url) {
urlByToken[media.token] = media.media_links.cdn_url;
}
}
return urlByToken;
} catch (error) {
console.error("[Moodboard] media URL resolution failed:", error);
return {};
}
},
};
export const webappMoodboardAdapter: MoodboardAdapter = {
uploadImage,
sendToGeneration,
renderLibraryPicker: (props) => <WebappLibraryPicker {...props} />,
persistence,
};
// Dedupe by token but keep tokenless refs (a manually-added prompt reference
@@ -11,19 +11,14 @@
import { useMemo } from "react";
import {
FilterEngineCategories,
GetCdnOrigin,
MediaFilesApi,
MediaUploadApi,
ProjectsApi,
StorytellerApiHostStore,
UploadImageMedia,
UsersApi,
} from "@storyteller/api";
import type {
PageSceneAdapter,
SceneProjectListItem,
} from "@storyteller/ui-pagescene";
import type { PageSceneAdapter } from "@storyteller/ui-pagescene";
import { uploadByKind } from "../video-editor/adapters/upload-by-kind";
import {
getActiveEditor,
@@ -47,15 +42,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;
@@ -71,24 +62,13 @@ const saveSceneViaApi = async (
sceneThumbnail: Blob | undefined,
): Promise<string> => {
const blob = new Blob([saveJson], { type: "application/json" });
const projectsApi = new ProjectsApi();
const uploadApi = new MediaUploadApi();
const uuid = crypto.randomUUID();
const fileName = `${sceneTitle}.scn.json`;
const fileName = `${sceneTitle}.glb`;
// Scenes persist through the project endpoints
// (/v1/media_files/upload/project/scene_3d/*). The update endpoint also
// accepts LEGACY rows saved through the old upload/new_scene flow and
// upgrades them in place — the token is preserved.
const uploadResp = sceneToken
? await projectsApi.UpdateProject({
projectType: "scene_3d",
token: sceneToken,
blob,
fileName,
uuid,
})
: await projectsApi.UploadNewProject({
projectType: "scene_3d",
? await uploadApi.UploadSavedScene({ blob, fileName, uuid, mediaToken: sceneToken })
: await uploadApi.UploadNewScene({
blob,
fileName,
uuid,
@@ -105,11 +85,7 @@ const saveSceneViaApi = async (
// independent mutation — kick it off in the background so the save
// spinner dismisses immediately after the scene JSON lands.
if (sceneThumbnail) {
void uploadCoverImageInBackground(
new MediaUploadApi(),
newToken,
sceneThumbnail,
);
void uploadCoverImageInBackground(uploadApi, newToken, sceneThumbnail);
}
return newToken;
};
@@ -174,82 +150,6 @@ const loadSceneViaApi = async (token: string): Promise<unknown> => {
return JSON.parse(text);
};
// List the user's saved scenes for the scene picker. New saves are project
// rows (media_class=project, project_type=scene_3d); scenes saved before the
// project-endpoint migration are engine_category=scene rows. Query both and
// merge until the backend backfill upgrades the legacy rows. A re-saved
// legacy scene appears in both lists — dedupe by token, preferring the
// project row (it has the fresher updated_at).
const listUserSceneProjectsViaApi = async (
username: string | undefined,
): Promise<{
success: boolean;
data?: SceneProjectListItem[];
errorMessage?: string;
}> => {
if (!username) {
return {
success: false,
errorMessage: "Sign in to load your saved scenes.",
};
}
const [projectResp, legacyResp] = await Promise.all([
new ProjectsApi().ListSessionProjects({
filter_project_type: "scene_3d",
page_size: 100,
}),
new MediaFilesApi().ListUserMediaFiles({
username,
// Saved scenes are persisted with is_user_upload = TRUE; the list
// endpoint excludes uploads unless this is set.
include_user_uploads: true,
page_size: 100,
filter_engine_categories: [FilterEngineCategories.SCENE],
}),
]);
if (!projectResp.success && !legacyResp.success) {
return {
success: false,
errorMessage:
projectResp.errorMessage ||
legacyResp.errorMessage ||
"Failed to load saved scenes.",
};
}
const byToken = new Map<string, SceneProjectListItem>();
for (const item of legacyResp.data ?? []) {
byToken.set(item.token, {
token: item.token,
maybe_title: item.maybe_title,
updated_at: item.updated_at,
// Legacy rows expose the cover as a bucket path.
maybe_thumbnail: item.cover_image?.maybe_cover_image_public_bucket_path,
});
}
for (const item of projectResp.data ?? []) {
// Project rows expose the cover through `maybe_links` only (the
// deprecated bucket-path field is never populated). Prefer the sized
// thumbnail template; fall back to the raw cdn_url.
const coverLinks = item.cover_image?.maybe_links;
byToken.set(item.token, {
token: item.token,
maybe_title: item.maybe_title,
updated_at: item.updated_at,
maybe_thumbnail:
coverLinks?.thumbnail_template?.replace("{WIDTH}", "360") ??
coverLinks?.cdn_url,
});
}
const merged = [...byToken.values()].sort((a, b) =>
b.updated_at.localeCompare(a.updated_at),
);
return { success: true, data: merged };
};
// ─── Adapter ───────────────────────────────────────────────────────────────
export interface WebAppPageSceneAdapterOptions {
@@ -338,11 +238,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}`);
@@ -390,10 +285,6 @@ export const useWebAppPageSceneAdapter = (
};
},
// Scene picker listing — merged project + legacy rows (see helper).
listUserSceneProjects: () =>
listUserSceneProjectsViaApi(getLiveUser()?.username),
listFeaturedMediaFiles: async (query) => {
const api = new MediaFilesApi();
const response = await api.ListFeaturedMediaFiles({
@@ -1,238 +0,0 @@
import { useCallback, useEffect, useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faSparkles,
faTriangleExclamation,
} from "@fortawesome/pro-solid-svg-icons";
import { Button } from "@storyteller/ui-button";
import { FolderNameDialog } from "@storyteller/ui-gallery-modal";
import {
isActionReminderOpen,
showActionReminder,
} from "@storyteller/ui-action-reminder-modal";
import type { ProjectMeta } from "@storyteller/ui-video-editor";
import { TopBarActions } from "../../components/topbar/TopBarActions";
import { webappToastAdapter } from "./adapters/toast-adapter";
import type { WebappProjectStorage } from "./adapters/project-storage-adapter";
import {
NewProjectTile,
VideoProjectCard,
VideoProjectCardSkeleton,
} from "./components/VideoProjectCard";
// Landing view for /video-editor (no project in the URL): the user's saved
// video projects plus a "New project" tile. Styled to match the create
// pages and library: #101014 canvas, glow orbs, eyebrow + display heading,
// ui-controls cards with primary hover rings, kebab menus, and the app's
// standard confirm dialog.
const GRID_CLASSES =
"mt-8 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5";
const SKELETON_COUNT = 8;
interface VideoEditorProjectsLandingProps {
projectStorage: WebappProjectStorage;
onNewProject: () => void;
onOpenProject: (token: string) => void;
}
export function VideoEditorProjectsLanding({
projectStorage,
onNewProject,
onOpenProject,
}: VideoEditorProjectsLandingProps) {
const [status, setStatus] = useState<"loading" | "ready" | "error">(
"loading",
);
const [projects, setProjects] = useState<ProjectMeta[]>([]);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [renameTarget, setRenameTarget] = useState<ProjectMeta | null>(null);
const refresh = useCallback(async () => {
setStatus("loading");
try {
const listed = await projectStorage.listProjects();
setProjects(listed);
setStatus("ready");
} catch (error) {
console.error("Failed to list video projects:", error);
setProjects([]);
setStatus("error");
}
}, [projectStorage]);
useEffect(() => {
void refresh();
}, [refresh]);
const handleDeleteRequest = (project: ProjectMeta) => {
if (deletingId) return;
showActionReminder({
reminderType: "default",
title: `Delete "${project.name}"?`,
message: (
<p className="text-sm text-white/70">
This will permanently delete the project. This action cannot be
undone.
</p>
),
primaryActionText: "Delete",
secondaryActionText: "Cancel",
primaryActionBtnClassName: "bg-red text-white hover:bg-red/90",
onPrimaryAction: async () => {
try {
await deleteProject(project);
} finally {
isActionReminderOpen.value = false;
}
},
});
};
const deleteProject = async (project: ProjectMeta) => {
setDeletingId(project.id);
try {
await projectStorage.deleteProject(project.id);
setProjects((current) => current.filter((p) => p.id !== project.id));
} catch (error) {
console.error("Failed to delete video project:", error);
webappToastAdapter.error("Couldn't delete project", {
description:
error instanceof Error ? error.message : "Please try again",
});
} finally {
setDeletingId(null);
}
};
const submitRename = async (name: string) => {
const target = renameTarget;
setRenameTarget(null);
if (!target) return;
const trimmed = name.trim();
if (!trimmed || trimmed === target.name) return;
setProjects((current) =>
current.map((p) => (p.id === target.id ? { ...p, name: trimmed } : p)),
);
try {
await projectStorage.renameProject(target.id, trimmed);
} catch (error) {
console.error("Failed to rename video project:", error);
setProjects((current) =>
current.map((p) =>
p.id === target.id ? { ...p, name: target.name } : p,
),
);
webappToastAdapter.error("Couldn't rename project", {
description:
error instanceof Error ? error.message : "Please try again",
});
}
};
return (
<div className="relative h-full w-full overflow-y-auto bg-[#101014] text-white">
{/* Video-themed glow orbs (create-video recipe; absolute, not fixed,
since this page scrolls inside SidebarInset). */}
<div className="pointer-events-none absolute inset-0 z-0 overflow-hidden">
<div className="absolute left-1/2 top-[-10%] h-[700px] w-[700px] -translate-x-1/2 transform-gpu rounded-full bg-gradient-to-br from-blue-700 via-blue-500 to-[#00AABA] opacity-[0.10] blur-[120px]" />
<div className="absolute bottom-[-15%] left-[-10%] h-[500px] w-[500px] transform-gpu rounded-full bg-gradient-to-br from-[#00AABA] via-blue-500 to-purple-600 opacity-[0.07] blur-[120px]" />
</div>
{/* The global topbar is hidden on /video-editor routes; surface its
actions here like the editor header does. */}
<div className="absolute right-4 top-3 z-20">
<TopBarActions />
</div>
<div className="relative z-10 mx-auto w-full max-w-[1600px] px-3 pb-16 pt-10 sm:px-4 md:px-8 lg:px-12">
<header className="animate-fade-in-up">
<p className="text-[11px] font-semibold uppercase tracking-wider text-white/40">
Video editor
</p>
<h1 className="mt-1.5 font-display text-3xl font-semibold md:text-4xl">
Your <span className="font-serif-italic font-normal">projects</span>
</h1>
<p className="mt-2 text-base text-white/60">
Pick up where you left off, or start something new.
</p>
</header>
{status === "loading" && (
<div className={GRID_CLASSES}>
{Array.from({ length: SKELETON_COUNT }, (_, i) => (
<VideoProjectCardSkeleton key={i} />
))}
</div>
)}
{status === "ready" && projects.length > 0 && (
<div className={GRID_CLASSES}>
<NewProjectTile onClick={onNewProject} />
{projects.map((project, index) => (
<VideoProjectCard
key={project.id}
project={project}
index={index}
isDeleting={deletingId === project.id}
onOpen={() => onOpenProject(project.id)}
onRename={() => setRenameTarget(project)}
onDelete={() => handleDeleteRequest(project)}
/>
))}
</div>
)}
{status === "ready" && projects.length === 0 && (
<div className="animate-fade-in-up flex flex-col items-center justify-center py-28 text-center">
<h2 className="text-5xl font-semibold text-white md:text-6xl">
Make your first{" "}
<span className="font-serif-italic font-normal">edit</span>
</h2>
<p className="mt-3 text-lg text-white/80">
Arrange clips, add text and audio, export in minutes.
</p>
<Button
variant="primary"
icon={faSparkles}
onClick={onNewProject}
className="mt-8 h-12 rounded-full px-6 text-base font-semibold"
>
New project
</Button>
</div>
)}
{status === "error" && (
<div className="flex flex-col items-center justify-center gap-4 py-28 text-center">
<FontAwesomeIcon
icon={faTriangleExclamation}
className="text-3xl text-white/30"
/>
<div>
<div className="text-lg font-medium">
Couldn't load your projects
</div>
<p className="mt-1 text-sm text-white/50">
Something went wrong while fetching your saved edits.
</p>
</div>
<Button variant="secondary" onClick={() => void refresh()}>
Try again
</Button>
</div>
)}
</div>
<FolderNameDialog
isOpen={!!renameTarget}
title="Rename project"
initialValue={renameTarget?.name ?? ""}
confirmLabel="Rename"
onConfirm={(name) => void submitRename(name)}
onClose={() => setRenameTarget(null)}
/>
</div>
);
}
@@ -1,221 +0,0 @@
import { MediaFilesApi, ProjectsApi } from "@storyteller/api";
import { getThumbnailUrl } from "@storyteller/common";
import type {
EditorProject,
ProjectMeta,
ProjectStorageAdapter,
} from "@storyteller/ui-video-editor";
import { webappToastAdapter } from "./toast-adapter";
import { captureAndUploadProjectCover } from "./thumbnail-capture";
// Server-backed ProjectStorageAdapter over the project-document endpoints
// (`/v1/media_files/upload/project/video_timeline/{new,update/{token}}` +
// `/v1/media_files/project/list`). Replaces the lib's IndexedDB default in
// the webapp.
//
// Project ids: the host bootstraps fresh sessions with a transient
// `local-{uuid}` id; the first successful save creates the server row and
// every id after that is the media_file_token. The local→token mapping is
// kept here so debounced autosaves that still carry the local id route to
// the update endpoint, and `onProjectCreated` lets the host re-key the
// active project + URL.
const LOCAL_ID_PREFIX = "local-";
const UNTITLED_PROJECT_NAME = "Untitled project";
const COVER_CAPTURE_THROTTLE_MS = 60_000;
// The lib's ProjectStorageAdapter plus webapp-only operations the projects
// landing needs (rename without opening the editor).
export type WebappProjectStorage = ProjectStorageAdapter & {
renameProject(id: string, name: string): Promise<void>;
};
export function isLocalProjectId(id: string): boolean {
return id.startsWith(LOCAL_ID_PREFIX);
}
export function makeLocalProjectId(): string {
return `${LOCAL_ID_PREFIX}${crypto.randomUUID()}`;
}
export function createWebappProjectStorage({
onProjectCreated,
}: {
onProjectCreated?: (args: { localId: string; token: string }) => void;
} = {}): WebappProjectStorage {
const projectsApi = new ProjectsApi();
const filesApi = new MediaFilesApi();
// local-{uuid} → media_file_token, filled by the first create.
const localToRemote = new Map<string, string>();
// Per-project save chain: SaveManager already serializes autosaves, but
// renames call saveCurrentProject directly — the chain guarantees a
// create can never race a second create for the same project.
const saveQueues = new Map<string, Promise<void>>();
// Last cover capture per token: covers refresh on the first save of a
// session and then at most once a minute.
const coverCapturedAt = new Map<string, number>();
const resolveId = (id: string): string => localToRemote.get(id) ?? id;
const maybeCaptureCover = (id: string): void => {
const token = resolveId(id);
if (isLocalProjectId(token)) return;
const last = coverCapturedAt.get(token) ?? 0;
if (Date.now() - last < COVER_CAPTURE_THROTTLE_MS) return;
coverCapturedAt.set(token, Date.now());
void captureAndUploadProjectCover({ token });
};
const uploadDocument = async (envelope: EditorProject): Promise<void> => {
const name = envelope.name || UNTITLED_PROJECT_NAME;
const blob = new Blob([JSON.stringify(envelope.data)], {
type: "application/json",
});
const fileName = `${name}.timeline.json`;
const resolved = resolveId(envelope.id);
if (isLocalProjectId(resolved)) {
const response = await projectsApi.UploadNewProject({
projectType: "video_timeline",
blob,
fileName,
uuid: crypto.randomUUID(),
maybe_title: name,
});
if (!response.success || !response.data) {
throw new Error(response.errorMessage || "Upload failed");
}
localToRemote.set(envelope.id, response.data);
onProjectCreated?.({ localId: envelope.id, token: response.data });
return;
}
const response = await projectsApi.UpdateProject({
projectType: "video_timeline",
token: resolved,
blob,
fileName,
uuid: crypto.randomUUID(),
maybe_title: name,
});
if (!response.success) {
throw new Error(response.errorMessage || "Update failed");
}
};
return {
async saveProject(envelope) {
const queueKey = envelope.id;
const task = async () => {
try {
await uploadDocument(envelope);
// Fire-and-forget: by now a first save has filled localToRemote,
// so brand-new projects get a cover immediately.
maybeCaptureCover(envelope.id);
} catch (error) {
// Saves ship the full document, so a failed save is fully
// recovered by the next successful one — surface it and move
// on instead of leaving an unhandled rejection in the
// autosave path.
console.error("Project save failed:", error);
webappToastAdapter.error("Couldn't save project", {
description:
error instanceof Error ? error.message : "Please try again",
});
}
};
const prior = saveQueues.get(queueKey) ?? Promise.resolve();
const next = prior.then(task, task);
saveQueues.set(queueKey, next);
// Drop the chain entry once it settles with no successor queued, so
// ids (especially one-shot local-{uuid}s) don't accumulate forever.
void next.finally(() => {
if (saveQueues.get(queueKey) === next) saveQueues.delete(queueKey);
});
await next;
},
async loadProject(id) {
const token = resolveId(id);
if (isLocalProjectId(token)) return null;
const response = await filesApi.GetMediaFileByToken({
mediaFileToken: token,
});
const media = response.success ? response.data : undefined;
const cdnUrl = media?.media_links?.cdn_url;
if (!media || !cdnUrl) return null;
const documentResponse = await fetch(cdnUrl);
if (!documentResponse.ok) return null;
const data: unknown = await documentResponse.json();
return {
id: token,
name: media.maybe_title ?? UNTITLED_PROJECT_NAME,
updatedAt: new Date(media.updated_at).getTime(),
data,
};
},
async listProjects(): Promise<ProjectMeta[]> {
const response = await projectsApi.ListSessionProjects({
filter_project_type: "video_timeline",
page_size: 100,
});
if (!response.success || !response.data) {
throw new Error(response.errorMessage || "Couldn't list projects");
}
return response.data.map((row) => ({
id: row.token,
name: row.maybe_title ?? UNTITLED_PROJECT_NAME,
updatedAt: new Date(row.updated_at).getTime(),
thumbnailUrl:
getThumbnailUrl(row.cover_image?.maybe_links?.thumbnail_template, {
width: 600,
}) ??
row.cover_image?.maybe_links?.cdn_url ??
undefined,
}));
},
async deleteProject(id) {
const token = resolveId(id);
if (isLocalProjectId(token)) return;
const response = await filesApi.DeleteMediaFileByToken({
mediaFileToken: token,
asMod: false,
});
if (!response.success) {
throw new Error(response.errorMessage || "Delete failed");
}
},
async renameProject(id, name) {
const token = resolveId(id);
if (isLocalProjectId(token)) return;
// Title-only endpoint — no document re-upload. Known edge: an editor
// session open in another tab still autosaves its stale in-memory
// title and can overwrite this rename on its next save.
const response = await filesApi.RenameMediaFileByToken({
mediaToken: token,
name,
});
if (!response.success) {
throw new Error(response.errorMessage || "Rename failed");
}
},
async createProject(name) {
// The webapp defers server-row creation to the first save (see
// saveProject), so "creating" a project is purely local.
return {
id: makeLocalProjectId(),
name: name || UNTITLED_PROJECT_NAME,
updatedAt: Date.now(),
data: null,
};
},
};
}
@@ -1,91 +0,0 @@
import { MediaFilesApi, MediaUploadApi } from "@storyteller/api";
import { CanvasRenderer, EditorCore } from "@storyteller/ui-video-editor";
// Best-effort project cover capture: renders the active project's current
// frame and attaches it as the media file's cover image so the projects
// landing gets a real thumbnail. Covers are cosmetic — every failure path
// is a silent no-op so the save flow can never be affected.
//
// Mirrors the lib's own snapshot feature (RendererManager.createSnapshot):
// same CanvasRenderer + render tree from the same thread, so it doesn't
// contend with the live preview any more than the built-in snapshot does.
const COVER_WIDTH = 640;
const COVER_JPEG_QUALITY = 0.85;
export async function captureAndUploadProjectCover({
token,
}: {
token: string;
}): Promise<void> {
try {
const editor = EditorCore.getInstance();
const renderTree = editor.renderer.getRenderTree();
const activeProject = editor.project.getActive();
if (!renderTree || !activeProject) return;
// The save queue can outlive a project switch; only capture when the
// editor is still showing the project this save belongs to.
if (activeProject.metadata.id !== token) return;
if (editor.timeline.getTotalDuration() === 0) return;
const { canvasSize, fps } = activeProject.settings;
const renderTime = Math.min(
editor.playback.getCurrentTime(),
editor.timeline.getLastFrameTime(),
);
const renderer = new CanvasRenderer({
width: canvasSize.width,
height: canvasSize.height,
fps,
});
const fullCanvas = document.createElement("canvas");
fullCanvas.width = canvasSize.width;
fullCanvas.height = canvasSize.height;
await renderer.renderToCanvas({
node: renderTree,
time: renderTime,
targetCanvas: fullCanvas,
});
const coverHeight = Math.max(
1,
Math.round((COVER_WIDTH * canvasSize.height) / canvasSize.width),
);
const coverCanvas = document.createElement("canvas");
coverCanvas.width = COVER_WIDTH;
coverCanvas.height = coverHeight;
const context = coverCanvas.getContext("2d");
if (!context) return;
context.drawImage(fullCanvas, 0, 0, COVER_WIDTH, coverHeight);
const blob = await new Promise<Blob | null>((resolve) => {
coverCanvas.toBlob(
(result) => resolve(result),
"image/jpeg",
COVER_JPEG_QUALITY,
);
});
if (!blob) return;
const upload = await new MediaUploadApi().UploadImage({
blob,
fileName: "cover.jpg",
uuid: crypto.randomUUID(),
});
if (!upload.success || !upload.data) {
console.warn("Project cover upload failed:", upload.errorMessage);
return;
}
const attach = await new MediaFilesApi().UpdateCoverImage({
mediaFileToken: token,
imageToken: upload.data,
});
if (!attach.success) {
console.warn("Project cover attach failed:", attach.errorMessage);
}
} catch (error) {
console.warn("Project cover capture failed:", error);
}
}
@@ -1,191 +0,0 @@
import { useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faClapperboardPlay,
faEllipsis,
faPen,
faPlus,
faTrashCan,
} from "@fortawesome/pro-solid-svg-icons";
import { twMerge } from "tailwind-merge";
import { PopoverMenu } from "@storyteller/ui-popover";
import type { ProjectMeta } from "@storyteller/ui-video-editor";
import { formatTimeAgo } from "../../../lib/format-time-ago";
// Card, skeleton, and "new project" tile for the video projects landing.
// Visual language follows the library/create-page cards: ui-controls
// surface, primary hover ring, hover-revealed kebab menu, and the house
// dashed recipe for the new-item tile.
// Deterministic per-project placeholder art so thumbnail-less cards don't
// all look identical (blue/teal/purple/pink families, matching the
// create-page glow orbs).
const PLACEHOLDER_GRADIENTS = [
"from-blue-600/30 via-blue-500/15 to-[#00AABA]/20",
"from-purple-600/30 via-blue-500/15 to-pink-500/20",
"from-[#00AABA]/30 via-blue-600/15 to-purple-500/20",
"from-pink-500/25 via-purple-500/15 to-blue-600/20",
"from-indigo-500/30 via-blue-500/15 to-teal-500/20",
"from-sky-500/25 via-indigo-500/15 to-purple-600/20",
];
const STAGGER_STEP_MS = 30;
const STAGGER_MAX_STEPS = 8;
interface VideoProjectCardProps {
project: ProjectMeta;
index: number;
isDeleting: boolean;
onOpen: () => void;
onRename: () => void;
onDelete: () => void;
}
export function VideoProjectCard({
project,
index,
isDeleting,
onOpen,
onRename,
onDelete,
}: VideoProjectCardProps) {
const [thumbnailError, setThumbnailError] = useState(false);
const showThumbnail = !!project.thumbnailUrl && !thumbnailError;
return (
<div
className={twMerge(
"animate-fade-in-up group relative flex flex-col overflow-hidden rounded-xl bg-ui-controls/40 ring-1 ring-white/5 transition-all duration-200 hover:ring-2 hover:ring-primary-400/60",
isDeleting && "pointer-events-none opacity-50",
)}
style={{
animationDelay: `${Math.min(index, STAGGER_MAX_STEPS) * STAGGER_STEP_MS}ms`,
}}
>
<button
type="button"
onClick={onOpen}
aria-label={`Open ${project.name}`}
className="relative block aspect-video w-full cursor-pointer overflow-hidden text-start"
>
{showThumbnail ? (
<img
src={project.thumbnailUrl}
alt={project.name}
crossOrigin="anonymous"
loading="lazy"
onError={() => setThumbnailError(true)}
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-[1.03]"
/>
) : (
<div
className={twMerge(
"flex h-full w-full items-center justify-center bg-gradient-to-br",
gradientForProject(project.id),
)}
>
<FontAwesomeIcon
icon={faClapperboardPlay}
className="text-3xl text-white/25 transition-colors group-hover:text-white/40"
/>
</div>
)}
<div className="absolute inset-0 flex items-end bg-gradient-to-t from-black/70 to-transparent p-3 opacity-0 transition-opacity duration-200 group-hover:opacity-100">
<span className="rounded-full bg-white/15 px-3 py-1 text-xs font-semibold text-white backdrop-blur-sm">
Open project
</span>
</div>
</button>
<div className="min-w-0 px-3 py-2.5">
<div className="truncate text-sm font-medium text-white">
{project.name}
</div>
<div className="text-xs text-white/45">
Edited {formatTimeAgo(project.updatedAt)}
</div>
</div>
<div
className="absolute right-2 top-2 z-10 opacity-0 transition-opacity duration-75 focus-within:opacity-100 group-hover:opacity-100 [@media(pointer:coarse)]:opacity-100"
onClick={(e) => e.stopPropagation()}
>
<PopoverMenu
position="bottom"
align="end"
mode="default"
triggerIcon={
<FontAwesomeIcon icon={faEllipsis} className="text-base-fg" />
}
buttonClassName="h-7 w-7 p-0 rounded-full bg-ui-controls/60 hover:bg-ui-controls/90 text-base-fg border border-ui-controls-border"
panelClassName="w-max min-w-44 p-1"
closeOnUnhover
>
{(close) => (
<div className="flex flex-col">
<button
type="button"
className="flex w-full items-center gap-2 whitespace-nowrap rounded-md px-2 py-2 text-sm text-base-fg hover:bg-ui-controls/60"
onClick={(e) => {
e.stopPropagation();
close();
onRename();
}}
>
<FontAwesomeIcon icon={faPen} className="w-3.5" />
Rename
</button>
<button
type="button"
className="flex w-full items-center gap-2 whitespace-nowrap rounded-md px-2 py-2 text-sm text-red hover:bg-red/10"
onClick={(e) => {
e.stopPropagation();
close();
onDelete();
}}
>
<FontAwesomeIcon icon={faTrashCan} className="w-3.5" />
Delete
</button>
</div>
)}
</PopoverMenu>
</div>
</div>
);
}
export function VideoProjectCardSkeleton() {
return (
<div className="flex flex-col overflow-hidden rounded-xl ring-1 ring-white/5">
<div className="animate-shimmer aspect-video w-full bg-white/[0.04]" />
<div className="space-y-2 px-3 py-3">
<div className="animate-shimmer h-3.5 w-2/3 rounded bg-white/[0.06]" />
<div className="animate-shimmer h-3 w-1/3 rounded bg-white/[0.05]" />
</div>
</div>
);
}
export function NewProjectTile({ onClick }: { onClick: () => void }) {
return (
<button
type="button"
onClick={onClick}
className="group flex h-full w-full cursor-pointer flex-col items-center justify-center gap-2.5 rounded-xl border border-dashed border-white/20 bg-white/[0.02] py-10 transition-colors hover:border-white/40 hover:bg-white/[0.05] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/60"
>
<span className="flex h-9 w-9 items-center justify-center rounded-full bg-white/5 text-white/55 transition-colors group-hover:bg-primary/15 group-hover:text-primary">
<FontAwesomeIcon icon={faPlus} />
</span>
<span className="text-sm font-medium text-white/70">New project</span>
</button>
);
}
function gradientForProject(id: string): string {
let hash = 0;
for (let i = 0; i < id.length; i++) {
hash = (hash * 31 + id.charCodeAt(i)) >>> 0;
}
return PLACEHOLDER_GRADIENTS[hash % PLACEHOLDER_GRADIENTS.length];
}
@@ -1,6 +1,5 @@
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { LoadingSpinner } from "@storyteller/ui-loading-spinner";
import { useEffect, useMemo, useRef, useState } from "react";
import { useParams } from "react-router-dom";
import {
VideoEditor,
EditorCore,
@@ -15,26 +14,15 @@ import { webappAuthUserAdapter } from "./adapters/auth-user-adapter";
import { webappMediaSourceAdapter } from "./adapters/media-source-adapter";
import { webappExportSinkAdapter } from "./adapters/export-sink-adapter";
import { useWebappAssetGalleryAdapter } from "./adapters/asset-gallery-adapter";
import {
createWebappProjectStorage,
isLocalProjectId,
makeLocalProjectId,
} from "./adapters/project-storage-adapter";
import { VideoEditorProjectsLanding } from "./VideoEditorProjectsLanding";
import { TopBarActions } from "../../components/topbar/TopBarActions";
// Webapp host for the @storyteller/ui-video-editor lib.
//
// Routing:
// /video-editor → saved-projects landing (list + new)
// /video-editor/local-{uuid} → fresh in-memory project; the first
// autosave creates the server row and
// rewrites the URL to the token
// /video-editor/{token} → load the project document from the
// server (ProjectsApi / video_timeline)
//
// Persistence goes through createWebappProjectStorage; the remaining
// adapters route uploads/gallery/toasts/session through the webapp.
// Bootstraps a default in-memory project so the editor shell can mount
// with an active scene (replace with a real load via
// ProjectStorageAdapter once the backend exists), and injects the
// webapp adapters that route through Artcraft's MediaUploadApi /
// MediaFilesApi / gallery modal / session store / toast event bus.
function buildBootstrapProject({ id }: { id: string }): TProject {
const scene = buildDefaultScene({ name: "Main scene", isMain: true });
@@ -60,47 +48,11 @@ function buildBootstrapProject({ id }: { id: string }): TProject {
export default function VideoEditorPage() {
const { projectId } = useParams<{ projectId?: string }>();
const navigate = useNavigate();
const navigateRef = useRef(navigate);
navigateRef.current = navigate;
const [ready, setReady] = useState(false);
const { adapter: assetGalleryAdapter, modal: galleryModal } =
useWebappAssetGalleryAdapter();
const projectStorage = useMemo(
() =>
createWebappProjectStorage({
onProjectCreated: ({ localId, token }) => {
// First save of a fresh project: re-key the active project to
// the server token, then swap the URL. The session effect's
// "active id already matches the route" check makes the
// navigation a no-op instead of a re-bootstrap.
const editor = EditorCore.getInstance();
const active = editor.project.getActive();
if (
active &&
isLocalProjectId(active.metadata.id) &&
active.metadata.id === localId
) {
editor.project.setActiveProject({
project: {
...active,
metadata: { ...active.metadata, id: token },
},
});
}
// Only rewrite the URL if the user is still on this project's
// route — a teardown flush can finish after they navigated
// elsewhere, and yanking them back into the editor would be
// hostile. The landing lists the project either way.
if (window.location.pathname === `/video-editor/${localId}`) {
navigateRef.current(`/video-editor/${token}`, { replace: true });
}
},
}),
[],
);
const adapters = useMemo<Partial<VideoEditorAdapters>>(
() => ({
toast: webappToastAdapter,
@@ -108,46 +60,16 @@ export default function VideoEditorPage() {
mediaSource: webappMediaSourceAdapter,
assetGallery: assetGalleryAdapter,
exportSink: webappExportSinkAdapter,
projectStorage,
}),
[assetGalleryAdapter, projectStorage],
[assetGalleryAdapter],
);
if (!projectId) {
return (
<VideoEditorProjectsLanding
projectStorage={projectStorage}
onNewProject={() =>
navigate(`/video-editor/${makeLocalProjectId()}`)
}
onOpenProject={(token) => navigate(`/video-editor/${token}`)}
/>
);
}
return (
<VideoEditorSession
key={projectId}
projectId={projectId}
adapters={adapters}
galleryModal={galleryModal}
/>
);
}
function VideoEditorSession({
projectId,
adapters,
galleryModal,
}: {
projectId: string;
adapters: Partial<VideoEditorAdapters>;
galleryModal: ReactNode;
}) {
const navigate = useNavigate();
const [status, setStatus] = useState<"pending" | "ready" | "missing">(
"pending",
);
// Stable transient id for the "no projectId in route" case. Computed
// once on first mount and reused across StrictMode double-mount,
// adapter ref churn, and any other dep change so we never overwrite
// the in-memory project with a fresh bootstrap.
const transientIdRef = useRef<string>(`local-${crypto.randomUUID()}`);
const resolvedProjectId = projectId ?? transientIdRef.current;
useEffect(() => {
// Explicit initialize ensures the webapp adapters are installed
@@ -159,78 +81,21 @@ function VideoEditorSession({
});
const editor = EditorCore.getInstance();
let cancelled = false;
// Already active (StrictMode remount, or the post-first-save URL
// rewrite from local id to token): nothing to do.
if (editor.project.getActive()?.metadata.id === projectId) {
setStatus("ready");
} else if (isLocalProjectId(projectId)) {
// Tear down whatever project was open before bootstrapping — this
// path is reachable without the header's exit flow (Back button,
// "New project"), and a leftover media bin or undo stack would
// bleed into the fresh project and persist on its first save.
editor.project.closeProject();
const project = buildBootstrapProject({ id: projectId });
// Bootstrap only when the active project doesn't already match the
// requested id. StrictMode mount → unmount → re-mount with the same
// id is a no-op; a route nav to a different :projectId reloads.
if (editor.project.getActive()?.metadata.id !== resolvedProjectId) {
const project = buildBootstrapProject({ id: resolvedProjectId });
editor.project.setActiveProject({ project });
editor.scenes.initializeScenes({
scenes: project.scenes,
currentSceneId: project.currentSceneId,
});
// closeProject stopped the autosave subscriptions; re-arm them.
editor.save.start();
setStatus("ready");
} else {
setStatus("pending");
// openProject performs its own full teardown of any prior project.
editor.project
.openProject({ id: projectId })
.then((project) => {
if (!cancelled) setStatus(project ? "ready" : "missing");
})
.catch((error) => {
console.error("Failed to open project:", projectId, error);
if (!cancelled) setStatus("missing");
});
}
setReady(true);
}, [resolvedProjectId, adapters]);
return () => {
cancelled = true;
};
}, [projectId, adapters]);
// Teardown is keyed on projectId alone so it fires exactly once, on
// true unmount (key={projectId} remounts the component per project) —
// never on an incidental adapter identity change mid-session.
useEffect(() => {
return () => teardownEditorSession(projectId);
}, [projectId]);
if (status === "missing") {
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-4">
<div className="text-lg font-medium">Couldn't open this project</div>
<p className="max-w-sm text-center text-sm opacity-60">
It may have been deleted, or it belongs to another account.
</p>
<button
onClick={() => navigate("/video-editor")}
className="rounded-lg border border-white/15 px-4 py-2 text-sm transition-colors hover:bg-white/10"
>
Back to projects
</button>
</div>
);
}
if (status === "pending") {
return (
<div className="flex h-full w-full items-center justify-center gap-3">
<LoadingSpinner />
<span className="font-medium opacity-70">Loading project...</span>
</div>
);
}
if (!ready) return null;
return (
<div className="h-full w-full overflow-hidden">
@@ -238,35 +103,8 @@ function VideoEditorSession({
projectId={projectId}
adapters={adapters}
headerEndSlot={<TopBarActions />}
exitTo="/video-editor"
/>
{galleryModal}
</div>
);
}
// Unmount teardown for a session. EditorCore is a singleton, so leaving the
// route (sidebar link, Back button) without this would keep the project
// active with live autosave subscriptions — a stale debounce could fire a
// server write long after the user left, and the next session would open on
// top of leaked state.
//
// Ordering matters: saveCurrentProject serializes the document synchronously
// (before its first await), so pending edits are captured even though
// closeProject clears the editor immediately after — the upload continues in
// the background against the captured snapshot. Skipped when nothing is
// dirty so an untouched bootstrap project never creates an empty server row.
function teardownEditorSession(projectId: string): void {
const editor = EditorCore.getInstance();
if (editor.project.getActive()?.metadata.id !== projectId) {
// Another session (different :projectId key) already took over the
// singleton — it owns teardown now.
return;
}
if (editor.save.getIsDirty()) {
editor.project.saveCurrentProject().catch((error) => {
console.error("Final save on editor exit failed:", error);
});
}
editor.project.closeProject();
}
@@ -341,39 +341,6 @@
animation: toast-in 0.2s ease-out both;
}
/* Prompt box drag & drop overlay */
@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;
}
@keyframes marquee {
0% {
transform: translateX(0);
@@ -76,9 +76,6 @@
{
"path": "../../libs/components/gallery-modal/tsconfig.lib.json"
},
{
"path": "../../libs/components/lightbox-modal/tsconfig.lib.json"
},
{
"path": "../../libs/components/switch/tsconfig.lib.json"
},
@@ -41,9 +41,6 @@
{
"path": "../../libs/components/gallery-modal"
},
{
"path": "../../libs/components/lightbox-modal"
},
{
"path": "../../libs/components/switch"
},

Some files were not shown because too many files have changed in this diff Show More