feat(jimeng): add capability mapping, request/response types and error classification
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
//! Jimeng (Volcengine Visual / CV) async generation provider.
|
||||
//!
|
||||
//! Both image and video capabilities share one submit→poll→store state
|
||||
//! machine and differ only by `req_key` and a few poll parameters (see
|
||||
//! [`types::JimengCapability`]). This module hosts the capability mapping,
|
||||
//! request/response types and business error-code classification; the submit
|
||||
//! and polling state machine are layered on top in later changes.
|
||||
|
||||
pub mod types;
|
||||
@@ -0,0 +1,382 @@
|
||||
//! Jimeng (Volcengine Visual / CV) request/response types, capability mapping
|
||||
//! and business error-code classification.
|
||||
//!
|
||||
//! Everything here is pure data + logic so it can be unit tested without any
|
||||
//! network access. The two capabilities (image / video) share one async
|
||||
//! submit→poll→store state machine and are distinguished only by `req_key`
|
||||
//! plus a handful of poll/timeout parameters.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Volcengine API version carried in the query string for both actions.
|
||||
pub const API_VERSION: &str = "2022-08-31";
|
||||
/// Submit action (sync-to-async submission).
|
||||
pub const ACTION_SUBMIT: &str = "CVSync2AsyncSubmitTask";
|
||||
/// Result action (sync-to-async polling).
|
||||
pub const ACTION_GET_RESULT: &str = "CVSync2AsyncGetResult";
|
||||
/// Business success code; any other code is an error (see [`classify`]).
|
||||
pub const CODE_SUCCESS: i64 = 10000;
|
||||
|
||||
/// A Jimeng generation capability. The enum is the single source of truth for
|
||||
/// the `req_key` and poll/timeout characteristics of each capability; adding a
|
||||
/// new model (e.g. video 3.0, OmniHuman) is a new variant + match arm and the
|
||||
/// submit/poll/store code is untouched.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum JimengCapability {
|
||||
/// Text-to-image, `jimeng_t2i_v40`.
|
||||
Image,
|
||||
/// Text/image-to-video, `jimeng_ti2v_v30_pro`.
|
||||
Video,
|
||||
}
|
||||
|
||||
impl JimengCapability {
|
||||
/// The `req_key` sent in the request body for this capability.
|
||||
pub fn req_key(self) -> &'static str {
|
||||
match self {
|
||||
JimengCapability::Image => "jimeng_t2i_v40",
|
||||
JimengCapability::Video => "jimeng_ti2v_v30_pro",
|
||||
}
|
||||
}
|
||||
|
||||
/// The `background_tasks.task_type` value used for this capability.
|
||||
pub fn task_type(self) -> &'static str {
|
||||
match self {
|
||||
JimengCapability::Image => "jimeng-image-generation",
|
||||
JimengCapability::Video => "jimeng-video-generation",
|
||||
}
|
||||
}
|
||||
|
||||
/// Local task id prefix (`jmi_` image / `jmv_` video).
|
||||
pub fn id_prefix(self) -> &'static str {
|
||||
match self {
|
||||
JimengCapability::Image => "jmi",
|
||||
JimengCapability::Video => "jmv",
|
||||
}
|
||||
}
|
||||
|
||||
/// Workspace asset kind for stored outputs.
|
||||
pub fn asset_kind(self) -> &'static str {
|
||||
match self {
|
||||
JimengCapability::Image => "image",
|
||||
JimengCapability::Video => "video",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The `data.status` field returned by the GetResult action.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TaskStatus {
|
||||
/// Submitted, waiting in queue.
|
||||
InQueue,
|
||||
/// Actively generating.
|
||||
Generating,
|
||||
/// Finished (still check outer `code` for success).
|
||||
Done,
|
||||
/// No such task or task expired past its 12h retention.
|
||||
NotFound,
|
||||
/// Task expired; may be re-submitted.
|
||||
Expired,
|
||||
/// Any status string we do not recognize.
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl TaskStatus {
|
||||
/// Parse the raw status string from the API.
|
||||
pub fn parse(raw: &str) -> Self {
|
||||
match raw {
|
||||
"in_queue" => TaskStatus::InQueue,
|
||||
"generating" => TaskStatus::Generating,
|
||||
"done" => TaskStatus::Done,
|
||||
"not_found" => TaskStatus::NotFound,
|
||||
"expired" => TaskStatus::Expired,
|
||||
_ => TaskStatus::Unknown,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Classification of a business `code` for the polling loop.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CodeClass {
|
||||
/// `code == 10000`; inspect `data.status`.
|
||||
Success,
|
||||
/// Permanent failure — report immediately, do not retry.
|
||||
Fatal,
|
||||
/// Transient — back off and retry the poll/submit.
|
||||
Retryable,
|
||||
}
|
||||
|
||||
/// Classify a Volcengine business `code` for a given capability.
|
||||
///
|
||||
/// Mapping per the integration notes §6. A handful of codes are
|
||||
/// capability-dependent: the post-generation video/audio audit codes
|
||||
/// (50516/50517) only occur for video, and the internal-error codes
|
||||
/// (50500/50501) are treated as retryable for video but fatal for image.
|
||||
pub fn classify(code: i64, cap: JimengCapability) -> CodeClass {
|
||||
if code == CODE_SUCCESS {
|
||||
return CodeClass::Success;
|
||||
}
|
||||
match code {
|
||||
// Input pre-moderation failures (image / text / copyright): not retryable.
|
||||
50411 | 50412 | 50413 | 50518 => CodeClass::Fatal,
|
||||
// Post-generation text moderation failure: not retryable.
|
||||
50512 => CodeClass::Fatal,
|
||||
// Moderation / copyright service exceptions (upstream): not retryable.
|
||||
50520 | 50521 | 50522 => CodeClass::Fatal,
|
||||
// Output post-moderation failures (image / copyright): retryable.
|
||||
50511 | 50519 => CodeClass::Retryable,
|
||||
// Output video / audio post-moderation failure (video only): retryable.
|
||||
50516 | 50517 => CodeClass::Retryable,
|
||||
// QPS / concurrency limit: retryable with backoff.
|
||||
50429 | 50430 => CodeClass::Retryable,
|
||||
// Internal / algorithm error: retryable for video, fatal for image.
|
||||
50500 | 50501 => match cap {
|
||||
JimengCapability::Video => CodeClass::Retryable,
|
||||
JimengCapability::Image => CodeClass::Fatal,
|
||||
},
|
||||
// Unknown codes: be conservative and fail rather than loop forever.
|
||||
_ => CodeClass::Fatal,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience predicate used by the poller.
|
||||
pub fn retryable(code: i64, cap: JimengCapability) -> bool {
|
||||
matches!(classify(code, cap), CodeClass::Retryable)
|
||||
}
|
||||
|
||||
/// Response envelope for the submit action.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct SubmitResponse {
|
||||
pub code: i64,
|
||||
#[serde(default)]
|
||||
pub message: Option<String>,
|
||||
/// Present only on success (`data: null` on error — must check `code` first).
|
||||
#[serde(default)]
|
||||
pub data: Option<SubmitData>,
|
||||
}
|
||||
|
||||
/// The `data` object of a successful submit.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct SubmitData {
|
||||
pub task_id: String,
|
||||
}
|
||||
|
||||
/// Response envelope for the GetResult action. Shared by image and video; the
|
||||
/// distinct output fields (`image_urls`/`binary_data_base64` vs `video_url`)
|
||||
/// are all optional so one struct covers both capabilities.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct GetResultResponse {
|
||||
pub code: i64,
|
||||
#[serde(default)]
|
||||
pub message: Option<String>,
|
||||
/// `data: null` on error — always check `code` before reading this.
|
||||
#[serde(default)]
|
||||
pub data: Option<GetResultData>,
|
||||
}
|
||||
|
||||
/// The `data` object of a GetResult poll.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct GetResultData {
|
||||
#[serde(default)]
|
||||
pub status: Option<String>,
|
||||
/// Image outputs (when `return_url:true`), 24h validity.
|
||||
#[serde(default)]
|
||||
pub image_urls: Option<Vec<String>>,
|
||||
/// Image outputs as base64 (default when no `return_url`).
|
||||
#[serde(default)]
|
||||
pub binary_data_base64: Option<Vec<String>>,
|
||||
/// Video output, 1h validity — must be transferred immediately.
|
||||
#[serde(default)]
|
||||
pub video_url: Option<String>,
|
||||
}
|
||||
|
||||
impl GetResultResponse {
|
||||
/// Parsed status (`Unknown` if absent/unrecognized).
|
||||
pub fn task_status(&self) -> TaskStatus {
|
||||
self.data
|
||||
.as_ref()
|
||||
.and_then(|d| d.status.as_deref())
|
||||
.map(TaskStatus::parse)
|
||||
.unwrap_or(TaskStatus::Unknown)
|
||||
}
|
||||
}
|
||||
|
||||
/// A finished task's outputs, ready to be transferred to the workspace.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum JimengOutput {
|
||||
/// Image URLs (and/or base64) produced by an image task.
|
||||
Image {
|
||||
urls: Vec<String>,
|
||||
base64: Vec<String>,
|
||||
},
|
||||
/// Single video URL produced by a video task (1h validity).
|
||||
Video { url: String },
|
||||
}
|
||||
|
||||
/// Validated request parameters for an image generation.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ImageRequest {
|
||||
pub prompt: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub width: Option<i64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub height: Option<i64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub scale: Option<f64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub force_single: Option<bool>,
|
||||
}
|
||||
|
||||
/// Validated request parameters for a video generation.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct VideoRequest {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub prompt: Option<String>,
|
||||
/// First-frame image URLs for image-to-video (public URLs only in v1).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub image_urls: Vec<String>,
|
||||
/// Frame count: 121 (5s) or 241 (10s).
|
||||
#[serde(default = "default_frames")]
|
||||
pub frames: i64,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub aspect_ratio: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub seed: Option<i64>,
|
||||
}
|
||||
|
||||
fn default_frames() -> i64 {
|
||||
121
|
||||
}
|
||||
|
||||
/// Allowed video frame counts (24*seconds + 1) → 5s / 10s.
|
||||
pub const ALLOWED_FRAMES: [i64; 2] = [121, 241];
|
||||
/// Allowed aspect ratios for video generation.
|
||||
pub const ALLOWED_ASPECT_RATIOS: [&str; 5] = ["16:9", "9:16", "1:1", "4:3", "3:4"];
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn capability_mapping_is_stable() {
|
||||
assert_eq!(JimengCapability::Image.req_key(), "jimeng_t2i_v40");
|
||||
assert_eq!(JimengCapability::Video.req_key(), "jimeng_ti2v_v30_pro");
|
||||
assert_eq!(JimengCapability::Image.id_prefix(), "jmi");
|
||||
assert_eq!(JimengCapability::Video.id_prefix(), "jmv");
|
||||
assert_eq!(JimengCapability::Image.asset_kind(), "image");
|
||||
assert_eq!(JimengCapability::Video.asset_kind(), "video");
|
||||
assert_eq!(
|
||||
JimengCapability::Image.task_type(),
|
||||
"jimeng-image-generation"
|
||||
);
|
||||
assert_eq!(
|
||||
JimengCapability::Video.task_type(),
|
||||
"jimeng-video-generation"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn success_code_classifies_as_success() {
|
||||
assert_eq!(
|
||||
classify(CODE_SUCCESS, JimengCapability::Image),
|
||||
CodeClass::Success
|
||||
);
|
||||
assert_eq!(
|
||||
classify(CODE_SUCCESS, JimengCapability::Video),
|
||||
CodeClass::Success
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_moderation_codes_are_fatal() {
|
||||
for code in [50411, 50412, 50413, 50518, 50512, 50520, 50521, 50522] {
|
||||
assert_eq!(
|
||||
classify(code, JimengCapability::Image),
|
||||
CodeClass::Fatal,
|
||||
"code {code} should be fatal"
|
||||
);
|
||||
assert!(!retryable(code, JimengCapability::Video));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_limit_and_output_moderation_are_retryable() {
|
||||
for code in [50429, 50430, 50511, 50519] {
|
||||
assert!(
|
||||
retryable(code, JimengCapability::Image),
|
||||
"code {code} should be retryable"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn video_only_moderation_codes_retryable() {
|
||||
// 50516/50517 are output video/audio moderation, surfaced for video.
|
||||
assert!(retryable(50516, JimengCapability::Video));
|
||||
assert!(retryable(50517, JimengCapability::Video));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn internal_error_codes_depend_on_capability() {
|
||||
// Internal/algorithm errors: retryable for video, fatal for image.
|
||||
assert!(retryable(50500, JimengCapability::Video));
|
||||
assert!(retryable(50501, JimengCapability::Video));
|
||||
assert!(!retryable(50500, JimengCapability::Image));
|
||||
assert!(!retryable(50501, JimengCapability::Image));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_codes_are_fatal() {
|
||||
assert_eq!(classify(99999, JimengCapability::Image), CodeClass::Fatal);
|
||||
assert_eq!(classify(0, JimengCapability::Video), CodeClass::Fatal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_parsing_covers_all_known_values() {
|
||||
assert_eq!(TaskStatus::parse("in_queue"), TaskStatus::InQueue);
|
||||
assert_eq!(TaskStatus::parse("generating"), TaskStatus::Generating);
|
||||
assert_eq!(TaskStatus::parse("done"), TaskStatus::Done);
|
||||
assert_eq!(TaskStatus::parse("not_found"), TaskStatus::NotFound);
|
||||
assert_eq!(TaskStatus::parse("expired"), TaskStatus::Expired);
|
||||
assert_eq!(TaskStatus::parse("weird"), TaskStatus::Unknown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_response_with_null_data_does_not_panic() {
|
||||
// The cardinal rule: check code before status; data is null on error.
|
||||
let raw = r#"{"code":50411,"message":"blocked","data":null}"#;
|
||||
let parsed: GetResultResponse = serde_json::from_str(raw).unwrap();
|
||||
assert_eq!(parsed.code, 50411);
|
||||
assert_eq!(parsed.task_status(), TaskStatus::Unknown);
|
||||
assert!(parsed.data.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn done_image_response_parses_urls() {
|
||||
let raw = r#"{"code":10000,"message":"Success","data":{"status":"done","image_urls":["https://x/a.png"],"binary_data_base64":null}}"#;
|
||||
let parsed: GetResultResponse = serde_json::from_str(raw).unwrap();
|
||||
assert_eq!(parsed.code, CODE_SUCCESS);
|
||||
assert_eq!(parsed.task_status(), TaskStatus::Done);
|
||||
let data = parsed.data.unwrap();
|
||||
assert_eq!(data.image_urls.unwrap(), vec!["https://x/a.png".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn done_video_response_parses_url() {
|
||||
let raw = r#"{"code":10000,"data":{"status":"done","video_url":"https://x/v.mp4"}}"#;
|
||||
let parsed: GetResultResponse = serde_json::from_str(raw).unwrap();
|
||||
assert_eq!(parsed.task_status(), TaskStatus::Done);
|
||||
assert_eq!(parsed.data.unwrap().video_url.unwrap(), "https://x/v.mp4");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn submit_response_success_and_error() {
|
||||
let ok = r#"{"code":10000,"data":{"task_id":"7392616336519610409"}}"#;
|
||||
let parsed: SubmitResponse = serde_json::from_str(ok).unwrap();
|
||||
assert_eq!(parsed.data.unwrap().task_id, "7392616336519610409");
|
||||
|
||||
let err = r#"{"code":50429,"message":"qps limit","data":null}"#;
|
||||
let parsed: SubmitResponse = serde_json::from_str(err).unwrap();
|
||||
assert_eq!(parsed.code, 50429);
|
||||
assert!(parsed.data.is_none());
|
||||
}
|
||||
}
|
||||
@@ -16,3 +16,4 @@ pub mod shared;
|
||||
pub mod workspace;
|
||||
pub mod comfy_cloud;
|
||||
pub mod pixelle_connection;
|
||||
pub mod jimeng;
|
||||
|
||||
Reference in New Issue
Block a user