Compare commits

..
Author SHA1 Message Date
Brandon Thomas 358c1efc45 add logging 2026-06-14 18:08:42 -04:00
Brandon Thomas 1dbe3d8b1e enhance database connection pooling 2026-06-14 17:26:53 -04:00
415 changed files with 2070 additions and 12234 deletions
+3 -12
View File
@@ -19,18 +19,9 @@ Handlers in `storyteller_web` follow this structure:
### Session/Auth
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.
- `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
### API Type Definitions
+4 -5
View File
@@ -7,11 +7,10 @@
- `#[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: 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`.
- 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.
- 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": 3
"max_size": 11
}
},
{
@@ -60,7 +60,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 10
"max_size": 11
}
},
{
@@ -70,7 +70,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 10
"max_size": 11
}
},
{
@@ -1,124 +0,0 @@
{
"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": 10
"max_size": 11
}
},
{
@@ -220,7 +220,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 10
"max_size": 11
}
},
{
@@ -30,7 +30,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 3
"max_size": 11
}
},
{
@@ -180,7 +180,7 @@
"type": "LongLong",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 10
"max_size": 20
}
},
{
@@ -40,7 +40,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 10
"max_size": 11
}
},
{
@@ -50,7 +50,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 10
"max_size": 11
}
},
{
@@ -50,7 +50,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 10
"max_size": 11
}
},
{
@@ -60,7 +60,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 10
"max_size": 11
}
},
{
@@ -210,7 +210,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 10
"max_size": 11
}
},
{
@@ -220,7 +220,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 10
"max_size": 11
}
},
{
@@ -40,7 +40,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 10
"max_size": 11
}
},
{
@@ -50,7 +50,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 10
"max_size": 11
}
},
{
@@ -180,7 +180,7 @@
"type": "LongLong",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 10
"max_size": 20
}
},
{
@@ -30,7 +30,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 3
"max_size": 11
}
},
{
@@ -190,7 +190,7 @@
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 10
"max_size": 11
}
},
{
@@ -30,7 +30,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 3
"max_size": 11
}
}
],
@@ -160,7 +160,7 @@
"type": "LongLong",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 10
"max_size": 20
}
},
{
@@ -60,7 +60,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 10
"max_size": 11
}
},
{
@@ -70,7 +70,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 10
"max_size": 11
}
},
{
@@ -150,7 +150,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 3
"max_size": 11
}
},
{
@@ -160,7 +160,7 @@
"type": "LongLong",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 10
"max_size": 20
}
},
{
@@ -190,7 +190,7 @@
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 10
"max_size": 11
}
},
{
@@ -200,7 +200,7 @@
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 5
"max_size": 11
}
},
{
@@ -210,7 +210,7 @@
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 5
"max_size": 11
}
},
{
@@ -160,7 +160,7 @@
"type": "LongLong",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 10
"max_size": 20
}
},
{
@@ -50,7 +50,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 10
"max_size": 11
}
},
{
@@ -60,7 +60,7 @@
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 10
"max_size": 11
}
},
{
@@ -140,7 +140,7 @@
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 10
"max_size": 11
}
},
{
@@ -1,124 +0,0 @@
{
"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": 3
"max_size": 11
}
},
{
@@ -30,7 +30,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 3
"max_size": 11
}
},
{
@@ -160,7 +160,7 @@
"type": "LongLong",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 10
"max_size": 20
}
},
{
@@ -270,7 +270,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 10
"max_size": 11
}
},
{
@@ -1,12 +0,0 @@
{
"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"
}
@@ -1,12 +0,0 @@
{
"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"
}
@@ -1,124 +0,0 @@
{
"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": 10
"max_size": 20
}
},
{
@@ -190,7 +190,7 @@
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 10
"max_size": 11
}
},
{
@@ -200,7 +200,7 @@
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 5
"max_size": 11
}
},
{
@@ -210,7 +210,7 @@
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 5
"max_size": 11
}
},
{
@@ -150,7 +150,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 3
"max_size": 11
}
},
{
@@ -1,12 +0,0 @@
{
"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": 10
"max_size": 11
}
},
{
@@ -190,7 +190,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 10
"max_size": 11
}
},
{
@@ -140,7 +140,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 10
"max_size": 11
}
},
{
@@ -180,7 +180,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 10
"max_size": 11
}
},
{
@@ -190,7 +190,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 10
"max_size": 11
}
},
{
@@ -1,47 +0,0 @@
{
"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": 10
"max_size": 11
}
},
{
@@ -140,7 +140,7 @@
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 10
"max_size": 11
}
},
{
+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. 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`.)
- SQLx for MySQL and SQLite; prefer `sqlx::query!` / `sqlx::query_as!` compile-time checked macros over runtime `sqlx::query()` whenever possible
- 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
+583 -531
View File
File diff suppressed because it is too large Load Diff
+5 -9
View File
@@ -7,7 +7,6 @@ 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/cloud_storage",
@@ -94,7 +93,6 @@ 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",
@@ -234,7 +232,6 @@ 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" }
@@ -291,7 +288,6 @@ 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" }
@@ -346,11 +342,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.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"
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"
tempdir = "0.3.7"
tempfile = "3.27.0"
thiserror = "2.0.18"
@@ -1,5 +0,0 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
DROP TABLE IF EXISTS api_keys;
@@ -1,50 +0,0 @@
-- 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;
@@ -1,50 +0,0 @@
-- 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;
-203
View File
@@ -1,203 +0,0 @@
# 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.
@@ -1,42 +0,0 @@
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,
}
@@ -1,28 +0,0 @@
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,
}
@@ -1,9 +0,0 @@
use serde_derive::Serialize;
use utoipa::ToSchema;
// ── DELETE /v1/api_keys/{api_key_token} ──
#[derive(Serialize, ToSchema)]
pub struct DeleteApiKeySuccessResponse {
pub success: bool,
}
@@ -1,12 +0,0 @@
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,
}
@@ -1,24 +0,0 @@
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>,
}
@@ -1,6 +0,0 @@
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;
@@ -1,15 +0,0 @@
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,5 +1,4 @@
pub mod analytics;
pub mod api_keys;
pub mod characters;
pub mod common;
pub mod credits;
@@ -8,7 +7,6 @@ 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;
@@ -16,5 +14,4 @@ pub mod subscriptions;
pub mod user_referral_codes;
pub mod users;
pub mod utils;
pub mod video_info;
pub mod web_referrals;
@@ -1,3 +0,0 @@
pub mod omni_upload_audio;
pub mod omni_upload_image;
pub mod omni_upload_video;
@@ -1,15 +0,0 @@
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,
}
@@ -1,15 +0,0 @@
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,
}
@@ -1,15 +0,0 @@
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,
}
@@ -1 +0,0 @@
pub mod read_only;
@@ -1,167 +0,0 @@
//! 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>,
}
@@ -1,20 +0,0 @@
[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
@@ -1,104 +0,0 @@
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…");
}
}
@@ -1,125 +0,0 @@
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);
}
}
@@ -125,23 +125,6 @@ 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,7 +1,5 @@
use serde_derive::Deserialize;
use crate::utils::number_coercion::de_u64_int_or_float;
#[derive(Deserialize, Debug)]
pub(super) struct BatchResponseItem {
pub result: BatchResponseResult,
@@ -21,8 +19,6 @@ 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,30 +457,6 @@ 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,9 +1,5 @@
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,
@@ -23,7 +19,6 @@ 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>,
}
@@ -44,16 +39,14 @@ pub(super) struct RawOrder {
/// The Kinovi credits charged for the order. `None` for older response
/// shapes that didn't include the field.
#[serde(default, deserialize_with = "de_opt_u32_int_or_float")]
#[serde(default)]
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,3 +1,2 @@
pub mod categorize_seedance2pro_error;
pub mod common_headers;
pub mod number_coercion;
@@ -1,136 +0,0 @@
//! 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.6.2", features = [] }
tauri-build = { version = "2.1.1", features = [] }
[dependencies]
# Workspace hack (faster compile times)
-28
View File
@@ -1,28 +0,0 @@
[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
@@ -1,407 +0,0 @@
//! `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
@@ -1,159 +0,0 @@
//! 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)));
}
}
-65
View File
@@ -1,65 +0,0 @@
use std::fmt;
use std::io;
/// Errors produced while reading and parsing video information.
#[derive(Debug)]
pub enum VideoInfoError {
/// The file could not be read.
Io(io::Error),
/// The video has no recognizable Seedance / C2PA generative-AI manifest —
/// either it isn't a Seedance generation, or the provenance metadata was
/// stripped (e.g. by re-encoding).
NotSeedance,
/// The video has no recognizable Google Veo / Generative-AI C2PA manifest.
NotVeo,
/// The video has no recognizable OpenAI Sora C2PA manifest.
NotSora,
/// The video has no recognizable Dreamina (ByteDance/CapCut) `ilst` metadata.
NotDreamina,
/// The video has no recognizable Kling (Kuaishou) AIGC-label `ilst` metadata.
NotKling,
/// The video has no recognized provenance of any supported kind (returned by
/// the [`crate::VideoInfo`] dispatcher when no format matches).
Unrecognized,
/// A manifest was detected, but a required field could not be extracted
/// (truncated / unexpected manifest encoding). Carries a short description of
/// what was missing.
MalformedManifest(String),
}
impl fmt::Display for VideoInfoError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Io(err) => write!(f, "I/O error reading video: {}", err),
Self::NotSeedance => write!(f, "no Seedance C2PA manifest found in video"),
Self::NotVeo => write!(f, "no Google Veo C2PA manifest found in video"),
Self::NotSora => write!(f, "no OpenAI Sora C2PA manifest found in video"),
Self::NotDreamina => write!(f, "no Dreamina metadata found in video"),
Self::NotKling => write!(f, "no Kling metadata found in video"),
Self::Unrecognized => write!(f, "no recognized provenance found in video"),
Self::MalformedManifest(detail) => write!(f, "malformed manifest: {}", detail),
}
}
}
impl std::error::Error for VideoInfoError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Io(err) => Some(err),
_ => None,
}
}
}
impl From<io::Error> for VideoInfoError {
fn from(err: io::Error) -> Self {
Self::Io(err)
}
}
-241
View File
@@ -1,241 +0,0 @@
//! Parse **Kling** (Kuaishou) provenance from an MP4.
//!
//! Kling stamps provenance two independent ways, and this parser reads both:
//!
//! 1. **Container AIGC label** — a JSON object in `moov/udta/meta` under the
//! `AIGC` key, following China's AIGC content-labeling standard (GB 45438):
//! `{"Label":"1","ContentProducer":"…","ProduceID":"KLingMuse_…",
//! "ContentPropagator":"…","PropagateID":"KLingMuse_…",
//! "ReservedCode1":null,"ReservedCode2":null}`.
//! `Label:"1"` marks AI-generated content; the `001…C10100` codes are the
//! provider's China registration code; `KLingMuse_…` is Kling's pipeline id.
//!
//! 2. **In-stream watermark** — an H.264 SEI `user_data_unregistered` NAL with a
//! fixed UUID (`91ca60614aee385486142d5f73f4ae2e`) carrying the bytes
//! `kling-ai\0`. This survives a re-encode that strips the container metadata,
//! so a clip can be recognized as Kling from the watermark alone.
//!
//! Note on the **model version** (Kling 1.5 / 1.6 / 2.0 / 2.5 / 2.6, pro / std /
//! fast): it is **not** embedded in either signal. Re-encoded exports (all the
//! samples we have went through `Lavf`) zero the `mvhd` dates and drop any model
//! metadata, leaving only the AIGC label and the watermark — neither of which
//! encodes the version. [`KlingInfo::model_version`] holds it when a clean export
//! does carry a printable `kling-<version>` token, and is `None` otherwise.
use std::fs;
use std::path::Path;
use crate::error::VideoInfoError;
use crate::scan::{find, find_from, json_str_field};
/// The fixed UUID of Kling's SEI `user_data_unregistered` watermark NAL,
/// immediately followed by the ASCII marker `kling-ai`.
const WATERMARK_MARKER: &[u8] = b"kling-ai";
/// Provenance extracted from a Kling (Kuaishou) video.
#[derive(Debug, Clone, PartialEq)]
pub struct KlingInfo {
/// Kling model version when a clean export embeds a printable `kling-<ver>`
/// token (e.g. `"2.5-pro"`). `None` for re-encoded exports, which don't carry
/// it — see the module docs.
pub model_version: Option<String>,
/// Whether the content is labeled AI-generated (`Label == "1"`, per GB 45438).
pub is_ai_generated: bool,
/// Raw `Label` value (`"1"` = AI-generated).
pub label: Option<String>,
/// Content producer registration code — the org/service code that generated it.
pub content_producer: Option<String>,
/// Per-generation produce id, e.g. `"KLingMuse_3d917354-4294-47ff-…"`.
pub produce_id: Option<String>,
/// Content propagator registration code (who distributed it; usually ==
/// producer, but can differ — e.g. a `…C20100` distribution channel).
pub content_propagator: Option<String>,
/// Per-generation propagate id (usually equal to [`KlingInfo::produce_id`]).
pub propagate_id: Option<String>,
/// `ReservedCode1` — reserved by the standard; observed `null` so far.
pub reserved_code_1: Option<String>,
/// `ReservedCode2` — reserved by the standard; observed `null` so far.
pub reserved_code_2: Option<String>,
/// Whether the in-stream `kling-ai` SEI watermark is present.
pub has_stream_watermark: bool,
/// The 16-byte SEI watermark UUID (hex) when the watermark is present,
/// e.g. `"91ca60614aee385486142d5f73f4ae2e"`.
pub watermark_uuid: Option<String>,
}
impl KlingInfo {
/// Parse Kling provenance from a file on disk.
pub fn from_path(path: impl AsRef<Path>) -> Result<KlingInfo, VideoInfoError> {
let bytes = fs::read(path)?;
Self::from_bytes(&bytes)
}
/// Parse Kling provenance from raw bytes. Returns [`VideoInfoError::NotKling`]
/// if neither the AIGC label nor the in-stream watermark is present.
pub fn from_bytes(data: &[u8]) -> Result<KlingInfo, VideoInfoError> {
let watermark_uuid = find_watermark_uuid(data);
let has_stream_watermark = watermark_uuid.is_some() || find(data, WATERMARK_MARKER).is_some();
let has_aigc_label = find(data, b"KLingMuse").is_some()
|| (find(data, b"\"ContentProducer\"").is_some() && find(data, b"\"Label\"").is_some());
if !has_aigc_label && !has_stream_watermark {
return Err(VideoInfoError::NotKling);
}
let label = json_str_field(data, "Label");
Ok(KlingInfo {
model_version: find_model_version(data),
is_ai_generated: label.as_deref() == Some("1"),
label,
content_producer: json_str_field(data, "ContentProducer"),
produce_id: json_str_field(data, "ProduceID"),
content_propagator: json_str_field(data, "ContentPropagator"),
propagate_id: json_str_field(data, "PropagateID"),
reserved_code_1: json_str_field(data, "ReservedCode1"),
reserved_code_2: json_str_field(data, "ReservedCode2"),
has_stream_watermark,
watermark_uuid,
})
}
}
/// Read the 16-byte UUID immediately preceding the `kling-ai` SEI marker (the
/// SEI `user_data_unregistered` payload is `<16-byte UUID><"kling-ai\0">`).
fn find_watermark_uuid(data: &[u8]) -> Option<String> {
let i = find(data, WATERMARK_MARKER)?;
let uuid = data.get(i.checked_sub(16)?..i)?;
Some(uuid.iter().map(|b| format!("{:02x}", b)).collect())
}
/// Find a printable `kling[-_/ ]v?<major>.<minor>[-<tier>]` model-version token.
///
/// Conservative by design: it requires a real `<digit>.<digit>` after a `kling`
/// prefix, so the watermark marker (`kling-ai`) and the produce id (`KLingMuse_…`)
/// never match. Returns `None` when no such printable token exists.
fn find_model_version(data: &[u8]) -> Option<String> {
const PREFIX: &[u8] = b"kling";
const TIERS: [&[u8]; 5] = [b"pro", b"std", b"fast", b"master", b"turbo"];
let mut from = 0;
while let Some(i) = find_from(data, PREFIX, from) {
from = i + PREFIX.len();
let mut j = from;
if matches!(data.get(j), Some(b'-' | b'_' | b'/' | b' ')) {
j += 1;
}
if matches!(data.get(j), Some(b'v' | b'V')) {
j += 1;
}
let num_start = j;
while matches!(data.get(j), Some(c) if c.is_ascii_digit() || *c == b'.') {
j += 1;
}
if j == num_start || !data[num_start..j].contains(&b'.') {
continue;
}
let mut version = String::from_utf8_lossy(&data[num_start..j]).into_owned();
// Optional `-<tier>` suffix (pro / std / fast / …).
if data.get(j) == Some(&b'-') {
for tier in TIERS {
if data.get(j + 1..j + 1 + tier.len()) == Some(tier) {
version.push('-');
version.push_str(&String::from_utf8_lossy(tier));
break;
}
}
}
return Some(version);
}
None
}
#[cfg(test)]
mod tests {
use super::*;
const AIGC_JSON: &[u8] = br#"{"Label":"1","ContentProducer":"001191110108335469089C10100","ProduceID":"KLingMuse_3d917354-4294-47ff-a48e-53f09b0ffee0","ReservedCode1":null,"ContentPropagator":"001191110108335469089C10100","PropagateID":"KLingMuse_3d917354-4294-47ff-a48e-53f09b0ffee0","ReservedCode2":null}"#;
fn with_watermark() -> Vec<u8> {
let mut v = Vec::new();
v.extend_from_slice(b"....mdat....");
// SEI: type 6, payload 5, len 0x19, 16-byte UUID, then "kling-ai\0".
v.extend_from_slice(&[0x06, 0x05, 0x19]);
v.extend_from_slice(&[
0x91, 0xca, 0x60, 0x61, 0x4a, 0xee, 0x38, 0x54, 0x86, 0x14, 0x2d, 0x5f, 0x73, 0xf4, 0xae,
0x2e,
]);
v.extend_from_slice(b"kling-ai\0");
v
}
#[test]
fn parses_aigc_label() {
let mut data = b"....ftypisom....moov....udta....ilst".to_vec();
data.extend_from_slice(AIGC_JSON);
let info = KlingInfo::from_bytes(&data).expect("should parse");
assert!(info.is_ai_generated);
assert_eq!(info.label.as_deref(), Some("1"));
assert_eq!(info.content_producer.as_deref(), Some("001191110108335469089C10100"));
assert_eq!(info.produce_id.as_deref(), Some("KLingMuse_3d917354-4294-47ff-a48e-53f09b0ffee0"));
assert_eq!(info.content_propagator.as_deref(), Some("001191110108335469089C10100"));
assert_eq!(info.propagate_id.as_deref(), Some("KLingMuse_3d917354-4294-47ff-a48e-53f09b0ffee0"));
assert_eq!(info.reserved_code_1, None);
assert_eq!(info.reserved_code_2, None);
assert_eq!(info.model_version, None);
assert!(!info.has_stream_watermark);
}
#[test]
fn detects_watermark_when_aigc_stripped() {
// A re-encoded Kling clip: no AIGC JSON, only the in-stream SEI watermark.
let info = KlingInfo::from_bytes(&with_watermark()).expect("should parse via watermark");
assert!(info.has_stream_watermark);
assert_eq!(info.watermark_uuid.as_deref(), Some("91ca60614aee385486142d5f73f4ae2e"));
assert_eq!(info.label, None);
assert_eq!(info.produce_id, None);
assert!(!info.is_ai_generated);
}
#[test]
fn reads_both_signals_together() {
let mut data = b"....ftypisom....ilst".to_vec();
data.extend_from_slice(AIGC_JSON);
data.extend_from_slice(&with_watermark());
let info = KlingInfo::from_bytes(&data).expect("should parse");
assert!(info.is_ai_generated);
assert!(info.has_stream_watermark);
assert_eq!(info.watermark_uuid.as_deref(), Some("91ca60614aee385486142d5f73f4ae2e"));
}
#[test]
fn extracts_model_version_from_clean_token() {
// A hypothetical clean export carrying a printable model token.
let data = b"....moov....model:kling-2.5-pro....mdat....";
let info = KlingInfo::from_bytes(data);
// No AIGC/watermark here, so it won't classify as Kling, but the version
// extractor itself should recognize the token.
assert_eq!(find_model_version(data).as_deref(), Some("2.5-pro"));
assert!(matches!(info, Err(VideoInfoError::NotKling)));
}
#[test]
fn watermark_marker_is_not_a_version() {
// `kling-ai` must not be misread as a version.
assert_eq!(find_model_version(&with_watermark()), None);
}
#[test]
fn non_kling_returns_not_kling() {
let data = b"....ftypisom....just an mp4....";
assert!(matches!(KlingInfo::from_bytes(data), Err(VideoInfoError::NotKling)));
}
}
-38
View File
@@ -1,38 +0,0 @@
//! Parse provenance / generation metadata out of video files.
//!
//! AI image/video generators increasingly embed [C2PA](https://c2pa.org/)
//! Content Credentials (a JUMBF box carrying a CBOR manifest) describing the
//! generating model, platform, and timestamp. This crate extracts that.
//!
//! Supported formats (see [`VideoInfo`]):
//! - **Seedance** (ByteDance API — Volcengine / BytePlus C2PA): [`seedance_info::SeedanceInfo`]
//! - **Veo** (Google Generative AI video C2PA): [`veo_info::VeoInfo`]
//! - **Sora** (OpenAI Generative AI video C2PA): [`sora_info::SoraInfo`]
//! - **Dreamina** (ByteDance/CapCut app `ilst` metadata): [`dreamina_info::DreaminaInfo`]
//! - **Kling** (Kuaishou AIGC-label `ilst` metadata): [`kling_info::KlingInfo`]
mod scan;
pub mod dreamina_info;
pub mod error;
pub mod kling_info;
pub mod seedance_info;
pub mod sora_info;
pub mod veo_info;
pub mod video;
pub use dreamina_info::DreaminaInfo;
pub use error::VideoInfoError;
pub use kling_info::KlingInfo;
pub use seedance_info::{SeedanceInfo, SeedancePlatform};
pub use sora_info::SoraInfo;
pub use veo_info::VeoInfo;
pub use video::VideoInfo;
/// Read the container's `©too` encoder tag (e.g. `"Lavf60.16.100"`, `"Google"`),
/// if present. Useful context for videos with no recognized provenance: an
/// `Lavf…` value means the file was re-encoded through ffmpeg, which strips C2PA
/// manifests and AIGC metadata — explaining why provenance is absent.
pub fn encoder_tag(data: &[u8]) -> Option<String> {
scan::find_encoder_tag(data)
}
-273
View File
@@ -1,273 +0,0 @@
//! Shared byte-scanning helpers for reading C2PA manifests embedded in MP4s.
//!
//! These avoid a full JUMBF + CBOR + X.509 stack: the values we want are small,
//! stably-encoded fields locatable by their key/marker. Used by both
//! [`crate::seedance_info`] and [`crate::veo_info`].
/// Find the first occurrence of `needle` in `haystack`.
pub(crate) fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
find_from(haystack, needle, 0)
}
/// Find `needle` at or after byte offset `start`.
pub(crate) fn find_from(haystack: &[u8], needle: &[u8], start: usize) -> Option<usize> {
if needle.is_empty() || start >= haystack.len() || needle.len() > haystack.len() - start {
return None;
}
haystack[start..]
.windows(needle.len())
.position(|w| w == needle)
.map(|p| p + start)
}
/// Read a CBOR text string (major type 3) starting at `at`. Supports inline
/// length (0x60–0x77), 1-byte length (0x78), and 2-byte length (0x79).
pub(crate) fn read_cbor_text(data: &[u8], at: usize) -> Option<String> {
let first = *data.get(at)?;
let (len, value_offset) = match first {
0x60..=0x77 => ((first - 0x60) as usize, 1),
0x78 => (*data.get(at + 1)? as usize, 2),
0x79 => {
let hi = *data.get(at + 1)? as usize;
let lo = *data.get(at + 2)? as usize;
((hi << 8) | lo, 3)
}
_ => return None,
};
let start = at + value_offset;
let bytes = data.get(start..start + len)?;
String::from_utf8(bytes.to_vec()).ok()
}
/// Find `key` and read the CBOR text string immediately following it.
pub(crate) fn text_after_key(data: &[u8], key: &[u8]) -> Option<String> {
let idx = find(data, key)?;
read_cbor_text(data, idx + key.len())
}
/// Like [`text_after_key`] but only searches at/after `start`.
pub(crate) fn text_after_key_from(data: &[u8], key: &[u8], start: usize) -> Option<String> {
let idx = find_from(data, key, start)?;
read_cbor_text(data, idx + key.len())
}
/// First RFC 3339 `YYYY-MM-DDTHH:MM:SSZ` (20 chars) timestamp in the buffer.
pub(crate) fn find_rfc3339(data: &[u8]) -> Option<String> {
const LEN: usize = 20;
data.windows(LEN).find(|w| is_rfc3339(w)).map(|w| String::from_utf8_lossy(w).into_owned())
}
fn is_rfc3339(w: &[u8]) -> bool {
// YYYY-MM-DDTHH:MM:SSZ
let d = |i: usize| w[i].is_ascii_digit();
w.len() == 20
&& d(0) && d(1) && d(2) && d(3) && w[4] == b'-'
&& d(5) && d(6) && w[7] == b'-'
&& d(8) && d(9) && w[10] == b'T'
&& d(11) && d(12) && w[13] == b':'
&& d(14) && d(15) && w[16] == b':'
&& d(17) && d(18) && w[19] == b'Z'
}
/// Find a `<prefix><uuid>` token (e.g. `urn:c2pa:…`, `xmp:iid:…`) and return the
/// full token including the prefix. The UUID is up to 36 hex/dash chars.
pub(crate) fn find_prefixed_uuid(data: &[u8], prefix: &[u8]) -> Option<String> {
let i = find(data, prefix)?;
let start = i + prefix.len();
let end = (start..data.len().min(start + 36))
.take_while(|&j| data[j].is_ascii_hexdigit() || data[j] == b'-')
.last()
.map(|j| j + 1)?;
let uuid = std::str::from_utf8(&data[start..end]).ok()?;
let mut out = String::with_capacity(prefix.len() + uuid.len());
out.push_str(&String::from_utf8_lossy(prefix));
out.push_str(uuid);
Some(out)
}
/// Hex serial number of the leaf signing certificate.
///
/// X.509 v3 certs encode the TBS prefix as `A0 03 02 01 02` (the `[0] EXPLICIT`
/// version = v3) immediately followed by the serial `INTEGER` (`02 <len> <bytes>`).
/// That fixed prefix lets us read the serial without a full DER parser; the leaf
/// cert is the first such occurrence.
pub(crate) fn find_cert_serial(data: &[u8]) -> Option<String> {
const TBS_VERSION_PREFIX: &[u8] = &[0xA0, 0x03, 0x02, 0x01, 0x02, 0x02];
let i = find(data, TBS_VERSION_PREFIX)?;
let len_pos = i + TBS_VERSION_PREFIX.len();
let len = *data.get(len_pos)? as usize;
if len == 0 || len > 40 {
return None;
}
let start = len_pos + 1;
let bytes = data.get(start..start + len)?;
Some(bytes.iter().map(|b| format!("{:02X}", b)).collect())
}
/// Read a DER length-prefixed string that begins with `prefix` — the byte
/// immediately before `prefix` is its DER length (as in an X.509 subject/issuer
/// CN). Returns the full string, e.g. given `b"Google C2PA Media Services"`
/// returns `"Google C2PA Media Services 1P ICA G3"`.
pub(crate) fn read_der_string(data: &[u8], prefix: &[u8]) -> Option<String> {
let i = find(data, prefix)?;
let len = *data.get(i.checked_sub(1)?)? as usize;
if len < prefix.len() || len > 128 {
return None;
}
let bytes = data.get(i..i + len)?;
if !bytes.iter().all(|b| b.is_ascii_graphic() || *b == b' ') {
return None;
}
std::str::from_utf8(bytes).ok().map(str::to_string)
}
/// Minimal base64url (RFC 4648 §5) decoder — no external dependency. Ignores
/// `=` padding; returns `None` on any invalid character.
pub(crate) fn decode_base64url(input: &str) -> Option<Vec<u8>> {
fn sextet(c: u8) -> Option<u8> {
match c {
b'A'..=b'Z' => Some(c - b'A'),
b'a'..=b'z' => Some(c - b'a' + 26),
b'0'..=b'9' => Some(c - b'0' + 52),
b'-' => Some(62),
b'_' => Some(63),
_ => None,
}
}
let chars: Vec<u8> = input.bytes().filter(|&b| b != b'=').collect();
let mut out = Vec::with_capacity(chars.len() * 3 / 4);
for chunk in chars.chunks(4) {
let mut buf = [0u8; 4];
for (i, &c) in chunk.iter().enumerate() {
buf[i] = sextet(c)?;
}
out.push((buf[0] << 2) | (buf[1] >> 4));
if chunk.len() >= 3 {
out.push((buf[1] << 4) | (buf[2] >> 2));
}
if chunk.len() >= 4 {
out.push((buf[2] << 6) | buf[3]);
}
}
Some(out)
}
/// The X.509 organization identifier (OID 2.5.4.97) from the signing cert, plus
/// the country implied by its registration scheme (`NTRSG-…` = SG, `NTRCN-…` = CN).
/// The value is a DER string whose length byte immediately precedes it.
pub(crate) fn find_org_identifier(data: &[u8]) -> Option<(String, &'static str)> {
for (prefix, country) in [(b"NTRSG-".as_slice(), "SG"), (b"NTRCN-".as_slice(), "CN")] {
let Some(start) = find(data, prefix) else { continue };
if start == 0 {
continue;
}
let len = data[start - 1] as usize;
if len < prefix.len() || len > 40 {
continue;
}
let Some(bytes) = data.get(start..start + len) else { continue };
if bytes.iter().all(|b| b.is_ascii_graphic()) {
if let Ok(value) = std::str::from_utf8(bytes) {
return Some((value.to_string(), country));
}
}
}
None
}
/// Read the iTunes-style `©too` encoder atom's value (the `encoder` tag exposed
/// by ffprobe). Layout: `©too` → `data` sub-box → 4-byte version/flags + 4-byte
/// reserved → the ASCII value. Returns the printable value (e.g. `"Google"`,
/// `"Lavf60.16.100"`).
pub(crate) fn find_encoder_tag(data: &[u8]) -> Option<String> {
const TOO: &[u8] = &[0xA9, b't', b'o', b'o'];
let too = find(data, TOO)?;
// The `data` sub-box header sits a few bytes after `©too`; its value begins 8
// bytes past the `data` marker (4 version/flags + 4 reserved/locale).
let data_marker = find_from(data, b"data", too)?;
let start = data_marker + 4 + 8;
let end = (start..data.len())
.take_while(|&j| {
let b = data[j];
b.is_ascii_graphic() || b == b'.'
})
.last()
.map(|j| j + 1)?;
if end <= start {
return None;
}
std::str::from_utf8(&data[start..end]).ok().map(str::to_string)
}
/// Extract a flat JSON string field value (`"key":"value"`) from raw bytes.
/// Assumes the value contains no escaped quotes (true for these manifests).
pub(crate) fn json_str_field(data: &[u8], key: &str) -> Option<String> {
let pat = format!("\"{}\":\"", key);
let i = find(data, pat.as_bytes())?;
let start = i + pat.len();
let rel_end = data[start..].iter().position(|&b| b == b'"')?;
String::from_utf8(data[start..start + rel_end].to_vec()).ok()
}
/// Extract a flat JSON integer field value (`"key":<int>`) from raw bytes.
pub(crate) fn json_int_field(data: &[u8], key: &str) -> Option<i64> {
let pat = format!("\"{}\":", key);
let i = find(data, pat.as_bytes())?;
let mut e = i + pat.len();
let start = e;
if data.get(e) == Some(&b'-') {
e += 1;
}
while data.get(e).is_some_and(|b| b.is_ascii_digit()) {
e += 1;
}
if e == start || (e == start + 1 && data[start] == b'-') {
return None;
}
std::str::from_utf8(&data[start..e]).ok()?.parse().ok()
}
/// CBOR-encode a text string (major type 3) — test helper for building manifests.
#[cfg(test)]
pub(crate) fn push_cbor_text(buf: &mut Vec<u8>, s: &str) {
let b = s.as_bytes();
if b.len() <= 0x17 {
buf.push(0x60 + b.len() as u8);
} else {
buf.push(0x78);
buf.push(b.len() as u8);
}
buf.extend_from_slice(b);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cbor_text_reader_handles_length_encodings() {
let mut inline = Vec::new();
push_cbor_text(&mut inline, "doubao-seedance-2-0"); // 19 chars, inline
assert_eq!(read_cbor_text(&inline, 0).as_deref(), Some("doubao-seedance-2-0"));
let mut long = Vec::new();
push_cbor_text(&mut long, "doubao-seedance-2-0-fast"); // 24 chars → 0x78
assert_eq!(long[0], 0x78);
assert_eq!(read_cbor_text(&long, 0).as_deref(), Some("doubao-seedance-2-0-fast"));
}
#[test]
fn base64url_decodes_log_id() {
// ATIAA7b8D_iKjF32GukAAAAA → 18 bytes, region byte 0x32 (Volcengine).
let bytes = decode_base64url("ATIAA7b8D_iKjF32GukAAAAA").unwrap();
let hex: String = bytes.iter().map(|b| format!("{:02x}", b)).collect();
assert_eq!(hex, "01320003b6fc0ff88a8c5df61ae900000000");
}
#[test]
fn cert_serial_reads_v3_prefix() {
let mut data = vec![0xFF, 0x00];
data.extend_from_slice(&[0xA0, 0x03, 0x02, 0x01, 0x02, 0x02, 0x03, 0xAB, 0xCD, 0xEF]);
assert_eq!(find_cert_serial(&data).as_deref(), Some("ABCDEF"));
}
}
-417
View File
@@ -1,417 +0,0 @@
//! Parse Seedance (ByteDance) generation provenance out of an MP4.
//!
//! Seedance videos carry a [C2PA](https://c2pa.org/) manifest embedded as a
//! JUMBF box. Inside is a CBOR `c2pa.created` action whose `softwareAgent`
//! identifies the generating platform (Volcengine vs BytePlus), the model, the
//! generation timestamp, and an opaque generation `log_id`.
//!
//! Rather than fully decode JUMBF + CBOR (which would require pulling in a C2PA
//! stack), we scan for the small, stable set of CBOR text fields we care about.
//! The values are CBOR text strings (major type 3) immediately following their
//! key, so each is recovered by locating the key and reading the text string.
use std::fs;
use std::path::Path;
use chrono::{DateTime, Utc};
use crate::error::VideoInfoError;
use crate::scan::{
decode_base64url, find, find_cert_serial, find_org_identifier, find_prefixed_uuid, find_rfc3339,
text_after_key, text_after_key_from,
};
// ── Markers in the embedded C2PA manifest ──
const VENDOR_VOLCENGINE: &[u8] = b"Volcengine_Ark_CN";
const VENDOR_BYTEPLUS: &[u8] = b"BytePlus_ModelArk";
/// The platform that produced a Seedance generation. The two share the same
/// underlying model family but are operated as separate products / regions
/// (Volcengine = ByteDance China "Doubao"/Ark; BytePlus = international "Dreamina").
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SeedancePlatform {
/// `Volcengine_Ark_CN`, signed by `…@volcengine.com`.
Volcengine,
/// `BytePlus_ModelArk`, signed by `Byteplus Pte. Ltd.` (`…@byteplus.com`).
BytePlus,
}
impl SeedancePlatform {
pub fn as_str(&self) -> &'static str {
match self {
Self::Volcengine => "volcengine",
Self::BytePlus => "byteplus",
}
}
}
/// Provenance extracted from a Seedance video's embedded C2PA manifest.
#[derive(Debug, Clone, PartialEq)]
pub struct SeedanceInfo {
/// Volcengine vs BytePlus.
pub platform: SeedancePlatform,
/// The C2PA `softwareAgent` name, e.g. `"Volcengine_Ark_CN"` / `"BytePlus_ModelArk"`.
pub software_agent: String,
/// The `softwareAgent` version, e.g. `"1.0.0"`.
pub software_agent_version: Option<String>,
/// Full model identifier, e.g. `"doubao-seedance-2-0-fast"`,
/// `"doubao-seedance-2-0"`, `"dreamina-seedance-2-0"`,
/// `"doubao-seedance-1-0-lite"`.
pub model_name: String,
/// Brand prefix of the model, e.g. `"doubao"` (Volcengine) / `"dreamina"` (BytePlus).
pub model_brand: Option<String>,
/// Model version parsed from the name, e.g. `"2.0"` (from `…-2-0`) or `"1.0"`
/// (from `…-1-0-lite`). The parser is version-agnostic — Seedance 1.0 and 2.0
/// share the same Ark C2PA shape and both land here.
pub model_version: Option<String>,
/// Whether this is the `-fast` variant of the model.
pub is_fast: bool,
/// Whether this is the `-lite` variant of the model (e.g. Seedance 1.0 Lite).
pub is_lite: bool,
/// Raw generation timestamp string (RFC 3339, e.g. `"2026-06-19T01:32:58Z"`).
pub generated_at: String,
/// Parsed [`generated_at`](Self::generated_at), when it is valid RFC 3339.
pub generated_at_utc: Option<DateTime<Utc>>,
/// Per-generation log / request id, base64url-encoded
/// (e.g. `"ATIAA7b8D_iKjF32GukAAAAA"`).
pub log_id: Option<String>,
/// [`log_id`](Self::log_id) base64url-decoded to hex (18 bytes). It is a
/// structured id: byte 0 = version (`01`), byte 1 = region/platform
/// (`0x32` = Volcengine, `0x33` = BytePlus), bytes 2–3 constant (`0003`),
/// bytes 4–13 a per-generation value, bytes 14–17 zero padding. The region
/// byte independently corroborates [`platform`](Self::platform).
pub log_id_decoded_hex: Option<String>,
/// IPTC digital source type URL — always trained-algorithmic-media for these.
pub digital_source_type: Option<String>,
/// C2PA claim generator, e.g. `"c2pa-rs"`.
pub claim_generator: Option<String>,
/// C2PA claim generator version, e.g. `"0.78.4"`.
pub claim_generator_version: Option<String>,
/// The manifest URN, e.g. `"urn:c2pa:40a5f6fe-b88a-4a2c-ae72-f76a1e5a6012"`.
/// Identifies the manifest; not a stable per-asset identity.
pub manifest_id: Option<String>,
/// The XMP asset instance id, e.g. `"xmp:iid:95a3ac67-cb28-4a0d-ac54-911f1787b7de"`.
/// Unique per generated asset (distinct from [`manifest_id`](Self::manifest_id)).
pub instance_id: Option<String>,
/// Signing certificate email, e.g. `"certificate_center@volcengine.com"`.
pub signer_email: Option<String>,
/// Signing certificate organization identifier (X.509 OID 2.5.4.97), e.g.
/// `"NTRSG-202024632W"` (Byteplus Pte. Ltd., Singapore) or
/// `"NTRCN-91110108MA01R70K8D"` (Beijing Volcano Engine, China). The `NTRxx`
/// scheme prefix is itself a strong jurisdiction signal.
pub signer_org_id: Option<String>,
/// Two-letter country of the signing organization, derived from the org
/// identifier scheme (`"SG"` for BytePlus, `"CN"` for Volcengine).
pub signer_country: Option<String>,
/// Hex serial number of the leaf signing certificate, e.g.
/// `"3687D48590A2FD5101AB0316"`. Stable per signing certificate (Volcengine
/// and BytePlus each reuse one), so it identifies the signer across assets —
/// useful for grouping/auditing, not as a per-asset id.
pub cert_serial: Option<String>,
}
impl SeedanceInfo {
/// Parse Seedance provenance from a video file on disk.
pub fn from_path(path: impl AsRef<Path>) -> Result<SeedanceInfo, VideoInfoError> {
let bytes = fs::read(path)?;
Self::from_bytes(&bytes)
}
/// Parse Seedance provenance from raw video bytes.
///
/// Returns [`VideoInfoError::NotSeedance`] if no Seedance/C2PA generative
/// manifest is present, or [`VideoInfoError::MalformedManifest`] if the
/// manifest is present but a required field can't be read.
pub fn from_bytes(data: &[u8]) -> Result<SeedanceInfo, VideoInfoError> {
let (platform, vendor_marker) = if let Some(_) = find(data, VENDOR_VOLCENGINE) {
(SeedancePlatform::Volcengine, VENDOR_VOLCENGINE)
} else if let Some(_) = find(data, VENDOR_BYTEPLUS) {
(SeedancePlatform::BytePlus, VENDOR_BYTEPLUS)
} else {
return Err(VideoInfoError::NotSeedance);
};
let software_agent = String::from_utf8_lossy(vendor_marker).into_owned();
let vendor_end = find(data, vendor_marker).map(|i| i + vendor_marker.len()).unwrap_or(0);
// model_name is required for a meaningful Seedance manifest.
let model_name = text_after_key(data, b"model_name")
.ok_or_else(|| VideoInfoError::MalformedManifest("missing model_name".to_string()))?;
if !model_name.contains("seedance") {
// The vendor marker is present but the model isn't Seedance — treat as
// "not a Seedance generation" rather than a parse failure.
return Err(VideoInfoError::NotSeedance);
}
let generated_at = find_rfc3339(data)
.ok_or_else(|| VideoInfoError::MalformedManifest("missing generation timestamp".to_string()))?;
let generated_at_utc = DateTime::parse_from_rfc3339(&generated_at)
.ok()
.map(|dt| dt.with_timezone(&Utc));
let (model_brand, model_version, is_fast, is_lite) = parse_model_name(&model_name);
let log_id = text_after_key(data, b"log_id");
let log_id_decoded_hex = log_id
.as_deref()
.and_then(decode_base64url)
.map(|bytes| bytes.iter().map(|b| format!("{:02x}", b)).collect::<String>());
let software_agent_version = text_after_key_from(data, b"version", vendor_end);
let claim_generator = find(data, b"c2pa-rs").map(|_| "c2pa-rs".to_string());
let claim_generator_version = find(data, b"c2pa-rs")
.and_then(|i| text_after_key_from(data, b"version", i + b"c2pa-rs".len()));
let (signer_org_id, signer_country) = match find_org_identifier(data) {
Some((org_id, country)) => (Some(org_id), Some(country.to_string())),
None => (None, None),
};
Ok(SeedanceInfo {
platform,
software_agent,
software_agent_version,
model_name,
model_brand,
model_version,
is_fast,
is_lite,
generated_at,
generated_at_utc,
log_id,
log_id_decoded_hex,
digital_source_type: text_after_key(data, b"digitalSourceType"),
claim_generator,
claim_generator_version,
manifest_id: find_prefixed_uuid(data, b"urn:c2pa:"),
instance_id: find_prefixed_uuid(data, b"xmp:iid:"),
signer_email: find_signer_email(data),
signer_org_id,
signer_country,
cert_serial: find_cert_serial(data),
})
}
}
// ── Model name parsing ──
/// Split `"{brand}-seedance-{maj}-{min}[-{variant}]"` into
/// (brand, version, is_fast, is_lite).
///
/// e.g. `"doubao-seedance-2-0-fast"` → (`"doubao"`, `"2.0"`, `true`, `false`);
/// `"doubao-seedance-1-0-lite"` → (`"doubao"`, `"1.0"`, `false`, `true`).
/// The version is the leading run of numeric segments after `seedance-`, so a
/// trailing variant token (`fast`/`lite`) doesn't leak into it.
fn parse_model_name(model_name: &str) -> (Option<String>, Option<String>, bool, bool) {
let brand = model_name.split("-seedance").next()
.filter(|s| !s.is_empty() && *s != model_name)
.map(|s| s.to_string());
let mut version = None;
let mut is_fast = false;
let mut is_lite = false;
if let Some(after) = model_name.split("seedance-").nth(1) {
let segments: Vec<&str> = after.split('-').collect();
// Leading numeric segments form the version: ["1","0"] → "1.0".
let nums: Vec<&str> = segments
.iter()
.take_while(|s| !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit()))
.copied()
.collect();
if !nums.is_empty() {
version = Some(nums.join("."));
}
for segment in &segments {
match *segment {
"fast" => is_fast = true,
"lite" => is_lite = true,
_ => {}
}
}
}
(brand, version, is_fast, is_lite)
}
// ── Seedance-specific scanners ──
/// Signing certificate email (`…@volcengine.com` / `…@byteplus.com`), recovered
/// by finding the domain and walking back over the local part.
fn find_signer_email(data: &[u8]) -> Option<String> {
for domain in [b"@volcengine.com".as_slice(), b"@byteplus.com".as_slice()] {
if let Some(at) = find(data, domain) {
let mut start = at;
while start > 0 {
let c = data[start - 1];
if c.is_ascii_alphanumeric() || matches!(c, b'.' | b'_' | b'%' | b'+' | b'-') {
start -= 1;
} else {
break;
}
}
let end = at + domain.len();
if let Ok(email) = std::str::from_utf8(&data[start..end]) {
if email.starts_with(|c: char| c.is_ascii_alphanumeric()) {
return Some(email.to_string());
}
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::scan::push_cbor_text as push_text;
// ── Helpers to synthesize a minimal C2PA-like manifest ──
fn synth_manifest(vendor: &str, model: &str, time: &str, log_id: &str, signer: &str, org_id: &str) -> Vec<u8> {
let mut v = Vec::new();
v.extend_from_slice(b"....ftypisom....moov....c2pa");
// X.509 v3 TBS prefix + serial INTEGER (02 <len> <bytes>) for serial 3687D485…
v.extend_from_slice(&[0xA0, 0x03, 0x02, 0x01, 0x02, 0x02, 0x0C]);
v.extend_from_slice(&[0x36, 0x87, 0xD4, 0x85, 0x90, 0xA2, 0xFD, 0x51, 0x01, 0xAB, 0x03, 0x16]);
v.extend_from_slice(b"urn:c2pa:40a5f6fe-b88a-4a2c-ae72-f76a1e5a6012");
v.extend_from_slice(b"c2pa.createddwhen");
push_text(&mut v, time);
v.extend_from_slice(b"msoftwareAgentdname");
push_text(&mut v, vendor);
v.extend_from_slice(b"gversion");
push_text(&mut v, "1.0.0");
v.extend_from_slice(b"jparametersflog_id");
push_text(&mut v, log_id);
v.extend_from_slice(b"dtime");
push_text(&mut v, time);
v.extend_from_slice(b"jmodel_name");
push_text(&mut v, model);
v.extend_from_slice(b"qdigitalSourceType");
push_text(&mut v, "http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia");
v.extend_from_slice(b"jinstanceID");
push_text(&mut v, "xmp:iid:95a3ac67-cb28-4a0d-ac54-911f1787b7de");
v.extend_from_slice(b"tclaim_generator_infodname");
push_text(&mut v, "c2pa-rs");
v.extend_from_slice(b"gversion");
push_text(&mut v, "0.78.4");
// In a real file the signing cert's email is preceded by binary DER bytes;
// emulate that boundary so the email local-part walk-back terminates here.
v.push(0x00);
v.extend_from_slice(signer.as_bytes());
// Org identifier (2.5.4.97) preceded by its DER length byte, as in a real cert.
v.push(0x31); // a DER tag byte before the length, like a real subject RDN
v.push(org_id.len() as u8);
v.extend_from_slice(org_id.as_bytes());
v.push(0x31); // trailing DER byte after the value
v
}
#[test]
fn parses_volcengine_fast() {
let data = synth_manifest(
"Volcengine_Ark_CN", "doubao-seedance-2-0-fast",
"2026-06-19T01:32:58Z", "ATIAA7b8D_iKjF32GukAAAAA",
"certificate_center@volcengine.com", "NTRCN-91110108MA01R70K8D",
);
let info = SeedanceInfo::from_bytes(&data).expect("should parse");
assert_eq!(info.platform, SeedancePlatform::Volcengine);
assert_eq!(info.signer_org_id.as_deref(), Some("NTRCN-91110108MA01R70K8D"));
assert_eq!(info.signer_country.as_deref(), Some("CN"));
assert_eq!(info.cert_serial.as_deref(), Some("3687D48590A2FD5101AB0316"));
assert_eq!(info.software_agent, "Volcengine_Ark_CN");
assert_eq!(info.software_agent_version.as_deref(), Some("1.0.0"));
assert_eq!(info.model_name, "doubao-seedance-2-0-fast");
assert_eq!(info.model_brand.as_deref(), Some("doubao"));
assert_eq!(info.model_version.as_deref(), Some("2.0"));
assert!(info.is_fast);
assert!(!info.is_lite);
assert_eq!(info.generated_at, "2026-06-19T01:32:58Z");
assert!(info.generated_at_utc.is_some());
assert_eq!(info.log_id.as_deref(), Some("ATIAA7b8D_iKjF32GukAAAAA"));
assert_eq!(info.log_id_decoded_hex.as_deref(), Some("01320003b6fc0ff88a8c5df61ae900000000"));
assert_eq!(info.claim_generator.as_deref(), Some("c2pa-rs"));
assert_eq!(info.claim_generator_version.as_deref(), Some("0.78.4"));
assert_eq!(info.manifest_id.as_deref(), Some("urn:c2pa:40a5f6fe-b88a-4a2c-ae72-f76a1e5a6012"));
assert_eq!(info.instance_id.as_deref(), Some("xmp:iid:95a3ac67-cb28-4a0d-ac54-911f1787b7de"));
assert_eq!(info.signer_email.as_deref(), Some("certificate_center@volcengine.com"));
assert!(info.digital_source_type.as_deref().unwrap().contains("trainedAlgorithmicMedia"));
}
#[test]
fn parses_byteplus_non_fast() {
let data = synth_manifest(
"BytePlus_ModelArk", "dreamina-seedance-2-0",
"2026-06-19T01:27:48Z", "ATMAA7b36J8cLZO2iUkAAAAA",
"certificate@byteplus.com", "NTRSG-202024632W",
);
let info = SeedanceInfo::from_bytes(&data).expect("should parse");
assert_eq!(info.platform, SeedancePlatform::BytePlus);
assert_eq!(info.model_brand.as_deref(), Some("dreamina"));
assert_eq!(info.model_version.as_deref(), Some("2.0"));
assert!(!info.is_fast);
assert_eq!(info.signer_email.as_deref(), Some("certificate@byteplus.com"));
assert_eq!(info.signer_org_id.as_deref(), Some("NTRSG-202024632W"));
assert_eq!(info.signer_country.as_deref(), Some("SG"));
// BytePlus region byte is 0x33 (vs 0x32 for Volcengine).
assert_eq!(info.log_id_decoded_hex.as_deref(), Some("01330003b6f7e89f1c2d93b6894900000000"));
}
#[test]
fn parses_seedance_1_0_lite() {
let data = synth_manifest(
"Volcengine_Ark_CN", "doubao-seedance-1-0-lite",
"2026-06-19T01:30:00Z", "ATIAA7b8D_iKjF32GukAAAAA",
"certificate_center@volcengine.com", "NTRCN-91110108MA01R70K8D",
);
let info = SeedanceInfo::from_bytes(&data).expect("should parse");
assert_eq!(info.model_name, "doubao-seedance-1-0-lite");
assert_eq!(info.model_brand.as_deref(), Some("doubao"));
// The `-lite` token must not leak into the version.
assert_eq!(info.model_version.as_deref(), Some("1.0"));
assert!(info.is_lite);
assert!(!info.is_fast);
}
#[test]
fn non_seedance_returns_not_seedance() {
let data = b"....ftypisom....just a normal mp4 with no provenance....";
match SeedanceInfo::from_bytes(data) {
Err(VideoInfoError::NotSeedance) => {}
other => panic!("expected NotSeedance, got {:?}", other),
}
}
#[test]
fn vendor_without_model_is_malformed() {
// Vendor marker present, but no model_name key.
let mut data = Vec::new();
data.extend_from_slice(b"....Volcengine_Ark_CN....2026-06-19T01:32:58Z....");
match SeedanceInfo::from_bytes(&data) {
Err(VideoInfoError::MalformedManifest(_)) => {}
other => panic!("expected MalformedManifest, got {:?}", other),
}
}
}
-148
View File
@@ -1,148 +0,0 @@
//! Parse OpenAI **Sora** (OpenAI Generative AI video) provenance out of an MP4.
//!
//! Like Veo, Sora attaches a [C2PA](https://c2pa.org/) Content Credentials
//! manifest (a JUMBF box with a CBOR claim) signed by OpenAI. The manifest's
//! `claim_generator` / signer identifies OpenAI; `digitalSourceType` is
//! `trainedAlgorithmicMedia`.
//!
//! ⚠️ **Unverified format.** Every Sora clip in our local sample set had been
//! re-encoded through ffmpeg (encoder `Lavf…`), which strips the C2PA box — so
//! this parser is modeled on OpenAI's documented Content Credentials structure
//! and the shared C2PA layout used by [`crate::veo_info`], but has **not** been
//! validated against an untouched Sora export. Field extraction is intentionally
//! generic (the C2PA markers we're confident about) until a clean sample exists.
use std::fs;
use std::path::Path;
use crate::error::VideoInfoError;
use crate::scan::{find, find_cert_serial, find_prefixed_uuid, text_after_key};
// ── Detection markers ──
/// OpenAI's signer / claim-generator identity in the C2PA manifest.
const OPENAI_MARKER: &[u8] = b"OpenAI";
/// A C2PA manifest must be present for these markers to be meaningful.
const C2PA_CLAIM_MARKER: &[u8] = b"c2pa.claim";
const JUMBF_MARKER: &[u8] = b"jumbf";
/// The Sora model token, when embedded.
const SORA_MARKER: &[u8] = b"sora";
/// Provenance extracted from an OpenAI Sora (OpenAI Generative AI) video.
#[derive(Debug, Clone, PartialEq)]
pub struct SoraInfo {
/// Always `"OpenAI"` — the producer established by the C2PA signer / generator.
pub producer: String,
/// The model token if embedded (e.g. `"sora"`); often absent, like Veo.
pub model_name: Option<String>,
/// The `c2pa.created` action description, when present.
pub created_description: Option<String>,
/// IPTC digital source type URL — trained-algorithmic-media for these.
pub digital_source_type: Option<String>,
/// C2PA claim generator string.
pub claim_generator: Option<String>,
/// The manifest URN, e.g. `"urn:c2pa:…"`.
pub manifest_id: Option<String>,
/// The per-asset instance id.
pub instance_id: Option<String>,
/// Hex serial number of the leaf signing certificate.
pub cert_serial: Option<String>,
}
impl SoraInfo {
/// Parse Sora provenance from a video file on disk.
pub fn from_path(path: impl AsRef<Path>) -> Result<SoraInfo, VideoInfoError> {
let bytes = fs::read(path)?;
Self::from_bytes(&bytes)
}
/// Parse Sora provenance from raw video bytes.
///
/// Returns [`VideoInfoError::NotSora`] if no OpenAI C2PA manifest is present.
pub fn from_bytes(data: &[u8]) -> Result<SoraInfo, VideoInfoError> {
let has_c2pa = find(data, C2PA_CLAIM_MARKER).is_some() || find(data, JUMBF_MARKER).is_some();
let is_openai = find(data, OPENAI_MARKER).is_some();
if !(has_c2pa && is_openai) {
return Err(VideoInfoError::NotSora);
}
Ok(SoraInfo {
producer: "OpenAI".to_string(),
model_name: find(data, SORA_MARKER).map(|_| "sora".to_string()),
created_description: find_description_containing(data, b"OpenAI")
.or_else(|| find_description_containing(data, b"Generated")),
digital_source_type: text_after_key(data, b"digitalSourceType"),
claim_generator: text_after_key(data, b"claim_generator"),
manifest_id: find_prefixed_uuid(data, b"urn:c2pa:"),
instance_id: text_after_key(data, b"instanceID"),
cert_serial: find_cert_serial(data),
})
}
}
/// Find a CBOR `description` value whose text contains `needle`.
fn find_description_containing(data: &[u8], needle: &[u8]) -> Option<String> {
let mut from = 0;
while let Some(i) = crate::scan::find_from(data, b"description", from) {
if let Some(text) = crate::scan::read_cbor_text(data, i + b"description".len()) {
if text.as_bytes().windows(needle.len()).any(|w| w == needle) {
return Some(text);
}
}
from = i + b"description".len();
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::scan::push_cbor_text as push_text;
fn synth_sora() -> Vec<u8> {
let mut v = Vec::new();
v.extend_from_slice(b"....ftypisom....moov....jumbfc2pa.claim");
v.extend_from_slice(&[0xA0, 0x03, 0x02, 0x01, 0x02, 0x02, 0x03, 0xAB, 0xCD, 0xEF]);
v.extend_from_slice(b"OpenAI, OPC LLC");
v.extend_from_slice(b"urn:c2pa:11111111-2222-3333-4444-555555555555");
v.extend_from_slice(b"c2pa.createdkdescription");
push_text(&mut v, "Generated by OpenAI.");
v.extend_from_slice(b"qdigitalSourceType");
push_text(&mut v, "http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia");
v.extend_from_slice(b"jinstanceID");
push_text(&mut v, "abcd1234-0000-0000-0000-000000000000");
v.extend_from_slice(b"sora");
v
}
#[test]
fn parses_sora_manifest() {
let info = SoraInfo::from_bytes(&synth_sora()).expect("should parse");
assert_eq!(info.producer, "OpenAI");
assert_eq!(info.model_name.as_deref(), Some("sora"));
assert_eq!(info.created_description.as_deref(), Some("Generated by OpenAI."));
assert_eq!(info.manifest_id.as_deref(), Some("urn:c2pa:11111111-2222-3333-4444-555555555555"));
assert_eq!(info.instance_id.as_deref(), Some("abcd1234-0000-0000-0000-000000000000"));
assert_eq!(info.cert_serial.as_deref(), Some("ABCDEF"));
assert!(info.digital_source_type.as_deref().unwrap().contains("trainedAlgorithmicMedia"));
}
#[test]
fn non_openai_returns_not_sora() {
let data = b"....ftypisom....jumbfc2pa.claim....Volcengine_Ark_CN....";
assert!(matches!(SoraInfo::from_bytes(data), Err(VideoInfoError::NotSora)));
}
#[test]
fn openai_without_c2pa_returns_not_sora() {
let data = b"....ftypisom....mentions OpenAI but no manifest....";
assert!(matches!(SoraInfo::from_bytes(data), Err(VideoInfoError::NotSora)));
}
}
-240
View File
@@ -1,240 +0,0 @@
//! Parse Google **Veo** (Google Generative AI video) provenance out of an MP4.
//!
//! Like Seedance, Veo embeds a [C2PA](https://c2pa.org/) manifest as a JUMBF box.
//! Unlike Seedance, the manifest does **not** carry an explicit model name —
//! origin is established by the Google C2PA signer chain (`pki.goog`), the
//! `encoder=Google` tag, and the `c2pa.created` action description
//! "Created by Google Generative AI." A second `c2pa.edited` action records the
//! "Applied imperceptible SynthID watermark." step (Google DeepMind's SynthID).
//!
//! So "the model is Veo" is an inference from "Google-generated video" — we
//! surface every Google/C2PA field we can and leave model name `None` because it
//! genuinely isn't in the metadata.
use std::fs;
use std::path::Path;
use crate::error::VideoInfoError;
use crate::scan::{
find, find_cert_serial, find_encoder_tag, find_prefixed_uuid, read_der_string, text_after_key,
};
// ── Detection markers ──
/// The generative `c2pa.created` action description Google stamps.
const CREATED_BY_GOOGLE: &[u8] = b"Created by Google Generative AI";
/// Google's C2PA signer infrastructure (cert CN / ICA names).
const GOOGLE_C2PA_MARKER: &[u8] = b"Google C2PA";
/// The SynthID watermark edit description.
const SYNTHID_MARKER: &[u8] = b"SynthID watermark";
/// Prefix of the issuing intermediate-CA CN that signs Veo manifests.
const SIGNER_CA_PREFIX: &[u8] = b"Google C2PA Media Services";
/// Prefix of the RFC 3161 time-stamping CA CN.
const TIMESTAMP_CA_PREFIX: &[u8] = b"Google C2PA Core Time-Stamping";
/// The bare `©too` encoder value Google stamps on Veo/Flow exports — survives a
/// re-encode that strips the C2PA box, so it's a weaker fallback signal.
const GOOGLE_ENCODER: &str = "Google";
/// Provenance extracted from a Google Veo (Google Generative AI) video.
#[derive(Debug, Clone, PartialEq)]
pub struct VeoInfo {
/// Always `"Google"` — the producer established by the signer chain + encoder.
pub producer: String,
/// Whether a full Google C2PA manifest is present. `false` means the video was
/// recognized only by its `encoder=Google` tag (C2PA likely stripped by a
/// re-encode) — all the C2PA fields below will be `None`.
pub has_c2pa_manifest: bool,
/// The `©too` encoder atom value, e.g. `"Google"`.
pub encoder: Option<String>,
/// The `c2pa.created` action description, e.g. `"Created by Google Generative AI."`.
pub created_description: Option<String>,
/// Whether a SynthID watermark edit action is present (Google DeepMind's
/// imperceptible watermark — applied to all Veo output).
pub has_synthid_watermark: bool,
/// The SynthID edit description, e.g. `"Applied imperceptible SynthID watermark."`.
pub synthid_description: Option<String>,
/// IPTC digital source type URL — trained-algorithmic-media for these.
pub digital_source_type: Option<String>,
/// C2PA claim generator, e.g. `"Google C2PA Core Generator Library"`.
pub claim_generator: Option<String>,
/// C2PA claim generator version, e.g. `"929410399:929410399"`.
pub claim_generator_version: Option<String>,
/// The manifest URN, e.g. `"urn:c2pa:a6b2b914-8d34-c7f1-717f-4c622328d8f0"`.
pub manifest_id: Option<String>,
/// The per-asset instance id (bare UUID; Veo does not prefix it with `xmp:iid:`).
pub instance_id: Option<String>,
/// Hex serial number of the leaf signing certificate (Google's `pki.goog`),
/// e.g. `"E2139E2DAA977AC13E775A1CBD006D32BF4ACB"`.
pub cert_serial: Option<String>,
/// Issuing intermediate-CA common name, e.g.
/// `"Google C2PA Media Services 1P ICA G3"` — identifies Google's signing path.
pub signer_ca: Option<String>,
/// Whether the manifest carries an RFC 3161 trusted timestamp (a
/// `Google C2PA Core Time-Stamping` authority is present in the chain).
pub is_timestamped: bool,
/// The time-stamping authority CN when present, e.g.
/// `"Google C2PA Core Time-Stamping ICA G3"`.
pub timestamp_authority: Option<String>,
}
impl VeoInfo {
/// Parse Veo provenance from a video file on disk.
pub fn from_path(path: impl AsRef<Path>) -> Result<VeoInfo, VideoInfoError> {
let bytes = fs::read(path)?;
Self::from_bytes(&bytes)
}
/// Parse Veo provenance from raw video bytes.
///
/// Returns [`VideoInfoError::NotVeo`] if no Google generative C2PA manifest is
/// present.
pub fn from_bytes(data: &[u8]) -> Result<VeoInfo, VideoInfoError> {
let has_c2pa_manifest =
find(data, CREATED_BY_GOOGLE).is_some() || find(data, GOOGLE_C2PA_MARKER).is_some();
let encoder = find_encoder_tag(data);
let encoder_is_google = encoder.as_deref() == Some(GOOGLE_ENCODER);
if !has_c2pa_manifest && !encoder_is_google {
return Err(VideoInfoError::NotVeo);
}
// The created/edited descriptions follow the CBOR `description` key; there
// are two `description` values (created, edited) and order can vary, so read
// by matching the leading text rather than positional assumptions.
let created_description = find_description_containing(data, b"Created by");
let synthid_description = find_description_containing(data, b"SynthID");
let has_synthid_watermark =
synthid_description.is_some() || find(data, SYNTHID_MARKER).is_some();
let claim_generator = text_after_key(data, b"claim_generator_info")
.or_else(|| find(data, b"Google C2PA Core Generator Library").map(|_| {
"Google C2PA Core Generator Library".to_string()
}));
let claim_generator_version = find(data, b"Google C2PA Core Generator Library")
.and_then(|i| {
crate::scan::text_after_key_from(data, b"version", i + "Google C2PA Core Generator Library".len())
});
Ok(VeoInfo {
producer: "Google".to_string(),
has_c2pa_manifest,
encoder,
created_description,
has_synthid_watermark,
synthid_description,
digital_source_type: text_after_key(data, b"digitalSourceType"),
claim_generator,
claim_generator_version,
manifest_id: find_prefixed_uuid(data, b"urn:c2pa:"),
instance_id: text_after_key(data, b"instanceID"),
cert_serial: find_cert_serial(data),
signer_ca: read_der_string(data, SIGNER_CA_PREFIX),
is_timestamped: find(data, TIMESTAMP_CA_PREFIX).is_some(),
timestamp_authority: read_der_string(data, TIMESTAMP_CA_PREFIX),
})
}
}
/// Find a CBOR `description` value whose text begins with `needle`. There can be
/// several descriptions in the manifest (one per action); this returns the one
/// starting with the given text.
fn find_description_containing(data: &[u8], needle: &[u8]) -> Option<String> {
let mut from = 0;
while let Some(i) = crate::scan::find_from(data, b"description", from) {
if let Some(text) = crate::scan::read_cbor_text(data, i + b"description".len()) {
if text.as_bytes().windows(needle.len()).any(|w| w == needle) {
return Some(text);
}
}
from = i + b"description".len();
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::scan::push_cbor_text as push_text;
fn synth_veo() -> Vec<u8> {
let mut v = Vec::new();
v.extend_from_slice(b"....ftypisom....moov....c2pa");
// X.509 v3 serial prefix.
v.extend_from_slice(&[0xA0, 0x03, 0x02, 0x01, 0x02, 0x02, 0x03, 0xE2, 0x13, 0x9E]);
// DER length-prefixed issuer + time-stamping CA CNs (length byte precedes).
let signer_ca = b"Google C2PA Media Services 1P ICA G3";
v.push(signer_ca.len() as u8);
v.extend_from_slice(signer_ca);
let ts_ca = b"Google C2PA Core Time-Stamping ICA G3";
v.push(ts_ca.len() as u8);
v.extend_from_slice(ts_ca);
v.extend_from_slice(b"urn:c2pa:a6b2b914-8d34-c7f1-717f-4c622328d8f0");
v.extend_from_slice(b"c2pa.createdkdescription");
push_text(&mut v, "Created by Google Generative AI.");
v.extend_from_slice(b"qdigitalSourceType");
push_text(&mut v, "http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia");
v.extend_from_slice(b"c2pa.editedkdescription");
push_text(&mut v, "Applied imperceptible SynthID watermark.");
v.extend_from_slice(b"jinstanceID");
push_text(&mut v, "e9738dbe-1dbb-9b61-516c-46a64daa7499");
v.extend_from_slice(b"dnamex\"Google C2PA Core Generator Librarygversion");
push_text(&mut v, "929410399:929410399");
v
}
#[test]
fn parses_veo_manifest() {
let info = VeoInfo::from_bytes(&synth_veo()).expect("should parse");
assert_eq!(info.producer, "Google");
assert!(info.has_c2pa_manifest);
assert_eq!(info.created_description.as_deref(), Some("Created by Google Generative AI."));
assert!(info.has_synthid_watermark);
assert_eq!(info.synthid_description.as_deref(), Some("Applied imperceptible SynthID watermark."));
assert_eq!(info.claim_generator.as_deref(), Some("Google C2PA Core Generator Library"));
assert_eq!(info.claim_generator_version.as_deref(), Some("929410399:929410399"));
assert_eq!(info.manifest_id.as_deref(), Some("urn:c2pa:a6b2b914-8d34-c7f1-717f-4c622328d8f0"));
assert_eq!(info.instance_id.as_deref(), Some("e9738dbe-1dbb-9b61-516c-46a64daa7499"));
assert_eq!(info.cert_serial.as_deref(), Some("E2139E"));
assert_eq!(info.signer_ca.as_deref(), Some("Google C2PA Media Services 1P ICA G3"));
assert!(info.is_timestamped);
assert_eq!(info.timestamp_authority.as_deref(), Some("Google C2PA Core Time-Stamping ICA G3"));
assert!(info.digital_source_type.as_deref().unwrap().contains("trainedAlgorithmicMedia"));
}
#[test]
fn detects_veo_by_encoder_tag_when_c2pa_stripped() {
// A re-encoded Veo: no C2PA manifest, but the `©too` encoder atom is "Google".
let mut v = Vec::new();
v.extend_from_slice(b"....ftypisom....moov....udta....meta....ilst");
v.extend_from_slice(&[0xA9, b't', b'o', b'o']);
v.extend_from_slice(b"....data");
v.extend_from_slice(&[0, 0, 0, 1, 0, 0, 0, 0]); // version/flags + reserved
v.extend_from_slice(b"Google");
let info = VeoInfo::from_bytes(&v).expect("should parse via encoder tag");
assert_eq!(info.producer, "Google");
assert!(!info.has_c2pa_manifest);
assert_eq!(info.encoder.as_deref(), Some("Google"));
assert_eq!(info.cert_serial, None);
assert_eq!(info.signer_ca, None);
assert!(!info.is_timestamped);
}
#[test]
fn non_google_returns_not_veo() {
let data = b"....ftypisom....Volcengine_Ark_CN doubao-seedance-2-0....";
assert!(matches!(VeoInfo::from_bytes(data), Err(VideoInfoError::NotVeo)));
}
}
-81
View File
@@ -1,81 +0,0 @@
//! [`VideoInfo`] — a union over the supported AI-video provenance formats.
use std::fs;
use std::path::Path;
use crate::dreamina_info::DreaminaInfo;
use crate::error::VideoInfoError;
use crate::kling_info::KlingInfo;
use crate::seedance_info::SeedanceInfo;
use crate::sora_info::SoraInfo;
use crate::veo_info::VeoInfo;
/// Recognized AI-video provenance, dispatched by [`VideoInfo::from_bytes`].
#[derive(Debug, Clone, PartialEq)]
pub enum VideoInfo {
/// ByteDance Seedance API (Volcengine / BytePlus C2PA).
Seedance(SeedanceInfo),
/// Google Veo (Google Generative AI video C2PA).
Veo(VeoInfo),
/// OpenAI Sora (OpenAI Generative AI video C2PA).
Sora(SoraInfo),
/// Dreamina (ByteDance/CapCut consumer app `ilst` metadata).
Dreamina(DreaminaInfo),
/// Kling (Kuaishou AIGC-label `ilst` metadata).
Kling(KlingInfo),
}
impl VideoInfo {
/// Detect and parse provenance from a video file on disk.
pub fn from_path(path: impl AsRef<Path>) -> Result<VideoInfo, VideoInfoError> {
let bytes = fs::read(path)?;
Self::from_bytes(&bytes)
}
/// Detect and parse provenance from raw video bytes.
///
/// C2PA-based formats (Seedance, Veo) are tried before the `ilst`-based
/// consumer-app formats (Dreamina, Kling): some Dreamina exports carry *both*
/// a Bytedance C2PA box and Dreamina `ilst` JSON, but they lack the Ark
/// vendor markers so [`SeedanceInfo`] declines them and they fall through to
/// Dreamina. Returns [`VideoInfoError::Unrecognized`] if no format matches.
pub fn from_bytes(data: &[u8]) -> Result<VideoInfo, VideoInfoError> {
match SeedanceInfo::from_bytes(data) {
Ok(info) => return Ok(VideoInfo::Seedance(info)),
Err(VideoInfoError::NotSeedance) => {}
Err(other) => return Err(other),
}
match VeoInfo::from_bytes(data) {
Ok(info) => return Ok(VideoInfo::Veo(info)),
Err(VideoInfoError::NotVeo) => {}
Err(other) => return Err(other),
}
match SoraInfo::from_bytes(data) {
Ok(info) => return Ok(VideoInfo::Sora(info)),
Err(VideoInfoError::NotSora) => {}
Err(other) => return Err(other),
}
match DreaminaInfo::from_bytes(data) {
Ok(info) => return Ok(VideoInfo::Dreamina(info)),
Err(VideoInfoError::NotDreamina) => {}
Err(other) => return Err(other),
}
match KlingInfo::from_bytes(data) {
Ok(info) => return Ok(VideoInfo::Kling(info)),
Err(VideoInfoError::NotKling) => {}
Err(other) => return Err(other),
}
Err(VideoInfoError::Unrecognized)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unrecognized_for_plain_video() {
let data = b"....ftypisom....just a normal mp4....";
assert!(matches!(VideoInfo::from_bytes(data), Err(VideoInfoError::Unrecognized)));
}
}
@@ -20,7 +20,6 @@ path = "src/lib.rs"
workspace-build-acceleration.workspace = true
# Internal
artcraft_api_keys.workspace = true
bucket_paths.workspace = true
composite_identifiers = { path = "../../../schema/public/composite_identifiers" }
enums = { workspace = true, features = ["database"] }
@@ -1,27 +0,0 @@
use chrono::{DateTime, Utc};
use tokens::tokens::api_keys::ApiKeyToken;
use tokens::tokens::users::UserToken;
/// A materialized `api_keys` row, minus the internal `id` and the full secret
/// `api_key`. `api_key_prefix` is the first 20 characters of the key — enough to
/// disambiguate keys in a UI without ever exposing the secret value.
#[derive(Debug, Clone)]
pub struct ApiKeyRow {
pub token: ApiKeyToken,
/// First 20 characters of the `api_key`. The full secret is never returned.
pub api_key_prefix: 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>,
pub maybe_deleted_at: Option<DateTime<Utc>>,
}
@@ -1,38 +0,0 @@
use std::marker::PhantomData;
use sqlx::{Executor, MySql};
use tokens::tokens::api_keys::ApiKeyToken;
pub struct DeleteApiKeyArgs<'e, 'c, E>
where
E: 'e + Executor<'c, Database = MySql>,
{
pub token: &'e ApiKeyToken,
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
}
/// Soft-delete an API key by stamping `maybe_deleted_at = NOW()`. Returns the
/// number of rows affected (0 if no live key matched the token).
pub async fn delete_api_key<'e, 'c: 'e, E>(
args: DeleteApiKeyArgs<'e, 'c, E>,
) -> Result<u64, sqlx::Error>
where
E: 'e + Executor<'c, Database = MySql>,
{
let result = sqlx::query!(
r#"
UPDATE api_keys
SET maybe_deleted_at = NOW()
WHERE token = ?
AND maybe_deleted_at IS NULL
LIMIT 1
"#,
args.token.as_str(),
)
.execute(args.mysql_executor)
.await?;
Ok(result.rows_affected())
}
@@ -1,63 +0,0 @@
use std::marker::PhantomData;
use sqlx::{Executor, MySql};
use tokens::tokens::api_keys::ApiKeyToken;
use tokens::tokens::users::UserToken;
use crate::queries::api_keys::api_key_row::ApiKeyRow;
pub struct GetApiKeyByTokenArgs<'e, 'c, E>
where
E: 'e + Executor<'c, Database = MySql>,
{
pub token: &'e ApiKeyToken,
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
}
/// Look up an API key by its `token`. Returns every detail (including
/// `maybe_deleted_at`, so soft-deleted keys are still visible here) except the
/// internal `id` and the full secret `api_key` (only its first 20 characters
/// are returned). `Ok(None)` if no row matches the token.
pub async fn get_api_key_by_token<'e, 'c: 'e, E>(
args: GetApiKeyByTokenArgs<'e, 'c, E>,
) -> Result<Option<ApiKeyRow>, sqlx::Error>
where
E: 'e + Executor<'c, Database = MySql>,
{
let result = sqlx::query!(
r#"
SELECT
token as `token: ApiKeyToken`,
LEFT(api_key, 20) as `api_key_prefix!`,
name,
maybe_description,
owner_user_token as `owner_user_token: UserToken`,
ip_address_creation,
ip_address_update,
created_at,
updated_at,
maybe_deleted_at
FROM api_keys
WHERE token = ?
LIMIT 1
"#,
args.token.as_str(),
)
.fetch_optional(args.mysql_executor)
.await?;
Ok(result.map(|r| ApiKeyRow {
token: r.token,
api_key_prefix: r.api_key_prefix,
name: r.name,
maybe_description: r.maybe_description,
owner_user_token: r.owner_user_token,
ip_address_creation: r.ip_address_creation,
ip_address_update: r.ip_address_update,
created_at: r.created_at,
updated_at: r.updated_at,
maybe_deleted_at: r.maybe_deleted_at,
}))
}
@@ -1,63 +0,0 @@
use std::marker::PhantomData;
use sqlx::{Executor, MySql};
use artcraft_api_keys::ArtcraftApiKey;
use tokens::tokens::api_keys::ApiKeyToken;
use tokens::tokens::users::UserToken;
use crate::helpers::boolean_converters::i8_to_bool;
pub struct GetApiKeyUserByValueArgs<'e, 'c, E>
where
E: 'e + Executor<'c, Database = MySql>,
{
/// The actual API key secret presented by the client.
pub api_key: &'e ArtcraftApiKey,
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
}
/// A live API key joined to its owning user. This is the minimal identity needed to authenticate
/// an API-key request — far smaller than a cookie `SessionUserRecord`.
pub struct ApiKeyUserRecord {
pub api_key_token: ApiKeyToken,
pub user_token: UserToken,
pub is_banned: bool,
}
/// Look up the user behind an API key secret, for authenticating an incoming request.
///
/// Returns `Ok(None)` when no row matches, when the matching API key is soft-deleted
/// (`maybe_deleted_at IS NOT NULL`), or when the owning user row does not exist (the `INNER JOIN`
/// requires both records to be present). The full secret is never echoed back.
pub async fn get_api_key_user_by_value<'e, 'c: 'e, E>(
args: GetApiKeyUserByValueArgs<'e, 'c, E>,
) -> Result<Option<ApiKeyUserRecord>, sqlx::Error>
where
E: 'e + Executor<'c, Database = MySql>,
{
let result = sqlx::query!(
r#"
SELECT
api_keys.token as `api_key_token: ApiKeyToken`,
users.token as `user_token: UserToken`,
users.is_banned
FROM api_keys
INNER JOIN users
ON users.token = api_keys.owner_user_token
WHERE api_keys.api_key = ?
AND api_keys.maybe_deleted_at IS NULL
LIMIT 1
"#,
args.api_key.as_str_be_careful(),
)
.fetch_optional(args.mysql_executor)
.await?;
Ok(result.map(|r| ApiKeyUserRecord {
api_key_token: r.api_key_token,
user_token: r.user_token,
is_banned: i8_to_bool(r.is_banned),
}))
}
@@ -1,66 +0,0 @@
use std::marker::PhantomData;
use sqlx::{Executor, MySql};
use tokens::tokens::api_keys::ApiKeyToken;
use tokens::tokens::users::UserToken;
use crate::queries::api_keys::api_key_row::ApiKeyRow;
pub struct GetLiveApiKeyByValueArgs<'e, 'c, E>
where
E: 'e + Executor<'c, Database = MySql>,
{
/// The actual API key secret presented by the client.
pub api_key: &'e str,
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
}
/// Look up a **live** API key by its secret value (for authenticating an
/// incoming request). Returns `Ok(None)` if no row matches OR if the matching
/// row is soft-deleted (`maybe_deleted_at IS NOT NULL`). The full secret is
/// never echoed back — only its first 20 characters — and the internal `id` is
/// omitted.
pub async fn get_live_api_key_by_value<'e, 'c: 'e, E>(
args: GetLiveApiKeyByValueArgs<'e, 'c, E>,
) -> Result<Option<ApiKeyRow>, sqlx::Error>
where
E: 'e + Executor<'c, Database = MySql>,
{
let result = sqlx::query!(
r#"
SELECT
token as `token: ApiKeyToken`,
LEFT(api_key, 20) as `api_key_prefix!`,
name,
maybe_description,
owner_user_token as `owner_user_token: UserToken`,
ip_address_creation,
ip_address_update,
created_at,
updated_at,
maybe_deleted_at
FROM api_keys
WHERE api_key = ?
AND maybe_deleted_at IS NULL
LIMIT 1
"#,
args.api_key,
)
.fetch_optional(args.mysql_executor)
.await?;
Ok(result.map(|r| ApiKeyRow {
token: r.token,
api_key_prefix: r.api_key_prefix,
name: r.name,
maybe_description: r.maybe_description,
owner_user_token: r.owner_user_token,
ip_address_creation: r.ip_address_creation,
ip_address_update: r.ip_address_update,
created_at: r.created_at,
updated_at: r.updated_at,
maybe_deleted_at: r.maybe_deleted_at,
}))
}
@@ -1,59 +0,0 @@
use std::marker::PhantomData;
use sqlx::{Executor, MySql};
use artcraft_api_keys::ArtcraftApiKey;
use tokens::tokens::api_keys::ApiKeyToken;
use tokens::tokens::users::UserToken;
pub struct InsertApiKeyArgs<'e, 'c, E>
where
E: 'e + Executor<'c, Database = MySql>,
{
pub owner_user_token: &'e UserToken,
pub ip_address: &'e str,
pub name: &'e str,
pub maybe_description: Option<&'e str>,
pub api_key: &'e ArtcraftApiKey,
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
}
/// Insert a new API key. The `ApiKeyToken` is minted here (in the data-access
/// layer, not the handler) and returned to the caller. The provided IP address
/// is stamped on both the creation and update IP columns, and `created_at` is
/// set to `NOW()`.
pub async fn insert_api_key<'e, 'c: 'e, E>(
args: InsertApiKeyArgs<'e, 'c, E>,
) -> Result<ApiKeyToken, sqlx::Error>
where
E: 'e + Executor<'c, Database = MySql>,
{
let token = ApiKeyToken::generate();
sqlx::query!(
r#"
INSERT INTO api_keys
SET
token = ?,
api_key = ?,
name = ?,
maybe_description = ?,
owner_user_token = ?,
ip_address_creation = ?,
ip_address_update = ?,
created_at = NOW()
"#,
token.as_str(),
args.api_key.as_str_be_careful(), // NB: This is okay in this case.
args.name,
args.maybe_description,
args.owner_user_token.as_str(),
args.ip_address,
args.ip_address,
)
.execute(args.mysql_executor)
.await?;
Ok(token)
}
@@ -1,77 +0,0 @@
use std::marker::PhantomData;
use sqlx::{Executor, MySql};
use tokens::tokens::api_keys::ApiKeyToken;
use tokens::tokens::users::UserToken;
use crate::queries::api_keys::api_key_row::ApiKeyRow;
pub struct ListApiKeysForUserArgs<'e, 'c, E>
where
E: 'e + Executor<'c, Database = MySql>,
{
pub owner_user_token: &'e UserToken,
/// Page size and page offset. Pagination is LIMIT/OFFSET rather than keyset
/// so we don't have to expose the internal `id`.
pub limit: u32,
pub offset: u32,
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
}
/// List a user's API keys, newest first, **including soft-deleted** keys.
/// Never returns the full `api_key` — only its first 20 characters — and never
/// returns the internal `id`.
pub async fn list_api_keys_for_user<'e, 'c: 'e, E>(
args: ListApiKeysForUserArgs<'e, 'c, E>,
) -> Result<Vec<ApiKeyRow>, sqlx::Error>
where
E: 'e + Executor<'c, Database = MySql>,
{
let limit = args.limit as i64;
let offset = args.offset as i64;
let rows = sqlx::query!(
r#"
SELECT
token as `token: ApiKeyToken`,
LEFT(api_key, 20) as `api_key_prefix!`,
name,
maybe_description,
owner_user_token as `owner_user_token: UserToken`,
ip_address_creation,
ip_address_update,
created_at,
updated_at,
maybe_deleted_at
FROM api_keys
WHERE owner_user_token = ?
ORDER BY id DESC
LIMIT ? OFFSET ?
"#,
args.owner_user_token.as_str(),
limit,
offset,
)
.fetch_all(args.mysql_executor)
.await?
.into_iter()
.map(|r| ApiKeyRow {
token: r.token,
api_key_prefix: r.api_key_prefix,
name: r.name,
maybe_description: r.maybe_description,
owner_user_token: r.owner_user_token,
ip_address_creation: r.ip_address_creation,
ip_address_update: r.ip_address_update,
created_at: r.created_at,
updated_at: r.updated_at,
maybe_deleted_at: r.maybe_deleted_at,
})
.collect::<Vec<_>>();
Ok(rows)
}
@@ -1,8 +0,0 @@
pub mod api_key_row;
pub mod delete_api_key;
pub mod get_api_key_by_token;
pub mod get_api_key_user_by_value;
pub mod get_live_api_key_by_value;
pub mod insert_api_key;
pub mod list_api_keys_for_user;
pub mod update_api_key;
@@ -1,48 +0,0 @@
use std::marker::PhantomData;
use sqlx::{Executor, MySql};
use tokens::tokens::api_keys::ApiKeyToken;
pub struct UpdateApiKeyArgs<'e, 'c, E>
where
E: 'e + Executor<'c, Database = MySql>,
{
pub token: &'e ApiKeyToken,
pub maybe_description: Option<&'e str>,
/// IP address of the request making the update; stamped on `ip_address_update`.
pub ip_address: &'e str,
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
}
/// Update an API key's `maybe_description`, also refreshing `ip_address_update`
/// (and, via the column's `ON UPDATE` clause, `updated_at`). Scoped to live keys
/// only. Returns the number of rows affected (0 if no live key matched).
pub async fn update_api_key<'e, 'c: 'e, E>(
args: UpdateApiKeyArgs<'e, 'c, E>,
) -> Result<u64, sqlx::Error>
where
E: 'e + Executor<'c, Database = MySql>,
{
let result = sqlx::query!(
r#"
UPDATE api_keys
SET
maybe_description = ?,
ip_address_update = ?
WHERE token = ?
AND maybe_deleted_at IS NULL
LIMIT 1
"#,
args.maybe_description,
args.ip_address,
args.token.as_str(),
)
.execute(args.mysql_executor)
.await?;
Ok(result.rows_affected())
}
@@ -1,6 +1,5 @@
pub mod analytics_active_users;
pub mod api_keys;
pub mod api_tokens;
pub mod audit_logs;
pub mod batch_generations;
@@ -19,7 +19,6 @@ pub enum UserFeatureFlag {
/// (For now, it's hidden until we get an NSFW filter.)
ExploreMedia,
/// Access to studio features
Studio,
@@ -45,10 +44,6 @@ pub enum UserFeatureFlag {
/// Access to the referrals program
#[serde(rename = "referrals")]
ReferralsProgram,
/// Access to API key creation
#[serde(rename = "api_key")]
ApiKey,
}
// TODO(bt, 2022-12-21): This desperately needs MySQL integration tests!
@@ -68,7 +63,6 @@ impl UserFeatureFlag {
Self::HappyHorse => "hh",
Self::HappyHorseRateLimit => "hh_rl",
Self::ReferralsProgram => "referrals",
Self::ApiKey => "api_key",
}
}
@@ -82,7 +76,6 @@ impl UserFeatureFlag {
"hh" => Ok(Self::HappyHorse),
"hh_rl" => Ok(Self::HappyHorseRateLimit),
"referrals" => Ok(Self::ReferralsProgram),
"api_key" => Ok(Self::ApiKey),
_ => Err(format!("invalid value: {:?}", value)),
}
}
@@ -97,7 +90,6 @@ impl UserFeatureFlag {
Self::HappyHorse => "Happy Horse (no Rate Limit)",
Self::HappyHorseRateLimit => "Happy Horse (with Rate Limit)",
Self::ReferralsProgram => "Referrals Program",
Self::ApiKey => "API Keys",
}
}
@@ -111,7 +103,6 @@ impl UserFeatureFlag {
Self::HappyHorse => "Access to Happy Horse (without a rate limit - dangerous)",
Self::HappyHorseRateLimit => "Access to Happy Horse (with a rate limit)",
Self::ReferralsProgram => "Access to the referrals program",
Self::ApiKey => "Access to API key creation",
}
}
@@ -127,7 +118,6 @@ impl UserFeatureFlag {
Self::HappyHorse,
Self::HappyHorseRateLimit,
Self::ReferralsProgram,
Self::ApiKey,
])
}
}
@@ -150,7 +140,6 @@ mod tests {
assert_serialization(UserFeatureFlag::HappyHorse, "hh");
assert_serialization(UserFeatureFlag::HappyHorseRateLimit, "hh_rl");
assert_serialization(UserFeatureFlag::ReferralsProgram, "referrals");
assert_serialization(UserFeatureFlag::ApiKey, "api_key");
}
#[test]
@@ -163,7 +152,6 @@ mod tests {
assert_eq!(UserFeatureFlag::HappyHorse.to_str(), "hh");
assert_eq!(UserFeatureFlag::HappyHorseRateLimit.to_str(), "hh_rl");
assert_eq!(UserFeatureFlag::ReferralsProgram.to_str(), "referrals");
assert_eq!(UserFeatureFlag::ApiKey.to_str(), "api_key");
}
#[test]
@@ -176,14 +164,13 @@ mod tests {
assert_eq!(UserFeatureFlag::from_str("hh").unwrap(), UserFeatureFlag::HappyHorse);
assert_eq!(UserFeatureFlag::from_str("hh_rl").unwrap(), UserFeatureFlag::HappyHorseRateLimit);
assert_eq!(UserFeatureFlag::from_str("referrals").unwrap(), UserFeatureFlag::ReferralsProgram);
assert_eq!(UserFeatureFlag::from_str("api_key").unwrap(), UserFeatureFlag::ApiKey);
assert!(UserFeatureFlag::from_str("foo").is_err());
}
#[test]
fn all_variants() {
let mut variants = UserFeatureFlag::all_variants();
assert_eq!(variants.len(), 9);
assert_eq!(variants.len(), 8);
assert_eq!(variants.pop_first(), Some(UserFeatureFlag::ExploreMedia));
assert_eq!(variants.pop_first(), Some(UserFeatureFlag::Studio));
assert_eq!(variants.pop_first(), Some(UserFeatureFlag::Upload3d));
@@ -192,7 +179,6 @@ mod tests {
assert_eq!(variants.pop_first(), Some(UserFeatureFlag::HappyHorse));
assert_eq!(variants.pop_first(), Some(UserFeatureFlag::HappyHorseRateLimit));
assert_eq!(variants.pop_first(), Some(UserFeatureFlag::ReferralsProgram));
assert_eq!(variants.pop_first(), Some(UserFeatureFlag::ApiKey));
assert_eq!(variants.pop_first(), None);
}
}
@@ -26,9 +26,6 @@ pub enum PlatformType {
/// Desktop app users
DesktopApp,
/// Requests authenticated via an `Authorization` header API key (the Omni API surface).
ApiKey,
// ========== Other clients ==========
Curl,
@@ -45,7 +42,6 @@ impl PlatformType {
match self {
Self::Web => "web",
Self::DesktopApp => "desktop_app",
Self::ApiKey => "api_key",
Self::Curl => "curl",
Self::PythonRequests => "python_requests",
Self::Postman => "postman",
@@ -56,7 +52,6 @@ impl PlatformType {
match value {
"web" => Ok(Self::Web),
"desktop_app" => Ok(Self::DesktopApp),
"api_key" => Ok(Self::ApiKey),
"curl" => Ok(Self::Curl),
"python_requests" => Ok(Self::PythonRequests),
"postman" => Ok(Self::Postman),
@@ -69,7 +64,6 @@ impl PlatformType {
BTreeSet::from([
Self::Web,
Self::DesktopApp,
Self::ApiKey,
Self::Curl,
Self::PythonRequests,
Self::Postman,
@@ -90,7 +84,6 @@ mod tests {
fn test_serialization() {
assert_serialization(PlatformType::Web, "web");
assert_serialization(PlatformType::DesktopApp, "desktop_app");
assert_serialization(PlatformType::ApiKey, "api_key");
assert_serialization(PlatformType::Curl, "curl");
assert_serialization(PlatformType::PythonRequests, "python_requests");
assert_serialization(PlatformType::Postman, "postman");
@@ -100,7 +93,6 @@ mod tests {
fn to_str() {
assert_eq!(PlatformType::Web.to_str(), "web");
assert_eq!(PlatformType::DesktopApp.to_str(), "desktop_app");
assert_eq!(PlatformType::ApiKey.to_str(), "api_key");
assert_eq!(PlatformType::Curl.to_str(), "curl");
assert_eq!(PlatformType::PythonRequests.to_str(), "python_requests");
assert_eq!(PlatformType::Postman.to_str(), "postman");
@@ -110,7 +102,6 @@ mod tests {
fn from_str() {
assert_eq!(PlatformType::from_str("web").unwrap(), PlatformType::Web);
assert_eq!(PlatformType::from_str("desktop_app").unwrap(), PlatformType::DesktopApp);
assert_eq!(PlatformType::from_str("api_key").unwrap(), PlatformType::ApiKey);
assert_eq!(PlatformType::from_str("curl").unwrap(), PlatformType::Curl);
assert_eq!(PlatformType::from_str("python_requests").unwrap(), PlatformType::PythonRequests);
assert_eq!(PlatformType::from_str("postman").unwrap(), PlatformType::Postman);
@@ -130,10 +121,9 @@ mod tests {
#[test]
fn all_variants() {
let mut variants = PlatformType::all_variants();
assert_eq!(variants.len(), 6);
assert_eq!(variants.len(), 5);
assert_eq!(variants.pop_first(), Some(PlatformType::Web));
assert_eq!(variants.pop_first(), Some(PlatformType::DesktopApp));
assert_eq!(variants.pop_first(), Some(PlatformType::ApiKey));
assert_eq!(variants.pop_first(), Some(PlatformType::Curl));
assert_eq!(variants.pop_first(), Some(PlatformType::PythonRequests));
assert_eq!(variants.pop_first(), Some(PlatformType::Postman));
@@ -23,7 +23,6 @@ use strum::EnumIter;
#[cfg_attr(test, derive(EnumIter, EnumCount))]
pub(crate) enum TokenPrefix {
AnonymousVisitorTracking, // AVTs are not stored as primary keys in any table, but an index in many tables.
ApiKey,
AppSession, // NB: These are generated client-side (!)
AuditLog,
BatchGeneration,
@@ -113,7 +112,6 @@ impl PrefixGenerator for TokenPrefix {
fn prefix(self) -> &'static str {
match self {
Self::AnonymousVisitorTracking => "avt_",
Self::ApiKey => "api_key_",
Self::AppSession => "app_session_",
Self::AuditLog => "audit_",
Self::BatchGeneration => "batch_g_",
@@ -350,7 +348,6 @@ mod tests {
|| prefix == "INT_API:"
|| prefix == "batch_g_"
|| prefix == "beta_key_"
|| prefix == "api_key_"
|| prefix == "email_job_"
|| prefix == "pw_reset_"
|| prefix == "tts_task_"
@@ -1,17 +0,0 @@
use std::fmt::Debug;
use serde::Deserialize;
use serde::Serialize;
use utoipa::ToSchema;
use crate::prefixes::TokenPrefix;
/// The primary key for api_keys.
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Serialize, Deserialize, Default, ToSchema)]
#[cfg_attr(feature = "database", derive(sqlx::Type))]
#[cfg_attr(feature = "database", sqlx(transparent))]
pub struct ApiKeyToken(pub String);
impl_mysql_token_from_row!(ApiKeyToken);
impl_string_token!(ApiKeyToken);
impl_crockford_generator!(ApiKeyToken, 32usize, TokenPrefix::ApiKey, CrockfordLower);
@@ -1,5 +1,4 @@
pub mod anonymous_visitor_tracking;
pub mod api_keys;
pub mod api_tokens_external;
pub mod api_tokens_internal;
pub mod app_session;

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