fix types to be nullable

This commit is contained in:
Brandon Thomas
2026-06-23 16:08:56 -04:00
parent c0b700705f
commit ea761b2d37
5 changed files with 51 additions and 40 deletions
@@ -107,10 +107,13 @@ impl TaskStatus {
#[derive(Debug, Clone)]
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>,
}
@@ -232,8 +235,8 @@ pub async fn poll_orders(args: PollOrdersArgs<'_>) -> Result<PollOrdersResponse,
result_url: o.result_url,
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,
@@ -422,7 +425,7 @@ mod tests {
task_status,
result_url: o.result_url,
results: o.results.into_iter().map(|r| MediaResult {
url: r.url, width: r.width, height: r.height,
url: r.url, maybe_width: r.maybe_width, maybe_height: r.maybe_height,
}).collect(),
fail_reason,
created_at: o.created_at,
@@ -472,17 +475,17 @@ 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 coerce to `0` and let the rest of the response parse.
/// They must parse as `None` and let the rest of the response parse.
#[test]
fn null_dimensions_are_coerced_to_zero() {
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",
@@ -495,11 +498,29 @@ 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, 0);
assert_eq!(orders[0].results[0].height, 0);
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]
@@ -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>,
}
@@ -29,15 +29,13 @@ fn coerce_to_u32(number: &Number) -> u32 {
}
/// `#[serde(deserialize_with = "...")]` for a required `u64` field that may
/// arrive as an integer or a float — or, increasingly, as `null`. A `null`
/// (or JSON `null`) coerces to `0` rather than failing the whole parse; the
/// Kinovi API intermittently returns `null` for fields it used to populate.
/// arrive as an integer or a float.
pub fn de_u64_int_or_float<'de, D>(deserializer: D) -> Result<u64, D::Error>
where
D: Deserializer<'de>,
{
let maybe_number = Option::<Number>::deserialize(deserializer)?;
Ok(maybe_number.as_ref().map(coerce_to_u64).unwrap_or(0))
let number = Number::deserialize(deserializer)?;
Ok(coerce_to_u64(&number))
}
/// `#[serde(deserialize_with = "...")]` for an optional `u64` field (handles
@@ -61,15 +59,13 @@ where
}
/// `#[serde(deserialize_with = "...")]` for a required `u32` field that may
/// arrive as an integer or a float — or, increasingly, as `null`. A `null`
/// coerces to `0` rather than failing the whole parse; the Kinovi API
/// intermittently returns `null` for result `width`/`height`.
/// arrive as an integer or a float.
pub fn de_u32_int_or_float<'de, D>(deserializer: D) -> Result<u32, D::Error>
where
D: Deserializer<'de>,
{
let maybe_number = Option::<Number>::deserialize(deserializer)?;
Ok(maybe_number.as_ref().map(coerce_to_u32).unwrap_or(0))
let number = Number::deserialize(deserializer)?;
Ok(coerce_to_u32(&number))
}
#[cfg(test)]
@@ -130,15 +126,6 @@ mod tests {
assert_eq!(parse_u64(r#"{"value": -5.5}"#), 0);
}
#[test]
fn required_fields_coerce_null_to_zero() {
// The Kinovi API intermittently returns `null` for numbers it used to
// populate (e.g. result width/height). Required coercers must treat that
// as 0 rather than failing the whole response parse.
assert_eq!(parse_u32(r#"{"value": null}"#), 0);
assert_eq!(parse_u64(r#"{"value": null}"#), 0);
}
#[test]
fn optional_handles_null_missing_and_values() {
assert_eq!(serde_json::from_str::<OptU32Holder>(r#"{"value": null}"#).unwrap().value, None);
@@ -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()