uploaded videos - tables, endpoints, frontend (#1668)

This commit is contained in:
Brandon Thomas authored and GitHub committed 2026-06-19 21:17:45 -04:00
1 parent c7341d8f63
commit 914c2532ec
57 files changed
+3386 -222

No files matched your search

@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\nUPDATE uploaded_video_notes\nSET\n maybe_filename = ?,\n maybe_reported_model_type = ?,\n maybe_reported_model_name = ?,\n maybe_website = ?,\n maybe_other_website = ?,\n maybe_comments = ?,\n maybe_email_address = ?,\n can_share_report = ?,\n was_scammed = ?,\n maybe_comment_update_ip_address = ?,\n version = version + 1\nWHERE token = ?\nLIMIT 1\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 11
},
"nullable": []
},
"hash": "4036dfd9d4e9e77e557776468c14b75f49170de1e55a1b9b9e83446a0c356e74"
}
@@ -0,0 +1,157 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n token as `token: UploadedVideoToken`,\n sha1_checksum,\n filesize_bytes as `filesize_bytes: u32`,\n maybe_width as `maybe_width: u32`,\n maybe_height as `maybe_height: u32`,\n maybe_resolution,\n maybe_detected_model_family as `maybe_detected_model_family: UploadedVideoDetectedModelFamily`,\n maybe_detected_model_type as `maybe_detected_model_type: UploadedVideoDetectedModelType`,\n maybe_report,\n upload_ip_address,\n maybe_updated_ip_address,\n created_at,\n updated_at\nFROM uploaded_videos\nWHERE sha1_checksum = ?\nLIMIT 1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "token: UploadedVideoToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 1,
"name": "sha1_checksum",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 160
}
},
{
"ordinal": 2,
"name": "filesize_bytes: u32",
"type_info": {
"type": "Long",
"flags": "NOT_NULL | UNSIGNED | NO_DEFAULT_VALUE",
"char_set": 63,
"max_size": 10
}
},
{
"ordinal": 3,
"name": "maybe_width: u32",
"type_info": {
"type": "Long",
"flags": "UNSIGNED",
"char_set": 63,
"max_size": 10
}
},
{
"ordinal": 4,
"name": "maybe_height: u32",
"type_info": {
"type": "Long",
"flags": "UNSIGNED",
"char_set": 63,
"max_size": 10
}
},
{
"ordinal": 5,
"name": "maybe_resolution",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 48
}
},
{
"ordinal": 6,
"name": "maybe_detected_model_family: UploadedVideoDetectedModelFamily",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 7,
"name": "maybe_detected_model_type: UploadedVideoDetectedModelType",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 8,
"name": "maybe_report",
"type_info": {
"type": "Blob",
"flags": "BLOB | BINARY",
"char_set": 224,
"max_size": 67108860
}
},
{
"ordinal": 9,
"name": "upload_ip_address",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 160
}
},
{
"ordinal": 10,
"name": "maybe_updated_ip_address",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 160
}
},
{
"ordinal": 11,
"name": "created_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | BINARY | TIMESTAMP",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 12,
"name": "updated_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | BINARY | TIMESTAMP | ON_UPDATE_NOW",
"char_set": 63,
"max_size": 19
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
true,
true,
true,
true,
true,
true,
false,
true,
false,
false
]
},
"hash": "408c893a0ced805de4c3c6cfdf0c6a4bcc93dd1eaf98382522eae46e72eb3aa5"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\nUPDATE uploaded_videos\nSET\n maybe_width = ?,\n maybe_height = ?,\n maybe_resolution = ?,\n maybe_detected_model_family = ?,\n maybe_detected_model_type = ?,\n maybe_report = ?,\n maybe_updated_ip_address = ?,\n version = version + 1\nWHERE token = ?\nLIMIT 1\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 8
},
"nullable": []
},
"hash": "62f08d5f32bdc35e6faf43e3d21affa2dcf1501cda831cb88524bf1571d08e2f"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\nINSERT INTO uploaded_videos\nSET\n token = ?,\n sha1_checksum = ?,\n filesize_bytes = ?,\n maybe_width = ?,\n maybe_height = ?,\n maybe_resolution = ?,\n maybe_detected_model_family = ?,\n maybe_detected_model_type = ?,\n maybe_report = ?,\n upload_ip_address = ?,\n created_at = NOW()\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 10
},
"nullable": []
},
"hash": "64efd175fa4eb751be1125d55de93ed66a9fc6582e36cec4fc8225c350ec51e2"
}
@@ -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"
}
@@ -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
View File
@@ -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",
+2
View File
@@ -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>,
}
+19
View File
@@ -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"
+17
View File
@@ -0,0 +1,17 @@
use std::fmt;
/// Errors produced while computing a SHA-1 hash.
#[derive(Debug)]
pub enum Sha1HashError {
/// The SHA-1 digest wasn't the expected 20 bytes. This should be unreachable
/// for a well-behaved SHA-1 implementation; it exists as a defensive guard.
UnexpectedDigestLength(usize),
}
impl fmt::Display for Sha1HashError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{:?}", self)
}
}
impl std::error::Error for Sha1HashError {}
+11
View File
@@ -0,0 +1,11 @@
//! sha1_hash
//!
//! SHA-1 hashing utilities. The primary entry point is
//! [`sha1_hash_bytes_as_crockford`], which hashes bytes with SHA-1 and returns
//! the 20-byte digest as a lowercase Crockford base32 string (32 chars).
pub mod error;
pub mod sha1_hash_bytes_as_crockford;
pub use error::Sha1HashError;
pub use sha1_hash_bytes_as_crockford::sha1_hash_bytes_as_crockford;
@@ -0,0 +1,74 @@
use sha1::{Digest, Sha1};
use crate::error::Sha1HashError;
/// Lowercase Crockford base32 alphabet (excludes I, L, O, U). Matches the casing
/// used by the `tokens` crate's Crockford generators.
const CROCKFORD_LOWERCASE: &[u8] = b"0123456789abcdefghjkmnpqrstvwxyz";
/// The byte length of a SHA-1 digest.
const SHA1_DIGEST_LEN: usize = 20;
/// SHA-1 hash `bytes` and return the 20-byte digest encoded as a lowercase
/// Crockford base32 string (32 characters, no padding — 160 bits / 5).
pub fn sha1_hash_bytes_as_crockford(bytes: &[u8]) -> Result<String, Sha1HashError> {
let mut hasher = Sha1::new();
hasher.update(bytes);
let digest = hasher.finalize();
if digest.len() != SHA1_DIGEST_LEN {
return Err(Sha1HashError::UnexpectedDigestLength(digest.len()));
}
Ok(encode_crockford_lower(&digest))
}
/// Encode bytes as lowercase Crockford base32, reading 5 bits at a time.
fn encode_crockford_lower(data: &[u8]) -> String {
let mut out = String::with_capacity(data.len() * 8 / 5 + 1);
let mut buffer: u32 = 0;
let mut bits: u32 = 0;
for &byte in data {
buffer = (buffer << 8) | byte as u32;
bits += 8;
while bits >= 5 {
bits -= 5;
let index = ((buffer >> bits) & 0x1f) as usize;
out.push(CROCKFORD_LOWERCASE[index] as char);
}
}
if bits > 0 {
let index = ((buffer << (5 - bits)) & 0x1f) as usize;
out.push(CROCKFORD_LOWERCASE[index] as char);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_input_is_32_crockford_chars() {
// SHA-1("") = da39a3ee5e6b4b0d3255bfef95601890afd80709
let out = sha1_hash_bytes_as_crockford(&[]).unwrap();
assert_eq!(out.len(), 32);
assert!(out.bytes().all(|b| CROCKFORD_LOWERCASE.contains(&b)));
}
#[test]
fn deterministic_and_distinct() {
let a = sha1_hash_bytes_as_crockford(b"hello").unwrap();
let b = sha1_hash_bytes_as_crockford(b"hello").unwrap();
let c = sha1_hash_bytes_as_crockford(b"world").unwrap();
assert_eq!(a, b);
assert_ne!(a, c);
assert_eq!(a.len(), 32);
}
#[test]
fn encodes_known_bytes() {
// 20 zero bytes → 32 '0' chars.
assert_eq!(encode_crockford_lower(&[0u8; 20]), "0".repeat(32));
}
}
@@ -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;
@@ -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;
@@ -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())
}
@@ -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,
}))
}
@@ -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,
}))
}
@@ -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;
@@ -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())
}
@@ -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;
@@ -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);
}
}
}
@@ -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;
@@ -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);
}
}
}
@@ -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;
@@ -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(),
}
}
@@ -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)
}
@@ -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,
}))
}
@@ -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
}
}
}
@@ -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;
}
+118 -33
View File
@@ -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 &amp; 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&apos;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 &amp; 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' },
];