fix seedance client again - nullable width/height (#1682)

This commit is contained in:
Brandon Thomas
2026-06-23 16:13:54 -04:00
committed by GitHub
parent 39dd4dee9a
commit fef420129d
5 changed files with 186 additions and 63 deletions
@@ -35,6 +35,41 @@ pub struct PollOrdersResponse {
// --- Public types ---
/// The status of one order (one generation task — video or image).
#[derive(Debug, Clone)]
pub struct OrderStatus {
pub order_id: String,
pub task_status: TaskStatus,
/// Top-level result URL (video file for video orders, the first image of
/// the 4-image grid for Midjourney image orders). Populated when
/// `task_status` is `Completed`.
pub result_url: Option<String>,
/// Detailed result entries. One entry per video frame (video orders), or
/// four entries per Midjourney task (image orders).
pub results: Vec<MediaResult>,
/// Structured failure reason. Populated when `task_status` is `Failed` or `fail_reason` is present.
pub fail_reason: Option<FailureReason>,
/// ISO 8601 creation timestamp (e.g. `"2026-02-19T01:20:50.398Z"`).
pub created_at: String,
/// Parsed `created_at` as a `DateTime<Utc>`. `None` if the raw string could not be parsed.
pub created_at_utc: Option<DateTime<Utc>>,
/// Whether this order produced an image or a video. `None` for older
/// polling responses that didn't include the field — those came from the
/// video-only era and can be treated as video by callers that need to.
pub media_type: Option<OrderMediaType>,
/// The Kinovi credits charged for the order (the API's `totalCredits`).
/// `None` for older polling responses that didn't include the field.
pub total_credits: Option<u32>,
}
/// The lifecycle status of a video generation task.
#[derive(Debug, Clone, PartialEq)]
pub enum TaskStatus {
@@ -70,19 +105,18 @@ impl TaskStatus {
/// orders, or one of Midjourney's 4 generated images for image orders.
/// (Originally named for the video-only days; the underlying shape is shared.)
#[derive(Debug, Clone)]
pub struct VideoResult {
pub struct MediaResult {
pub url: String,
pub width: u32,
pub height: u32,
/// Frame width in pixels. `None` when the API returns `null`/omits it
/// (it does so intermittently); callers should persist NULL, not a sentinel.
pub maybe_width: Option<u32>,
/// Frame height in pixels. `None` when the API returns `null`/omits it.
pub maybe_height: Option<u32>,
// NB: We don't need these.
// /// Width / height ratio (e.g. 1.777… for 16:9). `None` when the server returns null (e.g. width/height are 0).
// /// Width / height ratio (e.g. 1.777… for 16:9). `None` when the server returns null.
// pub ratio: Option<f64>,
}
/// Type-neutral alias for [`VideoResult`]. Prefer this name in new
/// callers that deal with both image and video orders.
pub type MediaResult = VideoResult;
/// Media type of an order. Midjourney orders are `Image`; the various
/// Seedance/keyframe/reference flows are `Video`.
///
@@ -108,41 +142,6 @@ impl OrderMediaType {
pub fn is_video(&self) -> bool { matches!(self, Self::Video) }
}
/// The status of one order (one generation task — video or image).
#[derive(Debug, Clone)]
pub struct OrderStatus {
pub order_id: String,
pub task_status: TaskStatus,
/// Top-level result URL (video file for video orders, the first image of
/// the 4-image grid for Midjourney image orders). Populated when
/// `task_status` is `Completed`.
pub result_url: Option<String>,
/// Detailed result entries. One entry per video frame (video orders), or
/// four entries per Midjourney task (image orders).
pub results: Vec<VideoResult>,
/// Structured failure reason. Populated when `task_status` is `Failed` or `fail_reason` is present.
pub fail_reason: Option<FailureReason>,
/// ISO 8601 creation timestamp (e.g. `"2026-02-19T01:20:50.398Z"`).
pub created_at: String,
/// Parsed `created_at` as a `DateTime<Utc>`. `None` if the raw string could not be parsed.
pub created_at_utc: Option<DateTime<Utc>>,
/// Whether this order produced an image or a video. `None` for older
/// polling responses that didn't include the field — those came from the
/// video-only era and can be treated as video by callers that need to.
pub media_type: Option<OrderMediaType>,
/// The Kinovi credits charged for the order (the API's `totalCredits`).
/// `None` for older polling responses that didn't include the field.
pub total_credits: Option<u32>,
}
// --- Implementation ---
pub async fn poll_orders(args: PollOrdersArgs<'_>) -> Result<PollOrdersResponse, Seedance2ProError> {
@@ -234,10 +233,10 @@ pub async fn poll_orders(args: PollOrdersArgs<'_>) -> Result<PollOrdersResponse,
order_id: o.order_id,
task_status,
result_url: o.result_url,
results: o.results.into_iter().map(|r| VideoResult {
results: o.results.into_iter().map(|r| MediaResult {
url: r.url,
width: r.width,
height: r.height,
maybe_width: r.maybe_width,
maybe_height: r.maybe_height,
}).collect(),
fail_reason,
created_at: o.created_at,
@@ -425,8 +424,8 @@ mod tests {
order_id: o.order_id,
task_status,
result_url: o.result_url,
results: o.results.into_iter().map(|r| VideoResult {
url: r.url, width: r.width, height: r.height,
results: o.results.into_iter().map(|r| MediaResult {
url: r.url, maybe_width: r.maybe_width, maybe_height: r.maybe_height,
}).collect(),
fail_reason,
created_at: o.created_at,
@@ -476,11 +475,52 @@ mod tests {
let orders = parse_orders(body);
assert_eq!(orders.len(), 1);
assert_eq!(orders[0].results.len(), 1);
assert_eq!(orders[0].results[0].width, 569);
assert_eq!(orders[0].results[0].height, 1023);
assert_eq!(orders[0].results[0].maybe_width, Some(569));
assert_eq!(orders[0].results[0].maybe_height, Some(1023));
assert_eq!(orders[0].total_credits, Some(13));
}
/// Regression (paging outage): the Kinovi API started returning result
/// `width`/`height` as `null`, which blew up the whole poll with
/// "invalid type: null, expected a JSON number" and stalled the queue.
/// They must parse as `None` and let the rest of the response parse.
#[test]
fn null_dimensions_parse_as_none() {
let body = r#"[{"result":{"data":{"json":{"orders":[{
"orderId":"ord_foo",
"resultUrl":"https://static.seedance2-pro.com/videos/seedance_video.mp4",
"taskStatus":"COMPLETED",
"results":[{"url":"https://static.seedance2-pro.com/videos/seedance_video.mp4","width":null,"height":null}],
"failReason":null,
"createdAt":"2026-02-19T01:20:50.398Z",
"mediaType":"video"
}],"nextCursor":null}}}}]"#;
let orders = parse_orders(body);
assert_eq!(orders.len(), 1);
assert_eq!(orders[0].results.len(), 1);
assert_eq!(orders[0].results[0].maybe_width, None);
assert_eq!(orders[0].results[0].maybe_height, None);
assert_eq!(orders[0].task_status, TaskStatus::Completed);
}
/// Omitted `width`/`height` keys (not just `null`) also parse as `None`.
#[test]
fn missing_dimensions_parse_as_none() {
let body = r#"[{"result":{"data":{"json":{"orders":[{
"orderId":"ord_no_dims",
"resultUrl":"https://example.com/v.mp4",
"taskStatus":"COMPLETED",
"results":[{"url":"https://example.com/v.mp4"}],
"failReason":null,
"createdAt":"2026-02-19T01:20:50.398Z",
"mediaType":"video"
}],"nextCursor":null}}}}]"#;
let orders = parse_orders(body);
assert_eq!(orders.len(), 1);
assert_eq!(orders[0].results[0].maybe_width, None);
assert_eq!(orders[0].results[0].maybe_height, None);
}
/// Unrecognised mediaType values shouldn't crash deserialisation;
/// they end up as `Unknown(_)`.
#[test]
@@ -634,4 +674,84 @@ mod tests {
assert_eq!(1, 2); // NB: Intentional failure to inspect output.
Ok(())
}
/// Live diagnostic for the paging outage: try EACH Kinovi account's cookies
/// from the job's secrets env file and cursor across several pages, reporting
/// which account(s) parse cleanly and which blow up. Never prints cookies.
///
/// cargo test -p seedance2pro_client live_cursor_all_accounts -- --ignored --nocapture
#[tokio::test]
#[ignore]
async fn live_cursor_all_accounts() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Info);
const SECRETS_ENV: &str = concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../../crates/service/job/seedance2_pro_job/config/seedance2-pro-job.development-secrets.env",
);
const ACCOUNTS: &[&str] = &[
"SEEDANCE2PRO_VOLCENGINE_COOKIES",
"SEEDANCE2PRO_BYTEPLUS_COOKIES",
"SEEDANCE2PRO_BYTEPLUS_ULTRA_COOKIES",
];
const MAX_PAGES: usize = 5;
let env_contents = std::fs::read_to_string(SECRETS_ENV)
.unwrap_or_else(|e| panic!("could not read secrets env at {}: {}", SECRETS_ENV, e));
for account in ACCOUNTS {
let Some(cookies) = read_env_value(&env_contents, account) else {
println!("[{}] not present in secrets env — skipping", account);
continue;
};
if cookies.is_empty() {
println!("[{}] empty — skipping", account);
continue;
}
let session = Seedance2ProSession::from_cookies_string(cookies);
let mut cursor: Option<u64> = None;
let mut pages = 0usize;
let mut total = 0usize;
let mut ok = true;
for _ in 0..MAX_PAGES {
match poll_orders(PollOrdersArgs { session: &session, cursor, host_override: None }).await {
Ok(result) => {
pages += 1;
total += result.orders.len();
cursor = result.next_cursor;
if cursor.is_none() { break; }
}
Err(err) => {
ok = false;
println!("[{}] FAILED on page {}: {:?}", account, pages + 1, err);
break;
}
}
}
println!(
"[{}] {} — {} page(s), {} order(s) parsed cleanly",
account, if ok { "OK" } else { "ERROR" }, pages, total,
);
}
Ok(())
}
/// Minimal dotenv value reader: returns the value for `KEY=...` from raw env
/// file contents, trimming surrounding quotes. Used so the diagnostic can try
/// each account without depending on process-wide env being loaded.
fn read_env_value(contents: &str, key: &str) -> Option<String> {
for line in contents.lines() {
let line = line.trim();
if line.starts_with('#') { continue; }
let Some((k, v)) = line.split_once('=') else { continue; };
if k.trim() != key { continue; }
let v = v.trim().trim_matches('"').trim_matches('\'').to_string();
return Some(v);
}
None
}
}
@@ -1,7 +1,7 @@
use serde_derive::Deserialize;
use crate::utils::number_coercion::{
de_opt_u32_int_or_float, de_opt_u64_int_or_float, de_u32_int_or_float,
de_opt_u32_int_or_float, de_opt_u64_int_or_float,
};
#[derive(Deserialize, Debug)]
@@ -51,9 +51,11 @@ pub(super) struct RawOrder {
#[derive(Deserialize, Debug)]
pub(super) struct RawVideoResult {
pub url: String,
#[serde(deserialize_with = "de_u32_int_or_float")]
pub width: u32,
#[serde(deserialize_with = "de_u32_int_or_float")]
pub height: u32,
/// The API intermittently returns `null` (or omits) these dimensions, so
/// they're optional rather than coerced to a sentinel.
#[serde(rename = "width", default, deserialize_with = "de_opt_u32_int_or_float")]
pub maybe_width: Option<u32>,
#[serde(rename = "height", default, deserialize_with = "de_opt_u32_int_or_float")]
pub maybe_height: Option<u32>,
// pub ratio: Option<f64>,
}
@@ -60,8 +60,8 @@ fn order_to_json(order: &seedance2pro_client::requests::poll_orders::poll_orders
"results": order.results.iter().map(|r| {
serde_json::json!({
"url": r.url,
"width": r.width,
"height": r.height,
"width": r.maybe_width,
"height": r.maybe_height,
})
}).collect::<Vec<_>>(),
})
@@ -14,7 +14,7 @@ use mysql_queries::queries::generic_inference::api_providers::seedance2pro::list
use mysql_queries::queries::generic_inference::job::select_inference_job_status_for_update::select_inference_job_status_for_update;
use mysql_queries::queries::generic_inference::web::mark_generic_inference_job_successfully_done_by_token_with_executor::{mark_generic_inference_job_successfully_done_by_token_with_executor, MarkGenericInferenceJobSuccessfullyDoneByTokenWithExecutorArgs};
use mysql_queries::queries::media_files::create::insert_builder::media_file_insert_builder::MediaFileInsertBuilder;
use seedance2pro_client::requests::poll_orders::poll_orders::{OrderStatus, VideoResult};
use seedance2pro_client::requests::poll_orders::poll_orders::{OrderStatus, MediaResult};
use tokens::tokens::batch_generations::BatchGenerationToken;
use tokens::tokens::media_files::MediaFileToken;
@@ -175,7 +175,7 @@ async fn download_and_store_one_image(
deps: &JobDependencies,
job: &PendingSeedance2ProJob,
order: &OrderStatus,
result: &VideoResult,
result: &MediaResult,
index: usize,
maybe_batch_token: Option<&BatchGenerationToken>,
) -> AnyhowResult<MediaFileToken> {
@@ -224,8 +224,8 @@ async fn download_and_store_one_image(
.maybe_batch_generation_token(maybe_batch_token)
.maybe_creator_anonymous_visitor(job.maybe_creator_anonymous_visitor_token.as_ref())
.maybe_creator_user(job.maybe_creator_user_token.as_ref())
.maybe_frame_height(Some(result.height))
.maybe_frame_width(Some(result.width))
.maybe_frame_height(result.maybe_height)
.maybe_frame_width(result.maybe_width)
.maybe_generation_provider(Some(GenerationProvider::Artcraft))
.maybe_prompt_token(job.maybe_prompt_token.as_ref())
.maybe_platform_type(job.maybe_platform_type)
@@ -118,8 +118,9 @@ pub async fn process_successful_video_job(
);
// Optionally extract frame dimensions from the order results.
let maybe_frame_width = order.results.first().map(|r| r.width);
let maybe_frame_height = order.results.first().map(|r| r.height);
// Persist the integer dimensions when the API provides them, NULL otherwise.
let maybe_frame_width = order.results.first().and_then(|r| r.maybe_width);
let maybe_frame_height = order.results.first().and_then(|r| r.maybe_height);
// Insert media file record.
let media_file_result = MediaFileInsertBuilder::new()