Compare commits

...
Author SHA1 Message Date
Brandon Thomas 6abf4e57f5 upload for analysis 2026-06-19 23:34:01 -04:00
Brandon Thomas d26a45f7f7 upload 2026-06-19 23:25:01 -04:00
Brandon Thomas 63bc5f7878 setup 2026-06-19 23:05:25 -04:00
Brandon Thomas 67c2c33eb1 object name 2026-06-19 22:59:21 -04:00
Brandon Thomas fd25e2de31 edition 2026-06-19 22:51:15 -04:00
Brandon Thomas b95750f15e bucket client 2026-06-19 22:49:51 -04:00
Brandon Thomas bf373b1d5c netlify configs 2026-06-19 21:27:36 -04:00
Brandon Thomas 914c2532ec uploaded videos - tables, endpoints, frontend (#1668) 2026-06-19 21:17:45 -04:00
Brandon Thomas c7341d8f63 video info website (#1667) 2026-06-19 13:42:34 -04:00
Brandon Thomas 92e99cfcb6 video info handler (#1666) 2026-06-19 10:44:22 -04:00
Brandon Thomas 754cdb8a6c parse video info (#1665) 2026-06-19 10:24:01 -04:00
Brandon Thomas 7b9db101fe fix float/int credits parsing (#1664) 2026-06-18 19:37:13 -04:00
BFlat e94987e5a4 Restyle manifesto "control" emphasis as a tilted marker highlight
Swap the underline for a shared MarkerHighlight SVG stroke behind the word. Desktop reveal wipes left-to-right via clip-path instead of scaleX so the stroke is uncovered, not stretched.
2026-06-18 23:05:09 +07:00
BFlat 47d83be3fb Landing page polish: manifesto, feature deck, model marquee, communit… (#1663)
…y CTA

- Manifesto: drop "3D" and animate a blue underline on "control"
- Feature cards: scroll-driven stacking deck (scale back + fade once
covered)
- Use Every Model: fill chips from canonical model list with correct
brand icons
- Use Every Model: alternating-direction marquees, highlight on hover
- Stop renting: italicize "renting"; make the good/bad comparison read
clearly
- Community CTA: "Join our community" banner restyled to fit site + not
steal focus from final CTA
2026-06-18 11:25:24 +07:00
bflatastic fa4b57980c Webapp: rounded corner ui updates for library page 2026-06-17 22:15:57 +07:00
Brandon Thomas 87d060e68c fixes for frontend build (#1660) 2026-06-16 03:03:38 -04:00
Brandon Thomas fc7651071c more frontend build fixes (#1659) 2026-06-16 02:53:06 -04:00
Brandon Thomas 7a0bfa35e6 fixes for frontend build failures (#1658) 2026-06-16 02:41:36 -04:00
Brandon Thomas 0b4e288a3e refactor another module location 2026-06-16 01:53:13 -04:00
Brandon Thomas a0b6047a78 refactor module location 2026-06-16 01:42:01 -04:00
Brandon Thomas ebe1287182 misc cleanup, update claude docs (#1657) 2026-06-16 01:34:50 -04:00
Brandon Thomas de538ba94c package import 2026-06-16 00:37:23 -04:00
Brandon Thomas 29a127aac0 jsonify actix handlers (#1656) 2026-06-16 00:30:06 -04:00
Brandon Thomas 126f874c90 api key feature flag (#1655) 2026-06-15 23:25:58 -04:00
Brandon Thomas 65e72f8c24 omni api fixes (#1654) 2026-06-15 23:06:05 -04:00
Brandon Thomas fafb485a60 omni upload APIs (#1653) 2026-06-15 22:37:11 -04:00
Brandon Thomas 1e20f2bfff omni api image and video (#1652) 2026-06-15 22:08:31 -04:00
BFlat e30b4366ce Update SettingsModal.tsx to flip api feature flag 2026-06-16 09:06:38 +07:00
Tanish Manku d27d5cec75 PageDraw: Port and mount to web app (#1650) 2026-06-15 17:53:15 -07:00
Brandon Thomas 749124f834 api groundwork (#1648) 2026-06-15 20:11:36 -04:00
bflatastic 65b18b9b51 Webapp: API section styling updates 2026-06-16 00:10:04 +07:00
BFlat 6eb85010d6 Webapp: API keys section in settings modal (#1647)
- create, update, delete api keys
- feature flagged (api_key)
2026-06-15 22:38:21 +07:00
Brandon Thomas 855e169b44 codegen database 2026-06-15 11:10:11 -04:00
Brandon Thomas ea2f5106df api keys (#1646) 2026-06-15 11:09:31 -04:00
Brandon Thomas 0de867ad13 upgrade tauri 2.8 -> 2.11 (#1645) 2026-06-14 22:20:29 -04:00
Brandon Thomas a1cb1b2629 enhance database connection pooling (#1644) 2026-06-14 18:09:42 -04:00
BFlat 8da68b009c Desktop create pages redesign: shared generation feed, embedded model selector, gallery folders sidebar (#1635)
## Summary

Reworks the desktop (Tauri) app's Create Image / Create Video pages to
match the webapp,
extracts the webapp's generation gallery into a shared library used by
both apps, embeds the
model selector into every prompt box, adds a folders sidebar + marquee
selection to the gallery
modal, restyles the login/pricing/credits modals to the webapp look, and
turns the Apps page
into the default landing tab.

### Shared generation-list lib

- Extracted the webapp's generation feed into
`@storyteller/ui-generation-list`: list + masonry
grid views, pending/failed/completed rows and cards, thumbnail handling,
gallery/prompt data
hooks, and the shared grid/list view store + toggle. Host-specific
actions (recreate,
make-video, share, download, toasts) inject via render props — webapp
markup unchanged, its
  old files are now thin wrappers.

### Desktop create pages

- Prompt box fixed at the bottom, `max-w-5xl` like the webapp; pages
renamed to
  "Create Image" / "Create Video".
- Generation feed: in-progress/failed from the Tauri task queue, history
from the library API,
rendered as grid or list (toggle in the TopBar next to credits). Typed
Tauri completion events
  push finished media in instantly; the poll reconciles and dedupes.
- Row/card quick actions (recreate, make video, copy link, download)
backed by a shared
  `desktopMediaActions` module extracted from the TopBar gallery wiring.
- Hero + background act as a splash that fades when the feed loads;
bottom gradient behind the
prompt box. Batch generations show "Generating N images" on the single
pending chip.
- Video prompt box now expands vertically as far as the image prompt box
(viewport-relative).

### Embedded model selector

- `ClassyModelSelector` gained `variant="embedded"`: a compact pill that
opens the webapp-style
rich-list popover while keeping the desktop's provider selection.
Embedded on all five
prompt-input pages; floating selectors removed; legacy "10 sec." badges
dropped.

### Gallery modal

- Left sidebar lists the user's folders (drag-and-drop, right-click
management, new-folder
button) and shows on the Folders tab too, highlighting the active
folder's root branch.
- Marquee (drag-rectangle) selection in view mode, with edge
auto-scroll; starts only on blank
  background so it doesn't interfere with drag-to-folder/canvas.

### Restyled modals (desktop)

- Login/signup: webapp 2-column layout (form left, Vimeo showcase
right), no Google button
(Google auth isn't wired for Tauri), `artcraft-icon` logo, eye toggles.
The dismiss button is
removed so login is required; the post-auth Discord + Success onboarding
steps are kept, as is
  the existing Tauri auth flow.
- Pricing + credits: match the webapp visually (unified monochrome plan
cards with
Most Popular / Best Value highlight frames; `#161618` credit-pack cards
with Popular badge).
All billing stays on the existing Tauri `invoke` calls — no billing
logic changed.

### Apps page / navigation

- The Apps page is now the first tab and the default landing tab, with a
Home icon. Restyled to
the webapp home: "What will you craft today?" hero, Create / Edit
sections, gradient-hover
cards sized to their content, full-width scrollbar. The TopBar
quick-menu header reads
  "Create" instead of "Generate".

### Fixes

- Video Editor no longer crashes on the desktop app (`Tooltip must be
used within
TooltipProvider`): the editor shell now mounts its own Radix
`TooltipProvider`. The webapp got
one app-wide via `SidebarProvider`; the Tauri app had none, so panel
tooltips threw.

### Misc

- `getCreatorIconPathForModelId()` added to `@storyteller/model-list`
(model id → creator icon
  path, desktop/webapp aware).
2026-06-13 17:25:40 -04:00
Brandon Thomas e4bc031fbb modernize require_user_session_extended() (#1643) 2026-06-13 17:03:21 -04:00
Brandon Thomas d937a7f78f cleanup require_user_session (#1642) 2026-06-13 05:54:01 -04:00
Brandon Thomas fef96c3835 cleanup usage of require_moderator further (#1641) 2026-06-13 05:26:36 -04:00
Brandon Thomas b9e793098c cleanup session and require_moderator (#1640) 2026-06-13 04:52:35 -04:00
595 changed files with 22402 additions and 5407 deletions
+12 -3
View File
@@ -19,9 +19,18 @@ Handlers in `storyteller_web` follow this structure:
### Session/Auth
- `require_user_session_using_connection()` reuses an open DB connection (preferred)
- `require_user_session()` acquires its own connection (deprecated)
- `require_moderator()` for admin-only endpoints
Helpers live in `storyteller_web` under `http_server/web_utils/user_session/`. Each takes a
generic `mysql_executor: E` (any `sqlx::Executor`), so whether it reuses a connection or acquires
its own depends on the **argument you pass**:
- `require_user_session(http_request, session_checker, mysql_executor)` — require a logged-in user.
- `require_user_session_extended(...)` — same, with extra session detail.
- `require_moderator(http_request, session_checker, mysql_executor)` — admin-only endpoints.
PREFER passing an already-open connection (`&mut *conn`) so the helper reuses it. Passing
`&server_state.mysql_pool` makes it acquire a fresh connection — avoid that when the handler
already holds one (the double-acquire adds pool pressure; this crate has had pool-timeout
incidents). There is no `*_using_connection` variant — the reuse/acquire choice is the argument.
### API Type Definitions
+5 -4
View File
@@ -7,10 +7,11 @@
- `#[macro_use] extern crate serde_derive;` is used in binary crates; library crates use `use serde_derive::{Serialize, Deserialize};`
- Never use `println!` or `eprintln!` outside of tests; use `log` crate macros (`info!`, `warn!`, `error!`, `debug!`, `trace!`) instead
- Use `anyhow::Result` / `AnyhowResult` for fallible functions in application code; new database queries should use `Result<T, sqlx::Error>` instead
- SQLx queries: prefer `sqlx::query!` and `sqlx::query_as!` compile-time checked macros over
runtime `sqlx::query()` / `sqlx::query_as()`. The macros catch SQL typos, column mismatches,
and type errors at build time. Use runtime queries only when assembling complicated
dynamic/branching queries that cannot be expressed as a compile-time macro.
- SQLx: MySQL queries belong in the `mysql_queries` crate. Do NOT write `sqlx::query!` /
`sqlx::query()` in application/service crates (e.g. `storyteller-web`, which has zero direct
SQLx queries) — add or reuse a function in `mysql_queries` and call it. The compile-time
`query!` / `query_as!` macro conventions for that crate are documented in
`crates/schema/database/mysql_queries/CLAUDE.md`.
- Enum Display/Debug: use `impl_enum_display_and_debug_using_to_str!` macro, not `derive_more::Display` (CLion doesn't understand it)
- Fields that are optional use the `maybe_` prefix: `maybe_creator_user_token`, `maybe_prompt_token`
- When implementing `fmt::Display` for error types, use the pattern: `write!(f, "{:?}", self)`
@@ -120,7 +120,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 11
"max_size": 3
}
},
{
@@ -60,7 +60,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 11
"max_size": 10
}
},
{
@@ -70,7 +70,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 11
"max_size": 10
}
},
{
@@ -0,0 +1,124 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n token as `token: ApiKeyToken`,\n LEFT(api_key, 20) as `api_key_prefix!`,\n name,\n maybe_description,\n owner_user_token as `owner_user_token: UserToken`,\n ip_address_creation,\n ip_address_update,\n created_at,\n updated_at,\n maybe_deleted_at\nFROM api_keys\nWHERE owner_user_token = ?\nORDER BY id DESC\nLIMIT ? OFFSET ?\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "token: ApiKeyToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 1,
"name": "api_key_prefix!",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 2,
"name": "name",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 3,
"name": "maybe_description",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 2048
}
},
{
"ordinal": 4,
"name": "owner_user_token: UserToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 5,
"name": "ip_address_creation",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 160
}
},
{
"ordinal": 6,
"name": "ip_address_update",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 160
}
},
{
"ordinal": 7,
"name": "created_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | BINARY | TIMESTAMP",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 8,
"name": "updated_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | BINARY | TIMESTAMP | ON_UPDATE_NOW",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 9,
"name": "maybe_deleted_at",
"type_info": {
"type": "Timestamp",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 63,
"max_size": 19
}
}
],
"parameters": {
"Right": 3
},
"nullable": [
false,
true,
false,
true,
false,
false,
false,
false,
false,
true
]
},
"hash": "097b8a8d02734c8c8e31dbb8a3bf4ace970693a2a0df2f05a31caf86b4d3d0ab"
}
@@ -210,7 +210,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 11
"max_size": 10
}
},
{
@@ -220,7 +220,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 11
"max_size": 10
}
},
{
@@ -30,7 +30,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 11
"max_size": 3
}
},
{
@@ -180,7 +180,7 @@
"type": "LongLong",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 20
"max_size": 10
}
},
{
@@ -40,7 +40,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 11
"max_size": 10
}
},
{
@@ -50,7 +50,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 11
"max_size": 10
}
},
{
@@ -50,7 +50,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 11
"max_size": 10
}
},
{
@@ -60,7 +60,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 11
"max_size": 10
}
},
{
@@ -210,7 +210,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 11
"max_size": 10
}
},
{
@@ -220,7 +220,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 11
"max_size": 10
}
},
{
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\nUPDATE uploaded_video_notes\nSET\n maybe_filename = ?,\n maybe_reported_model_type = ?,\n maybe_reported_model_name = ?,\n maybe_website = ?,\n maybe_other_website = ?,\n maybe_comments = ?,\n maybe_email_address = ?,\n can_share_report = ?,\n was_scammed = ?,\n maybe_comment_update_ip_address = ?,\n version = version + 1\nWHERE token = ?\nLIMIT 1\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 11
},
"nullable": []
},
"hash": "4036dfd9d4e9e77e557776468c14b75f49170de1e55a1b9b9e83446a0c356e74"
}
@@ -0,0 +1,157 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n token as `token: UploadedVideoToken`,\n sha1_checksum,\n filesize_bytes as `filesize_bytes: u32`,\n maybe_width as `maybe_width: u32`,\n maybe_height as `maybe_height: u32`,\n maybe_resolution,\n maybe_detected_model_family as `maybe_detected_model_family: UploadedVideoDetectedModelFamily`,\n maybe_detected_model_type as `maybe_detected_model_type: UploadedVideoDetectedModelType`,\n maybe_report,\n upload_ip_address,\n maybe_updated_ip_address,\n created_at,\n updated_at\nFROM uploaded_videos\nWHERE sha1_checksum = ?\nLIMIT 1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "token: UploadedVideoToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 1,
"name": "sha1_checksum",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 160
}
},
{
"ordinal": 2,
"name": "filesize_bytes: u32",
"type_info": {
"type": "Long",
"flags": "NOT_NULL | UNSIGNED | NO_DEFAULT_VALUE",
"char_set": 63,
"max_size": 10
}
},
{
"ordinal": 3,
"name": "maybe_width: u32",
"type_info": {
"type": "Long",
"flags": "UNSIGNED",
"char_set": 63,
"max_size": 10
}
},
{
"ordinal": 4,
"name": "maybe_height: u32",
"type_info": {
"type": "Long",
"flags": "UNSIGNED",
"char_set": 63,
"max_size": 10
}
},
{
"ordinal": 5,
"name": "maybe_resolution",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 48
}
},
{
"ordinal": 6,
"name": "maybe_detected_model_family: UploadedVideoDetectedModelFamily",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 7,
"name": "maybe_detected_model_type: UploadedVideoDetectedModelType",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 8,
"name": "maybe_report",
"type_info": {
"type": "Blob",
"flags": "BLOB | BINARY",
"char_set": 224,
"max_size": 67108860
}
},
{
"ordinal": 9,
"name": "upload_ip_address",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 160
}
},
{
"ordinal": 10,
"name": "maybe_updated_ip_address",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 160
}
},
{
"ordinal": 11,
"name": "created_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | BINARY | TIMESTAMP",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 12,
"name": "updated_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | BINARY | TIMESTAMP | ON_UPDATE_NOW",
"char_set": 63,
"max_size": 19
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
true,
true,
true,
true,
true,
true,
false,
true,
false,
false
]
},
"hash": "408c893a0ced805de4c3c6cfdf0c6a4bcc93dd1eaf98382522eae46e72eb3aa5"
}
@@ -40,7 +40,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 11
"max_size": 10
}
},
{
@@ -50,7 +50,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 11
"max_size": 10
}
},
{
@@ -180,7 +180,7 @@
"type": "LongLong",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 20
"max_size": 10
}
},
{
@@ -30,7 +30,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 11
"max_size": 3
}
},
{
@@ -190,7 +190,7 @@
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 11
"max_size": 10
}
},
{
@@ -30,7 +30,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 11
"max_size": 3
}
}
],
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\nUPDATE uploaded_videos\nSET\n maybe_width = ?,\n maybe_height = ?,\n maybe_resolution = ?,\n maybe_detected_model_family = ?,\n maybe_detected_model_type = ?,\n maybe_report = ?,\n maybe_updated_ip_address = ?,\n version = version + 1\nWHERE token = ?\nLIMIT 1\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 8
},
"nullable": []
},
"hash": "62f08d5f32bdc35e6faf43e3d21affa2dcf1501cda831cb88524bf1571d08e2f"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\nINSERT INTO uploaded_videos\nSET\n token = ?,\n sha1_checksum = ?,\n filesize_bytes = ?,\n maybe_width = ?,\n maybe_height = ?,\n maybe_resolution = ?,\n maybe_detected_model_family = ?,\n maybe_detected_model_type = ?,\n maybe_report = ?,\n upload_ip_address = ?,\n created_at = NOW()\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 10
},
"nullable": []
},
"hash": "64efd175fa4eb751be1125d55de93ed66a9fc6582e36cec4fc8225c350ec51e2"
}
@@ -160,7 +160,7 @@
"type": "LongLong",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 20
"max_size": 10
}
},
{
@@ -0,0 +1,157 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n token as `token: UploadedVideoToken`,\n sha1_checksum,\n filesize_bytes as `filesize_bytes: u32`,\n maybe_width as `maybe_width: u32`,\n maybe_height as `maybe_height: u32`,\n maybe_resolution,\n maybe_detected_model_family as `maybe_detected_model_family: UploadedVideoDetectedModelFamily`,\n maybe_detected_model_type as `maybe_detected_model_type: UploadedVideoDetectedModelType`,\n maybe_report,\n upload_ip_address,\n maybe_updated_ip_address,\n created_at,\n updated_at\nFROM uploaded_videos\nWHERE token = ?\nLIMIT 1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "token: UploadedVideoToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 1,
"name": "sha1_checksum",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 160
}
},
{
"ordinal": 2,
"name": "filesize_bytes: u32",
"type_info": {
"type": "Long",
"flags": "NOT_NULL | UNSIGNED | NO_DEFAULT_VALUE",
"char_set": 63,
"max_size": 10
}
},
{
"ordinal": 3,
"name": "maybe_width: u32",
"type_info": {
"type": "Long",
"flags": "UNSIGNED",
"char_set": 63,
"max_size": 10
}
},
{
"ordinal": 4,
"name": "maybe_height: u32",
"type_info": {
"type": "Long",
"flags": "UNSIGNED",
"char_set": 63,
"max_size": 10
}
},
{
"ordinal": 5,
"name": "maybe_resolution",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 48
}
},
{
"ordinal": 6,
"name": "maybe_detected_model_family: UploadedVideoDetectedModelFamily",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 7,
"name": "maybe_detected_model_type: UploadedVideoDetectedModelType",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 8,
"name": "maybe_report",
"type_info": {
"type": "Blob",
"flags": "BLOB | BINARY",
"char_set": 224,
"max_size": 67108860
}
},
{
"ordinal": 9,
"name": "upload_ip_address",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 160
}
},
{
"ordinal": 10,
"name": "maybe_updated_ip_address",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 160
}
},
{
"ordinal": 11,
"name": "created_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | BINARY | TIMESTAMP",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 12,
"name": "updated_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | BINARY | TIMESTAMP | ON_UPDATE_NOW",
"char_set": 63,
"max_size": 19
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
true,
true,
true,
true,
true,
true,
false,
true,
false,
false
]
},
"hash": "73d70c5ee77f5059801d324610a80b3f52da3ddec2e89f63e8f41fc68ff0aaec"
}
@@ -60,7 +60,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 11
"max_size": 10
}
},
{
@@ -70,7 +70,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 11
"max_size": 10
}
},
{
@@ -150,7 +150,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 11
"max_size": 3
}
},
{
@@ -160,7 +160,7 @@
"type": "LongLong",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 20
"max_size": 10
}
},
{
@@ -190,7 +190,7 @@
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 11
"max_size": 10
}
},
{
@@ -200,7 +200,7 @@
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 11
"max_size": 5
}
},
{
@@ -210,7 +210,7 @@
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 11
"max_size": 5
}
},
{
@@ -160,7 +160,7 @@
"type": "LongLong",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 20
"max_size": 10
}
},
{
@@ -50,7 +50,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 11
"max_size": 10
}
},
{
@@ -60,7 +60,7 @@
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 11
"max_size": 10
}
},
{
@@ -140,7 +140,7 @@
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 11
"max_size": 10
}
},
{
@@ -0,0 +1,124 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n token as `token: ApiKeyToken`,\n LEFT(api_key, 20) as `api_key_prefix!`,\n name,\n maybe_description,\n owner_user_token as `owner_user_token: UserToken`,\n ip_address_creation,\n ip_address_update,\n created_at,\n updated_at,\n maybe_deleted_at\nFROM api_keys\nWHERE api_key = ?\n AND maybe_deleted_at IS NULL\nLIMIT 1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "token: ApiKeyToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 1,
"name": "api_key_prefix!",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 2,
"name": "name",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 3,
"name": "maybe_description",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 2048
}
},
{
"ordinal": 4,
"name": "owner_user_token: UserToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 5,
"name": "ip_address_creation",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 160
}
},
{
"ordinal": 6,
"name": "ip_address_update",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 160
}
},
{
"ordinal": 7,
"name": "created_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | BINARY | TIMESTAMP",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 8,
"name": "updated_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | BINARY | TIMESTAMP | ON_UPDATE_NOW",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 9,
"name": "maybe_deleted_at",
"type_info": {
"type": "Timestamp",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 63,
"max_size": 19
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
true,
false,
true,
false,
false,
false,
false,
false,
true
]
},
"hash": "9b30c67842e4c408c9002fc116dc86705484268efcadb3100ef13244bcd8f922"
}
@@ -150,7 +150,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 11
"max_size": 3
}
},
{
@@ -30,7 +30,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 11
"max_size": 3
}
},
{
@@ -160,7 +160,7 @@
"type": "LongLong",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 20
"max_size": 10
}
},
{
@@ -270,7 +270,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 11
"max_size": 10
}
},
{
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\nINSERT INTO uploaded_video_notes\nSET\n token = ?,\n uploaded_video_token = ?,\n maybe_filename = ?,\n maybe_reported_model_type = ?,\n maybe_reported_model_name = ?,\n maybe_website = ?,\n maybe_other_website = ?,\n maybe_comments = ?,\n maybe_email_address = ?,\n can_share_report = ?,\n was_scammed = ?,\n comment_create_ip_address = ?,\n created_at = NOW()\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 12
},
"nullable": []
},
"hash": "b1f30a79864351a28a58cae43ee38ff12a8d64d9393b8f27590fe03b0ae3ab05"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\nUPDATE api_keys\nSET\n maybe_description = ?,\n ip_address_update = ?\nWHERE token = ?\n AND maybe_deleted_at IS NULL\nLIMIT 1\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 3
},
"nullable": []
},
"hash": "b72d951fc8b60e3622bb981dd5cd66ce86c6c2d788c5606d2df80a3d07e588fe"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\nINSERT INTO api_keys\nSET\n token = ?,\n api_key = ?,\n name = ?,\n maybe_description = ?,\n owner_user_token = ?,\n ip_address_creation = ?,\n ip_address_update = ?,\n created_at = NOW()\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 7
},
"nullable": []
},
"hash": "bb39045ffcc9d3a9e150b32e7ff884dccbae60101deeaaad23ccd430b25bb5a4"
}
@@ -0,0 +1,124 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n token as `token: ApiKeyToken`,\n LEFT(api_key, 20) as `api_key_prefix!`,\n name,\n maybe_description,\n owner_user_token as `owner_user_token: UserToken`,\n ip_address_creation,\n ip_address_update,\n created_at,\n updated_at,\n maybe_deleted_at\nFROM api_keys\nWHERE token = ?\nLIMIT 1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "token: ApiKeyToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 1,
"name": "api_key_prefix!",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 2,
"name": "name",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 3,
"name": "maybe_description",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 2048
}
},
{
"ordinal": 4,
"name": "owner_user_token: UserToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 5,
"name": "ip_address_creation",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 160
}
},
{
"ordinal": 6,
"name": "ip_address_update",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 160
}
},
{
"ordinal": 7,
"name": "created_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | BINARY | TIMESTAMP",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 8,
"name": "updated_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | BINARY | TIMESTAMP | ON_UPDATE_NOW",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 9,
"name": "maybe_deleted_at",
"type_info": {
"type": "Timestamp",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 63,
"max_size": 19
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
true,
false,
true,
false,
false,
false,
false,
false,
true
]
},
"hash": "bb542b8a86cf2bf770daf29e09a92be981b307810241dd3cc1a7225a83f6824c"
}
@@ -160,7 +160,7 @@
"type": "LongLong",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 20
"max_size": 10
}
},
{
@@ -190,7 +190,7 @@
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 11
"max_size": 10
}
},
{
@@ -200,7 +200,7 @@
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 11
"max_size": 5
}
},
{
@@ -210,7 +210,7 @@
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 11
"max_size": 5
}
},
{
@@ -150,7 +150,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 11
"max_size": 3
}
},
{
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\nUPDATE api_keys\nSET maybe_deleted_at = NOW()\nWHERE token = ?\n AND maybe_deleted_at IS NULL\nLIMIT 1\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "c28c716aa08601b208c727102680bfa91025f0e3958390ed9e27e521d38b5805"
}
@@ -180,7 +180,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 11
"max_size": 10
}
},
{
@@ -190,7 +190,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 11
"max_size": 10
}
},
{
@@ -140,7 +140,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 11
"max_size": 10
}
},
{
@@ -180,7 +180,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 11
"max_size": 10
}
},
{
@@ -190,7 +190,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 11
"max_size": 10
}
},
{
@@ -0,0 +1,47 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n api_keys.token as `api_key_token: ApiKeyToken`,\n users.token as `user_token: UserToken`,\n users.is_banned\nFROM api_keys\nINNER JOIN users\n ON users.token = api_keys.owner_user_token\nWHERE api_keys.api_key = ?\n AND api_keys.maybe_deleted_at IS NULL\nLIMIT 1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "api_key_token: ApiKeyToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 1,
"name": "user_token: UserToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 2,
"name": "is_banned",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 1
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false
]
},
"hash": "f52c495a7ff5cafb357f79c4cdb503bbbef045fd193a5a37a66180fcac77f363"
}
@@ -190,7 +190,7 @@
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 11
"max_size": 10
}
},
{
@@ -140,7 +140,7 @@
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 11
"max_size": 10
}
},
{
+1 -1
View File
@@ -58,7 +58,7 @@ artcraft/
- Rust with no minimum supported version
- Actix-web for HTTP services
- SQLx for MySQL and SQLite; prefer `sqlx::query!` / `sqlx::query_as!` compile-time checked macros over runtime `sqlx::query()` whenever possible
- SQLx for MySQL and SQLite. MySQL queries belong in the `mysql_queries` crate, not in application/service crates like `storyteller-web` — call those query functions instead of embedding `sqlx::query!` / `sqlx::query()` in handlers. (Query-writing conventions live in `crates/schema/database/mysql_queries/CLAUDE.md`.)
- A mix of wreq and reqwest for Rust HTTP clients
- Never use `println!` or `eprintln!` outside of tests; use `log` crate macros instead
- When two crates export the same type name, alias imports with a suffix: `use foo::Bar as BarFoo;`
Generated
+530 -564
View File
File diff suppressed because it is too large Load Diff
+13 -5
View File
@@ -7,8 +7,10 @@ members = [
# API clients for external services
# These might be custom implementations or wrappers over other client libraries.
"crates/api_clients/artcraft/artcraft_api_defs",
"crates/api_clients/artcraft/artcraft_api_keys",
"crates/api_clients/artcraft/artcraft_client",
"crates/api_clients/artcraft/artcraft_router",
"crates/api_clients/bucket_client",
"crates/api_clients/cloud_storage",
"crates/api_clients/comet_api_client",
"crates/api_clients/datadog_client",
@@ -86,6 +88,7 @@ members = [
"crates/lib/pager",
"crates/lib/password",
"crates/lib/server_environment",
"crates/lib/sha1_hash",
"crates/lib/sqlx_mysql_helpers",
"crates/lib/storyteller_root",
"crates/lib/url_config",
@@ -93,6 +96,7 @@ members = [
"crates/lib/user_input_common",
"crates/lib/users",
"crates/lib/uuid_utils",
"crates/lib/video_info",
#"crates/lib/api_clients/fakeyou_client",
#"crates/lib/deprecated/machine_learning/ml_models",
@@ -232,9 +236,11 @@ workspace-build-acceleration = { path = "crates/workspace-build-acceleration" }
actix_artcraft = { path = "crates/lib/actix_artcraft" }
actix_helpers = { path = "crates/lib/actix_helpers" }
artcraft_api_defs = { path = "crates/api_clients/artcraft/artcraft_api_defs" }
artcraft_api_keys = { path = "crates/api_clients/artcraft/artcraft_api_keys" }
artcraft_client = { path = "crates/api_clients/artcraft/artcraft_client" }
artcraft_router = { path = "crates/api_clients/artcraft/artcraft_router" }
bootstrap = { path = "crates/lib/bootstrap" }
bucket_client = { path = "crates/api_clients/bucket_client" }
bucket_paths = { path = "crates/schema/buckets/bucket_paths" }
build_metadata = { path = "crates/lib/build_metadata" }
cloud_storage = { path = "crates/api_clients/cloud_storage" }
@@ -279,6 +285,7 @@ openai_sora_client = { path = "crates/api_clients/openai_sora_client" }
primitives = { path = "crates/lib/data/primitives" }
seedance2pro_client = { path = "crates/api_clients/seedance2pro_client" }
server_environment = { path = "crates/lib/server_environment" }
sha1_hash = { path = "crates/lib/sha1_hash" }
shared_service_components = { path = "crates/lib/shared_service_components" }
sqlite_tasks = { path = "crates/schema/database/sqlite_tasks" }
subprocess_common = { path = "crates/lib/deprecated/subprocess_common" }
@@ -288,6 +295,7 @@ tokens = { path = "crates/schema/public/tokens" }
url_utils = { path = "crates/lib/url_utils" }
users = { path = "crates/lib/users" }
uuid_utils = { path = "crates/lib/uuid_utils" }
video_info = { path = "crates/lib/video_info" }
web_base64 = { path = "crates/lib/data/web_base64" }
worldlabs_api_client = { path = "crates/api_clients/worldlabs_api_client" }
worldlabs_consumer_client = { path = "crates/api_clients/worldlabs_consumer_client" }
@@ -342,11 +350,11 @@ sha2 = "0.11.0-rc.5"
sha3 = "0.10.8"
speculoos = "0.11.0"
strum = { version = "0.28.0", features = ["derive"] }
tauri = { version = "2.8.3", features = ["unstable", "devtools", "protocol-asset"] } # NB: "devtools" prevents App Store distribution; "ubstable" is needed for window access
tauri-plugin-dialog = "2.3.3"
tauri-plugin-fs = "2.4.2"
tauri-plugin-log = "2.6.0"
tauri-plugin-upload = "2.3.1"
tauri = { version = "2.11.2", features = ["unstable", "devtools", "protocol-asset"] } # NB: "devtools" prevents App Store distribution; "ubstable" is needed for window access
tauri-plugin-dialog = "2.7.1"
tauri-plugin-fs = "2.5.1"
tauri-plugin-log = "2.8.0"
tauri-plugin-upload = "2.4.0"
tempdir = "0.3.7"
tempfile = "3.27.0"
thiserror = "2.0.18"
@@ -0,0 +1,5 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
DROP TABLE IF EXISTS api_keys;
@@ -0,0 +1,50 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
CREATE TABLE api_keys (
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
-- Effective "primary key" (used internally for lookups and as a foreign key).
token VARCHAR(32) NOT NULL,
-- The actual API key value presented by clients to authenticate.
api_key VARCHAR(255) NOT NULL,
-- ========== KEY INFO ==========
-- User-supplied display name for the key.
name VARCHAR(255) NOT NULL,
-- Optional user-supplied description.
maybe_description VARCHAR(512) DEFAULT NULL,
-- ========== OWNER ==========
-- The user that owns this API key.
owner_user_token VARCHAR(32) NOT NULL,
-- ========== IP ADDRESSES ==========
-- IP address that created the key. Wide enough for IPv4/IPv6.
ip_address_creation VARCHAR(40) NOT NULL,
-- IP address that last updated the key. Wide enough for IPv4/IPv6.
ip_address_update VARCHAR(40) NOT NULL,
-- ========== TIMESTAMPS ==========
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
-- Soft-delete timestamp. NULL means the key is live.
maybe_deleted_at TIMESTAMP NULL,
-- INDICES --
PRIMARY KEY (id),
UNIQUE KEY (token),
UNIQUE KEY (api_key),
KEY fk_owner_user_token (owner_user_token),
KEY index_maybe_deleted_at (maybe_deleted_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
@@ -0,0 +1,5 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
DROP TABLE IF EXISTS uploaded_videos;
@@ -0,0 +1,62 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
CREATE TABLE uploaded_videos (
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
-- Effective "primary key" (used internally for lookups and as a foreign key).
token VARCHAR(32) NOT NULL,
-- ========== FILE ==========
-- SHA-1 checksum of the uploaded bytes (40 hex chars). De-dupes uploads.
sha1_checksum VARCHAR(40) NOT NULL,
-- Size of the uploaded file in bytes.
filesize_bytes INT(10) UNSIGNED NOT NULL,
-- Pixel dimensions, when they could be determined.
maybe_width INT(10) UNSIGNED DEFAULT NULL,
maybe_height INT(10) UNSIGNED DEFAULT NULL,
-- Resolution label (e.g. "1080p", "1920x1080"), when known.
maybe_resolution VARCHAR(12) DEFAULT NULL,
-- ========== DETECTION ==========
-- Detected provenance family (e.g. "seedance", "veo", "sora", "dreamina", "kling").
maybe_detected_model_family VARCHAR(32) DEFAULT NULL,
-- Detected model type / variant (e.g. "full", "fast", "mini").
maybe_detected_model_type VARCHAR(32) DEFAULT NULL,
-- Free-form report / analysis blob for this upload.
maybe_report MEDIUMTEXT DEFAULT NULL,
-- ========== IP ADDRESSES ==========
-- IP address that uploaded the video. Wide enough for IPv4/IPv6.
upload_ip_address VARCHAR(40) NOT NULL,
-- IP address that last updated the row. Wide enough for IPv4/IPv6.
maybe_updated_ip_address VARCHAR(40) DEFAULT NULL,
-- ========== VERSION ==========
-- Optimistic-concurrency "vector clock". Bump by 1 on every update — set in
-- the UPDATE statement (MySQL can't auto-increment a plain column via schema).
version INT(8) UNSIGNED NOT NULL DEFAULT 0,
-- ========== TIMESTAMPS ==========
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
-- INDICES --
PRIMARY KEY (id),
UNIQUE KEY (token),
UNIQUE KEY (sha1_checksum),
KEY index_maybe_detected_model_family (maybe_detected_model_family)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
@@ -0,0 +1,5 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
DROP TABLE IF EXISTS uploaded_video_notes;
@@ -0,0 +1,69 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
CREATE TABLE uploaded_video_notes (
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
-- Effective "primary key" (used internally for lookups and as a foreign key).
token VARCHAR(32) NOT NULL,
-- The uploaded video this note is about (FK to uploaded_videos.token).
uploaded_video_token VARCHAR(32) NOT NULL,
-- ========== SUBMISSION ==========
-- Original filename of the submitted clip, as provided by the user.
maybe_filename VARCHAR(512) DEFAULT NULL,
-- The model type the submitter claims this video is.
maybe_reported_model_type VARCHAR(32) DEFAULT NULL,
-- Free-form custom model name when the submitter's model isn't a known type.
maybe_reported_model_name VARCHAR(64) DEFAULT NULL,
-- The website / platform the video was obtained from.
maybe_website VARCHAR(32) DEFAULT NULL,
-- Free-form website when it isn't a known option.
maybe_other_website VARCHAR(128) DEFAULT NULL,
-- Free-form comments from the submitter.
maybe_comments VARCHAR(1024) DEFAULT NULL,
-- ========== CONTACT & FLAGS ==========
-- Optional email address for follow-up.
maybe_email_address VARCHAR(255) DEFAULT NULL,
-- Whether the submitter consents to us sharing the report.
can_share_report BOOLEAN NOT NULL DEFAULT FALSE,
-- Whether the submitter believes they were scammed.
was_scammed BOOLEAN NOT NULL DEFAULT FALSE,
-- ========== IP ADDRESSES ==========
-- IP address that submitted the note. Wide enough for IPv4/IPv6.
comment_create_ip_address VARCHAR(40) NOT NULL,
-- IP address that last updated the note. Wide enough for IPv4/IPv6.
maybe_comment_update_ip_address VARCHAR(40) DEFAULT NULL,
-- ========== VERSION ==========
-- Optimistic-concurrency "vector clock". Bump by 1 on every update — set in
-- the UPDATE statement (MySQL can't auto-increment a plain column via schema).
version INT(8) UNSIGNED NOT NULL DEFAULT 0,
-- ========== TIMESTAMPS ==========
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
-- INDICES --
PRIMARY KEY (id),
UNIQUE KEY (token),
KEY fk_uploaded_video_token (uploaded_video_token)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
@@ -0,0 +1,50 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
CREATE TABLE api_keys (
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
-- Effective "primary key" (used internally for lookups and as a foreign key).
token VARCHAR(32) NOT NULL,
-- The actual API key value presented by clients to authenticate.
api_key VARCHAR(255) NOT NULL,
-- ========== KEY INFO ==========
-- User-supplied display name for the key.
name VARCHAR(255) NOT NULL,
-- Optional user-supplied description.
maybe_description VARCHAR(512) DEFAULT NULL,
-- ========== OWNER ==========
-- The user that owns this API key.
owner_user_token VARCHAR(32) NOT NULL,
-- ========== IP ADDRESSES ==========
-- IP address that created the key. Wide enough for IPv4/IPv6.
ip_address_creation VARCHAR(40) NOT NULL,
-- IP address that last updated the key. Wide enough for IPv4/IPv6.
ip_address_update VARCHAR(40) NOT NULL,
-- ========== TIMESTAMPS ==========
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
-- Soft-delete timestamp. NULL means the key is live.
maybe_deleted_at TIMESTAMP NULL,
-- INDICES --
PRIMARY KEY (id),
UNIQUE KEY (token),
UNIQUE KEY (api_key),
KEY fk_owner_user_token (owner_user_token),
KEY index_maybe_deleted_at (maybe_deleted_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
@@ -0,0 +1,69 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
CREATE TABLE uploaded_video_notes (
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
-- Effective "primary key" (used internally for lookups and as a foreign key).
token VARCHAR(32) NOT NULL,
-- The uploaded video this note is about (FK to uploaded_videos.token).
uploaded_video_token VARCHAR(32) NOT NULL,
-- ========== SUBMISSION ==========
-- Original filename of the submitted clip, as provided by the user.
maybe_filename VARCHAR(512) DEFAULT NULL,
-- The model type the submitter claims this video is.
maybe_reported_model_type VARCHAR(32) DEFAULT NULL,
-- Free-form custom model name when the submitter's model isn't a known type.
maybe_reported_model_name VARCHAR(64) DEFAULT NULL,
-- The website / platform the video was obtained from.
maybe_website VARCHAR(32) DEFAULT NULL,
-- Free-form website when it isn't a known option.
maybe_other_website VARCHAR(128) DEFAULT NULL,
-- Free-form comments from the submitter.
maybe_comments VARCHAR(1024) DEFAULT NULL,
-- ========== CONTACT & FLAGS ==========
-- Optional email address for follow-up.
maybe_email_address VARCHAR(255) DEFAULT NULL,
-- Whether the submitter consents to us sharing the report.
can_share_report BOOLEAN NOT NULL DEFAULT FALSE,
-- Whether the submitter believes they were scammed.
was_scammed BOOLEAN NOT NULL DEFAULT FALSE,
-- ========== IP ADDRESSES ==========
-- IP address that submitted the note. Wide enough for IPv4/IPv6.
comment_create_ip_address VARCHAR(40) NOT NULL,
-- IP address that last updated the note. Wide enough for IPv4/IPv6.
maybe_comment_update_ip_address VARCHAR(40) DEFAULT NULL,
-- ========== VERSION ==========
-- Optimistic-concurrency "vector clock". Bump by 1 on every update — set in
-- the UPDATE statement (MySQL can't auto-increment a plain column via schema).
version INT(8) UNSIGNED NOT NULL DEFAULT 0,
-- ========== TIMESTAMPS ==========
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
-- INDICES --
PRIMARY KEY (id),
UNIQUE KEY (token),
KEY fk_uploaded_video_token (uploaded_video_token)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
@@ -0,0 +1,62 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
CREATE TABLE uploaded_videos (
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
-- Effective "primary key" (used internally for lookups and as a foreign key).
token VARCHAR(32) NOT NULL,
-- ========== FILE ==========
-- SHA-1 checksum of the uploaded bytes (40 hex chars). De-dupes uploads.
sha1_checksum VARCHAR(40) NOT NULL,
-- Size of the uploaded file in bytes.
filesize_bytes INT(10) UNSIGNED NOT NULL,
-- Pixel dimensions, when they could be determined.
maybe_width INT(10) UNSIGNED DEFAULT NULL,
maybe_height INT(10) UNSIGNED DEFAULT NULL,
-- Resolution label (e.g. "1080p", "1920x1080"), when known.
maybe_resolution VARCHAR(12) DEFAULT NULL,
-- ========== DETECTION ==========
-- Detected provenance family (e.g. "seedance", "veo", "sora", "dreamina", "kling").
maybe_detected_model_family VARCHAR(32) DEFAULT NULL,
-- Detected model type / variant (e.g. "full", "fast", "mini").
maybe_detected_model_type VARCHAR(32) DEFAULT NULL,
-- Free-form report / analysis blob for this upload.
maybe_report MEDIUMTEXT DEFAULT NULL,
-- ========== IP ADDRESSES ==========
-- IP address that uploaded the video. Wide enough for IPv4/IPv6.
upload_ip_address VARCHAR(40) NOT NULL,
-- IP address that last updated the row. Wide enough for IPv4/IPv6.
maybe_updated_ip_address VARCHAR(40) DEFAULT NULL,
-- ========== VERSION ==========
-- Optimistic-concurrency "vector clock". Bump by 1 on every update — set in
-- the UPDATE statement (MySQL can't auto-increment a plain column via schema).
version INT(8) UNSIGNED NOT NULL DEFAULT 0,
-- ========== TIMESTAMPS ==========
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
-- INDICES --
PRIMARY KEY (id),
UNIQUE KEY (token),
UNIQUE KEY (sha1_checksum),
KEY index_maybe_detected_model_family (maybe_detected_model_family)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
+203
View File
@@ -0,0 +1,203 @@
# PageDraw — Visual Design & Architecture Review
**Surface:** `@storyteller/ui-pagedraw` image editor + its `artcraft-webapp` integration (`/edit-image`).
**Lens:** Awwwards / Linear-tier "premium app chrome" rubric, adapted for an in-product creative tool.
**Date:** 2026-06-15 · **Branch:** `worktree-pagedraw-webapp`
---
## 0. How to read this
PageDraw is a **professional canvas tool**, not a marketing page. So the elite-design rubric is
applied in its *app-chrome* register — the north star is **Figma / Linear / Photoshop-web**, not an
Awwwards landing page. That means:
- **Applies:** token discipline, concentric radii, hairline + inset-highlight surfaces ("double-bezel"),
island/pill controls, custom-eased micro-motion, z-index hygiene, premium type ramp.
- **Does _not_ apply:** `py-40` macro-whitespace, hero typography, editorial-split layouts, scroll-reveal
entrances. A tool surface is dense and persistent by design; forcing landing-page whitespace would hurt it.
Citations are `file:line` against the current tree.
---
## 1. Verdict
| Axis | Score | One-liner |
|---|---|---|
| Color system | ⚠️ 5/10 | Good token foundation, but hardcoded greys (`#1b1b1b`, `#303030`, `#404040`) leak through a *themeable lib*. |
| Typography | ❌ 3/10 | No type ramp, no font-family ownership; rides whatever the host sets. Banned-by-rubric default stack risk. |
| Radii | ⚠️ 4/10 | Five radius scales in play (`sm`→`2xl`→`full`) with no concentric nesting. |
| Surfaces (border/shadow) | ⚠️ 5/10 | Flat `shadow-lg` everywhere; no inset highlights; no double-bezel. Reads "functional", not "machined". |
| Motion | ❌ 3/10 | Only `transition-colors`/`ease-in-out`, durations 75–200ms; **zero custom easing**, no spring, no entrance choreography. |
| Layout / z-index | ⚠️ 6/10 | Clean fixed-panel composition, but a `z-[9999999]` escape hatch and a 6-tier ad-hoc z-stack. |
| Icons | ✅ 7/10 | FontAwesome Pro (solid + regular) — acceptable, but solid weight is heavier than the rubric's "ultra-light line" ideal. |
| Architecture | ⚠️ 5/10 | Clean adapter boundary; but `PaintSurface.tsx` (2005 LOC), `SceneState.ts` (1539), `PageDraw.tsx` (1292) are oversized, and the lib hardcodes host-theme colors. |
**Bottom line:** the editor is *structurally competent and functional* but visually reads as
"capable internal tool," not "$150k product surface." The single highest-leverage fix is a **shared
design-token layer** (color + radius + motion + type), because today the lib both (a) reaches for
host tokens (`bg-ui-panel`, `text-base-fg`) *and* (b) hardcodes its own greys — so it can never be
fully reskinned by the webapp vs. the desktop app.
---
## 2. What's already good (keep)
- **Token-aware base.** Most surfaces use semantic tokens (`bg-ui-panel`, `bg-ui-controls`,
`text-base-fg`, `border-ui-panel-border`) — the right instinct (SideToolbar, PromptEditor, HistoryStack).
- **Clean platform boundary.** The lib has zero REST/Tauri imports; everything I/O flows through
`PageDrawAdapter` (`adapter.ts`). The webapp adapter (`web-adapter.tsx`) and desktop adapter stay
swappable. This is genuinely good architecture and should be protected.
- **Performance-minded canvas.** Composite pre-baking on idle (`PageDraw.tsx` ~661–710) and worker
encoding (`generatePipeline.worker.ts`) keep the generate click off the main thread. Don't regress this.
- **CORS already handled** for browser canvas export (`crossOrigin="anonymous"` in `Node.ts:134`,
`SceneState.ts:1473`, `imageHelpers.ts:16`) — the web port works because of this.
---
## 3. Findings & recommendations
### 3.1 Color — stop hardcoding greys in a themeable lib ⚠️ P0
**Current:** semantic tokens *and* raw hex coexist:
- `#1b1b1b` pegboard (`pagedraw.css:6`), `#1A1A1A`/`#333333` context menu (`ContextMenu.tsx:44`),
`#303030`/`#404040` dividers + popovers (`SideToolbar.tsx:381,550`), `bg-zinc-700/600` swatch
(`SideToolbar.tsx:173`), plus ad-hoc `bg-blue-600/500` accents (`PageDraw.tsx:157`, base selector).
- Accent color is inconsistent: the lib's primary action uses raw **blue-600**, while the webapp's
design system uses `--primary` / `brand-primary`. So the editor's blue doesn't match the product.
**Why it fails the bar:** a premium component library exposes *one* surface ramp and *one* accent, both
driven by tokens, so it reskins per host. Hardcoded greys mean the desktop and web themes can drift, and
the raw `blue-600` clashes with `brand-primary` used elsewhere in the webapp.
**Do:**
- Replace every raw grey with the existing ramp: `#1b1b1b`→`bg-ui-background`, `#1A1A1A`→`bg-ui-panel`,
`#303030`/`#404040`→`border-ui-panel-border` / `bg-ui-controls`. For the pegboard, add a token
`--st-canvas` so hosts can theme the canvas backdrop.
- Replace `blue-600/500` accents with `bg-primary` / `text-primary` (the token the rest of the webapp uses),
so the Generate/Edit-3D buttons match `brand-primary`.
- Move the react-colorful overrides off pure `rgb(255,255,255)` borders (`pagedraw.css:15,19`) onto
`white/15` hairlines for consistency with the rest of the chrome.
### 3.2 Typography — own a type ramp ❌ P0
**Current:** no `font-family` anywhere in the lib (grep: 0 hits); only `text-xs/sm/base/lg/xl` +
`font-medium/semibold/bold`. The editor inherits whatever the host's body font is.
**Why it fails the bar:** the rubric explicitly bans the default web stack (Inter/Roboto/Arial/Helvetica).
Inheriting an unspecified stack means the tool can render in a banned font with no recourse, and there's
no deliberate hierarchy (numerics in sliders, labels, modal titles all share one undifferentiated ramp).
**Do:**
- Adopt the product's display/grotesk token (e.g. a `font-sans` mapped to Geist / Plus Jakarta Sans at
the app level) and reference it in the lib's root container, not per-component.
- Define a small semantic ramp: `text-[11px] tracking-[0.16em] uppercase` for toolbar/section labels,
`text-sm` for controls, `text-base font-semibold` for modal titles. Apply `tabular-nums` to slider
read-outs (`SliderWithIndicator.tsx`) and dimension fields (`BlankCanvasModal`) so numbers don't jitter.
### 3.3 Radii — collapse to a concentric scale ⚠️ P1
**Current:** `rounded-sm / md / lg / xl / 2xl / full` all appear, with no parent→child relationship
(e.g. `rounded-2xl` card at `PageDraw.tsx:1123`, `rounded-xl` panels in SideToolbar, `rounded-lg`
buttons in InpaintToolBar).
**Do:** pick a 3-step system — **`rounded-2xl` shells, `rounded-xl` inner cards, `rounded-lg` controls,
`rounded-full` pills** — and apply concentrically (the "double-bezel" curve math: inner radius =
outer − padding). Drop `rounded-sm`/`rounded-md` from the editor entirely.
### 3.4 Surfaces — add the "machined" double-bezel ⚠️ P1
**Current:** panels are a single `bg-ui-panel` + `border` + `shadow-lg`. No nested enclosure, no inset
top-highlight. Reads flat.
**Do (for the four floating panels — SideToolbar, HistoryStack, PromptEditor, InpaintToolBar):**
- Outer shell: `bg-ui-panel/80 ring-1 ring-white/10 backdrop-blur-xl p-1.5 rounded-2xl`.
- Inner core: own bg + `shadow-[inset_0_1px_0_rgba(255,255,255,0.08)] rounded-xl`.
- This is the single change that most moves the surface from "tool" to "premium hardware." The panels are
already `fixed` (`PageDraw.tsx:1144,1175,1195`), so `backdrop-blur` here is GPU-safe per the perf rules.
### 3.5 Motion — replace linear/ease with spring-eased micro-motion ❌ P0 (cheap, high impact)
**Current:** `transition-colors`/`transition-all` with `ease-in-out` and `duration-75/150/200`
(`SideToolbar.tsx:381,550`, `SliderWithIndicator.tsx:65`, `ContextMenu.tsx:53`). No custom easing,
no entrance/exit on popovers, color pickers, or the context menu (they pop instantly).
**Why it fails the bar:** instant state changes and `ease-in-out` are explicitly banned. A pro tool still
deserves *interpolated* motion — it's what separates Figma from a jQuery plugin.
**Do:**
- Standardize on one curve: `transition-[transform,opacity,background-color] duration-300
ease-[cubic-bezier(0.32,0.72,0,1)]`.
- Tool buttons: `active:scale-[0.97]` press physics; selected tool gets a spring-eased indicator slide
rather than a hard background swap.
- Popovers / color picker / context menu: animate in with `opacity-0 translate-y-1 scale-[0.98]` →
resolved, ~180ms. Transform+opacity only (perf rule).
- History tiles: stagger their `opacity` reveal when a generation lands (they already use
`transition-opacity`, just add per-index delay).
### 3.6 Layout & z-index — tame the stack ⚠️ P1
**Current:** clean fixed-panel composition, but z-index is ad-hoc across 6 tiers and includes a
`z-[9999999]` on the context menu (`ContextMenu.tsx:44`) and a one-off `z-[60]` (`PageDraw.tsx:237`).
**Do:** define a named scale in one place — `canvas:0, chrome:10, floating-controls:20, overlay-button:40,
menu:50` — and replace the magic numbers. The portal menu only needs to clear the chrome, not `9999999`.
### 3.7 Responsive — declare intent ⚠️ P2
**Current:** essentially desktop-only; fixed sidebars assume a wide viewport; only LightboxDetails has
`sm:` variants. The webapp wrapper scopes the lib correctly (`pagedraw.tsx` `translateZ(0)` containing
block) but there's no mobile story.
**Do:** mirror the Edit-3D page's explicit **mobile gate** (`pagescene.tsx` `MobileGate`) on `/edit-image`
rather than letting fixed toolbars overlap on touch. A precision canvas tool is legitimately desktop-first;
just say so deliberately instead of degrading silently.
---
## 4. Architecture notes
- **Oversized modules.** `PaintSurface.tsx` (2005 LOC), `SceneState.ts` (1539), `PageDraw.tsx` (1292).
These are review/maintenance hotspots. Suggest extracting from `SceneState.ts`: history/undo slice,
base-image-load slice, and generation/pending slice into composable Zustand slices. `PageDraw.tsx`'s
generate-flow (cases 1–3, ~780–868) could move to a `useGenerate()` hook.
- **Theme ownership.** Per §3.1, the lib should *only* consume tokens. Today it mixes tokens + hex, which
defeats the adapter pattern's promise (one component, many themed hosts).
- **Adapter surface is healthy.** `PageDrawAdapter` is small and intentional; the web vs. Tauri split is
clean. The webapp's `enqueueInpaint` stub and 402→credits-modal handling are explicit and documented.
- **Icon weight.** FontAwesome **solid** is the heaviest option; if a line/light set is available
(Phosphor/Remix or FA light), the toolbar would read more refined. Low priority.
---
## 5. Prioritized roadmap
| P | Item | Effort | Payoff |
|---|---|---|---|
| **P0** | Token sweep: kill hardcoded greys; unify accent on `--primary` (§3.1) | M | Highest — enables true theming + product-consistent accent |
| **P0** | Own a font-family + semantic type ramp at the lib root (§3.2) | S | High — removes banned-font risk, adds hierarchy |
| **P0** | One custom easing curve + press/popover micro-motion (§3.5) | S | High — cheapest "feels expensive" win |
| **P1** | Double-bezel + inset highlights on the 4 floating panels (§3.4) | M | High — "machined" surface upgrade |
| **P1** | Concentric 3-step radius system (§3.3) | S | Medium — visual coherence |
| **P1** | Named z-index scale; remove `z-[9999999]` (§3.6) | S | Medium — maintainability |
| **P2** | Explicit mobile gate on `/edit-image` (§3.7) | S | Medium — correctness on touch |
| **P2** | Split `SceneState.ts` / `PaintSurface.tsx` into slices (§4) | L | Medium — maintainability |
---
## 6. Appendix — proposed token additions
```css
/* canvas + chrome surface ramp (lib should consume only these) */
--st-canvas: <theme>; /* replaces #1b1b1b pegboard */
--st-accent: var(--primary); /* replaces blue-600/500 */
--st-hairline: rgb(255 255 255 / 0.10);
--st-inset-hi: inset 0 1px 0 rgb(255 255 255 / 0.08);
/* motion */
--st-ease: cubic-bezier(0.32, 0.72, 0, 1);
--st-dur-fast: 180ms;
--st-dur: 300ms;
/* radius scale (concentric) */
--st-r-shell: 1.25rem; /* rounded-2xl */
--st-r-card: 0.875rem; /* rounded-xl */
--st-r-control: 0.5rem; /* rounded-lg */
```
> Scope note: every recommendation here is **styling/architecture only** — none change the generate,
> polling, upload, bg-removal, or adapter contracts validated during the functional debug pass.
@@ -0,0 +1,42 @@
use chrono::{DateTime, Utc};
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
use tokens::tokens::api_keys::ApiKeyToken;
use tokens::tokens::users::UserToken;
/// Canonical wire shape for an API key, used by the list rows and the
/// single-key GET.
///
/// NB: the full secret `api_key` is NEVER included here — only
/// `truncated_api_key` (its first 20 characters), enough to disambiguate keys
/// in a UI. The full value is returned exactly once, at creation time.
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct ApiKeyInfo {
pub token: ApiKeyToken,
/// First 20 characters of the API key. The full secret is never returned
/// after creation.
pub truncated_api_key: String,
pub name: String,
pub maybe_description: Option<String>,
pub owner_user_token: UserToken,
pub ip_address_creation: String,
pub ip_address_update: String,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
/// Soft-delete timestamp. `None` for live keys.
pub maybe_deleted_at: Option<DateTime<Utc>>,
}
/// Path info for endpoints addressed by the API key's `token` (NOT the secret
/// `api_key` value): `GET` / `DELETE` / `PUT` `/v1/api_keys/{api_key_token}`.
#[derive(Deserialize, ToSchema)]
pub struct ApiKeyPathInfo {
pub api_key_token: ApiKeyToken,
}
@@ -0,0 +1,28 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
use tokens::tokens::api_keys::ApiKeyToken;
// ── POST /v1/api_keys/create ──
#[derive(Deserialize, ToSchema)]
pub struct CreateApiKeyRequest {
/// Display name (title) for the key.
pub name: String,
/// Optional description.
pub maybe_description: Option<String>,
}
#[derive(Serialize, ToSchema)]
pub struct CreateApiKeySuccessResponse {
pub success: bool,
/// The key's `token` — used for all subsequent management
/// (get / update / delete). Safe to store and display.
pub api_key_token: ApiKeyToken,
/// The full secret API key value. This is the ONLY time it is ever
/// returned — the caller must store it now, as it cannot be retrieved again.
pub api_key: String,
}
@@ -0,0 +1,9 @@
use serde_derive::Serialize;
use utoipa::ToSchema;
// ── DELETE /v1/api_keys/{api_key_token} ──
#[derive(Serialize, ToSchema)]
pub struct DeleteApiKeySuccessResponse {
pub success: bool,
}
@@ -0,0 +1,12 @@
use serde_derive::Serialize;
use utoipa::ToSchema;
use crate::api_keys::common::ApiKeyInfo;
// ── GET /v1/api_keys/{api_key_token} ──
#[derive(Serialize, ToSchema)]
pub struct GetApiKeySuccessResponse {
pub success: bool,
pub api_key: ApiKeyInfo,
}
@@ -0,0 +1,24 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
use crate::api_keys::common::ApiKeyInfo;
// ── GET /v1/api_keys/list ──
#[derive(Deserialize, ToSchema, IntoParams)]
pub struct ListApiKeysQueryParams {
/// Page size. Defaults server-side if omitted.
pub limit: Option<u32>,
/// Number of rows to skip. Defaults to 0.
pub offset: Option<u32>,
}
#[derive(Serialize, ToSchema)]
pub struct ListApiKeysSuccessResponse {
pub success: bool,
/// The user's API keys (including soft-deleted ones), newest first. Each
/// entry's key value is truncated — the full secret is never listed.
pub api_keys: Vec<ApiKeyInfo>,
}
@@ -0,0 +1,6 @@
pub mod common;
pub mod create_api_key;
pub mod delete_api_key;
pub mod get_api_key;
pub mod list_api_keys;
pub mod update_api_key;
@@ -0,0 +1,15 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
// ── PUT /v1/api_keys/{api_key_token} ──
#[derive(Deserialize, ToSchema)]
pub struct UpdateApiKeyRequest {
/// New description for the key. `None` clears it.
pub maybe_description: Option<String>,
}
#[derive(Serialize, ToSchema)]
pub struct UpdateApiKeySuccessResponse {
pub success: bool,
}
@@ -1,4 +1,5 @@
pub mod analytics;
pub mod api_keys;
pub mod characters;
pub mod common;
pub mod credits;
@@ -7,6 +8,7 @@ pub mod generate;
pub mod jobs;
pub mod media_file;
pub mod moderation;
pub mod omni_api;
pub mod omni_gen;
pub mod prompts;
pub mod stripe_artcraft;
@@ -14,4 +16,5 @@ pub mod subscriptions;
pub mod user_referral_codes;
pub mod users;
pub mod utils;
pub mod video_info;
pub mod web_referrals;
@@ -0,0 +1,3 @@
pub mod omni_upload_audio;
pub mod omni_upload_image;
pub mod omni_upload_video;
@@ -0,0 +1,15 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
use tokens::tokens::media_files::MediaFileToken;
/// Success response for the API-key authenticated audio upload endpoint
/// (`POST /v1/omni_api/upload/audio`).
///
/// NB: The request is a `multipart/form-data` body. Because the multipart form type is bound to the
/// web framework, it is declared alongside the handler in `storyteller_web` rather than here.
#[derive(Clone, Serialize, Deserialize, ToSchema, Debug)]
pub struct OmniUploadAudioMediaFileSuccessResponse {
pub success: bool,
pub media_file_token: MediaFileToken,
}
@@ -0,0 +1,15 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
use tokens::tokens::media_files::MediaFileToken;
/// Success response for the API-key authenticated image upload endpoint
/// (`POST /v1/omni_api/upload/image`).
///
/// NB: The request is a `multipart/form-data` body. Because the multipart form type is bound to the
/// web framework, it is declared alongside the handler in `storyteller_web` rather than here.
#[derive(Clone, Serialize, Deserialize, ToSchema, Debug)]
pub struct OmniUploadImageMediaFileSuccessResponse {
pub success: bool,
pub media_file_token: MediaFileToken,
}
@@ -0,0 +1,15 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
use tokens::tokens::media_files::MediaFileToken;
/// Success response for the API-key authenticated video upload endpoint
/// (`POST /v1/omni_api/upload/video`).
///
/// NB: The request is a `multipart/form-data` body. Because the multipart form type is bound to the
/// web framework, it is declared alongside the handler in `storyteller_web` rather than here.
#[derive(Clone, Serialize, Deserialize, ToSchema, Debug)]
pub struct OmniUploadVideoMediaFileSuccessResponse {
pub success: bool,
pub media_file_token: MediaFileToken,
}
@@ -0,0 +1,3 @@
pub mod notes;
pub mod read_only;
pub mod upload;
@@ -0,0 +1,60 @@
//! Request/response types for `POST /v1/video_info/notes`.
//!
//! A note is user-submitted context about an uploaded video (what model they
//! believe it is, where it came from, free-form comments). Always tied to an
//! [`UploadedVideoToken`]; pass a [`UploadVideoNoteToken`] to update an existing
//! note instead of creating a new one.
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
use enums::by_table::uploaded_video_notes::uploaded_video_note_reported_model_type::UploadedVideoNoteReportedModelType;
use enums::by_table::uploaded_video_notes::uploaded_video_note_reported_website::UploadedVideoNoteReportedWebsite;
use tokens::tokens::uploaded_video_notes::UploadVideoNoteToken;
use tokens::tokens::uploaded_videos::UploadedVideoToken;
/// Request body for `POST /v1/video_info/notes`.
#[derive(Deserialize, ToSchema, Debug, Clone)]
pub struct VideoInfoNoteRequest {
/// The uploaded video this note is about. Required.
pub uploaded_video_token: UploadedVideoToken,
/// If present, update this existing note instead of creating a new one.
pub maybe_uploaded_video_note_token: Option<UploadVideoNoteToken>,
/// Original filename of the submitted clip.
pub maybe_filename: Option<String>,
/// The model type the submitter claims this video is (dropdown / enum).
pub maybe_reported_model_type: Option<UploadedVideoNoteReportedModelType>,
/// Free-form custom model name (trimmed; empty becomes absent).
pub maybe_reported_model_name: Option<String>,
/// The website / platform the video came from (dropdown / enum).
pub maybe_website: Option<UploadedVideoNoteReportedWebsite>,
/// Free-form website (trimmed; empty becomes absent).
pub maybe_other_website: Option<String>,
/// Free-form comments (trimmed; empty becomes absent).
pub maybe_comments: Option<String>,
/// Optional contact email (trimmed; empty becomes absent).
pub maybe_email_address: Option<String>,
/// Whether the submitter consents to us sharing the report. Defaults to false.
pub can_share_report: Option<bool>,
/// Whether the submitter believes they were scammed. Defaults to false.
pub was_scammed: Option<bool>,
}
/// Response body for `POST /v1/video_info/notes`.
#[derive(Serialize, Deserialize, ToSchema, Debug, Clone)]
pub struct VideoInfoNoteResponse {
pub success: bool,
/// The created or updated note's token (e.g. `uvnote_…`).
pub uploaded_video_note_token: UploadVideoNoteToken,
}
@@ -0,0 +1,167 @@
//! Response types for `POST /v1/video_info/read_only`.
//!
//! The endpoint accepts a `multipart/form-data` video upload, inspects its
//! embedded provenance (C2PA manifests, AIGC labels, in-stream watermarks), and
//! returns what it found. These are the public, wire-stable API types — they
//! deliberately mirror, but do not re-export, the internal `video_info` crate
//! types so the parsing library can evolve independently of the HTTP contract.
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
/// Response for `POST /v1/video_info/read_only`.
///
/// [`kind`](Self::kind) names the detected provenance family; the matching
/// `maybe_*` field carries its details (all others are `null`). `unrecognized`
/// means no supported provenance was found — [`maybe_encoder`](Self::maybe_encoder)
/// still reports the container encoder tag when present (an `Lavf…` value means
/// the file was re-encoded through ffmpeg, which strips provenance).
#[derive(Serialize, Deserialize, ToSchema, Debug, Clone)]
pub struct VideoInfoReadOnlyResponse {
pub success: bool,
/// Which provenance family was detected (if any).
pub kind: VideoProvenanceKind,
/// The container `©too` encoder tag, e.g. `"Lavf60.16.100"` or `"Google"`.
pub maybe_encoder: Option<String>,
/// Present when [`kind`](Self::kind) is [`VideoProvenanceKind::Seedance`].
pub maybe_seedance: Option<SeedanceVideoInfo>,
/// Present when [`kind`](Self::kind) is [`VideoProvenanceKind::Veo`].
pub maybe_veo: Option<VeoVideoInfo>,
/// Present when [`kind`](Self::kind) is [`VideoProvenanceKind::Sora`].
pub maybe_sora: Option<SoraVideoInfo>,
/// Present when [`kind`](Self::kind) is [`VideoProvenanceKind::Dreamina`].
pub maybe_dreamina: Option<DreaminaVideoInfo>,
/// Present when [`kind`](Self::kind) is [`VideoProvenanceKind::Kling`].
pub maybe_kling: Option<KlingVideoInfo>,
}
/// The recognized AI-video provenance family.
#[derive(Serialize, Deserialize, ToSchema, Debug, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum VideoProvenanceKind {
/// ByteDance Seedance API (Volcengine / BytePlus C2PA).
Seedance,
/// Google Veo (Google Generative AI video C2PA).
Veo,
/// OpenAI Sora (OpenAI Generative AI video C2PA).
Sora,
/// Dreamina (ByteDance/CapCut consumer app `ilst` metadata).
Dreamina,
/// Kling (Kuaishou AIGC-label / in-stream watermark).
Kling,
/// No supported provenance was found (often a re-encoded / stripped file).
Unrecognized,
}
/// Seedance (ByteDance) provenance from an embedded C2PA manifest.
#[derive(Serialize, Deserialize, ToSchema, Debug, Clone)]
pub struct SeedanceVideoInfo {
/// `"volcengine"` or `"byteplus"`.
pub platform: String,
/// C2PA `softwareAgent`, e.g. `"Volcengine_Ark_CN"`.
pub software_agent: String,
pub maybe_software_agent_version: Option<String>,
/// Full model identifier, e.g. `"doubao-seedance-2-0-fast"`.
pub model_name: String,
pub maybe_model_brand: Option<String>,
/// Parsed version, e.g. `"2.0"` / `"1.0"`.
pub maybe_model_version: Option<String>,
pub is_fast: bool,
pub is_lite: bool,
/// Raw generation timestamp string (RFC 3339).
pub generated_at: String,
/// Normalized RFC 3339 timestamp, when parseable.
pub maybe_generated_at_utc: Option<String>,
pub maybe_log_id: Option<String>,
pub maybe_log_id_decoded_hex: Option<String>,
pub maybe_digital_source_type: Option<String>,
pub maybe_claim_generator: Option<String>,
pub maybe_claim_generator_version: Option<String>,
pub maybe_manifest_id: Option<String>,
pub maybe_instance_id: Option<String>,
pub maybe_signer_email: Option<String>,
pub maybe_signer_org_id: Option<String>,
pub maybe_signer_country: Option<String>,
pub maybe_cert_serial: Option<String>,
}
/// Google Veo provenance.
#[derive(Serialize, Deserialize, ToSchema, Debug, Clone)]
pub struct VeoVideoInfo {
/// Always `"Google"`.
pub producer: String,
/// Whether a full Google C2PA manifest is present (`false` = recognized only
/// by the `encoder=Google` tag, C2PA likely stripped by a re-encode).
pub has_c2pa_manifest: bool,
pub maybe_encoder: Option<String>,
pub maybe_created_description: Option<String>,
pub has_synthid_watermark: bool,
pub maybe_synthid_description: Option<String>,
pub maybe_digital_source_type: Option<String>,
pub maybe_claim_generator: Option<String>,
pub maybe_claim_generator_version: Option<String>,
pub maybe_manifest_id: Option<String>,
pub maybe_instance_id: Option<String>,
pub maybe_cert_serial: Option<String>,
/// Issuing intermediate-CA common name, e.g. `"Google C2PA Media Services 1P ICA G3"`.
pub maybe_signer_ca: Option<String>,
/// Whether the manifest carries an RFC 3161 trusted timestamp.
pub is_timestamped: bool,
pub maybe_timestamp_authority: Option<String>,
}
/// OpenAI Sora provenance.
#[derive(Serialize, Deserialize, ToSchema, Debug, Clone)]
pub struct SoraVideoInfo {
/// Always `"OpenAI"`.
pub producer: String,
pub maybe_model_name: Option<String>,
pub maybe_created_description: Option<String>,
pub maybe_digital_source_type: Option<String>,
pub maybe_claim_generator: Option<String>,
pub maybe_manifest_id: Option<String>,
pub maybe_instance_id: Option<String>,
pub maybe_cert_serial: Option<String>,
}
/// Dreamina (ByteDance/CapCut app) provenance from `ilst` metadata.
#[derive(Serialize, Deserialize, ToSchema, Debug, Clone)]
pub struct DreaminaVideoInfo {
/// The product string, e.g. `"dreamina"`.
pub product: String,
pub maybe_export_type: Option<String>,
pub maybe_os: Option<String>,
pub maybe_source_info: Option<String>,
pub maybe_aigc_label_type: Option<i64>,
pub maybe_video_id: Option<String>,
pub has_c2pa: bool,
pub maybe_signer_org_id: Option<String>,
pub maybe_signer_country: Option<String>,
pub maybe_cert_serial: Option<String>,
}
/// Kling (Kuaishou) provenance from the AIGC label and/or in-stream watermark.
#[derive(Serialize, Deserialize, ToSchema, Debug, Clone)]
pub struct KlingVideoInfo {
/// Model version when a clean export embeds it (e.g. `"2.5-pro"`); usually `null`.
pub maybe_model_version: Option<String>,
/// Whether labeled AI-generated (`Label == "1"`, per China GB 45438).
pub is_ai_generated: bool,
pub maybe_label: Option<String>,
pub maybe_content_producer: Option<String>,
pub maybe_produce_id: Option<String>,
pub maybe_content_propagator: Option<String>,
pub maybe_propagate_id: Option<String>,
pub maybe_reserved_code_1: Option<String>,
pub maybe_reserved_code_2: Option<String>,
/// Whether the in-stream `kling-ai` SEI watermark is present.
pub has_stream_watermark: bool,
pub maybe_watermark_uuid: Option<String>,
}
@@ -0,0 +1,46 @@
//! Response type for `POST /v1/video_info/upload`.
//!
//! Same detected provenance as the read-only endpoint, plus the persisted
//! [`UploadedVideoToken`] (used for follow-up requests, e.g. attaching a note).
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
use tokens::tokens::uploaded_videos::UploadedVideoToken;
use crate::video_info::read_only::{
DreaminaVideoInfo, KlingVideoInfo, SeedanceVideoInfo, SoraVideoInfo, VeoVideoInfo,
VideoProvenanceKind,
};
/// Response for `POST /v1/video_info/upload`. Mirrors
/// [`crate::video_info::read_only::VideoInfoReadOnlyResponse`] and adds the
/// persisted record's token.
#[derive(Serialize, Deserialize, ToSchema, Debug, Clone)]
pub struct VideoInfoUploadResponse {
pub success: bool,
/// The persisted `uploaded_videos` record token (e.g. `uvt_…`).
pub uploaded_video_token: UploadedVideoToken,
/// Which provenance family was detected (if any).
pub kind: VideoProvenanceKind,
/// The container `©too` encoder tag, e.g. `"Lavf60.16.100"` or `"Google"`.
pub maybe_encoder: Option<String>,
/// Present when [`kind`](Self::kind) is [`VideoProvenanceKind::Seedance`].
pub maybe_seedance: Option<SeedanceVideoInfo>,
/// Present when [`kind`](Self::kind) is [`VideoProvenanceKind::Veo`].
pub maybe_veo: Option<VeoVideoInfo>,
/// Present when [`kind`](Self::kind) is [`VideoProvenanceKind::Sora`].
pub maybe_sora: Option<SoraVideoInfo>,
/// Present when [`kind`](Self::kind) is [`VideoProvenanceKind::Dreamina`].
pub maybe_dreamina: Option<DreaminaVideoInfo>,
/// Present when [`kind`](Self::kind) is [`VideoProvenanceKind::Kling`].
pub maybe_kling: Option<KlingVideoInfo>,
}
@@ -0,0 +1,20 @@
[package]
name = "artcraft_api_keys"
edition = "2024"
version = "0.0.1"
authors = [
"Brandon Thomas <bt@brand.io>",
"Brandon Thomas <echelon@gmail.com>",
]
publish = false
[package.metadata.cargo-udeps.ignore]
normal = ["workspace-build-acceleration"]
[lib]
name = "artcraft_api_keys"
path = "src/lib.rs"
[dependencies]
# Hackari workspace (faster compile times)
workspace-build-acceleration.workspace = true
@@ -0,0 +1,104 @@
use std::fmt;
use crate::parse_from_header_value::parse_from_header_value;
pub mod parse_from_header_value;
/// Number of leading characters shown when an [`ArtcraftApiKey`] is formatted. The remainder of
/// the secret is redacted so the full key never lands in logs or error messages.
const REDACTED_PREFIX_CHAR_COUNT: usize = 20;
/// An Artcraft API key (e.g. `artcraft_api_<entropy>`).
///
/// `Debug` and `Display` both redact the value, printing only the first
/// [`REDACTED_PREFIX_CHAR_COUNT`] characters followed by an ellipsis so the full secret is never
/// accidentally logged.
#[derive(Clone, PartialEq, Eq, Hash)]
pub struct ArtcraftApiKey(pub String);
impl ArtcraftApiKey {
pub fn new_from_str(value: &str) -> ArtcraftApiKey {
ArtcraftApiKey(value.to_string())
}
/// Parse an [`ArtcraftApiKey`] from the value of an HTTP `Authorization` header, accepting the
/// `Bearer <key>`, `Key <key>`, and bare `<key>` forms. See [`parse_from_header_value`] for the
/// full parsing rules.
pub fn parse_from_authorization_header_value(header_value: &str) -> Option<ArtcraftApiKey> {
parse_from_header_value(header_value)
}
/// The full, unredacted key value as a borrowed `&str`.
///
/// NB: unlike `Display`/`Debug` (which redact), this exposes the complete secret — the
/// `_be_careful` suffix is a reminder to use it only where the real value is genuinely needed
/// (storage, the create-key response, tests), never in logs or error messages.
pub fn as_str_be_careful(&self) -> &str {
&self.0
}
/// The full, unredacted key value as an owned `String`. See [`Self::as_str_be_careful`] for the
/// caveat behind the `_be_careful` suffix.
pub fn to_string_be_careful(&self) -> String {
self.0.clone()
}
/// The redacted form used by both `Debug` and `Display`: the first
/// [`REDACTED_PREFIX_CHAR_COUNT`] characters followed by an ellipsis.
fn redacted(&self) -> String {
let prefix: String = self.0.chars().take(REDACTED_PREFIX_CHAR_COUNT).collect();
format!("{prefix}…")
}
}
impl fmt::Display for ArtcraftApiKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.redacted())
}
}
impl fmt::Debug for ArtcraftApiKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "ArtcraftApiKey({:?})", self.redacted())
}
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE_KEY: &str = "artcraft_api_55ax0zhd580m598r6n4n7szdwjb2b28sypapvawh";
#[test]
fn new_from_str_and_as_str_round_trip() {
let key = ArtcraftApiKey::new_from_str(SAMPLE_KEY);
assert_eq!(key.as_str_be_careful(), SAMPLE_KEY);
assert_eq!(key.0, SAMPLE_KEY);
}
#[test]
fn display_redacts_to_first_twenty_characters() {
let key = ArtcraftApiKey::new_from_str(SAMPLE_KEY);
assert_eq!(format!("{key}"), "artcraft_api_55ax0zh…");
}
#[test]
fn debug_redacts_to_first_twenty_characters() {
let key = ArtcraftApiKey::new_from_str(SAMPLE_KEY);
assert_eq!(format!("{key:?}"), "ArtcraftApiKey(\"artcraft_api_55ax0zh…\")");
}
#[test]
fn formatting_does_not_leak_the_full_secret() {
let key = ArtcraftApiKey::new_from_str(SAMPLE_KEY);
let suffix = &SAMPLE_KEY[REDACTED_PREFIX_CHAR_COUNT..];
assert!(!format!("{key}").contains(suffix));
assert!(!format!("{key:?}").contains(suffix));
}
#[test]
fn shorter_than_prefix_is_printed_in_full() {
let key = ArtcraftApiKey::new_from_str("abc");
assert_eq!(format!("{key}"), "abc…");
}
}
@@ -0,0 +1,125 @@
use crate::ArtcraftApiKey;
/// `Bearer <api_key>` — the widely-accepted style for most APIs.
const BEARER_SCHEME: &str = "bearer";
/// `Key <api_key>` — legacy GitHub-style / Fal-style.
const KEY_SCHEME: &str = "key";
/// Parse an [`ArtcraftApiKey`] from the value of an HTTP `Authorization` header.
///
/// The value is accepted in three forms:
///
/// - `Bearer <api_key>` (widely-accepted style for most APIs)
/// - `Key <api_key>` (legacy GitHub-style / Fal-style)
/// - `<api_key>` (AWS-style — the bare key, no scheme)
///
/// The scheme keyword is matched case-insensitively, and surrounding/inner whitespace is trimmed.
/// Returns `None` if the value is empty, carries an unsupported scheme (e.g. `Basic ...`), or names
/// a scheme with no credential.
pub fn parse_from_header_value(header_value: &str) -> Option<ArtcraftApiKey> {
let trimmed = header_value.trim();
if trimmed.is_empty() {
return None;
}
let key = match trimmed.split_once(char::is_whitespace) {
// A scheme word followed by a value: "Bearer <key>" / "Key <key>".
Some((scheme, rest)) => {
if is_supported_scheme(scheme) {
let key = rest.trim();
if key.is_empty() {
// A supported scheme with no credential.
return None;
}
key
} else {
// An unsupported scheme (e.g. "Basic ..."). A bare API key never
// contains whitespace, so a multi-token value we don't recognize is
// not a usable key.
return None;
}
}
// No whitespace: AWS-style bare API key ("<api_key>") — unless the value is
// just a bare scheme keyword (a malformed header carrying no credential).
None => {
if is_supported_scheme(trimmed) {
return None;
}
trimmed
}
};
Some(ArtcraftApiKey::new_from_str(key))
}
fn is_supported_scheme(word: &str) -> bool {
word.eq_ignore_ascii_case(BEARER_SCHEME) || word.eq_ignore_ascii_case(KEY_SCHEME)
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE_KEY: &str = "artcraft_api_55ax0zhd580m598r6n4n7szdwjb2b28sypapvawh";
fn parsed(header_value: &str) -> Option<String> {
parse_from_header_value(header_value).map(|key| key.to_string_be_careful())
}
#[test]
fn bearer_scheme_is_case_insensitive() {
assert_eq!(parsed("Bearer abc"), Some("abc".to_string()));
assert_eq!(parsed("bearer abc"), Some("abc".to_string()));
assert_eq!(parsed("BEARER abc"), Some("abc".to_string()));
assert_eq!(parsed("BeArEr abc"), Some("abc".to_string()));
}
#[test]
fn key_scheme_is_case_insensitive() {
assert_eq!(parsed("Key abc"), Some("abc".to_string()));
assert_eq!(parsed("key abc"), Some("abc".to_string()));
assert_eq!(parsed("KEY abc"), Some("abc".to_string()));
}
#[test]
fn bare_key_aws_style() {
assert_eq!(parsed("abc"), Some("abc".to_string()));
assert_eq!(parsed(SAMPLE_KEY), Some(SAMPLE_KEY.to_string()));
}
#[test]
fn surrounding_and_inner_whitespace_is_trimmed() {
assert_eq!(parsed(" Bearer abc "), Some("abc".to_string()));
assert_eq!(parsed("\tKey\tabc\t"), Some("abc".to_string()));
assert_eq!(parsed(" abc "), Some("abc".to_string()));
}
#[test]
fn empty_or_blank_is_none() {
assert_eq!(parsed(""), None);
assert_eq!(parsed(" "), None);
}
#[test]
fn scheme_with_no_credential_is_none() {
assert_eq!(parsed("Bearer"), None);
assert_eq!(parsed("Bearer "), None);
assert_eq!(parsed("Key"), None);
assert_eq!(parsed("key "), None);
}
#[test]
fn unsupported_scheme_is_none() {
assert_eq!(parsed("Basic dXNlcjpwYXNz"), None);
assert_eq!(parsed("Digest abc"), None);
// Two non-scheme tokens — a bare key never contains whitespace.
assert_eq!(parsed("abc def"), None);
}
#[test]
fn returns_an_artcraft_api_key() {
let key = parse_from_header_value(&format!("Bearer {SAMPLE_KEY}")).expect("should parse");
assert_eq!(key.as_str_be_careful(), SAMPLE_KEY);
}
}
@@ -0,0 +1,19 @@
[package]
name = "bucket_client"
edition = "2024"
version = "0.0.1"
publish = false
[lib]
name = "bucket_client"
path = "src/lib.rs"
[package.metadata.cargo-udeps.ignore]
normal = ["workspace-build-acceleration"]
[dependencies]
# cargo-hakari workspace hack (faster compile times)
workspace-build-acceleration.workspace = true
# External
rust-s3.workspace = true
@@ -0,0 +1,66 @@
use s3::Bucket;
use crate::bucket_client_error::BucketClientError;
use crate::object_name::ObjectName;
/// Arguments for [`BucketClient::upload_file_bytes`].
pub struct UploadFileBytesArgs<'a, S: AsRef<str>> {
/// The destination object key (normalized via [`ObjectName`]).
pub object_name: S,
/// The raw bytes to upload.
pub bytes: &'a [u8],
/// Optional MIME type. Defaults to `application/octet-stream` when `None`.
pub content_type: Option<&'a str>,
}
/// A client for a single S3-compatible bucket. Cheap to clone. Build one with
/// [`crate::BucketClientBuilder`].
#[derive(Clone)]
pub struct BucketClient {
bucket: Bucket,
}
impl BucketClient {
/// Wrap a fully-configured `Bucket`. Internal — use
/// [`crate::BucketClientBuilder`] to construct a client.
pub(crate) fn from_bucket(bucket: Bucket) -> Self {
Self { bucket }
}
/// The name of the bucket this client targets.
pub fn bucket_name(&self) -> &str {
self.bucket.name.as_str()
}
/// Upload raw bytes to `object_name`, optionally with an explicit content type.
///
/// Returns [`BucketClientError::UploadFailed`] if the store responds with a
/// non-2xx status, or [`BucketClientError::S3Error`] on a transport/protocol
/// error.
pub async fn upload_file_bytes<S: AsRef<str>>(
&self,
args: UploadFileBytesArgs<'_, S>,
) -> Result<(), BucketClientError> {
let object_name = ObjectName::new(&args.object_name);
let response = match args.content_type {
Some(content_type) => {
self
.bucket
.put_object_with_content_type(object_name.as_str(), args.bytes, content_type)
.await?
}
None => self.bucket.put_object(object_name.as_str(), args.bytes).await?,
};
let status_code = response.status_code();
if !(200..300).contains(&status_code) {
return Err(BucketClientError::UploadFailed {
status_code,
message: String::from_utf8_lossy(response.bytes()).into_owned(),
});
}
Ok(())
}
}
@@ -0,0 +1,119 @@
use std::time::Duration;
use s3::creds::Credentials;
use s3::region::Region;
use s3::Bucket;
use crate::bucket_client::BucketClient;
use crate::bucket_client_error::BucketClientError;
/// Builder for [`BucketClient`].
///
/// `access_key`, `secret_key`, `region_name`, and `bucket_name` are required;
/// `endpoint` and `bucket_request_timeout` are optional.
///
/// - With an `endpoint` set, the bucket targets a custom S3-compatible host
/// (R2, GCS, MinIO, etc.) via `Region::Custom`, and uses path-style addressing
/// (subdomain-style for Google Cloud Storage).
/// - Without an `endpoint`, the `region_name` is parsed as an AWS region
/// (e.g. `"us-east-1"`), which determines the AWS endpoint automatically.
#[derive(Debug, Default, Clone)]
pub struct BucketClientBuilder {
access_key: Option<String>,
secret_key: Option<String>,
region_name: Option<String>,
bucket_name: Option<String>,
endpoint: Option<String>,
bucket_request_timeout: Option<Duration>,
}
impl BucketClientBuilder {
pub fn new() -> Self {
Self::default()
}
pub fn access_key(mut self, access_key: impl Into<String>) -> Self {
self.access_key = Some(access_key.into());
self
}
pub fn secret_key(mut self, secret_key: impl Into<String>) -> Self {
self.secret_key = Some(secret_key.into());
self
}
pub fn region_name(mut self, region_name: impl Into<String>) -> Self {
self.region_name = Some(region_name.into());
self
}
pub fn bucket_name(mut self, bucket_name: impl Into<String>) -> Self {
self.bucket_name = Some(bucket_name.into());
self
}
/// Optional custom S3-compatible endpoint (e.g. an R2/GCS/MinIO URL). When
/// unset, an AWS endpoint is derived from `region_name`.
pub fn endpoint(mut self, endpoint: impl Into<String>) -> Self {
self.endpoint = Some(endpoint.into());
self
}
pub fn bucket_request_timeout(mut self, timeout: Duration) -> Self {
self.bucket_request_timeout = Some(timeout);
self
}
/// Validate the configuration and construct a [`BucketClient`].
pub fn build(self) -> Result<BucketClient, BucketClientError> {
let access_key = require(self.access_key, "access_key")?;
let secret_key = require(self.secret_key, "secret_key")?;
let region_name = require(self.region_name, "region_name")?;
let bucket_name = require(self.bucket_name, "bucket_name")?;
let credentials = Credentials {
access_key: Some(access_key),
secret_key: Some(secret_key),
security_token: None,
session_token: None,
expiration: None,
};
// A custom endpoint targets an S3-compatible host via `Region::Custom`;
// otherwise the region name is parsed as a standard AWS region.
let region = match self.endpoint.as_deref() {
Some(endpoint) => Region::Custom {
region: region_name.clone(),
endpoint: endpoint.to_string(),
},
None => region_name.parse().map_err(|err| {
BucketClientError::ClientBuilderSetupError(format!(
"invalid region {region_name:?}: {err:?}"
))
})?,
};
let mut bucket = Bucket::new(&bucket_name, region, credentials)?;
bucket.set_request_timeout(self.bucket_request_timeout);
// Addressing style only matters for custom endpoints: Google Cloud Storage
// needs subdomain (virtual-hosted) style; other S3-compatible stores use
// path style. AWS uses the crate default (virtual-hosted).
if let Some(endpoint) = self.endpoint.as_deref() {
if endpoint == "https://storage.googleapis.com" {
bucket.set_subdomain_style();
} else {
bucket.set_path_style();
}
}
Ok(BucketClient::from_bucket(*bucket))
}
}
/// Resolve a required builder field, or report which one was missing.
fn require(value: Option<String>, field: &str) -> Result<String, BucketClientError> {
value.ok_or_else(|| {
BucketClientError::ClientBuilderSetupError(format!("{field} is required"))
})
}
@@ -0,0 +1,56 @@
use std::fmt;
use std::io;
/// Errors produced by [`crate::BucketClient`] and [`crate::BucketClientBuilder`].
#[derive(Debug)]
pub enum BucketClientError {
/// An error surfaced by the underlying `rust-s3` client.
S3Error(s3::error::S3Error),
/// A local I/O error (e.g. while reading bytes to upload).
IoError(io::Error),
/// The builder was misconfigured — a required field was missing, or client
/// construction failed for a reason we can describe.
ClientBuilderSetupError(String),
/// The object store accepted the request but returned a non-success status.
UploadFailed { status_code: u16, message: String },
}
impl fmt::Display for BucketClientError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::S3Error(err) => write!(f, "S3 error: {}", err),
Self::IoError(err) => write!(f, "I/O error: {}", err),
Self::ClientBuilderSetupError(message) => {
write!(f, "bucket client setup error: {}", message)
}
Self::UploadFailed { status_code, message } => {
write!(f, "upload failed (HTTP {}): {}", status_code, message)
}
}
}
}
impl std::error::Error for BucketClientError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::S3Error(err) => Some(err),
Self::IoError(err) => Some(err),
_ => None,
}
}
}
impl From<s3::error::S3Error> for BucketClientError {
fn from(err: s3::error::S3Error) -> Self {
Self::S3Error(err)
}
}
impl From<io::Error> for BucketClientError {
fn from(err: io::Error) -> Self {
Self::IoError(err)
}
}
@@ -0,0 +1,15 @@
//! bucket_client
//!
//! A small, ergonomic client for S3-compatible object storage, built on the
//! `rust-s3` crate. Construct one with [`BucketClientBuilder`] and upload bytes
//! with [`BucketClient::upload_file_bytes`].
pub mod bucket_client;
pub mod bucket_client_builder;
pub mod bucket_client_error;
pub mod object_name;
pub use bucket_client::{BucketClient, UploadFileBytesArgs};
pub use bucket_client_builder::BucketClientBuilder;
pub use bucket_client_error::BucketClientError;
pub use object_name::ObjectName;
@@ -0,0 +1,52 @@
use std::fmt;
/// A normalized object key for the bucket.
///
/// Leading slashes are stripped: S3/R2 treat a leading `/` as an empty leading
/// path segment, which silently produces surprising keys (e.g. `/foo.mp4`
/// becomes an object literally named `/foo.mp4`). Normalizing here keeps stored
/// keys consistent regardless of how callers format the name.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ObjectName(String);
impl ObjectName {
/// Build a normalized object name from any string-like value.
pub fn new(raw: impl AsRef<str>) -> Self {
ObjectName(raw.as_ref().trim_start_matches('/').to_string())
}
/// The normalized key as a string slice.
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for ObjectName {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl AsRef<str> for ObjectName {
fn as_ref(&self) -> &str {
&self.0
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strips_leading_slashes() {
assert_eq!(ObjectName::new("/foo/bar.mp4").as_str(), "foo/bar.mp4");
assert_eq!(ObjectName::new("///a").as_str(), "a");
assert_eq!(ObjectName::new("already/clean.txt").as_str(), "already/clean.txt");
}
#[test]
fn accepts_string_and_str() {
assert_eq!(ObjectName::new(String::from("x.bin")).as_str(), "x.bin");
assert_eq!(ObjectName::new("x.bin").as_str(), "x.bin");
}
}
@@ -125,6 +125,23 @@ mod tests {
Ok(Seedance2ProSession::from_cookies_string(cookies))
}
/// Regression: the Kinovi API started returning `credits` as a float
/// (e.g. `372215.18`) while `availableCredits` stays an int. Both must parse
/// and coerce to `u64` instead of failing with
/// "invalid type: floating point ..., expected u64".
#[test]
fn float_credits_are_coerced() {
let body = r#"[{"result":{"data":{"json":{
"email":"hello@storyteller.ai",
"credits":372215.18,
"availableCredits":372215
}}}}]"#;
let batch: Vec<BatchResponseItem> = serde_json::from_str(body).expect("parse user auth");
let json = batch.into_iter().next().expect("non-empty batch").result.data.json;
assert_eq!(json.credits, 372215);
assert_eq!(json.available_credits, 372215);
}
#[tokio::test]
#[ignore] // manually test — requires real cookies
async fn test_get_user_auth_details() -> AnyhowResult<()> {
@@ -1,5 +1,7 @@
use serde_derive::Deserialize;
use crate::utils::number_coercion::de_u64_int_or_float;
#[derive(Deserialize, Debug)]
pub(super) struct BatchResponseItem {
pub result: BatchResponseResult,
@@ -19,6 +21,8 @@ pub(super) struct BatchResponseData {
#[serde(rename_all = "camelCase")]
pub(super) struct RawUserAuthDetails {
pub email: String,
#[serde(deserialize_with = "de_u64_int_or_float")]
pub credits: u64,
#[serde(deserialize_with = "de_u64_int_or_float")]
pub available_credits: u64,
}
@@ -457,6 +457,30 @@ mod tests {
assert_eq!(orders[0].task_status, TaskStatus::Processing);
}
/// Regression: the Kinovi API started returning result `width`/`height`
/// (and `totalCredits`) as floats (e.g. `568.5`). They must parse and
/// coerce to unsigned ints instead of failing with
/// "invalid type: floating point ..., expected u32".
#[test]
fn float_dimensions_and_credits_are_coerced() {
let body = r#"[{"result":{"data":{"json":{"orders":[{
"orderId":"ord_float",
"resultUrl":"https://example.com/v.mp4",
"taskStatus":"COMPLETED",
"results":[{"url":"https://example.com/v.mp4","width":568.5,"height":1023.4}],
"failReason":null,
"createdAt":"2026-02-19T01:20:50.398Z",
"mediaType":"video",
"totalCredits":12.9
}],"nextCursor":394062.0}}}}]"#;
let orders = parse_orders(body);
assert_eq!(orders.len(), 1);
assert_eq!(orders[0].results.len(), 1);
assert_eq!(orders[0].results[0].width, 569);
assert_eq!(orders[0].results[0].height, 1023);
assert_eq!(orders[0].total_credits, Some(13));
}
/// Unrecognised mediaType values shouldn't crash deserialisation;
/// they end up as `Unknown(_)`.
#[test]
@@ -1,5 +1,9 @@
use serde_derive::Deserialize;
use crate::utils::number_coercion::{
de_opt_u32_int_or_float, de_opt_u64_int_or_float, de_u32_int_or_float,
};
#[derive(Deserialize, Debug)]
pub(super) struct BatchResponseItem {
pub result: BatchResponseResult,
@@ -19,6 +23,7 @@ pub(super) struct BatchResponseData {
#[serde(rename_all = "camelCase")]
pub(super) struct OrdersResponseJson {
pub orders: Vec<RawOrder>,
#[serde(default, deserialize_with = "de_opt_u64_int_or_float")]
pub next_cursor: Option<u64>,
}
@@ -39,14 +44,16 @@ pub(super) struct RawOrder {
/// The Kinovi credits charged for the order. `None` for older response
/// shapes that didn't include the field.
#[serde(default)]
#[serde(default, deserialize_with = "de_opt_u32_int_or_float")]
pub total_credits: Option<u32>,
}
#[derive(Deserialize, Debug)]
pub(super) struct RawVideoResult {
pub url: String,
#[serde(deserialize_with = "de_u32_int_or_float")]
pub width: u32,
#[serde(deserialize_with = "de_u32_int_or_float")]
pub height: u32,
// pub ratio: Option<f64>,
}
@@ -1,2 +1,3 @@
pub mod categorize_seedance2pro_error;
pub mod common_headers;
pub mod number_coercion;
@@ -0,0 +1,136 @@
//! Deserialization helpers for numeric fields that the Kinovi API has started
//! returning as floating-point values where it previously returned integers
//! (e.g. `width: 568.5`, `credits: 372215.18`).
//!
//! Each helper accepts EITHER a JSON integer or a JSON float and coerces it to
//! an unsigned integer: exact integers pass through unchanged (no precision
//! loss for large `u64` credit balances), and floats are rounded to the nearest
//! integer. Negative / non-finite values clamp to `0`.
use serde::{Deserialize, Deserializer};
use serde_json::Number;
/// Coerce a parsed JSON number to `u64`. Exact non-negative integers pass
/// through; floats are rounded; anything negative / non-finite becomes `0`.
fn coerce_to_u64(number: &Number) -> u64 {
if let Some(unsigned) = number.as_u64() {
return unsigned;
}
if let Some(float) = number.as_f64() {
if float.is_finite() && float >= 0.0 {
return float.round() as u64;
}
}
0
}
fn coerce_to_u32(number: &Number) -> u32 {
coerce_to_u64(number).min(u32::MAX as u64) as u32
}
/// `#[serde(deserialize_with = "...")]` for a required `u64` field that may
/// arrive as an integer or a float.
pub fn de_u64_int_or_float<'de, D>(deserializer: D) -> Result<u64, D::Error>
where
D: Deserializer<'de>,
{
let number = Number::deserialize(deserializer)?;
Ok(coerce_to_u64(&number))
}
/// `#[serde(deserialize_with = "...")]` for an optional `u64` field (handles
/// `null` / missing as `None`).
pub fn de_opt_u64_int_or_float<'de, D>(deserializer: D) -> Result<Option<u64>, D::Error>
where
D: Deserializer<'de>,
{
let maybe_number = Option::<Number>::deserialize(deserializer)?;
Ok(maybe_number.as_ref().map(coerce_to_u64))
}
/// `#[serde(deserialize_with = "...")]` for an optional `u32` field (handles
/// `null` / missing as `None`).
pub fn de_opt_u32_int_or_float<'de, D>(deserializer: D) -> Result<Option<u32>, D::Error>
where
D: Deserializer<'de>,
{
let maybe_number = Option::<Number>::deserialize(deserializer)?;
Ok(maybe_number.as_ref().map(coerce_to_u32))
}
/// `#[serde(deserialize_with = "...")]` for a required `u32` field that may
/// arrive as an integer or a float.
pub fn de_u32_int_or_float<'de, D>(deserializer: D) -> Result<u32, D::Error>
where
D: Deserializer<'de>,
{
let number = Number::deserialize(deserializer)?;
Ok(coerce_to_u32(&number))
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Deserialize)]
struct U32Holder {
#[serde(deserialize_with = "de_u32_int_or_float")]
value: u32,
}
#[derive(Deserialize)]
struct U64Holder {
#[serde(deserialize_with = "de_u64_int_or_float")]
value: u64,
}
#[derive(Deserialize)]
struct OptU32Holder {
#[serde(default, deserialize_with = "de_opt_u32_int_or_float")]
value: Option<u32>,
}
fn parse_u32(json: &str) -> u32 {
serde_json::from_str::<U32Holder>(json).unwrap().value
}
fn parse_u64(json: &str) -> u64 {
serde_json::from_str::<U64Holder>(json).unwrap().value
}
#[test]
fn u32_accepts_integer() {
assert_eq!(parse_u32(r#"{"value": 568}"#), 568);
}
#[test]
fn u32_accepts_float_and_rounds() {
assert_eq!(parse_u32(r#"{"value": 568.5}"#), 569);
assert_eq!(parse_u32(r#"{"value": 568.4}"#), 568);
}
#[test]
fn u64_accepts_integer_exactly() {
// Large value that would lose precision via f64 — must pass through exact.
assert_eq!(parse_u64(r#"{"value": 372215}"#), 372215);
}
#[test]
fn u64_accepts_float_and_rounds() {
assert_eq!(parse_u64(r#"{"value": 372215.18}"#), 372215);
}
#[test]
fn negative_and_nonfinite_clamp_to_zero() {
assert_eq!(parse_u64(r#"{"value": -5}"#), 0);
assert_eq!(parse_u64(r#"{"value": -5.5}"#), 0);
}
#[test]
fn optional_handles_null_missing_and_values() {
assert_eq!(serde_json::from_str::<OptU32Holder>(r#"{"value": null}"#).unwrap().value, None);
assert_eq!(serde_json::from_str::<OptU32Holder>(r#"{}"#).unwrap().value, None);
assert_eq!(serde_json::from_str::<OptU32Holder>(r#"{"value": 12.9}"#).unwrap().value, Some(13));
assert_eq!(serde_json::from_str::<OptU32Holder>(r#"{"value": 12}"#).unwrap().value, Some(12));
}
}
+1 -1
View File
@@ -19,7 +19,7 @@ name = "artcraft_app_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2.1.1", features = [] }
tauri-build = { version = "2.6.2", features = [] }
[dependencies]
# Workspace hack (faster compile times)
+19
View File
@@ -0,0 +1,19 @@
[package]
name = "sha1_hash"
edition = "2024"
version = "0.0.1"
publish = false
[lib]
name = "sha1_hash"
path = "src/lib.rs"
[package.metadata.cargo-udeps.ignore]
normal = ["workspace-build-acceleration"]
[dependencies]
# Workspace hack (faster compile times)
workspace-build-acceleration = { version = "0.1", path = "../../workspace-build-acceleration" }
# External
sha1 = "0.10"
+17
View File
@@ -0,0 +1,17 @@
use std::fmt;
/// Errors produced while computing a SHA-1 hash.
#[derive(Debug)]
pub enum Sha1HashError {
/// The SHA-1 digest wasn't the expected 20 bytes. This should be unreachable
/// for a well-behaved SHA-1 implementation; it exists as a defensive guard.
UnexpectedDigestLength(usize),
}
impl fmt::Display for Sha1HashError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{:?}", self)
}
}
impl std::error::Error for Sha1HashError {}
+11
View File
@@ -0,0 +1,11 @@
//! sha1_hash
//!
//! SHA-1 hashing utilities. The primary entry point is
//! [`sha1_hash_bytes_as_crockford`], which hashes bytes with SHA-1 and returns
//! the 20-byte digest as a lowercase Crockford base32 string (32 chars).
pub mod error;
pub mod sha1_hash_bytes_as_crockford;
pub use error::Sha1HashError;
pub use sha1_hash_bytes_as_crockford::sha1_hash_bytes_as_crockford;
@@ -0,0 +1,74 @@
use sha1::{Digest, Sha1};
use crate::error::Sha1HashError;
/// Lowercase Crockford base32 alphabet (excludes I, L, O, U). Matches the casing
/// used by the `tokens` crate's Crockford generators.
const CROCKFORD_LOWERCASE: &[u8] = b"0123456789abcdefghjkmnpqrstvwxyz";
/// The byte length of a SHA-1 digest.
const SHA1_DIGEST_LEN: usize = 20;
/// SHA-1 hash `bytes` and return the 20-byte digest encoded as a lowercase
/// Crockford base32 string (32 characters, no padding — 160 bits / 5).
pub fn sha1_hash_bytes_as_crockford(bytes: &[u8]) -> Result<String, Sha1HashError> {
let mut hasher = Sha1::new();
hasher.update(bytes);
let digest = hasher.finalize();
if digest.len() != SHA1_DIGEST_LEN {
return Err(Sha1HashError::UnexpectedDigestLength(digest.len()));
}
Ok(encode_crockford_lower(&digest))
}
/// Encode bytes as lowercase Crockford base32, reading 5 bits at a time.
fn encode_crockford_lower(data: &[u8]) -> String {
let mut out = String::with_capacity(data.len() * 8 / 5 + 1);
let mut buffer: u32 = 0;
let mut bits: u32 = 0;
for &byte in data {
buffer = (buffer << 8) | byte as u32;
bits += 8;
while bits >= 5 {
bits -= 5;
let index = ((buffer >> bits) & 0x1f) as usize;
out.push(CROCKFORD_LOWERCASE[index] as char);
}
}
if bits > 0 {
let index = ((buffer << (5 - bits)) & 0x1f) as usize;
out.push(CROCKFORD_LOWERCASE[index] as char);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_input_is_32_crockford_chars() {
// SHA-1("") = da39a3ee5e6b4b0d3255bfef95601890afd80709
let out = sha1_hash_bytes_as_crockford(&[]).unwrap();
assert_eq!(out.len(), 32);
assert!(out.bytes().all(|b| CROCKFORD_LOWERCASE.contains(&b)));
}
#[test]
fn deterministic_and_distinct() {
let a = sha1_hash_bytes_as_crockford(b"hello").unwrap();
let b = sha1_hash_bytes_as_crockford(b"hello").unwrap();
let c = sha1_hash_bytes_as_crockford(b"world").unwrap();
assert_eq!(a, b);
assert_ne!(a, c);
assert_eq!(a.len(), 32);
}
#[test]
fn encodes_known_bytes() {
// 20 zero bytes → 32 '0' chars.
assert_eq!(encode_crockford_lower(&[0u8; 20]), "0".repeat(32));
}
}
+28
View File
@@ -0,0 +1,28 @@
[package]
name = "video_info"
edition = "2024"
version = "0.0.1"
authors = [
"Brandon Thomas <bt@brand.io>",
"Brandon Thomas <echelon@gmail.com>",
]
publish = false
[package.metadata.cargo-udeps.ignore]
normal = ["workspace-build-acceleration"]
[lib]
name = "video_info"
path = "src/lib.rs"
# Optional CLI utility for inspecting a video's Seedance provenance.
[[bin]]
name = "video-info-parser"
path = "src/bin/video_info_parser.rs"
[dependencies]
# Workspace hack (faster compile times)
workspace-build-acceleration.workspace = true
# External
chrono.workspace = true
@@ -0,0 +1,407 @@
//! `video-info-parser` — inspect AI-generation C2PA provenance (Seedance, Veo,
//! Sora, Dreamina, Kling) for a single video, or a whole directory of them.
//!
//! Usage:
//! video-info-parser <FILE> # detailed report for one video
//! video-info-parser <DIR> # summary table for every .mp4 in the dir
//! video-info-parser --filename <PATH>
//! video-info-parser <DIR> --truncate # shorten long filenames in the table
use std::fs;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use video_info::error::VideoInfoError;
use video_info::{DreaminaInfo, KlingInfo, SeedanceInfo, SoraInfo, VeoInfo, VideoInfo};
/// Max width before a cell value is truncated with an ellipsis.
const MAX_CELL: usize = 30;
/// Parsed command-line arguments.
struct Args {
/// File or directory to inspect.
path: String,
/// Whether to shorten long filenames in directory-table output. Defaults to
/// `false` so full filenames are always shown.
truncate_names: bool,
}
fn main() -> ExitCode {
let args = match parse_args(std::env::args().skip(1)) {
Ok(args) => args,
Err(msg) => {
eprintln!("{msg}");
eprintln!("usage: video-info-parser <FILE|DIR> [--truncate]");
eprintln!(" video-info-parser --filename <PATH>");
return ExitCode::from(2);
}
};
match fs::metadata(&args.path) {
Ok(meta) if meta.is_dir() => run_directory(&args.path, args.truncate_names),
Ok(_) => run_single_file(&args.path),
Err(err) => {
eprintln!("error: cannot read {:?}: {err}", args.path);
ExitCode::FAILURE
}
}
}
/// Accept the path as the first positional arg, or via `--filename <PATH>` /
/// `--filename=<PATH>`. The path may be a single file or a directory.
/// `--truncate` shortens long filenames in directory-table output.
fn parse_args(args: impl Iterator<Item = String>) -> Result<Args, String> {
let mut path: Option<String> = None;
let mut truncate_names = false;
let mut args = args.peekable();
while let Some(arg) = args.next() {
match arg.as_str() {
"--filename" | "-f" => {
let value = args.next().ok_or_else(|| "error: --filename requires a value".to_string())?;
path = Some(value);
}
"--truncate" | "--truncate-names" => truncate_names = true,
"--help" | "-h" => {
return Err("video-info-parser: print AI-generation provenance for a file or directory".to_string());
}
_ => {
if let Some(value) = arg.strip_prefix("--filename=") {
path = Some(value.to_string());
} else if arg.starts_with('-') {
return Err(format!("error: unknown flag {arg:?}"));
} else if path.is_none() {
path = Some(arg);
} else {
return Err(format!("error: unexpected extra argument {arg:?}"));
}
}
}
}
match path {
Some(path) => Ok(Args { path, truncate_names }),
None => Err("error: no path provided".to_string()),
}
}
// ── Single-file mode ──
fn run_single_file(filename: &str) -> ExitCode {
let bytes = match fs::read(filename) {
Ok(bytes) => bytes,
Err(err) => {
eprintln!("error: cannot read {filename:?}: {err}");
return ExitCode::FAILURE;
}
};
match VideoInfo::from_bytes(&bytes) {
Ok(VideoInfo::Seedance(info)) => print_seedance(filename, &info),
Ok(VideoInfo::Veo(info)) => print_veo(filename, &info),
Ok(VideoInfo::Sora(info)) => print_sora(filename, &info),
Ok(VideoInfo::Dreamina(info)) => print_dreamina(filename, &info),
Ok(VideoInfo::Kling(info)) => print_kling(filename, &info),
Err(VideoInfoError::Unrecognized) => {
print!("No recognized provenance (not Seedance, Veo, Sora, Dreamina, or Kling)");
match video_info::encoder_tag(&bytes) {
Some(enc) if enc.starts_with("Lavf") => {
println!(" — re-encoded through ffmpeg (encoder: {enc}), which strips provenance");
}
Some(enc) => println!(" — encoder: {enc}"),
None => println!(),
}
}
Err(err) => {
eprintln!("error: {err}");
return ExitCode::FAILURE;
}
}
ExitCode::SUCCESS
}
// ── Directory mode (summary table) ──
const TABLE_HEADERS: [&str; 6] = ["FILE", "KIND", "MODEL", "DETAIL", "GENERATED", "SIGNER"];
fn run_directory(dir: &str, truncate_names: bool) -> ExitCode {
let files = match collect_mp4s(Path::new(dir)) {
Ok(files) => files,
Err(err) => {
eprintln!("error: cannot list {dir:?}: {err}");
return ExitCode::FAILURE;
}
};
if files.is_empty() {
println!("no .mp4 files found in {dir}");
return ExitCode::SUCCESS;
}
let mut rows: Vec<[String; 6]> = Vec::with_capacity(files.len());
let mut tally: std::collections::BTreeMap<String, usize> = std::collections::BTreeMap::new();
for file in &files {
let name = file.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
let row = match fs::read(file) {
Ok(bytes) => {
let encoder = video_info::encoder_tag(&bytes);
summary_row(&name, &VideoInfo::from_bytes(&bytes), encoder.as_deref(), truncate_names)
}
Err(err) => summary_row(&name, &Err(VideoInfoError::Io(err)), None, truncate_names),
};
*tally.entry(row[1].clone()).or_default() += 1;
rows.push(row);
}
render_table(&TABLE_HEADERS, &rows);
println!();
let total = files.len();
let breakdown =
tally.iter().map(|(kind, n)| format!("{kind}: {n}")).collect::<Vec<_>>().join(", ");
println!("{total} file(s) — {breakdown}");
ExitCode::SUCCESS
}
/// Collect every `.mp4` (case-insensitive) directly under `dir`, sorted by name.
fn collect_mp4s(dir: &Path) -> std::io::Result<Vec<PathBuf>> {
let mut out = Vec::new();
for entry in fs::read_dir(dir)? {
let path = entry?.path();
let is_mp4 = path
.extension()
.and_then(|e| e.to_str())
.is_some_and(|e| e.eq_ignore_ascii_case("mp4"));
if path.is_file() && is_mp4 {
out.push(path);
}
}
out.sort();
Ok(out)
}
/// Reduce a parse result to one table row: `[file, kind, model, detail, generated, signer]`.
/// Filenames are shown in full unless `truncate_names` is set. `encoder` is the
/// `©too` tag, shown in DETAIL for unrecognized files to explain the absence.
fn summary_row(
name: &str,
result: &Result<VideoInfo, VideoInfoError>,
encoder: Option<&str>,
truncate_names: bool,
) -> [String; 6] {
let file = if truncate_names { truncate(name, MAX_CELL) } else { name.to_string() };
let (kind, model, detail, generated, signer) = match result {
Ok(VideoInfo::Seedance(i)) => {
let variant = if i.is_fast {
" (fast)"
} else if i.is_lite {
" (lite)"
} else {
""
};
let detail = format!("{}{variant}", i.platform.as_str());
("Seedance", i.model_name.clone(), detail, i.generated_at.clone(), signer_line(&i.signer_country, &i.cert_serial))
}
Ok(VideoInfo::Veo(i)) => {
let detail = if i.has_c2pa_manifest {
if i.has_synthid_watermark { "c2pa + synthid".to_string() } else { "c2pa".to_string() }
} else {
format!("encoder={}", i.encoder.as_deref().unwrap_or("?"))
};
("Veo", "Google Veo".to_string(), detail, String::new(), short_opt(&i.cert_serial))
}
Ok(VideoInfo::Sora(i)) => (
"Sora",
"OpenAI Sora".to_string(),
i.model_name.clone().unwrap_or_default(),
String::new(),
short_opt(&i.cert_serial),
),
Ok(VideoInfo::Dreamina(i)) => {
let signer = if i.has_c2pa { signer_line(&i.signer_country, &i.cert_serial) } else { String::new() };
("Dreamina", i.product.clone(), i.video_id.clone().unwrap_or_default(), String::new(), signer)
}
Ok(VideoInfo::Kling(i)) => {
let model = match &i.model_version {
Some(v) => format!("Kling {v}"),
None => "Kling".to_string(),
};
let detail = i.produce_id.clone().unwrap_or_else(|| {
if i.has_stream_watermark { "watermark-only".to_string() } else { String::new() }
});
("Kling", model, detail, String::new(), i.content_producer.clone().unwrap_or_default())
}
Err(VideoInfoError::Unrecognized) => {
let detail = match encoder {
Some(enc) if enc.starts_with("Lavf") => format!("re-encoded: {enc}"),
Some(enc) => format!("encoder: {enc}"),
None => "no provenance".to_string(),
};
("—", String::new(), detail, String::new(), String::new())
}
Err(err) => ("error", err.to_string(), String::new(), String::new(), String::new()),
};
[
file,
kind.to_string(),
truncate(&model, MAX_CELL),
truncate(&detail, MAX_CELL),
generated,
truncate(&signer, MAX_CELL),
]
}
/// `"<country> <short-cert>"`, omitting whichever parts are absent.
fn signer_line(country: &Option<String>, cert: &Option<String>) -> String {
match (country.as_deref(), cert.as_deref()) {
(Some(c), Some(s)) => format!("{c} {}", short(s, 14)),
(Some(c), None) => c.to_string(),
(None, Some(s)) => short(s, 14),
(None, None) => String::new(),
}
}
fn short_opt(value: &Option<String>) -> String {
value.as_deref().map(|s| short(s, 14)).unwrap_or_default()
}
fn short(value: &str, max: usize) -> String {
truncate(value, max)
}
/// Truncate to `max` characters, appending `…` when shortened.
fn truncate(value: &str, max: usize) -> String {
if value.chars().count() <= max {
return value.to_string();
}
let kept: String = value.chars().take(max.saturating_sub(1)).collect();
format!("{kept}…")
}
/// Render left-aligned columns sized to the widest cell in each.
fn render_table(headers: &[&str], rows: &[[String; 6]]) {
let mut widths: Vec<usize> = headers.iter().map(|h| h.chars().count()).collect();
for row in rows {
for (i, cell) in row.iter().enumerate() {
widths[i] = widths[i].max(cell.chars().count());
}
}
let render = |cells: &[String]| {
cells
.iter()
.enumerate()
.map(|(i, c)| format!("{:<width$}", c, width = widths[i]))
.collect::<Vec<_>>()
.join(" ")
};
let header_cells: Vec<String> = headers.iter().map(|h| h.to_string()).collect();
println!("{}", render(&header_cells).trim_end());
let separators: Vec<String> = widths.iter().map(|w| "-".repeat(*w)).collect();
println!("{}", render(&separators).trim_end());
for row in rows {
println!("{}", render(row).trim_end());
}
}
// ── Printing ──
fn row(label: &str, value: &str) {
println!(" {label:>24} : {value}");
}
fn opt(label: &str, value: &Option<String>) {
row(label, value.as_deref().unwrap_or("(none)"));
}
fn header(title: &str, filename: &str) {
println!("{title}");
row("file", filename);
println!(" {}", "-".repeat(60));
}
fn print_seedance(filename: &str, info: &SeedanceInfo) {
header("Seedance video provenance", filename);
row("platform", info.platform.as_str());
row("software agent", &info.software_agent);
opt("software agent version", &info.software_agent_version);
row("model name", &info.model_name);
opt("model brand", &info.model_brand);
opt("model version", &info.model_version);
row("fast variant", if info.is_fast { "yes" } else { "no" });
row("lite variant", if info.is_lite { "yes" } else { "no" });
row("generated at", &info.generated_at);
row(
"generated at (parsed)",
&info.generated_at_utc.map(|t| t.to_rfc3339()).unwrap_or_else(|| "(unparseable)".to_string()),
);
opt("log id", &info.log_id);
opt("log id (decoded hex)", &info.log_id_decoded_hex);
opt("digital source type", &info.digital_source_type);
opt("claim generator", &info.claim_generator);
opt("claim generator version", &info.claim_generator_version);
opt("manifest id", &info.manifest_id);
opt("instance id", &info.instance_id);
opt("signer email", &info.signer_email);
opt("signer org id", &info.signer_org_id);
opt("signer country", &info.signer_country);
opt("cert serial", &info.cert_serial);
}
fn print_veo(filename: &str, info: &VeoInfo) {
header("Google Veo video provenance", filename);
row("producer", &info.producer);
row("c2pa manifest", if info.has_c2pa_manifest { "yes" } else { "no (encoder tag only)" });
opt("encoder", &info.encoder);
opt("created description", &info.created_description);
row("synthid watermark", if info.has_synthid_watermark { "yes" } else { "no" });
opt("synthid description", &info.synthid_description);
opt("digital source type", &info.digital_source_type);
opt("claim generator", &info.claim_generator);
opt("claim generator version", &info.claim_generator_version);
opt("manifest id", &info.manifest_id);
opt("instance id", &info.instance_id);
opt("cert serial", &info.cert_serial);
opt("signer ca", &info.signer_ca);
row("timestamped", if info.is_timestamped { "yes" } else { "no" });
opt("timestamp authority", &info.timestamp_authority);
row("model name", "(not embedded in metadata)");
}
fn print_sora(filename: &str, info: &SoraInfo) {
header("OpenAI Sora video provenance", filename);
row("producer", &info.producer);
opt("model name", &info.model_name);
opt("created description", &info.created_description);
opt("digital source type", &info.digital_source_type);
opt("claim generator", &info.claim_generator);
opt("manifest id", &info.manifest_id);
opt("instance id", &info.instance_id);
opt("cert serial", &info.cert_serial);
}
fn print_dreamina(filename: &str, info: &DreaminaInfo) {
header("Dreamina (ByteDance/CapCut) video provenance", filename);
row("product", &info.product);
opt("export type", &info.export_type);
opt("os", &info.os);
opt("source info", &info.source_info);
row(
"aigc label type",
&info.aigc_label_type.map(|n| n.to_string()).unwrap_or_else(|| "(none)".to_string()),
);
opt("video id", &info.video_id);
row("has c2pa", if info.has_c2pa { "yes" } else { "no" });
opt("signer org id", &info.signer_org_id);
opt("signer country", &info.signer_country);
opt("cert serial", &info.cert_serial);
}
fn print_kling(filename: &str, info: &KlingInfo) {
header("Kling (Kuaishou) video provenance", filename);
opt("model version", &info.model_version);
row("ai-generated", if info.is_ai_generated { "yes" } else { "no" });
opt("aigc label", &info.label);
opt("content producer", &info.content_producer);
opt("produce id", &info.produce_id);
opt("content propagator", &info.content_propagator);
opt("propagate id", &info.propagate_id);
opt("reserved code 1", &info.reserved_code_1);
opt("reserved code 2", &info.reserved_code_2);
row("stream watermark", if info.has_stream_watermark { "yes" } else { "no" });
opt("watermark uuid", &info.watermark_uuid);
}
+159
View File
@@ -0,0 +1,159 @@
//! Parse **Dreamina** (ByteDance / CapCut consumer app) provenance from an MP4.
//!
//! Unlike the Seedance API (which embeds a Volcengine/BytePlus C2PA manifest),
//! the Dreamina app stamps JSON metadata into the MP4's `moov/udta/meta/ilst`
//! atoms — e.g. `{"product":"dreamina","exportType":"generation",…}`,
//! `{"aigc_label_type":0,"source_info":"dreamina"}`, and a `vid:…` video id.
//! Some Dreamina exports additionally carry a Bytedance-signed C2PA box.
//!
//! Dreamina is ByteDance's consumer product built on the Seedance model family,
//! so this is a distinct provenance shape from [`crate::seedance_info`].
use std::fs;
use std::path::Path;
use crate::error::VideoInfoError;
use crate::scan::{find, find_cert_serial, find_org_identifier, json_int_field, json_str_field};
/// Provenance extracted from a Dreamina (ByteDance/CapCut) video export.
#[derive(Debug, Clone, PartialEq)]
pub struct DreaminaInfo {
/// The product string, always `"dreamina"`.
pub product: String,
/// Export type, e.g. `"generation"`.
pub export_type: Option<String>,
/// OS the export was made on (often empty).
pub os: Option<String>,
/// `source_info`, e.g. `"dreamina"`.
pub source_info: Option<String>,
/// `aigc_label_type` — ByteDance's AIGC labeling flag (e.g. `0`).
pub aigc_label_type: Option<i64>,
/// Dreamina video id, e.g. `"v02870g10004d6jktf7og65h7ouce21g"` (the `vid:` tag).
pub video_id: Option<String>,
/// Whether the export also carries a (Bytedance-signed) C2PA manifest.
pub has_c2pa: bool,
/// Signing-cert organization identifier (when a C2PA box is present), e.g.
/// `"NTRSG-201923456H"` (Bytedance Pte. Ltd.).
pub signer_org_id: Option<String>,
/// Country of the signing org (`"SG"`) when a C2PA box is present.
pub signer_country: Option<String>,
/// Leaf signing-cert hex serial when a C2PA box is present.
pub cert_serial: Option<String>,
}
impl DreaminaInfo {
/// Parse Dreamina provenance from a file on disk.
pub fn from_path(path: impl AsRef<Path>) -> Result<DreaminaInfo, VideoInfoError> {
let bytes = fs::read(path)?;
Self::from_bytes(&bytes)
}
/// Parse Dreamina provenance from raw bytes. Returns
/// [`VideoInfoError::NotDreamina`] if the Dreamina markers aren't present.
pub fn from_bytes(data: &[u8]) -> Result<DreaminaInfo, VideoInfoError> {
let product = json_str_field(data, "product");
let source_info = json_str_field(data, "source_info");
let is_dreamina = product.as_deref() == Some("dreamina")
|| source_info.as_deref() == Some("dreamina");
if !is_dreamina {
return Err(VideoInfoError::NotDreamina);
}
let has_c2pa = find(data, b"jumbf").is_some() || find(data, b"c2pa.claim").is_some();
let (signer_org_id, signer_country) = if has_c2pa {
match find_org_identifier(data) {
Some((id, country)) => (Some(id), Some(country.to_string())),
None => (None, None),
}
} else {
(None, None)
};
Ok(DreaminaInfo {
product: product.unwrap_or_else(|| "dreamina".to_string()),
export_type: json_str_field(data, "exportType"),
os: json_str_field(data, "os"),
source_info,
aigc_label_type: json_int_field(data, "aigc_label_type"),
video_id: find_vid_token(data),
has_c2pa,
signer_org_id,
signer_country,
cert_serial: if has_c2pa { find_cert_serial(data) } else { None },
})
}
}
/// Read the `vid:<id>` token (id is lowercase-alphanumeric).
fn find_vid_token(data: &[u8]) -> Option<String> {
const PREFIX: &[u8] = b"vid:";
let i = find(data, PREFIX)?;
let start = i + PREFIX.len();
let end = (start..data.len())
.take_while(|&j| data[j].is_ascii_alphanumeric())
.last()
.map(|j| j + 1)?;
if end == start {
return None;
}
std::str::from_utf8(&data[start..end]).ok().map(str::to_string)
}
#[cfg(test)]
mod tests {
use super::*;
fn synth(with_c2pa: bool) -> Vec<u8> {
let mut v = Vec::new();
v.extend_from_slice(b"....ftypisom....moov....udta....ilst");
v.extend_from_slice(br#"{"data":{"os":"","product":"dreamina","exportType":"generation","videoId":"0"}}"#);
v.extend_from_slice(br#"{"aigc_label_type":0,"source_info":"dreamina"}"#);
v.extend_from_slice(b"vid:v02870g10004d6jktf7og65h7ouce21g");
if with_c2pa {
v.extend_from_slice(b"jumbfc2pa.claim");
v.extend_from_slice(&[0xA0, 0x03, 0x02, 0x01, 0x02, 0x02, 0x03, 0xAB, 0xCD, 0xEF]);
v.push(0x13);
v.push(16);
v.extend_from_slice(b"NTRSG-201923456H");
v.push(0x31);
}
v
}
#[test]
fn parses_dreamina_no_c2pa() {
let info = DreaminaInfo::from_bytes(&synth(false)).expect("should parse");
assert_eq!(info.product, "dreamina");
assert_eq!(info.export_type.as_deref(), Some("generation"));
assert_eq!(info.os.as_deref(), Some(""));
assert_eq!(info.source_info.as_deref(), Some("dreamina"));
assert_eq!(info.aigc_label_type, Some(0));
assert_eq!(info.video_id.as_deref(), Some("v02870g10004d6jktf7og65h7ouce21g"));
assert!(!info.has_c2pa);
assert_eq!(info.cert_serial, None);
}
#[test]
fn parses_dreamina_with_c2pa() {
let info = DreaminaInfo::from_bytes(&synth(true)).expect("should parse");
assert!(info.has_c2pa);
assert_eq!(info.signer_org_id.as_deref(), Some("NTRSG-201923456H"));
assert_eq!(info.signer_country.as_deref(), Some("SG"));
assert_eq!(info.cert_serial.as_deref(), Some("ABCDEF"));
}
#[test]
fn non_dreamina_returns_not_dreamina() {
let data = b"....ftypisom....just an mp4....";
assert!(matches!(DreaminaInfo::from_bytes(data), Err(VideoInfoError::NotDreamina)));
}
}

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