feat(jimeng): add capability mapping, request/response types and error classification

This commit is contained in:
boos
2026-06-23 12:22:58 +08:00
parent 916e92a25a
commit 311659cafd
3 changed files with 392 additions and 0 deletions
+9
View File
@@ -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;
+382
View File
@@ -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());
}
}
+1
View File
@@ -16,3 +16,4 @@ pub mod shared;
pub mod workspace; pub mod workspace;
pub mod comfy_cloud; pub mod comfy_cloud;
pub mod pixelle_connection; pub mod pixelle_connection;
pub mod jimeng;