mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-24 10:39:28 -04:00
Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2481f88a01 | ||
|
|
61dceffd3e | ||
|
|
9191fe337a | ||
|
|
eb3b733852 | ||
|
|
7d8d9462d1 | ||
|
|
660cf9f0ac | ||
|
|
bf373b1d5c | ||
|
|
914c2532ec | ||
|
|
c7341d8f63 | ||
|
|
92e99cfcb6 | ||
|
|
754cdb8a6c | ||
|
|
7b9db101fe | ||
|
|
e94987e5a4 | ||
|
|
47d83be3fb | ||
|
|
fa4b57980c | ||
|
|
87d060e68c | ||
|
|
fc7651071c | ||
|
|
7a0bfa35e6 | ||
|
|
0b4e288a3e | ||
|
|
a0b6047a78 | ||
|
|
ebe1287182 | ||
|
|
de538ba94c | ||
|
|
29a127aac0 |
@@ -19,9 +19,18 @@ Handlers in `storyteller_web` follow this structure:
|
||||
|
||||
### Session/Auth
|
||||
|
||||
- `require_user_session_using_connection()` reuses an open DB connection (preferred)
|
||||
- `require_user_session()` acquires its own connection (deprecated)
|
||||
- `require_moderator()` for admin-only endpoints
|
||||
Helpers live in `storyteller_web` under `http_server/web_utils/user_session/`. Each takes a
|
||||
generic `mysql_executor: E` (any `sqlx::Executor`), so whether it reuses a connection or acquires
|
||||
its own depends on the **argument you pass**:
|
||||
|
||||
- `require_user_session(http_request, session_checker, mysql_executor)` — require a logged-in user.
|
||||
- `require_user_session_extended(...)` — same, with extra session detail.
|
||||
- `require_moderator(http_request, session_checker, mysql_executor)` — admin-only endpoints.
|
||||
|
||||
PREFER passing an already-open connection (`&mut *conn`) so the helper reuses it. Passing
|
||||
`&server_state.mysql_pool` makes it acquire a fresh connection — avoid that when the handler
|
||||
already holds one (the double-acquire adds pool pressure; this crate has had pool-timeout
|
||||
incidents). There is no `*_using_connection` variant — the reuse/acquire choice is the argument.
|
||||
|
||||
### API Type Definitions
|
||||
|
||||
|
||||
@@ -7,10 +7,11 @@
|
||||
- `#[macro_use] extern crate serde_derive;` is used in binary crates; library crates use `use serde_derive::{Serialize, Deserialize};`
|
||||
- Never use `println!` or `eprintln!` outside of tests; use `log` crate macros (`info!`, `warn!`, `error!`, `debug!`, `trace!`) instead
|
||||
- Use `anyhow::Result` / `AnyhowResult` for fallible functions in application code; new database queries should use `Result<T, sqlx::Error>` instead
|
||||
- SQLx queries: prefer `sqlx::query!` and `sqlx::query_as!` compile-time checked macros over
|
||||
runtime `sqlx::query()` / `sqlx::query_as()`. The macros catch SQL typos, column mismatches,
|
||||
and type errors at build time. Use runtime queries only when assembling complicated
|
||||
dynamic/branching queries that cannot be expressed as a compile-time macro.
|
||||
- SQLx: MySQL queries belong in the `mysql_queries` crate. Do NOT write `sqlx::query!` /
|
||||
`sqlx::query()` in application/service crates (e.g. `storyteller-web`, which has zero direct
|
||||
SQLx queries) — add or reuse a function in `mysql_queries` and call it. The compile-time
|
||||
`query!` / `query_as!` macro conventions for that crate are documented in
|
||||
`crates/schema/database/mysql_queries/CLAUDE.md`.
|
||||
- Enum Display/Debug: use `impl_enum_display_and_debug_using_to_str!` macro, not `derive_more::Display` (CLion doesn't understand it)
|
||||
- Fields that are optional use the `maybe_` prefix: `maybe_creator_user_token`, `maybe_prompt_token`
|
||||
- When implementing `fmt::Display` for error types, use the pattern: `write!(f, "{:?}", self)`
|
||||
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nINSERT INTO uploaded_videos\nSET\n token = ?,\n sha1_checksum = ?,\n filesize_bytes = ?,\n maybe_filename = ?,\n maybe_width = ?,\n maybe_height = ?,\n maybe_resolution = ?,\n maybe_detected_model_family = ?,\n maybe_detected_model_type = ?,\n maybe_report = ?,\n upload_ip_address = ?,\n created_at = NOW()\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 11
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "14e63e59f7d550181126151029bcd17d08278196ba0ee4d32450c2567b1da26e"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nUPDATE uploaded_video_notes\nSET\n maybe_filename = ?,\n maybe_reported_model_type = ?,\n maybe_reported_model_name = ?,\n maybe_website = ?,\n maybe_other_website = ?,\n maybe_comments = ?,\n maybe_email_address = ?,\n can_share_report = ?,\n was_scammed = ?,\n maybe_comment_update_ip_address = ?,\n version = version + 1\nWHERE token = ?\nLIMIT 1\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 11
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "4036dfd9d4e9e77e557776468c14b75f49170de1e55a1b9b9e83446a0c356e74"
|
||||
}
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nSELECT\n token as `token: UploadedVideoToken`,\n sha1_checksum,\n filesize_bytes as `filesize_bytes: u32`,\n maybe_width as `maybe_width: u32`,\n maybe_height as `maybe_height: u32`,\n maybe_resolution,\n maybe_detected_model_family as `maybe_detected_model_family: UploadedVideoDetectedModelFamily`,\n maybe_detected_model_type as `maybe_detected_model_type: UploadedVideoDetectedModelType`,\n maybe_report,\n upload_ip_address,\n maybe_updated_ip_address,\n created_at,\n updated_at\nFROM uploaded_videos\nWHERE sha1_checksum = ?\nLIMIT 1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "token: UploadedVideoToken",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "sha1_checksum",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 160
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "filesize_bytes: u32",
|
||||
"type_info": {
|
||||
"type": "Long",
|
||||
"flags": "NOT_NULL | UNSIGNED | NO_DEFAULT_VALUE",
|
||||
"char_set": 63,
|
||||
"max_size": 10
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "maybe_width: u32",
|
||||
"type_info": {
|
||||
"type": "Long",
|
||||
"flags": "UNSIGNED",
|
||||
"char_set": 63,
|
||||
"max_size": 10
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "maybe_height: u32",
|
||||
"type_info": {
|
||||
"type": "Long",
|
||||
"flags": "UNSIGNED",
|
||||
"char_set": 63,
|
||||
"max_size": 10
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "maybe_resolution",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 48
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "maybe_detected_model_family: UploadedVideoDetectedModelFamily",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "MULTIPLE_KEY | BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "maybe_detected_model_type: UploadedVideoDetectedModelType",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "maybe_report",
|
||||
"type_info": {
|
||||
"type": "Blob",
|
||||
"flags": "BLOB | BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 67108860
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "upload_ip_address",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 160
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "maybe_updated_ip_address",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 160
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"name": "created_at",
|
||||
"type_info": {
|
||||
"type": "Timestamp",
|
||||
"flags": "NOT_NULL | BINARY | TIMESTAMP",
|
||||
"char_set": 63,
|
||||
"max_size": 19
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 12,
|
||||
"name": "updated_at",
|
||||
"type_info": {
|
||||
"type": "Timestamp",
|
||||
"flags": "NOT_NULL | BINARY | TIMESTAMP | ON_UPDATE_NOW",
|
||||
"char_set": 63,
|
||||
"max_size": 19
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "408c893a0ced805de4c3c6cfdf0c6a4bcc93dd1eaf98382522eae46e72eb3aa5"
|
||||
}
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nSELECT\n token as `token: UploadedVideoToken`,\n sha1_checksum,\n filesize_bytes as `filesize_bytes: u32`,\n maybe_width as `maybe_width: u32`,\n maybe_height as `maybe_height: u32`,\n maybe_resolution,\n maybe_detected_model_family as `maybe_detected_model_family: UploadedVideoDetectedModelFamily`,\n maybe_detected_model_type as `maybe_detected_model_type: UploadedVideoDetectedModelType`,\n maybe_report,\n upload_ip_address,\n maybe_updated_ip_address,\n created_at,\n updated_at\nFROM uploaded_videos\nWHERE token = ?\nLIMIT 1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "token: UploadedVideoToken",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "sha1_checksum",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 160
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "filesize_bytes: u32",
|
||||
"type_info": {
|
||||
"type": "Long",
|
||||
"flags": "NOT_NULL | UNSIGNED | NO_DEFAULT_VALUE",
|
||||
"char_set": 63,
|
||||
"max_size": 10
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "maybe_width: u32",
|
||||
"type_info": {
|
||||
"type": "Long",
|
||||
"flags": "UNSIGNED",
|
||||
"char_set": 63,
|
||||
"max_size": 10
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "maybe_height: u32",
|
||||
"type_info": {
|
||||
"type": "Long",
|
||||
"flags": "UNSIGNED",
|
||||
"char_set": 63,
|
||||
"max_size": 10
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "maybe_resolution",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 48
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "maybe_detected_model_family: UploadedVideoDetectedModelFamily",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "MULTIPLE_KEY | BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "maybe_detected_model_type: UploadedVideoDetectedModelType",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "maybe_report",
|
||||
"type_info": {
|
||||
"type": "Blob",
|
||||
"flags": "BLOB | BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 67108860
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "upload_ip_address",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 160
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "maybe_updated_ip_address",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 160
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"name": "created_at",
|
||||
"type_info": {
|
||||
"type": "Timestamp",
|
||||
"flags": "NOT_NULL | BINARY | TIMESTAMP",
|
||||
"char_set": 63,
|
||||
"max_size": 19
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 12,
|
||||
"name": "updated_at",
|
||||
"type_info": {
|
||||
"type": "Timestamp",
|
||||
"flags": "NOT_NULL | BINARY | TIMESTAMP | ON_UPDATE_NOW",
|
||||
"char_set": 63,
|
||||
"max_size": 19
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "73d70c5ee77f5059801d324610a80b3f52da3ddec2e89f63e8f41fc68ff0aaec"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nINSERT INTO uploaded_video_notes\nSET\n token = ?,\n uploaded_video_token = ?,\n maybe_filename = ?,\n maybe_reported_model_type = ?,\n maybe_reported_model_name = ?,\n maybe_website = ?,\n maybe_other_website = ?,\n maybe_comments = ?,\n maybe_email_address = ?,\n can_share_report = ?,\n was_scammed = ?,\n comment_create_ip_address = ?,\n created_at = NOW()\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 12
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "b1f30a79864351a28a58cae43ee38ff12a8d64d9393b8f27590fe03b0ae3ab05"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nUPDATE uploaded_videos\nSET\n maybe_filename = ?,\n maybe_width = ?,\n maybe_height = ?,\n maybe_resolution = ?,\n maybe_detected_model_family = ?,\n maybe_detected_model_type = ?,\n maybe_report = ?,\n maybe_updated_ip_address = ?,\n version = version + 1\nWHERE token = ?\nLIMIT 1\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 9
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "c6bbdc963e0d49ac55973a728d04b371967c20bfb9dd4bd75e4693dc7a114c99"
|
||||
}
|
||||
@@ -58,7 +58,7 @@ artcraft/
|
||||
|
||||
- Rust with no minimum supported version
|
||||
- Actix-web for HTTP services
|
||||
- SQLx for MySQL and SQLite; prefer `sqlx::query!` / `sqlx::query_as!` compile-time checked macros over runtime `sqlx::query()` whenever possible
|
||||
- SQLx for MySQL and SQLite. MySQL queries belong in the `mysql_queries` crate, not in application/service crates like `storyteller-web` — call those query functions instead of embedding `sqlx::query!` / `sqlx::query()` in handlers. (Query-writing conventions live in `crates/schema/database/mysql_queries/CLAUDE.md`.)
|
||||
- A mix of wreq and reqwest for Rust HTTP clients
|
||||
- Never use `println!` or `eprintln!` outside of tests; use `log` crate macros instead
|
||||
- When two crates export the same type name, alias imports with a suffix: `use foo::Bar as BarFoo;`
|
||||
|
||||
Generated
+27
@@ -1730,6 +1730,14 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bucket_client"
|
||||
version = "0.0.1"
|
||||
dependencies = [
|
||||
"rust-s3",
|
||||
"workspace-build-acceleration",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bucket_paths"
|
||||
version = "0.0.1"
|
||||
@@ -10996,6 +11004,14 @@ dependencies = [
|
||||
"digest 0.11.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1_hash"
|
||||
version = "0.0.1"
|
||||
dependencies = [
|
||||
"sha1 0.10.6",
|
||||
"workspace-build-acceleration",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1_smol"
|
||||
version = "1.0.0"
|
||||
@@ -11573,6 +11589,7 @@ dependencies = [
|
||||
"billing_artcraft_component",
|
||||
"billing_component",
|
||||
"bootstrap",
|
||||
"bucket_client",
|
||||
"bucket_paths",
|
||||
"bytes",
|
||||
"chrono",
|
||||
@@ -11644,6 +11661,7 @@ dependencies = [
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"server_environment",
|
||||
"sha1_hash",
|
||||
"shared_env_var_config",
|
||||
"speculoos",
|
||||
"sqlx",
|
||||
@@ -11667,6 +11685,7 @@ dependencies = [
|
||||
"utoipa",
|
||||
"utoipa-swagger-ui",
|
||||
"uuid_utils",
|
||||
"video_info",
|
||||
"videos",
|
||||
"workspace-build-acceleration",
|
||||
"worldlabs_api_client",
|
||||
@@ -13896,6 +13915,14 @@ dependencies = [
|
||||
"workspace-build-acceleration",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "video_info"
|
||||
version = "0.0.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"workspace-build-acceleration",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "videos"
|
||||
version = "0.0.1"
|
||||
|
||||
@@ -10,6 +10,7 @@ members = [
|
||||
"crates/api_clients/artcraft/artcraft_api_keys",
|
||||
"crates/api_clients/artcraft/artcraft_client",
|
||||
"crates/api_clients/artcraft/artcraft_router",
|
||||
"crates/api_clients/bucket_client",
|
||||
"crates/api_clients/cloud_storage",
|
||||
"crates/api_clients/comet_api_client",
|
||||
"crates/api_clients/datadog_client",
|
||||
@@ -87,6 +88,7 @@ members = [
|
||||
"crates/lib/pager",
|
||||
"crates/lib/password",
|
||||
"crates/lib/server_environment",
|
||||
"crates/lib/sha1_hash",
|
||||
"crates/lib/sqlx_mysql_helpers",
|
||||
"crates/lib/storyteller_root",
|
||||
"crates/lib/url_config",
|
||||
@@ -94,6 +96,7 @@ members = [
|
||||
"crates/lib/user_input_common",
|
||||
"crates/lib/users",
|
||||
"crates/lib/uuid_utils",
|
||||
"crates/lib/video_info",
|
||||
#"crates/lib/api_clients/fakeyou_client",
|
||||
#"crates/lib/deprecated/machine_learning/ml_models",
|
||||
|
||||
@@ -237,6 +240,7 @@ artcraft_api_keys = { path = "crates/api_clients/artcraft/artcraft_api_keys" }
|
||||
artcraft_client = { path = "crates/api_clients/artcraft/artcraft_client" }
|
||||
artcraft_router = { path = "crates/api_clients/artcraft/artcraft_router" }
|
||||
bootstrap = { path = "crates/lib/bootstrap" }
|
||||
bucket_client = { path = "crates/api_clients/bucket_client" }
|
||||
bucket_paths = { path = "crates/schema/buckets/bucket_paths" }
|
||||
build_metadata = { path = "crates/lib/build_metadata" }
|
||||
cloud_storage = { path = "crates/api_clients/cloud_storage" }
|
||||
@@ -281,6 +285,7 @@ openai_sora_client = { path = "crates/api_clients/openai_sora_client" }
|
||||
primitives = { path = "crates/lib/data/primitives" }
|
||||
seedance2pro_client = { path = "crates/api_clients/seedance2pro_client" }
|
||||
server_environment = { path = "crates/lib/server_environment" }
|
||||
sha1_hash = { path = "crates/lib/sha1_hash" }
|
||||
shared_service_components = { path = "crates/lib/shared_service_components" }
|
||||
sqlite_tasks = { path = "crates/schema/database/sqlite_tasks" }
|
||||
subprocess_common = { path = "crates/lib/deprecated/subprocess_common" }
|
||||
@@ -290,6 +295,7 @@ tokens = { path = "crates/schema/public/tokens" }
|
||||
url_utils = { path = "crates/lib/url_utils" }
|
||||
users = { path = "crates/lib/users" }
|
||||
uuid_utils = { path = "crates/lib/uuid_utils" }
|
||||
video_info = { path = "crates/lib/video_info" }
|
||||
web_base64 = { path = "crates/lib/data/web_base64" }
|
||||
worldlabs_api_client = { path = "crates/api_clients/worldlabs_api_client" }
|
||||
worldlabs_consumer_client = { path = "crates/api_clients/worldlabs_consumer_client" }
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
DROP TABLE IF EXISTS uploaded_videos;
|
||||
@@ -0,0 +1,62 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
CREATE TABLE uploaded_videos (
|
||||
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
|
||||
-- Effective "primary key" (used internally for lookups and as a foreign key).
|
||||
token VARCHAR(32) NOT NULL,
|
||||
|
||||
-- ========== FILE ==========
|
||||
|
||||
-- SHA-1 checksum of the uploaded bytes (40 hex chars). De-dupes uploads.
|
||||
sha1_checksum VARCHAR(40) NOT NULL,
|
||||
|
||||
-- Size of the uploaded file in bytes.
|
||||
filesize_bytes INT(10) UNSIGNED NOT NULL,
|
||||
|
||||
-- Pixel dimensions, when they could be determined.
|
||||
maybe_width INT(10) UNSIGNED DEFAULT NULL,
|
||||
maybe_height INT(10) UNSIGNED DEFAULT NULL,
|
||||
|
||||
-- Resolution label (e.g. "1080p", "1920x1080"), when known.
|
||||
maybe_resolution VARCHAR(12) DEFAULT NULL,
|
||||
|
||||
-- ========== DETECTION ==========
|
||||
|
||||
-- Detected provenance family (e.g. "seedance", "veo", "sora", "dreamina", "kling").
|
||||
maybe_detected_model_family VARCHAR(32) DEFAULT NULL,
|
||||
|
||||
-- Detected model type / variant (e.g. "full", "fast", "mini").
|
||||
maybe_detected_model_type VARCHAR(32) DEFAULT NULL,
|
||||
|
||||
-- Free-form report / analysis blob for this upload.
|
||||
maybe_report MEDIUMTEXT DEFAULT NULL,
|
||||
|
||||
-- ========== IP ADDRESSES ==========
|
||||
|
||||
-- IP address that uploaded the video. Wide enough for IPv4/IPv6.
|
||||
upload_ip_address VARCHAR(40) NOT NULL,
|
||||
|
||||
-- IP address that last updated the row. Wide enough for IPv4/IPv6.
|
||||
maybe_updated_ip_address VARCHAR(40) DEFAULT NULL,
|
||||
|
||||
-- ========== VERSION ==========
|
||||
|
||||
-- Optimistic-concurrency "vector clock". Bump by 1 on every update — set in
|
||||
-- the UPDATE statement (MySQL can't auto-increment a plain column via schema).
|
||||
version INT(8) UNSIGNED NOT NULL DEFAULT 0,
|
||||
|
||||
-- ========== TIMESTAMPS ==========
|
||||
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
|
||||
-- INDICES --
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY (token),
|
||||
UNIQUE KEY (sha1_checksum),
|
||||
KEY index_maybe_detected_model_family (maybe_detected_model_family)
|
||||
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
|
||||
@@ -0,0 +1,5 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
DROP TABLE IF EXISTS uploaded_video_notes;
|
||||
@@ -0,0 +1,69 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
CREATE TABLE uploaded_video_notes (
|
||||
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
|
||||
-- Effective "primary key" (used internally for lookups and as a foreign key).
|
||||
token VARCHAR(32) NOT NULL,
|
||||
|
||||
-- The uploaded video this note is about (FK to uploaded_videos.token).
|
||||
uploaded_video_token VARCHAR(32) NOT NULL,
|
||||
|
||||
-- ========== SUBMISSION ==========
|
||||
|
||||
-- Original filename of the submitted clip, as provided by the user.
|
||||
maybe_filename VARCHAR(512) DEFAULT NULL,
|
||||
|
||||
-- The model type the submitter claims this video is.
|
||||
maybe_reported_model_type VARCHAR(32) DEFAULT NULL,
|
||||
|
||||
-- Free-form custom model name when the submitter's model isn't a known type.
|
||||
maybe_reported_model_name VARCHAR(64) DEFAULT NULL,
|
||||
|
||||
-- The website / platform the video was obtained from.
|
||||
maybe_website VARCHAR(32) DEFAULT NULL,
|
||||
|
||||
-- Free-form website when it isn't a known option.
|
||||
maybe_other_website VARCHAR(128) DEFAULT NULL,
|
||||
|
||||
-- Free-form comments from the submitter.
|
||||
maybe_comments VARCHAR(1024) DEFAULT NULL,
|
||||
|
||||
-- ========== CONTACT & FLAGS ==========
|
||||
|
||||
-- Optional email address for follow-up.
|
||||
maybe_email_address VARCHAR(255) DEFAULT NULL,
|
||||
|
||||
-- Whether the submitter consents to us sharing the report.
|
||||
can_share_report BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
|
||||
-- Whether the submitter believes they were scammed.
|
||||
was_scammed BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
|
||||
-- ========== IP ADDRESSES ==========
|
||||
|
||||
-- IP address that submitted the note. Wide enough for IPv4/IPv6.
|
||||
comment_create_ip_address VARCHAR(40) NOT NULL,
|
||||
|
||||
-- IP address that last updated the note. Wide enough for IPv4/IPv6.
|
||||
maybe_comment_update_ip_address VARCHAR(40) DEFAULT NULL,
|
||||
|
||||
-- ========== VERSION ==========
|
||||
|
||||
-- Optimistic-concurrency "vector clock". Bump by 1 on every update — set in
|
||||
-- the UPDATE statement (MySQL can't auto-increment a plain column via schema).
|
||||
version INT(8) UNSIGNED NOT NULL DEFAULT 0,
|
||||
|
||||
-- ========== TIMESTAMPS ==========
|
||||
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
|
||||
-- INDICES --
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY (token),
|
||||
KEY fk_uploaded_video_token (uploaded_video_token)
|
||||
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
ALTER TABLE uploaded_videos
|
||||
DROP COLUMN maybe_filename;
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
ALTER TABLE uploaded_videos
|
||||
ADD COLUMN maybe_filename VARCHAR(512) DEFAULT NULL AFTER filesize_bytes;
|
||||
@@ -0,0 +1,69 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
CREATE TABLE uploaded_video_notes (
|
||||
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
|
||||
-- Effective "primary key" (used internally for lookups and as a foreign key).
|
||||
token VARCHAR(32) NOT NULL,
|
||||
|
||||
-- The uploaded video this note is about (FK to uploaded_videos.token).
|
||||
uploaded_video_token VARCHAR(32) NOT NULL,
|
||||
|
||||
-- ========== SUBMISSION ==========
|
||||
|
||||
-- Original filename of the submitted clip, as provided by the user.
|
||||
maybe_filename VARCHAR(512) DEFAULT NULL,
|
||||
|
||||
-- The model type the submitter claims this video is.
|
||||
maybe_reported_model_type VARCHAR(32) DEFAULT NULL,
|
||||
|
||||
-- Free-form custom model name when the submitter's model isn't a known type.
|
||||
maybe_reported_model_name VARCHAR(64) DEFAULT NULL,
|
||||
|
||||
-- The website / platform the video was obtained from.
|
||||
maybe_website VARCHAR(32) DEFAULT NULL,
|
||||
|
||||
-- Free-form website when it isn't a known option.
|
||||
maybe_other_website VARCHAR(128) DEFAULT NULL,
|
||||
|
||||
-- Free-form comments from the submitter.
|
||||
maybe_comments VARCHAR(1024) DEFAULT NULL,
|
||||
|
||||
-- ========== CONTACT & FLAGS ==========
|
||||
|
||||
-- Optional email address for follow-up.
|
||||
maybe_email_address VARCHAR(255) DEFAULT NULL,
|
||||
|
||||
-- Whether the submitter consents to us sharing the report.
|
||||
can_share_report BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
|
||||
-- Whether the submitter believes they were scammed.
|
||||
was_scammed BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
|
||||
-- ========== IP ADDRESSES ==========
|
||||
|
||||
-- IP address that submitted the note. Wide enough for IPv4/IPv6.
|
||||
comment_create_ip_address VARCHAR(40) NOT NULL,
|
||||
|
||||
-- IP address that last updated the note. Wide enough for IPv4/IPv6.
|
||||
maybe_comment_update_ip_address VARCHAR(40) DEFAULT NULL,
|
||||
|
||||
-- ========== VERSION ==========
|
||||
|
||||
-- Optimistic-concurrency "vector clock". Bump by 1 on every update — set in
|
||||
-- the UPDATE statement (MySQL can't auto-increment a plain column via schema).
|
||||
version INT(8) UNSIGNED NOT NULL DEFAULT 0,
|
||||
|
||||
-- ========== TIMESTAMPS ==========
|
||||
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
|
||||
-- INDICES --
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY (token),
|
||||
KEY fk_uploaded_video_token (uploaded_video_token)
|
||||
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
|
||||
@@ -0,0 +1,65 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
CREATE TABLE uploaded_videos (
|
||||
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
|
||||
-- Effective "primary key" (used internally for lookups and as a foreign key).
|
||||
token VARCHAR(32) NOT NULL,
|
||||
|
||||
-- ========== FILE ==========
|
||||
|
||||
-- SHA-1 checksum of the uploaded bytes (40 hex chars). De-dupes uploads.
|
||||
sha1_checksum VARCHAR(40) NOT NULL,
|
||||
|
||||
-- Size of the uploaded file in bytes.
|
||||
filesize_bytes INT(10) UNSIGNED NOT NULL,
|
||||
|
||||
-- Original filename of the uploaded clip, as provided by the user.
|
||||
maybe_filename VARCHAR(512) DEFAULT NULL,
|
||||
|
||||
-- Pixel dimensions, when they could be determined.
|
||||
maybe_width INT(10) UNSIGNED DEFAULT NULL,
|
||||
maybe_height INT(10) UNSIGNED DEFAULT NULL,
|
||||
|
||||
-- Resolution label (e.g. "1080p", "1920x1080"), when known.
|
||||
maybe_resolution VARCHAR(12) DEFAULT NULL,
|
||||
|
||||
-- ========== DETECTION ==========
|
||||
|
||||
-- Detected provenance family (e.g. "seedance", "veo", "sora", "dreamina", "kling").
|
||||
maybe_detected_model_family VARCHAR(32) DEFAULT NULL,
|
||||
|
||||
-- Detected model type / variant (e.g. "full", "fast", "mini").
|
||||
maybe_detected_model_type VARCHAR(32) DEFAULT NULL,
|
||||
|
||||
-- Free-form report / analysis blob for this upload.
|
||||
maybe_report MEDIUMTEXT DEFAULT NULL,
|
||||
|
||||
-- ========== IP ADDRESSES ==========
|
||||
|
||||
-- IP address that uploaded the video. Wide enough for IPv4/IPv6.
|
||||
upload_ip_address VARCHAR(40) NOT NULL,
|
||||
|
||||
-- IP address that last updated the row. Wide enough for IPv4/IPv6.
|
||||
maybe_updated_ip_address VARCHAR(40) DEFAULT NULL,
|
||||
|
||||
-- ========== VERSION ==========
|
||||
|
||||
-- Optimistic-concurrency "vector clock". Bump by 1 on every update — set in
|
||||
-- the UPDATE statement (MySQL can't auto-increment a plain column via schema).
|
||||
version INT(8) UNSIGNED NOT NULL DEFAULT 0,
|
||||
|
||||
-- ========== TIMESTAMPS ==========
|
||||
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
|
||||
-- INDICES --
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY (token),
|
||||
UNIQUE KEY (sha1_checksum),
|
||||
KEY index_maybe_detected_model_family (maybe_detected_model_family)
|
||||
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
|
||||
@@ -16,4 +16,5 @@ pub mod subscriptions;
|
||||
pub mod user_referral_codes;
|
||||
pub mod users;
|
||||
pub mod utils;
|
||||
pub mod video_info;
|
||||
pub mod web_referrals;
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod notes;
|
||||
pub mod read_only;
|
||||
pub mod upload;
|
||||
@@ -0,0 +1,60 @@
|
||||
//! Request/response types for `POST /v1/video_info/notes`.
|
||||
//!
|
||||
//! A note is user-submitted context about an uploaded video (what model they
|
||||
//! believe it is, where it came from, free-form comments). Always tied to an
|
||||
//! [`UploadedVideoToken`]; pass a [`UploadVideoNoteToken`] to update an existing
|
||||
//! note instead of creating a new one.
|
||||
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use enums::by_table::uploaded_video_notes::uploaded_video_note_reported_model_type::UploadedVideoNoteReportedModelType;
|
||||
use enums::by_table::uploaded_video_notes::uploaded_video_note_reported_website::UploadedVideoNoteReportedWebsite;
|
||||
use tokens::tokens::uploaded_video_notes::UploadVideoNoteToken;
|
||||
use tokens::tokens::uploaded_videos::UploadedVideoToken;
|
||||
|
||||
/// Request body for `POST /v1/video_info/notes`.
|
||||
#[derive(Deserialize, ToSchema, Debug, Clone)]
|
||||
pub struct VideoInfoNoteRequest {
|
||||
/// The uploaded video this note is about. Required.
|
||||
pub uploaded_video_token: UploadedVideoToken,
|
||||
|
||||
/// If present, update this existing note instead of creating a new one.
|
||||
pub maybe_uploaded_video_note_token: Option<UploadVideoNoteToken>,
|
||||
|
||||
/// Original filename of the submitted clip.
|
||||
pub maybe_filename: Option<String>,
|
||||
|
||||
/// The model type the submitter claims this video is (dropdown / enum).
|
||||
pub maybe_reported_model_type: Option<UploadedVideoNoteReportedModelType>,
|
||||
|
||||
/// Free-form custom model name (trimmed; empty becomes absent).
|
||||
pub maybe_reported_model_name: Option<String>,
|
||||
|
||||
/// The website / platform the video came from (dropdown / enum).
|
||||
pub maybe_website: Option<UploadedVideoNoteReportedWebsite>,
|
||||
|
||||
/// Free-form website (trimmed; empty becomes absent).
|
||||
pub maybe_other_website: Option<String>,
|
||||
|
||||
/// Free-form comments (trimmed; empty becomes absent).
|
||||
pub maybe_comments: Option<String>,
|
||||
|
||||
/// Optional contact email (trimmed; empty becomes absent).
|
||||
pub maybe_email_address: Option<String>,
|
||||
|
||||
/// Whether the submitter consents to us sharing the report. Defaults to false.
|
||||
pub can_share_report: Option<bool>,
|
||||
|
||||
/// Whether the submitter believes they were scammed. Defaults to false.
|
||||
pub was_scammed: Option<bool>,
|
||||
}
|
||||
|
||||
/// Response body for `POST /v1/video_info/notes`.
|
||||
#[derive(Serialize, Deserialize, ToSchema, Debug, Clone)]
|
||||
pub struct VideoInfoNoteResponse {
|
||||
pub success: bool,
|
||||
|
||||
/// The created or updated note's token (e.g. `uvnote_…`).
|
||||
pub uploaded_video_note_token: UploadVideoNoteToken,
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
//! Response types for `POST /v1/video_info/read_only`.
|
||||
//!
|
||||
//! The endpoint accepts a `multipart/form-data` video upload, inspects its
|
||||
//! embedded provenance (C2PA manifests, AIGC labels, in-stream watermarks), and
|
||||
//! returns what it found. These are the public, wire-stable API types — they
|
||||
//! deliberately mirror, but do not re-export, the internal `video_info` crate
|
||||
//! types so the parsing library can evolve independently of the HTTP contract.
|
||||
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
/// Response for `POST /v1/video_info/read_only`.
|
||||
///
|
||||
/// [`kind`](Self::kind) names the detected provenance family; the matching
|
||||
/// `maybe_*` field carries its details (all others are `null`). `unrecognized`
|
||||
/// means no supported provenance was found — [`maybe_encoder`](Self::maybe_encoder)
|
||||
/// still reports the container encoder tag when present (an `Lavf…` value means
|
||||
/// the file was re-encoded through ffmpeg, which strips provenance).
|
||||
#[derive(Serialize, Deserialize, ToSchema, Debug, Clone)]
|
||||
pub struct VideoInfoReadOnlyResponse {
|
||||
pub success: bool,
|
||||
|
||||
/// Which provenance family was detected (if any).
|
||||
pub kind: VideoProvenanceKind,
|
||||
|
||||
/// The container `©too` encoder tag, e.g. `"Lavf60.16.100"` or `"Google"`.
|
||||
pub maybe_encoder: Option<String>,
|
||||
|
||||
/// Present when [`kind`](Self::kind) is [`VideoProvenanceKind::Seedance`].
|
||||
pub maybe_seedance: Option<SeedanceVideoInfo>,
|
||||
|
||||
/// Present when [`kind`](Self::kind) is [`VideoProvenanceKind::Veo`].
|
||||
pub maybe_veo: Option<VeoVideoInfo>,
|
||||
|
||||
/// Present when [`kind`](Self::kind) is [`VideoProvenanceKind::Sora`].
|
||||
pub maybe_sora: Option<SoraVideoInfo>,
|
||||
|
||||
/// Present when [`kind`](Self::kind) is [`VideoProvenanceKind::Dreamina`].
|
||||
pub maybe_dreamina: Option<DreaminaVideoInfo>,
|
||||
|
||||
/// Present when [`kind`](Self::kind) is [`VideoProvenanceKind::Kling`].
|
||||
pub maybe_kling: Option<KlingVideoInfo>,
|
||||
}
|
||||
|
||||
/// The recognized AI-video provenance family.
|
||||
#[derive(Serialize, Deserialize, ToSchema, Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum VideoProvenanceKind {
|
||||
/// ByteDance Seedance API (Volcengine / BytePlus C2PA).
|
||||
Seedance,
|
||||
/// Google Veo (Google Generative AI video C2PA).
|
||||
Veo,
|
||||
/// OpenAI Sora (OpenAI Generative AI video C2PA).
|
||||
Sora,
|
||||
/// Dreamina (ByteDance/CapCut consumer app `ilst` metadata).
|
||||
Dreamina,
|
||||
/// Kling (Kuaishou AIGC-label / in-stream watermark).
|
||||
Kling,
|
||||
/// No supported provenance was found (often a re-encoded / stripped file).
|
||||
Unrecognized,
|
||||
}
|
||||
|
||||
/// Seedance (ByteDance) provenance from an embedded C2PA manifest.
|
||||
#[derive(Serialize, Deserialize, ToSchema, Debug, Clone)]
|
||||
pub struct SeedanceVideoInfo {
|
||||
/// `"volcengine"` or `"byteplus"`.
|
||||
pub platform: String,
|
||||
/// C2PA `softwareAgent`, e.g. `"Volcengine_Ark_CN"`.
|
||||
pub software_agent: String,
|
||||
pub maybe_software_agent_version: Option<String>,
|
||||
/// Full model identifier, e.g. `"doubao-seedance-2-0-fast"`.
|
||||
pub model_name: String,
|
||||
pub maybe_model_brand: Option<String>,
|
||||
/// Parsed version, e.g. `"2.0"` / `"1.0"`.
|
||||
pub maybe_model_version: Option<String>,
|
||||
pub is_fast: bool,
|
||||
pub is_lite: bool,
|
||||
/// Raw generation timestamp string (RFC 3339).
|
||||
pub generated_at: String,
|
||||
/// Normalized RFC 3339 timestamp, when parseable.
|
||||
pub maybe_generated_at_utc: Option<String>,
|
||||
pub maybe_log_id: Option<String>,
|
||||
pub maybe_log_id_decoded_hex: Option<String>,
|
||||
pub maybe_digital_source_type: Option<String>,
|
||||
pub maybe_claim_generator: Option<String>,
|
||||
pub maybe_claim_generator_version: Option<String>,
|
||||
pub maybe_manifest_id: Option<String>,
|
||||
pub maybe_instance_id: Option<String>,
|
||||
pub maybe_signer_email: Option<String>,
|
||||
pub maybe_signer_org_id: Option<String>,
|
||||
pub maybe_signer_country: Option<String>,
|
||||
pub maybe_cert_serial: Option<String>,
|
||||
}
|
||||
|
||||
/// Google Veo provenance.
|
||||
#[derive(Serialize, Deserialize, ToSchema, Debug, Clone)]
|
||||
pub struct VeoVideoInfo {
|
||||
/// Always `"Google"`.
|
||||
pub producer: String,
|
||||
/// Whether a full Google C2PA manifest is present (`false` = recognized only
|
||||
/// by the `encoder=Google` tag, C2PA likely stripped by a re-encode).
|
||||
pub has_c2pa_manifest: bool,
|
||||
pub maybe_encoder: Option<String>,
|
||||
pub maybe_created_description: Option<String>,
|
||||
pub has_synthid_watermark: bool,
|
||||
pub maybe_synthid_description: Option<String>,
|
||||
pub maybe_digital_source_type: Option<String>,
|
||||
pub maybe_claim_generator: Option<String>,
|
||||
pub maybe_claim_generator_version: Option<String>,
|
||||
pub maybe_manifest_id: Option<String>,
|
||||
pub maybe_instance_id: Option<String>,
|
||||
pub maybe_cert_serial: Option<String>,
|
||||
/// Issuing intermediate-CA common name, e.g. `"Google C2PA Media Services 1P ICA G3"`.
|
||||
pub maybe_signer_ca: Option<String>,
|
||||
/// Whether the manifest carries an RFC 3161 trusted timestamp.
|
||||
pub is_timestamped: bool,
|
||||
pub maybe_timestamp_authority: Option<String>,
|
||||
}
|
||||
|
||||
/// OpenAI Sora provenance.
|
||||
#[derive(Serialize, Deserialize, ToSchema, Debug, Clone)]
|
||||
pub struct SoraVideoInfo {
|
||||
/// Always `"OpenAI"`.
|
||||
pub producer: String,
|
||||
pub maybe_model_name: Option<String>,
|
||||
pub maybe_created_description: Option<String>,
|
||||
pub maybe_digital_source_type: Option<String>,
|
||||
pub maybe_claim_generator: Option<String>,
|
||||
pub maybe_manifest_id: Option<String>,
|
||||
pub maybe_instance_id: Option<String>,
|
||||
pub maybe_cert_serial: Option<String>,
|
||||
}
|
||||
|
||||
/// Dreamina (ByteDance/CapCut app) provenance from `ilst` metadata.
|
||||
#[derive(Serialize, Deserialize, ToSchema, Debug, Clone)]
|
||||
pub struct DreaminaVideoInfo {
|
||||
/// The product string, e.g. `"dreamina"`.
|
||||
pub product: String,
|
||||
pub maybe_export_type: Option<String>,
|
||||
pub maybe_os: Option<String>,
|
||||
pub maybe_source_info: Option<String>,
|
||||
pub maybe_aigc_label_type: Option<i64>,
|
||||
pub maybe_video_id: Option<String>,
|
||||
pub has_c2pa: bool,
|
||||
pub maybe_signer_org_id: Option<String>,
|
||||
pub maybe_signer_country: Option<String>,
|
||||
pub maybe_cert_serial: Option<String>,
|
||||
}
|
||||
|
||||
/// Kling (Kuaishou) provenance from the AIGC label and/or in-stream watermark.
|
||||
#[derive(Serialize, Deserialize, ToSchema, Debug, Clone)]
|
||||
pub struct KlingVideoInfo {
|
||||
/// Model version when a clean export embeds it (e.g. `"2.5-pro"`); usually `null`.
|
||||
pub maybe_model_version: Option<String>,
|
||||
/// Whether labeled AI-generated (`Label == "1"`, per China GB 45438).
|
||||
pub is_ai_generated: bool,
|
||||
pub maybe_label: Option<String>,
|
||||
pub maybe_content_producer: Option<String>,
|
||||
pub maybe_produce_id: Option<String>,
|
||||
pub maybe_content_propagator: Option<String>,
|
||||
pub maybe_propagate_id: Option<String>,
|
||||
pub maybe_reserved_code_1: Option<String>,
|
||||
pub maybe_reserved_code_2: Option<String>,
|
||||
/// Whether the in-stream `kling-ai` SEI watermark is present.
|
||||
pub has_stream_watermark: bool,
|
||||
pub maybe_watermark_uuid: Option<String>,
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
//! Response type for `POST /v1/video_info/upload`.
|
||||
//!
|
||||
//! Same detected provenance as the read-only endpoint, plus the persisted
|
||||
//! [`UploadedVideoToken`] (used for follow-up requests, e.g. attaching a note).
|
||||
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use tokens::tokens::uploaded_videos::UploadedVideoToken;
|
||||
|
||||
use crate::video_info::read_only::{
|
||||
DreaminaVideoInfo, KlingVideoInfo, SeedanceVideoInfo, SoraVideoInfo, VeoVideoInfo,
|
||||
VideoProvenanceKind,
|
||||
};
|
||||
|
||||
/// Response for `POST /v1/video_info/upload`. Mirrors
|
||||
/// [`crate::video_info::read_only::VideoInfoReadOnlyResponse`] and adds the
|
||||
/// persisted record's token.
|
||||
#[derive(Serialize, Deserialize, ToSchema, Debug, Clone)]
|
||||
pub struct VideoInfoUploadResponse {
|
||||
pub success: bool,
|
||||
|
||||
/// The persisted `uploaded_videos` record token (e.g. `uvt_…`).
|
||||
pub uploaded_video_token: UploadedVideoToken,
|
||||
|
||||
/// Which provenance family was detected (if any).
|
||||
pub kind: VideoProvenanceKind,
|
||||
|
||||
/// The container `©too` encoder tag, e.g. `"Lavf60.16.100"` or `"Google"`.
|
||||
pub maybe_encoder: Option<String>,
|
||||
|
||||
/// Present when [`kind`](Self::kind) is [`VideoProvenanceKind::Seedance`].
|
||||
pub maybe_seedance: Option<SeedanceVideoInfo>,
|
||||
|
||||
/// Present when [`kind`](Self::kind) is [`VideoProvenanceKind::Veo`].
|
||||
pub maybe_veo: Option<VeoVideoInfo>,
|
||||
|
||||
/// Present when [`kind`](Self::kind) is [`VideoProvenanceKind::Sora`].
|
||||
pub maybe_sora: Option<SoraVideoInfo>,
|
||||
|
||||
/// Present when [`kind`](Self::kind) is [`VideoProvenanceKind::Dreamina`].
|
||||
pub maybe_dreamina: Option<DreaminaVideoInfo>,
|
||||
|
||||
/// Present when [`kind`](Self::kind) is [`VideoProvenanceKind::Kling`].
|
||||
pub maybe_kling: Option<KlingVideoInfo>,
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "bucket_client"
|
||||
edition = "2024"
|
||||
version = "0.0.1"
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
name = "bucket_client"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[package.metadata.cargo-udeps.ignore]
|
||||
normal = ["workspace-build-acceleration"]
|
||||
|
||||
[dependencies]
|
||||
# cargo-hakari workspace hack (faster compile times)
|
||||
workspace-build-acceleration.workspace = true
|
||||
|
||||
# External
|
||||
rust-s3.workspace = true
|
||||
@@ -0,0 +1,66 @@
|
||||
use s3::Bucket;
|
||||
|
||||
use crate::bucket_client_error::BucketClientError;
|
||||
use crate::object_name::ObjectName;
|
||||
|
||||
/// Arguments for [`BucketClient::upload_file_bytes`].
|
||||
pub struct UploadFileBytesArgs<'a, S: AsRef<str>> {
|
||||
/// The destination object key (normalized via [`ObjectName`]).
|
||||
pub object_name: S,
|
||||
/// The raw bytes to upload.
|
||||
pub bytes: &'a [u8],
|
||||
/// Optional MIME type. Defaults to `application/octet-stream` when `None`.
|
||||
pub content_type: Option<&'a str>,
|
||||
}
|
||||
|
||||
/// A client for a single S3-compatible bucket. Cheap to clone. Build one with
|
||||
/// [`crate::BucketClientBuilder`].
|
||||
#[derive(Clone)]
|
||||
pub struct BucketClient {
|
||||
bucket: Bucket,
|
||||
}
|
||||
|
||||
impl BucketClient {
|
||||
/// Wrap a fully-configured `Bucket`. Internal — use
|
||||
/// [`crate::BucketClientBuilder`] to construct a client.
|
||||
pub(crate) fn from_bucket(bucket: Bucket) -> Self {
|
||||
Self { bucket }
|
||||
}
|
||||
|
||||
/// The name of the bucket this client targets.
|
||||
pub fn bucket_name(&self) -> &str {
|
||||
self.bucket.name.as_str()
|
||||
}
|
||||
|
||||
/// Upload raw bytes to `object_name`, optionally with an explicit content type.
|
||||
///
|
||||
/// Returns [`BucketClientError::UploadFailed`] if the store responds with a
|
||||
/// non-2xx status, or [`BucketClientError::S3Error`] on a transport/protocol
|
||||
/// error.
|
||||
pub async fn upload_file_bytes<S: AsRef<str>>(
|
||||
&self,
|
||||
args: UploadFileBytesArgs<'_, S>,
|
||||
) -> Result<(), BucketClientError> {
|
||||
let object_name = ObjectName::new(&args.object_name);
|
||||
|
||||
let response = match args.content_type {
|
||||
Some(content_type) => {
|
||||
self
|
||||
.bucket
|
||||
.put_object_with_content_type(object_name.as_str(), args.bytes, content_type)
|
||||
.await?
|
||||
}
|
||||
None => self.bucket.put_object(object_name.as_str(), args.bytes).await?,
|
||||
};
|
||||
|
||||
let status_code = response.status_code();
|
||||
if !(200..300).contains(&status_code) {
|
||||
return Err(BucketClientError::UploadFailed {
|
||||
status_code,
|
||||
message: String::from_utf8_lossy(response.bytes()).into_owned(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use s3::creds::Credentials;
|
||||
use s3::region::Region;
|
||||
use s3::Bucket;
|
||||
|
||||
use crate::bucket_client::BucketClient;
|
||||
use crate::bucket_client_error::BucketClientError;
|
||||
|
||||
/// Builder for [`BucketClient`].
|
||||
///
|
||||
/// `access_key`, `secret_key`, `region_name`, and `bucket_name` are required;
|
||||
/// `endpoint` and `bucket_request_timeout` are optional.
|
||||
///
|
||||
/// - With an `endpoint` set, the bucket targets a custom S3-compatible host
|
||||
/// (R2, GCS, MinIO, etc.) via `Region::Custom`, and uses path-style addressing
|
||||
/// (subdomain-style for Google Cloud Storage).
|
||||
/// - Without an `endpoint`, the `region_name` is parsed as an AWS region
|
||||
/// (e.g. `"us-east-1"`), which determines the AWS endpoint automatically.
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct BucketClientBuilder {
|
||||
access_key: Option<String>,
|
||||
secret_key: Option<String>,
|
||||
region_name: Option<String>,
|
||||
bucket_name: Option<String>,
|
||||
endpoint: Option<String>,
|
||||
bucket_request_timeout: Option<Duration>,
|
||||
}
|
||||
|
||||
impl BucketClientBuilder {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn access_key(mut self, access_key: impl Into<String>) -> Self {
|
||||
self.access_key = Some(access_key.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn secret_key(mut self, secret_key: impl Into<String>) -> Self {
|
||||
self.secret_key = Some(secret_key.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn region_name(mut self, region_name: impl Into<String>) -> Self {
|
||||
self.region_name = Some(region_name.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn bucket_name(mut self, bucket_name: impl Into<String>) -> Self {
|
||||
self.bucket_name = Some(bucket_name.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Optional custom S3-compatible endpoint (e.g. an R2/GCS/MinIO URL). When
|
||||
/// unset, an AWS endpoint is derived from `region_name`.
|
||||
pub fn endpoint(mut self, endpoint: impl Into<String>) -> Self {
|
||||
self.endpoint = Some(endpoint.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn bucket_request_timeout(mut self, timeout: Duration) -> Self {
|
||||
self.bucket_request_timeout = Some(timeout);
|
||||
self
|
||||
}
|
||||
|
||||
/// Validate the configuration and construct a [`BucketClient`].
|
||||
pub fn build(self) -> Result<BucketClient, BucketClientError> {
|
||||
let access_key = require(self.access_key, "access_key")?;
|
||||
let secret_key = require(self.secret_key, "secret_key")?;
|
||||
let region_name = require(self.region_name, "region_name")?;
|
||||
let bucket_name = require(self.bucket_name, "bucket_name")?;
|
||||
|
||||
let credentials = Credentials {
|
||||
access_key: Some(access_key),
|
||||
secret_key: Some(secret_key),
|
||||
security_token: None,
|
||||
session_token: None,
|
||||
expiration: None,
|
||||
};
|
||||
|
||||
// A custom endpoint targets an S3-compatible host via `Region::Custom`;
|
||||
// otherwise the region name is parsed as a standard AWS region.
|
||||
let region = match self.endpoint.as_deref() {
|
||||
Some(endpoint) => Region::Custom {
|
||||
region: region_name.clone(),
|
||||
endpoint: endpoint.to_string(),
|
||||
},
|
||||
None => region_name.parse().map_err(|err| {
|
||||
BucketClientError::ClientBuilderSetupError(format!(
|
||||
"invalid region {region_name:?}: {err:?}"
|
||||
))
|
||||
})?,
|
||||
};
|
||||
|
||||
let mut bucket = Bucket::new(&bucket_name, region, credentials)?;
|
||||
bucket.set_request_timeout(self.bucket_request_timeout);
|
||||
|
||||
// Addressing style only matters for custom endpoints: Google Cloud Storage
|
||||
// needs subdomain (virtual-hosted) style; other S3-compatible stores use
|
||||
// path style. AWS uses the crate default (virtual-hosted).
|
||||
if let Some(endpoint) = self.endpoint.as_deref() {
|
||||
if endpoint == "https://storage.googleapis.com" {
|
||||
bucket.set_subdomain_style();
|
||||
} else {
|
||||
bucket.set_path_style();
|
||||
}
|
||||
}
|
||||
|
||||
Ok(BucketClient::from_bucket(*bucket))
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a required builder field, or report which one was missing.
|
||||
fn require(value: Option<String>, field: &str) -> Result<String, BucketClientError> {
|
||||
value.ok_or_else(|| {
|
||||
BucketClientError::ClientBuilderSetupError(format!("{field} is required"))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
use std::fmt;
|
||||
use std::io;
|
||||
|
||||
/// Errors produced by [`crate::BucketClient`] and [`crate::BucketClientBuilder`].
|
||||
#[derive(Debug)]
|
||||
pub enum BucketClientError {
|
||||
/// An error surfaced by the underlying `rust-s3` client.
|
||||
S3Error(s3::error::S3Error),
|
||||
|
||||
/// A local I/O error (e.g. while reading bytes to upload).
|
||||
IoError(io::Error),
|
||||
|
||||
/// The builder was misconfigured — a required field was missing, or client
|
||||
/// construction failed for a reason we can describe.
|
||||
ClientBuilderSetupError(String),
|
||||
|
||||
/// The object store accepted the request but returned a non-success status.
|
||||
UploadFailed { status_code: u16, message: String },
|
||||
}
|
||||
|
||||
impl fmt::Display for BucketClientError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::S3Error(err) => write!(f, "S3 error: {}", err),
|
||||
Self::IoError(err) => write!(f, "I/O error: {}", err),
|
||||
Self::ClientBuilderSetupError(message) => {
|
||||
write!(f, "bucket client setup error: {}", message)
|
||||
}
|
||||
Self::UploadFailed { status_code, message } => {
|
||||
write!(f, "upload failed (HTTP {}): {}", status_code, message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for BucketClientError {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
match self {
|
||||
Self::S3Error(err) => Some(err),
|
||||
Self::IoError(err) => Some(err),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<s3::error::S3Error> for BucketClientError {
|
||||
fn from(err: s3::error::S3Error) -> Self {
|
||||
Self::S3Error(err)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<io::Error> for BucketClientError {
|
||||
fn from(err: io::Error) -> Self {
|
||||
Self::IoError(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
//! bucket_client
|
||||
//!
|
||||
//! A small, ergonomic client for S3-compatible object storage, built on the
|
||||
//! `rust-s3` crate. Construct one with [`BucketClientBuilder`] and upload bytes
|
||||
//! with [`BucketClient::upload_file_bytes`].
|
||||
|
||||
pub mod bucket_client;
|
||||
pub mod bucket_client_builder;
|
||||
pub mod bucket_client_error;
|
||||
pub mod object_name;
|
||||
|
||||
pub use bucket_client::{BucketClient, UploadFileBytesArgs};
|
||||
pub use bucket_client_builder::BucketClientBuilder;
|
||||
pub use bucket_client_error::BucketClientError;
|
||||
pub use object_name::ObjectName;
|
||||
@@ -0,0 +1,52 @@
|
||||
use std::fmt;
|
||||
|
||||
/// A normalized object key for the bucket.
|
||||
///
|
||||
/// Leading slashes are stripped: S3/R2 treat a leading `/` as an empty leading
|
||||
/// path segment, which silently produces surprising keys (e.g. `/foo.mp4`
|
||||
/// becomes an object literally named `/foo.mp4`). Normalizing here keeps stored
|
||||
/// keys consistent regardless of how callers format the name.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ObjectName(String);
|
||||
|
||||
impl ObjectName {
|
||||
/// Build a normalized object name from any string-like value.
|
||||
pub fn new(raw: impl AsRef<str>) -> Self {
|
||||
ObjectName(raw.as_ref().trim_start_matches('/').to_string())
|
||||
}
|
||||
|
||||
/// The normalized key as a string slice.
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ObjectName {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<str> for ObjectName {
|
||||
fn as_ref(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn strips_leading_slashes() {
|
||||
assert_eq!(ObjectName::new("/foo/bar.mp4").as_str(), "foo/bar.mp4");
|
||||
assert_eq!(ObjectName::new("///a").as_str(), "a");
|
||||
assert_eq!(ObjectName::new("already/clean.txt").as_str(), "already/clean.txt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_string_and_str() {
|
||||
assert_eq!(ObjectName::new(String::from("x.bin")).as_str(), "x.bin");
|
||||
assert_eq!(ObjectName::new("x.bin").as_str(), "x.bin");
|
||||
}
|
||||
}
|
||||
+6
-6
@@ -17,7 +17,7 @@ use tokio::io::AsyncReadExt;
|
||||
use errors::AnyhowResult;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct BucketClient {
|
||||
pub struct LegacyBucketClient {
|
||||
bucket: Bucket,
|
||||
/// If set, put all files under this root path.
|
||||
optional_bucket_root: Option<String>,
|
||||
@@ -25,21 +25,21 @@ pub struct BucketClient {
|
||||
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum BucketClientError {
|
||||
pub enum LegacyBucketClientError {
|
||||
ErrorWithCodeAndMessage { code: u16, message: String },
|
||||
}
|
||||
|
||||
impl std::fmt::Display for BucketClientError {
|
||||
impl std::fmt::Display for LegacyBucketClientError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
match self {
|
||||
BucketClientError::ErrorWithCodeAndMessage { code, message } => write!(f, "Error {}: {}", code, message),
|
||||
LegacyBucketClientError::ErrorWithCodeAndMessage { code, message } => write!(f, "Error {}: {}", code, message),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Error for BucketClientError {}
|
||||
impl Error for LegacyBucketClientError {}
|
||||
|
||||
|
||||
impl BucketClient {
|
||||
impl LegacyBucketClient {
|
||||
pub fn bucket_name(&self) -> String {
|
||||
self.bucket.name().to_string()
|
||||
}
|
||||
@@ -1,2 +1,2 @@
|
||||
pub mod bucket_client;
|
||||
pub mod legacy_bucket_client;
|
||||
pub mod remote_file_manager;
|
||||
@@ -4,7 +4,7 @@ use tokio::time::Duration;
|
||||
|
||||
use errors::AnyhowResult;
|
||||
|
||||
use crate::bucket_client::BucketClient;
|
||||
use crate::legacy_bucket_client::LegacyBucketClient;
|
||||
|
||||
#[async_trait]
|
||||
#[deprecated(note="this abstraction is too complicated, use another bucket client")]
|
||||
@@ -106,13 +106,13 @@ impl BucketOrchestration {
|
||||
}
|
||||
|
||||
#[deprecated(note="this abstraction is too complicated, use another bucket client")]
|
||||
async fn get_bucket_with_visibility(&self, public: bool) -> AnyhowResult<BucketClient> {
|
||||
async fn get_bucket_with_visibility(&self, public: bool) -> AnyhowResult<LegacyBucketClient> {
|
||||
let bucket_timeout = easyenv::get_env_duration_seconds_or_default(
|
||||
"BUCKET_TIMEOUT_SECONDS", Duration::from_secs(60 * 10));
|
||||
let bucket_client = if public {
|
||||
// use public bucket client
|
||||
info!("Configuring public GCS bucket...");
|
||||
BucketClient::create(
|
||||
LegacyBucketClient::create(
|
||||
&self.access_key,
|
||||
&self.secret_key,
|
||||
&self.region_name,
|
||||
@@ -123,7 +123,7 @@ impl BucketOrchestration {
|
||||
)?
|
||||
} else {
|
||||
info!("Configuring private GCS bucket...");
|
||||
BucketClient::create(
|
||||
LegacyBucketClient::create(
|
||||
&self.access_key,
|
||||
&self.secret_key,
|
||||
&self.region_name,
|
||||
|
||||
+6
-6
@@ -20,7 +20,7 @@ pub struct RemoteCloudFileClient {
|
||||
|
||||
impl RemoteCloudFileClient {
|
||||
|
||||
#[deprecated(note="DO NOT USE THIS AWFUL CLOUD BUCKET CLIENT. These abstractions are complicated. See `BucketClient`")]
|
||||
#[deprecated(note="DO NOT USE THIS AWFUL CLOUD BUCKET CLIENT. These abstractions are complicated. See `LegacyBucketClient`")]
|
||||
pub async fn get_remote_cloud_file_client() -> AnyhowResult<Self> {
|
||||
let bucket_orchestration = match BucketOrchestration::new_bucket_client_from_existing_env() {
|
||||
Ok(client) => client,
|
||||
@@ -34,7 +34,7 @@ impl RemoteCloudFileClient {
|
||||
})
|
||||
}
|
||||
|
||||
#[deprecated(note="DO NOT USE THIS AWFUL CLOUD BUCKET CLIENT. These abstractions are complicated. See `BucketClient`")]
|
||||
#[deprecated(note="DO NOT USE THIS AWFUL CLOUD BUCKET CLIENT. These abstractions are complicated. See `LegacyBucketClient`")]
|
||||
pub fn new(bucket_orchestration_client: Box<dyn BucketOrchestrationCore>) -> Self {
|
||||
Self {
|
||||
bucket_orchestration_client
|
||||
@@ -42,7 +42,7 @@ impl RemoteCloudFileClient {
|
||||
}
|
||||
|
||||
// Where the to_system_file_path is let sd_checkpoint_path = work_temp_dir.path().join("sd_checkpoint.safetensors"); path and file name and extension
|
||||
#[deprecated(note="DO NOT USE THIS AWFUL CLOUD BUCKET CLIENT. These abstractions are complicated. See `BucketClient`")]
|
||||
#[deprecated(note="DO NOT USE THIS AWFUL CLOUD BUCKET CLIENT. These abstractions are complicated. See `LegacyBucketClient`")]
|
||||
pub async fn download_file(&self, remote_cloud_bucket_details:RemoteCloudBucketDetails, to_system_file_path:String) -> AnyhowResult<()> {
|
||||
let file_descriptor = remote_cloud_bucket_details.file_descriptor_from_bucket_details();
|
||||
let file_bucket_directory = FileBucketDirectory::from_existing_bucket_details(remote_cloud_bucket_details);
|
||||
@@ -53,7 +53,7 @@ impl RemoteCloudFileClient {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[deprecated(note="DO NOT USE THIS AWFUL CLOUD BUCKET CLIENT. These abstractions are complicated. See `BucketClient`")]
|
||||
#[deprecated(note="DO NOT USE THIS AWFUL CLOUD BUCKET CLIENT. These abstractions are complicated. See `LegacyBucketClient`")]
|
||||
pub async fn download_media_file(&self, media_file_path: &MediaFileBucketPath, to_system_file_path: String) -> AnyhowResult<()> {
|
||||
const IS_PUBLIC: bool = true; // NB: Media files are always public
|
||||
let full_remote_cloud_file_path = media_file_path.get_full_object_path_str().to_string();
|
||||
@@ -63,7 +63,7 @@ impl RemoteCloudFileClient {
|
||||
}
|
||||
|
||||
// return error or success with meta data.
|
||||
#[deprecated(note="DO NOT USE THIS AWFUL CLOUD BUCKET CLIENT. These abstractions are complicated. See `BucketClient`")]
|
||||
#[deprecated(note="DO NOT USE THIS AWFUL CLOUD BUCKET CLIENT. These abstractions are complicated. See `LegacyBucketClient`")]
|
||||
pub async fn upload_file(&self, file_descriptor:Box<dyn FileDescriptor>, from_system_file_path:&str) -> AnyhowResult<FileMetaData> {
|
||||
|
||||
// get file meta data
|
||||
@@ -99,7 +99,7 @@ impl RemoteCloudFileClient {
|
||||
}
|
||||
|
||||
// Retrieve the metadata from the file
|
||||
#[deprecated(note="DO NOT USE THIS AWFUL CLOUD BUCKET CLIENT. These abstractions are complicated. See `BucketClient`")]
|
||||
#[deprecated(note="DO NOT USE THIS AWFUL CLOUD BUCKET CLIENT. These abstractions are complicated. See `LegacyBucketClient`")]
|
||||
pub fn get_file_meta_data(system_file_path:&str) -> AnyhowResult<FileMetaData> {
|
||||
let file_size_bytes = file_size(system_file_path.clone())?;
|
||||
let sha256_checksum = sha256_hash_file(system_file_path.clone())?;
|
||||
|
||||
+17
@@ -125,6 +125,23 @@ mod tests {
|
||||
Ok(Seedance2ProSession::from_cookies_string(cookies))
|
||||
}
|
||||
|
||||
/// Regression: the Kinovi API started returning `credits` as a float
|
||||
/// (e.g. `372215.18`) while `availableCredits` stays an int. Both must parse
|
||||
/// and coerce to `u64` instead of failing with
|
||||
/// "invalid type: floating point ..., expected u64".
|
||||
#[test]
|
||||
fn float_credits_are_coerced() {
|
||||
let body = r#"[{"result":{"data":{"json":{
|
||||
"email":"hello@storyteller.ai",
|
||||
"credits":372215.18,
|
||||
"availableCredits":372215
|
||||
}}}}]"#;
|
||||
let batch: Vec<BatchResponseItem> = serde_json::from_str(body).expect("parse user auth");
|
||||
let json = batch.into_iter().next().expect("non-empty batch").result.data.json;
|
||||
assert_eq!(json.credits, 372215);
|
||||
assert_eq!(json.available_credits, 372215);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually test — requires real cookies
|
||||
async fn test_get_user_auth_details() -> AnyhowResult<()> {
|
||||
|
||||
+4
@@ -1,5 +1,7 @@
|
||||
use serde_derive::Deserialize;
|
||||
|
||||
use crate::utils::number_coercion::de_u64_int_or_float;
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub(super) struct BatchResponseItem {
|
||||
pub result: BatchResponseResult,
|
||||
@@ -19,6 +21,8 @@ pub(super) struct BatchResponseData {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(super) struct RawUserAuthDetails {
|
||||
pub email: String,
|
||||
#[serde(deserialize_with = "de_u64_int_or_float")]
|
||||
pub credits: u64,
|
||||
#[serde(deserialize_with = "de_u64_int_or_float")]
|
||||
pub available_credits: u64,
|
||||
}
|
||||
|
||||
@@ -457,6 +457,30 @@ mod tests {
|
||||
assert_eq!(orders[0].task_status, TaskStatus::Processing);
|
||||
}
|
||||
|
||||
/// Regression: the Kinovi API started returning result `width`/`height`
|
||||
/// (and `totalCredits`) as floats (e.g. `568.5`). They must parse and
|
||||
/// coerce to unsigned ints instead of failing with
|
||||
/// "invalid type: floating point ..., expected u32".
|
||||
#[test]
|
||||
fn float_dimensions_and_credits_are_coerced() {
|
||||
let body = r#"[{"result":{"data":{"json":{"orders":[{
|
||||
"orderId":"ord_float",
|
||||
"resultUrl":"https://example.com/v.mp4",
|
||||
"taskStatus":"COMPLETED",
|
||||
"results":[{"url":"https://example.com/v.mp4","width":568.5,"height":1023.4}],
|
||||
"failReason":null,
|
||||
"createdAt":"2026-02-19T01:20:50.398Z",
|
||||
"mediaType":"video",
|
||||
"totalCredits":12.9
|
||||
}],"nextCursor":394062.0}}}}]"#;
|
||||
let orders = parse_orders(body);
|
||||
assert_eq!(orders.len(), 1);
|
||||
assert_eq!(orders[0].results.len(), 1);
|
||||
assert_eq!(orders[0].results[0].width, 569);
|
||||
assert_eq!(orders[0].results[0].height, 1023);
|
||||
assert_eq!(orders[0].total_credits, Some(13));
|
||||
}
|
||||
|
||||
/// Unrecognised mediaType values shouldn't crash deserialisation;
|
||||
/// they end up as `Unknown(_)`.
|
||||
#[test]
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
use serde_derive::Deserialize;
|
||||
|
||||
use crate::utils::number_coercion::{
|
||||
de_opt_u32_int_or_float, de_opt_u64_int_or_float, de_u32_int_or_float,
|
||||
};
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub(super) struct BatchResponseItem {
|
||||
pub result: BatchResponseResult,
|
||||
@@ -19,6 +23,7 @@ pub(super) struct BatchResponseData {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(super) struct OrdersResponseJson {
|
||||
pub orders: Vec<RawOrder>,
|
||||
#[serde(default, deserialize_with = "de_opt_u64_int_or_float")]
|
||||
pub next_cursor: Option<u64>,
|
||||
}
|
||||
|
||||
@@ -39,14 +44,16 @@ pub(super) struct RawOrder {
|
||||
|
||||
/// The Kinovi credits charged for the order. `None` for older response
|
||||
/// shapes that didn't include the field.
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "de_opt_u32_int_or_float")]
|
||||
pub total_credits: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub(super) struct RawVideoResult {
|
||||
pub url: String,
|
||||
#[serde(deserialize_with = "de_u32_int_or_float")]
|
||||
pub width: u32,
|
||||
#[serde(deserialize_with = "de_u32_int_or_float")]
|
||||
pub height: u32,
|
||||
// pub ratio: Option<f64>,
|
||||
}
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
pub mod categorize_seedance2pro_error;
|
||||
pub mod common_headers;
|
||||
pub mod number_coercion;
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
//! Deserialization helpers for numeric fields that the Kinovi API has started
|
||||
//! returning as floating-point values where it previously returned integers
|
||||
//! (e.g. `width: 568.5`, `credits: 372215.18`).
|
||||
//!
|
||||
//! Each helper accepts EITHER a JSON integer or a JSON float and coerces it to
|
||||
//! an unsigned integer: exact integers pass through unchanged (no precision
|
||||
//! loss for large `u64` credit balances), and floats are rounded to the nearest
|
||||
//! integer. Negative / non-finite values clamp to `0`.
|
||||
|
||||
use serde::{Deserialize, Deserializer};
|
||||
use serde_json::Number;
|
||||
|
||||
/// Coerce a parsed JSON number to `u64`. Exact non-negative integers pass
|
||||
/// through; floats are rounded; anything negative / non-finite becomes `0`.
|
||||
fn coerce_to_u64(number: &Number) -> u64 {
|
||||
if let Some(unsigned) = number.as_u64() {
|
||||
return unsigned;
|
||||
}
|
||||
if let Some(float) = number.as_f64() {
|
||||
if float.is_finite() && float >= 0.0 {
|
||||
return float.round() as u64;
|
||||
}
|
||||
}
|
||||
0
|
||||
}
|
||||
|
||||
fn coerce_to_u32(number: &Number) -> u32 {
|
||||
coerce_to_u64(number).min(u32::MAX as u64) as u32
|
||||
}
|
||||
|
||||
/// `#[serde(deserialize_with = "...")]` for a required `u64` field that may
|
||||
/// arrive as an integer or a float.
|
||||
pub fn de_u64_int_or_float<'de, D>(deserializer: D) -> Result<u64, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let number = Number::deserialize(deserializer)?;
|
||||
Ok(coerce_to_u64(&number))
|
||||
}
|
||||
|
||||
/// `#[serde(deserialize_with = "...")]` for an optional `u64` field (handles
|
||||
/// `null` / missing as `None`).
|
||||
pub fn de_opt_u64_int_or_float<'de, D>(deserializer: D) -> Result<Option<u64>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let maybe_number = Option::<Number>::deserialize(deserializer)?;
|
||||
Ok(maybe_number.as_ref().map(coerce_to_u64))
|
||||
}
|
||||
|
||||
/// `#[serde(deserialize_with = "...")]` for an optional `u32` field (handles
|
||||
/// `null` / missing as `None`).
|
||||
pub fn de_opt_u32_int_or_float<'de, D>(deserializer: D) -> Result<Option<u32>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let maybe_number = Option::<Number>::deserialize(deserializer)?;
|
||||
Ok(maybe_number.as_ref().map(coerce_to_u32))
|
||||
}
|
||||
|
||||
/// `#[serde(deserialize_with = "...")]` for a required `u32` field that may
|
||||
/// arrive as an integer or a float.
|
||||
pub fn de_u32_int_or_float<'de, D>(deserializer: D) -> Result<u32, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let number = Number::deserialize(deserializer)?;
|
||||
Ok(coerce_to_u32(&number))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct U32Holder {
|
||||
#[serde(deserialize_with = "de_u32_int_or_float")]
|
||||
value: u32,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct U64Holder {
|
||||
#[serde(deserialize_with = "de_u64_int_or_float")]
|
||||
value: u64,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct OptU32Holder {
|
||||
#[serde(default, deserialize_with = "de_opt_u32_int_or_float")]
|
||||
value: Option<u32>,
|
||||
}
|
||||
|
||||
fn parse_u32(json: &str) -> u32 {
|
||||
serde_json::from_str::<U32Holder>(json).unwrap().value
|
||||
}
|
||||
|
||||
fn parse_u64(json: &str) -> u64 {
|
||||
serde_json::from_str::<U64Holder>(json).unwrap().value
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn u32_accepts_integer() {
|
||||
assert_eq!(parse_u32(r#"{"value": 568}"#), 568);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn u32_accepts_float_and_rounds() {
|
||||
assert_eq!(parse_u32(r#"{"value": 568.5}"#), 569);
|
||||
assert_eq!(parse_u32(r#"{"value": 568.4}"#), 568);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn u64_accepts_integer_exactly() {
|
||||
// Large value that would lose precision via f64 — must pass through exact.
|
||||
assert_eq!(parse_u64(r#"{"value": 372215}"#), 372215);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn u64_accepts_float_and_rounds() {
|
||||
assert_eq!(parse_u64(r#"{"value": 372215.18}"#), 372215);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_and_nonfinite_clamp_to_zero() {
|
||||
assert_eq!(parse_u64(r#"{"value": -5}"#), 0);
|
||||
assert_eq!(parse_u64(r#"{"value": -5.5}"#), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn optional_handles_null_missing_and_values() {
|
||||
assert_eq!(serde_json::from_str::<OptU32Holder>(r#"{"value": null}"#).unwrap().value, None);
|
||||
assert_eq!(serde_json::from_str::<OptU32Holder>(r#"{}"#).unwrap().value, None);
|
||||
assert_eq!(serde_json::from_str::<OptU32Holder>(r#"{"value": 12.9}"#).unwrap().value, Some(13));
|
||||
assert_eq!(serde_json::from_str::<OptU32Holder>(r#"{"value": 12}"#).unwrap().value, Some(12));
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,12 @@ use std::time::Duration;
|
||||
|
||||
use log::info;
|
||||
|
||||
use cloud_storage::bucket_client::BucketClient;
|
||||
use cloud_storage::legacy_bucket_client::LegacyBucketClient;
|
||||
use errors::AnyhowResult;
|
||||
|
||||
pub struct BucketClients {
|
||||
pub public: BucketClient,
|
||||
pub private: BucketClient,
|
||||
pub public: LegacyBucketClient,
|
||||
pub private: LegacyBucketClient,
|
||||
}
|
||||
|
||||
pub fn get_bucket_clients() -> AnyhowResult<BucketClients> {
|
||||
@@ -25,7 +25,7 @@ pub fn get_bucket_clients() -> AnyhowResult<BucketClients> {
|
||||
|
||||
info!("Configuring public GCS bucket...");
|
||||
|
||||
let public_bucket_client = BucketClient::create(
|
||||
let public_bucket_client = LegacyBucketClient::create(
|
||||
&access_key,
|
||||
&secret_key,
|
||||
®ion_name,
|
||||
@@ -37,7 +37,7 @@ pub fn get_bucket_clients() -> AnyhowResult<BucketClients> {
|
||||
|
||||
info!("Configuring private GCS bucket...");
|
||||
|
||||
let private_bucket_client = BucketClient::create(
|
||||
let private_bucket_client = LegacyBucketClient::create(
|
||||
&access_key,
|
||||
&secret_key,
|
||||
®ion_name,
|
||||
|
||||
@@ -2,7 +2,7 @@ use log::info;
|
||||
use sqlx::{MySql, Pool};
|
||||
|
||||
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
|
||||
use cloud_storage::bucket_client::BucketClient;
|
||||
use cloud_storage::legacy_bucket_client::LegacyBucketClient;
|
||||
use enums::by_table::media_files::media_file_origin_category::MediaFileOriginCategory;
|
||||
use enums::by_table::media_uploads::media_upload_type::MediaUploadType;
|
||||
use enums::by_table::zs_voices::encoding_type::ZsVoiceEncodingType;
|
||||
@@ -128,7 +128,7 @@ async fn create_voice_records(
|
||||
Ok((voice_token, dataset_token))
|
||||
}
|
||||
|
||||
async fn seed_file_to_bucket(wav_file: &str, bucket_client: &BucketClient) -> AnyhowResult<MediaFileBucketPath> {
|
||||
async fn seed_file_to_bucket(wav_file: &str, bucket_client: &LegacyBucketClient) -> AnyhowResult<MediaFileBucketPath> {
|
||||
info!("Uploading wav file {} ...", wav_file);
|
||||
|
||||
let public_upload_path = MediaFileBucketPath::generate_new(
|
||||
|
||||
@@ -4,7 +4,7 @@ use log::info;
|
||||
use sqlx::mysql::MySqlPoolOptions;
|
||||
|
||||
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
|
||||
use cloud_storage::bucket_client::BucketClient;
|
||||
use cloud_storage::legacy_bucket_client::LegacyBucketClient;
|
||||
use shared_env_var_config::logging::DEFAULT_RUST_LOG;
|
||||
use shared_env_var_config::mysql::env_get_mysql_connection_string_or_default;
|
||||
use enums::by_table::media_files::media_file_type::MediaFileType;
|
||||
@@ -48,7 +48,7 @@ pub async fn main() -> AnyhowResult<()> {
|
||||
|
||||
info!("Configuring GCS bucket...");
|
||||
|
||||
let public_bucket_client = BucketClient::create(
|
||||
let public_bucket_client = LegacyBucketClient::create(
|
||||
&access_key,
|
||||
&secret_key,
|
||||
®ion_name,
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
use sqlx::{MySql, Pool};
|
||||
|
||||
use cloud_storage::bucket_client::BucketClient;
|
||||
use cloud_storage::legacy_bucket_client::LegacyBucketClient;
|
||||
|
||||
pub struct Deps {
|
||||
pub mysql_development : Pool<MySql>,
|
||||
pub mysql_production : Pool<MySql>,
|
||||
|
||||
pub bucket_development_public: BucketClient,
|
||||
pub bucket_development_private: BucketClient,
|
||||
pub bucket_development_public: LegacyBucketClient,
|
||||
pub bucket_development_private: LegacyBucketClient,
|
||||
|
||||
pub bucket_production_public: BucketClient,
|
||||
pub bucket_production_private: BucketClient,
|
||||
pub bucket_production_public: LegacyBucketClient,
|
||||
pub bucket_production_private: LegacyBucketClient,
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ use log::info;
|
||||
use sqlx::{MySql, Pool};
|
||||
use sqlx::mysql::MySqlPoolOptions;
|
||||
|
||||
use cloud_storage::bucket_client::BucketClient;
|
||||
use cloud_storage::legacy_bucket_client::LegacyBucketClient;
|
||||
use shared_env_var_config::logging::DEFAULT_RUST_LOG;
|
||||
use errors::AnyhowResult;
|
||||
|
||||
@@ -68,7 +68,7 @@ async fn get_mysql(env_var_name: &str) -> AnyhowResult<Pool<MySql>> {
|
||||
Ok(pool)
|
||||
}
|
||||
|
||||
pub fn get_bucket_client(env_var_suffix: &str) -> AnyhowResult<BucketClient> {
|
||||
pub fn get_bucket_client(env_var_suffix: &str) -> AnyhowResult<LegacyBucketClient> {
|
||||
let env_var_suffix = env_var_suffix.to_uppercase();
|
||||
let access_key = easyenv::get_env_string_required(&format!("ACCESS_KEY_{env_var_suffix}"))?;
|
||||
let secret_key = easyenv::get_env_string_required(&format!("SECRET_KEY_{env_var_suffix}"))?;
|
||||
@@ -84,7 +84,7 @@ pub fn get_bucket_client(env_var_suffix: &str) -> AnyhowResult<BucketClient> {
|
||||
|
||||
info!("Configuring GCS bucket {env_var_suffix} ...");
|
||||
|
||||
Ok(BucketClient::create(
|
||||
Ok(LegacyBucketClient::create(
|
||||
&access_key,
|
||||
&secret_key,
|
||||
®ion_name,
|
||||
|
||||
@@ -6,6 +6,7 @@ pub (crate) mod artcraft_website;
|
||||
pub (crate) mod development_only;
|
||||
pub (crate) mod fakeyou;
|
||||
pub (crate) mod legacy;
|
||||
pub (crate) mod realseedance;
|
||||
pub (crate) mod storyteller;
|
||||
pub (crate) mod storyteller_board;
|
||||
pub (crate) mod storyteller_render;
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
use actix_cors::Cors;
|
||||
use crate::util::netlify_branch_domain_matches::netlify_branch_domain_matches;
|
||||
|
||||
/// CORS for the "Is That Real Seedance?" website (video-info-website).
|
||||
pub fn add_realseedance(cors: Cors, _is_production: bool) -> Cors {
|
||||
cors
|
||||
// Actual domain
|
||||
.allowed_origin("https://realseedance.com")
|
||||
.allowed_origin("https://www.realseedance.com")
|
||||
// Netlify project (bare domain + branch / deploy-preview subdomains)
|
||||
.allowed_origin_fn(|origin, _req_head| {
|
||||
netlify_branch_domain_matches(origin, "real-seedance.netlify.app")
|
||||
})
|
||||
}
|
||||
@@ -9,6 +9,7 @@ use crate::configs::artcraft_website::add_artcraft_website;
|
||||
use crate::configs::development_only::add_development_only;
|
||||
use crate::configs::fakeyou::{add_fakeyou, add_fakeyou_dev_proxy};
|
||||
use crate::configs::legacy::{add_legacy_storyteller_stream, add_legacy_trumped, add_legacy_vocodes, add_power_stream};
|
||||
use crate::configs::realseedance::add_realseedance;
|
||||
use crate::configs::storyteller::{add_storyteller, add_storyteller_dev_proxy};
|
||||
use crate::configs::storyteller_board::add_storyteller_board;
|
||||
use crate::configs::storyteller_render::add_storyteller_render;
|
||||
@@ -52,6 +53,7 @@ fn do_build_cors_config(is_production: bool) -> Cors {
|
||||
cors = add_artcraft_webapp(cors, is_production);
|
||||
cors = add_artcraft_website(cors, is_production);
|
||||
cors = add_artcraft_admin_dashboard(cors, is_production);
|
||||
cors = add_realseedance(cors, is_production);
|
||||
|
||||
// Legacy
|
||||
cors = add_legacy_trumped(cors, is_production);
|
||||
@@ -117,6 +119,9 @@ mod tests {
|
||||
"https://artcraft.ai",
|
||||
"https://www.artcraft.ai",
|
||||
"https://artcraft-dashboard.netlify.app",
|
||||
"https://realseedance.com",
|
||||
"https://www.realseedance.com",
|
||||
"https://real-seedance.netlify.app",
|
||||
];
|
||||
for origin in origins {
|
||||
assert_origin_ok(&production_cors, origin).await;
|
||||
@@ -181,6 +186,26 @@ mod tests {
|
||||
assert_origin_invalid(&development_cors, "https://staging.fakeyou.com").await;
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn test_realseedance_production() {
|
||||
let production_cors = build_cors_config(ServerEnvironment::Production);
|
||||
|
||||
// Valid origins
|
||||
assert_origin_ok(&production_cors, "https://realseedance.com").await;
|
||||
assert_origin_ok(&production_cors, "https://www.realseedance.com").await;
|
||||
assert_origin_ok(&production_cors, "https://real-seedance.netlify.app").await;
|
||||
// Netlify deploy-preview / branch subdomain
|
||||
assert_origin_ok(
|
||||
&production_cors,
|
||||
"https://deploy-preview-12--real-seedance.netlify.app",
|
||||
)
|
||||
.await;
|
||||
|
||||
// Invalid origins
|
||||
assert_origin_invalid(&production_cors, "https://realseedance.fake.com").await;
|
||||
assert_origin_invalid(&production_cors, "https://realseedance.netlify.app").await;
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn test_storyteller_production() {
|
||||
let production_cors = build_cors_config(ServerEnvironment::Production);
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "sha1_hash"
|
||||
edition = "2024"
|
||||
version = "0.0.1"
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
name = "sha1_hash"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[package.metadata.cargo-udeps.ignore]
|
||||
normal = ["workspace-build-acceleration"]
|
||||
|
||||
[dependencies]
|
||||
# Workspace hack (faster compile times)
|
||||
workspace-build-acceleration = { version = "0.1", path = "../../workspace-build-acceleration" }
|
||||
|
||||
# External
|
||||
sha1 = "0.10"
|
||||
@@ -0,0 +1,17 @@
|
||||
use std::fmt;
|
||||
|
||||
/// Errors produced while computing a SHA-1 hash.
|
||||
#[derive(Debug)]
|
||||
pub enum Sha1HashError {
|
||||
/// The SHA-1 digest wasn't the expected 20 bytes. This should be unreachable
|
||||
/// for a well-behaved SHA-1 implementation; it exists as a defensive guard.
|
||||
UnexpectedDigestLength(usize),
|
||||
}
|
||||
|
||||
impl fmt::Display for Sha1HashError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{:?}", self)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for Sha1HashError {}
|
||||
@@ -0,0 +1,11 @@
|
||||
//! sha1_hash
|
||||
//!
|
||||
//! SHA-1 hashing utilities. The primary entry point is
|
||||
//! [`sha1_hash_bytes_as_crockford`], which hashes bytes with SHA-1 and returns
|
||||
//! the 20-byte digest as a lowercase Crockford base32 string (32 chars).
|
||||
|
||||
pub mod error;
|
||||
pub mod sha1_hash_bytes_as_crockford;
|
||||
|
||||
pub use error::Sha1HashError;
|
||||
pub use sha1_hash_bytes_as_crockford::sha1_hash_bytes_as_crockford;
|
||||
@@ -0,0 +1,74 @@
|
||||
use sha1::{Digest, Sha1};
|
||||
|
||||
use crate::error::Sha1HashError;
|
||||
|
||||
/// Lowercase Crockford base32 alphabet (excludes I, L, O, U). Matches the casing
|
||||
/// used by the `tokens` crate's Crockford generators.
|
||||
const CROCKFORD_LOWERCASE: &[u8] = b"0123456789abcdefghjkmnpqrstvwxyz";
|
||||
|
||||
/// The byte length of a SHA-1 digest.
|
||||
const SHA1_DIGEST_LEN: usize = 20;
|
||||
|
||||
/// SHA-1 hash `bytes` and return the 20-byte digest encoded as a lowercase
|
||||
/// Crockford base32 string (32 characters, no padding — 160 bits / 5).
|
||||
pub fn sha1_hash_bytes_as_crockford(bytes: &[u8]) -> Result<String, Sha1HashError> {
|
||||
let mut hasher = Sha1::new();
|
||||
hasher.update(bytes);
|
||||
let digest = hasher.finalize();
|
||||
|
||||
if digest.len() != SHA1_DIGEST_LEN {
|
||||
return Err(Sha1HashError::UnexpectedDigestLength(digest.len()));
|
||||
}
|
||||
|
||||
Ok(encode_crockford_lower(&digest))
|
||||
}
|
||||
|
||||
/// Encode bytes as lowercase Crockford base32, reading 5 bits at a time.
|
||||
fn encode_crockford_lower(data: &[u8]) -> String {
|
||||
let mut out = String::with_capacity(data.len() * 8 / 5 + 1);
|
||||
let mut buffer: u32 = 0;
|
||||
let mut bits: u32 = 0;
|
||||
for &byte in data {
|
||||
buffer = (buffer << 8) | byte as u32;
|
||||
bits += 8;
|
||||
while bits >= 5 {
|
||||
bits -= 5;
|
||||
let index = ((buffer >> bits) & 0x1f) as usize;
|
||||
out.push(CROCKFORD_LOWERCASE[index] as char);
|
||||
}
|
||||
}
|
||||
if bits > 0 {
|
||||
let index = ((buffer << (5 - bits)) & 0x1f) as usize;
|
||||
out.push(CROCKFORD_LOWERCASE[index] as char);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn empty_input_is_32_crockford_chars() {
|
||||
// SHA-1("") = da39a3ee5e6b4b0d3255bfef95601890afd80709
|
||||
let out = sha1_hash_bytes_as_crockford(&[]).unwrap();
|
||||
assert_eq!(out.len(), 32);
|
||||
assert!(out.bytes().all(|b| CROCKFORD_LOWERCASE.contains(&b)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deterministic_and_distinct() {
|
||||
let a = sha1_hash_bytes_as_crockford(b"hello").unwrap();
|
||||
let b = sha1_hash_bytes_as_crockford(b"hello").unwrap();
|
||||
let c = sha1_hash_bytes_as_crockford(b"world").unwrap();
|
||||
assert_eq!(a, b);
|
||||
assert_ne!(a, c);
|
||||
assert_eq!(a.len(), 32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encodes_known_bytes() {
|
||||
// 20 zero bytes → 32 '0' chars.
|
||||
assert_eq!(encode_crockford_lower(&[0u8; 20]), "0".repeat(32));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
[package]
|
||||
name = "video_info"
|
||||
edition = "2024"
|
||||
version = "0.0.1"
|
||||
authors = [
|
||||
"Brandon Thomas <bt@brand.io>",
|
||||
"Brandon Thomas <echelon@gmail.com>",
|
||||
]
|
||||
publish = false
|
||||
|
||||
[package.metadata.cargo-udeps.ignore]
|
||||
normal = ["workspace-build-acceleration"]
|
||||
|
||||
[lib]
|
||||
name = "video_info"
|
||||
path = "src/lib.rs"
|
||||
|
||||
# Optional CLI utility for inspecting a video's Seedance provenance.
|
||||
[[bin]]
|
||||
name = "video-info-parser"
|
||||
path = "src/bin/video_info_parser.rs"
|
||||
|
||||
[dependencies]
|
||||
# Workspace hack (faster compile times)
|
||||
workspace-build-acceleration.workspace = true
|
||||
|
||||
# External
|
||||
chrono.workspace = true
|
||||
@@ -0,0 +1,407 @@
|
||||
//! `video-info-parser` — inspect AI-generation C2PA provenance (Seedance, Veo,
|
||||
//! Sora, Dreamina, Kling) for a single video, or a whole directory of them.
|
||||
//!
|
||||
//! Usage:
|
||||
//! video-info-parser <FILE> # detailed report for one video
|
||||
//! video-info-parser <DIR> # summary table for every .mp4 in the dir
|
||||
//! video-info-parser --filename <PATH>
|
||||
//! video-info-parser <DIR> --truncate # shorten long filenames in the table
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::ExitCode;
|
||||
|
||||
use video_info::error::VideoInfoError;
|
||||
use video_info::{DreaminaInfo, KlingInfo, SeedanceInfo, SoraInfo, VeoInfo, VideoInfo};
|
||||
|
||||
/// Max width before a cell value is truncated with an ellipsis.
|
||||
const MAX_CELL: usize = 30;
|
||||
|
||||
/// Parsed command-line arguments.
|
||||
struct Args {
|
||||
/// File or directory to inspect.
|
||||
path: String,
|
||||
/// Whether to shorten long filenames in directory-table output. Defaults to
|
||||
/// `false` so full filenames are always shown.
|
||||
truncate_names: bool,
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let args = match parse_args(std::env::args().skip(1)) {
|
||||
Ok(args) => args,
|
||||
Err(msg) => {
|
||||
eprintln!("{msg}");
|
||||
eprintln!("usage: video-info-parser <FILE|DIR> [--truncate]");
|
||||
eprintln!(" video-info-parser --filename <PATH>");
|
||||
return ExitCode::from(2);
|
||||
}
|
||||
};
|
||||
|
||||
match fs::metadata(&args.path) {
|
||||
Ok(meta) if meta.is_dir() => run_directory(&args.path, args.truncate_names),
|
||||
Ok(_) => run_single_file(&args.path),
|
||||
Err(err) => {
|
||||
eprintln!("error: cannot read {:?}: {err}", args.path);
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Accept the path as the first positional arg, or via `--filename <PATH>` /
|
||||
/// `--filename=<PATH>`. The path may be a single file or a directory.
|
||||
/// `--truncate` shortens long filenames in directory-table output.
|
||||
fn parse_args(args: impl Iterator<Item = String>) -> Result<Args, String> {
|
||||
let mut path: Option<String> = None;
|
||||
let mut truncate_names = false;
|
||||
let mut args = args.peekable();
|
||||
while let Some(arg) = args.next() {
|
||||
match arg.as_str() {
|
||||
"--filename" | "-f" => {
|
||||
let value = args.next().ok_or_else(|| "error: --filename requires a value".to_string())?;
|
||||
path = Some(value);
|
||||
}
|
||||
"--truncate" | "--truncate-names" => truncate_names = true,
|
||||
"--help" | "-h" => {
|
||||
return Err("video-info-parser: print AI-generation provenance for a file or directory".to_string());
|
||||
}
|
||||
_ => {
|
||||
if let Some(value) = arg.strip_prefix("--filename=") {
|
||||
path = Some(value.to_string());
|
||||
} else if arg.starts_with('-') {
|
||||
return Err(format!("error: unknown flag {arg:?}"));
|
||||
} else if path.is_none() {
|
||||
path = Some(arg);
|
||||
} else {
|
||||
return Err(format!("error: unexpected extra argument {arg:?}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
match path {
|
||||
Some(path) => Ok(Args { path, truncate_names }),
|
||||
None => Err("error: no path provided".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Single-file mode ──
|
||||
|
||||
fn run_single_file(filename: &str) -> ExitCode {
|
||||
let bytes = match fs::read(filename) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(err) => {
|
||||
eprintln!("error: cannot read {filename:?}: {err}");
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
};
|
||||
match VideoInfo::from_bytes(&bytes) {
|
||||
Ok(VideoInfo::Seedance(info)) => print_seedance(filename, &info),
|
||||
Ok(VideoInfo::Veo(info)) => print_veo(filename, &info),
|
||||
Ok(VideoInfo::Sora(info)) => print_sora(filename, &info),
|
||||
Ok(VideoInfo::Dreamina(info)) => print_dreamina(filename, &info),
|
||||
Ok(VideoInfo::Kling(info)) => print_kling(filename, &info),
|
||||
Err(VideoInfoError::Unrecognized) => {
|
||||
print!("No recognized provenance (not Seedance, Veo, Sora, Dreamina, or Kling)");
|
||||
match video_info::encoder_tag(&bytes) {
|
||||
Some(enc) if enc.starts_with("Lavf") => {
|
||||
println!(" — re-encoded through ffmpeg (encoder: {enc}), which strips provenance");
|
||||
}
|
||||
Some(enc) => println!(" — encoder: {enc}"),
|
||||
None => println!(),
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!("error: {err}");
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
}
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
|
||||
// ── Directory mode (summary table) ──
|
||||
|
||||
const TABLE_HEADERS: [&str; 6] = ["FILE", "KIND", "MODEL", "DETAIL", "GENERATED", "SIGNER"];
|
||||
|
||||
fn run_directory(dir: &str, truncate_names: bool) -> ExitCode {
|
||||
let files = match collect_mp4s(Path::new(dir)) {
|
||||
Ok(files) => files,
|
||||
Err(err) => {
|
||||
eprintln!("error: cannot list {dir:?}: {err}");
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
};
|
||||
if files.is_empty() {
|
||||
println!("no .mp4 files found in {dir}");
|
||||
return ExitCode::SUCCESS;
|
||||
}
|
||||
|
||||
let mut rows: Vec<[String; 6]> = Vec::with_capacity(files.len());
|
||||
let mut tally: std::collections::BTreeMap<String, usize> = std::collections::BTreeMap::new();
|
||||
for file in &files {
|
||||
let name = file.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
let row = match fs::read(file) {
|
||||
Ok(bytes) => {
|
||||
let encoder = video_info::encoder_tag(&bytes);
|
||||
summary_row(&name, &VideoInfo::from_bytes(&bytes), encoder.as_deref(), truncate_names)
|
||||
}
|
||||
Err(err) => summary_row(&name, &Err(VideoInfoError::Io(err)), None, truncate_names),
|
||||
};
|
||||
*tally.entry(row[1].clone()).or_default() += 1;
|
||||
rows.push(row);
|
||||
}
|
||||
|
||||
render_table(&TABLE_HEADERS, &rows);
|
||||
println!();
|
||||
let total = files.len();
|
||||
let breakdown =
|
||||
tally.iter().map(|(kind, n)| format!("{kind}: {n}")).collect::<Vec<_>>().join(", ");
|
||||
println!("{total} file(s) — {breakdown}");
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
|
||||
/// Collect every `.mp4` (case-insensitive) directly under `dir`, sorted by name.
|
||||
fn collect_mp4s(dir: &Path) -> std::io::Result<Vec<PathBuf>> {
|
||||
let mut out = Vec::new();
|
||||
for entry in fs::read_dir(dir)? {
|
||||
let path = entry?.path();
|
||||
let is_mp4 = path
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.is_some_and(|e| e.eq_ignore_ascii_case("mp4"));
|
||||
if path.is_file() && is_mp4 {
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
out.sort();
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Reduce a parse result to one table row: `[file, kind, model, detail, generated, signer]`.
|
||||
/// Filenames are shown in full unless `truncate_names` is set. `encoder` is the
|
||||
/// `©too` tag, shown in DETAIL for unrecognized files to explain the absence.
|
||||
fn summary_row(
|
||||
name: &str,
|
||||
result: &Result<VideoInfo, VideoInfoError>,
|
||||
encoder: Option<&str>,
|
||||
truncate_names: bool,
|
||||
) -> [String; 6] {
|
||||
let file = if truncate_names { truncate(name, MAX_CELL) } else { name.to_string() };
|
||||
let (kind, model, detail, generated, signer) = match result {
|
||||
Ok(VideoInfo::Seedance(i)) => {
|
||||
let variant = if i.is_fast {
|
||||
" (fast)"
|
||||
} else if i.is_lite {
|
||||
" (lite)"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let detail = format!("{}{variant}", i.platform.as_str());
|
||||
("Seedance", i.model_name.clone(), detail, i.generated_at.clone(), signer_line(&i.signer_country, &i.cert_serial))
|
||||
}
|
||||
Ok(VideoInfo::Veo(i)) => {
|
||||
let detail = if i.has_c2pa_manifest {
|
||||
if i.has_synthid_watermark { "c2pa + synthid".to_string() } else { "c2pa".to_string() }
|
||||
} else {
|
||||
format!("encoder={}", i.encoder.as_deref().unwrap_or("?"))
|
||||
};
|
||||
("Veo", "Google Veo".to_string(), detail, String::new(), short_opt(&i.cert_serial))
|
||||
}
|
||||
Ok(VideoInfo::Sora(i)) => (
|
||||
"Sora",
|
||||
"OpenAI Sora".to_string(),
|
||||
i.model_name.clone().unwrap_or_default(),
|
||||
String::new(),
|
||||
short_opt(&i.cert_serial),
|
||||
),
|
||||
Ok(VideoInfo::Dreamina(i)) => {
|
||||
let signer = if i.has_c2pa { signer_line(&i.signer_country, &i.cert_serial) } else { String::new() };
|
||||
("Dreamina", i.product.clone(), i.video_id.clone().unwrap_or_default(), String::new(), signer)
|
||||
}
|
||||
Ok(VideoInfo::Kling(i)) => {
|
||||
let model = match &i.model_version {
|
||||
Some(v) => format!("Kling {v}"),
|
||||
None => "Kling".to_string(),
|
||||
};
|
||||
let detail = i.produce_id.clone().unwrap_or_else(|| {
|
||||
if i.has_stream_watermark { "watermark-only".to_string() } else { String::new() }
|
||||
});
|
||||
("Kling", model, detail, String::new(), i.content_producer.clone().unwrap_or_default())
|
||||
}
|
||||
Err(VideoInfoError::Unrecognized) => {
|
||||
let detail = match encoder {
|
||||
Some(enc) if enc.starts_with("Lavf") => format!("re-encoded: {enc}"),
|
||||
Some(enc) => format!("encoder: {enc}"),
|
||||
None => "no provenance".to_string(),
|
||||
};
|
||||
("—", String::new(), detail, String::new(), String::new())
|
||||
}
|
||||
Err(err) => ("error", err.to_string(), String::new(), String::new(), String::new()),
|
||||
};
|
||||
[
|
||||
file,
|
||||
kind.to_string(),
|
||||
truncate(&model, MAX_CELL),
|
||||
truncate(&detail, MAX_CELL),
|
||||
generated,
|
||||
truncate(&signer, MAX_CELL),
|
||||
]
|
||||
}
|
||||
|
||||
/// `"<country> <short-cert>"`, omitting whichever parts are absent.
|
||||
fn signer_line(country: &Option<String>, cert: &Option<String>) -> String {
|
||||
match (country.as_deref(), cert.as_deref()) {
|
||||
(Some(c), Some(s)) => format!("{c} {}", short(s, 14)),
|
||||
(Some(c), None) => c.to_string(),
|
||||
(None, Some(s)) => short(s, 14),
|
||||
(None, None) => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn short_opt(value: &Option<String>) -> String {
|
||||
value.as_deref().map(|s| short(s, 14)).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn short(value: &str, max: usize) -> String {
|
||||
truncate(value, max)
|
||||
}
|
||||
|
||||
/// Truncate to `max` characters, appending `…` when shortened.
|
||||
fn truncate(value: &str, max: usize) -> String {
|
||||
if value.chars().count() <= max {
|
||||
return value.to_string();
|
||||
}
|
||||
let kept: String = value.chars().take(max.saturating_sub(1)).collect();
|
||||
format!("{kept}…")
|
||||
}
|
||||
|
||||
/// Render left-aligned columns sized to the widest cell in each.
|
||||
fn render_table(headers: &[&str], rows: &[[String; 6]]) {
|
||||
let mut widths: Vec<usize> = headers.iter().map(|h| h.chars().count()).collect();
|
||||
for row in rows {
|
||||
for (i, cell) in row.iter().enumerate() {
|
||||
widths[i] = widths[i].max(cell.chars().count());
|
||||
}
|
||||
}
|
||||
let render = |cells: &[String]| {
|
||||
cells
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, c)| format!("{:<width$}", c, width = widths[i]))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
};
|
||||
let header_cells: Vec<String> = headers.iter().map(|h| h.to_string()).collect();
|
||||
println!("{}", render(&header_cells).trim_end());
|
||||
let separators: Vec<String> = widths.iter().map(|w| "-".repeat(*w)).collect();
|
||||
println!("{}", render(&separators).trim_end());
|
||||
for row in rows {
|
||||
println!("{}", render(row).trim_end());
|
||||
}
|
||||
}
|
||||
|
||||
// ── Printing ──
|
||||
|
||||
fn row(label: &str, value: &str) {
|
||||
println!(" {label:>24} : {value}");
|
||||
}
|
||||
|
||||
fn opt(label: &str, value: &Option<String>) {
|
||||
row(label, value.as_deref().unwrap_or("(none)"));
|
||||
}
|
||||
|
||||
fn header(title: &str, filename: &str) {
|
||||
println!("{title}");
|
||||
row("file", filename);
|
||||
println!(" {}", "-".repeat(60));
|
||||
}
|
||||
|
||||
fn print_seedance(filename: &str, info: &SeedanceInfo) {
|
||||
header("Seedance video provenance", filename);
|
||||
row("platform", info.platform.as_str());
|
||||
row("software agent", &info.software_agent);
|
||||
opt("software agent version", &info.software_agent_version);
|
||||
row("model name", &info.model_name);
|
||||
opt("model brand", &info.model_brand);
|
||||
opt("model version", &info.model_version);
|
||||
row("fast variant", if info.is_fast { "yes" } else { "no" });
|
||||
row("lite variant", if info.is_lite { "yes" } else { "no" });
|
||||
row("generated at", &info.generated_at);
|
||||
row(
|
||||
"generated at (parsed)",
|
||||
&info.generated_at_utc.map(|t| t.to_rfc3339()).unwrap_or_else(|| "(unparseable)".to_string()),
|
||||
);
|
||||
opt("log id", &info.log_id);
|
||||
opt("log id (decoded hex)", &info.log_id_decoded_hex);
|
||||
opt("digital source type", &info.digital_source_type);
|
||||
opt("claim generator", &info.claim_generator);
|
||||
opt("claim generator version", &info.claim_generator_version);
|
||||
opt("manifest id", &info.manifest_id);
|
||||
opt("instance id", &info.instance_id);
|
||||
opt("signer email", &info.signer_email);
|
||||
opt("signer org id", &info.signer_org_id);
|
||||
opt("signer country", &info.signer_country);
|
||||
opt("cert serial", &info.cert_serial);
|
||||
}
|
||||
|
||||
fn print_veo(filename: &str, info: &VeoInfo) {
|
||||
header("Google Veo video provenance", filename);
|
||||
row("producer", &info.producer);
|
||||
row("c2pa manifest", if info.has_c2pa_manifest { "yes" } else { "no (encoder tag only)" });
|
||||
opt("encoder", &info.encoder);
|
||||
opt("created description", &info.created_description);
|
||||
row("synthid watermark", if info.has_synthid_watermark { "yes" } else { "no" });
|
||||
opt("synthid description", &info.synthid_description);
|
||||
opt("digital source type", &info.digital_source_type);
|
||||
opt("claim generator", &info.claim_generator);
|
||||
opt("claim generator version", &info.claim_generator_version);
|
||||
opt("manifest id", &info.manifest_id);
|
||||
opt("instance id", &info.instance_id);
|
||||
opt("cert serial", &info.cert_serial);
|
||||
opt("signer ca", &info.signer_ca);
|
||||
row("timestamped", if info.is_timestamped { "yes" } else { "no" });
|
||||
opt("timestamp authority", &info.timestamp_authority);
|
||||
row("model name", "(not embedded in metadata)");
|
||||
}
|
||||
|
||||
fn print_sora(filename: &str, info: &SoraInfo) {
|
||||
header("OpenAI Sora video provenance", filename);
|
||||
row("producer", &info.producer);
|
||||
opt("model name", &info.model_name);
|
||||
opt("created description", &info.created_description);
|
||||
opt("digital source type", &info.digital_source_type);
|
||||
opt("claim generator", &info.claim_generator);
|
||||
opt("manifest id", &info.manifest_id);
|
||||
opt("instance id", &info.instance_id);
|
||||
opt("cert serial", &info.cert_serial);
|
||||
}
|
||||
|
||||
fn print_dreamina(filename: &str, info: &DreaminaInfo) {
|
||||
header("Dreamina (ByteDance/CapCut) video provenance", filename);
|
||||
row("product", &info.product);
|
||||
opt("export type", &info.export_type);
|
||||
opt("os", &info.os);
|
||||
opt("source info", &info.source_info);
|
||||
row(
|
||||
"aigc label type",
|
||||
&info.aigc_label_type.map(|n| n.to_string()).unwrap_or_else(|| "(none)".to_string()),
|
||||
);
|
||||
opt("video id", &info.video_id);
|
||||
row("has c2pa", if info.has_c2pa { "yes" } else { "no" });
|
||||
opt("signer org id", &info.signer_org_id);
|
||||
opt("signer country", &info.signer_country);
|
||||
opt("cert serial", &info.cert_serial);
|
||||
}
|
||||
|
||||
fn print_kling(filename: &str, info: &KlingInfo) {
|
||||
header("Kling (Kuaishou) video provenance", filename);
|
||||
opt("model version", &info.model_version);
|
||||
row("ai-generated", if info.is_ai_generated { "yes" } else { "no" });
|
||||
opt("aigc label", &info.label);
|
||||
opt("content producer", &info.content_producer);
|
||||
opt("produce id", &info.produce_id);
|
||||
opt("content propagator", &info.content_propagator);
|
||||
opt("propagate id", &info.propagate_id);
|
||||
opt("reserved code 1", &info.reserved_code_1);
|
||||
opt("reserved code 2", &info.reserved_code_2);
|
||||
row("stream watermark", if info.has_stream_watermark { "yes" } else { "no" });
|
||||
opt("watermark uuid", &info.watermark_uuid);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
//! Parse **Dreamina** (ByteDance / CapCut consumer app) provenance from an MP4.
|
||||
//!
|
||||
//! Unlike the Seedance API (which embeds a Volcengine/BytePlus C2PA manifest),
|
||||
//! the Dreamina app stamps JSON metadata into the MP4's `moov/udta/meta/ilst`
|
||||
//! atoms — e.g. `{"product":"dreamina","exportType":"generation",…}`,
|
||||
//! `{"aigc_label_type":0,"source_info":"dreamina"}`, and a `vid:…` video id.
|
||||
//! Some Dreamina exports additionally carry a Bytedance-signed C2PA box.
|
||||
//!
|
||||
//! Dreamina is ByteDance's consumer product built on the Seedance model family,
|
||||
//! so this is a distinct provenance shape from [`crate::seedance_info`].
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::error::VideoInfoError;
|
||||
use crate::scan::{find, find_cert_serial, find_org_identifier, json_int_field, json_str_field};
|
||||
|
||||
/// Provenance extracted from a Dreamina (ByteDance/CapCut) video export.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct DreaminaInfo {
|
||||
/// The product string, always `"dreamina"`.
|
||||
pub product: String,
|
||||
|
||||
/// Export type, e.g. `"generation"`.
|
||||
pub export_type: Option<String>,
|
||||
|
||||
/// OS the export was made on (often empty).
|
||||
pub os: Option<String>,
|
||||
|
||||
/// `source_info`, e.g. `"dreamina"`.
|
||||
pub source_info: Option<String>,
|
||||
|
||||
/// `aigc_label_type` — ByteDance's AIGC labeling flag (e.g. `0`).
|
||||
pub aigc_label_type: Option<i64>,
|
||||
|
||||
/// Dreamina video id, e.g. `"v02870g10004d6jktf7og65h7ouce21g"` (the `vid:` tag).
|
||||
pub video_id: Option<String>,
|
||||
|
||||
/// Whether the export also carries a (Bytedance-signed) C2PA manifest.
|
||||
pub has_c2pa: bool,
|
||||
|
||||
/// Signing-cert organization identifier (when a C2PA box is present), e.g.
|
||||
/// `"NTRSG-201923456H"` (Bytedance Pte. Ltd.).
|
||||
pub signer_org_id: Option<String>,
|
||||
|
||||
/// Country of the signing org (`"SG"`) when a C2PA box is present.
|
||||
pub signer_country: Option<String>,
|
||||
|
||||
/// Leaf signing-cert hex serial when a C2PA box is present.
|
||||
pub cert_serial: Option<String>,
|
||||
}
|
||||
|
||||
impl DreaminaInfo {
|
||||
/// Parse Dreamina provenance from a file on disk.
|
||||
pub fn from_path(path: impl AsRef<Path>) -> Result<DreaminaInfo, VideoInfoError> {
|
||||
let bytes = fs::read(path)?;
|
||||
Self::from_bytes(&bytes)
|
||||
}
|
||||
|
||||
/// Parse Dreamina provenance from raw bytes. Returns
|
||||
/// [`VideoInfoError::NotDreamina`] if the Dreamina markers aren't present.
|
||||
pub fn from_bytes(data: &[u8]) -> Result<DreaminaInfo, VideoInfoError> {
|
||||
let product = json_str_field(data, "product");
|
||||
let source_info = json_str_field(data, "source_info");
|
||||
let is_dreamina = product.as_deref() == Some("dreamina")
|
||||
|| source_info.as_deref() == Some("dreamina");
|
||||
if !is_dreamina {
|
||||
return Err(VideoInfoError::NotDreamina);
|
||||
}
|
||||
|
||||
let has_c2pa = find(data, b"jumbf").is_some() || find(data, b"c2pa.claim").is_some();
|
||||
let (signer_org_id, signer_country) = if has_c2pa {
|
||||
match find_org_identifier(data) {
|
||||
Some((id, country)) => (Some(id), Some(country.to_string())),
|
||||
None => (None, None),
|
||||
}
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
|
||||
Ok(DreaminaInfo {
|
||||
product: product.unwrap_or_else(|| "dreamina".to_string()),
|
||||
export_type: json_str_field(data, "exportType"),
|
||||
os: json_str_field(data, "os"),
|
||||
source_info,
|
||||
aigc_label_type: json_int_field(data, "aigc_label_type"),
|
||||
video_id: find_vid_token(data),
|
||||
has_c2pa,
|
||||
signer_org_id,
|
||||
signer_country,
|
||||
cert_serial: if has_c2pa { find_cert_serial(data) } else { None },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the `vid:<id>` token (id is lowercase-alphanumeric).
|
||||
fn find_vid_token(data: &[u8]) -> Option<String> {
|
||||
const PREFIX: &[u8] = b"vid:";
|
||||
let i = find(data, PREFIX)?;
|
||||
let start = i + PREFIX.len();
|
||||
let end = (start..data.len())
|
||||
.take_while(|&j| data[j].is_ascii_alphanumeric())
|
||||
.last()
|
||||
.map(|j| j + 1)?;
|
||||
if end == start {
|
||||
return None;
|
||||
}
|
||||
std::str::from_utf8(&data[start..end]).ok().map(str::to_string)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn synth(with_c2pa: bool) -> Vec<u8> {
|
||||
let mut v = Vec::new();
|
||||
v.extend_from_slice(b"....ftypisom....moov....udta....ilst");
|
||||
v.extend_from_slice(br#"{"data":{"os":"","product":"dreamina","exportType":"generation","videoId":"0"}}"#);
|
||||
v.extend_from_slice(br#"{"aigc_label_type":0,"source_info":"dreamina"}"#);
|
||||
v.extend_from_slice(b"vid:v02870g10004d6jktf7og65h7ouce21g");
|
||||
if with_c2pa {
|
||||
v.extend_from_slice(b"jumbfc2pa.claim");
|
||||
v.extend_from_slice(&[0xA0, 0x03, 0x02, 0x01, 0x02, 0x02, 0x03, 0xAB, 0xCD, 0xEF]);
|
||||
v.push(0x13);
|
||||
v.push(16);
|
||||
v.extend_from_slice(b"NTRSG-201923456H");
|
||||
v.push(0x31);
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_dreamina_no_c2pa() {
|
||||
let info = DreaminaInfo::from_bytes(&synth(false)).expect("should parse");
|
||||
assert_eq!(info.product, "dreamina");
|
||||
assert_eq!(info.export_type.as_deref(), Some("generation"));
|
||||
assert_eq!(info.os.as_deref(), Some(""));
|
||||
assert_eq!(info.source_info.as_deref(), Some("dreamina"));
|
||||
assert_eq!(info.aigc_label_type, Some(0));
|
||||
assert_eq!(info.video_id.as_deref(), Some("v02870g10004d6jktf7og65h7ouce21g"));
|
||||
assert!(!info.has_c2pa);
|
||||
assert_eq!(info.cert_serial, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_dreamina_with_c2pa() {
|
||||
let info = DreaminaInfo::from_bytes(&synth(true)).expect("should parse");
|
||||
assert!(info.has_c2pa);
|
||||
assert_eq!(info.signer_org_id.as_deref(), Some("NTRSG-201923456H"));
|
||||
assert_eq!(info.signer_country.as_deref(), Some("SG"));
|
||||
assert_eq!(info.cert_serial.as_deref(), Some("ABCDEF"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_dreamina_returns_not_dreamina() {
|
||||
let data = b"....ftypisom....just an mp4....";
|
||||
assert!(matches!(DreaminaInfo::from_bytes(data), Err(VideoInfoError::NotDreamina)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
//! 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)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
//! 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)
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
//! 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"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
//! 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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
//! 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)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
//! 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)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
//! [`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)));
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,8 @@ pub mod trending_model_analytics;
|
||||
pub mod tts;
|
||||
pub mod twitch;
|
||||
pub mod unsubscribe_reason;
|
||||
pub mod uploaded_video_notes;
|
||||
pub mod uploaded_videos;
|
||||
pub mod user_impersonation_requests;
|
||||
pub mod user_referral_codes;
|
||||
pub mod user_referrals;
|
||||
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
use enums::by_table::uploaded_video_notes::uploaded_video_note_reported_model_type::UploadedVideoNoteReportedModelType;
|
||||
use enums::by_table::uploaded_video_notes::uploaded_video_note_reported_website::UploadedVideoNoteReportedWebsite;
|
||||
use tokens::tokens::uploaded_video_notes::UploadVideoNoteToken;
|
||||
use tokens::tokens::uploaded_videos::UploadedVideoToken;
|
||||
|
||||
pub struct InsertUploadedVideoNoteArgs<'e, 'c, E>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
pub uploaded_video_token: &'e UploadedVideoToken,
|
||||
|
||||
pub maybe_filename: Option<&'e str>,
|
||||
pub maybe_reported_model_type: Option<UploadedVideoNoteReportedModelType>,
|
||||
pub maybe_reported_model_name: Option<&'e str>,
|
||||
pub maybe_website: Option<UploadedVideoNoteReportedWebsite>,
|
||||
pub maybe_other_website: Option<&'e str>,
|
||||
pub maybe_comments: Option<&'e str>,
|
||||
|
||||
pub maybe_email_address: Option<&'e str>,
|
||||
pub can_share_report: bool,
|
||||
pub was_scammed: bool,
|
||||
|
||||
pub comment_create_ip_address: &'e str,
|
||||
|
||||
pub mysql_executor: E,
|
||||
pub phantom: PhantomData<&'c E>,
|
||||
}
|
||||
|
||||
/// Insert a new `uploaded_video_notes` record. The `UploadVideoNoteToken` is
|
||||
/// minted here (in the data-access layer, not the handler) and returned.
|
||||
/// `created_at` is set to `NOW()`.
|
||||
pub async fn insert_uploaded_video_note<'e, 'c: 'e, E>(
|
||||
args: InsertUploadedVideoNoteArgs<'e, 'c, E>,
|
||||
) -> Result<UploadVideoNoteToken, sqlx::Error>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
let token = UploadVideoNoteToken::generate();
|
||||
|
||||
sqlx::query!(
|
||||
r#"
|
||||
INSERT INTO uploaded_video_notes
|
||||
SET
|
||||
token = ?,
|
||||
uploaded_video_token = ?,
|
||||
maybe_filename = ?,
|
||||
maybe_reported_model_type = ?,
|
||||
maybe_reported_model_name = ?,
|
||||
maybe_website = ?,
|
||||
maybe_other_website = ?,
|
||||
maybe_comments = ?,
|
||||
maybe_email_address = ?,
|
||||
can_share_report = ?,
|
||||
was_scammed = ?,
|
||||
comment_create_ip_address = ?,
|
||||
created_at = NOW()
|
||||
"#,
|
||||
token.as_str(),
|
||||
args.uploaded_video_token.as_str(),
|
||||
args.maybe_filename,
|
||||
args.maybe_reported_model_type.map(|model_type| model_type.to_str()),
|
||||
args.maybe_reported_model_name,
|
||||
args.maybe_website.map(|website| website.to_str()),
|
||||
args.maybe_other_website,
|
||||
args.maybe_comments,
|
||||
args.maybe_email_address,
|
||||
args.can_share_report,
|
||||
args.was_scammed,
|
||||
args.comment_create_ip_address,
|
||||
)
|
||||
.execute(args.mysql_executor)
|
||||
.await?;
|
||||
|
||||
Ok(token)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod insert_uploaded_video_note;
|
||||
pub mod update_uploaded_video_note;
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
use enums::by_table::uploaded_video_notes::uploaded_video_note_reported_model_type::UploadedVideoNoteReportedModelType;
|
||||
use enums::by_table::uploaded_video_notes::uploaded_video_note_reported_website::UploadedVideoNoteReportedWebsite;
|
||||
use tokens::tokens::uploaded_video_notes::UploadVideoNoteToken;
|
||||
|
||||
pub struct UpdateUploadedVideoNoteArgs<'e, 'c, E>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
pub token: &'e UploadVideoNoteToken,
|
||||
|
||||
pub maybe_filename: Option<&'e str>,
|
||||
pub maybe_reported_model_type: Option<UploadedVideoNoteReportedModelType>,
|
||||
pub maybe_reported_model_name: Option<&'e str>,
|
||||
pub maybe_website: Option<UploadedVideoNoteReportedWebsite>,
|
||||
pub maybe_other_website: Option<&'e str>,
|
||||
pub maybe_comments: Option<&'e str>,
|
||||
|
||||
pub maybe_email_address: Option<&'e str>,
|
||||
pub can_share_report: bool,
|
||||
pub was_scammed: bool,
|
||||
|
||||
/// IP address of the request making the update; stamped on `maybe_comment_update_ip_address`.
|
||||
pub maybe_comment_update_ip_address: Option<&'e str>,
|
||||
|
||||
pub mysql_executor: E,
|
||||
pub phantom: PhantomData<&'c E>,
|
||||
}
|
||||
|
||||
/// Update the mutable fields of an `uploaded_video_notes` record by `token`. The
|
||||
/// `version` vector clock is incremented by 1, and `updated_at` is refreshed via
|
||||
/// the column's `ON UPDATE` clause. Returns the number of rows affected (0 if no
|
||||
/// row matched).
|
||||
pub async fn update_uploaded_video_note<'e, 'c: 'e, E>(
|
||||
args: UpdateUploadedVideoNoteArgs<'e, 'c, E>,
|
||||
) -> Result<u64, sqlx::Error>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
let result = sqlx::query!(
|
||||
r#"
|
||||
UPDATE uploaded_video_notes
|
||||
SET
|
||||
maybe_filename = ?,
|
||||
maybe_reported_model_type = ?,
|
||||
maybe_reported_model_name = ?,
|
||||
maybe_website = ?,
|
||||
maybe_other_website = ?,
|
||||
maybe_comments = ?,
|
||||
maybe_email_address = ?,
|
||||
can_share_report = ?,
|
||||
was_scammed = ?,
|
||||
maybe_comment_update_ip_address = ?,
|
||||
version = version + 1
|
||||
WHERE token = ?
|
||||
LIMIT 1
|
||||
"#,
|
||||
args.maybe_filename,
|
||||
args.maybe_reported_model_type.map(|model_type| model_type.to_str()),
|
||||
args.maybe_reported_model_name,
|
||||
args.maybe_website.map(|website| website.to_str()),
|
||||
args.maybe_other_website,
|
||||
args.maybe_comments,
|
||||
args.maybe_email_address,
|
||||
args.can_share_report,
|
||||
args.was_scammed,
|
||||
args.maybe_comment_update_ip_address,
|
||||
args.token.as_str(),
|
||||
)
|
||||
.execute(args.mysql_executor)
|
||||
.await?;
|
||||
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
use enums::by_table::uploaded_videos::uploaded_video_detected_model_family::UploadedVideoDetectedModelFamily;
|
||||
use enums::by_table::uploaded_videos::uploaded_video_detected_model_type::UploadedVideoDetectedModelType;
|
||||
use tokens::tokens::uploaded_videos::UploadedVideoToken;
|
||||
|
||||
use crate::queries::uploaded_videos::uploaded_video_row::UploadedVideoRow;
|
||||
|
||||
pub struct GetUploadedVideoBySha1ChecksumArgs<'e, 'c, E>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
pub sha1_checksum: &'e str,
|
||||
pub mysql_executor: E,
|
||||
pub phantom: PhantomData<&'c E>,
|
||||
}
|
||||
|
||||
/// Look up an `uploaded_videos` record by its `sha1_checksum`. `Ok(None)` if no
|
||||
/// row matches. Useful for de-duplicating re-uploads of the same bytes.
|
||||
pub async fn get_uploaded_video_by_sha1_checksum<'e, 'c: 'e, E>(
|
||||
args: GetUploadedVideoBySha1ChecksumArgs<'e, 'c, E>,
|
||||
) -> Result<Option<UploadedVideoRow>, sqlx::Error>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
let result = sqlx::query!(
|
||||
r#"
|
||||
SELECT
|
||||
token as `token: UploadedVideoToken`,
|
||||
sha1_checksum,
|
||||
filesize_bytes as `filesize_bytes: u32`,
|
||||
maybe_width as `maybe_width: u32`,
|
||||
maybe_height as `maybe_height: u32`,
|
||||
maybe_resolution,
|
||||
maybe_detected_model_family as `maybe_detected_model_family: UploadedVideoDetectedModelFamily`,
|
||||
maybe_detected_model_type as `maybe_detected_model_type: UploadedVideoDetectedModelType`,
|
||||
maybe_report,
|
||||
upload_ip_address,
|
||||
maybe_updated_ip_address,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM uploaded_videos
|
||||
WHERE sha1_checksum = ?
|
||||
LIMIT 1
|
||||
"#,
|
||||
args.sha1_checksum,
|
||||
)
|
||||
.fetch_optional(args.mysql_executor)
|
||||
.await?;
|
||||
|
||||
Ok(result.map(|r| UploadedVideoRow {
|
||||
token: r.token,
|
||||
sha1_checksum: r.sha1_checksum,
|
||||
filesize_bytes: r.filesize_bytes,
|
||||
maybe_width: r.maybe_width,
|
||||
maybe_height: r.maybe_height,
|
||||
maybe_resolution: r.maybe_resolution,
|
||||
maybe_detected_model_family: r.maybe_detected_model_family,
|
||||
maybe_detected_model_type: r.maybe_detected_model_type,
|
||||
maybe_report: r.maybe_report,
|
||||
upload_ip_address: r.upload_ip_address,
|
||||
maybe_updated_ip_address: r.maybe_updated_ip_address,
|
||||
created_at: r.created_at,
|
||||
updated_at: r.updated_at,
|
||||
}))
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
use enums::by_table::uploaded_videos::uploaded_video_detected_model_family::UploadedVideoDetectedModelFamily;
|
||||
use enums::by_table::uploaded_videos::uploaded_video_detected_model_type::UploadedVideoDetectedModelType;
|
||||
use tokens::tokens::uploaded_videos::UploadedVideoToken;
|
||||
|
||||
use crate::queries::uploaded_videos::uploaded_video_row::UploadedVideoRow;
|
||||
|
||||
pub struct GetUploadedVideoByTokenArgs<'e, 'c, E>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
pub token: &'e UploadedVideoToken,
|
||||
pub mysql_executor: E,
|
||||
pub phantom: PhantomData<&'c E>,
|
||||
}
|
||||
|
||||
/// Look up an `uploaded_videos` record by its `token`. `Ok(None)` if no row matches.
|
||||
pub async fn get_uploaded_video_by_token<'e, 'c: 'e, E>(
|
||||
args: GetUploadedVideoByTokenArgs<'e, 'c, E>,
|
||||
) -> Result<Option<UploadedVideoRow>, sqlx::Error>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
let result = sqlx::query!(
|
||||
r#"
|
||||
SELECT
|
||||
token as `token: UploadedVideoToken`,
|
||||
sha1_checksum,
|
||||
filesize_bytes as `filesize_bytes: u32`,
|
||||
maybe_width as `maybe_width: u32`,
|
||||
maybe_height as `maybe_height: u32`,
|
||||
maybe_resolution,
|
||||
maybe_detected_model_family as `maybe_detected_model_family: UploadedVideoDetectedModelFamily`,
|
||||
maybe_detected_model_type as `maybe_detected_model_type: UploadedVideoDetectedModelType`,
|
||||
maybe_report,
|
||||
upload_ip_address,
|
||||
maybe_updated_ip_address,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM uploaded_videos
|
||||
WHERE token = ?
|
||||
LIMIT 1
|
||||
"#,
|
||||
args.token.as_str(),
|
||||
)
|
||||
.fetch_optional(args.mysql_executor)
|
||||
.await?;
|
||||
|
||||
Ok(result.map(|r| UploadedVideoRow {
|
||||
token: r.token,
|
||||
sha1_checksum: r.sha1_checksum,
|
||||
filesize_bytes: r.filesize_bytes,
|
||||
maybe_width: r.maybe_width,
|
||||
maybe_height: r.maybe_height,
|
||||
maybe_resolution: r.maybe_resolution,
|
||||
maybe_detected_model_family: r.maybe_detected_model_family,
|
||||
maybe_detected_model_type: r.maybe_detected_model_type,
|
||||
maybe_report: r.maybe_report,
|
||||
upload_ip_address: r.upload_ip_address,
|
||||
maybe_updated_ip_address: r.maybe_updated_ip_address,
|
||||
created_at: r.created_at,
|
||||
updated_at: r.updated_at,
|
||||
}))
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
use enums::by_table::uploaded_videos::uploaded_video_detected_model_family::UploadedVideoDetectedModelFamily;
|
||||
use enums::by_table::uploaded_videos::uploaded_video_detected_model_type::UploadedVideoDetectedModelType;
|
||||
use tokens::tokens::uploaded_videos::UploadedVideoToken;
|
||||
|
||||
pub struct InsertUploadedVideoArgs<'e, 'c, E>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
pub sha1_checksum: &'e str,
|
||||
pub filesize_bytes: u32,
|
||||
pub maybe_filename: Option<&'e str>,
|
||||
|
||||
pub maybe_width: Option<u32>,
|
||||
pub maybe_height: Option<u32>,
|
||||
pub maybe_resolution: Option<&'e str>,
|
||||
|
||||
pub maybe_detected_model_family: Option<UploadedVideoDetectedModelFamily>,
|
||||
pub maybe_detected_model_type: Option<UploadedVideoDetectedModelType>,
|
||||
pub maybe_report: Option<&'e str>,
|
||||
|
||||
pub upload_ip_address: &'e str,
|
||||
|
||||
pub mysql_executor: E,
|
||||
pub phantom: PhantomData<&'c E>,
|
||||
}
|
||||
|
||||
/// Insert a new `uploaded_videos` record. The `UploadedVideoToken` is minted
|
||||
/// here (in the data-access layer, not the handler) and returned. `created_at`
|
||||
/// is set to `NOW()`.
|
||||
pub async fn insert_uploaded_video<'e, 'c: 'e, E>(
|
||||
args: InsertUploadedVideoArgs<'e, 'c, E>,
|
||||
) -> Result<UploadedVideoToken, sqlx::Error>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
let token = UploadedVideoToken::generate();
|
||||
|
||||
sqlx::query!(
|
||||
r#"
|
||||
INSERT INTO uploaded_videos
|
||||
SET
|
||||
token = ?,
|
||||
sha1_checksum = ?,
|
||||
filesize_bytes = ?,
|
||||
maybe_filename = ?,
|
||||
maybe_width = ?,
|
||||
maybe_height = ?,
|
||||
maybe_resolution = ?,
|
||||
maybe_detected_model_family = ?,
|
||||
maybe_detected_model_type = ?,
|
||||
maybe_report = ?,
|
||||
upload_ip_address = ?,
|
||||
created_at = NOW()
|
||||
"#,
|
||||
token.as_str(),
|
||||
args.sha1_checksum,
|
||||
args.filesize_bytes,
|
||||
args.maybe_filename,
|
||||
args.maybe_width,
|
||||
args.maybe_height,
|
||||
args.maybe_resolution,
|
||||
args.maybe_detected_model_family.map(|family| family.to_str()),
|
||||
args.maybe_detected_model_type.map(|model_type| model_type.to_str()),
|
||||
args.maybe_report,
|
||||
args.upload_ip_address,
|
||||
)
|
||||
.execute(args.mysql_executor)
|
||||
.await?;
|
||||
|
||||
Ok(token)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
pub mod get_uploaded_video_by_sha1_checksum;
|
||||
pub mod get_uploaded_video_by_token;
|
||||
pub mod insert_uploaded_video;
|
||||
pub mod update_uploaded_video;
|
||||
pub mod uploaded_video_row;
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
use enums::by_table::uploaded_videos::uploaded_video_detected_model_family::UploadedVideoDetectedModelFamily;
|
||||
use enums::by_table::uploaded_videos::uploaded_video_detected_model_type::UploadedVideoDetectedModelType;
|
||||
use tokens::tokens::uploaded_videos::UploadedVideoToken;
|
||||
|
||||
pub struct UpdateUploadedVideoArgs<'e, 'c, E>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
pub token: &'e UploadedVideoToken,
|
||||
|
||||
pub maybe_filename: Option<&'e str>,
|
||||
|
||||
pub maybe_width: Option<u32>,
|
||||
pub maybe_height: Option<u32>,
|
||||
pub maybe_resolution: Option<&'e str>,
|
||||
|
||||
pub maybe_detected_model_family: Option<UploadedVideoDetectedModelFamily>,
|
||||
pub maybe_detected_model_type: Option<UploadedVideoDetectedModelType>,
|
||||
pub maybe_report: Option<&'e str>,
|
||||
|
||||
/// IP address of the request making the update; stamped on `maybe_updated_ip_address`.
|
||||
pub maybe_updated_ip_address: Option<&'e str>,
|
||||
|
||||
pub mysql_executor: E,
|
||||
pub phantom: PhantomData<&'c E>,
|
||||
}
|
||||
|
||||
/// Update the mutable fields of an `uploaded_videos` record by `token`
|
||||
/// (dimensions, detection, report, updater IP). The `version` vector clock is
|
||||
/// incremented by 1, and `updated_at` is refreshed via the column's `ON UPDATE`
|
||||
/// clause. Returns the number of rows affected (0 if no row matched).
|
||||
pub async fn update_uploaded_video<'e, 'c: 'e, E>(
|
||||
args: UpdateUploadedVideoArgs<'e, 'c, E>,
|
||||
) -> Result<u64, sqlx::Error>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
let result = sqlx::query!(
|
||||
r#"
|
||||
UPDATE uploaded_videos
|
||||
SET
|
||||
maybe_filename = ?,
|
||||
maybe_width = ?,
|
||||
maybe_height = ?,
|
||||
maybe_resolution = ?,
|
||||
maybe_detected_model_family = ?,
|
||||
maybe_detected_model_type = ?,
|
||||
maybe_report = ?,
|
||||
maybe_updated_ip_address = ?,
|
||||
version = version + 1
|
||||
WHERE token = ?
|
||||
LIMIT 1
|
||||
"#,
|
||||
args.maybe_filename,
|
||||
args.maybe_width,
|
||||
args.maybe_height,
|
||||
args.maybe_resolution,
|
||||
args.maybe_detected_model_family.map(|family| family.to_str()),
|
||||
args.maybe_detected_model_type.map(|model_type| model_type.to_str()),
|
||||
args.maybe_report,
|
||||
args.maybe_updated_ip_address,
|
||||
args.token.as_str(),
|
||||
)
|
||||
.execute(args.mysql_executor)
|
||||
.await?;
|
||||
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
use enums::by_table::uploaded_videos::uploaded_video_detected_model_family::UploadedVideoDetectedModelFamily;
|
||||
use enums::by_table::uploaded_videos::uploaded_video_detected_model_type::UploadedVideoDetectedModelType;
|
||||
use tokens::tokens::uploaded_videos::UploadedVideoToken;
|
||||
|
||||
/// A materialized `uploaded_videos` row (minus the internal `id`).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UploadedVideoRow {
|
||||
pub token: UploadedVideoToken,
|
||||
|
||||
pub sha1_checksum: String,
|
||||
pub filesize_bytes: u32,
|
||||
|
||||
pub maybe_width: Option<u32>,
|
||||
pub maybe_height: Option<u32>,
|
||||
pub maybe_resolution: Option<String>,
|
||||
|
||||
pub maybe_detected_model_family: Option<UploadedVideoDetectedModelFamily>,
|
||||
pub maybe_detected_model_type: Option<UploadedVideoDetectedModelType>,
|
||||
pub maybe_report: Option<String>,
|
||||
|
||||
pub upload_ip_address: String,
|
||||
pub maybe_updated_ip_address: Option<String>,
|
||||
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
@@ -21,6 +21,8 @@ pub mod staff_audit_logs;
|
||||
pub mod tag_uses;
|
||||
pub mod trending_model_analytics;
|
||||
pub mod tts_models;
|
||||
pub mod uploaded_video_notes;
|
||||
pub mod uploaded_videos;
|
||||
pub mod usages;
|
||||
pub mod user_bookmarks;
|
||||
pub mod user_ratings;
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod uploaded_video_note_reported_model_type;
|
||||
pub mod uploaded_video_note_reported_website;
|
||||
+253
@@ -0,0 +1,253 @@
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
#[cfg(test)]
|
||||
use strum::EnumCount;
|
||||
#[cfg(test)]
|
||||
use strum::EnumIter;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
/// Used in the `uploaded_video_notes` table in a `VARCHAR(32)` field.
|
||||
///
|
||||
/// The model the note's submitter claims the video is. The variant set mirrors
|
||||
/// [`crate::common::generation::common_video_model::CommonVideoModel`].
|
||||
///
|
||||
/// DO NOT CHANGE VALUES WITHOUT A MIGRATION STRATEGY.
|
||||
#[cfg_attr(test, derive(EnumIter, EnumCount))]
|
||||
#[derive(Clone, Copy, Eq, PartialEq, Hash, Ord, PartialOrd, Deserialize, Serialize, ToSchema)]
|
||||
pub enum UploadedVideoNoteReportedModelType {
|
||||
#[serde(rename = "grok_video")]
|
||||
GrokVideo,
|
||||
#[serde(rename = "grok_imagine_video")]
|
||||
GrokImagineVideo,
|
||||
#[serde(rename = "grok_imagine_video_1p5")]
|
||||
GrokImagineVideo1p5,
|
||||
#[serde(rename = "kling_1p6_pro")]
|
||||
Kling16Pro,
|
||||
#[serde(rename = "kling_2p1_pro")]
|
||||
Kling21Pro,
|
||||
#[serde(rename = "kling_2p1_master")]
|
||||
Kling21Master,
|
||||
#[serde(rename = "kling_2p5_turbo_pro")]
|
||||
Kling2p5TurboPro,
|
||||
#[serde(rename = "kling_2p6_pro")]
|
||||
Kling2p6Pro,
|
||||
#[serde(rename = "kling_3p0_standard")]
|
||||
Kling3p0Standard,
|
||||
#[serde(rename = "kling_3p0_pro")]
|
||||
Kling3p0Pro,
|
||||
#[serde(rename = "happy_horse_1p0")]
|
||||
HappyHorse1p0,
|
||||
#[serde(rename = "seedance_1p0_lite")]
|
||||
Seedance10Lite,
|
||||
#[serde(rename = "seedance_1p5_pro")]
|
||||
Seedance1p5Pro,
|
||||
// `preview_model` is a retired alias that collapses onto Seedance 2.0.
|
||||
#[serde(rename = "seedance_2p0", alias = "preview_model")]
|
||||
Seedance2p0,
|
||||
// `preview_model_fast` is a retired alias that collapses onto Seedance 2.0 Fast.
|
||||
#[serde(rename = "seedance_2p0_fast", alias = "preview_model_fast")]
|
||||
Seedance2p0Fast,
|
||||
#[serde(rename = "seedance_2p0_bp")]
|
||||
Seedance2p0BytePlus,
|
||||
#[serde(rename = "seedance_2p0_bp_fast")]
|
||||
Seedance2p0BytePlusFast,
|
||||
#[serde(rename = "seedance_2p0_u")]
|
||||
Seedance2p0Ultra,
|
||||
#[serde(rename = "seedance_2p0_u_fast")]
|
||||
Seedance2p0UltraFast,
|
||||
#[serde(rename = "seedance_2p0_bpu")]
|
||||
Seedance2p0BytePlusUltra,
|
||||
#[serde(rename = "seedance_2p0_bpu_fast")]
|
||||
Seedance2p0BytePlusUltraFast,
|
||||
#[serde(rename = "sora_2")]
|
||||
Sora2,
|
||||
#[serde(rename = "sora_2_pro")]
|
||||
Sora2Pro,
|
||||
#[serde(rename = "veo_2")]
|
||||
Veo2,
|
||||
#[serde(rename = "veo_3")]
|
||||
Veo3,
|
||||
#[serde(rename = "veo_3_fast")]
|
||||
Veo3Fast,
|
||||
#[serde(rename = "veo_3p1")]
|
||||
Veo3p1,
|
||||
#[serde(rename = "veo_3p1_fast")]
|
||||
Veo3p1Fast,
|
||||
}
|
||||
|
||||
impl_enum_display_and_debug_using_to_str!(UploadedVideoNoteReportedModelType);
|
||||
impl_mysql_enum_coders!(UploadedVideoNoteReportedModelType);
|
||||
impl_mysql_from_row!(UploadedVideoNoteReportedModelType);
|
||||
|
||||
impl UploadedVideoNoteReportedModelType {
|
||||
pub fn to_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::GrokVideo => "grok_video",
|
||||
Self::GrokImagineVideo => "grok_imagine_video",
|
||||
Self::GrokImagineVideo1p5 => "grok_imagine_video_1p5",
|
||||
Self::Kling16Pro => "kling_1p6_pro",
|
||||
Self::Kling21Pro => "kling_2p1_pro",
|
||||
Self::Kling21Master => "kling_2p1_master",
|
||||
Self::Kling2p5TurboPro => "kling_2p5_turbo_pro",
|
||||
Self::Kling2p6Pro => "kling_2p6_pro",
|
||||
Self::Kling3p0Standard => "kling_3p0_standard",
|
||||
Self::Kling3p0Pro => "kling_3p0_pro",
|
||||
Self::HappyHorse1p0 => "happy_horse_1p0",
|
||||
Self::Seedance10Lite => "seedance_1p0_lite",
|
||||
Self::Seedance1p5Pro => "seedance_1p5_pro",
|
||||
Self::Seedance2p0 => "seedance_2p0",
|
||||
Self::Seedance2p0Fast => "seedance_2p0_fast",
|
||||
Self::Seedance2p0BytePlus => "seedance_2p0_bp",
|
||||
Self::Seedance2p0BytePlusFast => "seedance_2p0_bp_fast",
|
||||
Self::Seedance2p0Ultra => "seedance_2p0_u",
|
||||
Self::Seedance2p0UltraFast => "seedance_2p0_u_fast",
|
||||
Self::Seedance2p0BytePlusUltra => "seedance_2p0_bpu",
|
||||
Self::Seedance2p0BytePlusUltraFast => "seedance_2p0_bpu_fast",
|
||||
Self::Sora2 => "sora_2",
|
||||
Self::Sora2Pro => "sora_2_pro",
|
||||
Self::Veo2 => "veo_2",
|
||||
Self::Veo3 => "veo_3",
|
||||
Self::Veo3Fast => "veo_3_fast",
|
||||
Self::Veo3p1 => "veo_3p1",
|
||||
Self::Veo3p1Fast => "veo_3p1_fast",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_str(value: &str) -> Result<Self, String> {
|
||||
match value {
|
||||
"grok_video" => Ok(Self::GrokVideo),
|
||||
"grok_imagine_video" => Ok(Self::GrokImagineVideo),
|
||||
"grok_imagine_video_1p5" => Ok(Self::GrokImagineVideo1p5),
|
||||
"kling_1p6_pro" => Ok(Self::Kling16Pro),
|
||||
"kling_2p1_pro" => Ok(Self::Kling21Pro),
|
||||
"kling_2p1_master" => Ok(Self::Kling21Master),
|
||||
"kling_2p5_turbo_pro" => Ok(Self::Kling2p5TurboPro),
|
||||
"kling_2p6_pro" => Ok(Self::Kling2p6Pro),
|
||||
"kling_3p0_standard" => Ok(Self::Kling3p0Standard),
|
||||
"kling_3p0_pro" => Ok(Self::Kling3p0Pro),
|
||||
"happy_horse_1p0" => Ok(Self::HappyHorse1p0),
|
||||
"seedance_1p0_lite" => Ok(Self::Seedance10Lite),
|
||||
"seedance_1p5_pro" => Ok(Self::Seedance1p5Pro),
|
||||
"seedance_2p0" => Ok(Self::Seedance2p0),
|
||||
"seedance_2p0_fast" => Ok(Self::Seedance2p0Fast),
|
||||
"seedance_2p0_bp" => Ok(Self::Seedance2p0BytePlus),
|
||||
"seedance_2p0_bp_fast" => Ok(Self::Seedance2p0BytePlusFast),
|
||||
"seedance_2p0_u" => Ok(Self::Seedance2p0Ultra),
|
||||
"seedance_2p0_u_fast" => Ok(Self::Seedance2p0UltraFast),
|
||||
"seedance_2p0_bpu" => Ok(Self::Seedance2p0BytePlusUltra),
|
||||
"seedance_2p0_bpu_fast" => Ok(Self::Seedance2p0BytePlusUltraFast),
|
||||
"sora_2" => Ok(Self::Sora2),
|
||||
"sora_2_pro" => Ok(Self::Sora2Pro),
|
||||
"veo_2" => Ok(Self::Veo2),
|
||||
"veo_3" => Ok(Self::Veo3),
|
||||
"veo_3_fast" => Ok(Self::Veo3Fast),
|
||||
"veo_3p1" => Ok(Self::Veo3p1),
|
||||
"veo_3p1_fast" => Ok(Self::Veo3p1Fast),
|
||||
// Retired values: collapse onto the Seedance 2.0 equivalents.
|
||||
"preview_model" => Ok(Self::Seedance2p0),
|
||||
"preview_model_fast" => Ok(Self::Seedance2p0Fast),
|
||||
_ => Err(format!("invalid value: {:?}", value)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn all_variants() -> BTreeSet<Self> {
|
||||
BTreeSet::from([
|
||||
Self::GrokVideo,
|
||||
Self::GrokImagineVideo,
|
||||
Self::GrokImagineVideo1p5,
|
||||
Self::Kling16Pro,
|
||||
Self::Kling21Pro,
|
||||
Self::Kling21Master,
|
||||
Self::Kling2p5TurboPro,
|
||||
Self::Kling2p6Pro,
|
||||
Self::Kling3p0Standard,
|
||||
Self::Kling3p0Pro,
|
||||
Self::HappyHorse1p0,
|
||||
Self::Seedance10Lite,
|
||||
Self::Seedance1p5Pro,
|
||||
Self::Seedance2p0,
|
||||
Self::Seedance2p0Fast,
|
||||
Self::Seedance2p0BytePlus,
|
||||
Self::Seedance2p0BytePlusFast,
|
||||
Self::Seedance2p0Ultra,
|
||||
Self::Seedance2p0UltraFast,
|
||||
Self::Seedance2p0BytePlusUltra,
|
||||
Self::Seedance2p0BytePlusUltraFast,
|
||||
Self::Sora2,
|
||||
Self::Sora2Pro,
|
||||
Self::Veo2,
|
||||
Self::Veo3,
|
||||
Self::Veo3Fast,
|
||||
Self::Veo3p1,
|
||||
Self::Veo3p1Fast,
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn round_trip() {
|
||||
for variant in UploadedVideoNoteReportedModelType::all_variants() {
|
||||
assert_eq!(variant, UploadedVideoNoteReportedModelType::from_str(variant.to_str()).unwrap());
|
||||
assert_eq!(variant, UploadedVideoNoteReportedModelType::from_str(&format!("{}", variant)).unwrap());
|
||||
assert_eq!(variant, UploadedVideoNoteReportedModelType::from_str(&format!("{:?}", variant)).unwrap());
|
||||
}
|
||||
assert!(UploadedVideoNoteReportedModelType::from_str("foo").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde_round_trip() {
|
||||
for variant in UploadedVideoNoteReportedModelType::all_variants() {
|
||||
let json = serde_json::to_string(&variant).unwrap();
|
||||
let deserialized: UploadedVideoNoteReportedModelType = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(variant, deserialized);
|
||||
}
|
||||
}
|
||||
|
||||
/// The MySQL string (to_str) and the JSON string (serde) must stay aligned.
|
||||
#[test]
|
||||
fn to_str_matches_serde() {
|
||||
for variant in UploadedVideoNoteReportedModelType::all_variants() {
|
||||
let json = serde_json::to_string(&variant).unwrap();
|
||||
assert_eq!(json, format!("\"{}\"", variant.to_str()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variant_count() {
|
||||
use strum::IntoEnumIterator;
|
||||
assert_eq!(UploadedVideoNoteReportedModelType::all_variants().len(), UploadedVideoNoteReportedModelType::iter().len());
|
||||
assert_eq!(UploadedVideoNoteReportedModelType::COUNT, 28);
|
||||
}
|
||||
|
||||
/// Retired `preview_model*` values must still decode, collapsing onto Seedance 2.0.
|
||||
#[test]
|
||||
fn legacy_preview_values_map_to_seedance() {
|
||||
assert_eq!(
|
||||
UploadedVideoNoteReportedModelType::from_str("preview_model").unwrap(),
|
||||
UploadedVideoNoteReportedModelType::Seedance2p0
|
||||
);
|
||||
assert_eq!(
|
||||
UploadedVideoNoteReportedModelType::from_str("preview_model_fast").unwrap(),
|
||||
UploadedVideoNoteReportedModelType::Seedance2p0Fast
|
||||
);
|
||||
let decoded: UploadedVideoNoteReportedModelType =
|
||||
serde_json::from_str("\"preview_model\"").unwrap();
|
||||
assert_eq!(decoded, UploadedVideoNoteReportedModelType::Seedance2p0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialized_length_ok_for_database() {
|
||||
const MAX_LENGTH: usize = 32;
|
||||
for variant in UploadedVideoNoteReportedModelType::all_variants() {
|
||||
let serialized = variant.to_str();
|
||||
assert!(!serialized.is_empty(), "variant {:?} is too short", variant);
|
||||
assert!(serialized.len() <= MAX_LENGTH, "variant {:?} is too long", variant);
|
||||
}
|
||||
}
|
||||
}
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
#[cfg(test)]
|
||||
use strum::EnumCount;
|
||||
#[cfg(test)]
|
||||
use strum::EnumIter;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
/// Used in the `uploaded_video_notes` table in a `VARCHAR(32)` field.
|
||||
///
|
||||
/// The website / platform the note's submitter says the video came from.
|
||||
///
|
||||
/// DO NOT CHANGE VALUES WITHOUT A MIGRATION STRATEGY.
|
||||
#[cfg_attr(test, derive(EnumIter, EnumCount))]
|
||||
#[derive(Clone, Copy, Eq, PartialEq, Hash, Ord, PartialOrd, Deserialize, Serialize, ToSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum UploadedVideoNoteReportedWebsite {
|
||||
Runway,
|
||||
Higgsfield,
|
||||
Krea,
|
||||
OpenArt,
|
||||
Artcraft,
|
||||
Magnific,
|
||||
FreePik,
|
||||
}
|
||||
|
||||
impl_enum_display_and_debug_using_to_str!(UploadedVideoNoteReportedWebsite);
|
||||
impl_mysql_enum_coders!(UploadedVideoNoteReportedWebsite);
|
||||
impl_mysql_from_row!(UploadedVideoNoteReportedWebsite);
|
||||
|
||||
impl UploadedVideoNoteReportedWebsite {
|
||||
pub fn to_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Runway => "runway",
|
||||
Self::Higgsfield => "higgsfield",
|
||||
Self::Krea => "krea",
|
||||
Self::OpenArt => "open_art",
|
||||
Self::Artcraft => "artcraft",
|
||||
Self::Magnific => "magnific",
|
||||
Self::FreePik => "free_pik",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_str(value: &str) -> Result<Self, String> {
|
||||
match value {
|
||||
"runway" => Ok(Self::Runway),
|
||||
"higgsfield" => Ok(Self::Higgsfield),
|
||||
"krea" => Ok(Self::Krea),
|
||||
"open_art" => Ok(Self::OpenArt),
|
||||
"artcraft" => Ok(Self::Artcraft),
|
||||
"magnific" => Ok(Self::Magnific),
|
||||
"free_pik" => Ok(Self::FreePik),
|
||||
_ => Err(format!("invalid value: {:?}", value)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn all_variants() -> BTreeSet<Self> {
|
||||
BTreeSet::from([
|
||||
Self::Runway,
|
||||
Self::Higgsfield,
|
||||
Self::Krea,
|
||||
Self::OpenArt,
|
||||
Self::Artcraft,
|
||||
Self::Magnific,
|
||||
Self::FreePik,
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::test_helpers::assert_serialization;
|
||||
|
||||
#[test]
|
||||
fn test_serialization() {
|
||||
assert_serialization(UploadedVideoNoteReportedWebsite::Runway, "runway");
|
||||
assert_serialization(UploadedVideoNoteReportedWebsite::Higgsfield, "higgsfield");
|
||||
assert_serialization(UploadedVideoNoteReportedWebsite::Krea, "krea");
|
||||
assert_serialization(UploadedVideoNoteReportedWebsite::OpenArt, "open_art");
|
||||
assert_serialization(UploadedVideoNoteReportedWebsite::Artcraft, "artcraft");
|
||||
assert_serialization(UploadedVideoNoteReportedWebsite::Magnific, "magnific");
|
||||
assert_serialization(UploadedVideoNoteReportedWebsite::FreePik, "free_pik");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip() {
|
||||
for variant in UploadedVideoNoteReportedWebsite::all_variants() {
|
||||
assert_eq!(variant, UploadedVideoNoteReportedWebsite::from_str(variant.to_str()).unwrap());
|
||||
assert_eq!(variant, UploadedVideoNoteReportedWebsite::from_str(&format!("{}", variant)).unwrap());
|
||||
assert_eq!(variant, UploadedVideoNoteReportedWebsite::from_str(&format!("{:?}", variant)).unwrap());
|
||||
}
|
||||
assert!(UploadedVideoNoteReportedWebsite::from_str("foo").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_str_matches_serde() {
|
||||
for variant in UploadedVideoNoteReportedWebsite::all_variants() {
|
||||
let json = serde_json::to_string(&variant).unwrap();
|
||||
assert_eq!(json, format!("\"{}\"", variant.to_str()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variant_count() {
|
||||
use strum::IntoEnumIterator;
|
||||
assert_eq!(UploadedVideoNoteReportedWebsite::all_variants().len(), UploadedVideoNoteReportedWebsite::iter().len());
|
||||
assert_eq!(UploadedVideoNoteReportedWebsite::COUNT, 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialized_length_ok_for_database() {
|
||||
const MAX_LENGTH: usize = 32;
|
||||
for variant in UploadedVideoNoteReportedWebsite::all_variants() {
|
||||
let serialized = variant.to_str();
|
||||
assert!(!serialized.is_empty(), "variant {:?} is too short", variant);
|
||||
assert!(serialized.len() <= MAX_LENGTH, "variant {:?} is too long", variant);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod uploaded_video_detected_model_family;
|
||||
pub mod uploaded_video_detected_model_type;
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
#[cfg(test)]
|
||||
use strum::EnumCount;
|
||||
#[cfg(test)]
|
||||
use strum::EnumIter;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
/// Used in the `uploaded_videos` table in a `VARCHAR(32)` field.
|
||||
///
|
||||
/// The detected provenance family of an uploaded video.
|
||||
///
|
||||
/// DO NOT CHANGE VALUES WITHOUT A MIGRATION STRATEGY.
|
||||
#[cfg_attr(test, derive(EnumIter, EnumCount))]
|
||||
#[derive(Clone, Copy, Eq, PartialEq, Hash, Ord, PartialOrd, Deserialize, Serialize, ToSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum UploadedVideoDetectedModelFamily {
|
||||
Seedance,
|
||||
Veo,
|
||||
Sora,
|
||||
Kling,
|
||||
Grok,
|
||||
HappyHorse,
|
||||
}
|
||||
|
||||
impl_enum_display_and_debug_using_to_str!(UploadedVideoDetectedModelFamily);
|
||||
impl_mysql_enum_coders!(UploadedVideoDetectedModelFamily);
|
||||
impl_mysql_from_row!(UploadedVideoDetectedModelFamily);
|
||||
|
||||
impl UploadedVideoDetectedModelFamily {
|
||||
pub fn to_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Seedance => "seedance",
|
||||
Self::Veo => "veo",
|
||||
Self::Sora => "sora",
|
||||
Self::Kling => "kling",
|
||||
Self::Grok => "grok",
|
||||
Self::HappyHorse => "happy_horse",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_str(value: &str) -> Result<Self, String> {
|
||||
match value {
|
||||
"seedance" => Ok(Self::Seedance),
|
||||
"veo" => Ok(Self::Veo),
|
||||
"sora" => Ok(Self::Sora),
|
||||
"kling" => Ok(Self::Kling),
|
||||
"grok" => Ok(Self::Grok),
|
||||
"happy_horse" => Ok(Self::HappyHorse),
|
||||
_ => Err(format!("invalid value: {:?}", value)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn all_variants() -> BTreeSet<Self> {
|
||||
BTreeSet::from([
|
||||
Self::Seedance,
|
||||
Self::Veo,
|
||||
Self::Sora,
|
||||
Self::Kling,
|
||||
Self::Grok,
|
||||
Self::HappyHorse,
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::test_helpers::assert_serialization;
|
||||
|
||||
#[test]
|
||||
fn test_serialization() {
|
||||
assert_serialization(UploadedVideoDetectedModelFamily::Seedance, "seedance");
|
||||
assert_serialization(UploadedVideoDetectedModelFamily::Veo, "veo");
|
||||
assert_serialization(UploadedVideoDetectedModelFamily::Sora, "sora");
|
||||
assert_serialization(UploadedVideoDetectedModelFamily::Kling, "kling");
|
||||
assert_serialization(UploadedVideoDetectedModelFamily::Grok, "grok");
|
||||
assert_serialization(UploadedVideoDetectedModelFamily::HappyHorse, "happy_horse");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip() {
|
||||
for variant in UploadedVideoDetectedModelFamily::all_variants() {
|
||||
assert_eq!(variant, UploadedVideoDetectedModelFamily::from_str(variant.to_str()).unwrap());
|
||||
assert_eq!(variant, UploadedVideoDetectedModelFamily::from_str(&format!("{}", variant)).unwrap());
|
||||
assert_eq!(variant, UploadedVideoDetectedModelFamily::from_str(&format!("{:?}", variant)).unwrap());
|
||||
}
|
||||
assert!(UploadedVideoDetectedModelFamily::from_str("foo").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_str_matches_serde() {
|
||||
for variant in UploadedVideoDetectedModelFamily::all_variants() {
|
||||
let json = serde_json::to_string(&variant).unwrap();
|
||||
assert_eq!(json, format!("\"{}\"", variant.to_str()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variant_count() {
|
||||
use strum::IntoEnumIterator;
|
||||
assert_eq!(UploadedVideoDetectedModelFamily::all_variants().len(), UploadedVideoDetectedModelFamily::iter().len());
|
||||
assert_eq!(UploadedVideoDetectedModelFamily::COUNT, 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialized_length_ok_for_database() {
|
||||
const MAX_LENGTH: usize = 32;
|
||||
for variant in UploadedVideoDetectedModelFamily::all_variants() {
|
||||
let serialized = variant.to_str();
|
||||
assert!(!serialized.is_empty(), "variant {:?} is too short", variant);
|
||||
assert!(serialized.len() <= MAX_LENGTH, "variant {:?} is too long", variant);
|
||||
}
|
||||
}
|
||||
}
|
||||
+253
@@ -0,0 +1,253 @@
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
#[cfg(test)]
|
||||
use strum::EnumCount;
|
||||
#[cfg(test)]
|
||||
use strum::EnumIter;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
/// Used in the `uploaded_videos` table in a `VARCHAR(32)` field.
|
||||
///
|
||||
/// The detected model / variant of an uploaded video. The variant set mirrors
|
||||
/// [`crate::common::generation::common_video_model::CommonVideoModel`].
|
||||
///
|
||||
/// DO NOT CHANGE VALUES WITHOUT A MIGRATION STRATEGY.
|
||||
#[cfg_attr(test, derive(EnumIter, EnumCount))]
|
||||
#[derive(Clone, Copy, Eq, PartialEq, Hash, Ord, PartialOrd, Deserialize, Serialize, ToSchema)]
|
||||
pub enum UploadedVideoDetectedModelType {
|
||||
#[serde(rename = "grok_video")]
|
||||
GrokVideo,
|
||||
#[serde(rename = "grok_imagine_video")]
|
||||
GrokImagineVideo,
|
||||
#[serde(rename = "grok_imagine_video_1p5")]
|
||||
GrokImagineVideo1p5,
|
||||
#[serde(rename = "kling_1p6_pro")]
|
||||
Kling16Pro,
|
||||
#[serde(rename = "kling_2p1_pro")]
|
||||
Kling21Pro,
|
||||
#[serde(rename = "kling_2p1_master")]
|
||||
Kling21Master,
|
||||
#[serde(rename = "kling_2p5_turbo_pro")]
|
||||
Kling2p5TurboPro,
|
||||
#[serde(rename = "kling_2p6_pro")]
|
||||
Kling2p6Pro,
|
||||
#[serde(rename = "kling_3p0_standard")]
|
||||
Kling3p0Standard,
|
||||
#[serde(rename = "kling_3p0_pro")]
|
||||
Kling3p0Pro,
|
||||
#[serde(rename = "happy_horse_1p0")]
|
||||
HappyHorse1p0,
|
||||
#[serde(rename = "seedance_1p0_lite")]
|
||||
Seedance10Lite,
|
||||
#[serde(rename = "seedance_1p5_pro")]
|
||||
Seedance1p5Pro,
|
||||
// `preview_model` is a retired alias that collapses onto Seedance 2.0.
|
||||
#[serde(rename = "seedance_2p0", alias = "preview_model")]
|
||||
Seedance2p0,
|
||||
// `preview_model_fast` is a retired alias that collapses onto Seedance 2.0 Fast.
|
||||
#[serde(rename = "seedance_2p0_fast", alias = "preview_model_fast")]
|
||||
Seedance2p0Fast,
|
||||
#[serde(rename = "seedance_2p0_bp")]
|
||||
Seedance2p0BytePlus,
|
||||
#[serde(rename = "seedance_2p0_bp_fast")]
|
||||
Seedance2p0BytePlusFast,
|
||||
#[serde(rename = "seedance_2p0_u")]
|
||||
Seedance2p0Ultra,
|
||||
#[serde(rename = "seedance_2p0_u_fast")]
|
||||
Seedance2p0UltraFast,
|
||||
#[serde(rename = "seedance_2p0_bpu")]
|
||||
Seedance2p0BytePlusUltra,
|
||||
#[serde(rename = "seedance_2p0_bpu_fast")]
|
||||
Seedance2p0BytePlusUltraFast,
|
||||
#[serde(rename = "sora_2")]
|
||||
Sora2,
|
||||
#[serde(rename = "sora_2_pro")]
|
||||
Sora2Pro,
|
||||
#[serde(rename = "veo_2")]
|
||||
Veo2,
|
||||
#[serde(rename = "veo_3")]
|
||||
Veo3,
|
||||
#[serde(rename = "veo_3_fast")]
|
||||
Veo3Fast,
|
||||
#[serde(rename = "veo_3p1")]
|
||||
Veo3p1,
|
||||
#[serde(rename = "veo_3p1_fast")]
|
||||
Veo3p1Fast,
|
||||
}
|
||||
|
||||
impl_enum_display_and_debug_using_to_str!(UploadedVideoDetectedModelType);
|
||||
impl_mysql_enum_coders!(UploadedVideoDetectedModelType);
|
||||
impl_mysql_from_row!(UploadedVideoDetectedModelType);
|
||||
|
||||
impl UploadedVideoDetectedModelType {
|
||||
pub fn to_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::GrokVideo => "grok_video",
|
||||
Self::GrokImagineVideo => "grok_imagine_video",
|
||||
Self::GrokImagineVideo1p5 => "grok_imagine_video_1p5",
|
||||
Self::Kling16Pro => "kling_1p6_pro",
|
||||
Self::Kling21Pro => "kling_2p1_pro",
|
||||
Self::Kling21Master => "kling_2p1_master",
|
||||
Self::Kling2p5TurboPro => "kling_2p5_turbo_pro",
|
||||
Self::Kling2p6Pro => "kling_2p6_pro",
|
||||
Self::Kling3p0Standard => "kling_3p0_standard",
|
||||
Self::Kling3p0Pro => "kling_3p0_pro",
|
||||
Self::HappyHorse1p0 => "happy_horse_1p0",
|
||||
Self::Seedance10Lite => "seedance_1p0_lite",
|
||||
Self::Seedance1p5Pro => "seedance_1p5_pro",
|
||||
Self::Seedance2p0 => "seedance_2p0",
|
||||
Self::Seedance2p0Fast => "seedance_2p0_fast",
|
||||
Self::Seedance2p0BytePlus => "seedance_2p0_bp",
|
||||
Self::Seedance2p0BytePlusFast => "seedance_2p0_bp_fast",
|
||||
Self::Seedance2p0Ultra => "seedance_2p0_u",
|
||||
Self::Seedance2p0UltraFast => "seedance_2p0_u_fast",
|
||||
Self::Seedance2p0BytePlusUltra => "seedance_2p0_bpu",
|
||||
Self::Seedance2p0BytePlusUltraFast => "seedance_2p0_bpu_fast",
|
||||
Self::Sora2 => "sora_2",
|
||||
Self::Sora2Pro => "sora_2_pro",
|
||||
Self::Veo2 => "veo_2",
|
||||
Self::Veo3 => "veo_3",
|
||||
Self::Veo3Fast => "veo_3_fast",
|
||||
Self::Veo3p1 => "veo_3p1",
|
||||
Self::Veo3p1Fast => "veo_3p1_fast",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_str(value: &str) -> Result<Self, String> {
|
||||
match value {
|
||||
"grok_video" => Ok(Self::GrokVideo),
|
||||
"grok_imagine_video" => Ok(Self::GrokImagineVideo),
|
||||
"grok_imagine_video_1p5" => Ok(Self::GrokImagineVideo1p5),
|
||||
"kling_1p6_pro" => Ok(Self::Kling16Pro),
|
||||
"kling_2p1_pro" => Ok(Self::Kling21Pro),
|
||||
"kling_2p1_master" => Ok(Self::Kling21Master),
|
||||
"kling_2p5_turbo_pro" => Ok(Self::Kling2p5TurboPro),
|
||||
"kling_2p6_pro" => Ok(Self::Kling2p6Pro),
|
||||
"kling_3p0_standard" => Ok(Self::Kling3p0Standard),
|
||||
"kling_3p0_pro" => Ok(Self::Kling3p0Pro),
|
||||
"happy_horse_1p0" => Ok(Self::HappyHorse1p0),
|
||||
"seedance_1p0_lite" => Ok(Self::Seedance10Lite),
|
||||
"seedance_1p5_pro" => Ok(Self::Seedance1p5Pro),
|
||||
"seedance_2p0" => Ok(Self::Seedance2p0),
|
||||
"seedance_2p0_fast" => Ok(Self::Seedance2p0Fast),
|
||||
"seedance_2p0_bp" => Ok(Self::Seedance2p0BytePlus),
|
||||
"seedance_2p0_bp_fast" => Ok(Self::Seedance2p0BytePlusFast),
|
||||
"seedance_2p0_u" => Ok(Self::Seedance2p0Ultra),
|
||||
"seedance_2p0_u_fast" => Ok(Self::Seedance2p0UltraFast),
|
||||
"seedance_2p0_bpu" => Ok(Self::Seedance2p0BytePlusUltra),
|
||||
"seedance_2p0_bpu_fast" => Ok(Self::Seedance2p0BytePlusUltraFast),
|
||||
"sora_2" => Ok(Self::Sora2),
|
||||
"sora_2_pro" => Ok(Self::Sora2Pro),
|
||||
"veo_2" => Ok(Self::Veo2),
|
||||
"veo_3" => Ok(Self::Veo3),
|
||||
"veo_3_fast" => Ok(Self::Veo3Fast),
|
||||
"veo_3p1" => Ok(Self::Veo3p1),
|
||||
"veo_3p1_fast" => Ok(Self::Veo3p1Fast),
|
||||
// Retired values: collapse onto the Seedance 2.0 equivalents.
|
||||
"preview_model" => Ok(Self::Seedance2p0),
|
||||
"preview_model_fast" => Ok(Self::Seedance2p0Fast),
|
||||
_ => Err(format!("invalid value: {:?}", value)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn all_variants() -> BTreeSet<Self> {
|
||||
BTreeSet::from([
|
||||
Self::GrokVideo,
|
||||
Self::GrokImagineVideo,
|
||||
Self::GrokImagineVideo1p5,
|
||||
Self::Kling16Pro,
|
||||
Self::Kling21Pro,
|
||||
Self::Kling21Master,
|
||||
Self::Kling2p5TurboPro,
|
||||
Self::Kling2p6Pro,
|
||||
Self::Kling3p0Standard,
|
||||
Self::Kling3p0Pro,
|
||||
Self::HappyHorse1p0,
|
||||
Self::Seedance10Lite,
|
||||
Self::Seedance1p5Pro,
|
||||
Self::Seedance2p0,
|
||||
Self::Seedance2p0Fast,
|
||||
Self::Seedance2p0BytePlus,
|
||||
Self::Seedance2p0BytePlusFast,
|
||||
Self::Seedance2p0Ultra,
|
||||
Self::Seedance2p0UltraFast,
|
||||
Self::Seedance2p0BytePlusUltra,
|
||||
Self::Seedance2p0BytePlusUltraFast,
|
||||
Self::Sora2,
|
||||
Self::Sora2Pro,
|
||||
Self::Veo2,
|
||||
Self::Veo3,
|
||||
Self::Veo3Fast,
|
||||
Self::Veo3p1,
|
||||
Self::Veo3p1Fast,
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn round_trip() {
|
||||
for variant in UploadedVideoDetectedModelType::all_variants() {
|
||||
assert_eq!(variant, UploadedVideoDetectedModelType::from_str(variant.to_str()).unwrap());
|
||||
assert_eq!(variant, UploadedVideoDetectedModelType::from_str(&format!("{}", variant)).unwrap());
|
||||
assert_eq!(variant, UploadedVideoDetectedModelType::from_str(&format!("{:?}", variant)).unwrap());
|
||||
}
|
||||
assert!(UploadedVideoDetectedModelType::from_str("foo").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde_round_trip() {
|
||||
for variant in UploadedVideoDetectedModelType::all_variants() {
|
||||
let json = serde_json::to_string(&variant).unwrap();
|
||||
let deserialized: UploadedVideoDetectedModelType = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(variant, deserialized);
|
||||
}
|
||||
}
|
||||
|
||||
/// The MySQL string (to_str) and the JSON string (serde) must stay aligned.
|
||||
#[test]
|
||||
fn to_str_matches_serde() {
|
||||
for variant in UploadedVideoDetectedModelType::all_variants() {
|
||||
let json = serde_json::to_string(&variant).unwrap();
|
||||
assert_eq!(json, format!("\"{}\"", variant.to_str()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variant_count() {
|
||||
use strum::IntoEnumIterator;
|
||||
assert_eq!(UploadedVideoDetectedModelType::all_variants().len(), UploadedVideoDetectedModelType::iter().len());
|
||||
assert_eq!(UploadedVideoDetectedModelType::COUNT, 28);
|
||||
}
|
||||
|
||||
/// Retired `preview_model*` values must still decode, collapsing onto Seedance 2.0.
|
||||
#[test]
|
||||
fn legacy_preview_values_map_to_seedance() {
|
||||
assert_eq!(
|
||||
UploadedVideoDetectedModelType::from_str("preview_model").unwrap(),
|
||||
UploadedVideoDetectedModelType::Seedance2p0
|
||||
);
|
||||
assert_eq!(
|
||||
UploadedVideoDetectedModelType::from_str("preview_model_fast").unwrap(),
|
||||
UploadedVideoDetectedModelType::Seedance2p0Fast
|
||||
);
|
||||
let decoded: UploadedVideoDetectedModelType =
|
||||
serde_json::from_str("\"preview_model\"").unwrap();
|
||||
assert_eq!(decoded, UploadedVideoDetectedModelType::Seedance2p0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialized_length_ok_for_database() {
|
||||
const MAX_LENGTH: usize = 32;
|
||||
for variant in UploadedVideoDetectedModelType::all_variants() {
|
||||
let serialized = variant.to_str();
|
||||
assert!(!serialized.is_empty(), "variant {:?} is too short", variant);
|
||||
assert!(serialized.len() <= MAX_LENGTH, "variant {:?} is too long", variant);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,8 @@ pub(crate) enum TokenPrefix {
|
||||
StaffAuditLog,
|
||||
Tag,
|
||||
TtsRenderTask, // NB: aichatbot / sqlite
|
||||
UploadedVideo,
|
||||
UploadedVideoNote,
|
||||
User,
|
||||
UserImpersonationRequest,
|
||||
UserBookmark,
|
||||
@@ -136,6 +138,8 @@ impl PrefixGenerator for TokenPrefix {
|
||||
Self::StaffAuditLog => "stfaud_",
|
||||
Self::Tag => "tag_",
|
||||
Self::TtsRenderTask => "tts_task_",
|
||||
Self::UploadedVideo => "uvt_",
|
||||
Self::UploadedVideoNote => "uvnote_",
|
||||
Self::User => "user_", // NB: Previously "U:"
|
||||
Self::UserImpersonationRequest => "uimpr_",
|
||||
Self::UserBookmark => "ub_",
|
||||
|
||||
@@ -31,6 +31,8 @@ pub mod tts_results;
|
||||
pub mod twitch_event_rule;
|
||||
pub mod twitch_oauth_tokens_grouping;
|
||||
pub mod twitch_oauth_tokens_internal;
|
||||
pub mod uploaded_video_notes;
|
||||
pub mod uploaded_videos;
|
||||
pub mod user_bookmarks;
|
||||
pub mod user_impersonation_requests;
|
||||
pub mod user_referral_codes;
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
use std::fmt::Debug;
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::prefixes::TokenPrefix;
|
||||
|
||||
/// The primary key for uploaded_video_notes.
|
||||
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Serialize, Deserialize, ToSchema)]
|
||||
#[cfg_attr(feature = "database", derive(sqlx::Type))]
|
||||
#[cfg_attr(feature = "database", sqlx(transparent))]
|
||||
pub struct UploadVideoNoteToken(pub String);
|
||||
|
||||
impl_string_token!(UploadVideoNoteToken);
|
||||
impl_mysql_token_from_row!(UploadVideoNoteToken);
|
||||
impl_crockford_generator!(UploadVideoNoteToken, 32usize, TokenPrefix::UploadedVideoNote, CrockfordLower);
|
||||
@@ -0,0 +1,17 @@
|
||||
use std::fmt::Debug;
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::prefixes::TokenPrefix;
|
||||
|
||||
/// The primary key for uploaded_videos.
|
||||
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Serialize, Deserialize, ToSchema)]
|
||||
#[cfg_attr(feature = "database", derive(sqlx::Type))]
|
||||
#[cfg_attr(feature = "database", sqlx(transparent))]
|
||||
pub struct UploadedVideoToken(pub String);
|
||||
|
||||
impl_string_token!(UploadedVideoToken);
|
||||
impl_mysql_token_from_row!(UploadedVideoToken);
|
||||
impl_crockford_generator!(UploadedVideoToken, 32usize, TokenPrefix::UploadedVideo, CrockfordLower);
|
||||
@@ -5,7 +5,7 @@ use r2d2_redis::RedisConnectionManager;
|
||||
use sqlx::MySqlPool;
|
||||
|
||||
use bootstrap::bootstrap::ContainerEnvironment;
|
||||
use cloud_storage::bucket_client::BucketClient;
|
||||
use cloud_storage::legacy_bucket_client::LegacyBucketClient;
|
||||
use bucket_paths::legacy::old_bespoke_paths::bucket_path_unifier::BucketPathUnifier;
|
||||
use google_drive_downloader::google_drive_download_command::GoogleDriveDownloadCommand;
|
||||
use mysql_queries::common_inputs::container_environment_arg::ContainerEnvironmentArg;
|
||||
@@ -33,8 +33,8 @@ pub struct JobState {
|
||||
|
||||
pub redis_pool: r2d2::Pool<RedisConnectionManager>,
|
||||
|
||||
pub public_bucket_client: BucketClient,
|
||||
pub private_bucket_client: BucketClient,
|
||||
pub public_bucket_client: LegacyBucketClient,
|
||||
pub private_bucket_client: LegacyBucketClient,
|
||||
|
||||
pub firehose_publisher: FirehosePublisher,
|
||||
pub badge_granter: BadgeGranter,
|
||||
|
||||
+2
-2
@@ -4,7 +4,7 @@ use anyhow::anyhow;
|
||||
use log::info;
|
||||
use tempdir::TempDir;
|
||||
|
||||
use cloud_storage::bucket_client::BucketClient;
|
||||
use cloud_storage::legacy_bucket_client::LegacyBucketClient;
|
||||
use errors::AnyhowResult;
|
||||
use filesys::create_dir_all_if_missing::create_dir_all_if_missing;
|
||||
use filesys::file_exists::file_exists;
|
||||
@@ -38,7 +38,7 @@ impl PretrainedHubertModel {
|
||||
|
||||
pub async fn download_if_not_on_filesystem(
|
||||
&self,
|
||||
bucket_client: &BucketClient,
|
||||
bucket_client: &LegacyBucketClient,
|
||||
temp_dir: &TempDir,
|
||||
) -> AnyhowResult<()> {
|
||||
if file_exists(&self.filesystem_path) {
|
||||
|
||||
@@ -28,7 +28,7 @@ use sqlx::mysql::MySqlPoolOptions;
|
||||
use tokio::runtime::Runtime;
|
||||
|
||||
use bootstrap::bootstrap::{bootstrap, BootstrapArgs};
|
||||
use cloud_storage::bucket_client::BucketClient;
|
||||
use cloud_storage::legacy_bucket_client::LegacyBucketClient;
|
||||
use bucket_paths::legacy::old_bespoke_paths::bucket_path_unifier::BucketPathUnifier;
|
||||
use config::common_env::CommonEnv;
|
||||
use shared_env_var_config::logging::DEFAULT_RUST_LOG;
|
||||
@@ -96,7 +96,7 @@ async fn main() -> AnyhowResult<()> {
|
||||
let bucket_timeout = easyenv::get_env_duration_seconds_or_default("BUCKET_TIMEOUT_SECONDS",
|
||||
Duration::from_secs(60 * 5));
|
||||
|
||||
let private_bucket_client = BucketClient::create(
|
||||
let private_bucket_client = LegacyBucketClient::create(
|
||||
&access_key,
|
||||
&secret_key,
|
||||
®ion_name,
|
||||
@@ -106,7 +106,7 @@ async fn main() -> AnyhowResult<()> {
|
||||
Some(bucket_timeout),
|
||||
)?;
|
||||
|
||||
let public_bucket_client = BucketClient::create(
|
||||
let public_bucket_client = LegacyBucketClient::create(
|
||||
&access_key,
|
||||
&secret_key,
|
||||
®ion_name,
|
||||
|
||||
+3
-3
@@ -5,7 +5,7 @@ use log::{debug, error, info, warn};
|
||||
use tempdir::TempDir;
|
||||
use bucket_paths::legacy::typified_paths::public::weight_files::bucket_directory::WeightFileBucketDirectory;
|
||||
use bucket_paths::legacy::typified_paths::public::weight_files::bucket_file_path::WeightFileBucketPath;
|
||||
use cloud_storage::bucket_client::BucketClient;
|
||||
use cloud_storage::legacy_bucket_client::LegacyBucketClient;
|
||||
use filesys::rename_across_devices::{rename_across_devices, RenameError};
|
||||
use filesys::file_deletion::safe_delete_directory::safe_delete_directory;
|
||||
use tokens::tokens::model_weights::ModelWeightToken;
|
||||
@@ -18,7 +18,7 @@ pub async fn download_package(
|
||||
model_weight_token: ModelWeightToken,
|
||||
weights_file_bucket_directory: &WeightFileBucketDirectory,
|
||||
download_directory: &Path,
|
||||
bucket_client: &BucketClient,
|
||||
bucket_client: &LegacyBucketClient,
|
||||
scoped_tempdir_creator: &ScopedTempDirCreator,
|
||||
force_download: bool,
|
||||
) -> Result<(), ProcessSingleJobError> {
|
||||
@@ -69,7 +69,7 @@ pub async fn do_download_package(
|
||||
model_weight_token: ModelWeightToken,
|
||||
weights_file_bucket_directory: &WeightFileBucketDirectory,
|
||||
download_directory: &Path,
|
||||
bucket_client: &BucketClient,
|
||||
bucket_client: &LegacyBucketClient,
|
||||
temp_dir: &TempDir,
|
||||
force_download: bool,
|
||||
entry: GptSovitsPackageFileType,
|
||||
|
||||
+2
-2
@@ -5,7 +5,7 @@ use zip::ZipArchive;
|
||||
|
||||
use bucket_paths::legacy::typified_paths::public::weight_files::bucket_directory::WeightFileBucketDirectory;
|
||||
use bucket_paths::legacy::typified_paths::public::weight_files::bucket_file_path::WeightFileBucketPath;
|
||||
use cloud_storage::bucket_client::BucketClient;
|
||||
use cloud_storage::legacy_bucket_client::LegacyBucketClient;
|
||||
use filesys::path_to_string::path_to_string;
|
||||
use hashing::sha256::sha256_hash_bytes::sha256_hash_bytes;
|
||||
|
||||
@@ -14,7 +14,7 @@ use crate::job::job_types::gpt_sovits::upload_model::get_gptsovits_zip_entries::
|
||||
|
||||
pub async fn extract_and_upload_gpt_sovits_package_files(
|
||||
zip_container_file_bytes: &[u8],
|
||||
bucket_client: &BucketClient,
|
||||
bucket_client: &LegacyBucketClient,
|
||||
weights_file_bucket_directory: &WeightFileBucketDirectory,
|
||||
) -> Result<GptSovitsPackageDetails, GptSovitsPackageError> {
|
||||
let cursor = Cursor::new(zip_container_file_bytes);
|
||||
|
||||
+2
-2
@@ -8,7 +8,7 @@ use tempdir::TempDir;
|
||||
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
|
||||
use bucket_paths::legacy::typified_paths::public::media_uploads::bucket_file_path::MediaUploadOriginalFilePath;
|
||||
use bucket_paths::legacy::typified_paths::public::voice_conversion_results::bucket_file_path::VoiceConversionResultOriginalFilePath;
|
||||
use cloud_storage::bucket_client::BucketClient;
|
||||
use cloud_storage::legacy_bucket_client::LegacyBucketClient;
|
||||
use jobs_common::job_progress_reporter::job_progress_reporter::JobProgressReporter;
|
||||
use mysql_queries::payloads::generic_inference_args::inner_payloads::lipsync_payload::LipsyncAnimationAudioSource;
|
||||
use mysql_queries::queries::generic_inference::job::list_available_generic_inference_jobs::AvailableInferenceJob;
|
||||
@@ -28,7 +28,7 @@ pub struct AudioFile {
|
||||
|
||||
pub struct DownloadAudioFileArgs<'a> {
|
||||
pub audio_source: &'a LipsyncAnimationAudioSource,
|
||||
pub public_bucket_client: &'a BucketClient,
|
||||
pub public_bucket_client: &'a LegacyBucketClient,
|
||||
pub job_progress_reporter: &'a mut Box<dyn JobProgressReporter>,
|
||||
pub job: &'a AvailableInferenceJob,
|
||||
pub temp_dir_creator: &'a ScopedTempDirCreator,
|
||||
|
||||
+2
-2
@@ -7,7 +7,7 @@ use tempdir::TempDir;
|
||||
|
||||
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
|
||||
use bucket_paths::legacy::typified_paths::public::media_uploads::bucket_file_path::MediaUploadOriginalFilePath;
|
||||
use cloud_storage::bucket_client::BucketClient;
|
||||
use cloud_storage::legacy_bucket_client::LegacyBucketClient;
|
||||
use jobs_common::job_progress_reporter::job_progress_reporter::JobProgressReporter;
|
||||
use mysql_queries::payloads::generic_inference_args::inner_payloads::lipsync_payload::LipsyncAnimationImageSource;
|
||||
use mysql_queries::queries::generic_inference::job::list_available_generic_inference_jobs::AvailableInferenceJob;
|
||||
@@ -26,7 +26,7 @@ pub struct ImageFile {
|
||||
|
||||
pub struct DownloadImageFileArgs<'a> {
|
||||
pub image_source: &'a LipsyncAnimationImageSource,
|
||||
pub public_bucket_client: &'a BucketClient,
|
||||
pub public_bucket_client: &'a LegacyBucketClient,
|
||||
pub job_progress_reporter: &'a mut Box<dyn JobProgressReporter>,
|
||||
pub job: &'a AvailableInferenceJob,
|
||||
pub temp_dir_creator: &'a ScopedTempDirCreator,
|
||||
|
||||
+2
-2
@@ -7,7 +7,7 @@ use tempdir::TempDir;
|
||||
|
||||
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
|
||||
use bucket_paths::legacy::typified_paths::public::media_uploads::bucket_file_path::MediaUploadOriginalFilePath;
|
||||
use cloud_storage::bucket_client::BucketClient;
|
||||
use cloud_storage::legacy_bucket_client::LegacyBucketClient;
|
||||
use jobs_common::job_progress_reporter::job_progress_reporter::JobProgressReporter;
|
||||
use mysql_queries::payloads::generic_inference_args::inner_payloads::mocap_payload::MocapVideoSource;
|
||||
use mysql_queries::queries::generic_inference::job::list_available_generic_inference_jobs::AvailableInferenceJob;
|
||||
@@ -27,7 +27,7 @@ pub struct VideoFile {
|
||||
// TODO: Replace with remote cloud client
|
||||
pub async fn download_video_file(
|
||||
video_source: &MocapVideoSource,
|
||||
public_bucket_client: &BucketClient,
|
||||
public_bucket_client: &LegacyBucketClient,
|
||||
job_progress_reporter: &mut Box<dyn JobProgressReporter>,
|
||||
job: &AvailableInferenceJob,
|
||||
temp_dir_creator: &ScopedTempDirCreator,
|
||||
|
||||
+2
-2
@@ -8,7 +8,7 @@ use log::{error, info, warn};
|
||||
use bucket_paths::legacy::typified_paths::private::zs_voices::bucket_directory::{ModelCategory, ModelType};
|
||||
use bucket_paths::legacy::typified_paths::private::zs_voices::bucket_file_path::ZeroShotVoiceEmbeddingBucketPath;
|
||||
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
|
||||
use cloud_storage::bucket_client::BucketClient;
|
||||
use cloud_storage::legacy_bucket_client::LegacyBucketClient;
|
||||
use bucket_paths::legacy::old_bespoke_paths::bucket_path_unifier::BucketPathUnifier;
|
||||
use enums::by_table::generic_inference_jobs::inference_result_type::InferenceResultType;
|
||||
use enums::by_table::zs_voices::encoding_type::ZsVoiceEncodingType;
|
||||
@@ -295,7 +295,7 @@ pub struct AudioFile {
|
||||
pub async fn download_audio_from_hash(
|
||||
bucket_hash: &str,
|
||||
name: &str,
|
||||
private_bucket_client: &BucketClient,
|
||||
private_bucket_client: &LegacyBucketClient,
|
||||
path: &PathBuf
|
||||
) -> Result<AudioFile, ProcessSingleJobError> {
|
||||
let unifer = BucketPathUnifier::default_paths();
|
||||
|
||||
+3
-3
@@ -8,7 +8,7 @@ use log::{error, info, warn};
|
||||
use bucket_paths::legacy::typified_paths::private::zs_voices::bucket_directory::{ModelCategory, ModelType};
|
||||
use bucket_paths::legacy::typified_paths::private::zs_voices::bucket_file_path::ZeroShotVoiceEmbeddingBucketPath;
|
||||
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
|
||||
use cloud_storage::bucket_client::BucketClient;
|
||||
use cloud_storage::legacy_bucket_client::LegacyBucketClient;
|
||||
use bucket_paths::legacy::old_bespoke_paths::bucket_path_unifier::BucketPathUnifier;
|
||||
use enums::by_table::generic_inference_jobs::inference_result_type::InferenceResultType;
|
||||
use enums::by_table::media_files::media_file_origin_model_type::MediaFileOriginModelType;
|
||||
@@ -244,7 +244,7 @@ pub struct VoiceFile {
|
||||
pub async fn download_voice_embedding_from_hash(
|
||||
bucket_hash: &str,
|
||||
name: &str,
|
||||
private_bucket_client: &BucketClient,
|
||||
private_bucket_client: &LegacyBucketClient,
|
||||
path: &PathBuf
|
||||
) -> Result<VoiceFile, ProcessSingleJobError> {
|
||||
let unifer = BucketPathUnifier::default_paths();
|
||||
@@ -271,7 +271,7 @@ pub async fn download_voice_embedding_from_hash(
|
||||
|
||||
pub async fn download_voice_embedding(
|
||||
voice: &ZsVoice,
|
||||
private_bucket_client: &BucketClient,
|
||||
private_bucket_client: &LegacyBucketClient,
|
||||
download_path: &PathBuf,
|
||||
) -> Result<VoiceFile, ProcessSingleJobError> {
|
||||
|
||||
|
||||
+2
-2
@@ -8,7 +8,7 @@ use log::{error, info, warn};
|
||||
use bucket_paths::legacy::typified_paths::private::zs_voices::bucket_directory::{ModelCategory, ModelType};
|
||||
use bucket_paths::legacy::typified_paths::private::zs_voices::bucket_file_path::ZeroShotVoiceEmbeddingBucketPath;
|
||||
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
|
||||
use cloud_storage::bucket_client::BucketClient;
|
||||
use cloud_storage::legacy_bucket_client::LegacyBucketClient;
|
||||
use bucket_paths::legacy::old_bespoke_paths::bucket_path_unifier::BucketPathUnifier;
|
||||
use enums::by_table::generic_inference_jobs::inference_result_type::InferenceResultType;
|
||||
use enums::by_table::zs_voices::encoding_type::ZsVoiceEncodingType;
|
||||
@@ -316,7 +316,7 @@ pub struct AudioFile {
|
||||
pub async fn download_audio_from_hash(
|
||||
bucket_hash: &str,
|
||||
name: &str,
|
||||
private_bucket_client: &BucketClient,
|
||||
private_bucket_client: &LegacyBucketClient,
|
||||
path: &PathBuf
|
||||
) -> Result<AudioFile, ProcessSingleJobError> {
|
||||
let unifer = BucketPathUnifier::default_paths();
|
||||
|
||||
+3
-3
@@ -8,7 +8,7 @@ use log::{error, info, warn};
|
||||
use bucket_paths::legacy::typified_paths::private::zs_voices::bucket_directory::{ModelCategory, ModelType};
|
||||
use bucket_paths::legacy::typified_paths::private::zs_voices::bucket_file_path::ZeroShotVoiceEmbeddingBucketPath;
|
||||
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
|
||||
use cloud_storage::bucket_client::BucketClient;
|
||||
use cloud_storage::legacy_bucket_client::LegacyBucketClient;
|
||||
use bucket_paths::legacy::old_bespoke_paths::bucket_path_unifier::BucketPathUnifier;
|
||||
use enums::by_table::generic_inference_jobs::inference_result_type::InferenceResultType;
|
||||
use enums::by_table::media_files::media_file_origin_model_type::MediaFileOriginModelType;
|
||||
@@ -260,7 +260,7 @@ pub struct VoiceFile {
|
||||
pub async fn download_voice_embedding_from_hash(
|
||||
bucket_hash: &str,
|
||||
name: &str,
|
||||
private_bucket_client: &BucketClient,
|
||||
private_bucket_client: &LegacyBucketClient,
|
||||
path: &PathBuf
|
||||
) -> Result<VoiceFile, ProcessSingleJobError> {
|
||||
let unifer = BucketPathUnifier::default_paths();
|
||||
@@ -287,7 +287,7 @@ pub async fn download_voice_embedding_from_hash(
|
||||
|
||||
pub async fn download_voice_embedding(
|
||||
voice: &ZsVoice,
|
||||
private_bucket_client: &BucketClient,
|
||||
private_bucket_client: &LegacyBucketClient,
|
||||
download_path: &PathBuf,
|
||||
) -> Result<VoiceFile, ProcessSingleJobError> {
|
||||
|
||||
|
||||
+2
-2
@@ -3,7 +3,7 @@ use std::path::PathBuf;
|
||||
use anyhow::anyhow;
|
||||
use log::{error, info};
|
||||
|
||||
use cloud_storage::bucket_client::BucketClient;
|
||||
use cloud_storage::legacy_bucket_client::LegacyBucketClient;
|
||||
use filesys::create_dir_all_if_missing::create_dir_all_if_missing;
|
||||
use filesys::file_exists::file_exists;
|
||||
use filesys::rename_across_devices::rename_across_devices;
|
||||
@@ -41,7 +41,7 @@ impl PretrainedHubertModel {
|
||||
|
||||
pub async fn download_if_not_on_filesystem(
|
||||
&self,
|
||||
bucket_client: &BucketClient,
|
||||
bucket_client: &LegacyBucketClient,
|
||||
scoped_tempdir_creator: &ScopedTempDirCreator,
|
||||
) -> Result<(), ProcessSingleJobError> {
|
||||
if file_exists(&self.filesystem_path) {
|
||||
|
||||
+2
-2
@@ -4,7 +4,7 @@ use anyhow::anyhow;
|
||||
use tempdir::TempDir;
|
||||
|
||||
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
|
||||
use cloud_storage::bucket_client::BucketClient;
|
||||
use cloud_storage::legacy_bucket_client::LegacyBucketClient;
|
||||
use jobs_common::job_progress_reporter::job_progress_reporter::JobProgressReporter;
|
||||
use mysql_queries::queries::generic_inference::job::list_available_generic_inference_jobs::AvailableInferenceJob;
|
||||
use mysql_queries::queries::model_weights::get::get_weight::RetrievedModelWeight;
|
||||
@@ -19,7 +19,7 @@ pub struct ModelFile {
|
||||
// TODO replace this with remote cloud file manager.
|
||||
pub async fn download_model_file(
|
||||
model_record: &Option<RetrievedModelWeight>,
|
||||
public_bucket_client: &BucketClient,
|
||||
public_bucket_client: &LegacyBucketClient,
|
||||
job_progress_reporter: &mut Box<dyn JobProgressReporter>,
|
||||
job: &AvailableInferenceJob,
|
||||
temp_dir_creator: &ScopedTempDirCreator,
|
||||
|
||||
+2
-2
@@ -7,7 +7,7 @@ use tempdir::TempDir;
|
||||
|
||||
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
|
||||
use bucket_paths::legacy::typified_paths::public::media_uploads::bucket_file_path::MediaUploadOriginalFilePath;
|
||||
use cloud_storage::bucket_client::BucketClient;
|
||||
use cloud_storage::legacy_bucket_client::LegacyBucketClient;
|
||||
use jobs_common::job_progress_reporter::job_progress_reporter::JobProgressReporter;
|
||||
use mysql_queries::payloads::generic_inference_args::inner_payloads::videofilter_payload::VideofilterVideoSource;
|
||||
use mysql_queries::queries::generic_inference::job::list_available_generic_inference_jobs::AvailableInferenceJob;
|
||||
@@ -26,7 +26,7 @@ pub struct VideoFile {
|
||||
// TODO replace this with remote cloud file manager.
|
||||
pub async fn download_video_file(
|
||||
video_source: &VideofilterVideoSource,
|
||||
public_bucket_client: &BucketClient,
|
||||
public_bucket_client: &LegacyBucketClient,
|
||||
job_progress_reporter: &mut Box<dyn JobProgressReporter>,
|
||||
job: &AvailableInferenceJob,
|
||||
temp_dir_creator: &ScopedTempDirCreator,
|
||||
|
||||
+6
-6
@@ -1,5 +1,5 @@
|
||||
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
|
||||
use cloud_storage::bucket_client::BucketClient;
|
||||
use cloud_storage::legacy_bucket_client::LegacyBucketClient;
|
||||
use filesys::file_read_bytes::file_read_bytes;
|
||||
use log::{debug, warn};
|
||||
use std::path::PathBuf;
|
||||
@@ -20,12 +20,12 @@ pub enum BucketUploadMessage {
|
||||
UploadFrame {
|
||||
disk_path: PathBuf,
|
||||
bucket_path: MediaFileBucketPath,
|
||||
bucket_client: BucketClient,
|
||||
bucket_client: LegacyBucketClient,
|
||||
result: oneshot::Sender<BucketUploadResult>
|
||||
},
|
||||
UploadMultipleFrames {
|
||||
requests: Vec<BucketUploadRequest>,
|
||||
bucket_client: BucketClient,
|
||||
bucket_client: LegacyBucketClient,
|
||||
result: oneshot::Sender<Vec<(PathBuf, BucketUploadResult)>>,
|
||||
},
|
||||
}
|
||||
@@ -39,7 +39,7 @@ struct BucketUploadActor {
|
||||
rx: mpsc::Receiver<BucketUploadMessage>,
|
||||
}
|
||||
|
||||
fn handle_single_frame_upload(disk_path: PathBuf, bucket_path: MediaFileBucketPath, bucket_client: BucketClient) -> (PathBuf,BucketUploadResult) {
|
||||
fn handle_single_frame_upload(disk_path: PathBuf, bucket_path: MediaFileBucketPath, bucket_client: LegacyBucketClient) -> (PathBuf,BucketUploadResult) {
|
||||
let disk_path2 = disk_path.clone();
|
||||
let handle = tokio::runtime::Handle::current();
|
||||
let attempt = handle.block_on(
|
||||
@@ -139,7 +139,7 @@ impl BucketUploadActorHandle {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn upload_frame(&self, disk_path: PathBuf, bucket_path: MediaFileBucketPath, bucket_client: BucketClient) -> () {
|
||||
pub async fn upload_frame(&self, disk_path: PathBuf, bucket_path: MediaFileBucketPath, bucket_client: LegacyBucketClient) -> () {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
let message = BucketUploadMessage::UploadFrame {
|
||||
disk_path,
|
||||
@@ -151,7 +151,7 @@ impl BucketUploadActorHandle {
|
||||
let _ = rx.await;
|
||||
}
|
||||
|
||||
pub async fn upload_multiple_frames(&self, requests: Vec<BucketUploadRequest>, bucket_client: BucketClient) -> Vec<(PathBuf,BucketUploadResult)> {
|
||||
pub async fn upload_multiple_frames(&self, requests: Vec<BucketUploadRequest>, bucket_client: LegacyBucketClient) -> Vec<(PathBuf,BucketUploadResult)> {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
let message = BucketUploadMessage::UploadMultipleFrames {
|
||||
requests,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user