Closes the two remaining fully-local-stack seams found after the last round.
Uploads (previously every upload endpoint returned 500 with dummy S3 creds -
the bucket put happens before the DB insert and rust-s3 is fail-on-err):
- LegacyBucketClient gains an internal backend enum (S3 unchanged | LocalDir)
and a create_local(root, bucket_name, root_prefix) constructor. All upload/
download methods route LocalDir to the filesystem; bucket_name() reports the
configured name. Objects map disk = root + rooted object path via the new
pub local_object_disk_path (rejects ".." and backslash segments - PMX zips
feed user-controlled names), unit-tested. The fake-generation resolver now
uses the same function instead of its private copy so the layouts can't
drift.
- build_dependencies: when SERVER_ENVIRONMENT=Development and
LOCAL_MEDIA_ROOT is set, all three bucket clients are constructed in local
mode (private/gc have zero callers in storyteller-web, so this is purely
uniform); the server_environment read moved above client construction.
Uploads land under .devstack/media and are immediately viewable through
the existing /media mount + CDN_BASE_URL - no S3/R2/MinIO.
Frontend (pagescene was the only surface actually broken against a local
backend; moodboard/library/create-* already consume media_links correctly):
- The vite /__cdn dev proxy assumed https upstreams, so localhost media
(http) died on a TLS handshake with a 502 - breaking every pagescene
GLB/splat/animation fetch. fetchAsset no longer proxies localhost at all
(the dev backend sends CORS for any localhost origin), and the proxy
itself now picks http for localhost hosts as a backstop.
- GetCdnOrigin() returns the API host when it is localhost (the local
backend serves media itself; CDN origin == API origin), production CDN
otherwise. The pagescene adapter's getCdnUrl skips the Cloudflare
/cdn-cgi/image resize segment on localhost - the local backend has no
resizer, and dev serves originals anyway.
- vite /v1 fallback proxy now targets http://localhost:12345 instead of
production, so nothing can silently fall back to prod traffic in dev;
fixed the stale main.tsx comment claiming setDevelopment() is disabled.
Docs: seams table updated (uploads + vite proxy + GetCdnOrigin closed;
API_TARGETS/SegmentationApi noted as dead code; the earlier moodboard claim
was wrong - it never had a hardcoded host), LOCAL_MEDIA_ROOT now documents
the storage mode, verification checklist gains the upload check.
Verified E2E on this machine: POST /v1/media_files/upload/image (multipart,
real endpoint) returns success, the object lands under .devstack/media at
the canonical fan-out path, lists in the library with media_class=image,
and serves 200 image/jpeg from http://localhost:12345/media/...;
cloud_storage unit tests pass; storyteller-web + webapp dev server boot
clean with "Local filesystem storage mode is ON".
Closes the two gaps found smoke-testing the local stack: an empty gallery
(zero media_files rows, media URLs hardcoded to the remote r2.dev bucket)
and generation flows that could not complete (omni_gen calls providers
synchronously in the handler, so dummy keys meant a 500 after billing;
the webapp blocked submits earlier anyway because signup grants no wallet).
Backend (all development-only):
- CDN_BASE_URL env override in cdn_link.rs (host-only, validated loudly;
precedence over the compiled-in hosts for every domain/env) and a
dev-gated actix-files mount serving LOCAL_MEDIA_ROOT/media at /media,
mirroring the public bucket layout - media URLs in API responses resolve
against the backend itself
- DEV_FAKE_GENERATION flag on ServerState (easyenv bool, forced off outside
Development): the omni_gen image/video pipelines skip the external
provider call and return a synthetic Fal enqueue (external id fake_<uuid>),
so billing, prompt insert, and the pending generic_inference_jobs row all
stay real
- dev_fake_generation_resolver_thread: polls pending fake_ jobs (new
runtime-query list_pending_fake_generation_jobs in mysql_queries) and
after DEV_FAKE_GENERATION_RESOLVE_SECS (default 6s) marks them
complete_success with a media_files row backed by a test_data asset
copied under LOCAL_MEDIA_ROOT (mp4 results get -thumb.jpg/-thumb.gif
siblings since the thumbnail job never runs locally), or complete_failure
via the existing simulate_artcraft_failure prompt triggers (category word
supported, e.g. "simulate_artcraft_failure face_not_detected")
Bootstrap/seeds (PS + bash kept in lockstep):
- seed_demo_user seeds an artcraft wallet (DEMO_CREDITS, default 100k
banked + ledger rows; idempotent top-up) - signup creates no wallet and
the webapp gates generate on balance, which is why submits went nowhere
- seed_demo_user also seeds 6 gallery images (test_data jpgs) with objects
under .devstack/media in the exact bucket fan-out layout
- fixed the already-exists detection in seed_demo_user: create_account
returns error_type, not error_code, so re-runs died on UsernameTaken
- generated secrets env gains DEV_FAKE_GENERATION/CDN_BASE_URL/
LOCAL_MEDIA_ROOT (appended to existing files on bootstrap re-runs);
stale "providers are only contacted by workers" comments corrected
- doctor checks wallet balance, media rows, and the new secrets keys
Docs: dev_setup_local_stack.md documents the new flags, corrects the
sync-provider-call and upload-500 claims, updates the seams table and
verification checklist; storyteller_web CLAUDE.md video model examples
fixed (seedance_2p0_fast_g -> seedance_2p0_fast).
Verified E2E on this machine: gallery lists 8 items on localhost URLs;
image and video submits go pending ~6s then complete with locally-served
results (mp4 + gif preview 200); failure trigger yields complete_failure
with category face_not_detected; credits genuinely deducted (100000 ->
99932); doctor 0 failures / 0 warnings.
Pass 1 (correctness sweep):
- All native invocations (mysql, mysqld init, winget, curl downloads,
cargo run, seed curl) now go through Invoke-Native, so a caller capturing
the scripts' output can never turn ordinary stderr (warnings, progress)
into a terminating NativeCommandError under Stop preference
- Provisioning DDL feeds through a temp file + `source` and prints the mysql
output when it fails (with the MYSQL_ROOT_PASSWORD hint)
- Get-MySqlCount distinguishes "table empty" (0) from "query failed" (-1);
bootstrap and doctor no longer misread a failed COUNT as unseeded
- diesel_cli that is present but broken (moved libmysql.dll) is detected via
an exit-code probe and reinstalled with --force
- Shared Add-BuildToolsToSession maps winget install dirs + LIBCLANG_PATH in
bootstrap, doctor, and run_backend_dev (cargo run may recompile); the tools
check maps PATH before probing so re-runs skip winget entirely
- my.ini values quoted (clone paths with spaces); service-start timeouts
point at the .devstack log files
Pass 2 (live retest matrix, all green on this machine):
- Full re-run with output captured in-PowerShell: exit 0, no NativeCommandError
- Wiped-state rebuild (mysql-data/my.ini/redis/secrets deleted): re-init,
re-provision, 281 migrations replayed, seeds + secrets regenerated, backend
booted, demo user recreated via /v1/create_account, doctor 0 fail / 0 warn
- Failure paths: seed without backend and doctor without services both die
cleanly with actionable messages and exit 1; stop_dev_services frees both
ports gracefully
Adds script/bootstrap/windows/*.ps1 (PowerShell 5.1-compatible) as the primary
bootstrap path, validated end-to-end on a real Windows 10 machine:
- Portable stack under gitignored .devstack/: MySQL 8.4.11 (official
cdn.mysql.com zip, --initialize-insecure, bound to 127.0.0.1) and Redis
5.0.14.1 (tporadowski build, REDIS_ZIP_URL-overridable) run as plain user
processes - no admin, no services, no WSL, no Docker
- bootstrap_dev_stack.ps1: winget-installs missing build tools (Strawberry
Perl, NASM, LLVM; wreq's BoringSSL + aws-lc-sys need them), compiles
diesel_cli against the portable MySQL's own client lib
(MYSQLCLIENT_LIB_DIR), runs all 281 migrations, seeds roles/badges
idempotently, generates the dummy secrets env (plus TEMP_DIR for Windows),
builds storyteller-web, installs frontend deps
- start/stop_dev_services.ps1, run_backend_dev.ps1, seed_demo_user.ps1,
dev_stack_doctor.ps1 mirror the bash variants
- Invoke-Native (cmd /c ... 2>&1) wraps long-running native calls so PS 5.1
cannot turn harmless stderr warnings into terminating NativeCommandErrors
when output is captured; my.ini uses forward slashes (MySQL escape rules)
Verified on this machine: 281/281 migrations applied, backend built (3m05s)
and booted on :12345 with dummy secrets, POST /v1/create_account +
/v1/login round-trip succeeded, webapp dev server served on :4201, doctor
reports 0 failures. The bash scripts remain the Linux/WSL2/CI path.
Adds script/bootstrap/ — idempotent Ubuntu 22.04+/WSL2/CI automation that takes
a fresh clone to a running local stack with zero production traffic:
- bootstrap_dev_stack.sh: apt packages, MySQL 8 + Redis up, storyteller DB/user,
281 diesel migrations, idempotent role/badge seeds, generated dummy
development-secrets.env (19 required-at-boot vars no checked-in config
supplies), SQLX_OFFLINE backend build, frontend npm install
- run_backend_dev.sh / seed_demo_user.sh: run the API from the repo root and
create+verify a demo login through the real POST /v1/create_account path
- dev_stack_doctor.sh: PASS/WARN/FAIL diagnosis of every prerequisite
_docs/dev_setup_local_stack.md records the requirements the scripts encode and
corrects stale claims (server reads MYSQL_URL, not DATABASE_URL; repo-root .env
is not loaded by the server; config precedence is first-value-wins;
SQLX_OFFLINE is compile-time only).
.gitattributes now forces LF for *.sh/*.env so Windows clones run under WSL2.
- Create flow stored `inference_job_token` as the `character_token`, so
mentioning
a new character sent a bogus token - backend drops it and name-matches
the
raw `@name` against stale characters.
- Don't add pending characters to the mention store; they aren't
generatable yet.
- Keep the pending poll running after the modal closes and sync real
tokens into
the store once the character is active.
- Remove unused `addCharacter` store action.
- Same fix in all three copies: webapp, website, shared `promptbox` lib.
Ports the Video Frame Extractor from the desktop app to the webapp at
`/frame-extractor`, with a reworked UI.
### What's new
- New Frame Extractor page: upload a video, pick one from your library,
or drag and drop
- Scrub to any moment and capture the exact frame at the playhead with
one click
- Frame stepping buttons and keyboard shortcuts (arrows to step, Space
to play/pause, C or Enter to capture)
- Burst capture: grab a sequence of frames to find the sharpest one,
with progress and cancel
- Each captured frame can be used as an image reference, animated into a
video, saved to the library, or downloaded
- Save all and clear actions for captured frames
### Integrations
- "Extract frames" action on library videos (selection bar and lightbox)
opens the extractor with that video preloaded
- Sends frames straight into the create-image / create-video reference
deck, same flow as "Send to prompt"
- New sidebar entry (purple NEW badge) and home page card
- Page is public; saving and references prompt sign-up when logged out
- Adds a **Send to prompt** action to the library's multi-select bar
with two
targets: **Create image** and **Create video**
- Selected images are attached as reference images on the target page
without
touching the user's typed prompt or settings; selection is cleared and
the
app navigates to the create page
- Selected videos can be sent to the video prompt as reference videos;
models
without video-reference support (only Seedance has it) get an
explanatory
toast instead of a silent drop
- Media travels via `pendingRefImages` / `pendingRefVideos` store slots
(mirroring the existing `pendingRecreate` pattern) and is merged on the
receiving page once its model list is loaded, so the **real per-model
reference caps** apply - fixes silent no-ops when refs were already
attached
- Merging dedupes by media token, clamps to the model cap, and toasts
partial
adds ("Added 3 of 5 …"), full decks, and unsupported models
- Keyframe-only video models take the first sent image as the start
frame;
reference-capable models switch into reference mode so the deck is
visible
- DRY: shared `galleryItemToRefImage` mapper (reused by the create-image
library picker) and `buildVideoRefsToAdd` (reused by the video-ref
picker,
duration probing + total-duration cap)
- Bar UX: option list adapts to the selection (video-only hides Create
image),
icon-only button on phones to avoid overflowing the bar
## Changes
**Batch download**
- Add multi-select mode to the create-video results feed (webapp +
desktop) with a "Select" toggle in the topbar, checkboxes on completed
items, and a floating download bar
- Webapp: "Download" bundles selected videos into a single .zip
client-side (new jszip dependency, concurrent fetches, progress and
partial-failure toasts)
- Desktop: "Download" prompts for a folder and saves each selected video
as its own file via native Tauri save, with progress toasts
- Library: add a Download (zip) button to the existing bulk selection
bar
- Shared `ui-generation-list` lib gains a selection store, select
toggle, action bar, and optional selection props on the grid/list views
(existing hosts unaffected)
**Credits refresh on failed generations**
- Webapp: dispatch a credits refresh when the task queue or generation
feed observes a newly failed job (immediately and again after 2.5s so
server-side refunds settle)
- Desktop: also refresh credits when the 5s task poll discovers a
failure that the push event missed, and listen for `credits-change` in
the TopBar
**UI fixes**
- Selection download bar always sits above the promptbox regardless of
its size
- Mobile Generate/History header no longer overlaps the gallery toggles
(flex layout instead of absolute positioning)
- Reference deck top-aligns in the expanded prompt box instead of
centering
- "Play videos with sound" setting now defaults to on
- Drop images, videos, or audio anywhere on the prompt box - files are
sorted into the right reference type based on what the current model
supports
- Drag-over overlay shows which file kinds are accepted; turns red when
the hovered files aren't usable
- Video keyframe mode: dropped images fill the start frame, then the end
frame
- Unsupported kinds, full slots, and non-media files get a clear toast
instead of failing silently
- Reuses the existing `useDeckMedia` upload pipelines, so limits and
error handling match the "+" pickers
Users reported that after using **Recreate**, pressing the generate
arrow did nothing at all (no error, no card, no spinner). Root cause:
several failure paths gave zero feedback, so a rejected request looked
like a dead button or an outage.
### Changes
- **Show real error messages.** The API layer now keeps the server's
error message (e.g. "Image is required") instead of throwing it away, so
users see why a generation was rejected.
- **Toast on every failed submit.** Image and video pages (webapp +
website) now show an error toast when a generation fails to start.
Previously, most failures were recorded invisibly and never shown.
- **Out of credits feedback on the website.** 402 responses now show a
toast (webapp keeps its upgrade modal).
- **Network errors are announced** instead of failing silently.
- **No more silent submit blocks.** Cases that used to quietly ignore
the click now explain themselves:
- Model list still loading
- Model needs a starting image (website; also un-disabled the button so
clicking gives feedback)
- **Recreate model fallback is announced.** If the original generation's
model is no longer available, recreate now tells the user it switched to
the default model instead of silently swapping.
## Summary
Compacts the legacy reference bands above the prompt box (audio ref row,
multi-view/input-mesh row, reference video row) into the reference deck
design already used on the image and video pages. Mobile forms keep
their existing rows.
## Changes
- **Create Audio** - audio tracks are now deck cards (playable tile,
duration badge) with Upload / From library in the deck's "+" menu; band
removed. Mutual-exclusion and "Audio track required" behavior unchanged.
- **Create 3D World** - guide video is now a deck card; store reworked
to the shared `RefVideo[]` shape.
- **Create 3D Object** - Front/Back/Left/Right are always-visible
labeled slot cards next to the image-ref deck (same design as First/Last
frame on video), plus a "Mesh" slot card for mesh-to-mesh models. Upload
logic deduplicated into `mesh-upload.ts`.
- **Webapp PromptBox** - removed the `mediaReferenceRow` band prop; deck
video/audio groups gate on `videoRefsSupported`/`audioRefsSupported` (no
longer video-page-only); new `referenceSlots` prop; `maxImages: 0`
blocks image uploads on pages whose model takes no image refs.
- **Deck lib** - exported `DeckSlotCard`; new `mesh` card kind;
`sortable: false` for fixed slots; non-finite duration caps hide the
seconds limit; collapsed fan width now tracks card count, so a single
ref sits snug against the prompt (multi-ref unchanged).
- Tags now work in the desktop app - no separate build, it all comes
through the shared gallery/lightbox components
- Open any item in the gallery → edit its tags in the details panel
(same chip editor as the webapp, with autocomplete)
- New "Tags" section in the gallery sidebar: your 5 most-used tags + an
"All tags" entry
- Click a tag to see all media with that tag in the normal grid
- "All tags" opens a word cloud (bigger = used more), click a word to
jump in
- Webapp sidebar now also caps at 5 tags with a "See all tags" link
- The tag editor is now one shared component used by both apps instead
of two copies
- Tags editor in LightboxDetails (lightbox + /media/:id): chip input
with autocomplete over the user's existing tags, comma/paste splitting,
duplicate rejection, owner gating via session, and serialized+coalesced
SetMediaFileTags snapshot saves with optimistic chips and error revert
(components/tags/TagChipInput.tsx, TagsSection.tsx)
- Tag browser: "Tags" tab in the library with name/most-used sort, tag
view at /library/tag_… reusing the media grid, breadcrumb header with
rename (FolderNameDialog) and delete (confirm modal), tag context menu
- Sidebar: tags rows under the Assets group below folders, with
expand/collapse chevrons on both Folders (default open) and Tags
(default closed), animated via framer-motion; tag icons use violet to
distinguish them from folders
- New library-tags-store mirroring the folders store (cursor-paged media
caches, optimistic rename/delete with toast revert); shared item mappers
extracted to library-media-map.ts to avoid a circular import; media
deletion now purges tag caches; bulk-selection bar resolves thumbnails
from tag caches too
- Topbar breadcrumbs map /library/tags and tag views to "Tags"
- Tags show as word cloud
Redesigns how references are attached in the image and video
promptboxes, on both the desktop app and the webapp (desktop layout -
mobile is unchanged).
### Reference deck
- Replaces the image-toggle icon and the upload bands above the prompt
with a fanned stack of reference cards inside the promptbox, left of the
prompt text
- Hovering the deck expands it to show every attached ref - no
scrolling, it widens toward the end of the promptbox and wraps into more
rows as needed (ready for up to 50 refs for Seedance 2.5)
- Cards support drag-to-reorder (grab/grabbing cursors), hover remove,
name tooltips (Image 1, Video 2, …), upload progress spinners on
individual cards, and click-to-preview fullscreen
- A circular **+** overlaps the deck: hovering shows an add menu grouped
by type (Image / Video / Audio) with compact limits (e.g. `2/9`, `1/3 ·
8/15s`); clicking it opens a file picker that accepts any supported
media and sorts files by type automatically
### Video modes
- Keyframe mode: Start/End frame band replaced with compact "First frame
⇄ Last frame" cards with a swap button
- Reference mode renamed to **Omni reference** and is now the default;
images, videos, and audio all stack into one mixed deck
### Generate button
- Replaced the "Generate" pill with a circular arrow-up button, credit
cost shown beside it
### Under the hood
- New shared deck components live in `@storyteller/ui-promptbox` and are
used by both apps (previously the two promptbox implementations were
fully separate)
- New `GenerateIconButton` in `@storyteller/ui-button`; the old
`GenerateButton` stays for mobile
- Webapp mobile, audio/object/world pages, and the 2D/3D/Edit
promptboxes keep their existing UI
Deletes dormant legacy API surface (zero live callers, verified against
the full backend route table):
- VoiceDesignerApi, VoiceConversionApi, AnalyticsApi
(/v1/analytics/log_session was never registered) - both @storyteller/api
and the Tauri app's ApiManager copies, plus their tests
- PromptsApi/EngineApi: /v1/image_studio/prompt,
/v1/image_studio/session_jobs, /v1/jobs/session/{token} methods (routes
do not exist)
- UserBookmarksApi: broken-path list methods (/list/{user} and
/list/{et}/{etok}; backend shapes are /list/user/{u} and
/list/entity/{et}/{etok}) and the FakeYou-era scoped enums that only
they consumed
- Dashboard UsersApi: RequestPasswordReset/ResetPassword pointing at
removed /v1/request_password_reset + /v1/reset_password (live pages use
PasswordResetApi -> /v1/password_reset/* correctly)
Fixes WeightsApi cover-image path to the registered route:
/v1/weights/weight/{token}/cover_image (both copies).
- The "+" button on the Video Ref row now shows options on hover:
**Upload** or **Pick from library** (on mobile it opens an "Add video"
sheet)
- Picking from library opens the gallery in select mode, filtered to
videos only
- Library videos respect the same limits as uploads (max count and total
duration, with the same error toasts)
- Reused the existing add-button component from the image row so all
reference rows look and behave the same