uploaded videos - tables, endpoints, frontend (#1668)
This commit is contained in:
1 parent
c7341d8f63
commit
914c2532ec
57 files changed
+3386
-222
No files matched your search
+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"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nUPDATE uploaded_videos\nSET\n maybe_width = ?,\n maybe_height = ?,\n maybe_resolution = ?,\n maybe_detected_model_family = ?,\n maybe_detected_model_type = ?,\n maybe_report = ?,\n maybe_updated_ip_address = ?,\n version = version + 1\nWHERE token = ?\nLIMIT 1\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 8
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "62f08d5f32bdc35e6faf43e3d21affa2dcf1501cda831cb88524bf1571d08e2f"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nINSERT INTO uploaded_videos\nSET\n token = ?,\n sha1_checksum = ?,\n filesize_bytes = ?,\n maybe_width = ?,\n maybe_height = ?,\n maybe_resolution = ?,\n maybe_detected_model_family = ?,\n maybe_detected_model_type = ?,\n maybe_report = ?,\n upload_ip_address = ?,\n created_at = NOW()\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 10
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "64efd175fa4eb751be1125d55de93ed66a9fc6582e36cec4fc8225c350ec51e2"
|
||||
}
|
||||
+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"
|
||||
}
|
||||
Generated
+9
@@ -10996,6 +10996,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"
|
||||
@@ -11644,6 +11652,7 @@ dependencies = [
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"server_environment",
|
||||
"sha1_hash",
|
||||
"shared_env_var_config",
|
||||
"speculoos",
|
||||
"sqlx",
|
||||
|
||||
@@ -87,6 +87,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",
|
||||
@@ -282,6 +283,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" }
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
DROP TABLE IF EXISTS uploaded_videos;
|
||||
@@ -0,0 +1,62 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
CREATE TABLE uploaded_videos (
|
||||
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
|
||||
-- Effective "primary key" (used internally for lookups and as a foreign key).
|
||||
token VARCHAR(32) NOT NULL,
|
||||
|
||||
-- ========== FILE ==========
|
||||
|
||||
-- SHA-1 checksum of the uploaded bytes (40 hex chars). De-dupes uploads.
|
||||
sha1_checksum VARCHAR(40) NOT NULL,
|
||||
|
||||
-- Size of the uploaded file in bytes.
|
||||
filesize_bytes INT(10) UNSIGNED NOT NULL,
|
||||
|
||||
-- Pixel dimensions, when they could be determined.
|
||||
maybe_width INT(10) UNSIGNED DEFAULT NULL,
|
||||
maybe_height INT(10) UNSIGNED DEFAULT NULL,
|
||||
|
||||
-- Resolution label (e.g. "1080p", "1920x1080"), when known.
|
||||
maybe_resolution VARCHAR(12) DEFAULT NULL,
|
||||
|
||||
-- ========== DETECTION ==========
|
||||
|
||||
-- Detected provenance family (e.g. "seedance", "veo", "sora", "dreamina", "kling").
|
||||
maybe_detected_model_family VARCHAR(32) DEFAULT NULL,
|
||||
|
||||
-- Detected model type / variant (e.g. "full", "fast", "mini").
|
||||
maybe_detected_model_type VARCHAR(32) DEFAULT NULL,
|
||||
|
||||
-- Free-form report / analysis blob for this upload.
|
||||
maybe_report MEDIUMTEXT DEFAULT NULL,
|
||||
|
||||
-- ========== IP ADDRESSES ==========
|
||||
|
||||
-- IP address that uploaded the video. Wide enough for IPv4/IPv6.
|
||||
upload_ip_address VARCHAR(40) NOT NULL,
|
||||
|
||||
-- IP address that last updated the row. Wide enough for IPv4/IPv6.
|
||||
maybe_updated_ip_address VARCHAR(40) DEFAULT NULL,
|
||||
|
||||
-- ========== VERSION ==========
|
||||
|
||||
-- Optimistic-concurrency "vector clock". Bump by 1 on every update — set in
|
||||
-- the UPDATE statement (MySQL can't auto-increment a plain column via schema).
|
||||
version INT(8) UNSIGNED NOT NULL DEFAULT 0,
|
||||
|
||||
-- ========== TIMESTAMPS ==========
|
||||
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
|
||||
-- INDICES --
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY (token),
|
||||
UNIQUE KEY (sha1_checksum),
|
||||
KEY index_maybe_detected_model_family (maybe_detected_model_family)
|
||||
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
|
||||
@@ -0,0 +1,5 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
DROP TABLE IF EXISTS uploaded_video_notes;
|
||||
@@ -0,0 +1,69 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
CREATE TABLE uploaded_video_notes (
|
||||
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
|
||||
-- Effective "primary key" (used internally for lookups and as a foreign key).
|
||||
token VARCHAR(32) NOT NULL,
|
||||
|
||||
-- The uploaded video this note is about (FK to uploaded_videos.token).
|
||||
uploaded_video_token VARCHAR(32) NOT NULL,
|
||||
|
||||
-- ========== SUBMISSION ==========
|
||||
|
||||
-- Original filename of the submitted clip, as provided by the user.
|
||||
maybe_filename VARCHAR(512) DEFAULT NULL,
|
||||
|
||||
-- The model type the submitter claims this video is.
|
||||
maybe_reported_model_type VARCHAR(32) DEFAULT NULL,
|
||||
|
||||
-- Free-form custom model name when the submitter's model isn't a known type.
|
||||
maybe_reported_model_name VARCHAR(64) DEFAULT NULL,
|
||||
|
||||
-- The website / platform the video was obtained from.
|
||||
maybe_website VARCHAR(32) DEFAULT NULL,
|
||||
|
||||
-- Free-form website when it isn't a known option.
|
||||
maybe_other_website VARCHAR(128) DEFAULT NULL,
|
||||
|
||||
-- Free-form comments from the submitter.
|
||||
maybe_comments VARCHAR(1024) DEFAULT NULL,
|
||||
|
||||
-- ========== CONTACT & FLAGS ==========
|
||||
|
||||
-- Optional email address for follow-up.
|
||||
maybe_email_address VARCHAR(255) DEFAULT NULL,
|
||||
|
||||
-- Whether the submitter consents to us sharing the report.
|
||||
can_share_report BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
|
||||
-- Whether the submitter believes they were scammed.
|
||||
was_scammed BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
|
||||
-- ========== IP ADDRESSES ==========
|
||||
|
||||
-- IP address that submitted the note. Wide enough for IPv4/IPv6.
|
||||
comment_create_ip_address VARCHAR(40) NOT NULL,
|
||||
|
||||
-- IP address that last updated the note. Wide enough for IPv4/IPv6.
|
||||
maybe_comment_update_ip_address VARCHAR(40) DEFAULT NULL,
|
||||
|
||||
-- ========== VERSION ==========
|
||||
|
||||
-- Optimistic-concurrency "vector clock". Bump by 1 on every update — set in
|
||||
-- the UPDATE statement (MySQL can't auto-increment a plain column via schema).
|
||||
version INT(8) UNSIGNED NOT NULL DEFAULT 0,
|
||||
|
||||
-- ========== TIMESTAMPS ==========
|
||||
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
|
||||
-- INDICES --
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY (token),
|
||||
KEY fk_uploaded_video_token (uploaded_video_token)
|
||||
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
|
||||
@@ -0,0 +1,69 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
CREATE TABLE uploaded_video_notes (
|
||||
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
|
||||
-- Effective "primary key" (used internally for lookups and as a foreign key).
|
||||
token VARCHAR(32) NOT NULL,
|
||||
|
||||
-- The uploaded video this note is about (FK to uploaded_videos.token).
|
||||
uploaded_video_token VARCHAR(32) NOT NULL,
|
||||
|
||||
-- ========== SUBMISSION ==========
|
||||
|
||||
-- Original filename of the submitted clip, as provided by the user.
|
||||
maybe_filename VARCHAR(512) DEFAULT NULL,
|
||||
|
||||
-- The model type the submitter claims this video is.
|
||||
maybe_reported_model_type VARCHAR(32) DEFAULT NULL,
|
||||
|
||||
-- Free-form custom model name when the submitter's model isn't a known type.
|
||||
maybe_reported_model_name VARCHAR(64) DEFAULT NULL,
|
||||
|
||||
-- The website / platform the video was obtained from.
|
||||
maybe_website VARCHAR(32) DEFAULT NULL,
|
||||
|
||||
-- Free-form website when it isn't a known option.
|
||||
maybe_other_website VARCHAR(128) DEFAULT NULL,
|
||||
|
||||
-- Free-form comments from the submitter.
|
||||
maybe_comments VARCHAR(1024) DEFAULT NULL,
|
||||
|
||||
-- ========== CONTACT & FLAGS ==========
|
||||
|
||||
-- Optional email address for follow-up.
|
||||
maybe_email_address VARCHAR(255) DEFAULT NULL,
|
||||
|
||||
-- Whether the submitter consents to us sharing the report.
|
||||
can_share_report BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
|
||||
-- Whether the submitter believes they were scammed.
|
||||
was_scammed BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
|
||||
-- ========== IP ADDRESSES ==========
|
||||
|
||||
-- IP address that submitted the note. Wide enough for IPv4/IPv6.
|
||||
comment_create_ip_address VARCHAR(40) NOT NULL,
|
||||
|
||||
-- IP address that last updated the note. Wide enough for IPv4/IPv6.
|
||||
maybe_comment_update_ip_address VARCHAR(40) DEFAULT NULL,
|
||||
|
||||
-- ========== VERSION ==========
|
||||
|
||||
-- Optimistic-concurrency "vector clock". Bump by 1 on every update — set in
|
||||
-- the UPDATE statement (MySQL can't auto-increment a plain column via schema).
|
||||
version INT(8) UNSIGNED NOT NULL DEFAULT 0,
|
||||
|
||||
-- ========== TIMESTAMPS ==========
|
||||
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
|
||||
-- INDICES --
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY (token),
|
||||
KEY fk_uploaded_video_token (uploaded_video_token)
|
||||
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
|
||||
@@ -0,0 +1,62 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
CREATE TABLE uploaded_videos (
|
||||
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
|
||||
-- Effective "primary key" (used internally for lookups and as a foreign key).
|
||||
token VARCHAR(32) NOT NULL,
|
||||
|
||||
-- ========== FILE ==========
|
||||
|
||||
-- SHA-1 checksum of the uploaded bytes (40 hex chars). De-dupes uploads.
|
||||
sha1_checksum VARCHAR(40) NOT NULL,
|
||||
|
||||
-- Size of the uploaded file in bytes.
|
||||
filesize_bytes INT(10) UNSIGNED NOT NULL,
|
||||
|
||||
-- Pixel dimensions, when they could be determined.
|
||||
maybe_width INT(10) UNSIGNED DEFAULT NULL,
|
||||
maybe_height INT(10) UNSIGNED DEFAULT NULL,
|
||||
|
||||
-- Resolution label (e.g. "1080p", "1920x1080"), when known.
|
||||
maybe_resolution VARCHAR(12) DEFAULT NULL,
|
||||
|
||||
-- ========== DETECTION ==========
|
||||
|
||||
-- Detected provenance family (e.g. "seedance", "veo", "sora", "dreamina", "kling").
|
||||
maybe_detected_model_family VARCHAR(32) DEFAULT NULL,
|
||||
|
||||
-- Detected model type / variant (e.g. "full", "fast", "mini").
|
||||
maybe_detected_model_type VARCHAR(32) DEFAULT NULL,
|
||||
|
||||
-- Free-form report / analysis blob for this upload.
|
||||
maybe_report MEDIUMTEXT DEFAULT NULL,
|
||||
|
||||
-- ========== IP ADDRESSES ==========
|
||||
|
||||
-- IP address that uploaded the video. Wide enough for IPv4/IPv6.
|
||||
upload_ip_address VARCHAR(40) NOT NULL,
|
||||
|
||||
-- IP address that last updated the row. Wide enough for IPv4/IPv6.
|
||||
maybe_updated_ip_address VARCHAR(40) DEFAULT NULL,
|
||||
|
||||
-- ========== VERSION ==========
|
||||
|
||||
-- Optimistic-concurrency "vector clock". Bump by 1 on every update — set in
|
||||
-- the UPDATE statement (MySQL can't auto-increment a plain column via schema).
|
||||
version INT(8) UNSIGNED NOT NULL DEFAULT 0,
|
||||
|
||||
-- ========== TIMESTAMPS ==========
|
||||
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
|
||||
-- INDICES --
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY (token),
|
||||
UNIQUE KEY (sha1_checksum),
|
||||
KEY index_maybe_detected_model_family (maybe_detected_model_family)
|
||||
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
|
||||
@@ -1 +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,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 = "sha1_hash"
|
||||
edition = "2021"
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
+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 InsertUploadedVideoArgs<'e, 'c, E>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
pub sha1_checksum: &'e str,
|
||||
pub filesize_bytes: u32,
|
||||
|
||||
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_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_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;
|
||||
+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;
|
||||
|
||||
pub struct UpdateUploadedVideoArgs<'e, 'c, E>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
pub token: &'e UploadedVideoToken,
|
||||
|
||||
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_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_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);
|
||||
@@ -38,6 +38,7 @@ actix_helpers.workspace = true
|
||||
artcraft_api_defs.workspace = true
|
||||
artcraft_api_keys.workspace = true
|
||||
artcraft_router.workspace = true
|
||||
sha1_hash.workspace = true
|
||||
video_info.workspace = true
|
||||
bootstrap.workspace = true
|
||||
bucket_paths.workspace = true
|
||||
|
||||
@@ -365,7 +365,13 @@ use artcraft_api_defs::user_referral_codes::delete_referral_code::*;
|
||||
use artcraft_api_defs::user_referral_codes::list_referral_codes::*;
|
||||
use artcraft_api_defs::web_referrals::log_web_referral::*;
|
||||
use artcraft_api_defs::video_info::read_only::*;
|
||||
use artcraft_api_defs::video_info::upload::*;
|
||||
use artcraft_api_defs::video_info::notes::*;
|
||||
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 crate::http_server::endpoints::video_info::video_info_read_info_handler::*;
|
||||
use crate::http_server::endpoints::video_info::video_info_upload_handler::*;
|
||||
use crate::http_server::endpoints::video_info::video_info_notes_handler::*;
|
||||
use crate::http_server::endpoints::web_referrals::log_web_referral_handler::*;
|
||||
use crate::http_server::endpoints::image_studio::update_gpt_image_job_status_handler::*;
|
||||
use crate::http_server::endpoints::voice_conversion::enqueue_seed_vc_inference_handler::*;
|
||||
@@ -620,6 +626,8 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
|
||||
crate::http_server::endpoints::subscriptions::get_session_subscription_handler::get_session_subscription_handler,
|
||||
// Web Referrals
|
||||
crate::http_server::endpoints::video_info::video_info_read_info_handler::video_info_read_info_handler,
|
||||
crate::http_server::endpoints::video_info::video_info_upload_handler::video_info_upload_handler,
|
||||
crate::http_server::endpoints::video_info::video_info_notes_handler::video_info_notes_handler,
|
||||
crate::http_server::endpoints::web_referrals::log_web_referral_handler::log_web_referral_handler,
|
||||
// User Referral Codes
|
||||
crate::http_server::endpoints::user_referral_codes::create_referral_code_handler::create_referral_code_handler,
|
||||
@@ -1255,6 +1263,12 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
|
||||
// Video info (provenance detection)
|
||||
VideoInfoReadOnlyForm,
|
||||
VideoInfoReadOnlyResponse,
|
||||
VideoInfoUploadForm,
|
||||
VideoInfoUploadResponse,
|
||||
VideoInfoNoteRequest,
|
||||
VideoInfoNoteResponse,
|
||||
UploadedVideoNoteReportedModelType,
|
||||
UploadedVideoNoteReportedWebsite,
|
||||
VideoProvenanceKind,
|
||||
SeedanceVideoInfo,
|
||||
VeoVideoInfo,
|
||||
|
||||
@@ -1 +1,4 @@
|
||||
pub mod provenance;
|
||||
pub mod video_info_notes_handler;
|
||||
pub mod video_info_read_info_handler;
|
||||
pub mod video_info_upload_handler;
|
||||
+241
@@ -0,0 +1,241 @@
|
||||
//! Shared helpers for the video-info endpoints: mapping the internal
|
||||
//! `video_info` types into the public API types, and deriving the database
|
||||
//! `detected_model_type` / `detected_model_family` enums from a parse result.
|
||||
|
||||
use artcraft_api_defs::video_info::read_only::{
|
||||
DreaminaVideoInfo, KlingVideoInfo, SeedanceVideoInfo, SoraVideoInfo, VeoVideoInfo,
|
||||
VideoProvenanceKind,
|
||||
};
|
||||
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 video_info::dreamina_info::DreaminaInfo;
|
||||
use video_info::error::VideoInfoError;
|
||||
use video_info::kling_info::KlingInfo;
|
||||
use video_info::seedance_info::{SeedanceInfo, SeedancePlatform};
|
||||
use video_info::sora_info::SoraInfo;
|
||||
use video_info::veo_info::VeoInfo;
|
||||
use video_info::VideoInfo;
|
||||
|
||||
/// The public-facing provenance for a parsed video — the shared body of both the
|
||||
/// read-only and upload responses.
|
||||
pub struct Provenance {
|
||||
pub kind: VideoProvenanceKind,
|
||||
pub maybe_encoder: Option<String>,
|
||||
pub maybe_seedance: Option<SeedanceVideoInfo>,
|
||||
pub maybe_veo: Option<VeoVideoInfo>,
|
||||
pub maybe_sora: Option<SoraVideoInfo>,
|
||||
pub maybe_dreamina: Option<DreaminaVideoInfo>,
|
||||
pub maybe_kling: Option<KlingVideoInfo>,
|
||||
}
|
||||
|
||||
/// Map a `video_info` parse result into the public [`Provenance`]. Any parse
|
||||
/// error (unrecognized or malformed) collapses to `Unrecognized` — internal
|
||||
/// error types are never surfaced.
|
||||
pub fn to_provenance(
|
||||
result: &Result<VideoInfo, VideoInfoError>,
|
||||
maybe_encoder: Option<String>,
|
||||
) -> Provenance {
|
||||
let mut provenance = Provenance {
|
||||
kind: VideoProvenanceKind::Unrecognized,
|
||||
maybe_encoder,
|
||||
maybe_seedance: None,
|
||||
maybe_veo: None,
|
||||
maybe_sora: None,
|
||||
maybe_dreamina: None,
|
||||
maybe_kling: None,
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(VideoInfo::Seedance(info)) => {
|
||||
provenance.kind = VideoProvenanceKind::Seedance;
|
||||
provenance.maybe_seedance = Some(to_seedance(info));
|
||||
}
|
||||
Ok(VideoInfo::Veo(info)) => {
|
||||
provenance.kind = VideoProvenanceKind::Veo;
|
||||
provenance.maybe_veo = Some(to_veo(info));
|
||||
}
|
||||
Ok(VideoInfo::Sora(info)) => {
|
||||
provenance.kind = VideoProvenanceKind::Sora;
|
||||
provenance.maybe_sora = Some(to_sora(info));
|
||||
}
|
||||
Ok(VideoInfo::Dreamina(info)) => {
|
||||
provenance.kind = VideoProvenanceKind::Dreamina;
|
||||
provenance.maybe_dreamina = Some(to_dreamina(info));
|
||||
}
|
||||
Ok(VideoInfo::Kling(info)) => {
|
||||
provenance.kind = VideoProvenanceKind::Kling;
|
||||
provenance.maybe_kling = Some(to_kling(info));
|
||||
}
|
||||
Err(_) => {}
|
||||
}
|
||||
|
||||
provenance
|
||||
}
|
||||
|
||||
// ── Model detection (for the `uploaded_videos` columns) ──
|
||||
|
||||
/// Best-effort specific model type. Only Seedance carries enough metadata to pin
|
||||
/// an exact variant; other families return `None` (we can't honestly claim a
|
||||
/// version we didn't observe).
|
||||
pub fn detect_model_type(
|
||||
result: &Result<VideoInfo, VideoInfoError>,
|
||||
) -> Option<UploadedVideoDetectedModelType> {
|
||||
match result.as_ref().ok()? {
|
||||
VideoInfo::Seedance(info) => seedance_model_type(info),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a known model type to its family. The requested
|
||||
/// "family based on the model type" helper.
|
||||
pub fn model_family_for_type(
|
||||
model_type: UploadedVideoDetectedModelType,
|
||||
) -> UploadedVideoDetectedModelFamily {
|
||||
use UploadedVideoDetectedModelFamily as Family;
|
||||
use UploadedVideoDetectedModelType as Type;
|
||||
match model_type {
|
||||
Type::GrokVideo | Type::GrokImagineVideo | Type::GrokImagineVideo1p5 => Family::Grok,
|
||||
Type::Kling16Pro
|
||||
| Type::Kling21Pro
|
||||
| Type::Kling21Master
|
||||
| Type::Kling2p5TurboPro
|
||||
| Type::Kling2p6Pro
|
||||
| Type::Kling3p0Standard
|
||||
| Type::Kling3p0Pro => Family::Kling,
|
||||
Type::HappyHorse1p0 => Family::HappyHorse,
|
||||
Type::Seedance10Lite
|
||||
| Type::Seedance1p5Pro
|
||||
| Type::Seedance2p0
|
||||
| Type::Seedance2p0Fast
|
||||
| Type::Seedance2p0BytePlus
|
||||
| Type::Seedance2p0BytePlusFast
|
||||
| Type::Seedance2p0Ultra
|
||||
| Type::Seedance2p0UltraFast
|
||||
| Type::Seedance2p0BytePlusUltra
|
||||
| Type::Seedance2p0BytePlusUltraFast => Family::Seedance,
|
||||
Type::Sora2 | Type::Sora2Pro => Family::Sora,
|
||||
Type::Veo2 | Type::Veo3 | Type::Veo3Fast | Type::Veo3p1 | Type::Veo3p1Fast => Family::Veo,
|
||||
}
|
||||
}
|
||||
|
||||
/// Family derived directly from the parsed variant. Used as a fallback when an
|
||||
/// exact model type couldn't be pinned (e.g. Veo/Sora/Kling, whose version isn't
|
||||
/// in the metadata). Dreamina is ByteDance's Seedance consumer app, so it maps
|
||||
/// to the Seedance family.
|
||||
pub fn detect_model_family(
|
||||
result: &Result<VideoInfo, VideoInfoError>,
|
||||
) -> Option<UploadedVideoDetectedModelFamily> {
|
||||
use UploadedVideoDetectedModelFamily as Family;
|
||||
Some(match result.as_ref().ok()? {
|
||||
VideoInfo::Seedance(_) | VideoInfo::Dreamina(_) => Family::Seedance,
|
||||
VideoInfo::Veo(_) => Family::Veo,
|
||||
VideoInfo::Sora(_) => Family::Sora,
|
||||
VideoInfo::Kling(_) => Family::Kling,
|
||||
})
|
||||
}
|
||||
|
||||
fn seedance_model_type(info: &SeedanceInfo) -> Option<UploadedVideoDetectedModelType> {
|
||||
use UploadedVideoDetectedModelType as Type;
|
||||
let is_byteplus = matches!(info.platform, SeedancePlatform::BytePlus);
|
||||
match (info.model_version.as_deref(), info.is_fast, info.is_lite, is_byteplus) {
|
||||
(Some("1.0"), _, true, _) => Some(Type::Seedance10Lite),
|
||||
(Some("1.5"), _, _, _) => Some(Type::Seedance1p5Pro),
|
||||
(Some("2.0"), false, _, false) => Some(Type::Seedance2p0),
|
||||
(Some("2.0"), true, _, false) => Some(Type::Seedance2p0Fast),
|
||||
(Some("2.0"), false, _, true) => Some(Type::Seedance2p0BytePlus),
|
||||
(Some("2.0"), true, _, true) => Some(Type::Seedance2p0BytePlusFast),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Internal `video_info` → public API type converters ──
|
||||
|
||||
fn to_seedance(info: &SeedanceInfo) -> SeedanceVideoInfo {
|
||||
SeedanceVideoInfo {
|
||||
platform: info.platform.as_str().to_string(),
|
||||
software_agent: info.software_agent.clone(),
|
||||
maybe_software_agent_version: info.software_agent_version.clone(),
|
||||
model_name: info.model_name.clone(),
|
||||
maybe_model_brand: info.model_brand.clone(),
|
||||
maybe_model_version: info.model_version.clone(),
|
||||
is_fast: info.is_fast,
|
||||
is_lite: info.is_lite,
|
||||
generated_at: info.generated_at.clone(),
|
||||
maybe_generated_at_utc: info.generated_at_utc.map(|dt| dt.to_rfc3339()),
|
||||
maybe_log_id: info.log_id.clone(),
|
||||
maybe_log_id_decoded_hex: info.log_id_decoded_hex.clone(),
|
||||
maybe_digital_source_type: info.digital_source_type.clone(),
|
||||
maybe_claim_generator: info.claim_generator.clone(),
|
||||
maybe_claim_generator_version: info.claim_generator_version.clone(),
|
||||
maybe_manifest_id: info.manifest_id.clone(),
|
||||
maybe_instance_id: info.instance_id.clone(),
|
||||
maybe_signer_email: info.signer_email.clone(),
|
||||
maybe_signer_org_id: info.signer_org_id.clone(),
|
||||
maybe_signer_country: info.signer_country.clone(),
|
||||
maybe_cert_serial: info.cert_serial.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn to_veo(info: &VeoInfo) -> VeoVideoInfo {
|
||||
VeoVideoInfo {
|
||||
producer: info.producer.clone(),
|
||||
has_c2pa_manifest: info.has_c2pa_manifest,
|
||||
maybe_encoder: info.encoder.clone(),
|
||||
maybe_created_description: info.created_description.clone(),
|
||||
has_synthid_watermark: info.has_synthid_watermark,
|
||||
maybe_synthid_description: info.synthid_description.clone(),
|
||||
maybe_digital_source_type: info.digital_source_type.clone(),
|
||||
maybe_claim_generator: info.claim_generator.clone(),
|
||||
maybe_claim_generator_version: info.claim_generator_version.clone(),
|
||||
maybe_manifest_id: info.manifest_id.clone(),
|
||||
maybe_instance_id: info.instance_id.clone(),
|
||||
maybe_cert_serial: info.cert_serial.clone(),
|
||||
maybe_signer_ca: info.signer_ca.clone(),
|
||||
is_timestamped: info.is_timestamped,
|
||||
maybe_timestamp_authority: info.timestamp_authority.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn to_sora(info: &SoraInfo) -> SoraVideoInfo {
|
||||
SoraVideoInfo {
|
||||
producer: info.producer.clone(),
|
||||
maybe_model_name: info.model_name.clone(),
|
||||
maybe_created_description: info.created_description.clone(),
|
||||
maybe_digital_source_type: info.digital_source_type.clone(),
|
||||
maybe_claim_generator: info.claim_generator.clone(),
|
||||
maybe_manifest_id: info.manifest_id.clone(),
|
||||
maybe_instance_id: info.instance_id.clone(),
|
||||
maybe_cert_serial: info.cert_serial.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn to_dreamina(info: &DreaminaInfo) -> DreaminaVideoInfo {
|
||||
DreaminaVideoInfo {
|
||||
product: info.product.clone(),
|
||||
maybe_export_type: info.export_type.clone(),
|
||||
maybe_os: info.os.clone(),
|
||||
maybe_source_info: info.source_info.clone(),
|
||||
maybe_aigc_label_type: info.aigc_label_type,
|
||||
maybe_video_id: info.video_id.clone(),
|
||||
has_c2pa: info.has_c2pa,
|
||||
maybe_signer_org_id: info.signer_org_id.clone(),
|
||||
maybe_signer_country: info.signer_country.clone(),
|
||||
maybe_cert_serial: info.cert_serial.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn to_kling(info: &KlingInfo) -> KlingVideoInfo {
|
||||
KlingVideoInfo {
|
||||
maybe_model_version: info.model_version.clone(),
|
||||
is_ai_generated: info.is_ai_generated,
|
||||
maybe_label: info.label.clone(),
|
||||
maybe_content_producer: info.content_producer.clone(),
|
||||
maybe_produce_id: info.produce_id.clone(),
|
||||
maybe_content_propagator: info.content_propagator.clone(),
|
||||
maybe_propagate_id: info.propagate_id.clone(),
|
||||
maybe_reserved_code_1: info.reserved_code_1.clone(),
|
||||
maybe_reserved_code_2: info.reserved_code_2.clone(),
|
||||
has_stream_watermark: info.has_stream_watermark,
|
||||
maybe_watermark_uuid: info.watermark_uuid.clone(),
|
||||
}
|
||||
}
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
//! `POST /v1/video_info/notes` — attach (or update) a user-submitted note about
|
||||
//! an uploaded video.
|
||||
//!
|
||||
//! Always tied to an `UploadedVideoToken`. If the request carries an
|
||||
//! `UploadVideoNoteToken`, the existing note is updated; otherwise a new note is
|
||||
//! created. The IP address is stamped on the create or update column accordingly.
|
||||
|
||||
use std::marker::PhantomData;
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_web::web::Json;
|
||||
use actix_web::{web, HttpRequest};
|
||||
|
||||
use artcraft_api_defs::video_info::notes::{VideoInfoNoteRequest, VideoInfoNoteResponse};
|
||||
use http_server_common::request::get_request_ip::get_request_ip;
|
||||
use mysql_queries::queries::uploaded_video_notes::insert_uploaded_video_note::{
|
||||
insert_uploaded_video_note, InsertUploadedVideoNoteArgs,
|
||||
};
|
||||
use mysql_queries::queries::uploaded_video_notes::update_uploaded_video_note::{
|
||||
update_uploaded_video_note, UpdateUploadedVideoNoteArgs,
|
||||
};
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::web_utils::redis_rate_limiter::RateLimiterError;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
/// Create or update a note about an uploaded video.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
tag = "Video Info",
|
||||
path = "/v1/video_info/notes",
|
||||
request_body = VideoInfoNoteRequest,
|
||||
responses(
|
||||
(status = 200, description = "Note created or updated", body = VideoInfoNoteResponse),
|
||||
(status = 400, description = "Bad input", body = CommonWebError),
|
||||
(status = 500, description = "Server error", body = CommonWebError),
|
||||
)
|
||||
)]
|
||||
pub async fn video_info_notes_handler(
|
||||
http_request: HttpRequest,
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
Json(request): Json<VideoInfoNoteRequest>,
|
||||
) -> Result<Json<VideoInfoNoteResponse>, CommonWebError> {
|
||||
// IP-based rate limit (shared with the other video-info endpoints). Fails open.
|
||||
match server_state.redis_rate_limiters.video_info_read_only.rate_limit_request(&http_request).await {
|
||||
Ok(()) => {}
|
||||
Err(RateLimiterError::RateLimitExceededError) => return Err(CommonWebError::TooManyRequests),
|
||||
Err(RateLimiterError::ClientError) => {} // fail open
|
||||
}
|
||||
|
||||
let ip_address = get_request_ip(&http_request);
|
||||
|
||||
// Free-form text fields are trimmed; empty becomes absent.
|
||||
let maybe_filename = request.maybe_filename.as_deref();
|
||||
let maybe_reported_model_name = normalize_optional(&request.maybe_reported_model_name);
|
||||
let maybe_other_website = normalize_optional(&request.maybe_other_website);
|
||||
let maybe_comments = normalize_optional(&request.maybe_comments);
|
||||
let maybe_email_address = normalize_optional(&request.maybe_email_address);
|
||||
let can_share_report = request.can_share_report.unwrap_or(false);
|
||||
let was_scammed = request.was_scammed.unwrap_or(false);
|
||||
|
||||
let mut mysql_connection = server_state.mysql_pool.acquire().await?;
|
||||
|
||||
let uploaded_video_note_token = match &request.maybe_uploaded_video_note_token {
|
||||
// Update an existing note.
|
||||
Some(note_token) => {
|
||||
update_uploaded_video_note(UpdateUploadedVideoNoteArgs {
|
||||
token: note_token,
|
||||
maybe_filename,
|
||||
maybe_reported_model_type: request.maybe_reported_model_type,
|
||||
maybe_reported_model_name: maybe_reported_model_name.as_deref(),
|
||||
maybe_website: request.maybe_website,
|
||||
maybe_other_website: maybe_other_website.as_deref(),
|
||||
maybe_comments: maybe_comments.as_deref(),
|
||||
maybe_email_address: maybe_email_address.as_deref(),
|
||||
can_share_report,
|
||||
was_scammed,
|
||||
maybe_comment_update_ip_address: Some(ip_address.as_str()),
|
||||
mysql_executor: &mut *mysql_connection,
|
||||
phantom: PhantomData,
|
||||
})
|
||||
.await?;
|
||||
note_token.clone()
|
||||
}
|
||||
// Create a new note.
|
||||
None => {
|
||||
insert_uploaded_video_note(InsertUploadedVideoNoteArgs {
|
||||
uploaded_video_token: &request.uploaded_video_token,
|
||||
maybe_filename,
|
||||
maybe_reported_model_type: request.maybe_reported_model_type,
|
||||
maybe_reported_model_name: maybe_reported_model_name.as_deref(),
|
||||
maybe_website: request.maybe_website,
|
||||
maybe_other_website: maybe_other_website.as_deref(),
|
||||
maybe_comments: maybe_comments.as_deref(),
|
||||
maybe_email_address: maybe_email_address.as_deref(),
|
||||
can_share_report,
|
||||
was_scammed,
|
||||
comment_create_ip_address: &ip_address,
|
||||
mysql_executor: &mut *mysql_connection,
|
||||
phantom: PhantomData,
|
||||
})
|
||||
.await?
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Json(VideoInfoNoteResponse {
|
||||
success: true,
|
||||
uploaded_video_note_token,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Trim a free-form text field; a missing or whitespace-only value becomes `None`.
|
||||
fn normalize_optional(value: &Option<String>) -> Option<String> {
|
||||
value
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|trimmed| !trimmed.is_empty())
|
||||
.map(str::to_string)
|
||||
}
|
||||
+15
-153
@@ -3,8 +3,8 @@
|
||||
//!
|
||||
//! Stateless and read-only: the bytes are parsed in memory by the `video_info`
|
||||
//! crate and nothing is persisted. The internal `video_info` types are mapped
|
||||
//! into the public [`artcraft_api_defs`] wire types so the parsing library can
|
||||
//! evolve independently of the HTTP contract.
|
||||
//! into the public [`artcraft_api_defs`] wire types (see [`super::provenance`])
|
||||
//! so the parsing library can evolve independently of the HTTP contract.
|
||||
|
||||
use std::io::Read;
|
||||
use std::sync::Arc;
|
||||
@@ -16,18 +16,11 @@ use actix_web::{web, HttpRequest};
|
||||
use log::error;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use artcraft_api_defs::video_info::read_only::{
|
||||
DreaminaVideoInfo, KlingVideoInfo, SeedanceVideoInfo, SoraVideoInfo, VeoVideoInfo,
|
||||
VideoInfoReadOnlyResponse, VideoProvenanceKind,
|
||||
};
|
||||
use video_info::dreamina_info::DreaminaInfo;
|
||||
use video_info::kling_info::KlingInfo;
|
||||
use video_info::seedance_info::SeedanceInfo;
|
||||
use video_info::sora_info::SoraInfo;
|
||||
use video_info::veo_info::VeoInfo;
|
||||
use artcraft_api_defs::video_info::read_only::VideoInfoReadOnlyResponse;
|
||||
use video_info::VideoInfo;
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::endpoints::video_info::provenance::to_provenance;
|
||||
use crate::http_server::web_utils::redis_rate_limiter::RateLimiterError;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
@@ -60,9 +53,7 @@ pub async fn video_info_read_info_handler(
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
MultipartForm(mut form): MultipartForm<VideoInfoReadOnlyForm>,
|
||||
) -> Result<Json<VideoInfoReadOnlyResponse>, CommonWebError> {
|
||||
// IP-based rate limit (1 req / 5s by default, configurable). Fails open: the
|
||||
// limiter already treats Redis/parse errors as allowed, and we allow on any
|
||||
// non-"limit exceeded" outcome here too.
|
||||
// IP-based rate limit (1 req / 5s by default, configurable). Fails open.
|
||||
match server_state.redis_rate_limiters.video_info_read_only.rate_limit_request(&http_request).await {
|
||||
Ok(()) => {}
|
||||
Err(RateLimiterError::RateLimitExceededError) => return Err(CommonWebError::TooManyRequests),
|
||||
@@ -80,145 +71,16 @@ pub async fn video_info_read_info_handler(
|
||||
}
|
||||
|
||||
let maybe_encoder = video_info::encoder_tag(&bytes);
|
||||
let provenance = to_provenance(&VideoInfo::from_bytes(&bytes), maybe_encoder);
|
||||
|
||||
// Any parse error (unrecognized / malformed manifest) collapses to
|
||||
// `Unrecognized` — we never surface internal error types to clients.
|
||||
let response = match VideoInfo::from_bytes(&bytes) {
|
||||
Ok(info) => to_response(info, maybe_encoder),
|
||||
Err(_) => unrecognized_response(maybe_encoder),
|
||||
};
|
||||
|
||||
Ok(Json(response))
|
||||
}
|
||||
|
||||
// ── Mapping: internal `video_info` types → public API types ──
|
||||
|
||||
fn to_response(info: VideoInfo, maybe_encoder: Option<String>) -> VideoInfoReadOnlyResponse {
|
||||
let mut response = unrecognized_response(maybe_encoder);
|
||||
match info {
|
||||
VideoInfo::Seedance(seedance) => {
|
||||
response.kind = VideoProvenanceKind::Seedance;
|
||||
response.maybe_seedance = Some(to_seedance(seedance));
|
||||
}
|
||||
VideoInfo::Veo(veo) => {
|
||||
response.kind = VideoProvenanceKind::Veo;
|
||||
response.maybe_veo = Some(to_veo(veo));
|
||||
}
|
||||
VideoInfo::Sora(sora) => {
|
||||
response.kind = VideoProvenanceKind::Sora;
|
||||
response.maybe_sora = Some(to_sora(sora));
|
||||
}
|
||||
VideoInfo::Dreamina(dreamina) => {
|
||||
response.kind = VideoProvenanceKind::Dreamina;
|
||||
response.maybe_dreamina = Some(to_dreamina(dreamina));
|
||||
}
|
||||
VideoInfo::Kling(kling) => {
|
||||
response.kind = VideoProvenanceKind::Kling;
|
||||
response.maybe_kling = Some(to_kling(kling));
|
||||
}
|
||||
}
|
||||
response
|
||||
}
|
||||
|
||||
fn unrecognized_response(maybe_encoder: Option<String>) -> VideoInfoReadOnlyResponse {
|
||||
VideoInfoReadOnlyResponse {
|
||||
Ok(Json(VideoInfoReadOnlyResponse {
|
||||
success: true,
|
||||
kind: VideoProvenanceKind::Unrecognized,
|
||||
maybe_encoder,
|
||||
maybe_seedance: None,
|
||||
maybe_veo: None,
|
||||
maybe_sora: None,
|
||||
maybe_dreamina: None,
|
||||
maybe_kling: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_seedance(info: SeedanceInfo) -> SeedanceVideoInfo {
|
||||
SeedanceVideoInfo {
|
||||
platform: info.platform.as_str().to_string(),
|
||||
software_agent: info.software_agent,
|
||||
maybe_software_agent_version: info.software_agent_version,
|
||||
model_name: info.model_name,
|
||||
maybe_model_brand: info.model_brand,
|
||||
maybe_model_version: info.model_version,
|
||||
is_fast: info.is_fast,
|
||||
is_lite: info.is_lite,
|
||||
generated_at: info.generated_at,
|
||||
maybe_generated_at_utc: info.generated_at_utc.map(|dt| dt.to_rfc3339()),
|
||||
maybe_log_id: info.log_id,
|
||||
maybe_log_id_decoded_hex: info.log_id_decoded_hex,
|
||||
maybe_digital_source_type: info.digital_source_type,
|
||||
maybe_claim_generator: info.claim_generator,
|
||||
maybe_claim_generator_version: info.claim_generator_version,
|
||||
maybe_manifest_id: info.manifest_id,
|
||||
maybe_instance_id: info.instance_id,
|
||||
maybe_signer_email: info.signer_email,
|
||||
maybe_signer_org_id: info.signer_org_id,
|
||||
maybe_signer_country: info.signer_country,
|
||||
maybe_cert_serial: info.cert_serial,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_veo(info: VeoInfo) -> VeoVideoInfo {
|
||||
VeoVideoInfo {
|
||||
producer: info.producer,
|
||||
has_c2pa_manifest: info.has_c2pa_manifest,
|
||||
maybe_encoder: info.encoder,
|
||||
maybe_created_description: info.created_description,
|
||||
has_synthid_watermark: info.has_synthid_watermark,
|
||||
maybe_synthid_description: info.synthid_description,
|
||||
maybe_digital_source_type: info.digital_source_type,
|
||||
maybe_claim_generator: info.claim_generator,
|
||||
maybe_claim_generator_version: info.claim_generator_version,
|
||||
maybe_manifest_id: info.manifest_id,
|
||||
maybe_instance_id: info.instance_id,
|
||||
maybe_cert_serial: info.cert_serial,
|
||||
maybe_signer_ca: info.signer_ca,
|
||||
is_timestamped: info.is_timestamped,
|
||||
maybe_timestamp_authority: info.timestamp_authority,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_sora(info: SoraInfo) -> SoraVideoInfo {
|
||||
SoraVideoInfo {
|
||||
producer: info.producer,
|
||||
maybe_model_name: info.model_name,
|
||||
maybe_created_description: info.created_description,
|
||||
maybe_digital_source_type: info.digital_source_type,
|
||||
maybe_claim_generator: info.claim_generator,
|
||||
maybe_manifest_id: info.manifest_id,
|
||||
maybe_instance_id: info.instance_id,
|
||||
maybe_cert_serial: info.cert_serial,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_dreamina(info: DreaminaInfo) -> DreaminaVideoInfo {
|
||||
DreaminaVideoInfo {
|
||||
product: info.product,
|
||||
maybe_export_type: info.export_type,
|
||||
maybe_os: info.os,
|
||||
maybe_source_info: info.source_info,
|
||||
maybe_aigc_label_type: info.aigc_label_type,
|
||||
maybe_video_id: info.video_id,
|
||||
has_c2pa: info.has_c2pa,
|
||||
maybe_signer_org_id: info.signer_org_id,
|
||||
maybe_signer_country: info.signer_country,
|
||||
maybe_cert_serial: info.cert_serial,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_kling(info: KlingInfo) -> KlingVideoInfo {
|
||||
KlingVideoInfo {
|
||||
maybe_model_version: info.model_version,
|
||||
is_ai_generated: info.is_ai_generated,
|
||||
maybe_label: info.label,
|
||||
maybe_content_producer: info.content_producer,
|
||||
maybe_produce_id: info.produce_id,
|
||||
maybe_content_propagator: info.content_propagator,
|
||||
maybe_propagate_id: info.propagate_id,
|
||||
maybe_reserved_code_1: info.reserved_code_1,
|
||||
maybe_reserved_code_2: info.reserved_code_2,
|
||||
has_stream_watermark: info.has_stream_watermark,
|
||||
maybe_watermark_uuid: info.watermark_uuid,
|
||||
}
|
||||
kind: provenance.kind,
|
||||
maybe_encoder: provenance.maybe_encoder,
|
||||
maybe_seedance: provenance.maybe_seedance,
|
||||
maybe_veo: provenance.maybe_veo,
|
||||
maybe_sora: provenance.maybe_sora,
|
||||
maybe_dreamina: provenance.maybe_dreamina,
|
||||
maybe_kling: provenance.maybe_kling,
|
||||
}))
|
||||
}
|
||||
+210
@@ -0,0 +1,210 @@
|
||||
//! `POST /v1/video_info/upload` — like `read_only`, but persists the result.
|
||||
//!
|
||||
//! Algorithm: read bytes → SHA-1 (Crockford) → run `video_info` provenance
|
||||
//! detection → probe MP4 dimensions via ffprobe → upsert an `uploaded_videos`
|
||||
//! row keyed by the SHA-1 checksum → return the same provenance the read-only
|
||||
//! endpoint returns, plus the persisted `UploadedVideoToken`.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
use std::marker::PhantomData;
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_multipart::form::tempfile::TempFile;
|
||||
use actix_multipart::form::MultipartForm;
|
||||
use actix_web::web::Json;
|
||||
use actix_web::{web, HttpRequest};
|
||||
use log::{error, warn};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use artcraft_api_defs::video_info::upload::VideoInfoUploadResponse;
|
||||
use ffmpeg_utils::ffprobe::ffprobe_get_dimensions::ffprobe_get_dimensions;
|
||||
use http_server_common::request::get_request_ip::get_request_ip;
|
||||
use mysql_queries::queries::uploaded_videos::get_uploaded_video_by_sha1_checksum::{
|
||||
get_uploaded_video_by_sha1_checksum, GetUploadedVideoBySha1ChecksumArgs,
|
||||
};
|
||||
use mysql_queries::queries::uploaded_videos::insert_uploaded_video::{
|
||||
insert_uploaded_video, InsertUploadedVideoArgs,
|
||||
};
|
||||
use mysql_queries::queries::uploaded_videos::update_uploaded_video::{
|
||||
update_uploaded_video, UpdateUploadedVideoArgs,
|
||||
};
|
||||
use sha1_hash::sha1_hash_bytes_as_crockford;
|
||||
use video_info::VideoInfo;
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::endpoints::video_info::provenance::{
|
||||
detect_model_family, detect_model_type, model_family_for_type, to_provenance,
|
||||
};
|
||||
use crate::http_server::web_utils::redis_rate_limiter::RateLimiterError;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
/// Form-multipart request: a single video file under the `file` field.
|
||||
#[derive(MultipartForm, ToSchema)]
|
||||
#[multipart(duplicate_field = "deny")]
|
||||
pub struct VideoInfoUploadForm {
|
||||
/// The video file to inspect and store.
|
||||
#[multipart(limit = "256 MiB")]
|
||||
#[schema(value_type = Vec<u8>, format = Binary)]
|
||||
file: TempFile,
|
||||
}
|
||||
|
||||
/// Detect a video's AI-generation provenance and persist the result.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
tag = "Video Info",
|
||||
path = "/v1/video_info/upload",
|
||||
responses(
|
||||
(status = 200, description = "Detected provenance + persisted token", body = VideoInfoUploadResponse),
|
||||
(status = 400, description = "Bad input", body = CommonWebError),
|
||||
(status = 500, description = "Server error", body = CommonWebError),
|
||||
),
|
||||
params(
|
||||
("request" = VideoInfoUploadForm, description = "Multipart form with a single `file` video upload."),
|
||||
)
|
||||
)]
|
||||
pub async fn video_info_upload_handler(
|
||||
http_request: HttpRequest,
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
MultipartForm(mut form): MultipartForm<VideoInfoUploadForm>,
|
||||
) -> Result<Json<VideoInfoUploadResponse>, CommonWebError> {
|
||||
// IP-based rate limit (shared with the read-only endpoint). Fails open.
|
||||
match server_state.redis_rate_limiters.video_info_read_only.rate_limit_request(&http_request).await {
|
||||
Ok(()) => {}
|
||||
Err(RateLimiterError::RateLimitExceededError) => return Err(CommonWebError::TooManyRequests),
|
||||
Err(RateLimiterError::ClientError) => {} // fail open
|
||||
}
|
||||
|
||||
let mut bytes = Vec::new();
|
||||
form.file.file.read_to_end(&mut bytes).map_err(|err| {
|
||||
error!("video_info upload: problem reading uploaded file: {:?}", err);
|
||||
CommonWebError::server_error_with_message("could not read uploaded file")
|
||||
})?;
|
||||
|
||||
if bytes.is_empty() {
|
||||
return Err(CommonWebError::BadInputWithSimpleMessage("empty file".to_string()));
|
||||
}
|
||||
|
||||
// ── In-memory analysis (no DB connection held during any of this) ──
|
||||
|
||||
let filesize_bytes = bytes.len().min(u32::MAX as usize) as u32;
|
||||
let ip_address = get_request_ip(&http_request);
|
||||
|
||||
let sha1_checksum =
|
||||
sha1_hash_bytes_as_crockford(&bytes).map_err(CommonWebError::from_error)?;
|
||||
|
||||
let parse_result = VideoInfo::from_bytes(&bytes);
|
||||
let maybe_encoder = video_info::encoder_tag(&bytes);
|
||||
|
||||
// The full debug dump of the parse result becomes the stored report.
|
||||
let maybe_report = parse_result.as_ref().ok().map(|info| format!("{info:#?}"));
|
||||
|
||||
let maybe_detected_model_type = detect_model_type(&parse_result);
|
||||
let maybe_detected_model_family = maybe_detected_model_type
|
||||
.map(model_family_for_type)
|
||||
.or_else(|| detect_model_family(&parse_result));
|
||||
|
||||
let provenance = to_provenance(&parse_result, maybe_encoder);
|
||||
|
||||
// ffprobe is a blocking subprocess — run it off the async executor, and BEFORE
|
||||
// acquiring a DB connection (never hold a pooled connection across this).
|
||||
//
|
||||
// Fail open: ffprobe being missing, slow, panicking, or erroring must never
|
||||
// fail the upload — we just store the record without dimensions.
|
||||
let maybe_dimensions = match web::block(move || probe_dimensions(&bytes)).await {
|
||||
Ok(dimensions) => dimensions,
|
||||
Err(err) => {
|
||||
warn!("video_info upload: ffprobe task failed; storing without dimensions: {:?}", err);
|
||||
None
|
||||
}
|
||||
};
|
||||
let maybe_width = maybe_dimensions.map(|(width, _)| width);
|
||||
let maybe_height = maybe_dimensions.map(|(_, height)| height);
|
||||
let maybe_resolution = maybe_dimensions.map(|(width, height)| format!("{width}x{height}"));
|
||||
|
||||
// ── Persist (upsert by SHA-1) ──
|
||||
|
||||
let mut mysql_connection = server_state.mysql_pool.acquire().await?;
|
||||
|
||||
let maybe_existing = get_uploaded_video_by_sha1_checksum(GetUploadedVideoBySha1ChecksumArgs {
|
||||
sha1_checksum: &sha1_checksum,
|
||||
mysql_executor: &mut *mysql_connection,
|
||||
phantom: PhantomData,
|
||||
})
|
||||
.await?;
|
||||
|
||||
let uploaded_video_token = match maybe_existing {
|
||||
Some(existing) => {
|
||||
update_uploaded_video(UpdateUploadedVideoArgs {
|
||||
token: &existing.token,
|
||||
maybe_width,
|
||||
maybe_height,
|
||||
maybe_resolution: maybe_resolution.as_deref(),
|
||||
maybe_detected_model_family,
|
||||
maybe_detected_model_type,
|
||||
maybe_report: maybe_report.as_deref(),
|
||||
maybe_updated_ip_address: Some(ip_address.as_str()),
|
||||
mysql_executor: &mut *mysql_connection,
|
||||
phantom: PhantomData,
|
||||
})
|
||||
.await?;
|
||||
existing.token
|
||||
}
|
||||
None => {
|
||||
insert_uploaded_video(InsertUploadedVideoArgs {
|
||||
sha1_checksum: &sha1_checksum,
|
||||
filesize_bytes,
|
||||
maybe_width,
|
||||
maybe_height,
|
||||
maybe_resolution: maybe_resolution.as_deref(),
|
||||
maybe_detected_model_family,
|
||||
maybe_detected_model_type,
|
||||
maybe_report: maybe_report.as_deref(),
|
||||
upload_ip_address: &ip_address,
|
||||
mysql_executor: &mut *mysql_connection,
|
||||
phantom: PhantomData,
|
||||
})
|
||||
.await?
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Json(VideoInfoUploadResponse {
|
||||
success: true,
|
||||
uploaded_video_token,
|
||||
kind: provenance.kind,
|
||||
maybe_encoder: provenance.maybe_encoder,
|
||||
maybe_seedance: provenance.maybe_seedance,
|
||||
maybe_veo: provenance.maybe_veo,
|
||||
maybe_sora: provenance.maybe_sora,
|
||||
maybe_dreamina: provenance.maybe_dreamina,
|
||||
maybe_kling: provenance.maybe_kling,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Write the bytes to a temp file and ffprobe its video dimensions. Fail-soft:
|
||||
/// returns `None` on any failure (not a probeable video, ffprobe missing, I/O
|
||||
/// error) so the caller can proceed without dimensions. Never panics.
|
||||
fn probe_dimensions(bytes: &[u8]) -> Option<(u32, u32)> {
|
||||
let mut temp_file = match tempfile::NamedTempFile::new() {
|
||||
Ok(temp_file) => temp_file,
|
||||
Err(err) => {
|
||||
warn!("video_info upload: could not create temp file for ffprobe: {:?}", err);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(err) = temp_file.write_all(bytes) {
|
||||
warn!("video_info upload: could not write temp file for ffprobe: {:?}", err);
|
||||
return None;
|
||||
}
|
||||
let _ = temp_file.flush();
|
||||
|
||||
match ffprobe_get_dimensions(temp_file.path()) {
|
||||
Ok(Some(dimensions)) => Some((dimensions.width as u32, dimensions.height as u32)),
|
||||
// Not a probeable video (no video stream / no duration) — not an error.
|
||||
Ok(None) => None,
|
||||
Err(err) => {
|
||||
warn!("video_info upload: ffprobe failed; storing without dimensions: {:?}", err);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
@@ -4,7 +4,9 @@ use actix_web::dev::{ServiceRequest, ServiceResponse};
|
||||
use actix_web::error::Error;
|
||||
use actix_web::{web, App, HttpResponse};
|
||||
|
||||
use crate::http_server::endpoints::video_info::video_info_notes_handler::video_info_notes_handler;
|
||||
use crate::http_server::endpoints::video_info::video_info_read_info_handler::video_info_read_info_handler;
|
||||
use crate::http_server::endpoints::video_info::video_info_upload_handler::video_info_upload_handler;
|
||||
|
||||
pub fn add_video_info_routes<T, B> (app: App<T>) -> App<T>
|
||||
where
|
||||
@@ -22,5 +24,13 @@ where
|
||||
.route(web::post().to(video_info_read_info_handler))
|
||||
.route(web::head().to(|| HttpResponse::Ok()))
|
||||
)
|
||||
.service(web::resource("/upload")
|
||||
.route(web::post().to(video_info_upload_handler))
|
||||
.route(web::head().to(|| HttpResponse::Ok()))
|
||||
)
|
||||
.service(web::resource("/notes")
|
||||
.route(web::post().to(video_info_notes_handler))
|
||||
.route(web::head().to(|| HttpResponse::Ok()))
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,18 @@
|
||||
import type { VideoInfoReadOnlyResponse } from './types';
|
||||
import type {
|
||||
VideoInfoNoteRequest,
|
||||
VideoInfoNoteResponse,
|
||||
VideoInfoUploadResponse,
|
||||
} from './types';
|
||||
|
||||
// Dev: same-origin `/v1/...`, proxied to http://localhost:12345 by Vite (see
|
||||
// vite.config.ts). Prod: call the public API host directly.
|
||||
const API_BASE = import.meta.env.DEV ? '' : 'https://api.storyteller.ai';
|
||||
|
||||
const READ_ONLY_PATH = '/v1/video_info/read_only';
|
||||
// The upload endpoint persists a record (and returns its token) in addition to
|
||||
// returning the detected provenance.
|
||||
const UPLOAD_PATH = '/v1/video_info/upload';
|
||||
|
||||
const NOTES_PATH = '/v1/video_info/notes';
|
||||
|
||||
export class VideoInfoApiError extends Error {
|
||||
constructor(
|
||||
@@ -16,16 +24,19 @@ export class VideoInfoApiError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/** Upload a video file and return the detected provenance. */
|
||||
export async function readVideoInfo(
|
||||
/**
|
||||
* Upload a video file, persist a record, and return the detected provenance
|
||||
* along with the stored record's token.
|
||||
*/
|
||||
export async function uploadVideo(
|
||||
file: File,
|
||||
): Promise<VideoInfoReadOnlyResponse> {
|
||||
): Promise<VideoInfoUploadResponse> {
|
||||
const form = new FormData();
|
||||
form.append('file', file, file.name || 'upload.mp4');
|
||||
form.append('file', file, file.name || 'upload');
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(`${API_BASE}${READ_ONLY_PATH}`, {
|
||||
response = await fetch(`${API_BASE}${UPLOAD_PATH}`, {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
});
|
||||
@@ -52,5 +63,33 @@ export async function readVideoInfo(
|
||||
);
|
||||
}
|
||||
|
||||
return (await response.json()) as VideoInfoReadOnlyResponse;
|
||||
return (await response.json()) as VideoInfoUploadResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or update a note about an uploaded video. Pass
|
||||
* `maybe_uploaded_video_note_token` to update the existing note.
|
||||
*/
|
||||
export async function saveVideoNote(
|
||||
request: VideoInfoNoteRequest,
|
||||
): Promise<VideoInfoNoteResponse> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(`${API_BASE}${NOTES_PATH}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
} catch (err) {
|
||||
throw new VideoInfoApiError('network', 'Could not reach the server to save your note.');
|
||||
}
|
||||
|
||||
if (response.status === 429) {
|
||||
throw new VideoInfoApiError('rate_limited', 'Saving too quickly — try again in a moment.');
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new VideoInfoApiError('http', `The server returned an error (${response.status}).`);
|
||||
}
|
||||
|
||||
return (await response.json()) as VideoInfoNoteResponse;
|
||||
}
|
||||
@@ -98,3 +98,34 @@ export interface VideoInfoReadOnlyResponse {
|
||||
maybe_dreamina: DreaminaVideoInfo | null;
|
||||
maybe_kling: KlingVideoInfo | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response from `POST /v1/video_info/upload` — the same detected provenance as
|
||||
* the read-only endpoint, plus the persisted record token (kept for follow-up
|
||||
* requests like attaching a note).
|
||||
*/
|
||||
export interface VideoInfoUploadResponse extends VideoInfoReadOnlyResponse {
|
||||
uploaded_video_token: string;
|
||||
}
|
||||
|
||||
/** Request body for `POST /v1/video_info/notes`. */
|
||||
export interface VideoInfoNoteRequest {
|
||||
uploaded_video_token: string;
|
||||
/** Present → update that note instead of creating a new one. */
|
||||
maybe_uploaded_video_note_token?: string;
|
||||
maybe_filename?: string | null;
|
||||
maybe_reported_model_type?: string | null;
|
||||
maybe_reported_model_name?: string | null;
|
||||
maybe_website?: string | null;
|
||||
maybe_other_website?: string | null;
|
||||
maybe_comments?: string | null;
|
||||
maybe_email_address?: string | null;
|
||||
can_share_report?: boolean;
|
||||
was_scammed?: boolean;
|
||||
}
|
||||
|
||||
/** Response from `POST /v1/video_info/notes`. */
|
||||
export interface VideoInfoNoteResponse {
|
||||
success: boolean;
|
||||
uploaded_video_note_token: string;
|
||||
}
|
||||
@@ -1,20 +1,22 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { ArrowDown, RotateCcw, ScanSearch, Sparkles } from 'lucide-react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { ArrowDown, RotateCcw, ScanSearch, Sparkles, UploadCloud } from 'lucide-react';
|
||||
|
||||
import { readVideoInfo, VideoInfoApiError } from '../api/client';
|
||||
import type { VideoInfoReadOnlyResponse } from '../api/types';
|
||||
import { uploadVideo, VideoInfoApiError } from '../api/client';
|
||||
import type { VideoInfoUploadResponse } from '../api/types';
|
||||
import { classify } from '../lib/classify';
|
||||
import type { Verdict } from '../lib/classify';
|
||||
import { ThemeToggle } from '../components/ThemeToggle';
|
||||
import { UploadBox } from '../components/UploadBox';
|
||||
import { VerdictCard } from '../components/VerdictCard';
|
||||
import { SampleSubmitForm } from '../components/SampleSubmitForm';
|
||||
import { ReportForm } from '../components/ReportForm';
|
||||
|
||||
type Status = 'idle' | 'loading' | 'done' | 'error';
|
||||
|
||||
interface DoneState {
|
||||
fileName: string;
|
||||
response: VideoInfoReadOnlyResponse;
|
||||
response: VideoInfoUploadResponse;
|
||||
/** Persisted record token — kept for upcoming follow-up requests. */
|
||||
uploadedVideoToken: string;
|
||||
verdict: Verdict;
|
||||
}
|
||||
|
||||
@@ -23,16 +25,29 @@ export function App() {
|
||||
const [fileName, setFileName] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<DoneState | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
// Monotonic id so only the newest upload's result is applied: if you drop a
|
||||
// second video while the first is still processing, the latest one wins.
|
||||
const requestIdRef = useRef(0);
|
||||
|
||||
const handleFile = useCallback(async (file: File) => {
|
||||
const requestId = ++requestIdRef.current;
|
||||
setFileName(file.name);
|
||||
setStatus('loading');
|
||||
setError(null);
|
||||
try {
|
||||
const response = await readVideoInfo(file);
|
||||
setResult({ fileName: file.name, response, verdict: classify(response) });
|
||||
const response = await uploadVideo(file);
|
||||
if (requestIdRef.current !== requestId) return; // superseded by a newer upload
|
||||
setResult({
|
||||
fileName: file.name,
|
||||
response,
|
||||
uploadedVideoToken: response.uploaded_video_token,
|
||||
verdict: classify(response),
|
||||
});
|
||||
setStatus('done');
|
||||
} catch (err) {
|
||||
if (requestIdRef.current !== requestId) return; // superseded by a newer upload
|
||||
const message =
|
||||
err instanceof VideoInfoApiError
|
||||
? err.message
|
||||
@@ -43,37 +58,90 @@ export function App() {
|
||||
}, []);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
requestIdRef.current++; // discard any in-flight upload
|
||||
setStatus('idle');
|
||||
setFileName(null);
|
||||
setResult(null);
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
const showReset = status === 'done' || status === 'error';
|
||||
// The entire window is a drop target — drop a video anywhere, at any time.
|
||||
useEffect(() => {
|
||||
let dragDepth = 0;
|
||||
const hasFiles = (event: DragEvent) =>
|
||||
!!event.dataTransfer && Array.from(event.dataTransfer.types).includes('Files');
|
||||
|
||||
const onDragEnter = (event: DragEvent) => {
|
||||
if (!hasFiles(event)) return;
|
||||
event.preventDefault();
|
||||
dragDepth += 1;
|
||||
setIsDragging(true);
|
||||
};
|
||||
const onDragOver = (event: DragEvent) => {
|
||||
// Must preventDefault so the browser allows the drop (and doesn't navigate
|
||||
// away to open the file) anywhere on the page.
|
||||
if (hasFiles(event)) event.preventDefault();
|
||||
};
|
||||
const onDragLeave = (event: DragEvent) => {
|
||||
if (!hasFiles(event)) return;
|
||||
event.preventDefault();
|
||||
dragDepth -= 1;
|
||||
if (dragDepth <= 0) {
|
||||
dragDepth = 0;
|
||||
setIsDragging(false);
|
||||
}
|
||||
};
|
||||
const onDrop = (event: DragEvent) => {
|
||||
event.preventDefault();
|
||||
dragDepth = 0;
|
||||
setIsDragging(false);
|
||||
const file = event.dataTransfer?.files?.[0];
|
||||
if (file) handleFile(file);
|
||||
};
|
||||
|
||||
window.addEventListener('dragenter', onDragEnter);
|
||||
window.addEventListener('dragover', onDragOver);
|
||||
window.addEventListener('dragleave', onDragLeave);
|
||||
window.addEventListener('drop', onDrop);
|
||||
return () => {
|
||||
window.removeEventListener('dragenter', onDragEnter);
|
||||
window.removeEventListener('dragover', onDragOver);
|
||||
window.removeEventListener('dragleave', onDragLeave);
|
||||
window.removeEventListener('drop', onDrop);
|
||||
};
|
||||
}, [handleFile]);
|
||||
|
||||
const notIdle = status !== 'idle';
|
||||
|
||||
return (
|
||||
<div className="ambient-glow relative min-h-screen overflow-x-hidden">
|
||||
{/* Full-window drag overlay — shown whenever a file is dragged over the page. */}
|
||||
{isDragging && (
|
||||
<div className="pointer-events-none fixed inset-0 z-50 flex items-center justify-center bg-slate-950/70 p-6 backdrop-blur-sm">
|
||||
<div className="flex flex-col items-center gap-4 rounded-[2rem] border-2 border-dashed border-fuchsia-400/80 bg-white/5 px-12 py-16 text-center">
|
||||
<UploadCloud className="h-16 w-16 animate-bounce-down text-fuchsia-300" />
|
||||
<p className="brand-gradient font-display text-4xl font-extrabold sm:text-5xl">
|
||||
Drop to analyze
|
||||
</p>
|
||||
<p className="text-sm text-slate-200">Release anywhere — the newest video wins</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mx-auto flex min-h-screen w-full max-w-3xl flex-col px-5 pb-16 sm:px-8">
|
||||
{/* Header */}
|
||||
<header className="flex items-center justify-between py-6">
|
||||
<div className="flex items-center gap-2 font-display text-lg font-bold">
|
||||
<ScanSearch className="h-6 w-6 text-violet-500 dark:text-cyan-400" />
|
||||
<span>realseedance</span>
|
||||
<span className="text-violet-500 dark:text-cyan-400">?</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{showReset && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={reset}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-slate-300/70 bg-white/60 px-4 py-2 text-sm font-medium text-slate-600 transition hover:scale-105 hover:text-violet-600 dark:border-white/10 dark:bg-white/5 dark:text-slate-300 dark:hover:text-cyan-300"
|
||||
className="flex items-center gap-2 font-display text-lg font-bold transition hover:opacity-80"
|
||||
title="Back to home"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
Reset
|
||||
<ScanSearch className="h-6 w-6 text-violet-500 dark:text-cyan-400" />
|
||||
<span>realseedance</span>
|
||||
<span className="text-violet-500 dark:text-cyan-400">?</span>
|
||||
</button>
|
||||
)}
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Hero */}
|
||||
@@ -126,23 +194,26 @@ export function App() {
|
||||
|
||||
<div className="flex flex-col items-center gap-3 sm:flex-row sm:justify-center">
|
||||
<UploadAnotherButton onFile={handleFile} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={reset}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-slate-300/70 bg-white/60 px-6 py-3 text-sm font-semibold text-slate-600 transition hover:scale-105 hover:text-violet-600 dark:border-white/10 dark:bg-white/5 dark:text-slate-300 dark:hover:text-cyan-300"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
Reset
|
||||
</button>
|
||||
<BigResetButton onClick={reset} />
|
||||
</div>
|
||||
|
||||
<SampleSubmitForm />
|
||||
<ReportForm
|
||||
key={result.uploadedVideoToken}
|
||||
uploadedVideoToken={result.uploadedVideoToken}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* Prominent "start over" available any time something is on screen. */}
|
||||
{notIdle && (
|
||||
<div className="mt-14 flex justify-center">
|
||||
<BigResetButton onClick={reset} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<footer className="mt-16 text-center text-xs text-slate-400 dark:text-slate-600">
|
||||
Videos are analyzed in-memory and never stored.
|
||||
Drag & drop a video anywhere, any time.
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
@@ -162,11 +233,25 @@ function AnimatedPrompt() {
|
||||
);
|
||||
}
|
||||
|
||||
/** Big, bold "start over" button — clears all state back to the home screen. */
|
||||
function BigResetButton({ onClick }: { onClick: () => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="group inline-flex items-center gap-2.5 rounded-2xl bg-slate-900 px-8 py-4 text-lg font-extrabold text-white shadow-lg shadow-slate-900/25 transition hover:scale-105 active:scale-95 dark:bg-white dark:text-slate-900 dark:shadow-black/40"
|
||||
>
|
||||
<RotateCcw className="h-5 w-5 transition duration-500 group-hover:-rotate-180" />
|
||||
Start Over
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** "Upload another" — opens a file picker that reuses the same handler. */
|
||||
function UploadAnotherButton({ onFile }: { onFile: (file: File) => void }) {
|
||||
return (
|
||||
<label className="inline-flex cursor-pointer items-center gap-2 rounded-full bg-gradient-to-r from-violet-600 to-fuchsia-600 px-6 py-3 text-sm font-semibold text-white shadow-lg shadow-violet-500/30 transition hover:scale-105 active:scale-95">
|
||||
<ScanSearch className="h-4 w-4" />
|
||||
<label className="inline-flex cursor-pointer items-center gap-2.5 rounded-2xl bg-gradient-to-r from-violet-600 to-fuchsia-600 px-8 py-4 text-lg font-extrabold text-white shadow-lg shadow-violet-500/30 transition hover:scale-105 active:scale-95">
|
||||
<ScanSearch className="h-5 w-5" />
|
||||
Upload another
|
||||
<input
|
||||
type="file"
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Check, Loader2, Save, TriangleAlert } from 'lucide-react';
|
||||
|
||||
import { saveVideoNote, VideoInfoApiError } from '../api/client';
|
||||
import type { VideoInfoNoteRequest } from '../api/types';
|
||||
import {
|
||||
CUSTOM_VALUE,
|
||||
MODEL_TYPE_GROUPS,
|
||||
WEBSITE_OPTIONS,
|
||||
} from '../lib/options';
|
||||
|
||||
const DEBOUNCE_MS = 4000;
|
||||
|
||||
type SaveStatus = 'idle' | 'saving' | 'saved' | 'error';
|
||||
|
||||
interface ReportFormProps {
|
||||
/** The uploaded video this report is about. */
|
||||
uploadedVideoToken: string;
|
||||
}
|
||||
|
||||
export function ReportForm({ uploadedVideoToken }: ReportFormProps) {
|
||||
const [modelType, setModelType] = useState('');
|
||||
const [customModelName, setCustomModelName] = useState('');
|
||||
const [website, setWebsite] = useState('');
|
||||
const [customWebsite, setCustomWebsite] = useState('');
|
||||
const [comments, setComments] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [wasScammed, setWasScammed] = useState(false);
|
||||
const [canShare, setCanShare] = useState(false);
|
||||
|
||||
// The note token is kept so subsequent saves update the same record.
|
||||
const [noteToken, setNoteToken] = useState<string | null>(null);
|
||||
const [status, setStatus] = useState<SaveStatus>('idle');
|
||||
const [dirty, setDirty] = useState(false);
|
||||
|
||||
const modelChosen = modelType !== '' && modelType !== CUSTOM_VALUE;
|
||||
const websiteChosen = website !== '' && website !== CUSTOM_VALUE;
|
||||
const hasContent =
|
||||
modelChosen ||
|
||||
(modelType === CUSTOM_VALUE && customModelName.trim() !== '') ||
|
||||
websiteChosen ||
|
||||
(website === CUSTOM_VALUE && customWebsite.trim() !== '') ||
|
||||
comments.trim() !== '' ||
|
||||
email.trim() !== '' ||
|
||||
wasScammed ||
|
||||
canShare;
|
||||
|
||||
const submit = useCallback(async () => {
|
||||
if (!hasContent) return;
|
||||
setStatus('saving');
|
||||
const request: VideoInfoNoteRequest = {
|
||||
uploaded_video_token: uploadedVideoToken,
|
||||
maybe_uploaded_video_note_token: noteToken ?? undefined,
|
||||
maybe_reported_model_type: modelChosen ? modelType : undefined,
|
||||
maybe_reported_model_name:
|
||||
modelType === CUSTOM_VALUE ? customModelName.trim() || undefined : undefined,
|
||||
maybe_website: websiteChosen ? website : undefined,
|
||||
maybe_other_website:
|
||||
website === CUSTOM_VALUE ? customWebsite.trim() || undefined : undefined,
|
||||
maybe_comments: comments.trim() || undefined,
|
||||
maybe_email_address: email.trim() || undefined,
|
||||
can_share_report: canShare,
|
||||
was_scammed: wasScammed,
|
||||
};
|
||||
try {
|
||||
const response = await saveVideoNote(request);
|
||||
setNoteToken(response.uploaded_video_note_token);
|
||||
setStatus('saved');
|
||||
setDirty(false);
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof VideoInfoApiError ? err.message : 'Could not save your report.';
|
||||
console.warn('report save failed:', message);
|
||||
setStatus('error');
|
||||
}
|
||||
}, [
|
||||
hasContent,
|
||||
uploadedVideoToken,
|
||||
noteToken,
|
||||
modelChosen,
|
||||
modelType,
|
||||
customModelName,
|
||||
websiteChosen,
|
||||
website,
|
||||
customWebsite,
|
||||
comments,
|
||||
email,
|
||||
canShare,
|
||||
wasScammed,
|
||||
]);
|
||||
|
||||
// Auto-save 4s after the last change.
|
||||
useEffect(() => {
|
||||
if (!dirty || !hasContent) return;
|
||||
const timer = setTimeout(() => {
|
||||
void submit();
|
||||
}, DEBOUNCE_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}, [dirty, hasContent, submit]);
|
||||
|
||||
// Mark dirty on any field change.
|
||||
const onChange = <T,>(setter: (value: T) => void) => (value: T) => {
|
||||
setter(value);
|
||||
setDirty(true);
|
||||
setStatus((prev) => (prev === 'error' ? 'error' : 'idle'));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="glass space-y-5 px-6 py-6 sm:px-8">
|
||||
<div>
|
||||
<h4 className="font-display text-lg font-semibold">Report this video</h4>
|
||||
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
|
||||
Tell us what you think it really is. Saves automatically a few seconds after you stop.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Model the user thinks it is */}
|
||||
<Field label="What model do you think it is?">
|
||||
<Select value={modelType} onChange={onChange(setModelType)} placeholder="Select a model…">
|
||||
{MODEL_TYPE_GROUPS.map((group) => (
|
||||
<optgroup key={group.label} label={group.label}>
|
||||
{group.options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
<option value={CUSTOM_VALUE}>Custom / Other…</option>
|
||||
</Select>
|
||||
{modelType === CUSTOM_VALUE && (
|
||||
<TextInput
|
||||
value={customModelName}
|
||||
onChange={onChange(setCustomModelName)}
|
||||
placeholder="Name the model"
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
{/* Where it came from */}
|
||||
<Field label="Which website did it come from?">
|
||||
<Select value={website} onChange={onChange(setWebsite)} placeholder="Select a website…">
|
||||
{WEBSITE_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
<option value={CUSTOM_VALUE}>Other (enter URL)…</option>
|
||||
</Select>
|
||||
{website === CUSTOM_VALUE && (
|
||||
<TextInput
|
||||
value={customWebsite}
|
||||
onChange={onChange(setCustomWebsite)}
|
||||
placeholder="https://…"
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
{/* Free-form notes */}
|
||||
<Field label="Notes (optional)">
|
||||
<textarea
|
||||
value={comments}
|
||||
onChange={(e) => onChange(setComments)(e.target.value)}
|
||||
rows={3}
|
||||
placeholder="Anything else you noticed?"
|
||||
className="w-full resize-y rounded-2xl border border-slate-300/70 bg-white/70 px-4 py-3 text-sm outline-none transition placeholder:text-slate-400 focus:border-violet-400 focus:ring-2 focus:ring-violet-400/30 dark:border-white/10 dark:bg-white/5 dark:placeholder:text-slate-500"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{/* Flags */}
|
||||
<div className="space-y-2.5">
|
||||
<FancyCheckbox
|
||||
checked={wasScammed}
|
||||
onChange={onChange(setWasScammed)}
|
||||
label="Did you get scammed?"
|
||||
/>
|
||||
<FancyCheckbox
|
||||
checked={canShare}
|
||||
onChange={onChange(setCanShare)}
|
||||
label="Can we share this statistic with the community?"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Optional email */}
|
||||
<Field label="Want us to share better alternatives with you?">
|
||||
<TextInput value={email} onChange={onChange(setEmail)} placeholder="you@example.com (optional)" />
|
||||
</Field>
|
||||
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<StatusLine status={status} dirty={dirty} hasContent={hasContent} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setDirty(false);
|
||||
void submit();
|
||||
}}
|
||||
disabled={!hasContent || status === 'saving'}
|
||||
className="inline-flex items-center gap-2 rounded-full bg-gradient-to-r from-violet-600 to-fuchsia-600 px-6 py-3 text-sm font-semibold text-white shadow-lg shadow-violet-500/30 transition hover:scale-105 active:scale-95 disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100"
|
||||
>
|
||||
{status === 'saving' ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin-slow" />
|
||||
) : (
|
||||
<Save className="h-4 w-4" />
|
||||
)}
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusLine({
|
||||
status,
|
||||
dirty,
|
||||
hasContent,
|
||||
}: {
|
||||
status: SaveStatus;
|
||||
dirty: boolean;
|
||||
hasContent: boolean;
|
||||
}) {
|
||||
if (status === 'saving') {
|
||||
return <Hint icon={<Loader2 className="h-4 w-4 animate-spin-slow" />}>Saving…</Hint>;
|
||||
}
|
||||
if (status === 'error') {
|
||||
return (
|
||||
<Hint className="text-rose-500" icon={<TriangleAlert className="h-4 w-4" />}>
|
||||
Couldn't save — try again
|
||||
</Hint>
|
||||
);
|
||||
}
|
||||
if (dirty && hasContent) {
|
||||
return <Hint>Unsaved changes…</Hint>;
|
||||
}
|
||||
if (status === 'saved') {
|
||||
return (
|
||||
<Hint className="text-emerald-500" icon={<Check className="h-4 w-4" />}>
|
||||
Saved
|
||||
</Hint>
|
||||
);
|
||||
}
|
||||
return <span />;
|
||||
}
|
||||
|
||||
function Hint({
|
||||
children,
|
||||
icon,
|
||||
className = '',
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
icon?: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 text-xs text-slate-500 dark:text-slate-400 ${className}`}
|
||||
>
|
||||
{icon}
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function FancyCheckbox({
|
||||
checked,
|
||||
onChange,
|
||||
label,
|
||||
}: {
|
||||
checked: boolean;
|
||||
onChange: (value: boolean) => void;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="checkbox"
|
||||
aria-checked={checked}
|
||||
onClick={() => onChange(!checked)}
|
||||
className={[
|
||||
'group flex w-full items-center gap-3 rounded-2xl border px-4 py-3 text-left text-sm font-medium transition',
|
||||
checked
|
||||
? 'border-violet-400/70 bg-violet-500/10 text-slate-800 dark:border-cyan-400/40 dark:bg-cyan-400/10 dark:text-slate-100'
|
||||
: 'border-slate-300/70 text-slate-600 hover:border-violet-400/50 dark:border-white/10 dark:text-slate-300',
|
||||
].join(' ')}
|
||||
>
|
||||
<span
|
||||
className={[
|
||||
'flex h-6 w-6 shrink-0 items-center justify-center rounded-lg border-2 transition',
|
||||
checked
|
||||
? 'border-transparent bg-gradient-to-tr from-violet-600 to-fuchsia-600 text-white shadow-md shadow-fuchsia-500/30'
|
||||
: 'border-slate-400/60 group-hover:border-violet-400/70 dark:border-white/20',
|
||||
].join(' ')}
|
||||
>
|
||||
<Check
|
||||
className={[
|
||||
'h-4 w-4 transition',
|
||||
checked ? 'scale-100 opacity-100' : 'scale-50 opacity-0',
|
||||
].join(' ')}
|
||||
/>
|
||||
</span>
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-sm font-medium text-slate-600 dark:text-slate-300">{label}</span>
|
||||
<div className="space-y-2">{children}</div>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function Select({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
children,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="w-full rounded-2xl border border-slate-300/70 bg-white/70 px-4 py-3 text-sm outline-none transition focus:border-violet-400 focus:ring-2 focus:ring-violet-400/30 dark:border-white/10 dark:bg-white/5"
|
||||
>
|
||||
<option value="">{placeholder}</option>
|
||||
{children}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
function TextInput({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder: string;
|
||||
}) {
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="w-full rounded-2xl border border-slate-300/70 bg-white/70 px-4 py-3 text-sm outline-none transition placeholder:text-slate-400 focus:border-violet-400 focus:ring-2 focus:ring-violet-400/30 dark:border-white/10 dark:bg-white/5 dark:placeholder:text-slate-500"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import type { DragEvent } from 'react';
|
||||
import { useRef } from 'react';
|
||||
import { FileVideo, FolderOpen, Loader2, UploadCloud } from 'lucide-react';
|
||||
|
||||
interface UploadBoxProps {
|
||||
@@ -8,17 +7,10 @@ interface UploadBoxProps {
|
||||
onFile: (file: File) => void;
|
||||
}
|
||||
|
||||
// NB: Drag-and-drop is handled at the window level (see App.tsx) so a video can
|
||||
// be dropped anywhere on the page. This box is the visual hint + file picker.
|
||||
export function UploadBox({ busy, busyFileName, onFile }: UploadBoxProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
|
||||
function handleDrop(event: DragEvent<HTMLDivElement>) {
|
||||
event.preventDefault();
|
||||
setDragging(false);
|
||||
if (busy) return;
|
||||
const file = event.dataTransfer.files?.[0];
|
||||
if (file) onFile(file);
|
||||
}
|
||||
|
||||
function handleSelect(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0];
|
||||
@@ -29,18 +21,9 @@ export function UploadBox({ busy, busyFileName, onFile }: UploadBoxProps) {
|
||||
|
||||
return (
|
||||
<div
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
if (!busy) setDragging(true);
|
||||
}}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={handleDrop}
|
||||
className={[
|
||||
'glass relative flex flex-col items-center justify-center gap-5 px-8 py-14 text-center transition-all duration-300',
|
||||
dragging
|
||||
? 'scale-[1.02] border-fuchsia-400/80 ring-4 ring-fuchsia-400/30'
|
||||
: 'hover:border-violet-400/60',
|
||||
busy ? 'pointer-events-none' : '',
|
||||
busy ? 'pointer-events-none' : 'hover:border-violet-400/60',
|
||||
].join(' ')}
|
||||
>
|
||||
{/* Animated ring icon */}
|
||||
@@ -71,12 +54,7 @@ export function UploadBox({ busy, busyFileName, onFile }: UploadBoxProps) {
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-1">
|
||||
<p className="text-xl font-semibold">
|
||||
{dragging ? 'Drop it!' : 'Drag & drop your video here'}
|
||||
</p>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||
MP4 / MOV — we read it, we don't keep it.
|
||||
</p>
|
||||
<p className="text-xl font-semibold">Drag & drop your video anywhere</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
|
||||
@@ -74,7 +74,7 @@ export function classify(response: VideoInfoReadOnlyResponse): Verdict {
|
||||
|
||||
return {
|
||||
level: 'unknown',
|
||||
scammed: false,
|
||||
scammed: true,
|
||||
headline: "We don't know what that is.",
|
||||
modelLabel: 'Unknown',
|
||||
subline:
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
// Dropdown options for the report form. Values mirror the backend enums
|
||||
// (`UploadedVideoNoteReportedModelType`, `UploadedVideoNoteReportedWebsite`).
|
||||
|
||||
export const CUSTOM_VALUE = '__custom__';
|
||||
|
||||
export interface SelectOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface SelectGroup {
|
||||
label: string;
|
||||
options: SelectOption[];
|
||||
}
|
||||
|
||||
export const MODEL_TYPE_GROUPS: SelectGroup[] = [
|
||||
{
|
||||
label: 'Seedance',
|
||||
options: [
|
||||
{ value: 'seedance_1p0_lite', label: 'Seedance 1.0 Lite' },
|
||||
{ value: 'seedance_1p5_pro', label: 'Seedance 1.5 Pro' },
|
||||
{ value: 'seedance_2p0', label: 'Seedance 2.0' },
|
||||
{ value: 'seedance_2p0_fast', label: 'Seedance 2.0 Fast' },
|
||||
{ value: 'seedance_2p0_bp', label: 'Seedance 2.0 (BytePlus)' },
|
||||
{ value: 'seedance_2p0_bp_fast', label: 'Seedance 2.0 Fast (BytePlus)' },
|
||||
{ value: 'seedance_2p0_u', label: 'Seedance 2.0 Ultra' },
|
||||
{ value: 'seedance_2p0_u_fast', label: 'Seedance 2.0 Ultra Fast' },
|
||||
{ value: 'seedance_2p0_bpu', label: 'Seedance 2.0 Ultra (BytePlus)' },
|
||||
{ value: 'seedance_2p0_bpu_fast', label: 'Seedance 2.0 Ultra Fast (BytePlus)' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Veo',
|
||||
options: [
|
||||
{ value: 'veo_2', label: 'Veo 2' },
|
||||
{ value: 'veo_3', label: 'Veo 3' },
|
||||
{ value: 'veo_3_fast', label: 'Veo 3 Fast' },
|
||||
{ value: 'veo_3p1', label: 'Veo 3.1' },
|
||||
{ value: 'veo_3p1_fast', label: 'Veo 3.1 Fast' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Sora',
|
||||
options: [
|
||||
{ value: 'sora_2', label: 'Sora 2' },
|
||||
{ value: 'sora_2_pro', label: 'Sora 2 Pro' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Kling',
|
||||
options: [
|
||||
{ value: 'kling_1p6_pro', label: 'Kling 1.6 Pro' },
|
||||
{ value: 'kling_2p1_pro', label: 'Kling 2.1 Pro' },
|
||||
{ value: 'kling_2p1_master', label: 'Kling 2.1 Master' },
|
||||
{ value: 'kling_2p5_turbo_pro', label: 'Kling 2.5 Turbo Pro' },
|
||||
{ value: 'kling_2p6_pro', label: 'Kling 2.6 Pro' },
|
||||
{ value: 'kling_3p0_standard', label: 'Kling 3.0 Standard' },
|
||||
{ value: 'kling_3p0_pro', label: 'Kling 3.0 Pro' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Grok',
|
||||
options: [
|
||||
{ value: 'grok_video', label: 'Grok Video' },
|
||||
{ value: 'grok_imagine_video', label: 'Grok Imagine Video' },
|
||||
{ value: 'grok_imagine_video_1p5', label: 'Grok Imagine Video 1.5' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Other',
|
||||
options: [{ value: 'happy_horse_1p0', label: 'Happy Horse 1.0' }],
|
||||
},
|
||||
];
|
||||
|
||||
export const WEBSITE_OPTIONS: SelectOption[] = [
|
||||
{ value: 'runway', label: 'Runway' },
|
||||
{ value: 'higgsfield', label: 'Higgsfield' },
|
||||
{ value: 'krea', label: 'Krea' },
|
||||
{ value: 'open_art', label: 'OpenArt' },
|
||||
{ value: 'artcraft', label: 'Artcraft' },
|
||||
{ value: 'magnific', label: 'Magnific' },
|
||||
{ value: 'free_pik', label: 'Freepik' },
|
||||
];
|
||||
Reference in new issue
Block a user