From fef420129d22ef438bdff8e4ce1ac781df7ccfa2 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 23 Jun 2026 16:13:54 -0400 Subject: [PATCH] fix seedance client again - nullable width/height (#1682) --- .../src/requests/poll_orders/poll_orders.rs | 220 ++++++++++++++---- .../src/requests/poll_orders/request_types.rs | 12 +- .../seedance2pro/subcommands/find_job.rs | 4 +- .../process_successful_image_job.rs | 8 +- .../process_successful_video_job.rs | 5 +- 5 files changed, 186 insertions(+), 63 deletions(-) diff --git a/crates/api_clients/seedance2pro_client/src/requests/poll_orders/poll_orders.rs b/crates/api_clients/seedance2pro_client/src/requests/poll_orders/poll_orders.rs index dcbebfa2f0..5a7198801f 100644 --- a/crates/api_clients/seedance2pro_client/src/requests/poll_orders/poll_orders.rs +++ b/crates/api_clients/seedance2pro_client/src/requests/poll_orders/poll_orders.rs @@ -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, + + /// Detailed result entries. One entry per video frame (video orders), or + /// four entries per Midjourney task (image orders). + pub results: Vec, + + /// Structured failure reason. Populated when `task_status` is `Failed` or `fail_reason` is present. + pub fail_reason: Option, + + /// ISO 8601 creation timestamp (e.g. `"2026-02-19T01:20:50.398Z"`). + pub created_at: String, + + /// Parsed `created_at` as a `DateTime`. `None` if the raw string could not be parsed. + pub created_at_utc: Option>, + + /// 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, + + /// 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, +} + /// 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, + /// Frame height in pixels. `None` when the API returns `null`/omits it. + pub maybe_height: Option, // 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, } -/// 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, - - /// Detailed result entries. One entry per video frame (video orders), or - /// four entries per Midjourney task (image orders). - pub results: Vec, - - /// Structured failure reason. Populated when `task_status` is `Failed` or `fail_reason` is present. - pub fail_reason: Option, - - /// ISO 8601 creation timestamp (e.g. `"2026-02-19T01:20:50.398Z"`). - pub created_at: String, - - /// Parsed `created_at` as a `DateTime`. `None` if the raw string could not be parsed. - pub created_at_utc: Option>, - - /// 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, - - /// 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, -} - // --- Implementation --- pub async fn poll_orders(args: PollOrdersArgs<'_>) -> Result { @@ -234,10 +233,10 @@ pub async fn poll_orders(args: PollOrdersArgs<'_>) -> Result 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 = 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 { + 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 + } } diff --git a/crates/api_clients/seedance2pro_client/src/requests/poll_orders/request_types.rs b/crates/api_clients/seedance2pro_client/src/requests/poll_orders/request_types.rs index e88110df74..0e09b3968f 100644 --- a/crates/api_clients/seedance2pro_client/src/requests/poll_orders/request_types.rs +++ b/crates/api_clients/seedance2pro_client/src/requests/poll_orders/request_types.rs @@ -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, + #[serde(rename = "height", default, deserialize_with = "de_opt_u32_int_or_float")] + pub maybe_height: Option, // pub ratio: Option, } diff --git a/crates/cli/production/support-tool/src/commands/seedance2pro/subcommands/find_job.rs b/crates/cli/production/support-tool/src/commands/seedance2pro/subcommands/find_job.rs index 44a9ae50dc..921904ccb5 100644 --- a/crates/cli/production/support-tool/src/commands/seedance2pro/subcommands/find_job.rs +++ b/crates/cli/production/support-tool/src/commands/seedance2pro/subcommands/find_job.rs @@ -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::>(), }) diff --git a/crates/service/job/seedance2_pro_job/src/jobs/order_processing_job/process_successful_image_job.rs b/crates/service/job/seedance2_pro_job/src/jobs/order_processing_job/process_successful_image_job.rs index 791c8bdc4f..0c7cb82919 100644 --- a/crates/service/job/seedance2_pro_job/src/jobs/order_processing_job/process_successful_image_job.rs +++ b/crates/service/job/seedance2_pro_job/src/jobs/order_processing_job/process_successful_image_job.rs @@ -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 { @@ -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) diff --git a/crates/service/job/seedance2_pro_job/src/jobs/order_processing_job/process_successful_video_job.rs b/crates/service/job/seedance2_pro_job/src/jobs/order_processing_job/process_successful_video_job.rs index 23e1cfdfec..ed810634d9 100644 --- a/crates/service/job/seedance2_pro_job/src/jobs/order_processing_job/process_successful_video_job.rs +++ b/crates/service/job/seedance2_pro_job/src/jobs/order_processing_job/process_successful_video_job.rs @@ -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()