Compare commits

...
Author SHA1 Message Date
Brandon Thomas 2929eae094 rate limiter 2026-06-19 10:42:50 -04:00
Brandon Thomas b47f9a522a video info handler 2026-06-19 10:40:49 -04:00
14 changed files with 456 additions and 0 deletions
Generated
+1
View File
@@ -11667,6 +11667,7 @@ dependencies = [
"utoipa",
"utoipa-swagger-ui",
"uuid_utils",
"video_info",
"videos",
"workspace-build-acceleration",
"worldlabs_api_client",
@@ -16,4 +16,5 @@ pub mod subscriptions;
pub mod user_referral_codes;
pub mod users;
pub mod utils;
pub mod video_info;
pub mod web_referrals;
@@ -0,0 +1 @@
pub mod read_only;
@@ -0,0 +1,167 @@
//! Response types for `POST /v1/video_info/read_only`.
//!
//! The endpoint accepts a `multipart/form-data` video upload, inspects its
//! embedded provenance (C2PA manifests, AIGC labels, in-stream watermarks), and
//! returns what it found. These are the public, wire-stable API types — they
//! deliberately mirror, but do not re-export, the internal `video_info` crate
//! types so the parsing library can evolve independently of the HTTP contract.
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
/// Response for `POST /v1/video_info/read_only`.
///
/// [`kind`](Self::kind) names the detected provenance family; the matching
/// `maybe_*` field carries its details (all others are `null`). `unrecognized`
/// means no supported provenance was found — [`maybe_encoder`](Self::maybe_encoder)
/// still reports the container encoder tag when present (an `Lavf…` value means
/// the file was re-encoded through ffmpeg, which strips provenance).
#[derive(Serialize, Deserialize, ToSchema, Debug, Clone)]
pub struct VideoInfoReadOnlyResponse {
pub success: bool,
/// Which provenance family was detected (if any).
pub kind: VideoProvenanceKind,
/// The container `©too` encoder tag, e.g. `"Lavf60.16.100"` or `"Google"`.
pub maybe_encoder: Option<String>,
/// Present when [`kind`](Self::kind) is [`VideoProvenanceKind::Seedance`].
pub maybe_seedance: Option<SeedanceVideoInfo>,
/// Present when [`kind`](Self::kind) is [`VideoProvenanceKind::Veo`].
pub maybe_veo: Option<VeoVideoInfo>,
/// Present when [`kind`](Self::kind) is [`VideoProvenanceKind::Sora`].
pub maybe_sora: Option<SoraVideoInfo>,
/// Present when [`kind`](Self::kind) is [`VideoProvenanceKind::Dreamina`].
pub maybe_dreamina: Option<DreaminaVideoInfo>,
/// Present when [`kind`](Self::kind) is [`VideoProvenanceKind::Kling`].
pub maybe_kling: Option<KlingVideoInfo>,
}
/// The recognized AI-video provenance family.
#[derive(Serialize, Deserialize, ToSchema, Debug, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum VideoProvenanceKind {
/// ByteDance Seedance API (Volcengine / BytePlus C2PA).
Seedance,
/// Google Veo (Google Generative AI video C2PA).
Veo,
/// OpenAI Sora (OpenAI Generative AI video C2PA).
Sora,
/// Dreamina (ByteDance/CapCut consumer app `ilst` metadata).
Dreamina,
/// Kling (Kuaishou AIGC-label / in-stream watermark).
Kling,
/// No supported provenance was found (often a re-encoded / stripped file).
Unrecognized,
}
/// Seedance (ByteDance) provenance from an embedded C2PA manifest.
#[derive(Serialize, Deserialize, ToSchema, Debug, Clone)]
pub struct SeedanceVideoInfo {
/// `"volcengine"` or `"byteplus"`.
pub platform: String,
/// C2PA `softwareAgent`, e.g. `"Volcengine_Ark_CN"`.
pub software_agent: String,
pub maybe_software_agent_version: Option<String>,
/// Full model identifier, e.g. `"doubao-seedance-2-0-fast"`.
pub model_name: String,
pub maybe_model_brand: Option<String>,
/// Parsed version, e.g. `"2.0"` / `"1.0"`.
pub maybe_model_version: Option<String>,
pub is_fast: bool,
pub is_lite: bool,
/// Raw generation timestamp string (RFC 3339).
pub generated_at: String,
/// Normalized RFC 3339 timestamp, when parseable.
pub maybe_generated_at_utc: Option<String>,
pub maybe_log_id: Option<String>,
pub maybe_log_id_decoded_hex: Option<String>,
pub maybe_digital_source_type: Option<String>,
pub maybe_claim_generator: Option<String>,
pub maybe_claim_generator_version: Option<String>,
pub maybe_manifest_id: Option<String>,
pub maybe_instance_id: Option<String>,
pub maybe_signer_email: Option<String>,
pub maybe_signer_org_id: Option<String>,
pub maybe_signer_country: Option<String>,
pub maybe_cert_serial: Option<String>,
}
/// Google Veo provenance.
#[derive(Serialize, Deserialize, ToSchema, Debug, Clone)]
pub struct VeoVideoInfo {
/// Always `"Google"`.
pub producer: String,
/// Whether a full Google C2PA manifest is present (`false` = recognized only
/// by the `encoder=Google` tag, C2PA likely stripped by a re-encode).
pub has_c2pa_manifest: bool,
pub maybe_encoder: Option<String>,
pub maybe_created_description: Option<String>,
pub has_synthid_watermark: bool,
pub maybe_synthid_description: Option<String>,
pub maybe_digital_source_type: Option<String>,
pub maybe_claim_generator: Option<String>,
pub maybe_claim_generator_version: Option<String>,
pub maybe_manifest_id: Option<String>,
pub maybe_instance_id: Option<String>,
pub maybe_cert_serial: Option<String>,
/// Issuing intermediate-CA common name, e.g. `"Google C2PA Media Services 1P ICA G3"`.
pub maybe_signer_ca: Option<String>,
/// Whether the manifest carries an RFC 3161 trusted timestamp.
pub is_timestamped: bool,
pub maybe_timestamp_authority: Option<String>,
}
/// OpenAI Sora provenance.
#[derive(Serialize, Deserialize, ToSchema, Debug, Clone)]
pub struct SoraVideoInfo {
/// Always `"OpenAI"`.
pub producer: String,
pub maybe_model_name: Option<String>,
pub maybe_created_description: Option<String>,
pub maybe_digital_source_type: Option<String>,
pub maybe_claim_generator: Option<String>,
pub maybe_manifest_id: Option<String>,
pub maybe_instance_id: Option<String>,
pub maybe_cert_serial: Option<String>,
}
/// Dreamina (ByteDance/CapCut app) provenance from `ilst` metadata.
#[derive(Serialize, Deserialize, ToSchema, Debug, Clone)]
pub struct DreaminaVideoInfo {
/// The product string, e.g. `"dreamina"`.
pub product: String,
pub maybe_export_type: Option<String>,
pub maybe_os: Option<String>,
pub maybe_source_info: Option<String>,
pub maybe_aigc_label_type: Option<i64>,
pub maybe_video_id: Option<String>,
pub has_c2pa: bool,
pub maybe_signer_org_id: Option<String>,
pub maybe_signer_country: Option<String>,
pub maybe_cert_serial: Option<String>,
}
/// Kling (Kuaishou) provenance from the AIGC label and/or in-stream watermark.
#[derive(Serialize, Deserialize, ToSchema, Debug, Clone)]
pub struct KlingVideoInfo {
/// Model version when a clean export embeds it (e.g. `"2.5-pro"`); usually `null`.
pub maybe_model_version: Option<String>,
/// Whether labeled AI-generated (`Label == "1"`, per China GB 45438).
pub is_ai_generated: bool,
pub maybe_label: Option<String>,
pub maybe_content_producer: Option<String>,
pub maybe_produce_id: Option<String>,
pub maybe_content_propagator: Option<String>,
pub maybe_propagate_id: Option<String>,
pub maybe_reserved_code_1: Option<String>,
pub maybe_reserved_code_2: Option<String>,
/// Whether the in-stream `kling-ai` SEI watermark is present.
pub has_stream_watermark: bool,
pub maybe_watermark_uuid: Option<String>,
}
@@ -38,6 +38,7 @@ actix_helpers.workspace = true
artcraft_api_defs.workspace = true
artcraft_api_keys.workspace = true
artcraft_router.workspace = true
video_info.workspace = true
bootstrap.workspace = true
bucket_paths.workspace = true
cloud_storage.workspace = true
@@ -116,6 +116,19 @@ pub fn configure_redis_rate_limiters() -> AnyhowResult<RedisRateLimiters> {
RedisRateLimiter::new(limiter, "file_upload_logged_in", limiter_enabled)
};
let video_info_read_only_rate_limiter = {
let limiter_enabled = easyenv::get_env_bool_or_default("LIMITER_VIDEO_INFO_READ_ONLY_ENABLED", true);
let limiter_max_requests = easyenv::get_env_num("LIMITER_VIDEO_INFO_READ_ONLY_MAX_REQUESTS", 1)?;
let limiter_window_seconds = easyenv::get_env_num("LIMITER_VIDEO_INFO_READ_ONLY_WINDOW_SECONDS", 5)?;
let limiter = Limiter::build(&redis_connection_string)
.limit(limiter_max_requests)
.period(Duration::from_secs(limiter_window_seconds))
.finish()?;
RedisRateLimiter::new(limiter, "video_info_read_only", limiter_enabled)
};
Ok(RedisRateLimiters {
logged_out: logged_out_redis_rate_limiter,
logged_in: logged_in_redis_rate_limiter,
@@ -125,5 +138,6 @@ pub fn configure_redis_rate_limiters() -> AnyhowResult<RedisRateLimiters> {
model_upload: model_upload_rate_limiter,
file_upload_logged_out: file_upload_logged_out_redis_rate_limiter,
file_upload_logged_in: file_upload_logged_in_redis_rate_limiter,
video_info_read_only: video_info_read_only_rate_limiter,
})
}
@@ -364,6 +364,8 @@ use artcraft_api_defs::user_referral_codes::create_referral_code::*;
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 crate::http_server::endpoints::video_info::video_info_read_info_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::*;
@@ -617,6 +619,7 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
// Subscriptions
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::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,
@@ -1249,6 +1252,16 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
Veo3p1FastMultiFunctionVideoGenRequest,
Veo3p1FastMultiFunctionVideoGenResponse,
// Video info (provenance detection)
VideoInfoReadOnlyForm,
VideoInfoReadOnlyResponse,
VideoProvenanceKind,
SeedanceVideoInfo,
VeoVideoInfo,
SoraVideoInfo,
DreaminaVideoInfo,
KlingVideoInfo,
// Object Multi-Function types
Hunyuan3dV3MultiFunctionObjectGenRequest,
Hunyuan3dV3MultiFunctionObjectGenResponse,
@@ -32,6 +32,7 @@ pub mod user_ratings;
pub mod user_referral_codes;
pub mod user_wallets;
pub mod users;
pub mod video_info;
pub mod voice_clone_requests;
pub mod voice_conversion;
pub mod voice_designer;
@@ -0,0 +1 @@
pub mod video_info_read_info_handler;
@@ -0,0 +1,224 @@
//! `POST /v1/video_info/read_only` — inspect an uploaded video's embedded
//! AI-generation provenance and return what was detected.
//!
//! 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.
use std::io::Read;
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;
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 video_info::VideoInfo;
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;
/// Form-multipart request: a single video file under the `file` field.
#[derive(MultipartForm, ToSchema)]
#[multipart(duplicate_field = "deny")]
pub struct VideoInfoReadOnlyForm {
/// The video file to inspect.
#[multipart(limit = "256 MiB")]
#[schema(value_type = Vec<u8>, format = Binary)]
file: TempFile,
}
/// Detect the AI-generation provenance of an uploaded video.
#[utoipa::path(
post,
tag = "Video Info",
path = "/v1/video_info/read_only",
responses(
(status = 200, description = "Detected provenance (or `unrecognized`)", body = VideoInfoReadOnlyResponse),
(status = 400, description = "Bad input", body = CommonWebError),
(status = 500, description = "Server error", body = CommonWebError),
),
params(
("request" = VideoInfoReadOnlyForm, description = "Multipart form with a single `file` video upload."),
)
)]
pub async fn video_info_read_info_handler(
http_request: HttpRequest,
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.
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: 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()));
}
let maybe_encoder = video_info::encoder_tag(&bytes);
// 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 {
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,
}
}
@@ -21,6 +21,7 @@ use crate::http_server::routes::application_routes::user_bookmarks_routes::add_u
use crate::http_server::routes::application_routes::user_rating_routes::add_user_rating_routes;
use crate::http_server::routes::application_routes::user_referral_code_routes::add_user_referral_code_routes;
use crate::http_server::routes::application_routes::user_routes::add_user_routes;
use crate::http_server::routes::application_routes::video_info_routes::add_video_info_routes;
use crate::http_server::routes::application_routes::wallet_routes::add_wallet_routes;
use crate::http_server::routes::application_routes::web_referrals_routes::add_web_referrals_routes;
use crate::http_server::routes::application_routes::webhook_routes::add_webhook_routes;
@@ -68,6 +69,7 @@ where
app = add_job_routes(app);
// Other useful tools
app = add_video_info_routes(app); // /v1/video_info/... (video provenance detection)
app = add_analytics_routes(app); // /v1/analytics/...
app = add_web_referrals_routes(app); // /v1/web_referrals/...
app = add_dev_routes(app); // /v1/dev/... — dev-only error/response shape probes
@@ -21,6 +21,7 @@ mod user_bookmarks_routes;
mod user_rating_routes;
mod user_referral_code_routes;
mod user_routes;
mod video_info_routes;
mod wallet_routes;
mod web_referrals_routes;
mod webhook_routes;
@@ -0,0 +1,26 @@
use actix_http::body::MessageBody;
use actix_service::ServiceFactory;
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_read_info_handler::video_info_read_info_handler;
pub fn add_video_info_routes<T, B> (app: App<T>) -> App<T>
where
B: MessageBody,
T: ServiceFactory<
ServiceRequest,
Config = (),
Response = ServiceResponse<B>,
Error = Error,
InitError = (),
>,
{
app.service(web::scope("/v1/video_info")
.service(web::resource("/read_only")
.route(web::post().to(video_info_read_info_handler))
.route(web::head().to(|| HttpResponse::Ok()))
)
)
}
@@ -160,6 +160,9 @@ pub struct RedisRateLimiters {
/// For uploading files for voice conversion, face animator, etc.
pub file_upload_logged_in: RedisRateLimiter,
/// IP-based limiter for the read-only video-info upload endpoint. Fails open.
pub video_info_read_only: RedisRateLimiter,
}
/// In-memory caches of several types.