Compare commits

..
Author SHA1 Message Date
bflatastic 3849a18c8e api update 2 2026-06-15 22:23:31 +07:00
bflatastic ce7b180c7e api update 2026-06-15 21:26:33 +07:00
bflatastic 3f5bc6edf8 Webapp: settings modal feature flagged api key section 2026-06-15 21:16:44 +07:00
Brandon Thomas 0de867ad13 upgrade tauri 2.8 -> 2.11 (#1645) 2026-06-14 22:20:29 -04:00
Brandon Thomas a1cb1b2629 enhance database connection pooling (#1644) 2026-06-14 18:09:42 -04:00
BFlat 8da68b009c Desktop create pages redesign: shared generation feed, embedded model selector, gallery folders sidebar (#1635)
## Summary

Reworks the desktop (Tauri) app's Create Image / Create Video pages to
match the webapp,
extracts the webapp's generation gallery into a shared library used by
both apps, embeds the
model selector into every prompt box, adds a folders sidebar + marquee
selection to the gallery
modal, restyles the login/pricing/credits modals to the webapp look, and
turns the Apps page
into the default landing tab.

### Shared generation-list lib

- Extracted the webapp's generation feed into
`@storyteller/ui-generation-list`: list + masonry
grid views, pending/failed/completed rows and cards, thumbnail handling,
gallery/prompt data
hooks, and the shared grid/list view store + toggle. Host-specific
actions (recreate,
make-video, share, download, toasts) inject via render props — webapp
markup unchanged, its
  old files are now thin wrappers.

### Desktop create pages

- Prompt box fixed at the bottom, `max-w-5xl` like the webapp; pages
renamed to
  "Create Image" / "Create Video".
- Generation feed: in-progress/failed from the Tauri task queue, history
from the library API,
rendered as grid or list (toggle in the TopBar next to credits). Typed
Tauri completion events
  push finished media in instantly; the poll reconciles and dedupes.
- Row/card quick actions (recreate, make video, copy link, download)
backed by a shared
  `desktopMediaActions` module extracted from the TopBar gallery wiring.
- Hero + background act as a splash that fades when the feed loads;
bottom gradient behind the
prompt box. Batch generations show "Generating N images" on the single
pending chip.
- Video prompt box now expands vertically as far as the image prompt box
(viewport-relative).

### Embedded model selector

- `ClassyModelSelector` gained `variant="embedded"`: a compact pill that
opens the webapp-style
rich-list popover while keeping the desktop's provider selection.
Embedded on all five
prompt-input pages; floating selectors removed; legacy "10 sec." badges
dropped.

### Gallery modal

- Left sidebar lists the user's folders (drag-and-drop, right-click
management, new-folder
button) and shows on the Folders tab too, highlighting the active
folder's root branch.
- Marquee (drag-rectangle) selection in view mode, with edge
auto-scroll; starts only on blank
  background so it doesn't interfere with drag-to-folder/canvas.

### Restyled modals (desktop)

- Login/signup: webapp 2-column layout (form left, Vimeo showcase
right), no Google button
(Google auth isn't wired for Tauri), `artcraft-icon` logo, eye toggles.
The dismiss button is
removed so login is required; the post-auth Discord + Success onboarding
steps are kept, as is
  the existing Tauri auth flow.
- Pricing + credits: match the webapp visually (unified monochrome plan
cards with
Most Popular / Best Value highlight frames; `#161618` credit-pack cards
with Popular badge).
All billing stays on the existing Tauri `invoke` calls — no billing
logic changed.

### Apps page / navigation

- The Apps page is now the first tab and the default landing tab, with a
Home icon. Restyled to
the webapp home: "What will you craft today?" hero, Create / Edit
sections, gradient-hover
cards sized to their content, full-width scrollbar. The TopBar
quick-menu header reads
  "Create" instead of "Generate".

### Fixes

- Video Editor no longer crashes on the desktop app (`Tooltip must be
used within
TooltipProvider`): the editor shell now mounts its own Radix
`TooltipProvider`. The webapp got
one app-wide via `SidebarProvider`; the Tauri app had none, so panel
tooltips threw.

### Misc

- `getCreatorIconPathForModelId()` added to `@storyteller/model-list`
(model id → creator icon
  path, desktop/webapp aware).
2026-06-13 17:25:40 -04:00
Brandon Thomas e4bc031fbb modernize require_user_session_extended() (#1643) 2026-06-13 17:03:21 -04:00
Brandon Thomas d937a7f78f cleanup require_user_session (#1642) 2026-06-13 05:54:01 -04:00
Brandon Thomas fef96c3835 cleanup usage of require_moderator further (#1641) 2026-06-13 05:26:36 -04:00
Brandon Thomas b9e793098c cleanup session and require_moderator (#1640) 2026-06-13 04:52:35 -04:00
Brandon Thomas 222f3e958d actix crates upgrades (#1638) 2026-06-12 21:47:36 -04:00
Brandon Thomas 4ab06030dc fix cors with PUT methods (#1637) 2026-06-12 20:02:44 -04:00
Brandon Thomas 189c5afc80 updating pricing tests (#1636) 2026-06-12 19:52:58 -04:00
166 changed files with 3215 additions and 3564 deletions
+2
View File
@@ -38,6 +38,7 @@ script/socat_dev_database_connect.sh
venv/
# LLM Testing
/external/
/prompts/
/reports/
/requests/
@@ -102,3 +103,4 @@ windows/sqlite/sqlite3.lib
# ArtCraft Windows DLLs
crates/desktop/src-tauri/*.dll
windows_dlls/
Generated
+621 -721
View File
File diff suppressed because it is too large Load Diff
+11 -13
View File
@@ -295,16 +295,14 @@ worldlabs_consumer_client = { path = "crates/api_clients/worldlabs_consumer_clie
# External libs
# Centrally pinned versions so that we can uniformly upgrade/downgrade versions across the workspace
# If a crate needs to use an older/newer version, the dependency should be managed in that crate's own Cargo.toml
actix = "0.13.5"
actix-cors = "0.6.4" # TODO(bt): Carefully upgrade this. CORS broke last time.
actix-http = "3.7.0"
actix-multipart = "0.6.2"
actix-rt = "2.10.0"
actix-cors = "0.7.1"
actix-http = "3.12.1"
actix-multipart = "0.7.2"
actix-rt = "2.11.0"
actix-service = "2.0.3"
actix-utils = "3.0.1"
actix-web = "4.7.0" # TODO(bt): We're behind and need to bump versions.
actix-web-actors = "4.3.1+deprecated"
actix-web-lab = "0.20.2"
actix-web = "4.13.0"
actix-web-lab = "0.26.0"
anyhow = "1.0.102"
assertor = "0.0.3"
base64 = "0.22.1"
@@ -344,11 +342,11 @@ sha2 = "0.11.0-rc.5"
sha3 = "0.10.8"
speculoos = "0.11.0"
strum = { version = "0.28.0", features = ["derive"] }
tauri = { version = "2.8.3", features = ["unstable", "devtools", "protocol-asset"] } # NB: "devtools" prevents App Store distribution; "ubstable" is needed for window access
tauri-plugin-dialog = "2.3.3"
tauri-plugin-fs = "2.4.2"
tauri-plugin-log = "2.6.0"
tauri-plugin-upload = "2.3.1"
tauri = { version = "2.11.2", features = ["unstable", "devtools", "protocol-asset"] } # NB: "devtools" prevents App Store distribution; "ubstable" is needed for window access
tauri-plugin-dialog = "2.7.1"
tauri-plugin-fs = "2.5.1"
tauri-plugin-log = "2.8.0"
tauri-plugin-upload = "2.4.0"
tempdir = "0.3.7"
tempfile = "3.27.0"
thiserror = "2.0.18"
@@ -11,9 +11,11 @@ use crate::generate::generate_image::providers::artcraft::midjourney_7::request:
/// | 3 | 19 | 19¢ | (interpolated; Kinovi doesn't expose 3)
/// | 4 | 25 | 25¢ |
///
/// Kinovi-side math: 12 Kinovi credits per Midjourney task; 193 Kinovi
/// credits per USD. Kinovi rounds to nearest cent, so batches 1/2/4 land
/// on 6/12/25. Batch 3 is `ceil((12 × 3 / 193) × 100) = 19`.
/// Kinovi-side math (historical, 22,000-credit package at 193 credits/$1):
/// 12 Kinovi credits per Midjourney task → batches 1/2/4 land on 6/12/25¢;
/// batch 3 is `ceil((12 × 3 / 193) × 100) = 19`. Under the current
/// 525,000-credit package (243 credits/$1) Kinovi costs only 5/10/20¢ —
/// user pricing is intentionally kept at the 193-derived values.
#[derive(Clone, Debug)]
pub struct ArtcraftMidjourney7CostState {
pub num_images: u16,
@@ -127,62 +129,6 @@ mod tests {
}
}
// ── 1:1 cost parity with Kinovi for batches 1/2/4 ──
//
// Kinovi midjourney_7 returns 6/12/25¢ for batches 1/2/4. The artcraft
// provider must match those values byte-for-byte so users pay the same.
mod kinovi_parity {
use super::*;
use crate::generate::generate_image::providers::kinovi::midjourney_7::cost::KinoviMidjourney7CostState;
use seedance2pro_client::generate::image::generate_midjourney_v7::KinoviMidjourneyBatchCount;
#[test]
fn batch_1_matches_kinovi() {
let kinovi = KinoviMidjourney7CostState { batch_count: KinoviMidjourneyBatchCount::One }
.estimate_cost();
// NB: The kinovi provider estimate rounds fractional cents UP
// (KinoviGenerationCost) while this artcraft user price rounds to
// nearest — kinovi may exceed it by at most one cent.
let artcraft_cents = cost_cents(1);
let kinovi_cents = kinovi.cost_in_usd_cents.unwrap();
assert!(
kinovi_cents == artcraft_cents || kinovi_cents == artcraft_cents + 1,
"batch 1: artcraft={artcraft_cents}, kinovi={kinovi_cents}",
);
}
#[test]
fn batch_2_matches_kinovi() {
let kinovi = KinoviMidjourney7CostState { batch_count: KinoviMidjourneyBatchCount::Two }
.estimate_cost();
// NB: The kinovi provider estimate rounds fractional cents UP
// (KinoviGenerationCost) while this artcraft user price rounds to
// nearest — kinovi may exceed it by at most one cent.
let artcraft_cents = cost_cents(2);
let kinovi_cents = kinovi.cost_in_usd_cents.unwrap();
assert!(
kinovi_cents == artcraft_cents || kinovi_cents == artcraft_cents + 1,
"batch 2: artcraft={artcraft_cents}, kinovi={kinovi_cents}",
);
}
#[test]
fn batch_4_matches_kinovi() {
let kinovi = KinoviMidjourney7CostState { batch_count: KinoviMidjourneyBatchCount::Four }
.estimate_cost();
// NB: The kinovi provider estimate rounds fractional cents UP
// (KinoviGenerationCost) while this artcraft user price rounds to
// nearest — kinovi may exceed it by at most one cent.
let artcraft_cents = cost_cents(4);
let kinovi_cents = kinovi.cost_in_usd_cents.unwrap();
assert!(
kinovi_cents == artcraft_cents || kinovi_cents == artcraft_cents + 1,
"batch 4: artcraft={artcraft_cents}, kinovi={kinovi_cents}",
);
}
}
// ── Monotonicity ──
#[test]
@@ -82,56 +82,4 @@ mod tests {
}
}
// ── 1:1 cost parity with Kinovi 7-niji ──
mod kinovi_parity {
use super::*;
use crate::generate::generate_image::providers::kinovi::midjourney_7_niji::cost::KinoviMidjourney7NijiCostState;
use seedance2pro_client::generate::image::generate_midjourney_v7_niji::KinoviMidjourneyBatchCount;
#[test]
fn batch_1_matches_kinovi() {
let kinovi = KinoviMidjourney7NijiCostState { batch_count: KinoviMidjourneyBatchCount::One }
.estimate_cost();
// NB: The kinovi provider estimate rounds fractional cents UP
// (KinoviGenerationCost) while this artcraft user price rounds to
// nearest — kinovi may exceed it by at most one cent.
let artcraft_cents = cost_cents(1);
let kinovi_cents = kinovi.cost_in_usd_cents.unwrap();
assert!(
kinovi_cents == artcraft_cents || kinovi_cents == artcraft_cents + 1,
"batch 1: artcraft={artcraft_cents}, kinovi={kinovi_cents}",
);
}
#[test]
fn batch_2_matches_kinovi() {
let kinovi = KinoviMidjourney7NijiCostState { batch_count: KinoviMidjourneyBatchCount::Two }
.estimate_cost();
// NB: The kinovi provider estimate rounds fractional cents UP
// (KinoviGenerationCost) while this artcraft user price rounds to
// nearest — kinovi may exceed it by at most one cent.
let artcraft_cents = cost_cents(2);
let kinovi_cents = kinovi.cost_in_usd_cents.unwrap();
assert!(
kinovi_cents == artcraft_cents || kinovi_cents == artcraft_cents + 1,
"batch 2: artcraft={artcraft_cents}, kinovi={kinovi_cents}",
);
}
#[test]
fn batch_4_matches_kinovi() {
let kinovi = KinoviMidjourney7NijiCostState { batch_count: KinoviMidjourneyBatchCount::Four }
.estimate_cost();
// NB: The kinovi provider estimate rounds fractional cents UP
// (KinoviGenerationCost) while this artcraft user price rounds to
// nearest — kinovi may exceed it by at most one cent.
let artcraft_cents = cost_cents(4);
let kinovi_cents = kinovi.cost_in_usd_cents.unwrap();
assert!(
kinovi_cents == artcraft_cents || kinovi_cents == artcraft_cents + 1,
"batch 4: artcraft={artcraft_cents}, kinovi={kinovi_cents}",
);
}
}
}
@@ -82,56 +82,4 @@ mod tests {
}
}
// ── 1:1 cost parity with Kinovi v8 ──
mod kinovi_parity {
use super::*;
use crate::generate::generate_image::providers::kinovi::midjourney_8::cost::KinoviMidjourney8CostState;
use seedance2pro_client::generate::image::generate_midjourney_v8::KinoviMidjourneyBatchCount;
#[test]
fn batch_1_matches_kinovi() {
let kinovi = KinoviMidjourney8CostState { batch_count: KinoviMidjourneyBatchCount::One }
.estimate_cost();
// NB: The kinovi provider estimate rounds fractional cents UP
// (KinoviGenerationCost) while this artcraft user price rounds to
// nearest — kinovi may exceed it by at most one cent.
let artcraft_cents = cost_cents(1);
let kinovi_cents = kinovi.cost_in_usd_cents.unwrap();
assert!(
kinovi_cents == artcraft_cents || kinovi_cents == artcraft_cents + 1,
"batch 1: artcraft={artcraft_cents}, kinovi={kinovi_cents}",
);
}
#[test]
fn batch_2_matches_kinovi() {
let kinovi = KinoviMidjourney8CostState { batch_count: KinoviMidjourneyBatchCount::Two }
.estimate_cost();
// NB: The kinovi provider estimate rounds fractional cents UP
// (KinoviGenerationCost) while this artcraft user price rounds to
// nearest — kinovi may exceed it by at most one cent.
let artcraft_cents = cost_cents(2);
let kinovi_cents = kinovi.cost_in_usd_cents.unwrap();
assert!(
kinovi_cents == artcraft_cents || kinovi_cents == artcraft_cents + 1,
"batch 2: artcraft={artcraft_cents}, kinovi={kinovi_cents}",
);
}
#[test]
fn batch_4_matches_kinovi() {
let kinovi = KinoviMidjourney8CostState { batch_count: KinoviMidjourneyBatchCount::Four }
.estimate_cost();
// NB: The kinovi provider estimate rounds fractional cents UP
// (KinoviGenerationCost) while this artcraft user price rounds to
// nearest — kinovi may exceed it by at most one cent.
let artcraft_cents = cost_cents(4);
let kinovi_cents = kinovi.cost_in_usd_cents.unwrap();
assert!(
kinovi_cents == artcraft_cents || kinovi_cents == artcraft_cents + 1,
"batch 4: artcraft={artcraft_cents}, kinovi={kinovi_cents}",
);
}
}
}
@@ -86,27 +86,27 @@ mod tests {
}
}
// ── USD pricing (193 credits per dollar; rounded) ──
// ── USD pricing (243 credits per dollar; rounded up) ──
mod usd {
use super::*;
#[test]
fn batch_one_is_six_cents() {
// 12 / 193 × 100 = 6.21 → 6¢
assert_eq!(usd_cents_for(KinoviMidjourneyBatchCount::One), 7); // rounds UP
fn batch_one_is_five_cents() {
// 12/243 × 100 = 4.94 → 5¢
assert_eq!(usd_cents_for(KinoviMidjourneyBatchCount::One), 5); // rounds UP
}
#[test]
fn batch_two_is_twelve_cents() {
// 24 / 193 × 100 = 12.44 → 12¢
assert_eq!(usd_cents_for(KinoviMidjourneyBatchCount::Two), 13); // rounds UP
fn batch_two_is_ten_cents() {
// 24/243 × 100 = 9.88 → 10¢
assert_eq!(usd_cents_for(KinoviMidjourneyBatchCount::Two), 10); // rounds UP
}
#[test]
fn batch_four_is_twentyfive_cents() {
// 48 / 193 × 100 = 24.87 → 25¢
assert_eq!(usd_cents_for(KinoviMidjourneyBatchCount::Four), 25);
fn batch_four_is_twenty_cents() {
// 48/243 × 100 = 19.75 → 20¢
assert_eq!(usd_cents_for(KinoviMidjourneyBatchCount::Four), 20);
}
}
@@ -56,11 +56,11 @@ mod tests {
use super::*;
#[test]
fn batch_one_is_twelve_credits_and_six_cents() {
fn batch_one_is_twelve_credits_and_five_cents() {
let cost = KinoviMidjourney7NijiCostState { batch_count: KinoviMidjourneyBatchCount::One }
.estimate_cost();
assert_eq!(cost.cost_in_credits, Some(12));
assert_eq!(cost.cost_in_usd_cents, Some(7)); // 1200/193 = 6.22 -> rounds UP
assert_eq!(cost.cost_in_usd_cents, Some(5)); // 1200/243 = 4.94 -> rounds UP
}
#[test]
@@ -68,7 +68,7 @@ mod tests {
let cost = KinoviMidjourney7NijiCostState { batch_count: KinoviMidjourneyBatchCount::Two }
.estimate_cost();
assert_eq!(cost.cost_in_credits, Some(24));
assert_eq!(cost.cost_in_usd_cents, Some(13)); // 2400/193 = 12.44 -> rounds UP
assert_eq!(cost.cost_in_usd_cents, Some(10)); // 2400/243 = 9.88 -> rounds UP
}
#[test]
@@ -76,7 +76,7 @@ mod tests {
let cost = KinoviMidjourney7NijiCostState { batch_count: KinoviMidjourneyBatchCount::Four }
.estimate_cost();
assert_eq!(cost.cost_in_credits, Some(48));
assert_eq!(cost.cost_in_usd_cents, Some(25));
assert_eq!(cost.cost_in_usd_cents, Some(20));
}
#[test]
@@ -56,11 +56,11 @@ mod tests {
use super::*;
#[test]
fn batch_one_is_twelve_credits_and_six_cents() {
fn batch_one_is_twelve_credits_and_five_cents() {
let cost = KinoviMidjourney8CostState { batch_count: KinoviMidjourneyBatchCount::One }
.estimate_cost();
assert_eq!(cost.cost_in_credits, Some(12));
assert_eq!(cost.cost_in_usd_cents, Some(7)); // 1200/193 = 6.22 -> rounds UP
assert_eq!(cost.cost_in_usd_cents, Some(5)); // 1200/243 = 4.94 -> rounds UP
}
#[test]
@@ -68,7 +68,7 @@ mod tests {
let cost = KinoviMidjourney8CostState { batch_count: KinoviMidjourneyBatchCount::Two }
.estimate_cost();
assert_eq!(cost.cost_in_credits, Some(24));
assert_eq!(cost.cost_in_usd_cents, Some(13)); // 2400/193 = 12.44 -> rounds UP
assert_eq!(cost.cost_in_usd_cents, Some(10)); // 2400/243 = 9.88 -> rounds UP
}
#[test]
@@ -76,6 +76,6 @@ mod tests {
let cost = KinoviMidjourney8CostState { batch_count: KinoviMidjourneyBatchCount::Four }
.estimate_cost();
assert_eq!(cost.cost_in_credits, Some(48));
assert_eq!(cost.cost_in_usd_cents, Some(25));
assert_eq!(cost.cost_in_usd_cents, Some(20));
}
}
@@ -231,176 +231,6 @@ mod tests {
}
}
// ── Cross-provider margin: ArtCraft is consistently ~30% above GrokApi ──
mod small_margin {
use super::*;
use crate::api::router_aspect_ratio::RouterAspectRatio;
#[test]
fn small_margin() {
let resolutions = [
None,
Some(RouterResolution::FourEightyP),
Some(RouterResolution::SevenTwentyP),
Some(RouterResolution::TenEightyP), // clamps to 720p on both sides
];
let durations: &[u16] = &[1, 4, 5, 8, 10, 12, 15];
let aspect_ratios = [
None,
Some(RouterAspectRatio::Auto),
Some(RouterAspectRatio::Square),
Some(RouterAspectRatio::WideSixteenByNine),
Some(RouterAspectRatio::TallNineBySixteen),
Some(RouterAspectRatio::WideFourByThree),
Some(RouterAspectRatio::TallThreeByFour),
Some(RouterAspectRatio::WideThreeByTwo),
Some(RouterAspectRatio::TallTwoByThree),
];
// (has_start_frame, num_reference_images). At most one of the two is
// non-default — start_frame and reference_images are mutually exclusive
// on the GrokApi wire (image-to-video vs reference-to-video), so we
// don't mix them here to keep the comparison apples-to-apples.
let image_combos: &[(bool, usize)] = &[
(false, 0), // text-to-video
(true, 0), // image-to-video, 1 source image
(false, 1), // reference-to-video, 1 ref image
(false, 2),
(false, 3),
];
let mut total_cases = 0;
let mut failures = Vec::new();
for &res in &resolutions {
for &dur in durations {
for &ar in &aspect_ratios {
for &(has_start, num_refs) in image_combos {
total_cases += 1;
let artcraft = artcraft_cents(res, dur, ar, has_start, num_refs);
let grokapi = grokapi_cents(res, dur, ar, has_start, num_refs);
// Margin requirement: artcraft / max(grokapi - 1, 1) >= 1.29.
// The (-1, 1) floor accounts for one cent of GrokApi-side
// ceiling-rounding noise (see test doc comment above).
let grokapi_compare = grokapi.saturating_sub(1).max(1);
let lhs = artcraft.saturating_mul(100);
let rhs = grokapi_compare.saturating_mul(129);
if lhs < rhs {
let pct = if grokapi == 0 { 0.0 }
else { (artcraft as f64 / grokapi as f64 - 1.0) * 100.0 };
failures.push(format!(
"res={:?} dur={}s ar={:?} start={} refs={} → \
artcraft={}¢ vs grokapi={}¢ (margin {:.1}%, want ≥ 29% after 1¢ tolerance)",
res, dur, ar, has_start, num_refs, artcraft, grokapi, pct,
));
}
// Defense in depth: ArtCraft must always cost at least as
// much as GrokApi, regardless of rounding noise.
assert!(
artcraft >= grokapi,
"ArtCraft ({artcraft}¢) cheaper than GrokApi ({grokapi}¢) for \
res={res:?} dur={dur}s ar={ar:?} start={has_start} refs={num_refs}",
);
}
}
}
}
if !failures.is_empty() {
let summary = failures.iter().take(10).cloned().collect::<Vec<_>>().join("\n ");
panic!(
"{} / {} (res × duration × aspect × image_combo) tuples failed \
the ≥29% (with 1¢ tolerance) margin check.\nFirst 10:\n {}",
failures.len(), total_cases, summary,
);
}
}
// ── Cross-provider helpers ──
fn artcraft_cents(
resolution: Option<RouterResolution>,
duration_seconds: u16,
aspect_ratio: Option<RouterAspectRatio>,
has_start_frame: bool,
num_reference_images: usize,
) -> u64 {
let start_frame = if has_start_frame {
// ArtCraft's omni endpoint takes MediaFileTokens for image refs.
Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_start".to_string())))
} else { None };
let reference_images = if num_reference_images > 0 {
Some(ImageListRef::MediaFileTokens(
(0..num_reference_images)
.map(|i| MediaFileToken::new(format!("mf_ref_{i}")))
.collect(),
))
} else { None };
let builder = GenerateVideoRequestBuilder {
model: RouterVideoModel::GrokImagineVideo,
provider: RouterProvider::Artcraft,
resolution,
aspect_ratio,
duration_seconds: Some(duration_seconds),
// Batch is fixed at 1 because GrokApi's per-request cost calculator
// doesn't multiply by batch (Grok returns one video per call); the
// markup test would compare apples to oranges otherwise.
video_batch_count: Some(1),
start_frame,
reference_images,
..Default::default()
};
builder.build2()
.expect("build2 (artcraft) should succeed")
.estimate_cost()
.expect("estimate_cost (artcraft) should succeed")
.cost_in_usd_cents
.unwrap()
}
fn grokapi_cents(
resolution: Option<RouterResolution>,
duration_seconds: u16,
aspect_ratio: Option<RouterAspectRatio>,
has_start_frame: bool,
num_reference_images: usize,
) -> u64 {
let start_frame = if has_start_frame {
// GrokApi (direct xAI) takes URLs for image refs.
Some(ImageRef::Url("https://example.com/start.png".to_string()))
} else { None };
let reference_images = if num_reference_images > 0 {
Some(ImageListRef::Urls(
(0..num_reference_images)
.map(|i| format!("https://example.com/ref_{i}.png"))
.collect(),
))
} else { None };
let builder = GenerateVideoRequestBuilder {
model: RouterVideoModel::GrokImagineVideo,
provider: RouterProvider::GrokApi,
resolution,
aspect_ratio,
duration_seconds: Some(duration_seconds),
video_batch_count: Some(1),
start_frame,
reference_images,
..Default::default()
};
builder.build2()
.expect("build2 (grokapi) should succeed")
.estimate_cost()
.expect("estimate_cost (grokapi) should succeed")
.cost_in_usd_cents
.unwrap()
}
}
// ── Helpers ──
fn build_cost(
@@ -144,113 +144,6 @@ mod tests {
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
// ── Base cost parity with grok_api_client ─────────────────────────────
//
// The base cost (before ArtCraft markup, batch=1) must equal exactly what
// grok_api_client's calculator returns for an equivalent direct-Grok
// request. This protects against the artcraft and grok-api implementations
// silently drifting apart.
mod grok_api_parity {
use super::*;
// (resolution, duration, image_count)
//
// Durations are confined to [4, 15] because the ArtCraft pipeline clamps
// outside that range (see `plan_duration` in artcraft/build_common.rs).
// Every case carries exactly one image. v1.5 rejects text-to-video, AND
// the GrokApi build step collapses multi-image reference-to-video
// requests down to one `image` (promote-first; see grok_api side
// build.rs). So the only request shape where both sides agree on the
// input image count is the single-image case.
const PARITY_CASES: &[(Option<RouterResolution>, u16, u64)] = &[
// 480p variations
(Some(RouterResolution::FourEightyP), 4, 1),
(Some(RouterResolution::FourEightyP), 5, 1),
(Some(RouterResolution::FourEightyP), 8, 1),
(Some(RouterResolution::FourEightyP), 15, 1),
// 720p variations
(Some(RouterResolution::SevenTwentyP), 4, 1),
(Some(RouterResolution::SevenTwentyP), 5, 1),
(Some(RouterResolution::SevenTwentyP), 8, 1),
(Some(RouterResolution::SevenTwentyP), 15, 1),
// None defaults to 720p in our `from_request`, which matches the
// assumption Grok's calculator makes when fed a 720p request.
(None, 5, 1),
];
#[test]
fn base_cost_for_one_video_matches_grok_api_client() {
for &(router_res, duration, image_count) in PARITY_CASES {
let artcraft = artcraft_state(router_res, duration, image_count, 1);
let grok_cents = grok_cents(router_res, duration, image_count);
assert_eq!(
artcraft.base_cost_in_cents_for_one_video(),
grok_cents,
"parity mismatch at res={router_res:?} dur={duration} images={image_count}: \
artcraft_base={} grok={}",
artcraft.base_cost_in_cents_for_one_video(),
grok_cents,
);
}
}
#[test]
fn batch_one_base_total_equals_one_video_base() {
let s = artcraft_state(Some(RouterResolution::SevenTwentyP), 5, 1, 1);
assert_eq!(s.base_cost_in_cents_for_batch(), s.base_cost_in_cents_for_one_video());
}
#[test]
fn batch_scales_base_linearly() {
// The ArtCraft pipeline only accepts batch counts of 1, 2, or 4 (see
// `plan_batch_count` in artcraft/build_common.rs); anything else gets
// clamped, so we can only meaningfully test those values.
let per_one = artcraft_state(Some(RouterResolution::SevenTwentyP), 5, 1, 1)
.base_cost_in_cents_for_one_video();
for batch in [1u16, 2, 4] {
let s = artcraft_state(Some(RouterResolution::SevenTwentyP), 5, 1, batch);
assert_eq!(
s.base_cost_in_cents_for_batch(),
per_one * batch as u64,
"batch={batch}",
);
}
}
/// Builder-driven parity — exercises the full pipeline (build2 →
/// estimate_cost) on both providers and compares ArtCraft's base cost
/// (final / 1.05, accounting for ceiling) against GrokApi's direct
/// cost.
///
/// We verify the markup rather than the base here because the public
/// `estimate_cost()` returns the marked-up number; the explicit
/// `base_cost_in_cents_*` API is tested above.
#[test]
fn end_to_end_markup_is_at_least_five_percent_over_grok() {
// Single-image cases only — see PARITY_CASES comment above for why
// multi-image rows can't be apples-to-apples between the two sides.
let cases: &[(Option<RouterResolution>, u16, bool, usize)] = &[
(Some(RouterResolution::FourEightyP), 5, true, 0),
(Some(RouterResolution::FourEightyP), 10, true, 0),
(Some(RouterResolution::SevenTwentyP), 5, true, 0),
(Some(RouterResolution::SevenTwentyP), 8, true, 0),
(Some(RouterResolution::SevenTwentyP),15, true, 0),
];
for &(res, duration, has_start, refs) in cases {
let artcraft_cents = artcraft_end_to_end_cents(res, duration, 1, has_start, refs);
let grok_cents = grok_cents(res, duration, has_start as u64 + refs as u64);
let want_minimum = grok_cents.saturating_mul(MARKUP_NUMERATOR).div_ceil(MARKUP_DENOMINATOR);
assert_eq!(
artcraft_cents, want_minimum,
"res={res:?} dur={duration} start={has_start} refs={refs}: \
artcraft={artcraft_cents}¢ vs ceil(grok({grok_cents})×1.05)={want_minimum}¢",
);
}
}
}
// ── Markup correctness ─────────────────────────────────────────────────
mod markup {
@@ -484,42 +377,6 @@ mod tests {
}
}
fn artcraft_end_to_end_cents(
resolution: Option<RouterResolution>,
duration_seconds: u16,
batch_count: u16,
has_start_frame: bool,
num_reference_images: usize,
) -> u64 {
let start_frame = if has_start_frame {
Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_start".to_string())))
} else { None };
let reference_images = if num_reference_images > 0 {
Some(ImageListRef::MediaFileTokens(
(0..num_reference_images)
.map(|i| MediaFileToken::new(format!("mf_ref_{i}")))
.collect(),
))
} else { None };
let builder = GenerateVideoRequestBuilder {
model: RouterVideoModel::GrokImagineVideo1p5,
provider: RouterProvider::Artcraft,
resolution,
duration_seconds: Some(duration_seconds),
video_batch_count: Some(batch_count),
start_frame,
reference_images,
..Default::default()
};
builder.build2()
.expect("build2 should succeed")
.estimate_cost()
.expect("estimate_cost should succeed")
.cost_in_usd_cents
.unwrap()
}
fn build_cost_via_builder(
resolution: Option<RouterResolution>,
duration_seconds: u16,
@@ -542,40 +399,4 @@ mod tests {
.expect("estimate_cost should succeed")
}
/// Reference value: what GrokApi's own pipeline would charge for one
/// equivalent video. Used by parity tests.
///
/// The Grok build step prefers `start_frame` over `reference_images` when
/// both are supplied and drops the latter (see grok_api/grok_imagine_video_1p5/build.rs).
/// To keep parity with the ArtCraft input-image *count*, we put all source
/// images in `reference_images` only — the cost calculator counts the total
/// either way.
fn grok_cents(
resolution: Option<RouterResolution>,
duration_seconds: u16,
image_count: u64,
) -> u64 {
let reference_images = if image_count >= 1 {
Some(ImageListRef::Urls(
(0..image_count).map(|i| format!("https://example.com/ref_{i}.png")).collect(),
))
} else { None };
let builder = GenerateVideoRequestBuilder {
model: RouterVideoModel::GrokImagineVideo1p5,
provider: RouterProvider::GrokApi,
resolution,
duration_seconds: Some(duration_seconds),
video_batch_count: Some(1),
start_frame: None,
reference_images,
..Default::default()
};
builder.build2()
.expect("build2 (grokapi) should succeed")
.estimate_cost()
.expect("estimate_cost (grokapi) should succeed")
.cost_in_usd_cents
.unwrap()
}
}
@@ -14,13 +14,16 @@ use crate::generate::generate_video::providers::artcraft::happy_horse_1p0::reque
//
// Default resolution (None) is 720p.
// Batch count multiplies the total cost.
// Credit package: 22,000 credits for $114 (~192.98 credits/$1, rounded to 193).
// Credit package (historical): 22,000 credits for $114 (~193 credits/$1).
// The upstream package is now 525,000 credits for $2,159.0909 (~243 credits/$1),
// but user pricing is intentionally kept at the 193-derived rates.
//
// ArtCraft credits: 100 credits = $1.00, so ArtCraft credits = USD cents.
// We compute USD cents from upstream credits ÷ credits_per_dollar × 100.
const UPSTREAM_CREDITS_PER_SECOND_720P: u32 = 33;
const UPSTREAM_CREDITS_PER_SECOND_1080P: u32 = 66;
// Frozen at the historical rate so user pricing doesn't shift with the package deal.
const UPSTREAM_CREDITS_PER_DOLLAR: f64 = 193.0;
pub struct ArtcraftHappyHorse1p0CostState {
@@ -201,56 +204,6 @@ mod tests {
}
}
// ── Cross-check with Kinovi ──
mod cross_check_with_kinovi {
use super::*;
#[test]
fn artcraft_matches_kinovi_all_combos() {
let resolutions = [
Some(RouterResolution::SevenTwentyP),
None,
Some(RouterResolution::TenEightyP),
];
let durations: [u16; 4] = [4, 5, 10, 15];
let batches: [u16; 3] = [1, 2, 4];
for res in &resolutions {
for dur in &durations {
for batch in &batches {
let artcraft_cost = build_cost(*res, *dur, *batch);
let kinovi = GenerateVideoRequestBuilder {
model: RouterVideoModel::HappyHorse1p0,
provider: RouterProvider::Seedance2Pro,
resolution: *res,
duration_seconds: Some(*dur),
video_batch_count: Some(*batch),
..Default::default()
};
let kinovi_cost = kinovi.build2()
.expect("kinovi build2")
.estimate_cost()
.expect("kinovi estimate_cost");
// NB: The kinovi provider estimate rounds fractional cents UP
// (KinoviGenerationCost), while the artcraft user-facing price
// here rounds to nearest — so kinovi may exceed artcraft by at
// most one cent.
let artcraft_cents = artcraft_cost.cost_in_usd_cents.expect("artcraft cents");
let kinovi_cents = kinovi_cost.cost_in_usd_cents.expect("kinovi cents");
assert!(
kinovi_cents == artcraft_cents || kinovi_cents == artcraft_cents + 1,
"USD cents mismatch: res={:?} dur={}s batch={} (artcraft={}, kinovi={})",
res, dur, batch, artcraft_cents, kinovi_cents,
);
}
}
}
}
}
// ── Helpers ──
fn build_cost(
@@ -18,6 +18,10 @@ use crate::generate::generate_video::providers::artcraft::seedance_2p0::request:
///
/// We keep these as f64 because per-second rates are fractional; rounding
/// happens once at the end after multiplying by duration × batch.
///
/// NB: Derivation is historical (193 credits/$1 package). The upstream
/// package is now 525,000 credits for $2,159.0909 (~243 credits/$1), but
/// user pricing is intentionally kept at these 193-derived rates.
const CENTS_PER_SECOND_480P: f64 = 7.772;
const CENTS_PER_SECOND_720P: f64 = 16.0;
const CENTS_PER_SECOND_1080P: f64 = 46.632;
@@ -214,57 +218,6 @@ mod tests {
}
}
// -- Cross-check with Kinovi via builder --
mod cross_check_with_kinovi {
use super::*;
#[test]
fn artcraft_matches_kinovi_all_combos() {
let resolutions = [
Some(RouterResolution::FourEightyP),
Some(RouterResolution::TenEightyP),
// NB: These are underpriced (for now)
//Some(RouterResolution::SevenTwentyP),
//None,
];
let durations: [u16; 4] = [4, 5, 10, 15];
let batches: [u16; 3] = [1, 2, 4];
for res in &resolutions {
for dur in &durations {
for batch in &batches {
let artcraft_cost = build_cost(*res, *dur, *batch);
let kinovi = GenerateVideoRequestBuilder {
provider: RouterProvider::Seedance2Pro,
resolution: *res,
duration_seconds: Some(*dur),
video_batch_count: Some(*batch),
..Default::default()
};
let kinovi_cost = kinovi.build2()
.expect("kinovi build2")
.estimate_cost()
.expect("kinovi estimate_cost");
// NB: The kinovi provider estimate rounds fractional cents UP
// (KinoviGenerationCost), while the artcraft user-facing price
// here rounds to nearest — so kinovi may exceed artcraft by at
// most one cent.
let artcraft_cents = artcraft_cost.cost_in_usd_cents.expect("artcraft cents");
let kinovi_cents = kinovi_cost.cost_in_usd_cents.expect("kinovi cents");
assert!(
kinovi_cents == artcraft_cents || kinovi_cents == artcraft_cents + 1,
"USD cents mismatch: res={:?} dur={}s batch={} (artcraft={}, kinovi={})",
res, dur, batch, artcraft_cents, kinovi_cents,
);
}
}
}
}
}
// -- Helpers --
/// Build a cost estimate end-to-end: GenerateVideoRequestBuilder → build2 → estimate_cost.
@@ -154,6 +154,8 @@ mod tests {
}
}
// -- Helpers --
fn build_cost(
resolution: Option<RouterResolution>,
duration_seconds: u16,
@@ -125,6 +125,8 @@ mod tests {
}
}
// -- Helpers --
fn build_cost(
resolution: Option<RouterResolution>,
duration_seconds: u16,
@@ -154,6 +154,8 @@ mod tests {
}
}
// -- Helpers --
fn build_cost(
resolution: Option<RouterResolution>,
duration_seconds: u16,
@@ -125,6 +125,8 @@ mod tests {
}
}
// -- Helpers --
fn build_cost(
resolution: Option<RouterResolution>,
duration_seconds: u16,
@@ -13,6 +13,8 @@ use crate::generate::generate_video::providers::artcraft::seedance_2p0_fast::req
/// USD cents per second by resolution, derived from upstream Fast rates:
/// 480p: 10 upstream-credits/sec / 193 upstream-credits/$1 * 100 ~= 5.181 c/s
/// (historical 193 credits/$1 derivation; the upstream package is now
/// ~243 credits/$1 but user pricing is intentionally unchanged)
/// 720p: 28 upstream-credits/sec / 220 upstream-credits/$1 * 100 ~= 12.727 c/s
///
/// We keep these as f64 because per-second rates are fractional; rounding
@@ -184,50 +186,6 @@ mod tests {
}
}
// -- Cross-check with Kinovi Fast via builder --
//mod cross_check_with_kinovi {
// use super::*;
//
// #[test]
// fn artcraft_fast_matches_kinovi_fast_all_combos() {
// let resolutions = [
// Some(RouterResolution::FourEightyP),
// Some(RouterResolution::SevenTwentyP),
// None,
// ];
// let durations: [u16; 4] = [4, 5, 10, 15];
// let batches: [u16; 3] = [1, 2, 4];
//
// for res in &resolutions {
// for dur in &durations {
// for batch in &batches {
// let artcraft_cost = build_cost(*res, *dur, *batch);
//
// let kinovi = GenerateVideoRequestBuilder {
// model: RouterVideoModel::Seedance2p0Fast,
// provider: RouterProvider::Seedance2Pro,
// resolution: *res,
// duration_seconds: Some(*dur),
// video_batch_count: Some(*batch),
// ..Default::default()
// };
// let kinovi_cost = kinovi.build2()
// .expect("kinovi build2")
// .estimate_cost()
// .expect("kinovi estimate_cost");
//
// assert_eq!(
// artcraft_cost.cost_in_usd_cents, kinovi_cost.cost_in_usd_cents,
// "USD cents mismatch: res={:?} dur={}s batch={}",
// res, dur, batch,
// );
// }
// }
// }
// }
//}
// -- Helpers --
fn build_cost(
@@ -159,6 +159,8 @@ mod tests {
}
}
// -- Helpers --
fn build_cost(
resolution: Option<RouterResolution>,
duration_seconds: u16,
@@ -144,6 +144,8 @@ mod tests {
}
}
// -- Helpers --
fn build_cost(
resolution: Option<RouterResolution>,
duration_seconds: u16,
@@ -98,10 +98,8 @@ mod tests {
#[test]
fn usd_cents_720p() {
// 165 credits / 193 * 100 = 85.49 → 85¢
assert_eq!(usd_cents(None, 5, None), 86); // 16500/193 = 85.49 -> rounds UP
// 495 credits / 193 * 100 = 256.48 → 256¢
assert_eq!(usd_cents(None, 15, None), 257); // 49500/193 = 256.48 -> rounds UP
assert_eq!(usd_cents(None, 5, None), 68); // 16500/243 = 67.90 -> rounds UP
assert_eq!(usd_cents(None, 15, None), 204); // 49500/243 = 203.70 -> rounds UP
}
}
@@ -131,10 +129,10 @@ mod tests {
#[test]
fn usd_cents_1080p() {
let r = Some(KinoviHappyHorse1p0OutputResolution::TenEightyP);
// 330 credits / 193 * 100 = 170.98 → 171¢
assert_eq!(usd_cents(r, 5, None), 171);
// 990 credits / 193 * 100 = 512.95 → 513¢
assert_eq!(usd_cents(r, 15, None), 513);
// 330 credits; 33000/243 = 135.80 → rounds UP to 136¢
assert_eq!(usd_cents(r, 5, None), 136);
// 990 credits; 99000/243 = 407.41 → rounds UP to 408¢
assert_eq!(usd_cents(r, 15, None), 408);
}
}
@@ -238,7 +236,7 @@ mod tests {
);
let cost = KinoviHappyHorse1p0CostState::from_request(&req);
assert_eq!(cost.estimate_cost().cost_in_credits, Some(330));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(171));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(136));
}
}
@@ -66,8 +66,8 @@ impl KinoviSeedance2p0CostState {
};
let costs = pricing_request.calculate_costs();
let cost_in_credits = costs.kinovi_credits;
let cost_in_usd_cents = costs.usd_cents_rounded_up;
let cost_in_credits = costs.total_cost.kinovi_credits;
let cost_in_usd_cents = costs.total_cost.usd_cents_rounded_up;
VideoGenerationCostEstimate {
cost_in_credits: Some(cost_in_credits),
@@ -103,26 +103,26 @@ mod tests {
#[test]
fn cost_720p_batch_1() {
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::One), 83);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::One), 104);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 6, KinoviBatchCount::One), 125);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 7, KinoviBatchCount::One), 146);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 10, KinoviBatchCount::One), 208);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::One), 311);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::One), 66);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::One), 83);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 6, KinoviBatchCount::One), 99);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 7, KinoviBatchCount::One), 116);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 10, KinoviBatchCount::One), 165);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::One), 247);
}
#[test]
fn cost_720p_batch_2() {
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::Two), 166);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Two), 208);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::Two), 622);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::Two), 132);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Two), 165);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::Two), 494);
}
#[test]
fn cost_720p_batch_4() {
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::Four), 332);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Four), 415);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::Four), 1244);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::Four), 264);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Four), 330);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::Four), 988);
}
}
@@ -133,20 +133,20 @@ mod tests {
#[test]
fn cost_480p_batch_1() {
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 4, KinoviBatchCount::One), 32);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::One), 39);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 10, KinoviBatchCount::One), 78);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 15, KinoviBatchCount::One), 117);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 4, KinoviBatchCount::One), 25);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::One), 31);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 10, KinoviBatchCount::One), 62);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 15, KinoviBatchCount::One), 93);
}
#[test]
fn cost_480p_batch_2() {
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::Two), 78);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::Two), 62);
}
#[test]
fn cost_480p_batch_4() {
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::Four), 156);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::Four), 124);
}
}
@@ -157,20 +157,20 @@ mod tests {
#[test]
fn cost_1080p_batch_1() {
assert_eq!(usd_cents(KinoviOutputResolution::TenEightyP, 4, KinoviBatchCount::One), 187);
assert_eq!(usd_cents(KinoviOutputResolution::TenEightyP, 5, KinoviBatchCount::One), 234);
assert_eq!(usd_cents(KinoviOutputResolution::TenEightyP, 10, KinoviBatchCount::One), 467);
assert_eq!(usd_cents(KinoviOutputResolution::TenEightyP, 15, KinoviBatchCount::One), 700);
assert_eq!(usd_cents(KinoviOutputResolution::TenEightyP, 4, KinoviBatchCount::One), 149);
assert_eq!(usd_cents(KinoviOutputResolution::TenEightyP, 5, KinoviBatchCount::One), 186);
assert_eq!(usd_cents(KinoviOutputResolution::TenEightyP, 10, KinoviBatchCount::One), 371);
assert_eq!(usd_cents(KinoviOutputResolution::TenEightyP, 15, KinoviBatchCount::One), 556);
}
#[test]
fn cost_1080p_batch_2() {
assert_eq!(usd_cents(KinoviOutputResolution::TenEightyP, 5, KinoviBatchCount::Two), 467);
assert_eq!(usd_cents(KinoviOutputResolution::TenEightyP, 5, KinoviBatchCount::Two), 371);
}
#[test]
fn cost_1080p_batch_4() {
assert_eq!(usd_cents(KinoviOutputResolution::TenEightyP, 5, KinoviBatchCount::Four), 933);
assert_eq!(usd_cents(KinoviOutputResolution::TenEightyP, 5, KinoviBatchCount::Four), 741);
}
}
@@ -219,10 +219,10 @@ mod tests {
};
let without = base.estimate_cost();
let with = KinoviSeedance2p0CostState { has_video_reference: true, ..base }.estimate_cost();
// 720p surcharge is +8 credits/s: 200 -> 240 credits (24000/193 = 124.35 -> 125 cents).
// 720p surcharge is +8 credits/s: 200 -> 240 credits (24000/243 = 98.77 -> 99 cents).
assert_eq!(without.cost_in_credits, Some(200));
assert_eq!(with.cost_in_credits, Some(240));
assert_eq!(with.cost_in_usd_cents, Some(125));
assert_eq!(with.cost_in_usd_cents, Some(99));
}
// ── from_request() tests ──
@@ -238,7 +238,7 @@ mod tests {
assert_eq!(cost.duration_seconds, 5);
assert!(matches!(cost.batch_count, Some(KinoviBatchCount::One)));
assert!(!cost.has_video_reference);
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(104));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(83));
}
#[test]
@@ -246,21 +246,21 @@ mod tests {
let req = make_request_state(None, 5, KinoviBatchCount::One, false);
let cost = KinoviSeedance2p0CostState::from_request(&req);
assert!(cost.resolution.is_none()); // None defaults to 720p pricing
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(104));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(83));
}
#[test]
fn from_request_480p() {
let req = make_request_state(Some(KinoviOutputResolution::FourEightyP), 5, KinoviBatchCount::One, false);
let cost = KinoviSeedance2p0CostState::from_request(&req);
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(39));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(31));
}
#[test]
fn from_request_1080p_batch_2() {
let req = make_request_state(Some(KinoviOutputResolution::TenEightyP), 5, KinoviBatchCount::Two, false);
let cost = KinoviSeedance2p0CostState::from_request(&req);
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(467));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(371));
}
#[test]
@@ -270,7 +270,7 @@ mod tests {
assert!(cost.has_video_reference);
// Video refs add a +8 credits/s surcharge at 720p: 240 credits -> 125 cents.
assert_eq!(cost.estimate_cost().cost_in_credits, Some(240));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(125));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(99));
}
#[test]
@@ -294,7 +294,7 @@ mod tests {
assert_eq!(cost.duration_seconds, 5);
assert!(matches!(cost.batch_count, Some(KinoviBatchCount::One)));
assert!(!cost.has_video_reference);
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(104));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(83));
}
#[test]
@@ -302,7 +302,7 @@ mod tests {
let draft = make_draft(5, 1, Some(RouterResolution::FourEightyP), false);
let cost = KinoviSeedance2p0CostState::from_draft(&draft);
assert!(matches!(cost.resolution, Some(KinoviOutputResolution::FourEightyP)));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(39));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(31));
}
#[test]
@@ -311,7 +311,7 @@ mod tests {
let cost = KinoviSeedance2p0CostState::from_draft(&draft);
assert!(matches!(cost.resolution, Some(KinoviOutputResolution::TenEightyP)));
assert!(matches!(cost.batch_count, Some(KinoviBatchCount::Four)));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(933));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(741));
}
#[test]
@@ -321,7 +321,7 @@ mod tests {
assert!(cost.has_video_reference);
// Video refs add a +8 credits/s surcharge at 720p: 240 credits -> 125 cents.
assert_eq!(cost.estimate_cost().cost_in_credits, Some(240));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(125));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(99));
}
#[test]
@@ -337,7 +337,7 @@ mod tests {
let cost = KinoviSeedance2p0CostState::from_draft(&draft);
assert_eq!(cost.duration_seconds, 15);
assert!(matches!(cost.batch_count, Some(KinoviBatchCount::Two)));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(622));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(494));
}
}
@@ -66,8 +66,8 @@ impl KinoviSeedance2p0FastCostState {
};
let costs = pricing_request.calculate_costs();
let cost_in_credits = costs.kinovi_credits;
let cost_in_usd_cents = costs.usd_cents_rounded_up;
let cost_in_credits = costs.total_cost.kinovi_credits;
let cost_in_usd_cents = costs.total_cost.usd_cents_rounded_up;
VideoGenerationCostEstimate {
cost_in_credits: Some(cost_in_credits),
@@ -100,51 +100,51 @@ mod tests {
// ── Direct estimate_cost() tests ──
// These must match the reference implementation values exactly.
// -- 720p (28 credits/sec, 193 credits/$1) --
// -- 720p (28 credits/sec, 243 credits/$1) --
mod pricing_720p {
use super::*;
#[test]
fn cost_720p_batch_1() {
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::One), 59);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::One), 73);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 10, KinoviBatchCount::One), 146);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::One), 218);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::One), 47);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::One), 58);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 10, KinoviBatchCount::One), 116);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::One), 173);
}
#[test]
fn cost_720p_batch_2() {
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Two), 146);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Two), 116);
}
#[test]
fn cost_720p_batch_4() {
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Four), 291);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Four), 231);
}
}
// -- 480p (14 credits/sec, 193 credits/$1) --
// -- 480p (14 credits/sec, 243 credits/$1) --
mod pricing_480p {
use super::*;
#[test]
fn cost_480p_batch_1() {
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 4, KinoviBatchCount::One), 30);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::One), 37);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 10, KinoviBatchCount::One), 73);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 15, KinoviBatchCount::One), 109);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 4, KinoviBatchCount::One), 24);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::One), 29);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 10, KinoviBatchCount::One), 58);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 15, KinoviBatchCount::One), 87);
}
#[test]
fn cost_480p_batch_2() {
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::Two), 73);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::Two), 58);
}
#[test]
fn cost_480p_batch_4() {
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::Four), 146);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::Four), 116);
}
}
@@ -191,10 +191,10 @@ mod tests {
};
let without = base.estimate_cost();
let with = KinoviSeedance2p0FastCostState { has_video_reference: true, ..base }.estimate_cost();
// Fast 720p surcharge is +6 credits/s: 140 -> 170 credits (17000/193 = 88.08 -> 89 cents).
// Fast 720p surcharge is +6 credits/s: 140 -> 170 credits (17000/243 = 69.96 -> 70 cents).
assert_eq!(without.cost_in_credits, Some(140));
assert_eq!(with.cost_in_credits, Some(170));
assert_eq!(with.cost_in_usd_cents, Some(89));
assert_eq!(with.cost_in_usd_cents, Some(70));
}
// ── from_request() tests ──
@@ -210,7 +210,7 @@ mod tests {
assert_eq!(cost.duration_seconds, 5);
assert!(matches!(cost.batch_count, Some(KinoviBatchCount::One)));
assert!(!cost.has_video_reference);
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(73));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(58));
}
#[test]
@@ -218,14 +218,14 @@ mod tests {
let req = make_request_state(None, 5, KinoviBatchCount::One, false);
let cost = KinoviSeedance2p0FastCostState::from_request(&req);
assert!(cost.resolution.is_none());
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(73));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(58));
}
#[test]
fn from_request_480p() {
let req = make_request_state(Some(KinoviOutputResolution::FourEightyP), 5, KinoviBatchCount::One, false);
let cost = KinoviSeedance2p0FastCostState::from_request(&req);
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(37));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(29));
}
#[test]
@@ -235,7 +235,7 @@ mod tests {
assert!(cost.has_video_reference);
// Video refs add a +6 credits/s surcharge at 720p: 170 credits -> 89 cents.
assert_eq!(cost.estimate_cost().cost_in_credits, Some(170));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(89));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(70));
}
#[test]
@@ -259,7 +259,7 @@ mod tests {
assert_eq!(cost.duration_seconds, 5);
assert!(matches!(cost.batch_count, Some(KinoviBatchCount::One)));
assert!(!cost.has_video_reference);
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(73));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(58));
}
#[test]
@@ -267,7 +267,7 @@ mod tests {
let draft = make_draft(5, 1, Some(RouterResolution::FourEightyP), false);
let cost = KinoviSeedance2p0FastCostState::from_draft(&draft);
assert!(matches!(cost.resolution, Some(KinoviOutputResolution::FourEightyP)));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(37));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(29));
}
#[test]
@@ -277,7 +277,7 @@ mod tests {
assert!(cost.has_video_reference);
// Video refs add a +6 credits/s surcharge at 720p: 170 credits -> 89 cents.
assert_eq!(cost.estimate_cost().cost_in_credits, Some(170));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(89));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(70));
}
#[test]
@@ -293,7 +293,7 @@ mod tests {
let cost = KinoviSeedance2p0FastCostState::from_draft(&draft);
assert_eq!(cost.duration_seconds, 15);
assert!(matches!(cost.batch_count, Some(KinoviBatchCount::Two)));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(436));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(346));
}
}
@@ -0,0 +1,68 @@
Cost table built on 2026-06-11
Seedance 2.0 (~$0.19/sec)
Seedance 2.0 Fast (~$0.13/sec)
Without Video Uploads
=====================
### Seedance 2.0
4 sec 480p = 60 credits
5 sec 480p = 75 credits
10 sec 480p = 150 credits
15 sec 480p = 225 credits
4 sec 720p = 160 credits
5 sec 720p = 200 credits
10 sec 720p = 400 credits
15 sec 720p = 600 credits
4 sec 1080p = 360 credits
5 sec 1080p = 450 credits
10 sec 1080p = 900 credits
15 sec 1080p = 1350 credits
### Seedance 2.0 Fast
fast 4 sec 480p = 56 credits
fast 5 sec 480p = 70 credits
fast 10 sec 480p = 140 credits
fast 15 sec 480p = 210 credits
fast 4 sec 720p = 112 credits
fast 5 sec 720p = 140 credits
fast 10 sec 720p = 280 credits
fast 15 sec 720p = 420 credits
With Video Uploads
==================
### Seedance 2.0 With Video Refs
4 sec 480p + 10 sec video ref = 60 credits + 16 credits
5 sec 480p + 10 sec video ref = 75 credits + 20 credits
10 sec 480p + 10 sec video ref = 150 credits + 40 credits
15 sec 480p + 10 sec video ref = 225 credits + 60 credits
4 sec 720p + 10 sec video ref = 160 credits + 32 credits
5 sec 720p + 10 sec video ref = 200 credits + 40 credits
10 sec 720p + 10 sec video ref = 400 credits + 80 credits
15 sec 720p + 10 sec video ref = 600 credits + 120 credits
4 sec 1080p + 10 sec video ref = 360 credits + 72 credits
5 sec 1080p + 10 sec video ref = 450 credits + 90 credits
10 sec 1080p + 10 sec video ref = 900 credits + 180 credits
15 sec 1080p + 10 sec video ref = 1350 credits + 270 credits
### Seedance 2.0 Fast With Video Refs
fast 4 sec 480p + 10 sec video ref = 56 credits + 16 credits
fast 5 sec 480p + 10 sec video ref = 70 credits + 20 credits
fast 10 sec 480p + 10 sec video ref = 140 credits + 40 credits
fast 15 sec 480p + 10 sec video ref = 210 credits + 60 credits
fast 4 sec 720p + 10 sec video ref = 112 credits + 24 credits
fast 5 sec 720p + 10 sec video ref = 140 credits + 30 credits
fast 10 sec 720p + 10 sec video ref = 280 credits + 60 credits
fast 15 sec 720p + 10 sec video ref = 420 credits + 90 credits
@@ -0,0 +1,9 @@
/// Kinovi credits per US dollar.
///
/// Current package: 525,000 credits for $2,159.0909 (~243.16 credits/$1,
/// rounded down to 243).
///
/// Historical packages, for reference:
/// - 500,000 credits for $2,159.09 (~231.58 credits/$1, rounded down to 231)
/// - 22,000 credits for $114 (~192.98 credits/$1, rounded to 193)
pub const CREDITS_PER_DOLLAR: u64 = 243;
@@ -0,0 +1,90 @@
use crate::cost::constants::CREDITS_PER_DOLLAR;
/// The cost of a Kinovi generation, in both native credits and USD cents.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct KinoviGenerationCost {
/// The cost of the generation in Kinovi credits.
pub kinovi_credits: u64,
/// Estimated cost in cents, rounded up to the nearest whole cent.
/// This does not account for discounts, prorations, etc.
/// This is always rounded up when fractional.
pub usd_cents_rounded_up: u64,
/// Estimated cost in cents, rounded down to the nearest whole cent.
pub usd_cents_rounded_down: u64,
/// Estimated cost in cents without rounding (the exact fractional value).
pub usd_cents_fractional: f64,
}
impl KinoviGenerationCost {
/// Build a cost from a Kinovi credit amount, deriving the USD cents via
/// the credit-package rate.
pub fn from_kinovi_credits(kinovi_credits: u64) -> Self {
let total_hundredths = kinovi_credits * 100;
Self {
kinovi_credits,
// Integer ceiling / floor of (credits * 100 / CREDITS_PER_DOLLAR).
usd_cents_rounded_up: total_hundredths.div_ceil(CREDITS_PER_DOLLAR),
usd_cents_rounded_down: total_hundredths / CREDITS_PER_DOLLAR,
usd_cents_fractional: total_hundredths as f64 / CREDITS_PER_DOLLAR as f64,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const FLOAT_TOLERANCE: f64 = 1e-9;
#[test]
fn zero_credits_is_zero_cents() {
let cost = KinoviGenerationCost::from_kinovi_credits(0);
assert_eq!(cost.kinovi_credits, 0);
assert_eq!(cost.usd_cents_rounded_up, 0);
assert_eq!(cost.usd_cents_rounded_down, 0);
assert!((cost.usd_cents_fractional - 0.0).abs() < FLOAT_TOLERANCE);
}
#[test]
fn exact_dollar_boundaries_do_not_round() {
// 243 credits = exactly $1.00 — all three representations agree.
let cost = KinoviGenerationCost::from_kinovi_credits(243);
assert_eq!(cost.kinovi_credits, 243);
assert_eq!(cost.usd_cents_rounded_up, 100);
assert_eq!(cost.usd_cents_rounded_down, 100);
assert!((cost.usd_cents_fractional - 100.0).abs() < FLOAT_TOLERANCE);
let cost = KinoviGenerationCost::from_kinovi_credits(486);
assert_eq!(cost.usd_cents_rounded_up, 200);
assert_eq!(cost.usd_cents_rounded_down, 200);
assert!((cost.usd_cents_fractional - 200.0).abs() < FLOAT_TOLERANCE);
}
#[test]
fn fractional_cents_round_in_both_directions() {
// 244 credits = 100.411... cents.
let cost = KinoviGenerationCost::from_kinovi_credits(244);
assert_eq!(cost.usd_cents_rounded_up, 101);
assert_eq!(cost.usd_cents_rounded_down, 100);
assert!((cost.usd_cents_fractional - (24400.0 / 243.0)).abs() < FLOAT_TOLERANCE);
// 1 credit = 0.432... cents.
let cost = KinoviGenerationCost::from_kinovi_credits(1);
assert_eq!(cost.usd_cents_rounded_up, 1);
assert_eq!(cost.usd_cents_rounded_down, 0);
assert!((cost.usd_cents_fractional - (100.0 / 243.0)).abs() < FLOAT_TOLERANCE);
}
#[test]
fn rounded_bounds_bracket_the_fractional_value() {
for credits in [0, 1, 12, 75, 200, 243, 244, 450, 1350] {
let cost = KinoviGenerationCost::from_kinovi_credits(credits);
assert!(cost.usd_cents_rounded_down as f64 <= cost.usd_cents_fractional);
assert!(cost.usd_cents_fractional <= cost.usd_cents_rounded_up as f64);
assert!(cost.usd_cents_rounded_up - cost.usd_cents_rounded_down <= 1);
}
}
}
@@ -0,0 +1,111 @@
use crate::cost::kinovi_generation_cost::KinoviGenerationCost;
/// The cost of a Kinovi Seedance generation, with the video-reference
/// surcharge broken out from the base price.
///
/// `total_cost` covers base + surcharge. NB: the total's USD conversions are
/// computed from the SUMMED credits (rounded once), so they may differ by a
/// cent from adding the parts' rounded USD values together.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct KinoviSeedanceGenerationCost {
/// The full cost (base + any surcharge), in credits and USD cents.
pub total_cost: KinoviGenerationCost,
/// The base generation cost (resolution rate × output duration × batch
/// count), excluding any surcharges.
pub base_cost: KinoviGenerationCost,
/// The video-reference surcharge, when one or more reference videos are
/// attached. `None` when no reference videos are attached.
pub video_reference_surcharge_cost: Option<KinoviGenerationCost>,
}
impl KinoviSeedanceGenerationCost {
/// Build a cost from base credits plus an optional video-reference
/// surcharge (both in Kinovi credits). The per-part and total USD
/// conversions are derived.
pub fn from_base_and_surcharge(
base_credits: u64,
maybe_video_reference_surcharge_credits: Option<u64>,
) -> Self {
let total_credits = base_credits + maybe_video_reference_surcharge_credits.unwrap_or(0);
Self {
total_cost: KinoviGenerationCost::from_kinovi_credits(total_credits),
base_cost: KinoviGenerationCost::from_kinovi_credits(base_credits),
video_reference_surcharge_cost: maybe_video_reference_surcharge_credits
.map(KinoviGenerationCost::from_kinovi_credits),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const FLOAT_TOLERANCE: f64 = 1e-9;
#[test]
fn without_surcharge() {
let cost = KinoviSeedanceGenerationCost::from_base_and_surcharge(200, None);
assert!(cost.video_reference_surcharge_cost.is_none());
// Base: 200 credits; 20000/243 = 82.30.
assert_eq!(cost.base_cost.kinovi_credits, 200);
assert_eq!(cost.base_cost.usd_cents_rounded_up, 83);
assert_eq!(cost.base_cost.usd_cents_rounded_down, 82);
assert!((cost.base_cost.usd_cents_fractional - (20000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
// Total equals base when there's no surcharge.
assert_eq!(cost.total_cost, cost.base_cost);
}
#[test]
fn with_surcharge() {
let cost = KinoviSeedanceGenerationCost::from_base_and_surcharge(200, Some(40));
// Base: 200 credits; 20000/243 = 82.30.
assert_eq!(cost.base_cost.kinovi_credits, 200);
assert_eq!(cost.base_cost.usd_cents_rounded_up, 83);
assert_eq!(cost.base_cost.usd_cents_rounded_down, 82);
assert!((cost.base_cost.usd_cents_fractional - (20000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
// Surcharge: 40 credits; 4000/243 = 16.46.
let surcharge = cost.video_reference_surcharge_cost.expect("should have surcharge");
assert_eq!(surcharge.kinovi_credits, 40);
assert_eq!(surcharge.usd_cents_rounded_up, 17);
assert_eq!(surcharge.usd_cents_rounded_down, 16);
assert!((surcharge.usd_cents_fractional - (4000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
// Total: 240 credits; 24000/243 = 98.77.
assert_eq!(cost.total_cost.kinovi_credits, 240);
assert_eq!(cost.total_cost.usd_cents_rounded_up, 99);
assert_eq!(cost.total_cost.usd_cents_rounded_down, 98);
assert!((cost.total_cost.usd_cents_fractional - (24000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
}
#[test]
fn total_credits_are_base_plus_surcharge() {
let cost = KinoviSeedanceGenerationCost::from_base_and_surcharge(450, Some(90));
assert_eq!(
cost.total_cost.kinovi_credits,
cost.base_cost.kinovi_credits + cost.video_reference_surcharge_cost.unwrap().kinovi_credits);
}
/// The total's USD is rounded ONCE on the summed credits, so it can be a
/// cent under the sum of the parts' rounded-up values.
#[test]
fn total_usd_is_rounded_once_not_per_part() {
let cost = KinoviSeedanceGenerationCost::from_base_and_surcharge(200, Some(40));
let surcharge = cost.video_reference_surcharge_cost.unwrap();
// Parts round up to 83 + 17 = 100, but the total (240 credits) rounds
// once to 99 — one cent less than summing the rounded parts.
assert_eq!(cost.base_cost.usd_cents_rounded_up + surcharge.usd_cents_rounded_up, 100);
assert_eq!(cost.total_cost.usd_cents_rounded_up, 99);
// …but the fractional parts always sum exactly to the fractional total.
let parts = cost.base_cost.usd_cents_fractional + surcharge.usd_cents_fractional;
assert!((parts - cost.total_cost.usd_cents_fractional).abs() < FLOAT_TOLERANCE);
}
}
@@ -0,0 +1,3 @@
pub mod constants;
pub mod kinovi_generation_cost;
pub mod kinovi_seedance_generation_cost;
@@ -1,61 +0,0 @@
/// Kinovi credits per US dollar.
/// Credit package: 22,000 credits for $114 (~192.98 credits/$1, rounded to 193).
const CREDITS_PER_DOLLAR: u64 = 193;
/// The cost of a Kinovi generation, in both native credits and USD cents.
pub struct KinoviGenerationCost {
/// The cost of the generation in Kinovi credits.
pub kinovi_credits: u64,
/// Estimated cost in cents, rounded up to the nearest whole cent.
/// This does not account for discounts, prorations, etc.
/// This is always rounded up when fractional.
pub usd_cents_rounded_up: u64,
}
impl KinoviGenerationCost {
/// Build a cost from a Kinovi credit amount, deriving the USD cents via
/// the credit-package rate (always rounding up fractional cents).
pub fn from_kinovi_credits(kinovi_credits: u64) -> Self {
// Integer ceiling of (credits * 100 / CREDITS_PER_DOLLAR).
let usd_cents_rounded_up = (kinovi_credits * 100).div_ceil(CREDITS_PER_DOLLAR);
Self {
kinovi_credits,
usd_cents_rounded_up,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn zero_credits_is_zero_cents() {
let cost = KinoviGenerationCost::from_kinovi_credits(0);
assert_eq!(cost.kinovi_credits, 0);
assert_eq!(cost.usd_cents_rounded_up, 0);
}
#[test]
fn exact_dollar_boundaries_do_not_round() {
// 193 credits = exactly $1.00.
let cost = KinoviGenerationCost::from_kinovi_credits(193);
assert_eq!(cost.kinovi_credits, 193);
assert_eq!(cost.usd_cents_rounded_up, 100);
let cost = KinoviGenerationCost::from_kinovi_credits(386);
assert_eq!(cost.usd_cents_rounded_up, 200);
}
#[test]
fn fractional_cents_round_up() {
// 194 credits = 100.518... cents -> 101.
let cost = KinoviGenerationCost::from_kinovi_credits(194);
assert_eq!(cost.usd_cents_rounded_up, 101);
// 1 credit = 0.518... cents -> 1.
let cost = KinoviGenerationCost::from_kinovi_credits(1);
assert_eq!(cost.usd_cents_rounded_up, 1);
}
}
@@ -1,4 +1,4 @@
use crate::generate::cost::KinoviGenerationCost;
use crate::cost::kinovi_generation_cost::KinoviGenerationCost;
use crate::creds::seedance2pro_session::Seedance2ProSession;
use crate::error::seedance2pro_error::Seedance2ProError;
use crate::requests::generate_image::generate_image::{
@@ -288,13 +288,17 @@ mod tests {
}
#[test]
fn usd_cents_batch_one_is_six() {
assert_eq!(make_request(KinoviMidjourneyBatchCount::One).calculate_costs().usd_cents_rounded_up, 7); // 1200/193 = 6.22 -> rounds UP
fn usd_cents_batch_one_is_five() {
assert_eq!(make_request(KinoviMidjourneyBatchCount::One).calculate_costs().usd_cents_rounded_up, 5); // 1200/243 = 4.94 -> rounds UP
assert_eq!(make_request(KinoviMidjourneyBatchCount::One).calculate_costs().usd_cents_rounded_down, 4);
assert!((make_request(KinoviMidjourneyBatchCount::One).calculate_costs().usd_cents_fractional - (1200.0 / 243.0)).abs() < 1e-9);
}
#[test]
fn usd_cents_batch_four_is_twentyfive() {
assert_eq!(make_request(KinoviMidjourneyBatchCount::Four).calculate_costs().usd_cents_rounded_up, 25); // 4800/193 = 24.87 -> rounds UP
fn usd_cents_batch_four_is_twenty() {
assert_eq!(make_request(KinoviMidjourneyBatchCount::Four).calculate_costs().usd_cents_rounded_up, 20); // 4800/243 = 19.75 -> rounds UP
assert_eq!(make_request(KinoviMidjourneyBatchCount::Four).calculate_costs().usd_cents_rounded_down, 19);
assert!((make_request(KinoviMidjourneyBatchCount::Four).calculate_costs().usd_cents_fractional - (4800.0 / 243.0)).abs() < 1e-9);
}
/// Pricing must match the inner module byte-for-byte (sanity that no
@@ -1,4 +1,4 @@
use crate::generate::cost::KinoviGenerationCost;
use crate::cost::kinovi_generation_cost::KinoviGenerationCost;
use crate::creds::seedance2pro_session::Seedance2ProSession;
use crate::error::seedance2pro_error::Seedance2ProError;
use crate::requests::generate_image::generate_image::{
@@ -286,13 +286,17 @@ mod tests {
}
#[test]
fn usd_cents_batch_one_is_six() {
assert_eq!(make_request(KinoviMidjourneyBatchCount::One).calculate_costs().usd_cents_rounded_up, 7); // 1200/193 = 6.22 -> rounds UP
fn usd_cents_batch_one_is_five() {
assert_eq!(make_request(KinoviMidjourneyBatchCount::One).calculate_costs().usd_cents_rounded_up, 5); // 1200/243 = 4.94 -> rounds UP
assert_eq!(make_request(KinoviMidjourneyBatchCount::One).calculate_costs().usd_cents_rounded_down, 4);
assert!((make_request(KinoviMidjourneyBatchCount::One).calculate_costs().usd_cents_fractional - (1200.0 / 243.0)).abs() < 1e-9);
}
#[test]
fn usd_cents_batch_four_is_twentyfive() {
assert_eq!(make_request(KinoviMidjourneyBatchCount::Four).calculate_costs().usd_cents_rounded_up, 25); // 4800/193 = 24.87 -> rounds UP
fn usd_cents_batch_four_is_twenty() {
assert_eq!(make_request(KinoviMidjourneyBatchCount::Four).calculate_costs().usd_cents_rounded_up, 20); // 4800/243 = 19.75 -> rounds UP
assert_eq!(make_request(KinoviMidjourneyBatchCount::Four).calculate_costs().usd_cents_rounded_down, 19);
assert!((make_request(KinoviMidjourneyBatchCount::Four).calculate_costs().usd_cents_fractional - (4800.0 / 243.0)).abs() < 1e-9);
}
/// Pricing must match the inner module exactly.
@@ -1,4 +1,4 @@
use crate::generate::cost::KinoviGenerationCost;
use crate::cost::kinovi_generation_cost::KinoviGenerationCost;
use crate::creds::seedance2pro_session::Seedance2ProSession;
use crate::error::seedance2pro_error::Seedance2ProError;
use crate::requests::generate_image::generate_image::{
@@ -283,13 +283,17 @@ mod tests {
}
#[test]
fn usd_cents_batch_one_is_six() {
assert_eq!(make_request(KinoviMidjourneyBatchCount::One).calculate_costs().usd_cents_rounded_up, 7); // 1200/193 = 6.22 -> rounds UP
fn usd_cents_batch_one_is_five() {
assert_eq!(make_request(KinoviMidjourneyBatchCount::One).calculate_costs().usd_cents_rounded_up, 5); // 1200/243 = 4.94 -> rounds UP
assert_eq!(make_request(KinoviMidjourneyBatchCount::One).calculate_costs().usd_cents_rounded_down, 4);
assert!((make_request(KinoviMidjourneyBatchCount::One).calculate_costs().usd_cents_fractional - (1200.0 / 243.0)).abs() < 1e-9);
}
#[test]
fn usd_cents_batch_four_is_twentyfive() {
assert_eq!(make_request(KinoviMidjourneyBatchCount::Four).calculate_costs().usd_cents_rounded_up, 25); // 4800/193 = 24.87 -> rounds UP
fn usd_cents_batch_four_is_twenty() {
assert_eq!(make_request(KinoviMidjourneyBatchCount::Four).calculate_costs().usd_cents_rounded_up, 20); // 4800/243 = 19.75 -> rounds UP
assert_eq!(make_request(KinoviMidjourneyBatchCount::Four).calculate_costs().usd_cents_rounded_down, 19);
assert!((make_request(KinoviMidjourneyBatchCount::Four).calculate_costs().usd_cents_fractional - (4800.0 / 243.0)).abs() < 1e-9);
}
/// Pricing must match the inner module exactly.
@@ -1,3 +1,2 @@
pub mod cost;
pub mod image;
pub mod video;
@@ -1,6 +1,6 @@
use crate::creds::seedance2pro_session::Seedance2ProSession;
use crate::error::seedance2pro_error::Seedance2ProError;
use crate::generate::cost::KinoviGenerationCost;
use crate::cost::kinovi_generation_cost::KinoviGenerationCost;
use crate::requests::kinovi_host::KinoviHost;
use crate::requests::workflow_run_task::workflow_run_task::{
workflow_run_task, KinoviAspectRatioRaw, KinoviBatchCountRaw,
@@ -63,7 +63,7 @@ pub enum KinoviHappyHorse1p0BatchCount {
//
// Default resolution (None) is 720p.
// Batch count multiplies the total cost.
// Credit package: 22,000 credits for $114 (~192.98 credits/$1, rounded to 193).
// Credit package: 525,000 credits for $2,159.0909 (~243.16 credits/$1, rounded down to 243).
impl GenerateHappyHorse1p0Request {
/// Estimate the credit cost for this generation request.
@@ -334,42 +334,52 @@ mod tests {
/// (USD cents are always rounded UP when fractional.)
#[test]
fn costs_720p_5s() {
// 33 credits/s × 5s = 165 credits; 16500/193 = 85.49 → rounds UP to 86¢
// 33 credits/s × 5s = 165 credits; 16500/243 = 67.90 → rounds UP to 68¢
let costs = r720(5).calculate_costs();
assert_eq!(costs.kinovi_credits, 165);
assert_eq!(costs.usd_cents_rounded_up, 86);
assert_eq!(costs.usd_cents_rounded_up, 68);
assert_eq!(costs.usd_cents_rounded_down, 67);
assert!((costs.usd_cents_fractional - (16500.0 / 243.0)).abs() < 1e-9);
}
#[test]
fn costs_1080p_5s() {
// 66 credits/s × 5s = 330 credits; 33000/193 = 170.98 → 171¢
// 66 credits/s × 5s = 330 credits; 33000/243 = 135.80 → 136¢
let costs = r1080(5).calculate_costs();
assert_eq!(costs.kinovi_credits, 330);
assert_eq!(costs.usd_cents_rounded_up, 171);
assert_eq!(costs.usd_cents_rounded_up, 136);
assert_eq!(costs.usd_cents_rounded_down, 135);
assert!((costs.usd_cents_fractional - (33000.0 / 243.0)).abs() < 1e-9);
}
#[test]
fn costs_720p_15s() {
// 33 credits/s × 15s = 495 credits; 49500/193 = 256.48 → rounds UP to 257¢
// 33 credits/s × 15s = 495 credits; 49500/243 = 203.70 → rounds UP to 204¢
let costs = r720(15).calculate_costs();
assert_eq!(costs.kinovi_credits, 495);
assert_eq!(costs.usd_cents_rounded_up, 257);
assert_eq!(costs.usd_cents_rounded_up, 204);
assert_eq!(costs.usd_cents_rounded_down, 203);
assert!((costs.usd_cents_fractional - (49500.0 / 243.0)).abs() < 1e-9);
}
#[test]
fn costs_1080p_15s() {
// 66 credits/s × 15s = 990 credits; 99000/193 = 512.95 → 513¢
// 66 credits/s × 15s = 990 credits; 99000/243 = 407.41 → 408¢
let costs = r1080(15).calculate_costs();
assert_eq!(costs.kinovi_credits, 990);
assert_eq!(costs.usd_cents_rounded_up, 513);
assert_eq!(costs.usd_cents_rounded_up, 408);
assert_eq!(costs.usd_cents_rounded_down, 407);
assert!((costs.usd_cents_fractional - (99000.0 / 243.0)).abs() < 1e-9);
}
#[test]
fn costs_batch_2_720p_5s() {
// 165 credits × 2 = 330 credits; 33000/193 = 170.98 → 171¢
// 165 credits × 2 = 330 credits; 33000/243 = 135.80 → 136¢
let costs = make_request(5, None, Some(KinoviHappyHorse1p0BatchCount::Two)).calculate_costs();
assert_eq!(costs.kinovi_credits, 330);
assert_eq!(costs.usd_cents_rounded_up, 171);
assert_eq!(costs.usd_cents_rounded_up, 136);
assert_eq!(costs.usd_cents_rounded_down, 135);
assert!((costs.usd_cents_fractional - (33000.0 / 243.0)).abs() < 1e-9);
}
/// The deprecated shims return the corresponding struct fields.
@@ -1,6 +1,6 @@
use crate::creds::seedance2pro_session::Seedance2ProSession;
use crate::error::seedance2pro_error::Seedance2ProError;
use crate::generate::cost::KinoviGenerationCost;
use crate::cost::kinovi_seedance_generation_cost::KinoviSeedanceGenerationCost;
use crate::requests::kinovi_host::KinoviHost;
use crate::requests::workflow_run_task::workflow_run_task::{
workflow_run_task, KinoviAspectRatioRaw, KinoviBatchCountRaw,
@@ -72,7 +72,7 @@ pub enum KinoviSeedance2p0BatchCount {
//
// Default resolution (None) is 720p.
// Batch count multiplies the total cost.
// Credit package: 22,000 credits for $114 (~192.98 credits/$1, rounded to 193).
// Credit package: 525,000 credits for $2,159.0909 (~243.16 credits/$1, rounded down to 243).
impl GenerateSeedance2p0Request {
/// Calculate the cost of this generation request, in Kinovi credits and
@@ -80,7 +80,7 @@ impl GenerateSeedance2p0Request {
///
/// Attaching reference VIDEOS adds a per-output-second surcharge (see the
/// pricing table below). Reference images and audio are free.
pub fn calculate_costs(&self) -> KinoviGenerationCost {
pub fn calculate_costs(&self) -> KinoviSeedanceGenerationCost {
let credits_per_second: u64 = match self.output_resolution {
Some(KinoviSeedance2p0OutputResolution::FourEightyP) => 15,
Some(KinoviSeedance2p0OutputResolution::SevenTwentyP) | None => 40,
@@ -108,15 +108,24 @@ impl GenerateSeedance2p0Request {
0
};
let per_video = u64::from(self.duration_seconds)
* (credits_per_second + video_reference_surcharge_per_second);
let batch_multiplier: u64 = match self.batch_count {
None | Some(KinoviSeedance2p0BatchCount::One) => 1,
Some(KinoviSeedance2p0BatchCount::Two) => 2,
Some(KinoviSeedance2p0BatchCount::Four) => 4,
};
KinoviGenerationCost::from_kinovi_credits(per_video * batch_multiplier)
let duration = u64::from(self.duration_seconds);
let base_credits = duration * credits_per_second * batch_multiplier;
let maybe_video_reference_surcharge_credits = if self.has_video_reference() {
Some(duration * video_reference_surcharge_per_second * batch_multiplier)
} else {
None
};
KinoviSeedanceGenerationCost::from_base_and_surcharge(
base_credits,
maybe_video_reference_surcharge_credits,
)
}
fn has_video_reference(&self) -> bool {
@@ -128,14 +137,14 @@ impl GenerateSeedance2p0Request {
/// Estimate the credit cost for this generation request.
#[deprecated(note = "Use calculate_costs() instead")]
pub fn estimate_credits(&self) -> u32 {
self.calculate_costs().kinovi_credits as u32
self.calculate_costs().total_cost.kinovi_credits as u32
}
/// Estimate the USD cost in cents for this generation request.
/// NB: Rounds UP fractional cents (the historical behavior rounded to nearest).
#[deprecated(note = "Use calculate_costs() instead")]
pub fn estimate_cost_in_usd_cents(&self) -> u64 {
self.calculate_costs().usd_cents_rounded_up
self.calculate_costs().total_cost.usd_cents_rounded_up
}
}
@@ -262,6 +271,171 @@ mod tests {
build_request(dur, Some(KinoviSeedance2p0OutputResolution::TenEightyP), None)
}
// ── Comprehensive per-resolution coverage ──
//
// Seedance 2.0: 480p 15/s (+4/s video ref), 720p 40/s (+8/s), 1080p 90/s (+18/s).
// Every field and subfield is asserted: total_cost, base_cost,
// video_reference_surcharge_cost × (kinovi_credits, usd_cents_rounded_up,
// usd_cents_rounded_down, usd_cents_fractional). All cases use a 5 second
// duration with no batching.
mod comprehensive {
use super::*;
const FLOAT_TOLERANCE: f64 = 1e-9;
mod resolution_480p {
use super::*;
#[test]
fn test_without_reference_video() {
let costs = r480(5).calculate_costs();
// Base: 15 credits/s × 5s = 75 credits; 7500/243 = 30.8642.
assert_eq!(costs.base_cost.kinovi_credits, 75);
assert_eq!(costs.base_cost.usd_cents_rounded_up, 31);
assert_eq!(costs.base_cost.usd_cents_rounded_down, 30);
assert!((costs.base_cost.usd_cents_fractional - (7500.0 / 243.0)).abs() < FLOAT_TOLERANCE);
// No reference video: no surcharge, and the total equals the base.
assert!(costs.video_reference_surcharge_cost.is_none());
assert_eq!(costs.total_cost.kinovi_credits, 75);
assert_eq!(costs.total_cost.usd_cents_rounded_up, 31);
assert_eq!(costs.total_cost.usd_cents_rounded_down, 30);
assert!((costs.total_cost.usd_cents_fractional - (7500.0 / 243.0)).abs() < FLOAT_TOLERANCE);
}
#[test]
fn test_with_reference_video() {
let mut request = r480(5);
request.reference_video_urls = Some(vec!["https://example.com/ref.mp4".to_string()]);
let costs = request.calculate_costs();
// Base: 15 credits/s × 5s = 75 credits; 7500/243 = 30.8642.
assert_eq!(costs.base_cost.kinovi_credits, 75);
assert_eq!(costs.base_cost.usd_cents_rounded_up, 31);
assert_eq!(costs.base_cost.usd_cents_rounded_down, 30);
assert!((costs.base_cost.usd_cents_fractional - (7500.0 / 243.0)).abs() < FLOAT_TOLERANCE);
// Surcharge: 4 credits/s × 5s = 20 credits; 2000/243 = 8.2305.
let surcharge = costs.video_reference_surcharge_cost.expect("should have surcharge");
assert_eq!(surcharge.kinovi_credits, 20);
assert_eq!(surcharge.usd_cents_rounded_up, 9);
assert_eq!(surcharge.usd_cents_rounded_down, 8);
assert!((surcharge.usd_cents_fractional - (2000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
// Total: 75 + 20 = 95 credits; 9500/243 = 39.0947.
assert_eq!(costs.total_cost.kinovi_credits, 95);
assert_eq!(costs.total_cost.usd_cents_rounded_up, 40);
assert_eq!(costs.total_cost.usd_cents_rounded_down, 39);
assert!((costs.total_cost.usd_cents_fractional - (9500.0 / 243.0)).abs() < FLOAT_TOLERANCE);
}
}
mod resolution_720p {
use super::*;
#[test]
fn test_without_reference_video() {
let costs = r720(5).calculate_costs();
// Base: 40 credits/s × 5s = 200 credits; 20000/243 = 82.3045.
assert_eq!(costs.base_cost.kinovi_credits, 200);
assert_eq!(costs.base_cost.usd_cents_rounded_up, 83);
assert_eq!(costs.base_cost.usd_cents_rounded_down, 82);
assert!((costs.base_cost.usd_cents_fractional - (20000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
// No reference video: no surcharge, and the total equals the base.
assert!(costs.video_reference_surcharge_cost.is_none());
assert_eq!(costs.total_cost.kinovi_credits, 200);
assert_eq!(costs.total_cost.usd_cents_rounded_up, 83);
assert_eq!(costs.total_cost.usd_cents_rounded_down, 82);
assert!((costs.total_cost.usd_cents_fractional - (20000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
}
#[test]
fn test_with_reference_video() {
let mut request = r720(5);
request.reference_video_urls = Some(vec!["https://example.com/ref.mp4".to_string()]);
let costs = request.calculate_costs();
// Base: 40 credits/s × 5s = 200 credits; 20000/243 = 82.3045.
assert_eq!(costs.base_cost.kinovi_credits, 200);
assert_eq!(costs.base_cost.usd_cents_rounded_up, 83);
assert_eq!(costs.base_cost.usd_cents_rounded_down, 82);
assert!((costs.base_cost.usd_cents_fractional - (20000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
// Surcharge: 8 credits/s × 5s = 40 credits; 4000/243 = 16.4609.
let surcharge = costs.video_reference_surcharge_cost.expect("should have surcharge");
assert_eq!(surcharge.kinovi_credits, 40);
assert_eq!(surcharge.usd_cents_rounded_up, 17);
assert_eq!(surcharge.usd_cents_rounded_down, 16);
assert!((surcharge.usd_cents_fractional - (4000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
// Total: 200 + 40 = 240 credits; 24000/243 = 98.7654.
assert_eq!(costs.total_cost.kinovi_credits, 240);
assert_eq!(costs.total_cost.usd_cents_rounded_up, 99);
assert_eq!(costs.total_cost.usd_cents_rounded_down, 98);
assert!((costs.total_cost.usd_cents_fractional - (24000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
}
}
mod resolution_1080p {
use super::*;
#[test]
fn test_without_reference_video() {
let costs = r1080(5).calculate_costs();
// Base: 90 credits/s × 5s = 450 credits; 45000/243 = 185.1852.
assert_eq!(costs.base_cost.kinovi_credits, 450);
assert_eq!(costs.base_cost.usd_cents_rounded_up, 186);
assert_eq!(costs.base_cost.usd_cents_rounded_down, 185);
assert!((costs.base_cost.usd_cents_fractional - (45000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
// No reference video: no surcharge, and the total equals the base.
assert!(costs.video_reference_surcharge_cost.is_none());
assert_eq!(costs.total_cost.kinovi_credits, 450);
assert_eq!(costs.total_cost.usd_cents_rounded_up, 186);
assert_eq!(costs.total_cost.usd_cents_rounded_down, 185);
assert!((costs.total_cost.usd_cents_fractional - (45000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
}
#[test]
fn test_with_reference_video() {
let mut request = r1080(5);
request.reference_video_urls = Some(vec!["https://example.com/ref.mp4".to_string()]);
let costs = request.calculate_costs();
// Base: 90 credits/s × 5s = 450 credits; 45000/243 = 185.1852.
assert_eq!(costs.base_cost.kinovi_credits, 450);
assert_eq!(costs.base_cost.usd_cents_rounded_up, 186);
assert_eq!(costs.base_cost.usd_cents_rounded_down, 185);
assert!((costs.base_cost.usd_cents_fractional - (45000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
// Surcharge: 18 credits/s × 5s = 90 credits; 9000/243 = 37.0370.
let surcharge = costs.video_reference_surcharge_cost.expect("should have surcharge");
assert_eq!(surcharge.kinovi_credits, 90);
assert_eq!(surcharge.usd_cents_rounded_up, 38);
assert_eq!(surcharge.usd_cents_rounded_down, 37);
assert!((surcharge.usd_cents_fractional - (9000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
// Total: 450 + 90 = 540 credits; 54000/243 = 222.2222.
assert_eq!(costs.total_cost.kinovi_credits, 540);
assert_eq!(costs.total_cost.usd_cents_rounded_up, 223);
assert_eq!(costs.total_cost.usd_cents_rounded_down, 222);
assert!((costs.total_cost.usd_cents_fractional - (54000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
}
}
}
// ── 480p credits (15 credits/sec) ──
mod credits_480p {
@@ -269,19 +443,19 @@ mod tests {
#[test]
fn every_duration() {
assert_eq!(r480(3).calculate_costs().kinovi_credits, 45);
assert_eq!(r480(4).calculate_costs().kinovi_credits, 60);
assert_eq!(r480(5).calculate_costs().kinovi_credits, 75);
assert_eq!(r480(6).calculate_costs().kinovi_credits, 90);
assert_eq!(r480(7).calculate_costs().kinovi_credits, 105);
assert_eq!(r480(8).calculate_costs().kinovi_credits, 120);
assert_eq!(r480(9).calculate_costs().kinovi_credits, 135);
assert_eq!(r480(10).calculate_costs().kinovi_credits, 150);
assert_eq!(r480(11).calculate_costs().kinovi_credits, 165);
assert_eq!(r480(12).calculate_costs().kinovi_credits, 180);
assert_eq!(r480(13).calculate_costs().kinovi_credits, 195);
assert_eq!(r480(14).calculate_costs().kinovi_credits, 210);
assert_eq!(r480(15).calculate_costs().kinovi_credits, 225);
assert_eq!(r480(3).calculate_costs().total_cost.kinovi_credits, 45);
assert_eq!(r480(4).calculate_costs().total_cost.kinovi_credits, 60);
assert_eq!(r480(5).calculate_costs().total_cost.kinovi_credits, 75);
assert_eq!(r480(6).calculate_costs().total_cost.kinovi_credits, 90);
assert_eq!(r480(7).calculate_costs().total_cost.kinovi_credits, 105);
assert_eq!(r480(8).calculate_costs().total_cost.kinovi_credits, 120);
assert_eq!(r480(9).calculate_costs().total_cost.kinovi_credits, 135);
assert_eq!(r480(10).calculate_costs().total_cost.kinovi_credits, 150);
assert_eq!(r480(11).calculate_costs().total_cost.kinovi_credits, 165);
assert_eq!(r480(12).calculate_costs().total_cost.kinovi_credits, 180);
assert_eq!(r480(13).calculate_costs().total_cost.kinovi_credits, 195);
assert_eq!(r480(14).calculate_costs().total_cost.kinovi_credits, 210);
assert_eq!(r480(15).calculate_costs().total_cost.kinovi_credits, 225);
}
}
@@ -292,25 +466,25 @@ mod tests {
#[test]
fn every_duration() {
assert_eq!(r720(3).calculate_costs().kinovi_credits, 120);
assert_eq!(r720(4).calculate_costs().kinovi_credits, 160);
assert_eq!(r720(5).calculate_costs().kinovi_credits, 200);
assert_eq!(r720(6).calculate_costs().kinovi_credits, 240);
assert_eq!(r720(7).calculate_costs().kinovi_credits, 280);
assert_eq!(r720(8).calculate_costs().kinovi_credits, 320);
assert_eq!(r720(9).calculate_costs().kinovi_credits, 360);
assert_eq!(r720(10).calculate_costs().kinovi_credits, 400);
assert_eq!(r720(11).calculate_costs().kinovi_credits, 440);
assert_eq!(r720(12).calculate_costs().kinovi_credits, 480);
assert_eq!(r720(13).calculate_costs().kinovi_credits, 520);
assert_eq!(r720(14).calculate_costs().kinovi_credits, 560);
assert_eq!(r720(15).calculate_costs().kinovi_credits, 600);
assert_eq!(r720(3).calculate_costs().total_cost.kinovi_credits, 120);
assert_eq!(r720(4).calculate_costs().total_cost.kinovi_credits, 160);
assert_eq!(r720(5).calculate_costs().total_cost.kinovi_credits, 200);
assert_eq!(r720(6).calculate_costs().total_cost.kinovi_credits, 240);
assert_eq!(r720(7).calculate_costs().total_cost.kinovi_credits, 280);
assert_eq!(r720(8).calculate_costs().total_cost.kinovi_credits, 320);
assert_eq!(r720(9).calculate_costs().total_cost.kinovi_credits, 360);
assert_eq!(r720(10).calculate_costs().total_cost.kinovi_credits, 400);
assert_eq!(r720(11).calculate_costs().total_cost.kinovi_credits, 440);
assert_eq!(r720(12).calculate_costs().total_cost.kinovi_credits, 480);
assert_eq!(r720(13).calculate_costs().total_cost.kinovi_credits, 520);
assert_eq!(r720(14).calculate_costs().total_cost.kinovi_credits, 560);
assert_eq!(r720(15).calculate_costs().total_cost.kinovi_credits, 600);
}
#[test]
fn explicit_720p_same_as_default() {
let default = r720(5).calculate_costs().kinovi_credits;
let explicit = build_request(5, Some(KinoviSeedance2p0OutputResolution::SevenTwentyP), None).calculate_costs().kinovi_credits;
let default = r720(5).calculate_costs().total_cost.kinovi_credits;
let explicit = build_request(5, Some(KinoviSeedance2p0OutputResolution::SevenTwentyP), None).calculate_costs().total_cost.kinovi_credits;
assert_eq!(default, explicit);
}
}
@@ -322,19 +496,19 @@ mod tests {
#[test]
fn every_duration() {
assert_eq!(r1080(3).calculate_costs().kinovi_credits, 270);
assert_eq!(r1080(4).calculate_costs().kinovi_credits, 360);
assert_eq!(r1080(5).calculate_costs().kinovi_credits, 450);
assert_eq!(r1080(6).calculate_costs().kinovi_credits, 540);
assert_eq!(r1080(7).calculate_costs().kinovi_credits, 630);
assert_eq!(r1080(8).calculate_costs().kinovi_credits, 720);
assert_eq!(r1080(9).calculate_costs().kinovi_credits, 810);
assert_eq!(r1080(10).calculate_costs().kinovi_credits, 900);
assert_eq!(r1080(11).calculate_costs().kinovi_credits, 990);
assert_eq!(r1080(12).calculate_costs().kinovi_credits, 1080);
assert_eq!(r1080(13).calculate_costs().kinovi_credits, 1170);
assert_eq!(r1080(14).calculate_costs().kinovi_credits, 1260);
assert_eq!(r1080(15).calculate_costs().kinovi_credits, 1350);
assert_eq!(r1080(3).calculate_costs().total_cost.kinovi_credits, 270);
assert_eq!(r1080(4).calculate_costs().total_cost.kinovi_credits, 360);
assert_eq!(r1080(5).calculate_costs().total_cost.kinovi_credits, 450);
assert_eq!(r1080(6).calculate_costs().total_cost.kinovi_credits, 540);
assert_eq!(r1080(7).calculate_costs().total_cost.kinovi_credits, 630);
assert_eq!(r1080(8).calculate_costs().total_cost.kinovi_credits, 720);
assert_eq!(r1080(9).calculate_costs().total_cost.kinovi_credits, 810);
assert_eq!(r1080(10).calculate_costs().total_cost.kinovi_credits, 900);
assert_eq!(r1080(11).calculate_costs().total_cost.kinovi_credits, 990);
assert_eq!(r1080(12).calculate_costs().total_cost.kinovi_credits, 1080);
assert_eq!(r1080(13).calculate_costs().total_cost.kinovi_credits, 1170);
assert_eq!(r1080(14).calculate_costs().total_cost.kinovi_credits, 1260);
assert_eq!(r1080(15).calculate_costs().total_cost.kinovi_credits, 1350);
}
}
@@ -345,39 +519,39 @@ mod tests {
#[test]
fn batch_1_is_base() {
let base = r720(5).calculate_costs().kinovi_credits;
let explicit = build_request(5, None, Some(KinoviSeedance2p0BatchCount::One)).calculate_costs().kinovi_credits;
let base = r720(5).calculate_costs().total_cost.kinovi_credits;
let explicit = build_request(5, None, Some(KinoviSeedance2p0BatchCount::One)).calculate_costs().total_cost.kinovi_credits;
assert_eq!(base, explicit);
}
#[test]
fn batch_2_doubles() {
let base = r720(5).calculate_costs().kinovi_credits;
let batch2 = build_request(5, None, Some(KinoviSeedance2p0BatchCount::Two)).calculate_costs().kinovi_credits;
let base = r720(5).calculate_costs().total_cost.kinovi_credits;
let batch2 = build_request(5, None, Some(KinoviSeedance2p0BatchCount::Two)).calculate_costs().total_cost.kinovi_credits;
assert_eq!(batch2, base * 2);
}
#[test]
fn batch_4_quadruples() {
let base = r720(5).calculate_costs().kinovi_credits;
let batch4 = build_request(5, None, Some(KinoviSeedance2p0BatchCount::Four)).calculate_costs().kinovi_credits;
let base = r720(5).calculate_costs().total_cost.kinovi_credits;
let batch4 = build_request(5, None, Some(KinoviSeedance2p0BatchCount::Four)).calculate_costs().total_cost.kinovi_credits;
assert_eq!(batch4, base * 4);
}
#[test]
fn batch_multiplier_applies_to_1080p() {
let base = r1080(5).calculate_costs().kinovi_credits;
let batch2 = build_request(5, Some(KinoviSeedance2p0OutputResolution::TenEightyP), Some(KinoviSeedance2p0BatchCount::Two)).calculate_costs().kinovi_credits;
let batch4 = build_request(5, Some(KinoviSeedance2p0OutputResolution::TenEightyP), Some(KinoviSeedance2p0BatchCount::Four)).calculate_costs().kinovi_credits;
let base = r1080(5).calculate_costs().total_cost.kinovi_credits;
let batch2 = build_request(5, Some(KinoviSeedance2p0OutputResolution::TenEightyP), Some(KinoviSeedance2p0BatchCount::Two)).calculate_costs().total_cost.kinovi_credits;
let batch4 = build_request(5, Some(KinoviSeedance2p0OutputResolution::TenEightyP), Some(KinoviSeedance2p0BatchCount::Four)).calculate_costs().total_cost.kinovi_credits;
assert_eq!(batch2, base * 2);
assert_eq!(batch4, base * 4);
}
#[test]
fn batch_multiplier_applies_to_480p() {
let base = r480(5).calculate_costs().kinovi_credits;
let batch2 = build_request(5, Some(KinoviSeedance2p0OutputResolution::FourEightyP), Some(KinoviSeedance2p0BatchCount::Two)).calculate_costs().kinovi_credits;
let batch4 = build_request(5, Some(KinoviSeedance2p0OutputResolution::FourEightyP), Some(KinoviSeedance2p0BatchCount::Four)).calculate_costs().kinovi_credits;
let base = r480(5).calculate_costs().total_cost.kinovi_credits;
let batch2 = build_request(5, Some(KinoviSeedance2p0OutputResolution::FourEightyP), Some(KinoviSeedance2p0BatchCount::Two)).calculate_costs().total_cost.kinovi_credits;
let batch4 = build_request(5, Some(KinoviSeedance2p0OutputResolution::FourEightyP), Some(KinoviSeedance2p0BatchCount::Four)).calculate_costs().total_cost.kinovi_credits;
assert_eq!(batch2, base * 2);
assert_eq!(batch4, base * 4);
}
@@ -390,9 +564,9 @@ mod tests {
#[test]
fn cost_scales_with_duration() {
let c3 = r720(3).calculate_costs().kinovi_credits;
let c10 = r720(10).calculate_costs().kinovi_credits;
let c15 = r720(15).calculate_costs().kinovi_credits;
let c3 = r720(3).calculate_costs().total_cost.kinovi_credits;
let c10 = r720(10).calculate_costs().total_cost.kinovi_credits;
let c15 = r720(15).calculate_costs().total_cost.kinovi_credits;
assert!(c3 < c10);
assert!(c10 < c15);
}
@@ -400,9 +574,9 @@ mod tests {
#[test]
fn resolution_ordering() {
for dur in 3..=15u8 {
let c480 = build_request(dur, Some(KinoviSeedance2p0OutputResolution::FourEightyP), None).calculate_costs().kinovi_credits;
let c720 = build_request(dur, None, None).calculate_costs().kinovi_credits;
let c1080 = build_request(dur, Some(KinoviSeedance2p0OutputResolution::TenEightyP), None).calculate_costs().kinovi_credits;
let c480 = build_request(dur, Some(KinoviSeedance2p0OutputResolution::FourEightyP), None).calculate_costs().total_cost.kinovi_credits;
let c720 = build_request(dur, None, None).calculate_costs().total_cost.kinovi_credits;
let c1080 = build_request(dur, Some(KinoviSeedance2p0OutputResolution::TenEightyP), None).calculate_costs().total_cost.kinovi_credits;
assert!(c480 < c720, "480p should be cheaper than 720p at {}s", dur);
assert!(c720 < c1080, "720p should be cheaper than 1080p at {}s", dur);
}
@@ -418,50 +592,74 @@ mod tests {
/// (USD cents are always rounded UP when fractional.)
#[test]
fn costs_480p_5s() {
// 15 credits/s × 5s = 75 credits; 7500/193 = 38.86 → 39¢
// 15 credits/s × 5s = 75 credits; 7500/243 = 30.86 → 31¢
let costs = r480(5).calculate_costs();
assert_eq!(costs.kinovi_credits, 75);
assert_eq!(costs.usd_cents_rounded_up, 39);
assert_eq!(costs.total_cost.kinovi_credits, 75);
assert_eq!(costs.total_cost.usd_cents_rounded_up, 31);
assert_eq!(costs.total_cost.usd_cents_rounded_down, 30);
assert!((costs.total_cost.usd_cents_fractional - (7500.0 / 243.0)).abs() < 1e-9);
assert_eq!(costs.base_cost.kinovi_credits, 75);
assert!(costs.video_reference_surcharge_cost.is_none());
}
#[test]
fn costs_720p_5s() {
// 40 credits/s × 5s = 200 credits; 20000/193 = 103.63 → 104¢
// 40 credits/s × 5s = 200 credits; 20000/243 = 82.30 → 83¢
let costs = r720(5).calculate_costs();
assert_eq!(costs.kinovi_credits, 200);
assert_eq!(costs.usd_cents_rounded_up, 104);
assert_eq!(costs.total_cost.kinovi_credits, 200);
assert_eq!(costs.total_cost.usd_cents_rounded_up, 83);
assert_eq!(costs.total_cost.usd_cents_rounded_down, 82);
assert!((costs.total_cost.usd_cents_fractional - (20000.0 / 243.0)).abs() < 1e-9);
assert_eq!(costs.base_cost.kinovi_credits, 200);
assert!(costs.video_reference_surcharge_cost.is_none());
}
#[test]
fn costs_1080p_5s() {
// 90 credits/s × 5s = 450 credits; 45000/193 = 233.16 → rounds UP to 234¢
// 90 credits/s × 5s = 450 credits; 45000/243 = 185.19 → rounds UP to 186¢
let costs = r1080(5).calculate_costs();
assert_eq!(costs.kinovi_credits, 450);
assert_eq!(costs.usd_cents_rounded_up, 234);
assert_eq!(costs.total_cost.kinovi_credits, 450);
assert_eq!(costs.total_cost.usd_cents_rounded_up, 186);
assert_eq!(costs.total_cost.usd_cents_rounded_down, 185);
assert!((costs.total_cost.usd_cents_fractional - (45000.0 / 243.0)).abs() < 1e-9);
assert_eq!(costs.base_cost.kinovi_credits, 450);
assert!(costs.video_reference_surcharge_cost.is_none());
}
#[test]
fn costs_720p_15s() {
// 40 credits/s × 15s = 600 credits; 60000/193 = 310.88 → 311¢
// 40 credits/s × 15s = 600 credits; 60000/243 = 246.91 → 247¢
let costs = r720(15).calculate_costs();
assert_eq!(costs.kinovi_credits, 600);
assert_eq!(costs.usd_cents_rounded_up, 311);
assert_eq!(costs.total_cost.kinovi_credits, 600);
assert_eq!(costs.total_cost.usd_cents_rounded_up, 247);
assert_eq!(costs.total_cost.usd_cents_rounded_down, 246);
assert!((costs.total_cost.usd_cents_fractional - (60000.0 / 243.0)).abs() < 1e-9);
assert_eq!(costs.base_cost.kinovi_credits, 600);
assert!(costs.video_reference_surcharge_cost.is_none());
}
#[test]
fn costs_1080p_15s() {
// 90 credits/s × 15s = 1350 credits; 135000/193 = 699.48 → rounds UP to 700¢
// 90 credits/s × 15s = 1350 credits; 135000/243 = 555.56 → rounds UP to 556¢
let costs = r1080(15).calculate_costs();
assert_eq!(costs.kinovi_credits, 1350);
assert_eq!(costs.usd_cents_rounded_up, 700);
assert_eq!(costs.total_cost.kinovi_credits, 1350);
assert_eq!(costs.total_cost.usd_cents_rounded_up, 556);
assert_eq!(costs.total_cost.usd_cents_rounded_down, 555);
assert!((costs.total_cost.usd_cents_fractional - (135000.0 / 243.0)).abs() < 1e-9);
assert_eq!(costs.base_cost.kinovi_credits, 1350);
assert!(costs.video_reference_surcharge_cost.is_none());
}
#[test]
fn costs_batch_4_720p_5s() {
// 200 credits × 4 = 800 credits; 80000/193 = 414.51 → 415¢
// 200 credits × 4 = 800 credits; 80000/243 = 329.22 → 330¢
let costs = build_request(5, None, Some(KinoviSeedance2p0BatchCount::Four)).calculate_costs();
assert_eq!(costs.kinovi_credits, 800);
assert_eq!(costs.usd_cents_rounded_up, 415);
assert_eq!(costs.total_cost.kinovi_credits, 800);
assert_eq!(costs.total_cost.usd_cents_rounded_up, 330);
assert_eq!(costs.total_cost.usd_cents_rounded_down, 329);
assert!((costs.total_cost.usd_cents_fractional - (80000.0 / 243.0)).abs() < 1e-9);
assert_eq!(costs.base_cost.kinovi_credits, 800);
assert!(costs.video_reference_surcharge_cost.is_none());
}
/// The deprecated shims return the corresponding struct fields.
@@ -470,8 +668,8 @@ mod tests {
fn deprecated_methods_delegate() {
let request = r720(5);
let costs = request.calculate_costs();
assert_eq!(u64::from(request.estimate_credits()), costs.kinovi_credits);
assert_eq!(request.estimate_cost_in_usd_cents(), costs.usd_cents_rounded_up);
assert_eq!(u64::from(request.estimate_credits()), costs.total_cost.kinovi_credits);
assert_eq!(request.estimate_cost_in_usd_cents(), costs.total_cost.usd_cents_rounded_up);
}
}
@@ -489,47 +687,74 @@ mod tests {
}
/// The full base + surcharge table from Kinovi's pricing page
/// ("With Video Uploads", 10 sec video ref).
/// ("With Video Uploads", 10 sec video ref). Asserts every field:
/// base, surcharge, and the derived total.
#[test]
fn kinovi_pricing_table_with_video_reference() {
let cases: &[(fn(u8) -> GenerateSeedance2p0Request, u8, u64)] = &[
// (request, duration, base credits, surcharge credits)
let cases: &[(fn(u8) -> GenerateSeedance2p0Request, u8, u64, u64)] = &[
// 480p: base + 4/s
(r480, 4, 60 + 16),
(r480, 5, 75 + 20),
(r480, 10, 150 + 40),
(r480, 15, 225 + 60),
(r480, 4, 60, 16),
(r480, 5, 75, 20),
(r480, 10, 150, 40),
(r480, 15, 225, 60),
// 720p: base + 8/s
(r720, 4, 160 + 32),
(r720, 5, 200 + 40),
(r720, 10, 400 + 80),
(r720, 15, 600 + 120),
(r720, 4, 160, 32),
(r720, 5, 200, 40),
(r720, 10, 400, 80),
(r720, 15, 600, 120),
// 1080p: base + 18/s
(r1080, 4, 360 + 72),
(r1080, 5, 450 + 90),
(r1080, 10, 900 + 180),
(r1080, 15, 1350 + 270),
(r1080, 4, 360, 72),
(r1080, 5, 450, 90),
(r1080, 10, 900, 180),
(r1080, 15, 1350, 270),
];
for (make, duration, expected_credits) in cases {
for (make, duration, base, surcharge) in cases {
let costs = with_video_ref(make(*duration)).calculate_costs();
assert_eq!(costs.kinovi_credits, *expected_credits,
"duration {duration}s should cost {expected_credits} credits with a video reference");
assert_eq!(costs.base_cost.kinovi_credits, *base, "base for {duration}s");
assert_eq!(costs.video_reference_surcharge_cost.map(|c| c.kinovi_credits), Some(*surcharge), "surcharge for {duration}s");
assert_eq!(costs.total_cost.kinovi_credits, base + surcharge, "total for {duration}s");
}
}
#[test]
fn surcharge_includes_usd_cents() {
// 720p 5s + video ref = 240 credits; 24000/193 = 124.35 → 125¢
// 720p 5s + video ref = 200 + 40 = 240 credits; 24000/243 = 98.77 → 99¢
let costs = with_video_ref(r720(5)).calculate_costs();
assert_eq!(costs.kinovi_credits, 240);
assert_eq!(costs.usd_cents_rounded_up, 125);
assert_eq!(costs.base_cost.kinovi_credits, 200);
assert_eq!(costs.video_reference_surcharge_cost.map(|c| c.kinovi_credits), Some(40));
assert_eq!(costs.total_cost.kinovi_credits, 240);
assert_eq!(costs.total_cost.usd_cents_rounded_up, 99);
assert_eq!(costs.total_cost.usd_cents_rounded_down, 98);
assert!((costs.total_cost.usd_cents_fractional - (24000.0 / 243.0)).abs() < 1e-9);
}
/// The base and surcharge parts each carry their own USD conversions.
#[test]
fn parts_have_their_own_usd_conversions() {
let costs = with_video_ref(r720(5)).calculate_costs();
// Base: 200 credits; 20000/243 = 82.30.
assert_eq!(costs.base_cost.usd_cents_rounded_up, 83);
assert_eq!(costs.base_cost.usd_cents_rounded_down, 82);
assert!((costs.base_cost.usd_cents_fractional - (20000.0 / 243.0)).abs() < 1e-9);
// Surcharge: 40 credits; 4000/243 = 16.46.
let surcharge = costs.video_reference_surcharge_cost.expect("should have surcharge");
assert_eq!(surcharge.kinovi_credits, 40);
assert_eq!(surcharge.usd_cents_rounded_up, 17);
assert_eq!(surcharge.usd_cents_rounded_down, 16);
assert!((surcharge.usd_cents_fractional - (4000.0 / 243.0)).abs() < 1e-9);
}
#[test]
fn empty_video_reference_list_has_no_surcharge() {
let mut request = r720(5);
request.reference_video_urls = Some(vec![]);
assert_eq!(request.calculate_costs().kinovi_credits, 200);
let costs = request.calculate_costs();
assert_eq!(costs.total_cost.kinovi_credits, 200);
assert!(costs.video_reference_surcharge_cost.is_none());
}
/// Surcharge is flat per generation regardless of how many reference
@@ -541,7 +766,7 @@ mod tests {
"https://example.com/a.mp4".to_string(),
"https://example.com/b.mp4".to_string(),
]);
assert_eq!(request.calculate_costs().kinovi_credits, 240);
assert_eq!(request.calculate_costs().total_cost.kinovi_credits, 240);
}
/// The surcharge applies per generated video, so batches multiply it.
@@ -549,7 +774,10 @@ mod tests {
fn batch_multiplies_surcharge() {
let request = with_video_ref(build_request(5, None, Some(KinoviSeedance2p0BatchCount::Two)));
// (200 base + 40 surcharge) × 2 = 480 credits
assert_eq!(request.calculate_costs().kinovi_credits, 480);
let costs = request.calculate_costs();
assert_eq!(costs.base_cost.kinovi_credits, 400);
assert_eq!(costs.video_reference_surcharge_cost.map(|c| c.kinovi_credits), Some(80));
assert_eq!(costs.total_cost.kinovi_credits, 480);
}
}
@@ -557,7 +785,7 @@ mod tests {
#[test]
fn aspect_ratio_does_not_affect_credits() {
let baseline = r720(5).calculate_costs().kinovi_credits;
let baseline = r720(5).calculate_costs().total_cost.kinovi_credits;
let ratios = [
KinoviSeedance2p0AspectRatio::Landscape16x9,
@@ -584,7 +812,7 @@ mod tests {
use_face_blur_hack: None,
};
assert_eq!(
req.calculate_costs().kinovi_credits, baseline,
req.calculate_costs().total_cost.kinovi_credits, baseline,
"Aspect ratio {:?} should not change credits from baseline {}", ar, baseline,
);
}
@@ -1,6 +1,6 @@
use crate::creds::seedance2pro_session::Seedance2ProSession;
use crate::error::seedance2pro_error::Seedance2ProError;
use crate::generate::cost::KinoviGenerationCost;
use crate::cost::kinovi_seedance_generation_cost::KinoviSeedanceGenerationCost;
use crate::requests::kinovi_host::KinoviHost;
use crate::requests::workflow_run_task::workflow_run_task::{
workflow_run_task, KinoviAspectRatioRaw, KinoviBatchCountRaw,
@@ -70,7 +70,7 @@ pub enum KinoviSeedance2p0FastBatchCount {
//
// Default resolution (None) is 720p.
// Batch count multiplies the total cost.
// Credit package: 22,000 credits for $114 (~192.98 credits/$1, rounded to 193).
// Credit package: 525,000 credits for $2,159.0909 (~243.16 credits/$1, rounded down to 243).
impl GenerateSeedance2p0FastRequest {
/// Calculate the cost of this generation request, in Kinovi credits and
@@ -78,7 +78,7 @@ impl GenerateSeedance2p0FastRequest {
///
/// Attaching reference VIDEOS adds a per-output-second surcharge (see the
/// pricing table below). Reference images and audio are free.
pub fn calculate_costs(&self) -> KinoviGenerationCost {
pub fn calculate_costs(&self) -> KinoviSeedanceGenerationCost {
let credits_per_second: u64 = match self.output_resolution {
Some(KinoviSeedance2p0FastOutputResolution::FourEightyP) => 14,
Some(KinoviSeedance2p0FastOutputResolution::SevenTwentyP) | None => 28,
@@ -103,15 +103,24 @@ impl GenerateSeedance2p0FastRequest {
0
};
let per_video = u64::from(self.duration_seconds)
* (credits_per_second + video_reference_surcharge_per_second);
let batch_multiplier: u64 = match self.batch_count {
None | Some(KinoviSeedance2p0FastBatchCount::One) => 1,
Some(KinoviSeedance2p0FastBatchCount::Two) => 2,
Some(KinoviSeedance2p0FastBatchCount::Four) => 4,
};
KinoviGenerationCost::from_kinovi_credits(per_video * batch_multiplier)
let duration = u64::from(self.duration_seconds);
let base_credits = duration * credits_per_second * batch_multiplier;
let maybe_video_reference_surcharge_credits = if self.has_video_reference() {
Some(duration * video_reference_surcharge_per_second * batch_multiplier)
} else {
None
};
KinoviSeedanceGenerationCost::from_base_and_surcharge(
base_credits,
maybe_video_reference_surcharge_credits,
)
}
fn has_video_reference(&self) -> bool {
@@ -123,14 +132,14 @@ impl GenerateSeedance2p0FastRequest {
/// Estimate the credit cost for this generation request.
#[deprecated(note = "Use calculate_costs() instead")]
pub fn estimate_credits(&self) -> u32 {
self.calculate_costs().kinovi_credits as u32
self.calculate_costs().total_cost.kinovi_credits as u32
}
/// Estimate the USD cost in cents for this generation request.
/// NB: Rounds UP fractional cents (the historical behavior rounded to nearest).
#[deprecated(note = "Use calculate_costs() instead")]
pub fn estimate_cost_in_usd_cents(&self) -> u64 {
self.calculate_costs().usd_cents_rounded_up
self.calculate_costs().total_cost.usd_cents_rounded_up
}
}
@@ -252,6 +261,121 @@ mod tests {
build_request(dur, None, None)
}
// ── Comprehensive per-resolution coverage ──
//
// Seedance 2.0 Fast: 480p 14/s (+4/s video ref), 720p 28/s (+6/s). No 1080p.
// Every field and subfield is asserted: total_cost, base_cost,
// video_reference_surcharge_cost × (kinovi_credits, usd_cents_rounded_up,
// usd_cents_rounded_down, usd_cents_fractional). All cases use a 5 second
// duration with no batching.
mod comprehensive {
use super::*;
const FLOAT_TOLERANCE: f64 = 1e-9;
mod resolution_480p {
use super::*;
#[test]
fn test_without_reference_video() {
let costs = r480(5).calculate_costs();
// Base: 14 credits/s × 5s = 70 credits; 7000/243 = 28.8066.
assert_eq!(costs.base_cost.kinovi_credits, 70);
assert_eq!(costs.base_cost.usd_cents_rounded_up, 29);
assert_eq!(costs.base_cost.usd_cents_rounded_down, 28);
assert!((costs.base_cost.usd_cents_fractional - (7000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
// No reference video: no surcharge, and the total equals the base.
assert!(costs.video_reference_surcharge_cost.is_none());
assert_eq!(costs.total_cost.kinovi_credits, 70);
assert_eq!(costs.total_cost.usd_cents_rounded_up, 29);
assert_eq!(costs.total_cost.usd_cents_rounded_down, 28);
assert!((costs.total_cost.usd_cents_fractional - (7000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
}
#[test]
fn test_with_reference_video() {
let mut request = r480(5);
request.reference_video_urls = Some(vec!["https://example.com/ref.mp4".to_string()]);
let costs = request.calculate_costs();
// Base: 14 credits/s × 5s = 70 credits; 7000/243 = 28.8066.
assert_eq!(costs.base_cost.kinovi_credits, 70);
assert_eq!(costs.base_cost.usd_cents_rounded_up, 29);
assert_eq!(costs.base_cost.usd_cents_rounded_down, 28);
assert!((costs.base_cost.usd_cents_fractional - (7000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
// Surcharge: 4 credits/s × 5s = 20 credits; 2000/243 = 8.2305.
let surcharge = costs.video_reference_surcharge_cost.expect("should have surcharge");
assert_eq!(surcharge.kinovi_credits, 20);
assert_eq!(surcharge.usd_cents_rounded_up, 9);
assert_eq!(surcharge.usd_cents_rounded_down, 8);
assert!((surcharge.usd_cents_fractional - (2000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
// Total: 70 + 20 = 90 credits; 9000/243 = 37.0370.
assert_eq!(costs.total_cost.kinovi_credits, 90);
assert_eq!(costs.total_cost.usd_cents_rounded_up, 38);
assert_eq!(costs.total_cost.usd_cents_rounded_down, 37);
assert!((costs.total_cost.usd_cents_fractional - (9000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
}
}
mod resolution_720p {
use super::*;
#[test]
fn test_without_reference_video() {
let costs = r720(5).calculate_costs();
// Base: 28 credits/s × 5s = 140 credits; 14000/243 = 57.6132.
assert_eq!(costs.base_cost.kinovi_credits, 140);
assert_eq!(costs.base_cost.usd_cents_rounded_up, 58);
assert_eq!(costs.base_cost.usd_cents_rounded_down, 57);
assert!((costs.base_cost.usd_cents_fractional - (14000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
// No reference video: no surcharge, and the total equals the base.
assert!(costs.video_reference_surcharge_cost.is_none());
assert_eq!(costs.total_cost.kinovi_credits, 140);
assert_eq!(costs.total_cost.usd_cents_rounded_up, 58);
assert_eq!(costs.total_cost.usd_cents_rounded_down, 57);
assert!((costs.total_cost.usd_cents_fractional - (14000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
}
#[test]
fn test_with_reference_video() {
let mut request = r720(5);
request.reference_video_urls = Some(vec!["https://example.com/ref.mp4".to_string()]);
let costs = request.calculate_costs();
// Base: 28 credits/s × 5s = 140 credits; 14000/243 = 57.6132.
assert_eq!(costs.base_cost.kinovi_credits, 140);
assert_eq!(costs.base_cost.usd_cents_rounded_up, 58);
assert_eq!(costs.base_cost.usd_cents_rounded_down, 57);
assert!((costs.base_cost.usd_cents_fractional - (14000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
// Surcharge: 6 credits/s × 5s = 30 credits; 3000/243 = 12.3457.
let surcharge = costs.video_reference_surcharge_cost.expect("should have surcharge");
assert_eq!(surcharge.kinovi_credits, 30);
assert_eq!(surcharge.usd_cents_rounded_up, 13);
assert_eq!(surcharge.usd_cents_rounded_down, 12);
assert!((surcharge.usd_cents_fractional - (3000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
// Total: 140 + 30 = 170 credits; 17000/243 = 69.9588.
assert_eq!(costs.total_cost.kinovi_credits, 170);
assert_eq!(costs.total_cost.usd_cents_rounded_up, 70);
assert_eq!(costs.total_cost.usd_cents_rounded_down, 69);
assert!((costs.total_cost.usd_cents_fractional - (17000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
}
}
}
// ── 480p credits (14 credits/sec) ──
mod credits_480p {
@@ -259,19 +383,19 @@ mod tests {
#[test]
fn every_duration() {
assert_eq!(r480(3).calculate_costs().kinovi_credits, 42);
assert_eq!(r480(4).calculate_costs().kinovi_credits, 56);
assert_eq!(r480(5).calculate_costs().kinovi_credits, 70);
assert_eq!(r480(6).calculate_costs().kinovi_credits, 84);
assert_eq!(r480(7).calculate_costs().kinovi_credits, 98);
assert_eq!(r480(8).calculate_costs().kinovi_credits, 112);
assert_eq!(r480(9).calculate_costs().kinovi_credits, 126);
assert_eq!(r480(10).calculate_costs().kinovi_credits, 140);
assert_eq!(r480(11).calculate_costs().kinovi_credits, 154);
assert_eq!(r480(12).calculate_costs().kinovi_credits, 168);
assert_eq!(r480(13).calculate_costs().kinovi_credits, 182);
assert_eq!(r480(14).calculate_costs().kinovi_credits, 196);
assert_eq!(r480(15).calculate_costs().kinovi_credits, 210);
assert_eq!(r480(3).calculate_costs().total_cost.kinovi_credits, 42);
assert_eq!(r480(4).calculate_costs().total_cost.kinovi_credits, 56);
assert_eq!(r480(5).calculate_costs().total_cost.kinovi_credits, 70);
assert_eq!(r480(6).calculate_costs().total_cost.kinovi_credits, 84);
assert_eq!(r480(7).calculate_costs().total_cost.kinovi_credits, 98);
assert_eq!(r480(8).calculate_costs().total_cost.kinovi_credits, 112);
assert_eq!(r480(9).calculate_costs().total_cost.kinovi_credits, 126);
assert_eq!(r480(10).calculate_costs().total_cost.kinovi_credits, 140);
assert_eq!(r480(11).calculate_costs().total_cost.kinovi_credits, 154);
assert_eq!(r480(12).calculate_costs().total_cost.kinovi_credits, 168);
assert_eq!(r480(13).calculate_costs().total_cost.kinovi_credits, 182);
assert_eq!(r480(14).calculate_costs().total_cost.kinovi_credits, 196);
assert_eq!(r480(15).calculate_costs().total_cost.kinovi_credits, 210);
}
}
@@ -282,25 +406,25 @@ mod tests {
#[test]
fn every_duration() {
assert_eq!(r720(3).calculate_costs().kinovi_credits, 84);
assert_eq!(r720(4).calculate_costs().kinovi_credits, 112);
assert_eq!(r720(5).calculate_costs().kinovi_credits, 140);
assert_eq!(r720(6).calculate_costs().kinovi_credits, 168);
assert_eq!(r720(7).calculate_costs().kinovi_credits, 196);
assert_eq!(r720(8).calculate_costs().kinovi_credits, 224);
assert_eq!(r720(9).calculate_costs().kinovi_credits, 252);
assert_eq!(r720(10).calculate_costs().kinovi_credits, 280);
assert_eq!(r720(11).calculate_costs().kinovi_credits, 308);
assert_eq!(r720(12).calculate_costs().kinovi_credits, 336);
assert_eq!(r720(13).calculate_costs().kinovi_credits, 364);
assert_eq!(r720(14).calculate_costs().kinovi_credits, 392);
assert_eq!(r720(15).calculate_costs().kinovi_credits, 420);
assert_eq!(r720(3).calculate_costs().total_cost.kinovi_credits, 84);
assert_eq!(r720(4).calculate_costs().total_cost.kinovi_credits, 112);
assert_eq!(r720(5).calculate_costs().total_cost.kinovi_credits, 140);
assert_eq!(r720(6).calculate_costs().total_cost.kinovi_credits, 168);
assert_eq!(r720(7).calculate_costs().total_cost.kinovi_credits, 196);
assert_eq!(r720(8).calculate_costs().total_cost.kinovi_credits, 224);
assert_eq!(r720(9).calculate_costs().total_cost.kinovi_credits, 252);
assert_eq!(r720(10).calculate_costs().total_cost.kinovi_credits, 280);
assert_eq!(r720(11).calculate_costs().total_cost.kinovi_credits, 308);
assert_eq!(r720(12).calculate_costs().total_cost.kinovi_credits, 336);
assert_eq!(r720(13).calculate_costs().total_cost.kinovi_credits, 364);
assert_eq!(r720(14).calculate_costs().total_cost.kinovi_credits, 392);
assert_eq!(r720(15).calculate_costs().total_cost.kinovi_credits, 420);
}
#[test]
fn explicit_720p_same_as_default() {
let default = r720(5).calculate_costs().kinovi_credits;
let explicit = build_request(5, Some(KinoviSeedance2p0FastOutputResolution::SevenTwentyP), None).calculate_costs().kinovi_credits;
let default = r720(5).calculate_costs().total_cost.kinovi_credits;
let explicit = build_request(5, Some(KinoviSeedance2p0FastOutputResolution::SevenTwentyP), None).calculate_costs().total_cost.kinovi_credits;
assert_eq!(default, explicit);
}
}
@@ -312,30 +436,30 @@ mod tests {
#[test]
fn batch_1_is_base() {
let base = r720(5).calculate_costs().kinovi_credits;
let explicit = build_request(5, None, Some(KinoviSeedance2p0FastBatchCount::One)).calculate_costs().kinovi_credits;
let base = r720(5).calculate_costs().total_cost.kinovi_credits;
let explicit = build_request(5, None, Some(KinoviSeedance2p0FastBatchCount::One)).calculate_costs().total_cost.kinovi_credits;
assert_eq!(base, explicit);
}
#[test]
fn batch_2_doubles() {
let base = r720(5).calculate_costs().kinovi_credits;
let batch2 = build_request(5, None, Some(KinoviSeedance2p0FastBatchCount::Two)).calculate_costs().kinovi_credits;
let base = r720(5).calculate_costs().total_cost.kinovi_credits;
let batch2 = build_request(5, None, Some(KinoviSeedance2p0FastBatchCount::Two)).calculate_costs().total_cost.kinovi_credits;
assert_eq!(batch2, base * 2);
}
#[test]
fn batch_4_quadruples() {
let base = r720(5).calculate_costs().kinovi_credits;
let batch4 = build_request(5, None, Some(KinoviSeedance2p0FastBatchCount::Four)).calculate_costs().kinovi_credits;
let base = r720(5).calculate_costs().total_cost.kinovi_credits;
let batch4 = build_request(5, None, Some(KinoviSeedance2p0FastBatchCount::Four)).calculate_costs().total_cost.kinovi_credits;
assert_eq!(batch4, base * 4);
}
#[test]
fn batch_multiplier_applies_to_480p() {
let base = r480(5).calculate_costs().kinovi_credits;
let batch2 = build_request(5, Some(KinoviSeedance2p0FastOutputResolution::FourEightyP), Some(KinoviSeedance2p0FastBatchCount::Two)).calculate_costs().kinovi_credits;
let batch4 = build_request(5, Some(KinoviSeedance2p0FastOutputResolution::FourEightyP), Some(KinoviSeedance2p0FastBatchCount::Four)).calculate_costs().kinovi_credits;
let base = r480(5).calculate_costs().total_cost.kinovi_credits;
let batch2 = build_request(5, Some(KinoviSeedance2p0FastOutputResolution::FourEightyP), Some(KinoviSeedance2p0FastBatchCount::Two)).calculate_costs().total_cost.kinovi_credits;
let batch4 = build_request(5, Some(KinoviSeedance2p0FastOutputResolution::FourEightyP), Some(KinoviSeedance2p0FastBatchCount::Four)).calculate_costs().total_cost.kinovi_credits;
assert_eq!(batch2, base * 2);
assert_eq!(batch4, base * 4);
}
@@ -348,9 +472,9 @@ mod tests {
#[test]
fn cost_scales_with_duration() {
let c3 = r720(3).calculate_costs().kinovi_credits;
let c10 = r720(10).calculate_costs().kinovi_credits;
let c15 = r720(15).calculate_costs().kinovi_credits;
let c3 = r720(3).calculate_costs().total_cost.kinovi_credits;
let c10 = r720(10).calculate_costs().total_cost.kinovi_credits;
let c15 = r720(15).calculate_costs().total_cost.kinovi_credits;
assert!(c3 < c10);
assert!(c10 < c15);
}
@@ -358,8 +482,8 @@ mod tests {
#[test]
fn resolution_ordering() {
for dur in 3..=15u8 {
let c480 = build_request(dur, Some(KinoviSeedance2p0FastOutputResolution::FourEightyP), None).calculate_costs().kinovi_credits;
let c720 = build_request(dur, None, None).calculate_costs().kinovi_credits;
let c480 = build_request(dur, Some(KinoviSeedance2p0FastOutputResolution::FourEightyP), None).calculate_costs().total_cost.kinovi_credits;
let c720 = build_request(dur, None, None).calculate_costs().total_cost.kinovi_credits;
assert!(c480 < c720, "480p should be cheaper than 720p at {}s", dur);
}
}
@@ -374,42 +498,62 @@ mod tests {
/// (USD cents are always rounded UP when fractional.)
#[test]
fn costs_480p_5s() {
// 14 credits/s × 5s = 70 credits; 7000/193 = 36.27 → rounds UP to 37¢
// 14 credits/s × 5s = 70 credits; 7000/243 = 28.81 → rounds UP to 29¢
let costs = r480(5).calculate_costs();
assert_eq!(costs.kinovi_credits, 70);
assert_eq!(costs.usd_cents_rounded_up, 37);
assert_eq!(costs.total_cost.kinovi_credits, 70);
assert_eq!(costs.total_cost.usd_cents_rounded_up, 29);
assert_eq!(costs.total_cost.usd_cents_rounded_down, 28);
assert!((costs.total_cost.usd_cents_fractional - (7000.0 / 243.0)).abs() < 1e-9);
assert_eq!(costs.base_cost.kinovi_credits, 70);
assert!(costs.video_reference_surcharge_cost.is_none());
}
#[test]
fn costs_720p_5s() {
// 28 credits/s × 5s = 140 credits; 14000/193 = 72.54 → 73¢
// 28 credits/s × 5s = 140 credits; 14000/243 = 57.61 → 58¢
let costs = r720(5).calculate_costs();
assert_eq!(costs.kinovi_credits, 140);
assert_eq!(costs.usd_cents_rounded_up, 73);
assert_eq!(costs.total_cost.kinovi_credits, 140);
assert_eq!(costs.total_cost.usd_cents_rounded_up, 58);
assert_eq!(costs.total_cost.usd_cents_rounded_down, 57);
assert!((costs.total_cost.usd_cents_fractional - (14000.0 / 243.0)).abs() < 1e-9);
assert_eq!(costs.base_cost.kinovi_credits, 140);
assert!(costs.video_reference_surcharge_cost.is_none());
}
#[test]
fn costs_720p_15s() {
// 28 credits/s × 15s = 420 credits; 42000/193 = 217.62 → 218¢
// 28 credits/s × 15s = 420 credits; 42000/243 = 172.84 → 173¢
let costs = r720(15).calculate_costs();
assert_eq!(costs.kinovi_credits, 420);
assert_eq!(costs.usd_cents_rounded_up, 218);
assert_eq!(costs.total_cost.kinovi_credits, 420);
assert_eq!(costs.total_cost.usd_cents_rounded_up, 173);
assert_eq!(costs.total_cost.usd_cents_rounded_down, 172);
assert!((costs.total_cost.usd_cents_fractional - (42000.0 / 243.0)).abs() < 1e-9);
assert_eq!(costs.base_cost.kinovi_credits, 420);
assert!(costs.video_reference_surcharge_cost.is_none());
}
#[test]
fn costs_480p_15s() {
// 14 credits/s × 15s = 210 credits; 21000/193 = 108.81 → 109¢
// 14 credits/s × 15s = 210 credits; 21000/243 = 86.42 → 87¢
let costs = r480(15).calculate_costs();
assert_eq!(costs.kinovi_credits, 210);
assert_eq!(costs.usd_cents_rounded_up, 109);
assert_eq!(costs.total_cost.kinovi_credits, 210);
assert_eq!(costs.total_cost.usd_cents_rounded_up, 87);
assert_eq!(costs.total_cost.usd_cents_rounded_down, 86);
assert!((costs.total_cost.usd_cents_fractional - (21000.0 / 243.0)).abs() < 1e-9);
assert_eq!(costs.base_cost.kinovi_credits, 210);
assert!(costs.video_reference_surcharge_cost.is_none());
}
#[test]
fn costs_batch_2_720p_5s() {
// 140 credits × 2 = 280 credits; 28000/193 = 145.07 → 146¢
// 140 credits × 2 = 280 credits; 28000/243 = 115.23 → 116¢
let costs = build_request(5, None, Some(KinoviSeedance2p0FastBatchCount::Two)).calculate_costs();
assert_eq!(costs.kinovi_credits, 280);
assert_eq!(costs.usd_cents_rounded_up, 146);
assert_eq!(costs.total_cost.kinovi_credits, 280);
assert_eq!(costs.total_cost.usd_cents_rounded_up, 116);
assert_eq!(costs.total_cost.usd_cents_rounded_down, 115);
assert!((costs.total_cost.usd_cents_fractional - (28000.0 / 243.0)).abs() < 1e-9);
assert_eq!(costs.base_cost.kinovi_credits, 280);
assert!(costs.video_reference_surcharge_cost.is_none());
}
/// The deprecated shims return the corresponding struct fields.
@@ -418,8 +562,8 @@ mod tests {
fn deprecated_methods_delegate() {
let request = r720(5);
let costs = request.calculate_costs();
assert_eq!(u64::from(request.estimate_credits()), costs.kinovi_credits);
assert_eq!(request.estimate_cost_in_usd_cents(), costs.usd_cents_rounded_up);
assert_eq!(u64::from(request.estimate_credits()), costs.total_cost.kinovi_credits);
assert_eq!(request.estimate_cost_in_usd_cents(), costs.total_cost.usd_cents_rounded_up);
}
}
@@ -438,42 +582,69 @@ mod tests {
}
/// The full base + surcharge table from Kinovi's pricing page
/// ("Seedance 2.0 Fast With Video Ref", 10 sec video ref).
/// ("Seedance 2.0 Fast With Video Ref", 10 sec video ref). Asserts
/// every field: base, surcharge, and the derived total.
#[test]
fn kinovi_pricing_table_with_video_reference() {
let cases: &[(fn(u8) -> GenerateSeedance2p0FastRequest, u8, u64)] = &[
// (request, duration, base credits, surcharge credits)
let cases: &[(fn(u8) -> GenerateSeedance2p0FastRequest, u8, u64, u64)] = &[
// 480p: base + 4/s
(r480, 4, 56 + 16),
(r480, 5, 70 + 20),
(r480, 10, 140 + 40),
(r480, 15, 210 + 60),
(r480, 4, 56, 16),
(r480, 5, 70, 20),
(r480, 10, 140, 40),
(r480, 15, 210, 60),
// 720p: base + 6/s
(r720, 4, 112 + 24),
(r720, 5, 140 + 30),
(r720, 10, 280 + 60),
(r720, 15, 420 + 90),
(r720, 4, 112, 24),
(r720, 5, 140, 30),
(r720, 10, 280, 60),
(r720, 15, 420, 90),
];
for (make, duration, expected_credits) in cases {
for (make, duration, base, surcharge) in cases {
let costs = with_video_ref(make(*duration)).calculate_costs();
assert_eq!(costs.kinovi_credits, *expected_credits,
"duration {duration}s should cost {expected_credits} credits with a video reference");
assert_eq!(costs.base_cost.kinovi_credits, *base, "base for {duration}s");
assert_eq!(costs.video_reference_surcharge_cost.map(|c| c.kinovi_credits), Some(*surcharge), "surcharge for {duration}s");
assert_eq!(costs.total_cost.kinovi_credits, base + surcharge, "total for {duration}s");
}
}
#[test]
fn surcharge_includes_usd_cents() {
// 720p 5s + video ref = 170 credits; 17000/193 = 88.08 → 89¢
// 720p 5s + video ref = 140 + 30 = 170 credits; 17000/243 = 69.96 → 70¢
let costs = with_video_ref(r720(5)).calculate_costs();
assert_eq!(costs.kinovi_credits, 170);
assert_eq!(costs.usd_cents_rounded_up, 89);
assert_eq!(costs.base_cost.kinovi_credits, 140);
assert_eq!(costs.video_reference_surcharge_cost.map(|c| c.kinovi_credits), Some(30));
assert_eq!(costs.total_cost.kinovi_credits, 170);
assert_eq!(costs.total_cost.usd_cents_rounded_up, 70);
assert_eq!(costs.total_cost.usd_cents_rounded_down, 69);
assert!((costs.total_cost.usd_cents_fractional - (17000.0 / 243.0)).abs() < 1e-9);
}
/// The base and surcharge parts each carry their own USD conversions.
#[test]
fn parts_have_their_own_usd_conversions() {
let costs = with_video_ref(r720(5)).calculate_costs();
// Base: 140 credits; 14000/243 = 57.61.
assert_eq!(costs.base_cost.usd_cents_rounded_up, 58);
assert_eq!(costs.base_cost.usd_cents_rounded_down, 57);
assert!((costs.base_cost.usd_cents_fractional - (14000.0 / 243.0)).abs() < 1e-9);
// Surcharge: 30 credits; 3000/243 = 12.35.
let surcharge = costs.video_reference_surcharge_cost.expect("should have surcharge");
assert_eq!(surcharge.kinovi_credits, 30);
assert_eq!(surcharge.usd_cents_rounded_up, 13);
assert_eq!(surcharge.usd_cents_rounded_down, 12);
assert!((surcharge.usd_cents_fractional - (3000.0 / 243.0)).abs() < 1e-9);
}
#[test]
fn empty_video_reference_list_has_no_surcharge() {
let mut request = r720(5);
request.reference_video_urls = Some(vec![]);
assert_eq!(request.calculate_costs().kinovi_credits, 140);
let costs = request.calculate_costs();
assert_eq!(costs.total_cost.kinovi_credits, 140);
assert!(costs.video_reference_surcharge_cost.is_none());
}
/// Surcharge is flat per generation regardless of how many reference
@@ -485,7 +656,7 @@ mod tests {
"https://example.com/a.mp4".to_string(),
"https://example.com/b.mp4".to_string(),
]);
assert_eq!(request.calculate_costs().kinovi_credits, 170);
assert_eq!(request.calculate_costs().total_cost.kinovi_credits, 170);
}
/// The surcharge applies per generated video, so batches multiply it.
@@ -493,7 +664,10 @@ mod tests {
fn batch_multiplies_surcharge() {
let request = with_video_ref(build_request(5, None, Some(KinoviSeedance2p0FastBatchCount::Two)));
// (140 base + 30 surcharge) × 2 = 340 credits
assert_eq!(request.calculate_costs().kinovi_credits, 340);
let costs = request.calculate_costs();
assert_eq!(costs.base_cost.kinovi_credits, 280);
assert_eq!(costs.video_reference_surcharge_cost.map(|c| c.kinovi_credits), Some(60));
assert_eq!(costs.total_cost.kinovi_credits, 340);
}
}
@@ -501,7 +675,7 @@ mod tests {
#[test]
fn aspect_ratio_does_not_affect_credits() {
let baseline = r720(5).calculate_costs().kinovi_credits;
let baseline = r720(5).calculate_costs().total_cost.kinovi_credits;
let ratios = [
KinoviSeedance2p0FastAspectRatio::Landscape16x9,
@@ -528,7 +702,7 @@ mod tests {
use_face_blur_hack: None,
};
assert_eq!(
req.calculate_costs().kinovi_credits, baseline,
req.calculate_costs().total_cost.kinovi_credits, baseline,
"Aspect ratio {:?} should not change credits from baseline {}", ar, baseline,
);
}
@@ -2,6 +2,7 @@
pub (crate) mod test_utils;
// User API
pub mod cost;
pub mod creds;
pub mod error;
pub mod generate;
@@ -1,4 +1,4 @@
use crate::generate::cost::KinoviGenerationCost;
use crate::cost::kinovi_generation_cost::KinoviGenerationCost;
use crate::creds::seedance2pro_session::Seedance2ProSession;
use crate::error::seedance2pro_bad_request_api_error::Seedance2ProBadRequestApiError;
use crate::error::seedance2pro_client_error::Seedance2ProClientError;
@@ -22,7 +22,7 @@ const CREDITS_PER_MIDJOURNEY_TASK: u32 = 12;
/// Newer Kinovi credit-package rate. Used to convert credits to USD for the
/// Midjourney image generation flow.
///
/// 22,000 credits / $114 = 192.98 ≈ 193 credits per dollar.
/// 500,000 credits / $2,159.09 = 231.58 ≈ 243 credits per dollar (rounded down).
/// Kinovi only currently exposes a single Midjourney resolution preset.
const MIDJOURNEY_RESOLUTION: &str = "1k";
@@ -416,20 +416,26 @@ mod tests {
#[test]
fn usd_cents_batch_one() {
// 12 / 193 × 100 = 6.21¢ → 6¢
assert_eq!(make_request(KinoviMidjourneyBatchCount::One).calculate_costs().usd_cents_rounded_up, 7); // 1200/193 = 6.22 -> rounds UP
// 12/243 × 100 = 4.94¢ → 5¢
assert_eq!(make_request(KinoviMidjourneyBatchCount::One).calculate_costs().usd_cents_rounded_up, 5); // 1200/243 = 4.94 -> rounds UP
assert_eq!(make_request(KinoviMidjourneyBatchCount::One).calculate_costs().usd_cents_rounded_down, 4);
assert!((make_request(KinoviMidjourneyBatchCount::One).calculate_costs().usd_cents_fractional - (1200.0 / 243.0)).abs() < 1e-9);
}
#[test]
fn usd_cents_batch_two() {
// 24 / 193 × 100 = 12.43¢ → 12¢
assert_eq!(make_request(KinoviMidjourneyBatchCount::Two).calculate_costs().usd_cents_rounded_up, 13); // 2400/193 = 12.44 -> rounds UP
// 24/243 × 100 = 9.88¢ → 10¢
assert_eq!(make_request(KinoviMidjourneyBatchCount::Two).calculate_costs().usd_cents_rounded_up, 10); // 2400/243 = 9.88 -> rounds UP
assert_eq!(make_request(KinoviMidjourneyBatchCount::Two).calculate_costs().usd_cents_rounded_down, 9);
assert!((make_request(KinoviMidjourneyBatchCount::Two).calculate_costs().usd_cents_fractional - (2400.0 / 243.0)).abs() < 1e-9);
}
#[test]
fn usd_cents_batch_four() {
// 48 / 193 × 100 = 24.87¢ → 25¢
assert_eq!(make_request(KinoviMidjourneyBatchCount::Four).calculate_costs().usd_cents_rounded_up, 25); // 4800/193 = 24.87 -> rounds UP
// 48/243 × 100 = 19.75¢ → 20¢
assert_eq!(make_request(KinoviMidjourneyBatchCount::Four).calculate_costs().usd_cents_rounded_up, 20); // 4800/243 = 19.75 -> rounds UP
assert_eq!(make_request(KinoviMidjourneyBatchCount::Four).calculate_costs().usd_cents_rounded_down, 19);
assert!((make_request(KinoviMidjourneyBatchCount::Four).calculate_costs().usd_cents_fractional - (4800.0 / 243.0)).abs() < 1e-9);
}
/// Batch 4 should be exactly 4× the credit cost of batch 1, and the USD
@@ -141,7 +141,7 @@ impl KinoviGenerateVideoRequest {
/// Credits per dollar for billing conversion.
///
/// Legacy 720p pricing uses the original Kinovi credit package rates.
/// All other model/resolution combos use the newer rate: 22,000 credits / $114.
/// All other model/resolution combos use the newer rate: 525,000 credits / $2,159.0909.
fn credits_per_dollar(&self) -> f64 {
match (self.model_type, self.output_resolution) {
// Legacy: Seedance 2.0 Pro @ 720p — 25,000 credits for $99.99
@@ -152,8 +152,8 @@ impl KinoviGenerateVideoRequest {
(KinoviModelType::Seedance2Fast, None)
| (KinoviModelType::Seedance2Fast, Some(KinoviOutputResolution::SevenTwentyP)) => 220.0,
// New pricing: 22,000 credits for $114 (~192.98 credits/$1)
_ => 193.0,
// New pricing: 525,000 credits for $2,159.0909 (~243.16 credits/$1)
_ => 243.0,
}
}
@@ -669,7 +669,7 @@ mod tests {
#[test]
fn usd_cents_legacy_fast_720p() {
// 220 credits/$1: 140 credits = 63.6 → 64¢
// 220 credits/$1: 140 credits = 57.61 → 58¢
assert_eq!(fast(5, KinoviBatchCount::One).estimate_cost_in_usd_cents(), 64);
assert_eq!(fast(10, KinoviBatchCount::One).estimate_cost_in_usd_cents(), 127);
}
@@ -678,24 +678,24 @@ mod tests {
#[test]
fn usd_cents_new_pro_480p() {
// 192.98 credits/$1: 75 credits = 38.86 → 39¢
assert_eq!(pro_res(5, KinoviBatchCount::One, KinoviOutputResolution::FourEightyP).estimate_cost_in_usd_cents(), 39);
assert_eq!(pro_res(10, KinoviBatchCount::One, KinoviOutputResolution::FourEightyP).estimate_cost_in_usd_cents(), 78);
// 243 credits/$1: 75 credits = 30.86 → 31¢
assert_eq!(pro_res(5, KinoviBatchCount::One, KinoviOutputResolution::FourEightyP).estimate_cost_in_usd_cents(), 31);
assert_eq!(pro_res(10, KinoviBatchCount::One, KinoviOutputResolution::FourEightyP).estimate_cost_in_usd_cents(), 62);
}
#[test]
fn usd_cents_new_pro_1080p() {
// 192.98 credits/$1: 450 credits = 233.18 → 233¢
assert_eq!(pro_res(5, KinoviBatchCount::One, KinoviOutputResolution::TenEightyP).estimate_cost_in_usd_cents(), 233);
// 900 credits = 466.36 → 466¢
assert_eq!(pro_res(10, KinoviBatchCount::One, KinoviOutputResolution::TenEightyP).estimate_cost_in_usd_cents(), 466);
// 243 credits/$1: 450 credits = 185.19 → 185¢
assert_eq!(pro_res(5, KinoviBatchCount::One, KinoviOutputResolution::TenEightyP).estimate_cost_in_usd_cents(), 185);
// 900 credits = 370.37 → 370¢
assert_eq!(pro_res(10, KinoviBatchCount::One, KinoviOutputResolution::TenEightyP).estimate_cost_in_usd_cents(), 370);
}
#[test]
fn usd_cents_new_fast_480p() {
// 192.98 credits/$1: 50 credits = 25.91 → 26¢
assert_eq!(fast_res(5, KinoviBatchCount::One, KinoviOutputResolution::FourEightyP).estimate_cost_in_usd_cents(), 26);
assert_eq!(fast_res(10, KinoviBatchCount::One, KinoviOutputResolution::FourEightyP).estimate_cost_in_usd_cents(), 52);
// 243 credits/$1: 50 credits = 20.58 → 21¢
assert_eq!(fast_res(5, KinoviBatchCount::One, KinoviOutputResolution::FourEightyP).estimate_cost_in_usd_cents(), 21);
assert_eq!(fast_res(10, KinoviBatchCount::One, KinoviOutputResolution::FourEightyP).estimate_cost_in_usd_cents(), 41);
}
// ── credits_per_dollar: verify the rates directly ──
@@ -711,11 +711,11 @@ mod tests {
#[test]
fn credits_per_dollar_new_rate() {
assert_eq!(pro_res(5, KinoviBatchCount::One, KinoviOutputResolution::FourEightyP).credits_per_dollar(), 193.0);
assert_eq!(pro_res(5, KinoviBatchCount::One, KinoviOutputResolution::TenEightyP).credits_per_dollar(), 193.0);
assert_eq!(fast_res(5, KinoviBatchCount::One, KinoviOutputResolution::FourEightyP).credits_per_dollar(), 193.0);
assert_eq!(pro_res(5, KinoviBatchCount::One, KinoviOutputResolution::FourEightyP).credits_per_dollar(), 243.0);
assert_eq!(pro_res(5, KinoviBatchCount::One, KinoviOutputResolution::TenEightyP).credits_per_dollar(), 243.0);
assert_eq!(fast_res(5, KinoviBatchCount::One, KinoviOutputResolution::FourEightyP).credits_per_dollar(), 243.0);
// Fast 1080p (not officially supported) also uses new rate
assert_eq!(fast_res(5, KinoviBatchCount::One, KinoviOutputResolution::TenEightyP).credits_per_dollar(), 193.0);
assert_eq!(fast_res(5, KinoviBatchCount::One, KinoviOutputResolution::TenEightyP).credits_per_dollar(), 243.0);
}
}
@@ -150,7 +150,7 @@ impl WorkflowRunTaskRequest {
/// Credits per dollar for billing conversion.
///
/// Legacy 720p pricing uses the original Kinovi credit package rates.
/// All other model/resolution combos use the newer rate: 22,000 credits / $114.
/// All other model/resolution combos use the newer rate: 525,000 credits / $2,159.0909.
fn credits_per_dollar(&self) -> f64 {
match (self.model_type, self.output_resolution) {
// Legacy: Seedance 2.0 Pro @ 720p — 25,000 credits for $99.99
@@ -161,8 +161,8 @@ impl WorkflowRunTaskRequest {
(KinoviModelTypeRaw::Seedance2Fast, None)
| (KinoviModelTypeRaw::Seedance2Fast, Some(KinoviOutputResolutionRaw::SevenTwentyP)) => 220.0,
// New pricing: 22,000 credits for $114 (~192.98 credits/$1)
_ => 193.0,
// New pricing: 525,000 credits for $2,159.0909 (~243.16 credits/$1)
_ => 243.0,
}
}
@@ -661,20 +661,20 @@ mod tests {
#[test]
fn usd_cents_new_pro_480p() {
assert_eq!(pro_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::FourEightyP).estimate_cost_in_usd_cents(), 39);
assert_eq!(pro_res(10, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::FourEightyP).estimate_cost_in_usd_cents(), 78);
assert_eq!(pro_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::FourEightyP).estimate_cost_in_usd_cents(), 31);
assert_eq!(pro_res(10, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::FourEightyP).estimate_cost_in_usd_cents(), 62);
}
#[test]
fn usd_cents_new_pro_1080p() {
assert_eq!(pro_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::TenEightyP).estimate_cost_in_usd_cents(), 233);
assert_eq!(pro_res(10, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::TenEightyP).estimate_cost_in_usd_cents(), 466);
assert_eq!(pro_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::TenEightyP).estimate_cost_in_usd_cents(), 185);
assert_eq!(pro_res(10, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::TenEightyP).estimate_cost_in_usd_cents(), 370);
}
#[test]
fn usd_cents_new_fast_480p() {
assert_eq!(fast_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::FourEightyP).estimate_cost_in_usd_cents(), 26);
assert_eq!(fast_res(10, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::FourEightyP).estimate_cost_in_usd_cents(), 52);
assert_eq!(fast_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::FourEightyP).estimate_cost_in_usd_cents(), 21);
assert_eq!(fast_res(10, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::FourEightyP).estimate_cost_in_usd_cents(), 41);
}
#[test]
@@ -687,10 +687,10 @@ mod tests {
#[test]
fn credits_per_dollar_new_rate() {
assert_eq!(pro_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::FourEightyP).credits_per_dollar(), 193.0);
assert_eq!(pro_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::TenEightyP).credits_per_dollar(), 193.0);
assert_eq!(fast_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::FourEightyP).credits_per_dollar(), 193.0);
assert_eq!(fast_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::TenEightyP).credits_per_dollar(), 193.0);
assert_eq!(pro_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::FourEightyP).credits_per_dollar(), 243.0);
assert_eq!(pro_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::TenEightyP).credits_per_dollar(), 243.0);
assert_eq!(fast_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::FourEightyP).credits_per_dollar(), 243.0);
assert_eq!(fast_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::TenEightyP).credits_per_dollar(), 243.0);
}
// ── Happy Horse 1.0 ──
@@ -724,17 +724,17 @@ mod tests {
#[test]
fn happy_horse_uses_new_pricing_rate() {
// All Happy Horse resolutions use the new 193 credits/$1 rate (not legacy)
assert_eq!(happy_horse(5, KinoviBatchCountRaw::One).credits_per_dollar(), 193.0);
assert_eq!(happy_horse_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::SevenTwentyP).credits_per_dollar(), 193.0);
assert_eq!(happy_horse_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::FourEightyP).credits_per_dollar(), 193.0);
assert_eq!(happy_horse_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::TenEightyP).credits_per_dollar(), 193.0);
// All Happy Horse resolutions use the new 243 credits/$1 rate (not legacy)
assert_eq!(happy_horse(5, KinoviBatchCountRaw::One).credits_per_dollar(), 243.0);
assert_eq!(happy_horse_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::SevenTwentyP).credits_per_dollar(), 243.0);
assert_eq!(happy_horse_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::FourEightyP).credits_per_dollar(), 243.0);
assert_eq!(happy_horse_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::TenEightyP).credits_per_dollar(), 243.0);
}
#[test]
fn happy_horse_usd_cents_1080p() {
// 193 credits/$1: 360 credits (4s×90) = 186.53 → 187¢
assert_eq!(happy_horse_res(4, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::TenEightyP).estimate_cost_in_usd_cents(), 187);
// 243 credits/$1: 360 credits (4s×90) = 148.15 → 148¢
assert_eq!(happy_horse_res(4, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::TenEightyP).estimate_cost_in_usd_cents(), 148);
}
}
+1 -1
View File
@@ -19,7 +19,7 @@ name = "artcraft_app_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2.1.1", features = [] }
tauri-build = { version = "2.6.2", features = [] }
[dependencies]
# Workspace hack (faster compile times)
+66 -1
View File
@@ -65,7 +65,13 @@ fn do_build_cors_config(is_production: bool) -> Cors {
}
// Remaining setup
cors.allowed_methods(vec!["GET", "POST", "OPTIONS", "DELETE"])
//
// NB: actix-cors 0.7 flipped the `block_on_origin_mismatch` default to
// false (mismatched origins get a 200 without CORS headers and the browser
// enforces the block). We keep the long-standing 0.6 behavior of rejecting
// mismatched origins server-side with a 400.
cors.block_on_origin_mismatch(true)
.allowed_methods(vec!["GET", "POST", "PUT", "OPTIONS", "DELETE"])
.supports_credentials()
.allowed_headers(vec![
actix_http::header::ACCEPT,
@@ -82,11 +88,70 @@ fn do_build_cors_config(is_production: bool) -> Cors {
mod tests {
use server_environment::ServerEnvironment;
use crate::testing::assert_no_origin_header_ok;
use crate::testing::assert_origin_invalid;
use crate::testing::assert_origin_ok;
use crate::testing::assert_preflight_method_invalid;
use crate::testing::assert_preflight_method_ok;
use super::build_cors_config;
/// Every production origin that hosts a product (or IS a backend that
/// serves same-origin pages like swagger) must be allowed. This is the
/// guard against `block_on_origin_mismatch(true)` locking out a real
/// frontend after a CORS-crate upgrade.
#[actix_rt::test]
async fn test_production_product_and_backend_origins_are_allowed() {
let production_cors = build_cors_config(ServerEnvironment::Production);
let origins = [
// Backends (same-origin browser requests still carry an Origin header).
"https://api.fakeyou.com",
"https://api.storyteller.ai",
// Products
"https://fakeyou.com",
"https://storyteller.ai",
"https://app.getartcraft.com",
"https://getartcraft.com",
"https://www.getartcraft.com",
"https://artcraft.ai",
"https://www.artcraft.ai",
"https://artcraft-dashboard.netlify.app",
];
for origin in origins {
assert_origin_ok(&production_cors, origin).await;
}
}
/// Non-browser clients (curl, python API clients, server-to-server) send
/// no Origin header and must never be blocked, even with
/// `block_on_origin_mismatch(true)`.
#[actix_rt::test]
async fn test_requests_without_origin_header_are_not_blocked() {
let production_cors = build_cors_config(ServerEnvironment::Production);
assert_no_origin_header_ok(&production_cors).await;
}
#[actix_rt::test]
async fn test_preflight_allows_every_supported_method() {
let production_cors = build_cors_config(ServerEnvironment::Production);
// PUT is used by e.g. the folders routes (rename/star/color_code/cover_image).
for method in ["GET", "POST", "PUT", "DELETE"] {
assert_preflight_method_ok(&production_cors, "https://app.getartcraft.com", method).await;
assert_preflight_method_ok(&production_cors, "https://storyteller.ai", method).await;
}
}
#[actix_rt::test]
async fn test_preflight_rejects_unsupported_methods() {
let production_cors = build_cors_config(ServerEnvironment::Production);
for method in ["PATCH", "TRACE", "CONNECT"] {
assert_preflight_method_invalid(&production_cors, "https://app.getartcraft.com", method).await;
}
}
#[actix_rt::test]
async fn test_fakeyou_production() {
let production_cors = build_cors_config(ServerEnvironment::Production);
@@ -2,6 +2,7 @@ use actix_cors::Cors;
use actix_http::body::{BoxBody, EitherBody};
use actix_http::StatusCode;
use actix_web::dev::{ServiceResponse, Transform};
use actix_web::http::Method;
use actix_web::test;
use actix_web::test::TestRequest;
use speculoos::asserting;
@@ -20,6 +21,39 @@ pub (crate) async fn assert_origin_invalid(cors: &Cors, hostname: &str) {
.is_equal_to(StatusCode::BAD_REQUEST);
}
/// Assert that a CORS preflight (OPTIONS + Access-Control-Request-Method)
/// for the given method is accepted from the given origin.
pub (crate) async fn assert_preflight_method_ok(cors: &Cors, hostname: &str, method: &str) {
let response = make_preflight_request(cors, hostname, method).await;
asserting(&format!("Preflight for {} from {} is allowed", method, hostname))
.that(&response.status())
.is_equal_to(StatusCode::OK);
}
/// Assert that a CORS preflight for the given method is rejected.
pub (crate) async fn assert_preflight_method_invalid(cors: &Cors, hostname: &str, method: &str) {
let response = make_preflight_request(cors, hostname, method).await;
asserting(&format!("Preflight for {} from {} is rejected", method, hostname))
.that(&response.status())
.is_equal_to(StatusCode::BAD_REQUEST);
}
/// Assert that a request WITHOUT an Origin header (curl, server-to-server,
/// native API clients) is never blocked by CORS, regardless of
/// `block_on_origin_mismatch`.
pub (crate) async fn assert_no_origin_header_ok(cors: &Cors) {
let cors = cors.new_transform(test::ok_service())
.await
.unwrap();
let request = TestRequest::default().to_srv_request();
let response = test::call_service(&cors, request).await;
asserting("Requests without an Origin header are not blocked")
.that(&response.status())
.is_equal_to(StatusCode::OK);
}
async fn make_test_request(cors: &Cors, hostname: &str) -> ServiceResponse<EitherBody<BoxBody>> {
let cors= cors.new_transform(test::ok_service())
.await
@@ -31,3 +65,21 @@ async fn make_test_request(cors: &Cors, hostname: &str) -> ServiceResponse<Eithe
test::call_service(&cors, request).await
}
async fn make_preflight_request(
cors: &Cors,
hostname: &str,
method: &str,
) -> ServiceResponse<EitherBody<BoxBody>> {
let cors = cors.new_transform(test::ok_service())
.await
.unwrap();
let request = TestRequest::default()
.method(Method::OPTIONS)
.insert_header(("Origin", hostname))
.insert_header(("Access-Control-Request-Method", method))
.to_srv_request();
test::call_service(&cors, request).await
}
@@ -84,9 +84,8 @@ pub struct SessionUserRecord {
}
impl SessionUserRecord {
// TODO(bt, 2022-12-20): Convert all users of the bare record to using `UserToken`, then get rid of this method.
pub fn get_strongly_typed_user_token(&self) -> UserToken {
self.user_token.clone()
pub fn get_user_token(&self) -> &UserToken {
&self.user_token
}
pub fn is_mod(&self) -> bool {
@@ -1,8 +1,7 @@
use chrono::{DateTime, Utc};
use enums::common::payments_namespace::PaymentsNamespace;
use errors::AnyhowResult;
use sqlx::pool::PoolConnection;
use sqlx::MySql;
use sqlx::{Executor, MySql};
pub struct ActiveUserSubscription {
pub user_token: String,
@@ -19,10 +18,12 @@ pub struct ActiveUserSubscription {
pub subscription_expires_at: DateTime<Utc>,
}
pub async fn list_active_user_subscriptions(
mysql_connection: &mut PoolConnection<MySql>,
pub async fn list_active_user_subscriptions<'e, 'c : 'e, E>(
mysql_executor: E,
user_token: &str
) -> AnyhowResult<Vec<ActiveUserSubscription>> {
) -> AnyhowResult<Vec<ActiveUserSubscription>>
where E: 'e + Executor<'c, Database = MySql>
{
// NB: "status=incomplete" subscriptions can happen when a user submits credit card info,
// but the CRC is wrong. Eg. what happened with ftx on 2022.11.18.
//
@@ -45,7 +46,7 @@ WHERE
"#,
user_token,
)
.fetch_all(&mut **mysql_connection)
.fetch_all(mysql_executor)
.await?;
let records = records.into_iter()
@@ -162,12 +162,10 @@ tempfile.workspace = true
# https://github.com/durch/rust-s3/pull/254
rust-s3.workspace = true
actix.workspace = true
actix-http.workspace = true
actix-multipart.workspace = true
actix-service.workspace = true
actix-web.workspace = true
actix-web-actors.workspace = true
actix-web-lab.workspace = true
# Crypto
@@ -1,15 +1,36 @@
use std::time::Duration;
use log::info;
use errors::AnyhowResult;
use shared_env_var_config::mysql::env_get_mysql_connection_string_or_default;
use sqlx::mysql::MySqlPoolOptions;
use sqlx::MySqlPool;
pub async fn connect_to_database() -> AnyhowResult<MySqlPool>{
pub async fn connect_to_database() -> AnyhowResult<MySqlPool> {
let db_connection_string = env_get_mysql_connection_string_or_default();
let pool_options = MySqlPoolOptions::new()
.max_connections(easyenv::get_env_num("MYSQL_MAX_CONNECTIONS", 5)?);
// NB: The managed DB's connection ceiling is far above what we use, so the binding
// constraint is this per-pod pool. Keep `replicas * MYSQL_MAX_CONNECTIONS` (plus the
// background jobs' small pools) comfortably under the server's `max_connections`.
let max_connections = easyenv::get_env_num("MYSQL_MAX_CONNECTIONS", 20)?;
let min_connections = easyenv::get_env_num("MYSQL_MIN_CONNECTIONS", 0)?;
// TODO(bt,2024-09-17): Configure logging, etc. after we've upgraded sqlx
// NB: Fail fast under pool saturation rather than stacking up on sqlx's 30s default — a
// shorter timeout sheds load and surfaces the problem instead of hiding it behind long waits.
let acquire_timeout_seconds = easyenv::get_env_num::<u64>("MYSQL_ACQUIRE_TIMEOUT_SECONDS", 10)?;
// NB: Log the settings before building/connecting so a connect failure has context. We do NOT
// log the connection string (it contains credentials).
info!(
"Building MySQL pool: max_connections={}, min_connections={}, acquire_timeout_seconds={}",
max_connections, min_connections, acquire_timeout_seconds,
);
let pool_options = MySqlPoolOptions::new()
.max_connections(max_connections)
.min_connections(min_connections)
.acquire_timeout(Duration::from_secs(acquire_timeout_seconds));
let pool = pool_options
.connect(&db_connection_string)
@@ -2,7 +2,6 @@ use std::fmt::{Display, Formatter};
use std::sync::Arc;
use crate::http_server::session::session_checker_error::SessionCheckerError;
use crate::http_server::web_utils::user_session::require_user_session::RequireUserSessionError;
use actix_artcraft::sessions::anonymous_visitor_tracking::avt_cookie_payload_error::AvtCookiePayloadError;
use actix_artcraft::sessions::user_sessions::http_user_session_payload_error::HttpUserSessionPayloadError;
use actix_http::StatusCode;
@@ -19,7 +18,7 @@ use serde::Serialize;
/// ```ignore
/// pub async fn my_handler(...) -> Result<Json<MyResponse>, CommonWebError> {
/// // sqlx errors, anyhow errors, session errors — all convert via ?
/// let user = require_user_session_using_connection(&req, ...)?;
/// let user = require_user_session(&req, ...)?;
/// let data = some_db_query(&pool).await?;
///
/// if data.is_none() {
@@ -317,15 +316,6 @@ impl From<serde_json::Error> for CommonWebError {
}
}
impl From<RequireUserSessionError> for CommonWebError {
fn from(value: RequireUserSessionError) -> Self {
match value {
RequireUserSessionError::NotAuthorized => Self::NotAuthorized,
RequireUserSessionError::ServerError => Self::from_error(value),
}
}
}
impl From<HttpUserSessionPayloadError> for CommonWebError {
fn from(err: HttpUserSessionPayloadError) -> Self {
if err.is_server_error() {
@@ -545,22 +535,6 @@ mod tests {
assert!(source.is_some());
}
#[test]
fn require_user_session_not_authorized_maps_to_401() {
let error: CommonWebError = RequireUserSessionError::NotAuthorized.into();
assert_eq!(error.status_code(), StatusCode::UNAUTHORIZED);
assert!(!error.is_server_error());
}
#[test]
fn require_user_session_server_error_wraps_cause() {
let error: CommonWebError = RequireUserSessionError::ServerError.into();
assert_eq!(error.status_code(), StatusCode::INTERNAL_SERVER_ERROR);
assert!(error.is_server_error());
assert!(error.cause().is_some());
assert!(format!("{}", error.cause().unwrap()).contains("ServerError"));
}
#[test]
fn serde_json_error_converts_to_500() {
let bad_json = "not json at all{{{";
@@ -120,7 +120,8 @@ pub async fn create_scene_handler(
let ip_address = get_request_ip(&http_request);
let maybe_user_token = maybe_user_session
.map(|session| session.get_strongly_typed_user_token());
.as_ref()
.map(|session| session.get_user_token());
// ==================== FILE DATA ==================== //
@@ -166,7 +167,7 @@ pub async fn create_scene_handler(
let (token, record_id) = insert_media_file_from_file_upload(InsertMediaFileFromUploadArgs {
maybe_media_class: Some(MediaFileClass::Dimensional),
media_file_type: MediaFileType::SceneRon,
maybe_creator_user_token: maybe_user_token.as_ref(),
maybe_creator_user_token: maybe_user_token,
maybe_creator_anonymous_visitor_token: maybe_avt_token.as_ref(),
creator_ip_address: &ip_address,
creator_set_visibility: Visibility::Public,
@@ -30,7 +30,7 @@ pub async fn drain_multipart_request(mut multipart_payload: Multipart) -> Anyhow
let mut field_name = None;
let mut field_filename = None;
if let content_disposition = field.content_disposition() {
if let Some(content_disposition) = field.content_disposition() {
field_name = content_disposition.get_name()
.map(|s| s.to_string());
field_filename = content_disposition.get_filename() // NB: Only used for the file bytes.
@@ -127,7 +127,8 @@ pub async fn process_upload_media_file(
let ip_address = get_request_ip(&http_request);
let maybe_user_token = maybe_user_session
.map(|session| session.get_strongly_typed_user_token());
.as_ref()
.map(|session| session.get_user_token());
let maybe_file_size_bytes = upload_media_request.file_bytes
.as_ref()
@@ -325,7 +326,7 @@ pub async fn process_upload_media_file(
let (token, record_id) = insert_media_file_from_file_upload(InsertMediaFileFromUploadArgs {
maybe_media_class: None,
media_file_type,
maybe_creator_user_token: maybe_user_token.as_ref(),
maybe_creator_user_token: maybe_user_token,
maybe_creator_anonymous_visitor_token: maybe_avt_token.as_ref(),
creator_ip_address: &ip_address,
creator_set_visibility,
@@ -36,7 +36,7 @@ pub async fn drain_multipart_request(mut multipart_payload: Multipart) -> Anyhow
let mut field_name = None;
let mut field_filename = None;
if let content_disposition = field.content_disposition() {
if let Some(content_disposition) = field.content_disposition() {
field_name = content_disposition.get_name()
.map(|s| s.to_string());
field_filename = content_disposition.get_filename() // NB: Only used for the file bytes.
@@ -129,7 +129,8 @@ pub async fn handle_upload(
let ip_address = get_request_ip(&http_request);
let maybe_user_token = maybe_user_session
.map(|session| session.get_strongly_typed_user_token());
.as_ref()
.map(|session| session.get_user_token());
let maybe_file_size_bytes = upload_media_request.file_bytes
.as_ref()
@@ -300,7 +301,7 @@ pub async fn handle_upload(
checksum_sha2: &hash,
public_upload_path: &public_upload_path,
maybe_extra_file_modification_info: None,
maybe_creator_user_token: maybe_user_token.as_ref(),
maybe_creator_user_token: maybe_user_token,
maybe_creator_anonymous_visitor_token: None,
creator_ip_address: &ip_address,
creator_set_visibility,
@@ -317,12 +318,12 @@ pub async fn handle_upload(
let firehose_result = match media_upload_source {
MediaUploadSource::DeviceApi => {
server_state.firehose_publisher.publish_device_media_recorded(
maybe_user_token.as_ref(),
maybe_user_token,
&token).await
}
_ => {
server_state.firehose_publisher.publish_media_uploaded(
maybe_user_token.as_ref(),
maybe_user_token,
&token).await
}
};
@@ -28,8 +28,7 @@ use crate::http_server::requests::request_headers::get_routing_tag_header::get_r
use crate::http_server::requests::request_headers::has_debug_header::has_debug_header;
use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format;
use crate::http_server::web_utils::response_error_helpers::to_simple_json_error;
use crate::http_server::web_utils::user_session::require_user_session::RequireUserSessionError;
use crate::http_server::web_utils::user_session::require_user_session_extended_using_connection::require_user_session_extended_using_connection;
use crate::http_server::web_utils::user_session::require_user_session_extended::require_user_session_extended;
use crate::state::server_state::ServerState;
use crate::util::cleaners::empty_media_file_token_to_null::empty_media_file_token_to_null;
@@ -114,15 +113,10 @@ pub async fn enqueue_face_fusion_workflow_handler(
let maybe_avt_token = server_state.avt_cookie_manager
.get_avt_token_from_request(&http_request);
let user_session = require_user_session_extended_using_connection(
let user_session = require_user_session_extended(
&http_request,
&server_state.session_checker,
&mut mysql_connection)
.await
.map_err(|err| match err {
RequireUserSessionError::ServerError => CommonWebError::from_error(err),
RequireUserSessionError::NotAuthorized => CommonWebError::NotAuthorized,
})?;
&mut *mysql_connection).await?;
// ==================== PAID PLAN + PRIORITY ==================== //
@@ -29,8 +29,7 @@ use crate::http_server::requests::request_headers::get_routing_tag_header::get_r
use crate::http_server::requests::request_headers::has_debug_header::has_debug_header;
use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format;
use crate::http_server::web_utils::response_error_helpers::to_simple_json_error;
use crate::http_server::web_utils::user_session::require_user_session::RequireUserSessionError;
use crate::http_server::web_utils::user_session::require_user_session_extended_using_connection::require_user_session_extended_using_connection;
use crate::http_server::web_utils::user_session::require_user_session_extended::require_user_session_extended;
use crate::state::server_state::ServerState;
use crate::util::cleaners::empty_media_file_token_to_null::empty_media_file_token_to_null;
@@ -122,15 +121,10 @@ pub async fn enqueue_live_portrait_workflow_handler(
let maybe_avt_token = server_state.avt_cookie_manager
.get_avt_token_from_request(&http_request);
let user_session = require_user_session_extended_using_connection(
let user_session = require_user_session_extended(
&http_request,
&server_state.session_checker,
&mut mysql_connection)
.await
.map_err(|err| match err {
RequireUserSessionError::ServerError => CommonWebError::from_error(err),
RequireUserSessionError::NotAuthorized => CommonWebError::NotAuthorized,
})?;
&mut *mysql_connection).await?;
// ==================== PAID PLAN + PRIORITY ==================== //
@@ -1,272 +0,0 @@
use std::fmt;
use std::sync::Arc;
use std::time::Instant;
use actix::*;
use actix_web::error::ResponseError;
use actix_web::http::StatusCode;
use actix_web::web::{Json, Path};
use actix_web::{
web, App, Error, HttpRequest, HttpResponse, HttpServer, Responder,
};
use actix_web_actors::ws;
use chrono::{DateTime, Utc};
use log::error;
use redis::{Commands, RedisResult};
use utoipa::ToSchema;
use web::Data;
use enums::by_table::generic_inference_jobs::frontend_failure_category::FrontendFailureCategory;
use enums::common::job_status_plus::JobStatusPlus;
use mysql_queries::queries::generic_inference::web::get_inference_job_status::get_inference_job_status;
use mysql_queries::queries::generic_inference::web::job_status::GenericInferenceJobStatus;
use redis_schema::keys::inference_job::style_transfer_progress_key::StyleTransferProgressKey;
use redis_schema::payloads::inference_job::style_transfer_progress_state::InferenceProgressDetailsResponse;
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use crate::http_server::deprecated_endpoints::workflows::enqueue::{progress_tracker_server, progress_tracker_session};
use crate::http_server::endpoints::inference_job::utils::estimates::estimate_job_progress::estimate_job_progress;
use crate::http_server::endpoints::inference_job::utils::extractors::extract_polymorphic_inference_args::extract_polymorphic_inference_args;
use crate::http_server::web_utils::filter_model_name::maybe_filter_model_name;
use crate::http_server::web_utils::response_error_helpers::to_simple_json_error;
use crate::state::server_state::ServerState;
/// For certain jobs or job classes (eg. non-premium), we kill the jobs if the user hasn't
/// maintained a keepalive. This prevents wasted work when users who are unlikely to return
/// navigate away. Premium users have accounts and can always return to the site, so they
/// typically do not require keepalive.
const JOB_KEEPALIVE_TTL_SECONDS : usize = 60 * 3;
/// For the URL PathInfo
#[derive(Deserialize, ToSchema)]
pub struct GetInferenceJobStatusPathInfo {
token: InferenceJobToken,
}
#[derive(Serialize, ToSchema)]
pub struct GetInferenceJobStatusSuccessResponse {
pub success: bool,
pub state: InferenceJobStatusResponsePayload,
}
#[derive(Serialize, ToSchema)]
pub struct InferenceJobStatusResponsePayload {
pub job_token: InferenceJobToken,
pub request: RequestDetailsResponse,
pub status: StatusDetailsResponse,
pub maybe_result: Option<InferenceProgressDetailsResponse>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
/// Details about what the user requested for generation
#[derive(Serialize, ToSchema)]
pub struct RequestDetailsResponse {
}
/// Details about the ongoing job status
#[derive(Serialize, ToSchema)]
pub struct StatusDetailsResponse {
/// Primary status from the database (a state machine).
pub status: JobStatusPlus,
/// Extra, temporary status from Redis.
/// This can denote inference progress, and the Python code can write to it.
pub maybe_extra_status_description: Option<String>,
pub maybe_assigned_worker: Option<String>,
pub maybe_assigned_cluster: Option<String>,
pub maybe_first_started_at: Option<DateTime<Utc>>,
pub attempt_count: u8,
/// Whether the frontend needs to maintain a keepalive check.
/// This is typically only for non-premium users.
pub requires_keepalive: bool,
/// An enum the frontend can use to display localized/I18N error
/// messages. These pertain to both transient and permanent failures.
pub maybe_failure_category: Option<FrontendFailureCategory>,
/// This is an integer number between 0 and 100 (both inclusive) that
/// reports the completeness.
pub progress_percentage: u8,
}
// #[derive(Serialize, ToSchema)]
// pub struct InferenceStageDetails {
// pub stage_progress: u8,
// pub expected_frame_count: u8,
// pub stage_complete: bool,
// pub frames: Vec<Url>,
// }
/// Details about the completed result (if any)
// #[derive(Serialize, ToSchema)]
// pub struct InferenceProgressDetailsResponse {
// pub expected_stages: u8,
// pub currently_active_stage: u8,
// pub per_stage_frame_count: u8,
// pub stages: Vec<InferenceStageDetails>,
// }
#[derive(Debug, ToSchema)]
pub enum GetInferenceJobStatusError {
ServerError,
NotFound,
}
impl ResponseError for GetInferenceJobStatusError {
fn status_code(&self) -> StatusCode {
match *self {
GetInferenceJobStatusError::ServerError => StatusCode::INTERNAL_SERVER_ERROR,
GetInferenceJobStatusError::NotFound => StatusCode::NOT_FOUND,
}
}
fn error_response(&self) -> HttpResponse {
let error_reason = match self {
Self::ServerError => "server error".to_string(),
Self::NotFound => "not found".to_string(),
};
to_simple_json_error(&error_reason, self.status_code())
}
}
// NB: Not using derive_more::Display since Clion doesn't understand it.
impl fmt::Display for GetInferenceJobStatusError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{:?}", self)
}
}
pub async fn get_inference_preview_status_handler_ws(
http_request: HttpRequest,
path: Path<GetInferenceJobStatusPathInfo>,
server_state: Data<Arc<ServerState>>) -> Result<Json<GetInferenceJobStatusSuccessResponse>, GetInferenceJobStatusError>
{
if path.token.as_str().trim() == "None" {
// NB: A bunch of Python clients use our API and can fail in this manner.
// This was a large traffic driver during the 2023-03-08 outage.
// Presumably it's this client: https://github.com/shards-7/fakeyou.py
return Err(GetInferenceJobStatusError::NotFound);
}
// NB: Since this is publicly exposed, we don't query sensitive data.
let maybe_status = get_inference_job_status(
&path.token,
&server_state.mysql_pool
).await;
let record = match maybe_status {
Ok(Some(record)) => record,
Ok(None) => return Err(GetInferenceJobStatusError::NotFound),
Err(err) => {
error!("tts job query error: {:?}", err);
return Err(GetInferenceJobStatusError::ServerError);
}
};
let mut redis = server_state.redis_pool
.get()
.map_err(|e| {
error!("redis error: {:?}", e);
GetInferenceJobStatusError::ServerError
})?;
let progress_key = StyleTransferProgressKey::new_for_job_id(record.job_token.clone());
let progress_value: RedisResult<Option<String>> = redis.get(progress_key.to_string());
let maybe_result = match progress_value {
Ok(Some(value)) => {
let progress_details: InferenceProgressDetailsResponse = serde_json::from_str(&value)
.map_err(|e| {
error!("redis error: {:?}", e);
GetInferenceJobStatusError::ServerError
})?;
Some(progress_details)
},
Ok(None) => None,
Err(e) => {
error!("redis error: {:?}", e);
return Err(GetInferenceJobStatusError::ServerError);
}
};
Ok(Json(GetInferenceJobStatusSuccessResponse {
success: true,
state: record_to_payload(record, maybe_result, None),
}))
}
fn record_to_payload(
record: GenericInferenceJobStatus,
progress_details: Option<InferenceProgressDetailsResponse>,
maybe_extra_status_description: Option<String>,
) -> InferenceJobStatusResponsePayload {
let inference_category = record.request_details.inference_category;
// NB: Fail open. We don't want to fail the request if we can't extract the args.
let maybe_polymorphic_args = extract_polymorphic_inference_args(&record)
.ok()
.flatten();
let progress_percentage = estimate_job_progress(&record, maybe_polymorphic_args.as_ref());
InferenceJobStatusResponsePayload {
job_token: record.job_token,
request: RequestDetailsResponse {
// inference_category: record.request_details.inference_category,
// maybe_model_type: maybe_filter_model_name(record.request_details.maybe_model_type.as_deref()),
// maybe_model_token: record.request_details.maybe_model_token,
// maybe_model_title: record.request_details.maybe_model_title,
// maybe_raw_inference_text: record.request_details.maybe_raw_inference_text,
// maybe_style_name: record.request_details.maybe_style_name,
},
status: StatusDetailsResponse {
status: record.status,
maybe_extra_status_description,
maybe_assigned_worker: maybe_filter_model_name(record.maybe_assigned_worker.as_deref()),
maybe_assigned_cluster: record.maybe_assigned_cluster,
maybe_first_started_at: record.maybe_first_started_at,
attempt_count: record.attempt_count as u8,
requires_keepalive: record.is_keepalive_required,
maybe_failure_category: record.maybe_frontend_failure_category,
progress_percentage,
},
maybe_result: progress_details,
created_at: record.created_at,
updated_at: record.updated_at,
}
}
pub struct ProgressTrackerState {
}
pub async fn ws_progress_route(
req: HttpRequest,
stream: web::Payload,
srv: web::Data<Addr<progress_tracker_server::ChatServer>>,
) -> Result<HttpResponse, Error> {
ws::start(
progress_tracker_session::WsChatSession {
id: 0,
hb: Instant::now(),
room: "main".to_owned(),
name: None,
addr: srv.get_ref().clone(),
},
&req,
stream,
)
}
@@ -3,7 +3,4 @@ pub mod enqueue_live_portrait_workflow_handler;
pub mod enqueue_studio_workflow_handler;
pub mod enqueue_video_style_transfer_workflow_handler;
pub mod get_inference_preview_status;
pub mod get_inference_preview_status_ws;
pub mod progress_tracker_server;
pub mod progress_tracker_session;
pub mod vst_common;
@@ -1,216 +0,0 @@
//! `ChatServer` is an actor. It maintains a list of connection client sessions.
//! It also manages available rooms. Peers send messages to other peers in same
//! room through `ChatServer`.
use std::{
collections::{HashMap, HashSet},
sync::{
atomic::{AtomicUsize, Ordering},
Arc,
},
};
use actix::prelude::*;
use log::info;
use rand::{rngs::ThreadRng, Rng};
/// Chat server sends this messages to session
#[derive(Message)]
#[rtype(result = "()")]
pub struct Message(pub String);
/// Message for chat server communications
/// New chat session is created
#[derive(Message)]
#[rtype(usize)]
pub struct Connect {
pub addr: Recipient<Message>,
}
/// Session is disconnected
#[derive(Message)]
#[rtype(result = "()")]
pub struct Disconnect {
pub id: usize,
}
/// Send message to specific room
#[derive(Message)]
#[rtype(result = "()")]
pub struct ClientMessage {
/// Id of the client session
pub id: usize,
/// Peer message
pub msg: String,
/// Room name
pub room: String,
}
/// List of available rooms
pub struct ListRooms;
impl actix::Message for ListRooms {
type Result = Vec<String>;
}
/// Join room, if room does not exists create new one.
#[derive(Message)]
#[rtype(result = "()")]
pub struct Join {
/// Client ID
pub id: usize,
/// Room name
pub name: String,
}
/// `ChatServer` manages chat rooms and responsible for coordinating chat session.
///
/// Implementation is very naïve.
#[derive(Debug)]
pub struct ChatServer {
sessions: HashMap<usize, Recipient<Message>>,
rooms: HashMap<String, HashSet<usize>>,
rng: ThreadRng,
visitor_count: Arc<AtomicUsize>,
}
impl ChatServer {
pub fn new(visitor_count: Arc<AtomicUsize>) -> ChatServer {
// default room
let mut rooms = HashMap::new();
rooms.insert("main".to_owned(), HashSet::new());
ChatServer {
sessions: HashMap::new(),
rooms,
rng: rand::thread_rng(),
visitor_count,
}
}
}
impl ChatServer {
/// Send message to all users in the room
fn send_message(&self, room: &str, message: &str, skip_id: usize) {
if let Some(sessions) = self.rooms.get(room) {
for id in sessions {
if *id != skip_id {
if let Some(addr) = self.sessions.get(id) {
addr.do_send(Message(message.to_owned()));
}
}
}
}
}
}
/// Make actor from `ChatServer`
impl Actor for ChatServer {
/// We are going to use simple Context, we just need ability to communicate
/// with other actors.
type Context = Context<Self>;
}
/// Handler for Connect message.
///
/// Register new session and assign unique id to this session
impl Handler<Connect> for ChatServer {
type Result = usize;
fn handle(&mut self, msg: Connect, _: &mut Context<Self>) -> Self::Result {
info!("Someone joined");
// notify all users in same room
self.send_message("main", "Someone joined", 0);
// register session with random id
let id = self.rng.random::<u64>() as usize;
self.sessions.insert(id, msg.addr);
// auto join session to main room
self.rooms.entry("main".to_owned()).or_default().insert(id);
let count = self.visitor_count.fetch_add(1, Ordering::SeqCst);
self.send_message("main", &format!("Total visitors {count}"), 0);
// send id back
id
}
}
/// Handler for Disconnect message.
impl Handler<Disconnect> for ChatServer {
type Result = ();
fn handle(&mut self, msg: Disconnect, _: &mut Context<Self>) {
info!("Someone disconnected");
let mut rooms: Vec<String> = Vec::new();
// remove address
if self.sessions.remove(&msg.id).is_some() {
// remove session from all rooms
for (name, sessions) in &mut self.rooms {
if sessions.remove(&msg.id) {
rooms.push(name.to_owned());
}
}
}
// send message to other users
for room in rooms {
self.send_message(&room, "Someone disconnected", 0);
}
}
}
/// Handler for Message message.
impl Handler<ClientMessage> for ChatServer {
type Result = ();
fn handle(&mut self, msg: ClientMessage, _: &mut Context<Self>) {
self.send_message(&msg.room, msg.msg.as_str(), msg.id);
}
}
/// Handler for `ListRooms` message.
impl Handler<ListRooms> for ChatServer {
type Result = MessageResult<ListRooms>;
fn handle(&mut self, _: ListRooms, _: &mut Context<Self>) -> Self::Result {
let mut rooms = Vec::new();
for key in self.rooms.keys() {
rooms.push(key.to_owned())
}
MessageResult(rooms)
}
}
/// Join room, send disconnect message to old room
/// send join message to new room
impl Handler<Join> for ChatServer {
type Result = ();
fn handle(&mut self, msg: Join, _: &mut Context<Self>) {
let Join { id, name } = msg;
let mut rooms = Vec::new();
// remove session from all rooms
for (n, sessions) in &mut self.rooms {
if sessions.remove(&id) {
rooms.push(n.to_owned());
}
}
// send message to other users
for room in rooms {
self.send_message(&room, "Someone disconnected", 0);
}
self.rooms.entry(name.clone()).or_default().insert(id);
self.send_message(&name, "Someone connected", id);
}
}
@@ -1,205 +0,0 @@
use std::time::{Duration, Instant};
use actix::prelude::*;
use actix_web_actors::ws;
use log::{debug, info, warn};
use crate::http_server::deprecated_endpoints::workflows::enqueue::progress_tracker_server;
use crate::http_server::deprecated_endpoints::workflows::enqueue::progress_tracker_server::ChatServer;
/// How often heartbeat pings are sent
const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(5);
/// How long before lack of client response causes a timeout
const CLIENT_TIMEOUT: Duration = Duration::from_secs(10);
#[derive(Debug)]
pub struct WsChatSession {
/// unique session id
pub id: usize,
/// Client must send ping at least once per 10 seconds (CLIENT_TIMEOUT),
/// otherwise we drop connection.
pub hb: Instant,
/// joined room
pub room: String,
/// peer name
pub name: Option<String>,
/// Chat server
pub addr: Addr<ChatServer>,
}
impl WsChatSession {
/// helper method that sends ping to client every 5 seconds (HEARTBEAT_INTERVAL).
///
/// also this method checks heartbeats from client
fn hb(&self, ctx: &mut ws::WebsocketContext<Self>) {
ctx.run_interval(HEARTBEAT_INTERVAL, |act, ctx| {
// check client heartbeats
if Instant::now().duration_since(act.hb) > CLIENT_TIMEOUT {
// heartbeat timed out
warn!("Websocket Client heartbeat failed, disconnecting!");
// notify chat server
act.addr.do_send(progress_tracker_server::Disconnect { id: act.id });
// stop actor
ctx.stop();
// don't try to send a ping
return;
}
ctx.ping(b"");
});
}
}
impl Actor for WsChatSession {
type Context = ws::WebsocketContext<Self>;
/// Method is called on actor start.
/// We register ws session with ChatServer
fn started(&mut self, ctx: &mut Self::Context) {
// we'll start heartbeat process on session start.
self.hb(ctx);
// register self in chat server. `AsyncContext::wait` register
// future within context, but context waits until this future resolves
// before processing any other events.
// HttpContext::state() is instance of WsChatSessionState, state is shared
// across all routes within application
let addr = ctx.address();
self.addr
.send(progress_tracker_server::Connect {
addr: addr.recipient(),
})
.into_actor(self)
.then(|res, act, ctx| {
match res {
Ok(res) => act.id = res,
// something is wrong with chat server
_ => ctx.stop(),
}
fut::ready(())
})
.wait(ctx);
}
fn stopping(&mut self, _: &mut Self::Context) -> Running {
// notify chat server
self.addr.do_send(progress_tracker_server::Disconnect { id: self.id });
Running::Stop
}
}
/// Handle messages from chat server, we simply send it to peer websocket
impl Handler<progress_tracker_server::Message> for WsChatSession {
type Result = ();
fn handle(&mut self, msg: progress_tracker_server::Message, ctx: &mut Self::Context) {
ctx.text(msg.0);
}
}
/// WebSocket message handler
impl StreamHandler<Result<ws::Message, ws::ProtocolError>> for WsChatSession {
fn handle(&mut self, msg: Result<ws::Message, ws::ProtocolError>, ctx: &mut Self::Context) {
log::debug!("WEBSOCKET MESSAGE: {msg:?}");
let msg = match msg {
Err(_) => {
ctx.stop();
return;
}
Ok(msg) => msg,
};
log::debug!("WEBSOCKET MESSAGE: {msg:?}");
match msg {
ws::Message::Ping(msg) => {
self.hb = Instant::now();
ctx.pong(&msg);
}
ws::Message::Pong(_) => {
self.hb = Instant::now();
}
ws::Message::Text(text) => {
let m = text.trim();
// we check for /sss type of messages
if m.starts_with('/') {
let v: Vec<&str> = m.splitn(2, ' ').collect();
match v[0] {
"/list" => {
// Send ListRooms message to chat server and wait for
// response
debug!("List rooms");
self.addr
.send(progress_tracker_server::ListRooms)
.into_actor(self)
.then(|res, _, ctx| {
match res {
Ok(rooms) => {
for room in rooms {
ctx.text(room);
}
}
_ => warn!("Something is wrong"),
}
fut::ready(())
})
.wait(ctx)
// .wait(ctx) pauses all events in context,
// so actor wont receive any new messages until it get list
// of rooms back
}
"/join" => {
if v.len() == 2 {
v[1].clone_into(&mut self.room);
self.addr.do_send(progress_tracker_server::Join {
id: self.id,
name: self.room.clone(),
});
ctx.text("joined");
} else {
ctx.text("!!! room name is required");
}
}
"/name" => {
if v.len() == 2 {
self.name = Some(v[1].to_owned());
} else {
ctx.text("!!! name is required");
}
}
_ => ctx.text(format!("!!! unknown command: {m:?}")),
}
} else {
let msg = if let Some(ref name) = self.name {
format!("{name}: {m}")
} else {
m.to_owned()
};
// send message to chat server
self.addr.do_send(progress_tracker_server::ClientMessage {
id: self.id,
msg,
room: self.room.clone(),
})
}
}
ws::Message::Binary(_) => warn!("Unexpected binary"),
ws::Message::Close(reason) => {
ctx.close(reason);
ctx.stop();
}
ws::Message::Continuation(_) => {
ctx.stop();
}
ws::Message::Nop => (),
}
}
}
@@ -14,7 +14,7 @@ use mysql_queries::queries::users::user_profiles::get_user_profile_by_username::
use crate::http_server::web_utils::response_error_helpers::to_simple_json_error;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::web_utils::user_session::require_moderator::{require_moderator, RequireModeratorError, UseDatabase};
use crate::http_server::web_utils::user_session::require_moderator::require_moderator;
use crate::state::server_state::ServerState;
const MAXIMUM_KEYS : u32 = 100;
@@ -60,12 +60,7 @@ pub async fn create_beta_keys_handler(
server_state: web::Data<Arc<ServerState>>,
) -> Result<HttpResponse, CommonWebError>
{
let user_session = require_moderator(&http_request, &server_state, UseDatabase::GrabNewConnection)
.await
.map_err(|err| match err {
RequireModeratorError::ServerError => CommonWebError::from_error(err),
RequireModeratorError::NotAuthorized => CommonWebError::NotAuthorized,
})?;
let user_session = require_moderator(&http_request, &server_state.session_checker, &server_state.mysql_pool).await?;
let mut maybe_referrer_user_token = None;
@@ -14,7 +14,7 @@ use tokens::tokens::w2l_results::W2lResultToken;
use crate::http_server::web_utils::response_error_helpers::to_simple_json_error;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::web_utils::user_session::require_moderator::{require_moderator, RequireModeratorError, UseDatabase};
use crate::http_server::web_utils::user_session::require_moderator::require_moderator;
use crate::state::server_state::ServerState;
/// For the URL PathInfo
@@ -58,12 +58,7 @@ pub async fn edit_beta_key_distributed_flag_handler(
server_state: web::Data<Arc<ServerState>>,
) -> Result<HttpResponse, CommonWebError>
{
let user_session = require_moderator(&http_request, &server_state, UseDatabase::GrabNewConnection)
.await
.map_err(|err| match err {
RequireModeratorError::ServerError => CommonWebError::from_error(err),
RequireModeratorError::NotAuthorized => CommonWebError::NotAuthorized,
})?;
let user_session = require_moderator(&http_request, &server_state.session_checker, &server_state.mysql_pool).await?;
edit_beta_key_distributed_flag(&path.token, request.is_distributed, &server_state.mysql_pool)
.await
@@ -13,7 +13,7 @@ use tokens::tokens::beta_keys::BetaKeyToken;
use crate::http_server::web_utils::response_error_helpers::to_simple_json_error;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::web_utils::user_session::require_moderator::{require_moderator, RequireModeratorError, UseDatabase};
use crate::http_server::web_utils::user_session::require_moderator::require_moderator;
use crate::state::server_state::ServerState;
/// For the URL PathInfo
@@ -57,12 +57,7 @@ pub async fn edit_beta_key_note_handler(
server_state: web::Data<Arc<ServerState>>,
) -> Result<HttpResponse, CommonWebError>
{
let user_session = require_moderator(&http_request, &server_state, UseDatabase::GrabNewConnection)
.await
.map_err(|err| match err {
RequireModeratorError::ServerError => CommonWebError::from_error(err),
RequireModeratorError::NotAuthorized => CommonWebError::NotAuthorized,
})?;
let user_session = require_moderator(&http_request, &server_state.session_checker, &server_state.mysql_pool).await?;
let maybe_note = request.note.as_deref()
.map(|note| note.trim())
@@ -17,7 +17,7 @@ use tokens::tokens::beta_keys::BetaKeyToken;
use crate::http_server::common_responses::pagination_page::PaginationPage;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::common_responses::user_details_lite::UserDetailsLight;
use crate::http_server::web_utils::user_session::require_user_session::{require_user_session, RequireUserSessionError};
use crate::http_server::web_utils::user_session::require_user_session::require_user_session;
use crate::state::server_state::ServerState;
#[derive(Copy, Clone, Deserialize, ToSchema)]
@@ -108,12 +108,8 @@ pub async fn list_beta_keys_handler(
query: Query<ListBetaKeysQueryParams>,
server_state: web::Data<Arc<ServerState>>
) -> Result<HttpResponse, CommonWebError> {
let user_session = require_user_session(&http_request, &server_state)
.await
.map_err(|err| match err {
RequireUserSessionError::ServerError => CommonWebError::from_error(err),
RequireUserSessionError::NotAuthorized => CommonWebError::NotAuthorized,
})?;
let user_session = require_user_session(&http_request, &server_state.session_checker, &server_state.mysql_pool)
.await?;
let mut is_mod = user_session.can_ban_users;
@@ -52,7 +52,7 @@ pub async fn redeem_beta_key_handler(
server_state: web::Data<Arc<ServerState>>,
) -> Result<HttpResponse, CommonWebError>
{
let user_session = require_user_session(&http_request, &server_state).await?;
let user_session = require_user_session(&http_request, &server_state.session_checker, &server_state.mysql_pool).await?;
let rate_limiter = &server_state.redis_rate_limiters.logged_out;
@@ -10,7 +10,7 @@ use mysql_queries::queries::characters::delete_character::delete_character;
use mysql_queries::queries::characters::get_character_by_token_including_deleted::get_character_by_token_including_deleted;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::web_utils::user_session::require_user_session_using_connection::require_user_session_using_connection;
use crate::http_server::web_utils::user_session::require_user_session::require_user_session;
use crate::state::server_state::ServerState;
/// Delete a character.
@@ -40,11 +40,7 @@ pub async fn delete_character_handler(
let mut mysql_connection = server_state.mysql_pool.acquire().await?;
let user_session = require_user_session_using_connection(
&http_request,
&server_state.session_checker,
&mut mysql_connection,
).await?;
let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *mysql_connection).await?;
let user_token = &user_session.user_token;
let is_mod = user_session.is_mod();
@@ -11,7 +11,7 @@ use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
use seedance2pro_client::requests::update_character::update_character::{update_character, UpdateCharacterArgs};
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::web_utils::user_session::require_user_session_using_connection::require_user_session_using_connection;
use crate::http_server::web_utils::user_session::require_user_session::require_user_session;
use crate::state::server_state::ServerState;
/// Edit a character's name or description.
@@ -38,11 +38,7 @@ pub async fn edit_character_handler(
let mut mysql_connection = server_state.mysql_pool.acquire().await?;
let user_session = require_user_session_using_connection(
&http_request,
&server_state.session_checker,
&mut mysql_connection,
).await?;
let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *mysql_connection).await?;
let user_token = &user_session.user_token;
let is_mod = user_session.is_mod();
@@ -16,7 +16,7 @@ use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::common_responses::media::media_domain::MediaDomain;
use crate::http_server::common_responses::media::media_links_builder::MediaLinksBuilder;
use crate::http_server::endpoints::media_files::helpers::get_media_domain::get_media_domain;
use crate::http_server::web_utils::user_session::require_user_session_using_connection::require_user_session_using_connection;
use crate::http_server::web_utils::user_session::require_user_session::require_user_session;
use crate::state::server_state::ServerState;
/// List characters for the current session.
@@ -39,11 +39,7 @@ pub async fn list_characters_handler(
let mut mysql_connection = server_state.mysql_pool.acquire().await?;
let user_session = require_user_session_using_connection(
&http_request,
&server_state.session_checker,
&mut mysql_connection,
).await?;
let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *mysql_connection).await?;
let user_token = &user_session.user_token;
@@ -17,7 +17,7 @@ use mysql_queries::queries::folders::folder::update_folder_color_code::{
use tokens::tokens::folders::FolderToken;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::web_utils::user_session::require_user_session_using_connection::require_user_session_using_connection;
use crate::http_server::web_utils::user_session::require_user_session::require_user_session;
use crate::state::server_state::ServerState;
const MAX_COLOR_CODE_LEN: usize = 16;
@@ -47,9 +47,7 @@ pub async fn color_code_folder_handler(
CommonWebError::from_error(err)
})?;
let user_session = require_user_session_using_connection(
&http_request, &server_state.session_checker, &mut conn,
).await.map_err(|_| CommonWebError::NotAuthorized)?;
let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *conn).await?;
// Normalize: trim, treat empty after trim as `None`. This lets clients
// clear by passing `""` or by omitting the field entirely.
@@ -23,7 +23,7 @@ use tokens::tokens::folders::FolderToken;
use tokens::tokens::media_files::MediaFileToken;
use tokens::tokens::users::UserToken;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::web_utils::user_session::require_user_session_using_connection::require_user_session_using_connection;
use crate::http_server::web_utils::user_session::require_user_session::require_user_session;
use crate::state::server_state::ServerState;
/// Set or clear the custom cover image on a folder.
@@ -64,9 +64,7 @@ pub async fn cover_image_folder_handler(
CommonWebError::from_error(err)
})?;
let user_session = require_user_session_using_connection(
&http_request, &server_state.session_checker, &mut conn,
).await.map_err(|_| CommonWebError::NotAuthorized)?;
let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *conn).await?;
// Confirm the folder exists + is owned. Authoritative 404 source.
get_folder_for_owner(GetFolderForOwnerArgs {
@@ -17,7 +17,7 @@ use crate::http_server::endpoints::folders::folder::folder_info_conversion::{
build_folder_thumbnails_lookup, folder_row_to_info,
};
use crate::http_server::endpoints::media_files::helpers::get_media_domain::get_media_domain;
use crate::http_server::web_utils::user_session::require_user_session_using_connection::require_user_session_using_connection;
use crate::http_server::web_utils::user_session::require_user_session::require_user_session;
use crate::state::server_state::ServerState;
const MAX_NAME_LEN: usize = 255;
@@ -45,9 +45,7 @@ pub async fn create_folder_handler(
CommonWebError::from_error(err)
})?;
let user_session = require_user_session_using_connection(
&http_request, &server_state.session_checker, &mut conn,
).await.map_err(|_| CommonWebError::NotAuthorized)?;
let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *conn).await?;
let name = request.name.trim().to_string();
@@ -12,7 +12,7 @@ use mysql_queries::queries::folders::folder::soft_delete_folder::{
use tokens::tokens::folders::FolderToken;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::web_utils::user_session::require_user_session_using_connection::require_user_session_using_connection;
use crate::http_server::web_utils::user_session::require_user_session::require_user_session;
use crate::state::server_state::ServerState;
/// Soft-delete a folder. Children retain their parent pointer and become
@@ -39,9 +39,7 @@ pub async fn delete_folder_handler(
CommonWebError::from_error(err)
})?;
let user_session = require_user_session_using_connection(
&http_request, &server_state.session_checker, &mut conn,
).await.map_err(|_| CommonWebError::NotAuthorized)?;
let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *conn).await?;
let rows_affected = soft_delete_folder(SoftDeleteFolderArgs {
@@ -16,7 +16,7 @@ use crate::http_server::endpoints::folders::folder::folder_info_conversion::{
build_folder_thumbnails_lookup, folder_row_to_info,
};
use crate::http_server::endpoints::media_files::helpers::get_media_domain::get_media_domain;
use crate::http_server::web_utils::user_session::require_user_session_using_connection::require_user_session_using_connection;
use crate::http_server::web_utils::user_session::require_user_session::require_user_session;
use crate::state::server_state::ServerState;
/// Fetch a single folder owned by the logged-in user.
@@ -42,9 +42,7 @@ pub async fn get_folder_handler(
CommonWebError::from_error(err)
})?;
let user_session = require_user_session_using_connection(
&http_request, &server_state.session_checker, &mut conn,
).await.map_err(|_| CommonWebError::NotAuthorized)?;
let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *conn).await?;
let row = get_folder_for_owner(GetFolderForOwnerArgs {
folder_token: &path.folder_token,
@@ -17,7 +17,7 @@ use crate::http_server::endpoints::folders::folder::folder_info_conversion::{
build_folder_thumbnails_lookup, folder_row_to_info,
};
use crate::http_server::endpoints::media_files::helpers::get_media_domain::get_media_domain;
use crate::http_server::web_utils::user_session::require_user_session_using_connection::require_user_session_using_connection;
use crate::http_server::web_utils::user_session::require_user_session::require_user_session;
use crate::state::server_state::ServerState;
const CURSOR_NAME: &str = "folders_all";
@@ -48,9 +48,7 @@ pub async fn list_folders_handler(
CommonWebError::from_error(err)
})?;
let user_session = require_user_session_using_connection(
&http_request, &server_state.session_checker, &mut conn,
).await.map_err(|_| CommonWebError::NotAuthorized)?;
let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *conn).await?;
let limit = query.limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT);
@@ -17,7 +17,7 @@ use mysql_queries::queries::folders::folder::update_folder_name::{
use tokens::tokens::folders::FolderToken;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::web_utils::user_session::require_user_session_using_connection::require_user_session_using_connection;
use crate::http_server::web_utils::user_session::require_user_session::require_user_session;
use crate::state::server_state::ServerState;
const MAX_NAME_LEN: usize = 255;
@@ -47,9 +47,7 @@ pub async fn rename_folder_handler(
CommonWebError::from_error(err)
})?;
let user_session = require_user_session_using_connection(
&http_request, &server_state.session_checker, &mut conn,
).await.map_err(|_| CommonWebError::NotAuthorized)?;
let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *conn).await?;
let new_name = request.new_name.trim().to_string();
@@ -17,7 +17,7 @@ use mysql_queries::queries::folders::folder::update_folder_has_star::{
use tokens::tokens::folders::FolderToken;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::web_utils::user_session::require_user_session_using_connection::require_user_session_using_connection;
use crate::http_server::web_utils::user_session::require_user_session::require_user_session;
use crate::state::server_state::ServerState;
#[utoipa::path(
@@ -44,9 +44,7 @@ pub async fn star_folder_handler(
CommonWebError::from_error(err)
})?;
let user_session = require_user_session_using_connection(
&http_request, &server_state.session_checker, &mut conn,
).await.map_err(|_| CommonWebError::NotAuthorized)?;
let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *conn).await?;
// Confirm the folder exists + is owned before issuing the update.
// A 404 here is the authoritative "not found" signal — the update
@@ -27,7 +27,7 @@ use mysql_queries::queries::folders::media_files::recompute_folder_last_media_fi
use tokens::tokens::folders::FolderToken;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::web_utils::user_session::require_user_session_using_connection::require_user_session_using_connection;
use crate::http_server::web_utils::user_session::require_user_session::require_user_session;
use crate::state::server_state::ServerState;
const MAX_BULK: usize = 500;
@@ -61,9 +61,7 @@ pub async fn bulk_add_folder_media_files_handler(
CommonWebError::from_error(err)
})?;
let user_session = require_user_session_using_connection(
&http_request, &server_state.session_checker, &mut conn,
).await.map_err(|_| CommonWebError::NotAuthorized)?;
let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *conn).await?;
if request.media_file_tokens.len() > MAX_BULK {
return Err(CommonWebError::BadInputWithSimpleMessage(
@@ -30,7 +30,7 @@ use mysql_queries::queries::folders::media_files::recompute_folder_last_media_fi
use tokens::tokens::folders::FolderToken;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::web_utils::user_session::require_user_session_using_connection::require_user_session_using_connection;
use crate::http_server::web_utils::user_session::require_user_session::require_user_session;
use crate::state::server_state::ServerState;
const MAX_BULK: usize = 500;
@@ -72,9 +72,7 @@ pub async fn bulk_move_folder_media_files_handler(
CommonWebError::from_error(err)
})?;
let user_session = require_user_session_using_connection(
&http_request, &server_state.session_checker, &mut conn,
).await.map_err(|_| CommonWebError::NotAuthorized)?;
let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *conn).await?;
if request.media_file_tokens.len() > MAX_BULK {
return Err(CommonWebError::BadInputWithSimpleMessage(
@@ -24,7 +24,7 @@ use tokens::tokens::folders::FolderToken;
use tokens::tokens::media_files::MediaFileToken;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::web_utils::user_session::require_user_session_using_connection::require_user_session_using_connection;
use crate::http_server::web_utils::user_session::require_user_session::require_user_session;
use crate::state::server_state::ServerState;
const MAX_BULK: usize = 500;
@@ -56,9 +56,7 @@ pub async fn bulk_remove_folder_media_files_handler(
CommonWebError::from_error(err)
})?;
let user_session = require_user_session_using_connection(
&http_request, &server_state.session_checker, &mut conn,
).await.map_err(|_| CommonWebError::NotAuthorized)?;
let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *conn).await?;
if request.media_file_tokens.len() > MAX_BULK {
return Err(CommonWebError::BadInputWithSimpleMessage(
@@ -32,7 +32,7 @@ use crate::http_server::common_responses::media::media_domain::MediaDomain;
use crate::http_server::common_responses::media::media_file_cover_image_details::MediaFileCoverImageDetails;
use crate::http_server::common_responses::media::media_links_builder::MediaLinksBuilder;
use crate::http_server::endpoints::media_files::helpers::get_media_domain::get_media_domain;
use crate::http_server::web_utils::user_session::require_user_session_using_connection::require_user_session_using_connection;
use crate::http_server::web_utils::user_session::require_user_session::require_user_session;
use crate::state::server_state::ServerState;
const CURSOR_NAME: &str = "folder_mf";
@@ -127,9 +127,7 @@ pub async fn list_folder_media_files_handler(
CommonWebError::from_error(err)
})?;
let user_session = require_user_session_using_connection(
&http_request, &server_state.session_checker, &mut conn,
).await.map_err(|_| CommonWebError::NotAuthorized)?;
let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *conn).await?;
let folder = get_folder_for_owner(GetFolderForOwnerArgs {
folder_token: &path.folder_token,
@@ -25,7 +25,7 @@ use mysql_queries::queries::folders::subfolder::filter_existing_owned_folder_tok
use tokens::tokens::folders::FolderToken;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::web_utils::user_session::require_user_session_using_connection::require_user_session_using_connection;
use crate::http_server::web_utils::user_session::require_user_session::require_user_session;
use crate::state::server_state::ServerState;
const MAX_BULK: usize = 500;
@@ -58,9 +58,7 @@ pub async fn bulk_add_subfolders_handler(
CommonWebError::from_error(err)
})?;
let user_session = require_user_session_using_connection(
&http_request, &server_state.session_checker, &mut conn,
).await.map_err(|_| CommonWebError::NotAuthorized)?;
let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *conn).await?;
if request.subfolder_tokens.len() > MAX_BULK {
return Err(CommonWebError::BadInputWithSimpleMessage(
@@ -14,7 +14,7 @@ use mysql_queries::queries::folders::subfolder::bulk_clear_parent_folder::{
use tokens::tokens::folders::FolderToken;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::web_utils::user_session::require_user_session_using_connection::require_user_session_using_connection;
use crate::http_server::web_utils::user_session::require_user_session::require_user_session;
use crate::state::server_state::ServerState;
const MAX_BULK: usize = 500;
@@ -45,9 +45,7 @@ pub async fn bulk_remove_subfolders_handler(
CommonWebError::from_error(err)
})?;
let user_session = require_user_session_using_connection(
&http_request, &server_state.session_checker, &mut conn,
).await.map_err(|_| CommonWebError::NotAuthorized)?;
let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *conn).await?;
if request.subfolder_tokens.len() > MAX_BULK {
return Err(CommonWebError::BadInputWithSimpleMessage(
@@ -21,7 +21,7 @@ use crate::http_server::endpoints::folders::folder::folder_info_conversion::{
build_folder_thumbnails_lookup, folder_row_to_info,
};
use crate::http_server::endpoints::media_files::helpers::get_media_domain::get_media_domain;
use crate::http_server::web_utils::user_session::require_user_session_using_connection::require_user_session_using_connection;
use crate::http_server::web_utils::user_session::require_user_session::require_user_session;
use crate::state::server_state::ServerState;
const CURSOR_NAME: &str = "subfolders";
@@ -55,9 +55,7 @@ pub async fn list_subfolders_handler(
CommonWebError::from_error(err)
})?;
let user_session = require_user_session_using_connection(
&http_request, &server_state.session_checker, &mut conn,
).await.map_err(|_| CommonWebError::NotAuthorized)?;
let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *conn).await?;
// Confirm the parent exists + is owned by the caller before listing.
@@ -119,7 +119,7 @@ pub async fn upload_snapshot_media_file_handler(
let maybe_user_token = maybe_user_session
.as_ref()
.map(|session| session.get_strongly_typed_user_token());
.map(|session| session.get_user_token());
let maybe_avt_token = server_state
.avt_cookie_manager
@@ -247,7 +247,7 @@ pub async fn upload_snapshot_media_file_handler(
let (token, record_id) = insert_media_file_from_file_upload(InsertMediaFileFromUploadArgs {
maybe_media_class: Some(MediaFileClass::Image),
media_file_type,
maybe_creator_user_token: maybe_user_token.as_ref(),
maybe_creator_user_token: maybe_user_token,
maybe_creator_anonymous_visitor_token: maybe_avt_token.as_ref(),
creator_ip_address: &ip_address,
creator_set_visibility,
@@ -8,7 +8,7 @@ use utoipa::ToSchema;
use mysql_queries::queries::generic_inference::web::dismiss_finished_jobs_for_user::dismiss_finished_jobs_for_user;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::web_utils::user_session::require_user_session_using_connection::require_user_session_using_connection;
use crate::http_server::web_utils::user_session::require_user_session::require_user_session;
use crate::state::server_state::ServerState;
#[derive(Serialize, ToSchema)]
@@ -40,8 +40,7 @@ pub async fn dismiss_finished_session_jobs_handler(
CommonWebError::from_error(e)
})?;
let user_session = require_user_session_using_connection(
&http_request, &server_state.session_checker, &mut mysql_connection)
let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *mysql_connection)
.await?;
dismiss_finished_jobs_for_user(&mut mysql_connection, &user_session.user_token)
@@ -56,12 +56,8 @@ pub async fn change_media_file_animation_type_handler(
server_state: web::Data<Arc<ServerState>>
) -> Result<Json<SimpleResponse>, CommonWebError> {
let user_session = require_user_session(&http_request, &server_state)
.await
.map_err(|e| {
warn!("Not authorized: {:?}", e);
CommonWebError::NotAuthorized
})?;
let user_session = require_user_session(&http_request, &server_state.session_checker, &server_state.mysql_pool)
.await?;
let media_file_token = path.token.clone();
let is_mod = user_session.is_mod();
@@ -53,12 +53,8 @@ pub async fn change_media_file_engine_category_handler(
server_state: web::Data<Arc<ServerState>>
) -> Result<Json<SimpleResponse>, CommonWebError> {
let user_session = require_user_session(&http_request, &server_state)
.await
.map_err(|e| {
warn!("Not authorized: {:?}", e);
CommonWebError::NotAuthorized
})?;
let user_session = require_user_session(&http_request, &server_state.session_checker, &server_state.mysql_pool)
.await?;
let media_file_token = path.token.clone();
let is_mod = user_session.is_mod();
@@ -135,7 +135,7 @@ pub async fn upload_audio_media_file_handler(
let maybe_user_token = maybe_user_session
.as_ref()
.map(|session| session.get_strongly_typed_user_token());
.map(|session| session.get_user_token());
let maybe_avt_token = server_state
.avt_cookie_manager
@@ -334,7 +334,7 @@ pub async fn upload_audio_media_file_handler(
let (token, record_id) = insert_media_file_from_file_upload(InsertMediaFileFromUploadArgs {
maybe_media_class: Some(MediaFileClass::Audio),
media_file_type: MediaFileType::Audio,
maybe_creator_user_token: maybe_user_token.as_ref(),
maybe_creator_user_token: maybe_user_token,
maybe_creator_anonymous_visitor_token: maybe_avt_token.as_ref(),
creator_ip_address: &ip_address,
creator_set_visibility,
@@ -47,7 +47,7 @@ pub async fn drain_multipart_request(mut multipart_payload: Multipart) -> Anyhow
let mut field_name = None;
let mut field_filename = None;
if let content_disposition = field.content_disposition() {
if let Some(content_disposition) = field.content_disposition() {
field_name = content_disposition.get_name()
.map(|s| s.to_string());
field_filename = content_disposition.get_filename() // NB: Only used for the file bytes.
@@ -140,7 +140,8 @@ pub async fn upload_engine_asset_media_file_handler(
let ip_address = get_request_ip(&http_request);
let maybe_user_token = maybe_user_session
.map(|session| session.get_strongly_typed_user_token());
.as_ref()
.map(|session| session.get_user_token());
// ==================== FILE DATA ==================== //
@@ -205,7 +206,7 @@ pub async fn upload_engine_asset_media_file_handler(
let (token, record_id) = insert_media_file_from_file_upload(InsertMediaFileFromUploadArgs {
maybe_media_class: Some(MediaFileClass::Dimensional),
media_file_type,
maybe_creator_user_token: maybe_user_token.as_ref(),
maybe_creator_user_token: maybe_user_token,
maybe_creator_anonymous_visitor_token: maybe_avt_token.as_ref(),
creator_ip_address: &ip_address,
creator_set_visibility,
@@ -47,7 +47,7 @@ pub async fn drain_multipart_request(mut multipart_payload: Multipart) -> Anyhow
let mut field_name = None;
let mut field_filename = None;
if let content_disposition = field.content_disposition() {
if let Some(content_disposition) = field.content_disposition() {
field_name = content_disposition.get_name()
.map(|s| s.to_string());
field_filename = content_disposition.get_filename() // NB: Only used for the file bytes.
@@ -142,7 +142,8 @@ pub async fn process_upload_media_file(
let ip_address = get_request_ip(&http_request);
let maybe_user_token = maybe_user_session
.map(|session| session.get_strongly_typed_user_token());
.as_ref()
.map(|session| session.get_user_token());
let maybe_file_size_bytes = upload_media_request.file_bytes
.as_ref()
@@ -392,7 +393,7 @@ pub async fn process_upload_media_file(
})?;
let media_token = MediaFileInsertBuilder::new()
.maybe_creator_user(maybe_user_token.as_ref())
.maybe_creator_user(maybe_user_token)
.maybe_creator_anonymous_visitor(maybe_avt_token.as_ref())
.creator_ip_address(&ip_address)
.public_bucket_directory_hash(&public_upload_path)
@@ -417,7 +418,7 @@ pub async fn process_upload_media_file(
//let (token, record_id) = insert_media_file_from_file_upload(InsertMediaFileFromUploadArgs {
// maybe_media_class: Some(media_file_class), // DONE
// media_file_type, // DONE
// maybe_creator_user_token: maybe_user_token.as_ref(), // DONE
// maybe_creator_user_token: maybe_user_token, // DONE
// maybe_creator_anonymous_visitor_token: maybe_avt_token.as_ref(), // DONE
// creator_ip_address: &ip_address, // DONE
// creator_set_visibility, // DONE
@@ -145,7 +145,7 @@ pub async fn upload_image_media_file_handler(
let maybe_user_token = maybe_user_session
.as_ref()
.map(|session| session.get_strongly_typed_user_token());
.map(|session| session.get_user_token());
let maybe_avt_token = server_state
.avt_cookie_manager
@@ -310,7 +310,7 @@ pub async fn upload_image_media_file_handler(
let (token, record_id) = insert_media_file_from_file_upload(InsertMediaFileFromUploadArgs {
maybe_media_class: Some(MediaFileClass::Image),
media_file_type,
maybe_creator_user_token: maybe_user_token.as_ref(),
maybe_creator_user_token: maybe_user_token,
maybe_creator_anonymous_visitor_token: maybe_avt_token.as_ref(),
creator_ip_address: &ip_address,
creator_set_visibility,
@@ -179,7 +179,7 @@ pub async fn upload_new_engine_asset_media_file_handler(
let maybe_user_token = maybe_user_session
.as_ref()
.map(|session| session.get_strongly_typed_user_token());
.map(|session| session.get_user_token());
// ==================== FILE DATA ==================== //
@@ -211,7 +211,7 @@ pub async fn upload_new_engine_asset_media_file_handler(
let (token, record_id) = insert_media_file_from_file_upload(InsertMediaFileFromUploadArgs {
maybe_media_class: Some(file_info.media_class),
media_file_type: file_info.media_type,
maybe_creator_user_token: maybe_user_token.as_ref(),
maybe_creator_user_token: maybe_user_token,
maybe_creator_anonymous_visitor_token: maybe_avt_token.as_ref(),
creator_ip_address: &ip_address,
creator_set_visibility: file_info.creator_set_visibility,
@@ -112,7 +112,7 @@ pub async fn upload_new_scene_media_file_handler(
let maybe_user_token = maybe_user_session
.as_ref()
.map(|session| session.get_strongly_typed_user_token());
.map(|session| session.get_user_token());
let maybe_avt_token = server_state
.avt_cookie_manager
@@ -226,7 +226,7 @@ pub async fn upload_new_scene_media_file_handler(
let (token, record_id) = insert_media_file_from_file_upload(InsertMediaFileFromUploadArgs {
maybe_media_class: Some(MediaFileClass::Dimensional),
media_file_type: MediaFileType::SceneJson,
maybe_creator_user_token: maybe_user_token.as_ref(),
maybe_creator_user_token: maybe_user_token,
maybe_creator_anonymous_visitor_token: maybe_avt_token.as_ref(),
creator_ip_address: &ip_address,
creator_set_visibility,
@@ -23,7 +23,8 @@ use tokens::tokens::media_files::MediaFileToken;
use crate::http_server::endpoints::media_files::upload::upload_error::MediaFileUploadError;
use crate::http_server::endpoints::media_files::upload::upload_pmx::extract_and_upload_pmx_files::{extract_and_upload_pmx_files, PmxError};
use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format;
use crate::http_server::web_utils::user_session::require_moderator::{require_moderator, RequireModeratorError, UseDatabase};
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::web_utils::user_session::require_moderator::require_moderator;
use crate::state::server_state::ServerState;
/// Form-multipart request fields.
@@ -115,11 +116,11 @@ pub async fn upload_pmx_media_file_handler(
// ==================== READ SESSION ==================== //
// NB: We require a moderator to upload PMX files.
let user_session = require_moderator(&http_request, &server_state, UseDatabase::FromPool(&mut mysql_connection))
let user_session = require_moderator(&http_request, &server_state.session_checker, &mut *mysql_connection)
.await
.map_err(|err| match err {
RequireModeratorError::ServerError => MediaFileUploadError::ServerError,
RequireModeratorError::NotAuthorized => MediaFileUploadError::NotAuthorized,
CommonWebError::NotAuthorized => MediaFileUploadError::NotAuthorized,
_ => MediaFileUploadError::ServerError,
})?;
let maybe_avt_token = server_state
@@ -206,7 +207,7 @@ pub async fn upload_pmx_media_file_handler(
let ip_address = get_request_ip(&http_request);
//let maybe_user_token = maybe_user_session
// .map(|session| session.get_strongly_typed_user_token());
// .map(|session| session.get_user_token());
// ==================== FILE DATA ==================== //
@@ -257,7 +258,7 @@ pub async fn upload_pmx_media_file_handler(
let (token, record_id) = insert_media_file_from_file_upload(InsertMediaFileFromUploadArgs {
maybe_media_class: Some(MediaFileClass::Dimensional),
media_file_type: MediaFileType::Pmx,
maybe_creator_user_token: Some(&user_session.get_strongly_typed_user_token()),
maybe_creator_user_token: Some(user_session.get_user_token()),
maybe_creator_anonymous_visitor_token: maybe_avt_token.as_ref(),
creator_ip_address: &ip_address,
creator_set_visibility,
@@ -109,7 +109,7 @@ pub async fn upload_saved_scene_media_file_handler(
let maybe_user_token = maybe_user_session
.as_ref()
.map(|session| session.get_strongly_typed_user_token());
.map(|session| session.get_user_token());
let maybe_avt_token = server_state
.avt_cookie_manager
@@ -148,7 +148,7 @@ pub async fn upload_saved_scene_media_file_handler(
let creator_check = check_creator_tokens(CheckCreatorTokenArgs {
maybe_creator_user_token: media_file.maybe_creator_user_token.as_ref(),
maybe_current_request_user_token: maybe_user_token.as_ref(),
maybe_current_request_user_token: maybe_user_token,
maybe_creator_anonymous_visitor_token: media_file.maybe_creator_anonymous_visitor_token.as_ref(),
maybe_current_request_anonymous_visitor_token: maybe_avt_token.as_ref(),
});
@@ -111,7 +111,7 @@ pub async fn upload_scene_snapshot_media_file_handler(
let maybe_user_token = maybe_user_session
.as_ref()
.map(|session| session.get_strongly_typed_user_token());
.map(|session| session.get_user_token());
let maybe_avt_token = server_state
.avt_cookie_manager
@@ -219,7 +219,7 @@ pub async fn upload_scene_snapshot_media_file_handler(
let (token, record_id) = insert_media_file_from_file_upload(InsertMediaFileFromUploadArgs {
maybe_media_class: Some(MediaFileClass::Dimensional),
media_file_type: MediaFileType::SceneJson,
maybe_creator_user_token: maybe_user_token.as_ref(),
maybe_creator_user_token: maybe_user_token,
maybe_creator_anonymous_visitor_token: maybe_avt_token.as_ref(),
creator_ip_address: &ip_address,
creator_set_visibility: Visibility::Hidden,
@@ -119,7 +119,7 @@ pub async fn upload_spz_media_file_handler(
let maybe_user_token = maybe_user_session
.as_ref()
.map(|session| session.get_strongly_typed_user_token());
.map(|session| session.get_user_token());
let maybe_avt_token = server_state
.avt_cookie_manager
@@ -255,7 +255,7 @@ pub async fn upload_spz_media_file_handler(
let media_token = MediaFileInsertBuilder::new()
.media_file_class(MediaFileClass::Dimensional)
.media_file_type(MediaFileType::Spz)
.maybe_creator_user(maybe_user_token.as_ref())
.maybe_creator_user(maybe_user_token)
.maybe_creator_anonymous_visitor(maybe_avt_token.as_ref())
.creator_ip_address(&ip_address)
.creator_set_visibility(creator_set_visibility)
@@ -19,13 +19,13 @@ use http_server_common::request::get_request_ip::get_request_ip;
use mysql_queries::queries::idepotency_tokens::insert_idempotency_token::insert_idempotency_token;
use mysql_queries::queries::media_files::create::insert_media_file_from_studio_scene_render::{insert_media_file_from_studio_scene_render, InsertStudioSceneRenderArgs};
use tokens::tokens::media_files::MediaFileToken;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::endpoints::media_files::upload::upload_error::MediaFileUploadError;
use crate::http_server::endpoints::media_files::upload::upload_studio_shot::extract_frames_from_zip::{extract_frames_from_zip, ExtractFramesError};
use crate::http_server::endpoints::media_files::upload::upload_studio_shot::ffmpeg_frames_to_mp4::ffmpeg_frames_to_mp4;
use crate::http_server::requests::request_headers::has_debug_header::has_debug_header;
use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format;
use crate::http_server::web_utils::user_session::require_user_session_extended_using_connection::require_user_session_extended_using_connection;
use crate::http_server::web_utils::user_session::require_user_session_extended::require_user_session_extended;
use crate::state::server_state::ServerState;
const DEFAULT_FPS : u8 = 12;
@@ -111,11 +111,14 @@ pub async fn upload_studio_shot_media_file_handler(
// ==================== READ SESSION ==================== //
// NB: We require a moderator to upload PMX files.
let user_session = require_user_session_extended_using_connection(&http_request, &server_state.session_checker, &mut mysql_connection)
let user_session = require_user_session_extended(&http_request, &server_state.session_checker, &mut *mysql_connection)
.await
.map_err(|e| {
error!("User session error: {:?}", e);
MediaFileUploadError::NotAuthorized
match e {
CommonWebError::NotAuthorized => MediaFileUploadError::NotAuthorized,
_ => MediaFileUploadError::ServerError,
}
})?;
let maybe_avt_token = server_state
@@ -176,7 +179,7 @@ pub async fn upload_studio_shot_media_file_handler(
let ip_address = get_request_ip(&http_request);
//let maybe_user_token = maybe_user_session
// .map(|session| session.get_strongly_typed_user_token());
// .map(|session| session.get_user_token());
// ==================== FILE DATA ==================== //
@@ -177,7 +177,7 @@ pub async fn upload_new_video_media_file_handler(
let maybe_user_token = maybe_user_session
.as_ref()
.map(|session| session.get_strongly_typed_user_token());
.map(|session| session.get_user_token());
let maybe_avt_token = server_state
.avt_cookie_manager
@@ -507,7 +507,7 @@ pub async fn upload_new_video_media_file_handler(
let (token, record_id) = insert_media_file_from_file_upload(InsertMediaFileFromUploadArgs {
maybe_media_class: Some(MediaFileClass::Video),
media_file_type: MediaFileType::Video,
maybe_creator_user_token: maybe_user_token.as_ref(),
maybe_creator_user_token: maybe_user_token,
maybe_creator_anonymous_visitor_token: maybe_avt_token.as_ref(),
creator_ip_address: &ip_address,
creator_set_visibility,

Some files were not shown because too many files have changed in this diff Show More