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 workspace;
|
||||||
pub mod comfy_cloud;
|
pub mod comfy_cloud;
|
||||||
pub mod pixelle_connection;
|
pub mod pixelle_connection;
|
||||||
|
pub mod jimeng;
|
||||||
|
|||||||
Reference in New Issue
Block a user