diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p5/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p5/cost.rs index 7fba7090ea..386f4eb2ca 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p5/cost.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p5/cost.rs @@ -13,7 +13,7 @@ pub struct ArtcraftSeedance2p5CostState { pub resolution: CommonResolution, pub duration_seconds: u16, pub has_video_references: bool, - pub total_input_seconds: u16, + pub maybe_total_input_seconds: Option, } impl ArtcraftSeedance2p5CostState { @@ -30,20 +30,21 @@ impl ArtcraftSeedance2p5CostState { resolution, duration_seconds, has_video_references, - total_input_seconds: request.total_input_seconds.unwrap_or(0), + maybe_total_input_seconds: request.total_input_seconds, } } pub fn estimate_cost(&self) -> VideoGenerationCostEstimate { - // Input-second clamping (the 4..=30 billing range) happens inside the - // shared pricing function. - let total_input_seconds = self.total_input_seconds; + // Input-second clamping (the 4..=30 billing range) and the + // unknown-or-zero-input failsafe happen inside the shared pricing + // function. + let maybe_total_input_seconds = self.maybe_total_input_seconds; let usd_cents = seedance_2p5_usd_cents( self.resolution, self.duration_seconds, self.has_video_references, - total_input_seconds, + maybe_total_input_seconds, ); // ArtCraft credits: 100 credits = $1.00, so credits = cents. @@ -115,10 +116,12 @@ mod tests { } #[test] - fn missing_input_seconds_bill_the_four_second_minimum() { - // No measured input still bills the 4-second total minimum: - // 7.24279835 ¢/s × (10+4) = 101.40 → 102¢. - assert_eq!(cents_with_video_refs(Some(RouterResolution::FourEightyP), 10, None), 102); + fn missing_or_zero_input_seconds_bill_the_thirty_second_maximum() { + // FAILSAFE: an unmeasured (or zero) input bills the 30-second MAXIMUM, + // matching the provider client's fallback: + // 7.24279835 ¢/s × (10+30) = 289.71 → 290¢. + assert_eq!(cents_with_video_refs(Some(RouterResolution::FourEightyP), 10, None), 290); + assert_eq!(cents_with_video_refs(Some(RouterResolution::FourEightyP), 10, Some(0)), 290); } #[test] diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p5_u/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p5_u/cost.rs index 8f78581b2c..642060232b 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p5_u/cost.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p5_u/cost.rs @@ -14,7 +14,7 @@ pub struct ArtcraftSeedance2p5UltraCostState { pub resolution: CommonResolution, pub duration_seconds: u16, pub has_video_references: bool, - pub total_input_seconds: u16, + pub maybe_total_input_seconds: Option, } impl ArtcraftSeedance2p5UltraCostState { @@ -31,20 +31,21 @@ impl ArtcraftSeedance2p5UltraCostState { resolution, duration_seconds, has_video_references, - total_input_seconds: request.total_input_seconds.unwrap_or(0), + maybe_total_input_seconds: request.total_input_seconds, } } pub fn estimate_cost(&self) -> VideoGenerationCostEstimate { - // Input-second clamping (the 4..=30 billing range) happens inside the - // shared pricing function. - let total_input_seconds = self.total_input_seconds; + // Input-second clamping (the 4..=30 billing range) and the + // unknown-or-zero-input failsafe happen inside the shared pricing + // function. + let maybe_total_input_seconds = self.maybe_total_input_seconds; let usd_cents = seedance_2p5_ultra_usd_cents( self.resolution, self.duration_seconds, self.has_video_references, - total_input_seconds, + maybe_total_input_seconds, ); // ArtCraft credits: 100 credits = $1.00, so credits = cents. @@ -133,10 +134,12 @@ mod tests { } #[test] - fn missing_input_seconds_bill_the_four_second_minimum() { - // No measured input still bills the 4-second total minimum: - // 8.55967078 ¢/s × (10+4) = 119.84 → 120¢. - assert_eq!(cents_with_video_refs(Some(RouterResolution::FourEightyP), 10, None), 120); + fn missing_or_zero_input_seconds_bill_the_thirty_second_maximum() { + // FAILSAFE: an unmeasured (or zero) input bills the 30-second MAXIMUM, + // matching the provider client's fallback: + // 8.55967078 ¢/s × (10+30) = 342.39 → 343¢. + assert_eq!(cents_with_video_refs(Some(RouterResolution::FourEightyP), 10, None), 343); + assert_eq!(cents_with_video_refs(Some(RouterResolution::FourEightyP), 10, Some(0)), 343); } #[test] diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_common.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_common.rs index e55fed9f2f..8df86f63cf 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_common.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_common.rs @@ -154,7 +154,7 @@ pub fn seedance_2p5_usd_cents( resolution: CommonResolution, duration_seconds: u16, has_video_references: bool, - total_input_seconds: u16, + maybe_total_input_seconds: Option, ) -> u64 { let (cents_per_second, billed_seconds) = if has_video_references { let rate = match resolution { @@ -164,8 +164,18 @@ pub fn seedance_2p5_usd_cents( }; // The TOTAL input duration clamps to the 4..=30 second billing range // (three 1s videos sum to 3 and bill 4; three 3s videos bill 9). - let billed_input_seconds = total_input_seconds - .clamp(u16::from(MIN_BILLED_INPUT_SECONDS), u16::from(MAX_BILLED_INPUT_SECONDS)); + // + // FAILSAFE: an unknown (never probed) or zero input duration bills the + // 30-second MAXIMUM, matching the provider client's own fallback. It + // must never default toward the minimum — billing 4 input seconds for + // an unmeasured input while the provider assumes 30 sells input seconds + // far below cost. + let billed_input_seconds = match maybe_total_input_seconds { + None | Some(0) => u16::from(MAX_BILLED_INPUT_SECONDS), + Some(seconds) => { + seconds.clamp(u16::from(MIN_BILLED_INPUT_SECONDS), u16::from(MAX_BILLED_INPUT_SECONDS)) + } + }; (rate, u64::from(duration_seconds) + u64::from(billed_input_seconds)) } else { let rate = match resolution { @@ -198,7 +208,7 @@ pub fn seedance_2p5_ultra_usd_cents( resolution: CommonResolution, duration_seconds: u16, has_video_references: bool, - total_input_seconds: u16, + maybe_total_input_seconds: Option, ) -> u64 { let (cents_per_second, billed_seconds) = if has_video_references { let rate = match resolution { @@ -208,8 +218,18 @@ pub fn seedance_2p5_ultra_usd_cents( }; // The TOTAL input duration clamps to the 4..=30 second billing range // (three 1s videos sum to 3 and bill 4; three 3s videos bill 9). - let billed_input_seconds = total_input_seconds - .clamp(u16::from(MIN_BILLED_INPUT_SECONDS), u16::from(MAX_BILLED_INPUT_SECONDS)); + // + // FAILSAFE: an unknown (never probed) or zero input duration bills the + // 30-second MAXIMUM, matching the provider client's own fallback. It + // must never default toward the minimum — billing 4 input seconds for + // an unmeasured input while the provider assumes 30 sells input seconds + // far below cost. + let billed_input_seconds = match maybe_total_input_seconds { + None | Some(0) => u16::from(MAX_BILLED_INPUT_SECONDS), + Some(seconds) => { + seconds.clamp(u16::from(MIN_BILLED_INPUT_SECONDS), u16::from(MAX_BILLED_INPUT_SECONDS)) + } + }; (rate, u64::from(duration_seconds) + u64::from(billed_input_seconds)) } else { let rate = match resolution { diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p5/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p5/cost.rs index f29fa56043..04182766b5 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p5/cost.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p5/cost.rs @@ -73,10 +73,15 @@ impl KinoviSeedance2p5CostState { } }; - // NB: The kinovi calculator clamps too; clamping here as well keeps this - // state's math correct even if it's read directly. + // NB: The kinovi calculator clamps measured values and bills the + // worst-case maximum for unknown (None) or zero inputs. Zero passes + // through unclamped so that worst-case fallback applies — clamping it up + // to the minimum here would understate the cost. let total_input_seconds = self.total_input_seconds - .map(|seconds| seconds.clamp(MIN_BILLED_INPUT_SECONDS, MAX_BILLED_INPUT_SECONDS)); + .map(|seconds| match seconds { + 0 => 0, + measured => measured.clamp(MIN_BILLED_INPUT_SECONDS, MAX_BILLED_INPUT_SECONDS), + }); let pricing_request = GenerateSeedance2p5Request { prompt: String::new(), diff --git a/crates/api_clients/kinovi_web_client/src/generate/video/generate_seedance_2p5.rs b/crates/api_clients/kinovi_web_client/src/generate/video/generate_seedance_2p5.rs index d9a6799acc..c573dd02ae 100644 --- a/crates/api_clients/kinovi_web_client/src/generate/video/generate_seedance_2p5.rs +++ b/crates/api_clients/kinovi_web_client/src/generate/video/generate_seedance_2p5.rs @@ -145,9 +145,12 @@ impl GenerateSeedance2p5Request { Some(KinoviSeedance2p5OutputResolution::FourEightyP) => 16u64, Some(KinoviSeedance2p5OutputResolution::SevenTwentyP) | None => 35u64, }; - let input_seconds = self.total_input_seconds - .unwrap_or(MAX_BILLED_INPUT_SECONDS) - .clamp(MIN_BILLED_INPUT_SECONDS, MAX_BILLED_INPUT_SECONDS); + // An unknown (None) or zero input duration bills the worst-case + // maximum — never default toward the minimum. + let input_seconds = match self.total_input_seconds { + None | Some(0) => MAX_BILLED_INPUT_SECONDS, + Some(seconds) => seconds.clamp(MIN_BILLED_INPUT_SECONDS, MAX_BILLED_INPUT_SECONDS), + }; let seconds = u64::from(self.duration_seconds) + u64::from(input_seconds); (rate, seconds) } else { @@ -483,11 +486,12 @@ mod tests { fn video_reference_rate_is_cheaper_per_second() { // 16 < 26 and 35 < 59: the with-references rate is lower per billed // second (the input seconds are where the money goes). Compare at - // zero input seconds — the unknown-duration default assumes the - // 30-second maximum, which would swamp the rate comparison. - assert!(video_ref_480(10, Some(0)).calculate_costs().kinovi_credits + // the minimum billed input (1s clamps to 4) — unknown or zero input + // assumes the 30-second maximum, which would swamp the rate + // comparison. + assert!(video_ref_480(10, Some(1)).calculate_costs().kinovi_credits < t2v_480(10).calculate_costs().kinovi_credits); - assert!(video_ref_720(10, Some(0)).calculate_costs().kinovi_credits + assert!(video_ref_720(10, Some(1)).calculate_costs().kinovi_credits < t2v_720(10).calculate_costs().kinovi_credits); }