mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-03 00:47:53 +00:00
first api binding
This commit is contained in:
1 parent
957db2d07f
commit
edb5a81e02
23 files changed
+1308
No files matched your search
Generated
+13
@@ -2377,6 +2377,19 @@ dependencies = [
|
||||
"tokio-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "comet_api_client"
|
||||
version = "0.0.1"
|
||||
dependencies = [
|
||||
"log",
|
||||
"reqwest 0.12.23",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"workspace-build-acceleration",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "compact_str"
|
||||
version = "0.7.1"
|
||||
|
||||
@@ -10,6 +10,7 @@ members = [
|
||||
"crates/api_clients/artcraft/artcraft_client",
|
||||
"crates/api_clients/artcraft/artcraft_router",
|
||||
"crates/api_clients/cloud_storage",
|
||||
"crates/api_clients/comet_api_client",
|
||||
"crates/api_clients/datadog_client",
|
||||
"crates/api_clients/fal_client",
|
||||
"crates/api_clients/google_drive_downloader",
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
[package]
|
||||
name = "comet_api_client"
|
||||
edition = "2024"
|
||||
version = "0.0.1"
|
||||
publish = false
|
||||
|
||||
[package.metadata.cargo-udeps.ignore]
|
||||
normal = ["workspace-build-acceleration"]
|
||||
|
||||
[lib]
|
||||
name = "comet_api_client"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
# Workspace hack (faster compile times)
|
||||
workspace-build-acceleration.workspace = true
|
||||
|
||||
# External
|
||||
log.workspace = true
|
||||
# `multipart` is enabled on top of the workspace feature set because the
|
||||
# CometAPI video creation endpoint takes `multipart/form-data` (the
|
||||
# `input_reference` image fields are binary parts).
|
||||
reqwest = { workspace = true, features = ["multipart"] }
|
||||
serde.workspace = true
|
||||
serde_derive.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
@@ -0,0 +1,17 @@
|
||||
/// A CometAPI key used for Bearer token authentication.
|
||||
#[derive(Clone)]
|
||||
pub struct CometApiKey(pub String);
|
||||
|
||||
impl CometApiKey {
|
||||
pub fn new(api_key: String) -> Self {
|
||||
Self(api_key.trim().to_string())
|
||||
}
|
||||
|
||||
pub fn from_str(api_key: &str) -> Self {
|
||||
Self(api_key.trim().to_string())
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod comet_api_key;
|
||||
@@ -0,0 +1,157 @@
|
||||
use reqwest::StatusCode;
|
||||
|
||||
use crate::error::comet_bad_request_api_error::CometBadRequestApiError;
|
||||
use crate::error::comet_error::CometError;
|
||||
use crate::error::comet_generic_api_error::CometGenericApiError;
|
||||
use crate::error::comet_specific_api_error::CometSpecificApiError;
|
||||
|
||||
/// Marker CometAPI returns from `GET /v1/videos/{id}` for unknown task ids.
|
||||
const TASK_NOT_EXIST_MARKER: &str = "task_not_exist";
|
||||
|
||||
/// Categorize a non-success response from the video creation endpoint
|
||||
/// (`POST /v1/videos`).
|
||||
///
|
||||
/// Per the docs, 400s from this endpoint mean the request itself was invalid
|
||||
/// (missing prompt, out-of-range duration, image input on an unsupported
|
||||
/// model) — the caller's fault, surfaced as [`CometBadRequestApiError`].
|
||||
pub fn categorize_create_video_error(
|
||||
status_code: StatusCode,
|
||||
body: String,
|
||||
) -> CometError {
|
||||
if status_code == StatusCode::UNAUTHORIZED {
|
||||
return CometSpecificApiError::UnauthorizedInvalidApiKey { raw_body: body }.into();
|
||||
}
|
||||
|
||||
if status_code == StatusCode::BAD_REQUEST {
|
||||
return CometBadRequestApiError::InvalidRequestParameters { raw_body: body }.into();
|
||||
}
|
||||
|
||||
CometGenericApiError::UncategorizedBadResponseWithStatusAndBody {
|
||||
status_code,
|
||||
body,
|
||||
}.into()
|
||||
}
|
||||
|
||||
/// Categorize a non-success response from the poll endpoint
|
||||
/// (`GET /v1/videos/{id}`).
|
||||
///
|
||||
/// Unlike the creation endpoint, a 400 here means the task id is unknown
|
||||
/// ("task_not_exist") — a specific, well-known cause rather than a
|
||||
/// user-facing bad request.
|
||||
pub fn categorize_get_video_task_error(
|
||||
status_code: StatusCode,
|
||||
body: String,
|
||||
task_id: &str,
|
||||
) -> CometError {
|
||||
if status_code == StatusCode::UNAUTHORIZED {
|
||||
return CometSpecificApiError::UnauthorizedInvalidApiKey { raw_body: body }.into();
|
||||
}
|
||||
|
||||
if status_code == StatusCode::BAD_REQUEST && body.contains(TASK_NOT_EXIST_MARKER) {
|
||||
return CometSpecificApiError::TaskNotFound {
|
||||
task_id: task_id.to_string(),
|
||||
raw_body: body,
|
||||
}.into();
|
||||
}
|
||||
|
||||
CometGenericApiError::UncategorizedBadResponseWithStatusAndBody {
|
||||
status_code,
|
||||
body,
|
||||
}.into()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
mod create_video_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn bad_request_is_caller_fault() {
|
||||
let body = r#"{"error":{"message":"seconds must be between 4 and 15","type":"invalid_request_error"}}"#.to_string();
|
||||
|
||||
let error = categorize_create_video_error(StatusCode::BAD_REQUEST, body.clone());
|
||||
|
||||
match error {
|
||||
CometError::ApiBadRequest(CometBadRequestApiError::InvalidRequestParameters { raw_body }) => {
|
||||
assert_eq!(raw_body, body);
|
||||
}
|
||||
other => panic!("Expected InvalidRequestParameters, got: {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unauthorized_is_specific() {
|
||||
let body = r#"{"error":{"message":"invalid token","type":"cometapi_error"}}"#.to_string();
|
||||
|
||||
let error = categorize_create_video_error(StatusCode::UNAUTHORIZED, body);
|
||||
|
||||
match error {
|
||||
CometError::ApiSpecific(CometSpecificApiError::UnauthorizedInvalidApiKey { raw_body }) => {
|
||||
assert!(raw_body.contains("invalid token"));
|
||||
}
|
||||
other => panic!("Expected UnauthorizedInvalidApiKey, got: {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_errors_fall_back_to_uncategorized() {
|
||||
let error = categorize_create_video_error(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
"upstream timeout".to_string(),
|
||||
);
|
||||
|
||||
match &error {
|
||||
CometError::ApiGeneric(CometGenericApiError::UncategorizedBadResponseWithStatusAndBody { status_code, .. }) => {
|
||||
assert_eq!(*status_code, StatusCode::BAD_GATEWAY);
|
||||
}
|
||||
other => panic!("Expected UncategorizedBadResponseWithStatusAndBody, got: {:?}", other),
|
||||
}
|
||||
assert!(error.is_having_downtime_issues());
|
||||
}
|
||||
}
|
||||
|
||||
mod get_video_task_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn task_not_exist_is_task_not_found() {
|
||||
let body = r#"{"error":{"message":"task_not_exist","type":"cometapi_error"}}"#.to_string();
|
||||
|
||||
let error = categorize_get_video_task_error(StatusCode::BAD_REQUEST, body, "task_abc123");
|
||||
|
||||
match error {
|
||||
CometError::ApiSpecific(CometSpecificApiError::TaskNotFound { task_id, raw_body }) => {
|
||||
assert_eq!(task_id, "task_abc123");
|
||||
assert!(raw_body.contains("task_not_exist"));
|
||||
}
|
||||
other => panic!("Expected TaskNotFound, got: {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_bad_requests_fall_back_to_uncategorized() {
|
||||
let body = r#"{"error":{"message":"something else"}}"#.to_string();
|
||||
|
||||
let error = categorize_get_video_task_error(StatusCode::BAD_REQUEST, body, "task_abc123");
|
||||
|
||||
match error {
|
||||
CometError::ApiGeneric(CometGenericApiError::UncategorizedBadResponseWithStatusAndBody { .. }) => {}
|
||||
other => panic!("Expected UncategorizedBadResponseWithStatusAndBody, got: {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unauthorized_is_specific() {
|
||||
let body = r#"{"error":{"message":"invalid token"}}"#.to_string();
|
||||
|
||||
let error = categorize_get_video_task_error(StatusCode::UNAUTHORIZED, body, "task_abc123");
|
||||
|
||||
match error {
|
||||
CometError::ApiSpecific(CometSpecificApiError::UnauthorizedInvalidApiKey { .. }) => {}
|
||||
other => panic!("Expected UnauthorizedInvalidApiKey, got: {:?}", other),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
use std::error::Error;
|
||||
use std::fmt::{Display, Formatter};
|
||||
|
||||
/// These represent known "400s" that are the fault of the client sending a
|
||||
/// bad request. They should surface to the end user as 400s.
|
||||
#[derive(Debug)]
|
||||
pub enum CometBadRequestApiError {
|
||||
/// The video creation request was rejected for invalid parameters (eg.
|
||||
/// missing prompt, out-of-range duration, an `input_reference` image on a
|
||||
/// model that doesn't support image input).
|
||||
InvalidRequestParameters { raw_body: String },
|
||||
}
|
||||
|
||||
impl Error for CometBadRequestApiError {}
|
||||
|
||||
impl Display for CometBadRequestApiError {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::InvalidRequestParameters { raw_body } => write!(f, "Invalid request parameters: {}", raw_body),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
use std::error::Error;
|
||||
use std::fmt::{Display, Formatter};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum CometClientError {
|
||||
/// An error was encountered building the reqwest client or the request.
|
||||
ReqwestClientError(reqwest::Error),
|
||||
|
||||
/// A request field couldn't be validated or converted to the value
|
||||
/// expected on the wire. Carries the field name and the raw input the
|
||||
/// caller supplied.
|
||||
InvalidRequestField { field: &'static str, raw_value: String, reason: String },
|
||||
}
|
||||
|
||||
impl Error for CometClientError {}
|
||||
|
||||
impl Display for CometClientError {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::ReqwestClientError(err) => write!(f, "Reqwest client error: {}", err),
|
||||
Self::InvalidRequestField { field, raw_value, reason } => {
|
||||
write!(f, "Invalid value for request field `{}`: {:?} ({})", field, raw_value, reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
use std::error::Error;
|
||||
use std::fmt::{Display, Formatter};
|
||||
|
||||
use crate::error::comet_bad_request_api_error::CometBadRequestApiError;
|
||||
use crate::error::comet_client_error::CometClientError;
|
||||
use crate::error::comet_generic_api_error::CometGenericApiError;
|
||||
use crate::error::comet_specific_api_error::CometSpecificApiError;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum CometError {
|
||||
/// An error that happened client-side (before or while making the request).
|
||||
Client(CometClientError),
|
||||
|
||||
/// Known "400s" that are the fault of the client sending a bad request.
|
||||
/// These should surface to the end user as 400s.
|
||||
ApiBadRequest(CometBadRequestApiError),
|
||||
|
||||
/// Server-side errors with specific, well-known causes.
|
||||
ApiSpecific(CometSpecificApiError),
|
||||
|
||||
/// Server-side errors with unknown / grab-bag causes.
|
||||
ApiGeneric(CometGenericApiError),
|
||||
}
|
||||
|
||||
impl CometError {
|
||||
pub fn is_having_downtime_issues(&self) -> bool {
|
||||
match self {
|
||||
Self::ApiGeneric(CometGenericApiError::UncategorizedBadResponseWithStatusAndBody { status_code, body: _ }) => {
|
||||
matches!(status_code.as_u16(), 502 | 503 | 504 | 524)
|
||||
},
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for CometError {}
|
||||
|
||||
impl Display for CometError {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Client(e) => write!(f, "CometClientError: {:?}", e),
|
||||
Self::ApiBadRequest(e) => write!(f, "CometBadRequestApiError: {:?}", e),
|
||||
Self::ApiSpecific(e) => write!(f, "CometSpecificApiError: {:?}", e),
|
||||
Self::ApiGeneric(e) => write!(f, "CometGenericApiError: {:?}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<CometClientError> for CometError {
|
||||
fn from(error: CometClientError) -> Self {
|
||||
Self::Client(error)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<CometBadRequestApiError> for CometError {
|
||||
fn from(error: CometBadRequestApiError) -> Self {
|
||||
Self::ApiBadRequest(error)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<CometSpecificApiError> for CometError {
|
||||
fn from(error: CometSpecificApiError) -> Self {
|
||||
Self::ApiSpecific(error)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<CometGenericApiError> for CometError {
|
||||
fn from(error: CometGenericApiError) -> Self {
|
||||
Self::ApiGeneric(error)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
use std::error::Error;
|
||||
use std::fmt::{Display, Formatter};
|
||||
|
||||
use reqwest::StatusCode;
|
||||
|
||||
/// Server-side errors with unknown / grab-bag causes.
|
||||
#[derive(Debug)]
|
||||
pub enum CometGenericApiError {
|
||||
/// serde_json::Error, likely from a JSON deserialization schema mismatch.
|
||||
/// Includes the original body.
|
||||
SerdeResponseParseErrorWithBody(serde_json::Error, String),
|
||||
|
||||
/// An uncategorized bad HTTP response with status code and body.
|
||||
UncategorizedBadResponseWithStatusAndBody {
|
||||
status_code: StatusCode,
|
||||
body: String,
|
||||
},
|
||||
|
||||
/// An uncaught error from the HTTP client (network failures, timeouts, etc.)
|
||||
ReqwestError(reqwest::Error),
|
||||
}
|
||||
|
||||
impl Error for CometGenericApiError {}
|
||||
|
||||
impl Display for CometGenericApiError {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::SerdeResponseParseErrorWithBody(err, body) => write!(f, "Failed to parse response body: {:?}. Body: {}", err, body),
|
||||
Self::UncategorizedBadResponseWithStatusAndBody { status_code, body } => write!(f, "Uncategorized bad response: status code {}, body: {}", status_code, body),
|
||||
Self::ReqwestError(err) => write!(f, "Reqwest error: {}", err),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
use std::error::Error;
|
||||
use std::fmt::{Display, Formatter};
|
||||
|
||||
/// Server-side errors with specific, well-known causes.
|
||||
#[derive(Debug)]
|
||||
pub enum CometSpecificApiError {
|
||||
/// 401: the API key is missing or invalid.
|
||||
UnauthorizedInvalidApiKey { raw_body: String },
|
||||
|
||||
/// The poll endpoint responded "task_not_exist" for the given task id.
|
||||
TaskNotFound { task_id: String, raw_body: String },
|
||||
}
|
||||
|
||||
impl Error for CometSpecificApiError {}
|
||||
|
||||
impl Display for CometSpecificApiError {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::UnauthorizedInvalidApiKey { raw_body } => write!(f, "Unauthorized: invalid or missing API key: {}", raw_body),
|
||||
Self::TaskNotFound { task_id, raw_body } => write!(f, "Task not found: {} ({})", task_id, raw_body),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
pub mod categorize_comet_error;
|
||||
pub mod comet_bad_request_api_error;
|
||||
pub mod comet_client_error;
|
||||
pub mod comet_error;
|
||||
pub mod comet_generic_api_error;
|
||||
pub mod comet_specific_api_error;
|
||||
@@ -0,0 +1,20 @@
|
||||
//! comet_api_client
|
||||
//!
|
||||
//! HTTP client for CometAPI (https://www.cometapi.com/), which proxies a
|
||||
//! variety of third-party generative models. We use it for Doubao Seedance 2.0
|
||||
//! video generation ("Seedance 2.0" and "Seedance 2.0 Fast").
|
||||
//!
|
||||
//! Flow: enqueue with [`requests::create_video`], then poll with
|
||||
//! [`requests::get_video_task`] until the task reaches a terminal status.
|
||||
//!
|
||||
//! NB: There is deliberately no webhook module. CometAPI documents callbacks
|
||||
//! as provider-specific pass-through events with no standardized payload, and
|
||||
//! the Seedance video endpoint has no documented callback parameter. Comet's
|
||||
//! own guidance is to "keep polling as the source of truth".
|
||||
|
||||
pub mod creds;
|
||||
pub mod error;
|
||||
pub mod requests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod test_utils;
|
||||
@@ -0,0 +1,2 @@
|
||||
/// Base URL for the CometAPI HTTP API.
|
||||
pub const COMET_API_BASE_URL: &str = "https://api.cometapi.com";
|
||||
@@ -0,0 +1,430 @@
|
||||
use std::fmt;
|
||||
|
||||
use log::info;
|
||||
use reqwest::multipart::{Form, Part};
|
||||
|
||||
use crate::creds::comet_api_key::CometApiKey;
|
||||
use crate::error::categorize_comet_error::categorize_create_video_error;
|
||||
use crate::error::comet_client_error::CometClientError;
|
||||
use crate::error::comet_error::CometError;
|
||||
use crate::error::comet_generic_api_error::CometGenericApiError;
|
||||
use crate::requests::comet_host::COMET_API_BASE_URL;
|
||||
use crate::requests::create_video::request_types::CreateVideoRawResponse;
|
||||
use crate::requests::video_task_status::CometVideoTaskStatus;
|
||||
|
||||
/// Seedance 2.0 models support 4-15 second durations.
|
||||
const MIN_DURATION_SECONDS: u8 = 4;
|
||||
const MAX_DURATION_SECONDS: u8 = 15;
|
||||
|
||||
// ── Public args ──
|
||||
|
||||
/// Top-level argument to [`create_video`]. Borrows the API key separately
|
||||
/// from the request body so callers can log/save [`CreateVideoRequest`]
|
||||
/// without leaking the credential.
|
||||
pub struct CreateVideoArgs<'a> {
|
||||
pub api_key: &'a CometApiKey,
|
||||
pub request: CreateVideoRequest,
|
||||
}
|
||||
|
||||
/// The material part of a video creation request (`POST /v1/videos`).
|
||||
#[derive(Clone)]
|
||||
pub struct CreateVideoRequest {
|
||||
/// Which Doubao Seedance model fulfils the request.
|
||||
pub model: DoubaoSeedance2p0Model,
|
||||
|
||||
/// Text description of the desired video. Reference attached images in
|
||||
/// the prompt as `[Image 1]`, `[Image 2]`, etc.
|
||||
pub prompt: String,
|
||||
|
||||
/// Video duration in seconds (4-15 for Seedance 2.0 models). Default: 5.
|
||||
pub maybe_seconds: Option<u8>,
|
||||
|
||||
/// Output size: an aspect-ratio preset or exact dimensions.
|
||||
pub maybe_size: Option<CometVideoSize>,
|
||||
|
||||
/// Optional reference images (JPEG, PNG, WebP) for image-to-video.
|
||||
/// Each is sent as a repeated `input_reference` multipart field.
|
||||
pub input_reference_images: Vec<CometInputReferenceImage>,
|
||||
}
|
||||
|
||||
/// The Doubao Seedance 2.0 models CometAPI fronts.
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||
pub enum DoubaoSeedance2p0Model {
|
||||
/// Seedance 2.0
|
||||
DoubaoSeedance2p0,
|
||||
/// Seedance 2.0 Fast
|
||||
DoubaoSeedance2p0Fast,
|
||||
}
|
||||
|
||||
/// Output video size: a ratio preset or exact pixel dimensions.
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||
pub enum CometVideoSize {
|
||||
/// 16:9 (landscape)
|
||||
Landscape16x9,
|
||||
/// 4:3
|
||||
Standard4x3,
|
||||
/// 1:1 (square)
|
||||
Square,
|
||||
/// 3:4
|
||||
Portrait3x4,
|
||||
/// 9:16 (portrait)
|
||||
Portrait9x16,
|
||||
/// 21:9 (ultra-wide)
|
||||
UltraWide21x9,
|
||||
/// Exact dimensions, eg. 1280x720.
|
||||
Exact { width: u32, height: u32 },
|
||||
}
|
||||
|
||||
/// A reference image attached to the request as a binary multipart part.
|
||||
#[derive(Clone)]
|
||||
pub struct CometInputReferenceImage {
|
||||
/// Raw bytes of the image (JPEG, PNG, or WebP). Excluded from `Debug`
|
||||
/// output — a megabyte-scale blob shouldn't end up in a log line.
|
||||
pub file_bytes: Vec<u8>,
|
||||
|
||||
/// Filename for the multipart `Content-Disposition` header.
|
||||
pub filename: String,
|
||||
|
||||
/// Optional MIME type, eg. "image/png". If `None`, reqwest uses
|
||||
/// `application/octet-stream`.
|
||||
pub maybe_content_type: Option<String>,
|
||||
}
|
||||
|
||||
// ── Public response ──
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CreateVideoSuccess {
|
||||
/// Task identifier. Poll `GET /v1/videos/{task_id}` until terminal.
|
||||
pub task_id: String,
|
||||
|
||||
/// Initial lifecycle status, typically queued.
|
||||
pub status: CometVideoTaskStatus,
|
||||
|
||||
/// The model that will fulfil the task, as echoed by the API.
|
||||
pub maybe_model: Option<String>,
|
||||
|
||||
/// 0-100 completion percentage.
|
||||
pub maybe_progress: Option<u8>,
|
||||
|
||||
/// Unix timestamp (seconds).
|
||||
pub maybe_created_at: Option<i64>,
|
||||
}
|
||||
|
||||
/// Enqueue a Doubao Seedance 2.0 video generation task.
|
||||
///
|
||||
/// CometAPI's video creation endpoint takes `multipart/form-data`. The task
|
||||
/// id in the response is then polled with
|
||||
/// [`crate::requests::get_video_task::get_video_task::get_video_task`].
|
||||
pub async fn create_video(args: CreateVideoArgs<'_>) -> Result<CreateVideoSuccess, CometError> {
|
||||
let request = &args.request;
|
||||
request.validate()?;
|
||||
|
||||
let url = format!("{COMET_API_BASE_URL}/v1/videos");
|
||||
|
||||
let mut form = Form::new();
|
||||
for (field, value) in request.text_form_fields() {
|
||||
form = form.text(field, value);
|
||||
}
|
||||
for image in &request.input_reference_images {
|
||||
let mut part = Part::bytes(image.file_bytes.clone())
|
||||
.file_name(image.filename.clone());
|
||||
if let Some(content_type) = &image.maybe_content_type {
|
||||
part = part.mime_str(content_type)
|
||||
.map_err(|err| CometClientError::InvalidRequestField {
|
||||
field: "input_reference_images.maybe_content_type",
|
||||
raw_value: content_type.clone(),
|
||||
reason: format!("invalid mime type: {err}"),
|
||||
})?;
|
||||
}
|
||||
form = form.part("input_reference", part);
|
||||
}
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.build()
|
||||
.map_err(CometClientError::ReqwestClientError)?;
|
||||
|
||||
let response = client.post(&url)
|
||||
.bearer_auth(args.api_key.as_str())
|
||||
.multipart(form)
|
||||
.send()
|
||||
.await
|
||||
.map_err(CometGenericApiError::ReqwestError)?;
|
||||
|
||||
let status_code = response.status();
|
||||
let body = response.text()
|
||||
.await
|
||||
.map_err(CometGenericApiError::ReqwestError)?;
|
||||
|
||||
if !status_code.is_success() {
|
||||
return Err(categorize_create_video_error(status_code, body));
|
||||
}
|
||||
|
||||
let raw: CreateVideoRawResponse = serde_json::from_str(&body)
|
||||
.map_err(|err| CometGenericApiError::SerdeResponseParseErrorWithBody(err, body.clone()))?;
|
||||
|
||||
info!("Comet video task enqueued: {} (status: {})", raw.id, raw.status);
|
||||
|
||||
Ok(CreateVideoSuccess {
|
||||
task_id: raw.id,
|
||||
status: raw.status,
|
||||
maybe_model: raw.model,
|
||||
maybe_progress: raw.progress,
|
||||
maybe_created_at: raw.created_at,
|
||||
})
|
||||
}
|
||||
|
||||
impl CreateVideoRequest {
|
||||
/// The text fields of the multipart form, in wire order. Split out from
|
||||
/// [`create_video`] so the form shape is testable without HTTP.
|
||||
pub fn text_form_fields(&self) -> Vec<(&'static str, String)> {
|
||||
let mut fields = vec![
|
||||
("model", self.model.as_api_str().to_string()),
|
||||
("prompt", self.prompt.clone()),
|
||||
];
|
||||
if let Some(seconds) = self.maybe_seconds {
|
||||
fields.push(("seconds", seconds.to_string()));
|
||||
}
|
||||
if let Some(size) = self.maybe_size {
|
||||
fields.push(("size", size.as_api_string()));
|
||||
}
|
||||
fields
|
||||
}
|
||||
|
||||
fn validate(&self) -> Result<(), CometClientError> {
|
||||
if self.prompt.trim().is_empty() {
|
||||
return Err(CometClientError::InvalidRequestField {
|
||||
field: "prompt",
|
||||
raw_value: self.prompt.clone(),
|
||||
reason: "prompt must not be empty".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(seconds) = self.maybe_seconds {
|
||||
if !(MIN_DURATION_SECONDS..=MAX_DURATION_SECONDS).contains(&seconds) {
|
||||
return Err(CometClientError::InvalidRequestField {
|
||||
field: "seconds",
|
||||
raw_value: seconds.to_string(),
|
||||
reason: format!("duration must be {MIN_DURATION_SECONDS}-{MAX_DURATION_SECONDS} seconds for Seedance 2.0 models"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl DoubaoSeedance2p0Model {
|
||||
pub fn as_api_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::DoubaoSeedance2p0 => "doubao-seedance-2-0",
|
||||
Self::DoubaoSeedance2p0Fast => "doubao-seedance-2-0-fast",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CometVideoSize {
|
||||
pub fn as_api_string(&self) -> String {
|
||||
match self {
|
||||
Self::Landscape16x9 => "16:9".to_string(),
|
||||
Self::Standard4x3 => "4:3".to_string(),
|
||||
Self::Square => "1:1".to_string(),
|
||||
Self::Portrait3x4 => "3:4".to_string(),
|
||||
Self::Portrait9x16 => "9:16".to_string(),
|
||||
Self::UltraWide21x9 => "21:9".to_string(),
|
||||
Self::Exact { width, height } => format!("{width}x{height}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for CreateVideoRequest {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("CreateVideoRequest")
|
||||
.field("model", &self.model)
|
||||
.field("prompt", &self.prompt)
|
||||
.field("maybe_seconds", &self.maybe_seconds)
|
||||
.field("maybe_size", &self.maybe_size)
|
||||
.field("input_reference_images", &self.input_reference_images)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for CometInputReferenceImage {
|
||||
// Print byte-length, not raw bytes — derived Debug on Vec<u8> would dump
|
||||
// the entire image into the log line.
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("CometInputReferenceImage")
|
||||
.field("file_bytes_len", &self.file_bytes.len())
|
||||
.field("filename", &self.filename)
|
||||
.field("maybe_content_type", &self.maybe_content_type)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
mod shape_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn model_api_strings() {
|
||||
assert_eq!(DoubaoSeedance2p0Model::DoubaoSeedance2p0.as_api_str(), "doubao-seedance-2-0");
|
||||
assert_eq!(DoubaoSeedance2p0Model::DoubaoSeedance2p0Fast.as_api_str(), "doubao-seedance-2-0-fast");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn size_api_strings() {
|
||||
assert_eq!(CometVideoSize::Landscape16x9.as_api_string(), "16:9");
|
||||
assert_eq!(CometVideoSize::Standard4x3.as_api_string(), "4:3");
|
||||
assert_eq!(CometVideoSize::Square.as_api_string(), "1:1");
|
||||
assert_eq!(CometVideoSize::Portrait3x4.as_api_string(), "3:4");
|
||||
assert_eq!(CometVideoSize::Portrait9x16.as_api_string(), "9:16");
|
||||
assert_eq!(CometVideoSize::UltraWide21x9.as_api_string(), "21:9");
|
||||
assert_eq!(CometVideoSize::Exact { width: 1280, height: 720 }.as_api_string(), "1280x720");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minimal_form_fields() {
|
||||
let request = CreateVideoRequest {
|
||||
model: DoubaoSeedance2p0Model::DoubaoSeedance2p0Fast,
|
||||
prompt: "a corgi running through a field".to_string(),
|
||||
maybe_seconds: None,
|
||||
maybe_size: None,
|
||||
input_reference_images: vec![],
|
||||
};
|
||||
|
||||
assert_eq!(request.text_form_fields(), vec![
|
||||
("model", "doubao-seedance-2-0-fast".to_string()),
|
||||
("prompt", "a corgi running through a field".to_string()),
|
||||
]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_form_fields() {
|
||||
let request = CreateVideoRequest {
|
||||
model: DoubaoSeedance2p0Model::DoubaoSeedance2p0,
|
||||
prompt: "make [Image 1] come alive".to_string(),
|
||||
maybe_seconds: Some(10),
|
||||
maybe_size: Some(CometVideoSize::Portrait9x16),
|
||||
input_reference_images: vec![],
|
||||
};
|
||||
|
||||
assert_eq!(request.text_form_fields(), vec![
|
||||
("model", "doubao-seedance-2-0".to_string()),
|
||||
("prompt", "make [Image 1] come alive".to_string()),
|
||||
("seconds", "10".to_string()),
|
||||
("size", "9:16".to_string()),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
mod validation_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn empty_prompt_is_rejected() {
|
||||
let request = request_with_prompt_and_seconds(" ", None);
|
||||
let error = request.validate().expect_err("should reject empty prompt");
|
||||
assert!(matches!(error, CometClientError::InvalidRequestField { field: "prompt", .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_bounds() {
|
||||
assert!(request_with_prompt_and_seconds("ok", Some(3)).validate().is_err());
|
||||
assert!(request_with_prompt_and_seconds("ok", Some(4)).validate().is_ok());
|
||||
assert!(request_with_prompt_and_seconds("ok", Some(15)).validate().is_ok());
|
||||
assert!(request_with_prompt_and_seconds("ok", Some(16)).validate().is_err());
|
||||
assert!(request_with_prompt_and_seconds("ok", None).validate().is_ok());
|
||||
}
|
||||
|
||||
fn request_with_prompt_and_seconds(prompt: &str, maybe_seconds: Option<u8>) -> CreateVideoRequest {
|
||||
CreateVideoRequest {
|
||||
model: DoubaoSeedance2p0Model::DoubaoSeedance2p0,
|
||||
prompt: prompt.to_string(),
|
||||
maybe_seconds,
|
||||
maybe_size: None,
|
||||
input_reference_images: vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mod live_tests {
|
||||
use super::*;
|
||||
use crate::test_utils::load_api_key;
|
||||
|
||||
/// Live test: enqueues a REAL generation and INCURS COSTS.
|
||||
/// Run manually with:
|
||||
/// cargo test -p comet_api_client live_create_video -- --ignored --nocapture
|
||||
#[ignore]
|
||||
#[tokio::test]
|
||||
async fn live_create_video() {
|
||||
let api_key = load_api_key();
|
||||
|
||||
let result = create_video(CreateVideoArgs {
|
||||
api_key: &api_key,
|
||||
request: CreateVideoRequest {
|
||||
model: DoubaoSeedance2p0Model::DoubaoSeedance2p0Fast,
|
||||
prompt: "a corgi running through a sunny meadow, cinematic".to_string(),
|
||||
maybe_seconds: Some(4),
|
||||
maybe_size: Some(CometVideoSize::Landscape16x9),
|
||||
input_reference_images: vec![],
|
||||
},
|
||||
}).await.expect("create_video should succeed");
|
||||
|
||||
println!("Enqueued task: {} (status: {})", result.task_id, result.status);
|
||||
assert!(!result.task_id.is_empty());
|
||||
}
|
||||
|
||||
/// Live test: enqueues a REAL generation and polls it to completion.
|
||||
/// INCURS COSTS and takes a few minutes. Run manually with:
|
||||
/// cargo test -p comet_api_client live_create_and_poll_video -- --ignored --nocapture
|
||||
#[ignore]
|
||||
#[tokio::test]
|
||||
async fn live_create_and_poll_video() {
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::requests::get_video_task::get_video_task::{get_video_task, GetVideoTaskArgs};
|
||||
|
||||
// NB: Comet's docs claim most tasks finish in 1-3 minutes, but tasks
|
||||
// have been observed to sit at `in_progress` well past 10 minutes.
|
||||
const POLL_INTERVAL: Duration = Duration::from_secs(15);
|
||||
const MAX_POLLS: u32 = 120; // ~30 minutes
|
||||
|
||||
let api_key = load_api_key();
|
||||
|
||||
let created = create_video(CreateVideoArgs {
|
||||
api_key: &api_key,
|
||||
request: CreateVideoRequest {
|
||||
model: DoubaoSeedance2p0Model::DoubaoSeedance2p0Fast,
|
||||
prompt: "a corgi running through a sunny meadow, cinematic".to_string(),
|
||||
maybe_seconds: Some(4),
|
||||
maybe_size: Some(CometVideoSize::Landscape16x9),
|
||||
input_reference_images: vec![],
|
||||
},
|
||||
}).await.expect("create_video should succeed");
|
||||
|
||||
println!("Enqueued task: {} (status: {})", created.task_id, created.status);
|
||||
|
||||
for poll in 1..=MAX_POLLS {
|
||||
tokio::time::sleep(POLL_INTERVAL).await;
|
||||
|
||||
let task = get_video_task(GetVideoTaskArgs {
|
||||
api_key: &api_key,
|
||||
task_id: &created.task_id,
|
||||
}).await.expect("get_video_task should succeed");
|
||||
|
||||
println!("Poll {}: status {} (progress: {:?})", poll, task.status, task.maybe_progress);
|
||||
|
||||
if task.status.is_terminal() {
|
||||
assert!(task.status.is_success(), "task should complete successfully: {:?}", task);
|
||||
let video_url = task.maybe_video_url.expect("completed task should have a video url");
|
||||
println!("Video URL: {}", video_url);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
panic!("Task {} did not reach a terminal state in time", created.task_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod create_video;
|
||||
pub mod request_types;
|
||||
@@ -0,0 +1,77 @@
|
||||
use serde_derive::Deserialize;
|
||||
|
||||
use crate::requests::video_task_status::CometVideoTaskStatus;
|
||||
|
||||
/// Over-the-wire response from `POST /v1/videos`.
|
||||
///
|
||||
/// The docs show both `id` and `task_id` carrying the same task identifier;
|
||||
/// `task_id` is treated as an optional mirror of `id`.
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct CreateVideoRawResponse {
|
||||
/// Task identifier, eg. "task_abc123". Use this with the poll endpoint.
|
||||
pub id: String,
|
||||
|
||||
/// Mirror of `id`.
|
||||
pub task_id: Option<String>,
|
||||
|
||||
/// Always "video".
|
||||
pub object: Option<String>,
|
||||
|
||||
/// The model that will fulfil the task, eg. "doubao-seedance-2-0".
|
||||
pub model: Option<String>,
|
||||
|
||||
/// Initial lifecycle status, typically "queued".
|
||||
pub status: CometVideoTaskStatus,
|
||||
|
||||
/// 0-100 completion percentage.
|
||||
pub progress: Option<u8>,
|
||||
|
||||
/// Unix timestamp (seconds).
|
||||
pub created_at: Option<i64>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Documented response shape from
|
||||
/// https://apidoc.cometapi.com/api/video/seedance/create.md
|
||||
const REAL_CREATE_RESPONSE: &str = r#"
|
||||
{
|
||||
"id": "task_abc123",
|
||||
"task_id": "task_abc123",
|
||||
"object": "video",
|
||||
"model": "doubao-seedance-2-0",
|
||||
"status": "queued",
|
||||
"progress": 0,
|
||||
"created_at": 1776681149
|
||||
}
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn parse_real_create_response() {
|
||||
let response: CreateVideoRawResponse = serde_json::from_str(REAL_CREATE_RESPONSE)
|
||||
.expect("should parse");
|
||||
|
||||
assert_eq!(response.id, "task_abc123");
|
||||
assert_eq!(response.task_id.as_deref(), Some("task_abc123"));
|
||||
assert_eq!(response.object.as_deref(), Some("video"));
|
||||
assert_eq!(response.model.as_deref(), Some("doubao-seedance-2-0"));
|
||||
assert_eq!(response.status, CometVideoTaskStatus::Queued);
|
||||
assert_eq!(response.progress, Some(0));
|
||||
assert_eq!(response.created_at, Some(1776681149));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_minimal_response() {
|
||||
// Only `id` and `status` are load-bearing; everything else is optional
|
||||
// so schema drift doesn't break enqueuing.
|
||||
let response: CreateVideoRawResponse = serde_json::from_str(
|
||||
r#"{"id":"task_xyz","status":"queued"}"#,
|
||||
).expect("should parse");
|
||||
|
||||
assert_eq!(response.id, "task_xyz");
|
||||
assert_eq!(response.status, CometVideoTaskStatus::Queued);
|
||||
assert_eq!(response.task_id, None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
use log::info;
|
||||
|
||||
use crate::creds::comet_api_key::CometApiKey;
|
||||
use crate::error::categorize_comet_error::categorize_get_video_task_error;
|
||||
use crate::error::comet_client_error::CometClientError;
|
||||
use crate::error::comet_error::CometError;
|
||||
use crate::error::comet_generic_api_error::CometGenericApiError;
|
||||
use crate::requests::comet_host::COMET_API_BASE_URL;
|
||||
use crate::requests::get_video_task::request_types::VideoTaskRawResponse;
|
||||
use crate::requests::video_task_status::CometVideoTaskStatus;
|
||||
|
||||
// ── Public args ──
|
||||
|
||||
pub struct GetVideoTaskArgs<'a> {
|
||||
pub api_key: &'a CometApiKey,
|
||||
|
||||
/// The task id returned by the creation endpoint, eg. "task_abc123".
|
||||
pub task_id: &'a str,
|
||||
}
|
||||
|
||||
// ── Public response ──
|
||||
|
||||
/// Snapshot of a video task's state.
|
||||
///
|
||||
/// Poll every 10-20 seconds until [`CometVideoTaskStatus::is_terminal`];
|
||||
/// most tasks finish within 1-3 minutes.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CometVideoTask {
|
||||
pub task_id: String,
|
||||
|
||||
pub status: CometVideoTaskStatus,
|
||||
|
||||
/// The Seedance model used, as echoed by the API.
|
||||
pub maybe_model: Option<String>,
|
||||
|
||||
/// 0-100 completion percentage.
|
||||
pub maybe_progress: Option<u8>,
|
||||
|
||||
/// Signed download URL, present only when the task completed. The
|
||||
/// signature is time-limited — download or re-host the file promptly.
|
||||
pub maybe_video_url: Option<String>,
|
||||
|
||||
/// Unix timestamp (seconds).
|
||||
pub maybe_created_at: Option<i64>,
|
||||
|
||||
/// Unix timestamp (seconds) when the task finished.
|
||||
pub maybe_completed_at: Option<i64>,
|
||||
}
|
||||
|
||||
/// Fetch the current state of a video task (`GET /v1/videos/{id}`).
|
||||
pub async fn get_video_task(args: GetVideoTaskArgs<'_>) -> Result<CometVideoTask, CometError> {
|
||||
let url = format!("{COMET_API_BASE_URL}/v1/videos/{}", args.task_id);
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.build()
|
||||
.map_err(CometClientError::ReqwestClientError)?;
|
||||
|
||||
let response = client.get(&url)
|
||||
.bearer_auth(args.api_key.as_str())
|
||||
.send()
|
||||
.await
|
||||
.map_err(CometGenericApiError::ReqwestError)?;
|
||||
|
||||
let status_code = response.status();
|
||||
let body = response.text()
|
||||
.await
|
||||
.map_err(CometGenericApiError::ReqwestError)?;
|
||||
|
||||
if !status_code.is_success() {
|
||||
return Err(categorize_get_video_task_error(status_code, body, args.task_id));
|
||||
}
|
||||
|
||||
let raw: VideoTaskRawResponse = serde_json::from_str(&body)
|
||||
.map_err(|err| CometGenericApiError::SerdeResponseParseErrorWithBody(err, body.clone()))?;
|
||||
|
||||
info!("Comet video task {}: status {} (progress: {:?})", raw.id, raw.status, raw.progress);
|
||||
|
||||
Ok(CometVideoTask {
|
||||
task_id: raw.id,
|
||||
status: raw.status,
|
||||
maybe_model: raw.model,
|
||||
maybe_progress: raw.progress,
|
||||
maybe_video_url: raw.video_url,
|
||||
maybe_created_at: raw.created_at,
|
||||
maybe_completed_at: raw.completed_at,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::error::comet_specific_api_error::CometSpecificApiError;
|
||||
use crate::test_utils::load_api_key;
|
||||
|
||||
/// Live test: makes a REAL network request (no generation cost — the task
|
||||
/// id is bogus, so this exercises auth + the TaskNotFound error path).
|
||||
/// Run manually with:
|
||||
/// cargo test -p comet_api_client live_get_video_task -- --ignored --nocapture
|
||||
#[ignore]
|
||||
#[tokio::test]
|
||||
async fn live_get_video_task_unknown_id() {
|
||||
let api_key = load_api_key();
|
||||
|
||||
let result = get_video_task(GetVideoTaskArgs {
|
||||
api_key: &api_key,
|
||||
task_id: "task_does_not_exist_artcraft_test",
|
||||
}).await;
|
||||
|
||||
match result {
|
||||
Err(CometError::ApiSpecific(CometSpecificApiError::TaskNotFound { task_id, .. })) => {
|
||||
assert_eq!(task_id, "task_does_not_exist_artcraft_test");
|
||||
}
|
||||
other => panic!("Expected TaskNotFound, got: {:?}", other),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod get_video_task;
|
||||
pub mod request_types;
|
||||
@@ -0,0 +1,113 @@
|
||||
use serde_derive::Deserialize;
|
||||
|
||||
use crate::requests::video_task_status::CometVideoTaskStatus;
|
||||
|
||||
/// Over-the-wire response from `GET /v1/videos/{id}`.
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct VideoTaskRawResponse {
|
||||
/// Task identifier.
|
||||
pub id: String,
|
||||
|
||||
/// Always "video".
|
||||
pub object: Option<String>,
|
||||
|
||||
/// The Seedance model used, eg. "doubao-seedance-2-0".
|
||||
pub model: Option<String>,
|
||||
|
||||
/// Lifecycle status.
|
||||
pub status: CometVideoTaskStatus,
|
||||
|
||||
/// 0-100 completion percentage.
|
||||
pub progress: Option<u8>,
|
||||
|
||||
/// Signed download URL. Present only when `status` is "completed".
|
||||
/// NB: the signature is time-limited — download or re-host the file
|
||||
/// before it expires.
|
||||
pub video_url: Option<String>,
|
||||
|
||||
/// Unix timestamp (seconds).
|
||||
pub created_at: Option<i64>,
|
||||
|
||||
/// Unix timestamp (seconds) when the task finished.
|
||||
pub completed_at: Option<i64>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// Documented response shapes from
|
||||
// https://apidoc.cometapi.com/api/video/seedance/query.md
|
||||
|
||||
const REAL_IN_PROGRESS_RESPONSE: &str = r#"
|
||||
{
|
||||
"id": "task_abc123",
|
||||
"object": "video",
|
||||
"model": "doubao-seedance-2-0",
|
||||
"status": "in_progress",
|
||||
"progress": 30,
|
||||
"created_at": 1777385418,
|
||||
"completed_at": 1777385485
|
||||
}
|
||||
"#;
|
||||
|
||||
const REAL_COMPLETED_RESPONSE: &str = r#"
|
||||
{
|
||||
"id": "task_abc123",
|
||||
"object": "video",
|
||||
"model": "doubao-seedance-2-0",
|
||||
"status": "completed",
|
||||
"progress": 100,
|
||||
"created_at": 1777385418,
|
||||
"completed_at": 1777385526,
|
||||
"video_url": "https://example.com/seedance-output.mp4"
|
||||
}
|
||||
"#;
|
||||
|
||||
const REAL_FAILED_RESPONSE: &str = r#"
|
||||
{
|
||||
"id": "task_abc123",
|
||||
"object": "video",
|
||||
"model": "doubao-seedance-2-0",
|
||||
"status": "failed",
|
||||
"progress": 0,
|
||||
"created_at": 1777385418,
|
||||
"completed_at": 1777385526
|
||||
}
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn parse_real_in_progress_response() {
|
||||
let response: VideoTaskRawResponse = serde_json::from_str(REAL_IN_PROGRESS_RESPONSE)
|
||||
.expect("should parse");
|
||||
|
||||
assert_eq!(response.id, "task_abc123");
|
||||
assert_eq!(response.status, CometVideoTaskStatus::InProgress);
|
||||
assert_eq!(response.progress, Some(30));
|
||||
assert_eq!(response.video_url, None);
|
||||
assert!(!response.status.is_terminal());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_real_completed_response() {
|
||||
let response: VideoTaskRawResponse = serde_json::from_str(REAL_COMPLETED_RESPONSE)
|
||||
.expect("should parse");
|
||||
|
||||
assert_eq!(response.status, CometVideoTaskStatus::Completed);
|
||||
assert_eq!(response.progress, Some(100));
|
||||
assert_eq!(response.video_url.as_deref(), Some("https://example.com/seedance-output.mp4"));
|
||||
assert_eq!(response.completed_at, Some(1777385526));
|
||||
assert!(response.status.is_success());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_real_failed_response() {
|
||||
let response: VideoTaskRawResponse = serde_json::from_str(REAL_FAILED_RESPONSE)
|
||||
.expect("should parse");
|
||||
|
||||
assert_eq!(response.status, CometVideoTaskStatus::Failed);
|
||||
assert_eq!(response.video_url, None);
|
||||
assert!(response.status.is_terminal());
|
||||
assert!(response.status.is_failure());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
pub mod comet_host;
|
||||
pub mod create_video;
|
||||
pub mod get_video_task;
|
||||
pub mod video_task_status;
|
||||
@@ -0,0 +1,132 @@
|
||||
use std::fmt;
|
||||
|
||||
use serde::de::{Deserialize, Deserializer};
|
||||
use serde::ser::{Serialize, Serializer};
|
||||
|
||||
/// Lifecycle status of a CometAPI video task.
|
||||
///
|
||||
/// The `Unknown(String)` catch-all future-proofs against CometAPI adding new
|
||||
/// states — callers should treat unknown states as still-running unless
|
||||
/// proven otherwise.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum CometVideoTaskStatus {
|
||||
Queued,
|
||||
InProgress,
|
||||
Completed,
|
||||
Failed,
|
||||
Error,
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
impl CometVideoTaskStatus {
|
||||
pub fn to_str(&self) -> &str {
|
||||
match self {
|
||||
Self::Queued => "queued",
|
||||
Self::InProgress => "in_progress",
|
||||
Self::Completed => "completed",
|
||||
Self::Failed => "failed",
|
||||
Self::Error => "error",
|
||||
Self::Unknown(value) => value,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_str(value: &str) -> Self {
|
||||
match value {
|
||||
"queued" => Self::Queued,
|
||||
"in_progress" => Self::InProgress,
|
||||
"completed" => Self::Completed,
|
||||
"failed" => Self::Failed,
|
||||
"error" => Self::Error,
|
||||
other => Self::Unknown(other.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the task has reached a terminal state (stop polling).
|
||||
pub fn is_terminal(&self) -> bool {
|
||||
matches!(self, Self::Completed | Self::Failed | Self::Error)
|
||||
}
|
||||
|
||||
pub fn is_success(&self) -> bool {
|
||||
matches!(self, Self::Completed)
|
||||
}
|
||||
|
||||
pub fn is_failure(&self) -> bool {
|
||||
matches!(self, Self::Failed | Self::Error)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for CometVideoTaskStatus {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.to_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for CometVideoTaskStatus {
|
||||
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
let value = String::deserialize(deserializer)?;
|
||||
Ok(Self::from_str(&value))
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for CometVideoTaskStatus {
|
||||
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
serializer.serialize_str(self.to_str())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn deserialize_known_states() {
|
||||
let cases = [
|
||||
("\"queued\"", CometVideoTaskStatus::Queued),
|
||||
("\"in_progress\"", CometVideoTaskStatus::InProgress),
|
||||
("\"completed\"", CometVideoTaskStatus::Completed),
|
||||
("\"failed\"", CometVideoTaskStatus::Failed),
|
||||
("\"error\"", CometVideoTaskStatus::Error),
|
||||
];
|
||||
for (json, expected) in cases {
|
||||
let status: CometVideoTaskStatus = serde_json::from_str(json).expect("should parse");
|
||||
assert_eq!(status, expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_unknown_state_is_future_proof() {
|
||||
let status: CometVideoTaskStatus = serde_json::from_str("\"hyper_queued\"").expect("should parse");
|
||||
assert_eq!(status, CometVideoTaskStatus::Unknown("hyper_queued".to_string()));
|
||||
assert!(!status.is_terminal());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip() {
|
||||
for status in [
|
||||
CometVideoTaskStatus::Queued,
|
||||
CometVideoTaskStatus::InProgress,
|
||||
CometVideoTaskStatus::Completed,
|
||||
CometVideoTaskStatus::Failed,
|
||||
CometVideoTaskStatus::Error,
|
||||
CometVideoTaskStatus::Unknown("new_state".to_string()),
|
||||
] {
|
||||
let json = serde_json::to_string(&status).expect("should serialize");
|
||||
let parsed: CometVideoTaskStatus = serde_json::from_str(&json).expect("should parse");
|
||||
assert_eq!(parsed, status);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_states() {
|
||||
assert!(!CometVideoTaskStatus::Queued.is_terminal());
|
||||
assert!(!CometVideoTaskStatus::InProgress.is_terminal());
|
||||
assert!(CometVideoTaskStatus::Completed.is_terminal());
|
||||
assert!(CometVideoTaskStatus::Failed.is_terminal());
|
||||
assert!(CometVideoTaskStatus::Error.is_terminal());
|
||||
|
||||
assert!(CometVideoTaskStatus::Completed.is_success());
|
||||
assert!(!CometVideoTaskStatus::Completed.is_failure());
|
||||
assert!(CometVideoTaskStatus::Failed.is_failure());
|
||||
assert!(CometVideoTaskStatus::Error.is_failure());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
use std::fs::read_to_string;
|
||||
|
||||
use crate::creds::comet_api_key::CometApiKey;
|
||||
|
||||
const API_KEY_PATH: &str = "/Users/bt/Artcraft/credentials/comet_api_key.txt";
|
||||
|
||||
pub fn load_api_key() -> CometApiKey {
|
||||
let secret = read_to_string(API_KEY_PATH)
|
||||
.expect("Failed to read Comet API key file");
|
||||
CometApiKey::from_str(&secret)
|
||||
}
|
||||
Reference in new issue
Block a user