Compare commits

...
Author SHA1 Message Date
Brandon Thomas d5a2b36525 promo pricing 2026-09-18 12:09:19 -04:00
Brandon Thomas e3c553ff2c wan 3.0 client bindings 2026-09-18 12:00:58 -04:00
Brandon Thomas 094d33e405 Merge pull request #21 from storytold/bt-batch-counts
standardize batch counts
2026-09-18 09:46:32 -04:00
Brandon Thomas 6a35fccba3 standardize / cleanup video batch sizes 2026-09-18 09:45:53 -04:00
Brandon Thomas db925450c5 video batching 2026-09-18 09:36:01 -04:00
Brandon Thomas 90f1fcb25f prices 2026-09-18 09:17:00 -04:00
Brandon Thomas 0245058e05 tests 2026-09-18 09:12:50 -04:00
Brandon Thomas 5d311b7a20 increase batch counts 2026-09-18 09:07:06 -04:00
Brandon Thomas 4836854263 Merge pull request #20 from storytold/bt-rate-increase
seedance rates
2026-09-17 12:12:07 -04:00
Brandon Thomas 2a43d929ec update margins 2026-09-17 12:10:31 -04:00
Brandon Thomas 437bbadd0f seedance 1080p price is going up 2026-09-17 11:35:24 -04:00
Brandon Thomas d181ce5089 Merge pull request #19 from storytold/bt-fix-sound
don't turn off audio for seedance
2026-09-16 17:29:27 -04:00
Brandon Thomas dab29e1cc0 don't turn off audio for seedance 2026-09-16 17:27:24 -04:00
BFlat 3e6a02c29c Fix toggle sizing, soften hover states, and simplify selection marks (#18) 2026-09-16 23:49:13 +07:00
BFlat 16b87f3e07 Align editor, auth, create, and pricing UI with ArtCraft styling (#17) 2026-09-16 23:12:20 +07:00
Brandon Thomas fa56bb1c9c Merge pull request #16 from storytold/bt-fix-frontend
fix frontend for production
2026-09-16 10:37:41 -04:00
Brandon Thomas c7751251ac fix frontend for production 2026-09-16 10:36:52 -04:00
BFlat d2fc91f1f0 Pricing page fix webapp 2026-09-16 20:38:08 +07:00
BFlat 0379aa95dd Align webapp pricing page with the ArtCraft website design (#15)
* Align webapp pricing page with website design

* Clarify pricing headline to avoid implying unlimited usage

* Simplify pricing headline to Pick a plan
2026-09-16 20:30:29 +07:00
BFlat 6eb4f7707a Fix generation credit cost visibility on mobile (#14)
* Fix generation credit cost visibility on mobile

* Polish webapp styling, navigation, and media dialogs
2026-09-16 20:05:07 +07:00
BFlat 3ee521b2a5 Fix generation credit cost visibility on mobile (#13) 2026-09-16 18:00:37 +07:00
bflatastic 86367b6e33 hide demo section temporarily 2026-09-16 10:17:46 +07:00
Tanish Manku 730cf247f8 Merge pull request #12 from storytold/design/premium-website
Site redesign: Content tweaks and mobile improvements
2026-09-15 20:08:12 -07:00
Tanish Manku bbd9595352 Merge remote-tracking branch 'services/design/premium-website' into design/idea-hero-spiral 2026-09-15 19:46:00 -07:00
Tanish Manku 0386e2fd9f sections: seat half ticks on the hairline, lift above the frost
-top-[5px] centers the crossbar on the section's top rule (was 1px
high), and z-[31] lifts the marks over the frosted eyebrow band
(z-30), which was swallowing the right tick's downward stem.

Claude-Session: https://claude.ai/code/session_01YBoaBEt3gynzTwogBFtn4B
2026-09-15 19:44:20 -07:00
Tanish Manku 2a8a61697f sections: draw-up goes fully one-shot; top-edge ticks lose their upper arm
The whole frame (rule, eyebrow, ticks) now assembles once on first
entry and stays built — the scrub-linked reversal read as
inconsistent against the one-shot content. And the section-top
crosses become half ticks (.tick-half: crossbar + downward stem):
the full plus poked its upper arm above the boundary into the
frosted sticky eyebrow band.

Claude-Session: https://claude.ai/code/session_01YBoaBEt3gynzTwogBFtn4B
2026-09-15 19:42:48 -07:00
Tanish Manku 8c732ab38e sections: eyebrow reveal is one-shot — reversibility stays with the silent geometry
The eyebrow carries readable text and rides sticky with the reader,
so it must never fade back out under them. It now reveals once (same
tween as the cells) while the rule and ticks keep the scrubbed,
reversible draw. Verified: scroll back un-draws the rule to zero with
the eyebrow holding at full opacity.

Claude-Session: https://claude.ai/code/session_01YBoaBEt3gynzTwogBFtn4B
2026-09-15 19:39:35 -07:00
Tanish Manku 2912c8b608 sections: eyebrow top hairline restored; content reveals go per-cell
The sticky eyebrow band now owns BOTH hairlines (border-y): its top
edge was the section's drawn rule, which the frosted background
painted over at rest and which vanished entirely once the band stuck
mid-section. The band also fades as one unit with the draw
choreography — frost, rules, and contents together — coinciding with
the drawn rule at rest so they read as a single line.

Group content reveals move from one group-level trigger to per-CHILD
ScrollTriggers: the old single trigger fired when a tall grid's top
entered, revealing every cell below the fold unseen — so scrolled-to
content sat static while only the frames animated. Each cell now
assembles as it arrives, cells sharing a row cascading left to right
(delay derived from horizontal position), consistent with the drawn
frames at every scroll depth.

Claude-Session: https://claude.ai/code/session_01YBoaBEt3gynzTwogBFtn4B
2026-09-15 19:35:26 -07:00
Tanish Manku 5ec34e89ca sections: scrub-drawn frames, frosted sticky eyebrows, studio demo leads Features
Premium transition-in for the scrolled page, in the instrument voice:

- Structural draw-up, scrub-linked and reversible: as a section enters
  the viewport its top rule DRAWS across, the eyebrow's mono contents
  settle in, and the corner ticks pop — all tied to scroll position
  (scrub 0.35), so pulling back down un-draws the frame. Content
  reveals stay one-shot: text never wiggles under the reader. Rules
  render complete in server HTML; only motion-capable visitors get
  the choreography.

- Section eyebrows ("01 / Crafting features") are sticky while their
  section scrolls — frosted (backdrop blur over translucent page
  background) like the ruler rail, pinned under the navbar, pushed
  away by the next section rather than stacking.

- The studio demo moves out of the hero into Features as the lead
  cell: the announce strip becomes its header row and the
  blocking↔render comparator its 21:9 body (cursor hint as an
  invert chip over the footage, md+ only). The hero ends at the
  poster. Image to Location returns to the grid, which now splits
  evenly: seven feature cells plus a full-sized eighth cell — the
  launch CTA, a clickable door to the webapp with an arrow chip
  and the same header/body/copy anatomy as its siblings.

Verified in Firefox: rule at 52% mid-viewport and back to 0 on
scroll-back (reversible), eyebrow pinned at 48px deep into the
section, zero page errors.

Claude-Session: https://claude.ai/code/session_01YBoaBEt3gynzTwogBFtn4B
2026-09-15 19:23:49 -07:00
Tanish Manku 645e971b99 mobile: ruler off below the capability boundary; own spiral dial group
The scroll ruler is now truly absent on small screens — the
capability gate went live (media-query listeners instead of a
one-shot mount check) and the component renders nothing when
incapable: no rail element, no data-ruler stamp, native scrollbar,
no hero heading choreography. Resizing across the boundary
mounts/unmounts the instrument in place: a late mount comes up
settled instantly (an intro cascade firing on a window resize reads
as a glitch), and unmounting releases every inline style HeadingFlow
wrote on the hero wordmark letters so the masthead stands naturally.

The spiral's mobile profile graduates to its own tuner group (Galaxy
mobile): below the area threshold its dials replace the Galaxy-layout
values wholesale — arms, turns, card count (fixed, not scaled from
the desktop knob), outer radius, card height cap, birth radius,
density — so small screens get an independently tuned composition.

Verified in Firefox: phone shows no rail/attr and an untouched
wordmark; growing to 1500px mounts the settled instrument (ticks up,
HeadingFlow at identity rest pose); shrinking unmounts and restores.

Claude-Session: https://claude.ai/code/session_01YBoaBEt3gynzTwogBFtn4B
2026-09-15 19:06:22 -07:00
Tanish Manku 1764e4b590 galaxy: pulses ride the intro reveal wave; bake tuned defaults
The pulse comets are now choreographed into the intro: their
visibility is gated by the same trailing radial wave that uncovers
the cards (uWaveR/uWaveBand uniforms fed from the rollout clock), so
they appear rolling outward with the field instead of materializing
across the whole spiral at the instrument beat. Post-intro the wave
radius sits past every arm; the tuner replay rewinds it for free.

Baked the user's tuned values as defaults: cardN back to 64 (the
0.76 arm desync scatters it cleanly in slot units), guide lines 1×,
line 2px, pulses 0.72 alpha / 0.19 speed / 0.05 length / 3 per arm /
0.4 hue spread / 0.62 light-mode gray.

Claude-Session: https://claude.ai/code/session_01YBoaBEt3gynzTwogBFtn4B
2026-09-15 18:54:07 -07:00
Tanish Manku 75d8004d1b galaxy: light-mode pulses go colorless — dark gray + thickness only
Dark theme keeps the hue-spread accent comets. Light theme now drops
color entirely: pulses render as plain dark gray (ink mixed over the
paper by the Pulse gray knob, default 0.75) and read through the
ribbon thickness alone — colored trails fought the paper background
no matter how dark they sank. The hue spread is zeroed through the
same cross-fade that drives the color, so a theme toggle morphs
between the two treatments instead of snapping.

Claude-Session: https://claude.ai/code/session_01YBoaBEt3gynzTwogBFtn4B
2026-09-15 18:43:00 -07:00
Brandon Thomas f653987369 Merge pull request #11 from storytold/bt-quicktime-audio
quicktime audio
2026-09-15 19:41:39 -04:00
Tanish Manku 28762f51c5 galaxy: pulse ribbons — light-mode ink, thickness, comets, denser guides
Pulse feedback round. The arm guides move from GL lines (stuck at
1px) to thin ribbon meshes — each sample extrudes two vertices along
the curve normal and the vertex shader sets the width — so the
colored part can swell above the resting hairline (Pulse thick px,
default 3.5; base Line px 1.2).

Light theme sinks the pulse color toward ink (Pulse dark, default
0.65): bright accents washed out on the paper background; near-black
trails carry the needed contrast. Dark theme keeps the luminous
accent. The mix tracks background luminance, so theme cross-fades
morph it rather than snapping.

Trails are now comets — asymmetric falloff (sharp head, longer tail
behind the direction of travel), shorter (Pulse length 0.05 → 0.035)
and faster than the conveyor (Pulse speed 0.12 → 0.22).

Underlay density: Guide lines × (default 2) interleaves phantom guide
curves between the card arms — pulses ride those too. The mobile
profile pins 1× (its whole point is fewer, cheaper elements).

Claude-Session: https://claude.ai/code/session_013vwLpYJbWEqTJg9PGV6B6H
2026-09-15 12:23:56 -07:00
Tanish Manku 32d4a741f9 galaxy: mobile spiral profile, proximity clip dedup, arm color pulses
Three queued improvements, all tunable:

Mobile profile (Galaxy layout: Mobile < Mpx / arms / turns / density ×).
Below the area threshold the spiral switches to fewer, straighter arms
— at the tuned desktop angle, consecutive cards along an arm sit
radially close, so the neighbor-gap collision bound forced them small
on phones. Straighter arms space the ladder out and a density
multiplier lets cards claim the larger gaps.

Proximity dedup (Galaxy layout: Dedup px). Rebirth's least-recently-
shown pick spreads clip repeats over time, but cross-arm neighbors at
similar radii keep a constant angular offset — the same clip could
ride side by side indefinitely. A sweep on the cull cadence catches
visible pairs sharing a clip within the radius and reassigns the
blurrier card (the swap hides inside its birth blur) to the clip whose
current users sit furthest away, recency as tiebreak.

Arm color pulses (Galaxy look: Pulse alpha/speed/width/count/hue).
The arm guide lines move to a ShaderMaterial carrying arc-length and
arm-index attributes: a gaussian band of the theme accent travels
outward along each arm (with the cards' direction), hue-rotated per
arm for the colorful spread and dephased by the golden conjugate so
arms never flash in lockstep. Base hairline color/alpha and the
intro draw-up match the old line material; pulses fade out over the
blurred birth zone and lerp with theme cross-fades.

Also ported onto this branch: the pre-flight WebGL probe (cherry-pick)
and the 35-clip showcase pool.

Claude-Session: https://claude.ai/code/session_013vwLpYJbWEqTJg9PGV6B6H
2026-09-15 12:08:12 -07:00
Tanish Manku 2f4221da56 hero: pre-flight WebGL probe — context failure degrades silently
When WebGL context creation fails (exhausted drivers, blocklisted
GPU, webgl.disabled), THREE.WebGLRenderer throws inside r3f's async
init — an unhandled REJECTION that the render-phase CanvasBoundary
never catches, surfacing as a page error instead of the designed
static fallback.

Both r3f canvas gates (galaxy, comparator blocking scene) now consult
a shared cached probe (src/lib/webgl-support.ts) before mounting the
<Canvas>: no context available → the canvas never mounts, the page's
static layers stand, and the slate chrome already hides itself via
the dead-instrument rule. The probe releases its test context through
WEBGL_lose_context so it never eats a context slot.

Verified with Playwright Firefox: webgl.disabled=true → zero
canvases, zero page errors, copy/ruler fully functional; normal run →
both canvases mount, zero errors.

Claude-Session: https://claude.ai/code/session_013vwLpYJbWEqTJg9PGV6B6H
2026-09-15 12:03:05 -07:00
BFlat 60d8c0fd5f Merge pull request #10 from storytold/design/other-pages
Port marketing + campaign pages, pricing API flows
2026-09-15 09:20:18 +07:00
bflatastic 637f777135 website-next: port the campaign landing pages
Ports /seedance-2, /seedance2-5, /minimax-h3, and /creators/jboogxcreative
from the Vite site into the instrument design language, reusing the
landing's own Features / Ownership / Made-with sections (now taking an
`index` prop so eyebrow numbers stay sequential) plus a small set of
campaign building blocks (components/campaign): centered hero with reel
figure, section heading block, prose + flagged note, numbered tip grid,
native <details> FAQ, five-reasons cell grid with the "every model"
marquee, Discord/GitHub strip, closing CTA, and a shared CTA pair that
routes subscribers into the webapp and everyone else to pricing, with the
OS-detected desktop download and post-download signup modal.

- Seedance 2.5: overview with rumor disclaimer, prompt tips, FAQ (FAQPage
  JSON-LD), scroll-scrubbed manifesto word reveal (the Vite page's Three.js
  walking character and scrubbed knight video were not carried over), then
  the reused feature/ownership/reasons/community sections.
- MiniMax H3: real promptbox in the hero. Enqueues an H3 generation via
  /v1/omni_gen/generate/video, polls /v1/jobs/job/{token}, and plays the
  result inline; logged-out visitors get an inline email/password signup
  or login gate and the typed prompt runs on success. Aspect ratio,
  resolution, duration, and sound controls; keyframe uploads are left to
  the full app. Google SSO is not offered (no OAuth provider in this app).
- Creator spotlight: reel triptych, credits marquee, feed grid, craft
  cards, ProfilePage JSON-LD.

Shared: lib/use-platform.ts (OS detection + download-initiated flag) and
components/download/download-modal.tsx extracted from the download page;
api.ts gains login, signup, generateVideo, getVideoJob; ruler roster
reordered so campaign sections precede the reused landing ids; sitemap
lists the four routes.
2026-09-15 09:14:44 +07:00
bflatastic a1b4c4f362 website-next: port the pricing API flows (session, Stripe checkout, credits)
Restores the Vite pricing table's live behaviour on the Next site without
pulling the Nx @storyteller/api lib into this standalone app:

- lib/api.ts: minimal browser client for the endpoints pricing needs
  (session, active subscriptions, signup checkout, subscription checkout,
  switch plan, manage-plan portal, credits pack checkout, web referral
  log). Same paths, bodies, and envelopes as the shared lib; cookies plus
  the signed-session header fallback. Host via NEXT_PUBLIC_API_HOST.
- lib/referral.ts: landing-context capture (?u=, ?r=, landing URL,
  referrer) to apex-domain cookies + localStorage, mounted site-wide by
  components/landing-context.tsx, which also logs the referral once per
  browser session (the old main.tsx bootstrap).
- lib/use-account.ts: one shared session + ArtCraft plan fetch per page
  load for every consumer.
- Plan grid: logged out -> Stripe signup checkout with attribution; logged
  in without a plan -> subscription checkout; with a plan -> portal plan
  switch. "Your plan" tab and disabled CTA on the current tier, "Switch
  to X" labels, Manage plan portal link, inline error line.
- Credits cell: logged-in visitors get the one-time credit pack modal
  (Stripe checkout); logged-out visitors are sent to the app to sign in.

Server HTML still renders the logged-out state, so static output, SEO,
and every other page are unchanged.
2026-09-15 09:00:55 +07:00
bflatastic b6b24eef6c website-next: restore original pricing lede with a new headline 2026-09-15 08:50:06 +07:00
bflatastic 37dcab4db5 website-next: show the tagline on every pricing plan 2026-09-15 08:49:25 +07:00
bflatastic b9a769fdc4 website-next: sharper pricing hero headline and lede 2026-09-15 08:48:39 +07:00
bflatastic 2b127b6230 website-next: ruler treats interior page titles as current from load
The page-top section on interior pages (Support, Tutorials, News, …) was
running the ordinary anchor-space flip, whose progress at scroll 0 is
(12% of vh + 200px − ~110px) / 200px — so on shorter viewports the 01
heading sat partially flipped, or hidden, instead of leading the stack.
The first non-hero section is now pinned like the hero: flipP = detachP =
1 from the first frame, no queue home, no rail ride. Every page now opens
with its 01 section stacked at the top and 02+ queued below.
2026-09-15 08:48:18 +07:00
bflatastic 73ed977811 website-next: ruler sections from page top, desktop-only ruler, pricing tweaks
- Ruler: every interior page header now carries a section id (pricing,
  download, tutorials, news, faq, support, press-kit, beta) registered in
  RULER_SECTIONS ahead of the interior ids, so the heading stack starts
  with the page's 01 section at the top instead of first appearing
  mid-page. The pricing section wraps the promo strip so it begins at the
  very first pixel. Roster order is documented as load-bearing (anchors
  must increase with index).
- Ruler: capability gate tightened to hover-capable fine pointers at
  1024px+ and never on a mobile/tablet user agent.
- Pricing: dropped the "See plans" jump button; the "20% off · ends soon"
  badge moved into the plan toolbar next to the cadence toggle.
2026-09-15 08:45:52 +07:00
bflatastic e81f0bb94b website-next: color-coded, conversion-focused pricing page
Reworks /pricing on top of the ported instrument layout so the plan table
pops the way the original did:

- Per-plan color identity restored (green Basic, purple Pro, orange Max,
  blue Enterprise) via `.plan-*` tokens with light/dark ink variants: solid
  color index tab, color wash pooling under it, plan-colored checks and
  CTAs, and a 2px frame on the Most popular / Best value tiers.
- Conversion elements: full-width accent promo strip above the header,
  benefit-led headline ("Every model. One studio. Yours forever.") with a
  "See plans" jump CTA, 20% off pill and crossed-out anchor on every card,
  per-plan yearly savings in dollars, taglines, "2 months free" nudge on
  the cadence toggle, arrow CTAs, and a trust strip (cancel anytime, Stripe
  checkout, credit refunds, open source) under the grid.
2026-09-15 08:39:17 +07:00
bflatastic c3050a33f2 website-next: port marketing pages from the Vite site
Ports the interior marketing pages into artcraft-website-next, rebuilt in
the landing's instrument language (SectionShell rails + ticks, mono HUD
eyebrows, hairline gap-px cell grids, Archivo display headings with one
serif-italic contrast word, theme tokens for light/dark) instead of the old
dark-only Truchet/glow treatment.

Pages
- /pricing: plan grid (Basic/Pro/Max + Enterprise) with yearly/monthly
  TabSelector, 20% promo anchor pricing, highlighted plans invert their
  index row, credit-pack and footnote cells. ?ref=sd2fakeyou keeps the
  Seedance early-access header + reel. Checkout hands off to the webapp.
- /faq and /faq/[slug]: guide grid + article, FAQPage/Article JSON-LD.
- /news and /news/[slug]: post list + article, blog images resolved
  against the media host.
- /tutorials and /tutorials/[slug]: category filter grid, click-to-load
  YouTube via LiteYouTube, frontmatter aliases 308 to the canonical slug.
- /support, /download (client-side OS detection, requirements, quick
  start, post-download signup modal), /press-kit (asset cells + video
  modal), /beta (Google Form signup with honeypot).

Platform (Vite -> Next)
- Markdown content copied into src/content and read from disk in server
  code (lib/content.ts) with generateStaticParams, replacing
  import.meta.glob; markdown renderer ported to lib/markdown.ts and styled
  by token-based .article-content rules in globals.css.
- react-router Links/useParams/useSearchParams -> next/link, awaited
  params/searchParams, generateMetadata; react-device-detect -> a
  post-hydration user-agent hook; @storyteller/* lib imports replaced by
  local data modules (pricing-data, download-links, press-kit-data).
- Removed the legacy-route redirects to the Vite site from next.config
  (only the old Stripe return paths still redirect, now to the webapp);
  sitemap lists every page and article; ruler roster gains the interior
  section ids; Discord/Windows brand icons added.
2026-09-15 08:36:01 +07:00
Tanish Manku df9654fed9 Merge remote-tracking branch 'services/main' into design/proofing 2026-09-14 15:26:26 -07:00
Tanish Manku 239ae4ada2 hero: click boost 2x -> 3x (tuner max raised to 4x)
Claude-Session: https://claude.ai/code/session_01KpbGFYxEXuaYU9J75vTK9j
2026-09-11 19:31:51 -07:00
Tanish Manku 33d50851b0 fix Firefox back-navigation dead page: bump Next 16.2.6 -> 16.2.12
Reproduced in a real Firefox (Playwright): returning to the page via
browser Back was NOT a bfcache restore (pageshow persisted=false,
navType back_forward) — it was a fresh load on which Next's client
router threw "Internal Next.js error: Router action dispatched before
initialization" three times, aborting hydration. No effects ran, so
data-ruler was never stamped, the galaxy canvas never mounted, and
data-intro never lifted (the wordmark stayed CSS-hidden at opacity 0)
— the whole page looked unmounted.

The 16.2.12 patch fixes the router-initialization race; the same repro
now comes back fully mounted. The Firefox-gated unload bfcache-buster
from the previous attempt is removed (wrong theory, and it cost
Firefox users instant-back site-wide); the pageshow persisted reload
guard stays as insurance for genuinely broken bfcache restores.
Production build verified on 16.2.12.

Claude-Session: https://claude.ai/code/session_01KpbGFYxEXuaYU9J75vTK9j
2026-09-11 13:55:50 -07:00
298 changed files with 12880 additions and 2625 deletions

No files matched your search

@@ -264,30 +264,6 @@ pub fn plan_batch_count(
}
}
/// Batch counts for the Seedance 2.0 Mini family: 1-8 are all valid
/// (wider than the shared 1/2/4 planning); higher counts clamp to 8 per the
/// mitigation strategy.
pub fn plan_mini_batch_count(
video_batch_count: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<u16, ArtcraftRouterError> {
let count = video_batch_count.unwrap_or(1);
match count {
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
1..=8 => Ok(count),
_ => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "video_batch_count",
value: format!("{}", count),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade
| RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(8),
},
}
}
/// Translate the router-facing bitrate into the API `CommonBitrate`.
///
/// Bitrate does not affect cost. An unset value leaves the field `None` (the
@@ -0,0 +1,259 @@
//! Public ArtCraft batch planning and retail price snapshots for Kinovi models.
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
use tokens::tokens::media_files::MediaFileToken;
use crate::api::router_provider::RouterProvider;
use crate::api::router_resolution::RouterResolution;
use crate::api::router_video_model::RouterVideoModel;
use crate::api::video_list_ref::VideoListRef;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
const MODELS: [RouterVideoModel; 12] = [
RouterVideoModel::Seedance2p5,
RouterVideoModel::Seedance2p0,
RouterVideoModel::Seedance2p0Fast,
RouterVideoModel::Seedance2p0Mini,
RouterVideoModel::Seedance2p5Ultra,
RouterVideoModel::Seedance2p0BytePlus,
RouterVideoModel::Seedance2p0BytePlusFast,
RouterVideoModel::Seedance2p0BytePlusMini,
RouterVideoModel::Seedance2p0BytePlusUltra,
RouterVideoModel::Seedance2p0BytePlusUltraFast,
RouterVideoModel::Seedance2p0BytePlusUltraMini,
RouterVideoModel::HappyHorse1p0,
];
mod batch_planning {
use super::*;
#[test]
fn batches_one_through_four_survive_http_serialization() {
for model in MODELS {
for strategy in [
RequestMismatchMitigationStrategy::ErrorOut,
RequestMismatchMitigationStrategy::PayMoreUpgrade,
RequestMismatchMitigationStrategy::PayLessDowngrade,
] {
for batch in [None, Some(1), Some(2), Some(3), Some(4)] {
let mut builder = builder(model, RouterResolution::SevenTwentyP, batch);
builder.request_mismatch_mitigation_strategy = strategy;
let request = http_request(builder.build2().unwrap());
let json = serde_json::to_value(&request).unwrap();
assert_eq!(json["video_batch_count"], batch.unwrap_or(1), "{model:?}");
let decoded: OmniGenVideoCostAndGenerateRequest = serde_json::from_value(json).unwrap();
assert_eq!(decoded.video_batch_count, Some(batch.unwrap_or(1)));
}
}
}
}
#[test]
fn zero_is_rejected_under_every_strategy() {
for model in MODELS {
for strategy in [
RequestMismatchMitigationStrategy::ErrorOut,
RequestMismatchMitigationStrategy::PayMoreUpgrade,
RequestMismatchMitigationStrategy::PayLessDowngrade,
] {
let mut builder = builder(model, RouterResolution::SevenTwentyP, Some(0));
builder.request_mismatch_mitigation_strategy = strategy;
assert!(matches!(builder.build2(), Err(ArtcraftRouterError::Client(
ClientError::UserRequestedZeroGenerations
))), "{model:?}");
}
}
}
#[test]
fn batches_above_four_follow_the_mitigation_strategy() {
for model in MODELS {
for batch in [5, 6, 7, 8, u16::MAX] {
let builder = builder(model, RouterResolution::SevenTwentyP, Some(batch));
assert!(matches!(builder.clone().build2(), Err(ArtcraftRouterError::Client(
ClientError::ModelDoesNotSupportOption { field: "video_batch_count", .. }
))), "{model:?}, batch {batch}");
for strategy in [
RequestMismatchMitigationStrategy::PayMoreUpgrade,
RequestMismatchMitigationStrategy::PayLessDowngrade,
] {
let mut builder = builder.clone();
builder.request_mismatch_mitigation_strategy = strategy;
let request = http_request(builder.build2().unwrap());
assert_eq!(request.video_batch_count, Some(4), "{model:?}, batch {batch}");
}
}
}
}
}
mod retail_prices {
use super::*;
use RouterResolution::{FourEightyP, FourK, SevenTwentyP, TenEightyP};
use RouterVideoModel::{HappyHorse1p0, Seedance2p0, Seedance2p0Fast, Seedance2p0Mini, Seedance2p5,
Seedance2p5Ultra, Seedance2p0BytePlus, Seedance2p0BytePlusFast, Seedance2p0BytePlusMini, Seedance2p0BytePlusUltra, Seedance2p0BytePlusUltraFast, Seedance2p0BytePlusUltraMini,
};
#[test]
fn five_second_prices_for_every_resolution_and_batch() {
// Literal USD cents / ArtCraft credits for batches [1, 2, 3, 4].
// Pin the final rounded totals, not a multiple of an already-rounded price.
for (model, resolution, expected) in [
(Seedance2p5, FourEightyP, [59, 118, 177, 236]),
(Seedance2p5, SevenTwentyP, [134, 268, 401, 535]),
(Seedance2p5, TenEightyP, [309, 617, 925, 1233]),
(Seedance2p5Ultra, FourEightyP, [70, 140, 209, 279]),
(Seedance2p5Ultra, SevenTwentyP, [158, 316, 474, 632]),
(Seedance2p5Ultra, TenEightyP, [337, 673, 1009, 1345]),
(Seedance2p0, FourEightyP, [39, 78, 117, 155]),
(Seedance2p0, SevenTwentyP, [93, 185, 278, 370]),
(Seedance2p0, TenEightyP, [233, 466, 699, 933]),
(Seedance2p0, FourK, [463, 925, 1388, 1850]),
(Seedance2p0Fast, FourEightyP, [36, 71, 107, 143]),
(Seedance2p0Fast, SevenTwentyP, [64, 127, 191, 255]),
(Seedance2p0Mini, FourEightyP, [18, 35, 52, 69]),
(Seedance2p0Mini, SevenTwentyP, [45, 89, 134, 178]),
(Seedance2p0BytePlus, FourEightyP, [50, 100, 150, 200]),
(Seedance2p0BytePlus, SevenTwentyP, [125, 250, 375, 500]),
(Seedance2p0BytePlus, TenEightyP, [250, 500, 750, 1000]),
(Seedance2p0BytePlus, FourK, [475, 950, 1425, 1900]),
(Seedance2p0BytePlusUltra, FourEightyP, [50, 100, 150, 200]),
(Seedance2p0BytePlusUltra, SevenTwentyP, [125, 250, 375, 500]),
(Seedance2p0BytePlusUltra, TenEightyP, [250, 500, 750, 1000]),
(Seedance2p0BytePlusUltra, FourK, [475, 950, 1425, 1900]),
(Seedance2p0BytePlusFast, FourEightyP, [45, 90, 135, 180]),
(Seedance2p0BytePlusFast, SevenTwentyP, [100, 200, 300, 400]),
(Seedance2p0BytePlusUltraFast, FourEightyP, [45, 90, 135, 180]),
(Seedance2p0BytePlusUltraFast, SevenTwentyP, [100, 200, 300, 400]),
(Seedance2p0BytePlusMini, FourEightyP, [18, 36, 54, 71]),
(Seedance2p0BytePlusMini, SevenTwentyP, [46, 91, 137, 182]),
(Seedance2p0BytePlusUltraMini, FourEightyP, [18, 36, 54, 71]),
(Seedance2p0BytePlusUltraMini, SevenTwentyP, [46, 91, 137, 182]),
(HappyHorse1p0, SevenTwentyP, [85, 171, 256, 342]),
(HappyHorse1p0, TenEightyP, [171, 342, 513, 684]),
] {
assert_batch_prices(builder(model, resolution, None), expected);
}
}
#[test]
fn video_reference_prices_for_every_resolution_and_batch() {
// Five output seconds, plus ten measured input seconds for Seedance 2.5.
for (model, resolution, expected) in [
(Seedance2p5, FourEightyP, [109, 218, 326, 435]),
(Seedance2p5, SevenTwentyP, [238, 476, 713, 951]),
(Seedance2p5, TenEightyP, [578, 1155, 1733, 2310]),
(Seedance2p5Ultra, FourEightyP, [129, 257, 386, 514]),
(Seedance2p5Ultra, SevenTwentyP, [281, 562, 843, 1124]),
(Seedance2p5Ultra, TenEightyP, [628, 1256, 1884, 2511]),
(Seedance2p0, FourEightyP, [45, 89, 133, 177]),
(Seedance2p0, SevenTwentyP, [111, 221, 331, 441]),
(Seedance2p0, TenEightyP, [256, 511, 767, 1022]),
(Seedance2p0, FourK, [569, 1138, 1707, 2276]),
(Seedance2p0Fast, FourEightyP, [43, 85, 127, 169]),
(Seedance2p0Fast, SevenTwentyP, [78, 156, 234, 312]),
// Preserve Mini's existing floating-point round-up behavior.
(Seedance2p0Mini, FourEightyP, [22, 44, 66, 88]),
(Seedance2p0Mini, SevenTwentyP, [54, 108, 161, 215]),
(Seedance2p0BytePlus, FourEightyP, [51, 102, 153, 204]),
(Seedance2p0BytePlus, SevenTwentyP, [129, 257, 386, 514]),
(Seedance2p0BytePlus, TenEightyP, [289, 578, 867, 1156]),
(Seedance2p0BytePlus, FourK, [595, 1190, 1785, 2380]),
(Seedance2p0BytePlusUltra, FourEightyP, [51, 102, 153, 204]),
(Seedance2p0BytePlusUltra, SevenTwentyP, [129, 257, 386, 514]),
(Seedance2p0BytePlusUltra, TenEightyP, [289, 578, 867, 1156]),
(Seedance2p0BytePlusUltra, FourK, [595, 1190, 1785, 2380]),
(Seedance2p0BytePlusFast, FourEightyP, [49, 97, 145, 193]),
(Seedance2p0BytePlusFast, SevenTwentyP, [103, 205, 308, 410]),
(Seedance2p0BytePlusUltraFast, FourEightyP, [49, 97, 145, 193]),
(Seedance2p0BytePlusUltraFast, SevenTwentyP, [103, 205, 308, 410]),
(Seedance2p0BytePlusMini, FourEightyP, [23, 45, 68, 90]),
(Seedance2p0BytePlusMini, SevenTwentyP, [55, 110, 165, 219]),
(Seedance2p0BytePlusUltraMini, FourEightyP, [23, 45, 68, 90]),
(Seedance2p0BytePlusUltraMini, SevenTwentyP, [55, 110, 165, 219]),
] {
let mut builder = builder(model, resolution, None);
builder.reference_videos = Some(VideoListRef::MediaFileTokens(vec![
MediaFileToken::new("mf_reference".to_string()),
]));
builder.total_reference_video_input_seconds = Some(10);
assert_batch_prices(builder, expected);
}
}
#[test]
fn seedance_2p5_input_duration_clamping_and_fallback_scale_with_batch() {
for (model, resolution, minimum, maximum) in [
(Seedance2p5, FourEightyP, [66, 131, 196, 261], [254, 507, 761, 1014]),
(Seedance2p5, SevenTwentyP, [143, 286, 428, 571], [555, 1110, 1664, 2219]),
(Seedance2p5, TenEightyP, [347, 693, 1040, 1386], [1348, 2695, 4043, 5390]),
(Seedance2p5Ultra, FourEightyP, [78, 155, 232, 309], [300, 600, 899, 1199]),
(Seedance2p5Ultra, SevenTwentyP, [169, 338, 506, 675], [656, 1311, 1967, 2622]),
(Seedance2p5Ultra, TenEightyP, [377, 754, 1130, 1507], [1465, 2930, 4394, 5859]),
] {
for (input_seconds, expected) in [
(Some(1), minimum), (Some(4), minimum),
(None, maximum), (Some(0), maximum), (Some(30), maximum), (Some(99), maximum),
] {
let mut builder = builder(model, resolution, None);
builder.reference_videos = Some(VideoListRef::MediaFileTokens(vec![
MediaFileToken::new("mf_reference".to_string()),
]));
builder.total_reference_video_input_seconds = input_seconds;
assert_batch_prices(builder, expected);
}
}
}
}
fn builder(model: RouterVideoModel, resolution: RouterResolution, batch: Option<u16>) -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
provider: RouterProvider::Artcraft,
model,
resolution: Some(resolution),
duration_seconds: Some(5),
video_batch_count: batch,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..Default::default()
}
}
fn assert_batch_prices(builder: GenerateVideoRequestBuilder, expected: [u64; 4]) {
for (index, expected_price) in expected.into_iter().enumerate() {
let batch = index as u16 + 1;
let mut builder = builder.clone();
builder.video_batch_count = Some(batch);
let context = format!("{:?}, {:?}, batch {batch}", builder.model, builder.resolution);
let request = builder.build2().unwrap();
let cost = request.estimate_cost().unwrap();
assert_eq!(cost.cost_in_credits, Some(expected_price), "{context}");
assert_eq!(cost.cost_in_usd_cents, Some(expected_price), "{context}");
assert_eq!(http_request(request).video_batch_count, Some(batch), "{context}");
}
}
fn http_request(request: VideoGenerationDraftOrRequest) -> OmniGenVideoCostAndGenerateRequest {
match request {
VideoGenerationDraftOrRequest::Request(request) => match request {
VideoGenerationRequest::ArtcraftSeedance2p5(state) => state.request,
VideoGenerationRequest::ArtcraftSeedance2p0(state) => state.request,
VideoGenerationRequest::ArtcraftSeedance2p0Fast(state) => state.request,
VideoGenerationRequest::ArtcraftSeedance2p0Mini(state) => state.request,
VideoGenerationRequest::ArtcraftSeedance2p5Ultra(state) => state.request,
VideoGenerationRequest::ArtcraftSeedance2p0BytePlus(state) => state.request,
VideoGenerationRequest::ArtcraftSeedance2p0BytePlusFast(state) => state.request,
VideoGenerationRequest::ArtcraftSeedance2p0BytePlusMini(state) => state.request,
VideoGenerationRequest::ArtcraftSeedance2p0BytePlusUltra(state) => state.request,
VideoGenerationRequest::ArtcraftSeedance2p0BytePlusUltraFast(state) => state.request,
VideoGenerationRequest::ArtcraftSeedance2p0BytePlusUltraMini(state) => state.request,
VideoGenerationRequest::ArtcraftHappyHorse1p0(state) => state.request,
other => panic!("Unexpected request: {other:?}"),
},
other => panic!("Expected ArtCraft request: {other:?}"),
}
}
@@ -2,6 +2,8 @@ pub(crate) mod build_common;
pub(crate) mod seedance_common;
#[cfg(test)]
mod parity_tests;
#[cfg(test)]
mod kinovi_batch_tests;
pub mod grok_imagine_video;
pub mod grok_imagine_video_1p5;
pub mod flux_3;
@@ -43,4 +45,3 @@ pub mod veo_3p1_fast;
pub mod veo_3p1_lite;
pub mod vidu_q3;
pub mod vidu_q3_turbo;
@@ -3,27 +3,21 @@ use enums::common::generation::common_video_model::CommonVideoModel as CommonVid
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::providers::artcraft::build_common::{
build_artcraft_omni_video_request, plan_mini_batch_count, SupportedResolutions, UltraWideSupport,
build_artcraft_omni_video_request, SupportedResolutions, UltraWideSupport,
};
use crate::generate::generate_video::providers::artcraft::seedance_2p0_bp_mini::request::ArtcraftSeedance2p0BytePlusMiniRequestState;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
pub fn build_artcraft_seedance_2p0_bp_mini(mut builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
pub fn build_artcraft_seedance_2p0_bp_mini(builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
// Seedance 2.0 Mini supports 480p and 720p only (higher resolutions downgrade
// to 720p), and all six aspect ratios including 21:9.
// Mini supports batches of 1-8, wider than the shared 1/2/4 planning.
// Take the batch out before the shared build and set it after.
let strategy = builder.request_mismatch_mitigation_strategy;
let batch_count = plan_mini_batch_count(builder.video_batch_count.take(), strategy)?;
let mut request = build_artcraft_omni_video_request(
let request = build_artcraft_omni_video_request(
builder,
CommonVideoModelEnum::Seedance2p0BytePlusMini,
SupportedResolutions::Fast,
UltraWideSupport::Supported,
)?;
request.video_batch_count = Some(batch_count);
let state = ArtcraftSeedance2p0BytePlusMiniRequestState { request };
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p0BytePlusMini(state)))
@@ -87,18 +81,17 @@ mod tests {
}
#[test]
fn batch_count_up_to_eight_passed_through() {
// Mini supports batches of 1-8; execution and billing must agree.
for batch in 1..=8u16 {
fn batch_count_up_to_four_passed_through() {
for batch in 1..=4u16 {
let req = unwrap_request(builder_with(move |b| { b.video_batch_count = Some(batch); }));
assert_eq!(req.request.video_batch_count, Some(batch));
}
}
#[test]
fn batch_count_above_eight_clamps_to_eight() {
let req = unwrap_request(builder_with(|b| { b.video_batch_count = Some(9); }));
assert_eq!(req.request.video_batch_count, Some(8));
fn batch_count_above_four_clamps_to_four() {
let req = unwrap_request(builder_with(|b| { b.video_batch_count = Some(8); }));
assert_eq!(req.request.video_batch_count, Some(4));
}
#[test]
@@ -108,18 +108,13 @@ mod tests {
mod batch_pricing {
use super::*;
/// Mini bills batches of 1-8 in full (the platform's 1/2/4 cap does not
/// apply). Once, batch 8 executed in full but billed as batch 4.
/// ArtCraft exposes batches of 1-4 and bills the entire planned batch.
#[test]
fn every_batch_size_720p_5s() {
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 1, false), 46);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 2, false), 91);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 3, false), 137);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 4, false), 182);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 5, false), 228);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 6, false), 273);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 7, false), 319);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 8, false), 364);
}
#[test]
@@ -128,23 +123,16 @@ mod tests {
assert_eq!(cents(RouterResolution::FourEightyP, 5, 2, false), 36);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 3, false), 54);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 4, false), 71);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 5, false), 89);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 6, false), 107);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 7, false), 125);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 8, false), 142);
}
#[test]
fn batch_eight_with_video_reference() {
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 8, true), 438);
fn batch_four_with_video_reference() {
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 4, true), 219);
}
#[test]
fn batch_above_eight_clamps_to_eight() {
assert_eq!(
cents(RouterResolution::SevenTwentyP, 5, 9, false),
cents(RouterResolution::SevenTwentyP, 5, 8, false),
);
fn batch_above_four_clamps_to_four() {
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 8, false), 182);
}
}
@@ -160,7 +148,7 @@ mod tests {
];
for res in resolutions {
for dur in [4u16, 5, 10, 15] {
for batch in [1u16, 2, 4] {
for batch in 1..=4 {
for has_ref in [false, true] {
let cost = build_cost(res, dur, batch, has_ref);
assert_eq!(cost.cost_in_credits, cost.cost_in_usd_cents);
@@ -3,27 +3,21 @@ use enums::common::generation::common_video_model::CommonVideoModel as CommonVid
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::providers::artcraft::build_common::{
build_artcraft_omni_video_request, plan_mini_batch_count, SupportedResolutions, UltraWideSupport,
build_artcraft_omni_video_request, SupportedResolutions, UltraWideSupport,
};
use crate::generate::generate_video::providers::artcraft::seedance_2p0_bpu_mini::request::ArtcraftSeedance2p0BytePlusUltraMiniRequestState;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
pub fn build_artcraft_seedance_2p0_bpu_mini(mut builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
pub fn build_artcraft_seedance_2p0_bpu_mini(builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
// Seedance 2.0 Mini supports 480p and 720p only (higher resolutions downgrade
// to 720p), and all six aspect ratios including 21:9.
// Mini supports batches of 1-8, wider than the shared 1/2/4 planning.
// Take the batch out before the shared build and set it after.
let strategy = builder.request_mismatch_mitigation_strategy;
let batch_count = plan_mini_batch_count(builder.video_batch_count.take(), strategy)?;
let mut request = build_artcraft_omni_video_request(
let request = build_artcraft_omni_video_request(
builder,
CommonVideoModelEnum::Seedance2p0BytePlusUltraMini,
SupportedResolutions::Fast,
UltraWideSupport::Supported,
)?;
request.video_batch_count = Some(batch_count);
let state = ArtcraftSeedance2p0BytePlusUltraMiniRequestState { request };
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p0BytePlusUltraMini(state)))
@@ -87,18 +81,17 @@ mod tests {
}
#[test]
fn batch_count_up_to_eight_passed_through() {
// Mini supports batches of 1-8; execution and billing must agree.
for batch in 1..=8u16 {
fn batch_count_up_to_four_passed_through() {
for batch in 1..=4u16 {
let req = unwrap_request(builder_with(move |b| { b.video_batch_count = Some(batch); }));
assert_eq!(req.request.video_batch_count, Some(batch));
}
}
#[test]
fn batch_count_above_eight_clamps_to_eight() {
let req = unwrap_request(builder_with(|b| { b.video_batch_count = Some(9); }));
assert_eq!(req.request.video_batch_count, Some(8));
fn batch_count_above_four_clamps_to_four() {
let req = unwrap_request(builder_with(|b| { b.video_batch_count = Some(8); }));
assert_eq!(req.request.video_batch_count, Some(4));
}
#[test]
@@ -108,18 +108,13 @@ mod tests {
mod batch_pricing {
use super::*;
/// Mini bills batches of 1-8 in full (the platform's 1/2/4 cap does not
/// apply). Once, batch 8 executed in full but billed as batch 4.
/// ArtCraft exposes batches of 1-4 and bills the entire planned batch.
#[test]
fn every_batch_size_720p_5s() {
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 1, false), 46);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 2, false), 91);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 3, false), 137);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 4, false), 182);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 5, false), 228);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 6, false), 273);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 7, false), 319);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 8, false), 364);
}
#[test]
@@ -128,23 +123,16 @@ mod tests {
assert_eq!(cents(RouterResolution::FourEightyP, 5, 2, false), 36);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 3, false), 54);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 4, false), 71);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 5, false), 89);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 6, false), 107);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 7, false), 125);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 8, false), 142);
}
#[test]
fn batch_eight_with_video_reference() {
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 8, true), 438);
fn batch_four_with_video_reference() {
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 4, true), 219);
}
#[test]
fn batch_above_eight_clamps_to_eight() {
assert_eq!(
cents(RouterResolution::SevenTwentyP, 5, 9, false),
cents(RouterResolution::SevenTwentyP, 5, 8, false),
);
fn batch_above_four_clamps_to_four() {
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 8, false), 182);
}
}
@@ -160,7 +148,7 @@ mod tests {
];
for res in resolutions {
for dur in [4u16, 5, 10, 15] {
for batch in [1u16, 2, 4] {
for batch in 1..=4 {
for has_ref in [false, true] {
let cost = build_cost(res, dur, batch, has_ref);
assert_eq!(cost.cost_in_credits, cost.cost_in_usd_cents);
@@ -3,27 +3,21 @@ use enums::common::generation::common_video_model::CommonVideoModel as CommonVid
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::providers::artcraft::build_common::{
build_artcraft_omni_video_request, plan_mini_batch_count, SupportedResolutions, UltraWideSupport,
build_artcraft_omni_video_request, SupportedResolutions, UltraWideSupport,
};
use crate::generate::generate_video::providers::artcraft::seedance_2p0_mini::request::ArtcraftSeedance2p0MiniRequestState;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
pub fn build_artcraft_seedance_2p0_mini(mut builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
pub fn build_artcraft_seedance_2p0_mini(builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
// Seedance 2.0 Mini supports 480p and 720p only (higher resolutions downgrade
// to 720p), and all six aspect ratios including 21:9.
// Mini supports batches of 1-8, wider than the shared 1/2/4 planning.
// Take the batch out before the shared build and set it after.
let strategy = builder.request_mismatch_mitigation_strategy;
let batch_count = plan_mini_batch_count(builder.video_batch_count.take(), strategy)?;
let mut request = build_artcraft_omni_video_request(
let request = build_artcraft_omni_video_request(
builder,
CommonVideoModelEnum::Seedance2p0Mini,
SupportedResolutions::Fast,
UltraWideSupport::Supported,
)?;
request.video_batch_count = Some(batch_count);
let state = ArtcraftSeedance2p0MiniRequestState { request };
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p0Mini(state)))
@@ -87,18 +81,17 @@ mod tests {
}
#[test]
fn batch_count_up_to_eight_passed_through() {
// Mini supports batches of 1-8; execution and billing must agree.
for batch in 1..=8u16 {
fn batch_count_up_to_four_passed_through() {
for batch in 1..=4u16 {
let req = unwrap_request(builder_with(move |b| { b.video_batch_count = Some(batch); }));
assert_eq!(req.request.video_batch_count, Some(batch));
}
}
#[test]
fn batch_count_above_eight_clamps_to_eight() {
let req = unwrap_request(builder_with(|b| { b.video_batch_count = Some(9); }));
assert_eq!(req.request.video_batch_count, Some(8));
fn batch_count_above_four_clamps_to_four() {
let req = unwrap_request(builder_with(|b| { b.video_batch_count = Some(8); }));
assert_eq!(req.request.video_batch_count, Some(4));
}
#[test]
@@ -108,18 +108,13 @@ mod tests {
mod batch_pricing {
use super::*;
/// Mini bills batches of 1-8 in full (the platform's 1/2/4 cap does not
/// apply). Once, batch 8 executed in full but billed as batch 4.
/// ArtCraft exposes batches of 1-4 and bills the entire planned batch.
#[test]
fn every_batch_size_720p_5s() {
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 1, false), 45);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 2, false), 89);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 3, false), 134);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 4, false), 178);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 5, false), 223);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 6, false), 267);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 7, false), 312);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 8, false), 356);
}
#[test]
@@ -128,23 +123,16 @@ mod tests {
assert_eq!(cents(RouterResolution::FourEightyP, 5, 2, false), 35);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 3, false), 52);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 4, false), 69);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 5, false), 87);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 6, false), 104);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 7, false), 121);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 8, false), 138);
}
#[test]
fn batch_eight_with_video_reference() {
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 8, true), 429);
fn batch_four_with_video_reference() {
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 4, true), 215);
}
#[test]
fn batch_above_eight_clamps_to_eight() {
assert_eq!(
cents(RouterResolution::SevenTwentyP, 5, 9, false),
cents(RouterResolution::SevenTwentyP, 5, 8, false),
);
fn batch_above_four_clamps_to_four() {
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 8, false), 178);
}
}
@@ -160,7 +148,7 @@ mod tests {
];
for res in resolutions {
for dur in [4u16, 5, 10, 15] {
for batch in [1u16, 2, 4] {
for batch in 1..=4 {
for has_ref in [false, true] {
let cost = build_cost(res, dur, batch, has_ref);
assert_eq!(cost.cost_in_credits, cost.cost_in_usd_cents);
@@ -43,10 +43,8 @@ pub fn build_artcraft_seedance_2p5(mut builder: GenerateVideoRequestBuilder) ->
}
// 2.5 allows 4-30 second durations (longer than the shared 4-15 range in
// build_common) and generates a single video per request. Plan both here,
// leaving None for the shared builder, then set them after.
// build_common). Plan duration here and use the shared 1-4 batch planner.
let duration_seconds = plan_duration(builder.duration_seconds.take(), strategy)?;
plan_batch_count(builder.video_batch_count.take(), strategy)?;
let total_input_seconds = builder.total_reference_video_input_seconds.take();
let generate_audio = builder.generate_audio.take();
@@ -58,7 +56,6 @@ pub fn build_artcraft_seedance_2p5(mut builder: GenerateVideoRequestBuilder) ->
UltraWideSupport::Supported,
)?;
request.duration_seconds = duration_seconds;
request.video_batch_count = Some(1);
request.generate_audio = generate_audio;
let state = ArtcraftSeedance2p5RequestState { request, total_input_seconds };
@@ -87,28 +84,6 @@ fn plan_duration(
}
}
// Seedance 2.5 generates a single video per request (no batching).
fn plan_batch_count(
video_batch_count: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<(), ArtcraftRouterError> {
let count = video_batch_count.unwrap_or(1);
match count {
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
1 => Ok(()),
_ => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "video_batch_count",
value: format!("{}", count),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade
| RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(()),
},
}
}
#[cfg(test)]
mod tests {
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
@@ -153,9 +128,11 @@ mod tests {
}
#[test]
fn batch_count_is_always_one() {
let req = unwrap_request(builder_with(|b| { b.video_batch_count = Some(4); }));
assert_eq!(req.request.video_batch_count, Some(1));
fn batch_counts_one_through_four_are_preserved() {
for batch_count in 1..=4 {
let req = unwrap_request(builder_with(|b| { b.video_batch_count = Some(batch_count); }));
assert_eq!(req.request.video_batch_count, Some(batch_count));
}
}
#[test]
@@ -8,10 +8,11 @@ use crate::generate::generate_video::providers::artcraft::seedance_2p5::request:
/// — when reference videos are attached — the total seconds of reference
/// video input, which are billed on top of the output duration (at a lower
/// per-second rate). The TOTAL input duration clamps to the 4..=30 second
/// billing range. No batching.
/// billing range. Batch count multiplies the total before rounding.
pub struct ArtcraftSeedance2p5CostState {
pub resolution: CommonResolution,
pub duration_seconds: u16,
pub batch_count: u16,
pub has_video_references: bool,
pub maybe_total_input_seconds: Option<u16>,
}
@@ -29,6 +30,7 @@ impl ArtcraftSeedance2p5CostState {
Self {
resolution,
duration_seconds,
batch_count: request.request.video_batch_count.unwrap_or(1),
has_video_references,
maybe_total_input_seconds: request.total_input_seconds,
}
@@ -43,6 +45,7 @@ impl ArtcraftSeedance2p5CostState {
let usd_cents = seedance_2p5_usd_cents(
self.resolution,
self.duration_seconds,
self.batch_count,
self.has_video_references,
maybe_total_input_seconds,
);
@@ -94,11 +97,11 @@ mod tests {
#[test]
fn table_1080p() {
// 45.85869386 ¢/s, rounded up.
assert_eq!(cents(Some(RouterResolution::TenEightyP), 4), 184);
assert_eq!(cents(Some(RouterResolution::TenEightyP), 5), 230);
assert_eq!(cents(Some(RouterResolution::TenEightyP), 10), 459);
assert_eq!(cents(Some(RouterResolution::TenEightyP), 30), 1376);
// 61.62732357 ¢/s, rounded up.
assert_eq!(cents(Some(RouterResolution::TenEightyP), 4), 247);
assert_eq!(cents(Some(RouterResolution::TenEightyP), 5), 309);
assert_eq!(cents(Some(RouterResolution::TenEightyP), 10), 617);
assert_eq!(cents(Some(RouterResolution::TenEightyP), 30), 1849);
}
#[test]
@@ -121,16 +124,16 @@ mod tests {
assert_eq!(cents_with_video_refs(Some(RouterResolution::FourEightyP), 30, Some(10)), 290);
// 15.84362140 ¢/s × 40 = 633.74 → 634¢.
assert_eq!(cents_with_video_refs(Some(RouterResolution::SevenTwentyP), 30, Some(10)), 634);
// 27.39973680 ¢/s × 40 = 1095.99 → 1096¢.
assert_eq!(cents_with_video_refs(Some(RouterResolution::TenEightyP), 30, Some(10)), 1096);
// 38.49728574 ¢/s × 40 = 1539.89 → 1540¢.
assert_eq!(cents_with_video_refs(Some(RouterResolution::TenEightyP), 30, Some(10)), 1540);
}
#[test]
fn table_1080p_with_ten_input_seconds() {
// 27.39973680 ¢/s over (output + 10) billed seconds, rounded up.
assert_eq!(cents_with_video_refs(Some(RouterResolution::TenEightyP), 5, Some(10)), 411);
assert_eq!(cents_with_video_refs(Some(RouterResolution::TenEightyP), 10, Some(10)), 548);
assert_eq!(cents_with_video_refs(Some(RouterResolution::TenEightyP), 30, Some(10)), 1096);
// 38.49728574 ¢/s over (output + 10) billed seconds, rounded up.
assert_eq!(cents_with_video_refs(Some(RouterResolution::TenEightyP), 5, Some(10)), 578);
assert_eq!(cents_with_video_refs(Some(RouterResolution::TenEightyP), 10, Some(10)), 770);
assert_eq!(cents_with_video_refs(Some(RouterResolution::TenEightyP), 30, Some(10)), 1540);
}
#[test]
@@ -43,10 +43,8 @@ pub fn build_artcraft_seedance_2p5_u(mut builder: GenerateVideoRequestBuilder) -
}
// 2.5 allows 4-30 second durations (longer than the shared 4-15 range in
// build_common) and generates a single video per request. Plan both here,
// leaving None for the shared builder, then set them after.
// build_common). Plan duration here and use the shared 1-4 batch planner.
let duration_seconds = plan_duration(builder.duration_seconds.take(), strategy)?;
plan_batch_count(builder.video_batch_count.take(), strategy)?;
let total_input_seconds = builder.total_reference_video_input_seconds.take();
let generate_audio = builder.generate_audio.take();
@@ -58,7 +56,6 @@ pub fn build_artcraft_seedance_2p5_u(mut builder: GenerateVideoRequestBuilder) -
UltraWideSupport::Supported,
)?;
request.duration_seconds = duration_seconds;
request.video_batch_count = Some(1);
request.generate_audio = generate_audio;
let state = ArtcraftSeedance2p5UltraRequestState { request, total_input_seconds };
@@ -87,28 +84,6 @@ fn plan_duration(
}
}
// Seedance 2.5 generates a single video per request (no batching).
fn plan_batch_count(
video_batch_count: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<(), ArtcraftRouterError> {
let count = video_batch_count.unwrap_or(1);
match count {
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
1 => Ok(()),
_ => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "video_batch_count",
value: format!("{}", count),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade
| RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(()),
},
}
}
#[cfg(test)]
mod tests {
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
@@ -153,9 +128,11 @@ mod tests {
}
#[test]
fn batch_count_is_always_one() {
let req = unwrap_request(builder_with(|b| { b.video_batch_count = Some(4); }));
assert_eq!(req.request.video_batch_count, Some(1));
fn batch_counts_one_through_four_are_preserved() {
for batch_count in 1..=4 {
let req = unwrap_request(builder_with(|b| { b.video_batch_count = Some(batch_count); }));
assert_eq!(req.request.video_batch_count, Some(batch_count));
}
}
#[test]
@@ -9,10 +9,11 @@ use crate::generate::generate_video::providers::artcraft::seedance_2p5_u::reques
/// reference video input, which are billed on top of the output duration (at
/// a lower per-second rate). Input seconds are clamped to
/// [`MAX_BILLED_INPUT_SECONDS`] — the model accepts at most 30 seconds of
/// video. No batching.
/// video. Batch count multiplies the total before rounding.
pub struct ArtcraftSeedance2p5UltraCostState {
pub resolution: CommonResolution,
pub duration_seconds: u16,
pub batch_count: u16,
pub has_video_references: bool,
pub maybe_total_input_seconds: Option<u16>,
}
@@ -30,6 +31,7 @@ impl ArtcraftSeedance2p5UltraCostState {
Self {
resolution,
duration_seconds,
batch_count: request.request.video_batch_count.unwrap_or(1),
has_video_references,
maybe_total_input_seconds: request.total_input_seconds,
}
@@ -44,6 +46,7 @@ impl ArtcraftSeedance2p5UltraCostState {
let usd_cents = seedance_2p5_ultra_usd_cents(
self.resolution,
self.duration_seconds,
self.batch_count,
self.has_video_references,
maybe_total_input_seconds,
);
@@ -95,11 +98,11 @@ mod tests {
#[test]
fn table_1080p() {
// 50.10486922 ¢/s, rounded up.
assert_eq!(cents(Some(RouterResolution::TenEightyP), 4), 201);
assert_eq!(cents(Some(RouterResolution::TenEightyP), 5), 251);
assert_eq!(cents(Some(RouterResolution::TenEightyP), 10), 502);
assert_eq!(cents(Some(RouterResolution::TenEightyP), 30), 1504);
// 67.22980753 ¢/s, rounded up.
assert_eq!(cents(Some(RouterResolution::TenEightyP), 4), 269);
assert_eq!(cents(Some(RouterResolution::TenEightyP), 5), 337);
assert_eq!(cents(Some(RouterResolution::TenEightyP), 10), 673);
assert_eq!(cents(Some(RouterResolution::TenEightyP), 30), 2017);
}
#[test]
@@ -141,16 +144,16 @@ mod tests {
assert_eq!(cents_with_video_refs(Some(RouterResolution::FourEightyP), 30, Some(10)), 343);
// 18.72427984 ¢/s × 40 = 748.97 → 749¢.
assert_eq!(cents_with_video_refs(Some(RouterResolution::SevenTwentyP), 30, Some(10)), 749);
// 29.93674947 ¢/s × 40 = 1197.47 → 1198¢.
assert_eq!(cents_with_video_refs(Some(RouterResolution::TenEightyP), 30, Some(10)), 1198);
// 41.84487580 ¢/s × 40 = 1673.80 → 1674¢.
assert_eq!(cents_with_video_refs(Some(RouterResolution::TenEightyP), 30, Some(10)), 1674);
}
#[test]
fn table_1080p_with_ten_input_seconds() {
// 29.93674947 ¢/s over (output + 10) billed seconds, rounded up.
assert_eq!(cents_with_video_refs(Some(RouterResolution::TenEightyP), 5, Some(10)), 450);
assert_eq!(cents_with_video_refs(Some(RouterResolution::TenEightyP), 10, Some(10)), 599);
assert_eq!(cents_with_video_refs(Some(RouterResolution::TenEightyP), 30, Some(10)), 1198);
// 41.84487580 ¢/s over (output + 10) billed seconds, rounded up.
assert_eq!(cents_with_video_refs(Some(RouterResolution::TenEightyP), 5, Some(10)), 628);
assert_eq!(cents_with_video_refs(Some(RouterResolution::TenEightyP), 10, Some(10)), 837);
assert_eq!(cents_with_video_refs(Some(RouterResolution::TenEightyP), 30, Some(10)), 1674);
}
#[test]
@@ -137,14 +137,15 @@ pub fn seedance_2p5_preview_usd_cents(
const SEEDANCE_2P5_CENTS_PER_SECOND_480P: f64 = 11.76954733;
/// Seedance 2.5 — 720p price, USD cents per second.
const SEEDANCE_2P5_CENTS_PER_SECOND_720P: f64 = 26.70781893;
/// Seedance 2.5 — 1080p price, USD cents per second.
const SEEDANCE_2P5_CENTS_PER_SECOND_1080P: f64 = 45.85869386;
/// Seedance 2.5 — 1080p price, USD cents per second (updated 2026-09-17).
const SEEDANCE_2P5_CENTS_PER_SECOND_1080P: f64 = 61.62732357;
/// Seedance 2.5 — 480p price with video references, USD cents per second.
const SEEDANCE_2P5_VIDEO_REFERENCE_CENTS_PER_SECOND_480P: f64 = 7.24279835;
/// Seedance 2.5 — 720p price with video references, USD cents per second.
const SEEDANCE_2P5_VIDEO_REFERENCE_CENTS_PER_SECOND_720P: f64 = 15.84362140;
/// Seedance 2.5 — 1080p price with video references, USD cents per second.
const SEEDANCE_2P5_VIDEO_REFERENCE_CENTS_PER_SECOND_1080P: f64 = 27.39973680;
/// Seedance 2.5 — 1080p price with video references, USD cents per second
/// (updated 2026-09-17).
const SEEDANCE_2P5_VIDEO_REFERENCE_CENTS_PER_SECOND_1080P: f64 = 38.49728574;
/// ArtCraft's price (USD cents) for Seedance 2.5.
///
@@ -154,10 +155,11 @@ const SEEDANCE_2P5_VIDEO_REFERENCE_CENTS_PER_SECOND_1080P: f64 = 27.39973680;
/// seconds = output duration. With video references, the per-second rate
/// drops but the billed seconds are the output duration PLUS the total
/// seconds of reference video input (clamped to the 4..=30 second billing
/// range). The fractional total is rounded UP to a whole cent. No batching.
/// range). Multiply by batch count before rounding UP to a whole cent.
pub fn seedance_2p5_usd_cents(
resolution: CommonResolution,
duration_seconds: u16,
batch_count: u16,
has_video_references: bool,
maybe_total_input_seconds: Option<u16>,
) -> u64 {
@@ -192,21 +194,22 @@ pub fn seedance_2p5_usd_cents(
(rate, u64::from(duration_seconds))
};
(cents_per_second * billed_seconds as f64).ceil() as u64
(cents_per_second * billed_seconds as f64 * batch_count as f64).ceil() as u64
}
/// Seedance 2.5 Ultra — 480p price, USD cents per second.
const SEEDANCE_2P5_ULTRA_CENTS_PER_SECOND_480P: f64 = 13.90946502;
/// Seedance 2.5 Ultra — 720p price, USD cents per second.
const SEEDANCE_2P5_ULTRA_CENTS_PER_SECOND_720P: f64 = 31.56378601;
/// Seedance 2.5 Ultra — 1080p price, USD cents per second.
const SEEDANCE_2P5_ULTRA_CENTS_PER_SECOND_1080P: f64 = 50.10486922;
/// Seedance 2.5 Ultra — 1080p price, USD cents per second (updated 2026-09-17).
const SEEDANCE_2P5_ULTRA_CENTS_PER_SECOND_1080P: f64 = 67.22980753;
/// Seedance 2.5 Ultra — 480p price with video references, USD cents per second.
const SEEDANCE_2P5_ULTRA_VIDEO_REFERENCE_CENTS_PER_SECOND_480P: f64 = 8.55967078;
/// Seedance 2.5 Ultra — 720p price with video references, USD cents per second.
const SEEDANCE_2P5_ULTRA_VIDEO_REFERENCE_CENTS_PER_SECOND_720P: f64 = 18.72427984;
/// Seedance 2.5 Ultra — 1080p price with video references, USD cents per second.
const SEEDANCE_2P5_ULTRA_VIDEO_REFERENCE_CENTS_PER_SECOND_1080P: f64 = 29.93674947;
/// Seedance 2.5 Ultra — 1080p price with video references, USD cents per
/// second (updated 2026-09-17).
const SEEDANCE_2P5_ULTRA_VIDEO_REFERENCE_CENTS_PER_SECOND_1080P: f64 = 41.84487580;
/// ArtCraft's price (USD cents) for Seedance 2.5 Ultra.
///
@@ -215,11 +218,12 @@ const SEEDANCE_2P5_ULTRA_VIDEO_REFERENCE_CENTS_PER_SECOND_1080P: f64 = 29.936749
/// this fallback is only a failsafe). Without video references, billed
/// seconds = output duration. With video references, the per-second rate
/// drops but the billed seconds are the output duration PLUS the total
/// seconds of reference video input. The fractional total is rounded UP to
/// a whole cent. No batching.
/// seconds of reference video input. Multiply by batch count before rounding
/// UP to a whole cent.
pub fn seedance_2p5_ultra_usd_cents(
resolution: CommonResolution,
duration_seconds: u16,
batch_count: u16,
has_video_references: bool,
maybe_total_input_seconds: Option<u16>,
) -> u64 {
@@ -254,7 +258,7 @@ pub fn seedance_2p5_ultra_usd_cents(
(rate, u64::from(duration_seconds))
};
(cents_per_second * billed_seconds as f64).ceil() as u64
(cents_per_second * billed_seconds as f64 * batch_count as f64).ceil() as u64
}
#[allow(clippy::too_many_arguments)]
@@ -134,6 +134,7 @@ fn plan_batch_count(
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
1 => Ok(None),
2 => Ok(Some(KinoviHappyHorse1p0BatchCount::Two)),
3 => Ok(Some(KinoviHappyHorse1p0BatchCount::Three)),
4 => Ok(Some(KinoviHappyHorse1p0BatchCount::Four)),
_ => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
@@ -143,10 +144,10 @@ fn plan_batch_count(
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade => {
Ok(Some(if count >= 4 { KinoviHappyHorse1p0BatchCount::Four } else { KinoviHappyHorse1p0BatchCount::Two }))
Ok(Some(KinoviHappyHorse1p0BatchCount::Four))
}
RequestMismatchMitigationStrategy::PayLessDowngrade => {
Ok(Some(if count <= 2 { KinoviHappyHorse1p0BatchCount::Two } else { KinoviHappyHorse1p0BatchCount::Four }))
Ok(Some(KinoviHappyHorse1p0BatchCount::Four))
}
},
}
@@ -177,6 +178,24 @@ mod tests {
// ── Materialized field conversions ──
#[test]
fn supported_batches_reach_cost_estimation() {
use crate::generate::generate_video::providers::kinovi::happy_horse_1p0::cost::KinoviHappyHorse1p0CostState;
for count in 1..=4 {
let builder = GenerateVideoRequestBuilder {
video_batch_count: Some(count),
duration_seconds: Some(4),
resolution: Some(RouterResolution::SevenTwentyP),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..happy_horse_builder()
};
let draft = unwrap_draft(build_kinovi_happy_horse_1p0(builder));
let cost = KinoviHappyHorse1p0CostState::from_draft(&draft).estimate_cost();
assert_eq!(cost.cost_in_credits, Some((132.0 * f64::from(count)).round() as u64), "batch {count}");
}
}
mod materialized_field_conversions {
use super::*;
@@ -153,7 +153,7 @@ fn plan_output_resolution(
}
}
// Seedance2p0 supports batch counts of 1 through 4.
// Seedance2p0 supports batch counts of 1 through 8.
fn plan_batch_count(
video_batch_count: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
@@ -165,7 +165,11 @@ fn plan_batch_count(
2 => Ok(KinoviBatchCount::Two),
3 => Ok(KinoviBatchCount::Three),
4 => Ok(KinoviBatchCount::Four),
// 5 and above:
5 => Ok(KinoviBatchCount::Five),
6 => Ok(KinoviBatchCount::Six),
7 => Ok(KinoviBatchCount::Seven),
8 => Ok(KinoviBatchCount::Eight),
// 9 and above:
_ => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
@@ -173,8 +177,8 @@ fn plan_batch_count(
value: format!("{}", count),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(KinoviBatchCount::Four),
RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(KinoviBatchCount::Four),
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(KinoviBatchCount::Eight),
RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(KinoviBatchCount::Eight),
},
}
}
@@ -239,6 +243,24 @@ mod tests {
// ── Materialized field conversions ──
#[test]
fn supported_batches_reach_cost_estimation() {
use crate::generate::generate_video::providers::kinovi::seedance_2p0::cost::KinoviSeedance2p0CostState;
for count in 1..=8 {
let builder = GenerateVideoRequestBuilder {
video_batch_count: Some(count),
duration_seconds: Some(4),
resolution: Some(RouterResolution::FourEightyP),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..kinovi_web_builder()
};
let draft = unwrap_draft(build_kinovi_seedance_2p0(builder));
let cost = KinoviSeedance2p0CostState::from_draft(&draft).estimate_cost();
assert_eq!(cost.cost_in_credits, Some((60.0 * f64::from(count)).round() as u64), "batch {count}");
}
}
mod materialized_field_conversions {
use super::*;
@@ -167,7 +167,7 @@ fn plan_output_resolution(
}
}
// Seedance2p0Fast supports batch counts of 1 through 4.
// Seedance2p0Fast supports batch counts of 1 through 8.
fn plan_batch_count(
video_batch_count: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
@@ -179,7 +179,11 @@ fn plan_batch_count(
2 => Ok(KinoviBatchCount::Two),
3 => Ok(KinoviBatchCount::Three),
4 => Ok(KinoviBatchCount::Four),
// 5 and above:
5 => Ok(KinoviBatchCount::Five),
6 => Ok(KinoviBatchCount::Six),
7 => Ok(KinoviBatchCount::Seven),
8 => Ok(KinoviBatchCount::Eight),
// 9 and above:
_ => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
@@ -187,8 +191,8 @@ fn plan_batch_count(
value: format!("{}", count),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(KinoviBatchCount::Four),
RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(KinoviBatchCount::Four),
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(KinoviBatchCount::Eight),
RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(KinoviBatchCount::Eight),
},
}
}
@@ -255,6 +259,24 @@ mod tests {
// ── Materialized field conversions ──
#[test]
fn supported_batches_reach_cost_estimation() {
use crate::generate::generate_video::providers::kinovi::seedance_2p0_fast::cost::KinoviSeedance2p0FastCostState;
for count in 1..=8 {
let builder = GenerateVideoRequestBuilder {
video_batch_count: Some(count),
duration_seconds: Some(4),
resolution: Some(RouterResolution::FourEightyP),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..kinovi_web_fast_builder()
};
let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(builder));
let cost = KinoviSeedance2p0FastCostState::from_draft(&draft).estimate_cost();
assert_eq!(cost.cost_in_credits, Some((42.0 * f64::from(count)).round() as u64), "batch {count}");
}
}
mod materialized_field_conversions {
use super::*;
@@ -1,5 +1,6 @@
use kinovi_web_client::generate::video::generate_seedance_2p5::{
KinoviSeedance2p5AspectRatio as KinoviAspectRatio,
KinoviSeedance2p5BatchCount as KinoviBatchCount,
KinoviSeedance2p5Bitrate,
KinoviSeedance2p5OutputFormat,
KinoviSeedance2p5OutputResolution as KinoviOutputResolution,
@@ -61,7 +62,7 @@ fn do_build_kinovi_seedance_2p5(mut builder: GenerateVideoRequestBuilder) -> Res
// The planned value below only applies to reference mode.
let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio.take(), strategy)?;
let resolution = plan_output_resolution(builder.resolution.take(), strategy)?;
plan_batch_count(builder.video_batch_count.take(), strategy)?;
let batch_count = plan_batch_count(builder.video_batch_count.take(), strategy)?;
let duration_seconds = plan_duration(builder.duration_seconds.take(), strategy)?;
let prompt = builder.prompt.take().unwrap_or_default();
@@ -82,6 +83,7 @@ fn do_build_kinovi_seedance_2p5(mut builder: GenerateVideoRequestBuilder) -> Res
};
Ok(KinoviSeedance2p5DraftState {
batch_count,
aspect_ratio,
resolution,
duration_seconds,
@@ -210,16 +212,23 @@ fn plan_output_resolution(
}
}
// Seedance 2.5 generates a single video per request (no batching).
// Seedance 2.5 supports batches of 1–8 videos.
fn plan_batch_count(
video_batch_count: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<(), ArtcraftRouterError> {
) -> Result<KinoviBatchCount, ArtcraftRouterError> {
let count = video_batch_count.unwrap_or(1);
match count {
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
1 => Ok(()),
// Over the maximum of 1.
1 => Ok(KinoviBatchCount::One),
2 => Ok(KinoviBatchCount::Two),
3 => Ok(KinoviBatchCount::Three),
4 => Ok(KinoviBatchCount::Four),
5 => Ok(KinoviBatchCount::Five),
6 => Ok(KinoviBatchCount::Six),
7 => Ok(KinoviBatchCount::Seven),
8 => Ok(KinoviBatchCount::Eight),
// Over the maximum of 8.
_ => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
@@ -228,7 +237,7 @@ fn plan_batch_count(
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade
| RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(()),
| RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(KinoviBatchCount::Eight),
},
}
}
@@ -355,6 +364,24 @@ mod tests {
}
}
#[test]
fn supported_batches_reach_cost_estimation() {
use crate::generate::generate_video::providers::kinovi::seedance_2p5::cost::KinoviSeedance2p5CostState;
for count in 1..=8 {
let builder = GenerateVideoRequestBuilder {
video_batch_count: Some(count),
duration_seconds: Some(4),
resolution: Some(RouterResolution::FourEightyP),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..base_builder()
};
let draft = unwrap_draft(build_kinovi_seedance_2p5(builder));
let cost = KinoviSeedance2p5CostState::from_draft(&draft).estimate_cost();
assert_eq!(cost.cost_in_credits, Some((104.0 * f64::from(count)).round() as u64), "batch {count}");
}
}
mod materialized_field_conversions {
use super::*;
@@ -1,5 +1,5 @@
use kinovi_web_client::generate::video::generate_seedance_2p5::{
GenerateSeedance2p5Request, KinoviSeedance2p5Modality, KinoviSeedance2p5OutputResolution,
GenerateSeedance2p5Request, KinoviSeedance2p5BatchCount, KinoviSeedance2p5Modality, KinoviSeedance2p5OutputResolution,
MAX_BILLED_INPUT_SECONDS, MIN_BILLED_INPUT_SECONDS,
};
use kinovi_web_client::pricing::kinovi_cost_calculator_trait::KinoviCostCalculatorTrait;
@@ -18,6 +18,7 @@ use crate::generate::generate_video::providers::kinovi::seedance_2p5::request::K
pub struct KinoviSeedance2p5CostState {
pub resolution: Option<KinoviSeedance2p5OutputResolution>,
pub duration_seconds: u8,
pub batch_count: Option<KinoviSeedance2p5BatchCount>,
pub has_video_references: bool,
pub total_input_seconds: Option<u8>,
}
@@ -32,6 +33,7 @@ impl KinoviSeedance2p5CostState {
Self {
resolution: request.request.output_resolution,
duration_seconds: request.request.duration_seconds,
batch_count: request.request.batch_count,
has_video_references,
total_input_seconds: request.request.total_input_seconds,
}
@@ -49,6 +51,7 @@ impl KinoviSeedance2p5CostState {
Self {
resolution: draft.resolution,
duration_seconds: draft.duration_seconds,
batch_count: Some(draft.batch_count),
has_video_references,
total_input_seconds: draft.total_input_seconds,
}
@@ -57,7 +60,7 @@ impl KinoviSeedance2p5CostState {
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
// Delegate to the kinovi_web_client calculator via a pricing-only
// request. Only the modality's video references, the resolution, the
// duration, and the input seconds affect the price.
// duration, batch count, and the input seconds affect the price.
let modality = if self.has_video_references {
KinoviSeedance2p5Modality::Reference {
aspect_ratio: None,
@@ -89,6 +92,7 @@ impl KinoviSeedance2p5CostState {
modality,
output_resolution: self.resolution,
duration_seconds: self.duration_seconds,
batch_count: self.batch_count,
total_input_seconds,
use_face_blur_hack: None,
maybe_bitrate: None,
@@ -100,7 +104,7 @@ impl KinoviSeedance2p5CostState {
// per-model credit rate at our bulk credit purchase rate).
let costs = pricing_request.calculate_enterprise_costs();
// 2.5 bills whole credits at 480p/720p, so the rounding below is exact
// there; 1080p bills fractional credits (103.25/sec) and rounds to the
// there; 1080p bills fractional credits (136.23/sec) and rounds to the
// nearest credit. The USD cents (the authoritative charge) are rounded
// up.
let cost_in_credits = costs.kinovi_credits.round() as u64;
@@ -125,7 +129,7 @@ mod tests {
use super::*;
// ── Credits without video references (26/sec at 480p, 59/sec at 720p,
// 103.25/sec at 1080p enterprise) ──
// 136.23/sec at 1080p enterprise) ──
mod credits_without_video_references {
use super::*;
@@ -145,11 +149,11 @@ mod tests {
#[test]
fn credits_1080p() {
// 103.25/sec enterprise; fractional totals round to the nearest credit
// (516.25 → 516, 1032.5 → 1033, 3097.5 → 3098).
assert_eq!(credits(Some(KinoviOutputResolution::TenEightyP), 5, false, None), 516);
assert_eq!(credits(Some(KinoviOutputResolution::TenEightyP), 10, false, None), 1033);
assert_eq!(credits(Some(KinoviOutputResolution::TenEightyP), 30, false, None), 3098);
// 136.23/sec enterprise; fractional totals round to the nearest credit
// (681.15 → 681, 1362.3 → 1362, 4086.9 → 4087).
assert_eq!(credits(Some(KinoviOutputResolution::TenEightyP), 5, false, None), 681);
assert_eq!(credits(Some(KinoviOutputResolution::TenEightyP), 10, false, None), 1362);
assert_eq!(credits(Some(KinoviOutputResolution::TenEightyP), 30, false, None), 4087);
}
#[test]
@@ -164,7 +168,7 @@ mod tests {
}
// ── Credits with video references (16/sec at 480p, 35/sec at 720p,
// 61.69/sec at 1080p enterprise, over output duration + input seconds) ──
// 81.4/sec at 1080p enterprise, over output duration + input seconds) ──
mod credits_with_video_references {
use super::*;
@@ -173,8 +177,8 @@ mod tests {
fn thirty_second_output_with_ten_input_seconds_bills_forty() {
assert_eq!(credits(Some(KinoviOutputResolution::FourEightyP), 30, true, Some(10)), 16 * 40);
assert_eq!(credits(Some(KinoviOutputResolution::SevenTwentyP), 30, true, Some(10)), 35 * 40);
// 61.69/sec enterprise × 40 = 2467.6 → 2468 credits.
assert_eq!(credits(Some(KinoviOutputResolution::TenEightyP), 30, true, Some(10)), 2468);
// 81.4/sec enterprise × 40 = 3256 credits.
assert_eq!(credits(Some(KinoviOutputResolution::TenEightyP), 30, true, Some(10)), 3256);
}
#[test]
@@ -230,7 +234,7 @@ mod tests {
has_video_references: bool,
total_input_seconds: Option<u8>,
) -> KinoviSeedance2p5CostState {
KinoviSeedance2p5CostState { resolution, duration_seconds, has_video_references, total_input_seconds }
KinoviSeedance2p5CostState { batch_count: None, resolution, duration_seconds, has_video_references, total_input_seconds }
}
fn credits(
@@ -1,5 +1,5 @@
use kinovi_web_client::generate::video::generate_seedance_2p5::{
GenerateSeedance2p5Request, KinoviSeedance2p5AspectRatio, KinoviSeedance2p5Bitrate, KinoviSeedance2p5Modality,
GenerateSeedance2p5Request, KinoviSeedance2p5BatchCount, KinoviSeedance2p5AspectRatio, KinoviSeedance2p5Bitrate, KinoviSeedance2p5Modality,
KinoviSeedance2p5OutputFormat, KinoviSeedance2p5OutputResolution,
};
@@ -27,6 +27,7 @@ pub struct KinoviSeedance2p5DraftState {
pub resolution: Option<KinoviSeedance2p5OutputResolution>,
pub duration_seconds: u8,
pub batch_count: KinoviSeedance2p5BatchCount,
/// Calculation-only: total seconds of reference video input, summed across
/// all reference videos (each rounded up to a whole second).
@@ -113,6 +114,7 @@ impl KinoviSeedance2p5DraftState {
modality,
output_resolution: self.resolution,
duration_seconds: self.duration_seconds,
batch_count: Some(self.batch_count),
total_input_seconds: self.total_input_seconds,
use_face_blur_hack: None,
maybe_bitrate: self.maybe_bitrate,
@@ -1,5 +1,6 @@
use kinovi_web_client::generate::video::generate_seedance_2p5_preview::{
KinoviSeedance2p5PreviewAspectRatio as KinoviAspectRatio,
KinoviSeedance2p5PreviewBatchCount as KinoviBatchCount,
KinoviSeedance2p5PreviewOutputResolution as KinoviOutputResolution,
};
@@ -47,7 +48,7 @@ fn do_build_kinovi_seedance_2p5_preview(mut builder: GenerateVideoRequestBuilder
let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio.take(), strategy)?;
let resolution = plan_output_resolution(builder.resolution.take(), strategy)?;
plan_batch_count(builder.video_batch_count.take(), strategy)?;
let batch_count = plan_batch_count(builder.video_batch_count.take(), strategy)?;
let duration_seconds = plan_duration(builder.duration_seconds.take(), strategy)?;
let prompt = builder.prompt.take().unwrap_or_default();
@@ -61,6 +62,7 @@ fn do_build_kinovi_seedance_2p5_preview(mut builder: GenerateVideoRequestBuilder
};
Ok(KinoviSeedance2p5PreviewDraftState {
batch_count,
aspect_ratio,
resolution,
duration_seconds,
@@ -177,16 +179,23 @@ fn plan_output_resolution(
}
}
// Seedance 2.5 Preview generates a single video per request (no batching).
// Seedance 2.5 Preview supports batches of 1–8 videos.
fn plan_batch_count(
video_batch_count: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<(), ArtcraftRouterError> {
) -> Result<KinoviBatchCount, ArtcraftRouterError> {
let count = video_batch_count.unwrap_or(1);
match count {
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
1 => Ok(()),
// Over the maximum of 1.
1 => Ok(KinoviBatchCount::One),
2 => Ok(KinoviBatchCount::Two),
3 => Ok(KinoviBatchCount::Three),
4 => Ok(KinoviBatchCount::Four),
5 => Ok(KinoviBatchCount::Five),
6 => Ok(KinoviBatchCount::Six),
7 => Ok(KinoviBatchCount::Seven),
8 => Ok(KinoviBatchCount::Eight),
// Over the maximum of 8.
_ => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
@@ -195,7 +204,7 @@ fn plan_batch_count(
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade
| RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(()),
| RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(KinoviBatchCount::Eight),
},
}
}
@@ -248,6 +257,24 @@ mod tests {
use super::*;
#[test]
fn supported_batches_reach_cost_estimation() {
use crate::generate::generate_video::providers::kinovi::seedance_2p5_preview::cost::KinoviSeedance2p5PreviewCostState;
for count in 1..=8 {
let builder = GenerateVideoRequestBuilder {
video_batch_count: Some(count),
duration_seconds: Some(4),
resolution: Some(RouterResolution::FourEightyP),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..preview_builder()
};
let draft = unwrap_draft(build_kinovi_seedance_2p5_preview(builder));
let cost = KinoviSeedance2p5PreviewCostState::from_draft(&draft).estimate_cost();
assert_eq!(cost.cost_in_credits, Some((168.52 * f64::from(count)).round() as u64), "batch {count}");
}
}
mod materialized_field_conversions {
use super::*;
@@ -346,19 +373,20 @@ mod tests {
}
#[test]
fn batch_over_one_downgrades_to_single() {
fn batch_over_eight_downgrades_to_eight() {
let builder = GenerateVideoRequestBuilder {
video_batch_count: Some(4),
video_batch_count: Some(9),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayLessDowngrade,
..preview_builder()
};
assert!(build_kinovi_seedance_2p5_preview(builder).is_ok());
let draft = unwrap_draft(build_kinovi_seedance_2p5_preview(builder));
assert!(matches!(draft.batch_count, KinoviBatchCount::Eight));
}
#[test]
fn batch_over_one_error_out() {
fn batch_over_eight_error_out() {
let builder = GenerateVideoRequestBuilder {
video_batch_count: Some(4),
video_batch_count: Some(9),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..preview_builder()
};
@@ -1,18 +1,18 @@
use kinovi_web_client::pricing::kinovi_cost_calculator_trait::KinoviCostCalculatorTrait;
use kinovi_web_client::generate::video::generate_seedance_2p5_preview::{
GenerateSeedance2p5PreviewRequest, KinoviSeedance2p5PreviewOutputResolution,
GenerateSeedance2p5PreviewRequest, KinoviSeedance2p5PreviewBatchCount, KinoviSeedance2p5PreviewOutputResolution,
};
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
use crate::generate::generate_video::providers::kinovi::seedance_2p5_preview::draft::KinoviSeedance2p5PreviewDraftState;
use crate::generate::generate_video::providers::kinovi::seedance_2p5_preview::request::KinoviSeedance2p5PreviewRequestState;
/// Only the resolution and duration matter — Seedance 2.5 Preview has no
/// video-reference surcharge and no batching, so references never change the
/// price.
/// Resolution, duration, and batch count determine the price.
/// Seedance 2.5 Preview has no video-reference surcharge.
pub struct KinoviSeedance2p5PreviewCostState {
pub resolution: Option<KinoviSeedance2p5PreviewOutputResolution>,
pub duration_seconds: u8,
pub batch_count: Option<KinoviSeedance2p5PreviewBatchCount>,
}
impl KinoviSeedance2p5PreviewCostState {
@@ -20,6 +20,7 @@ impl KinoviSeedance2p5PreviewCostState {
Self {
resolution: request.request.output_resolution,
duration_seconds: request.request.duration_seconds,
batch_count: request.request.batch_count,
}
}
@@ -27,6 +28,7 @@ impl KinoviSeedance2p5PreviewCostState {
Self {
resolution: draft.resolution,
duration_seconds: draft.duration_seconds,
batch_count: Some(draft.batch_count),
}
}
@@ -34,6 +36,7 @@ impl KinoviSeedance2p5PreviewCostState {
let pricing_request = GenerateSeedance2p5PreviewRequest {
output_resolution: self.resolution,
duration_seconds: self.duration_seconds,
batch_count: self.batch_count,
// No impact on price (references never affect 2.5 Preview pricing)
prompt: String::new(),
@@ -139,7 +142,7 @@ mod tests {
// ── Helpers ──
fn cost_state(resolution: Option<KinoviOutputResolution>, duration_seconds: u8) -> KinoviSeedance2p5PreviewCostState {
KinoviSeedance2p5PreviewCostState { resolution, duration_seconds }
KinoviSeedance2p5PreviewCostState { batch_count: None, resolution, duration_seconds }
}
fn credits(resolution: Option<KinoviOutputResolution>, duration_seconds: u8) -> u64 {
@@ -1,5 +1,5 @@
use kinovi_web_client::generate::video::generate_seedance_2p5_preview::{
GenerateSeedance2p5PreviewRequest, KinoviSeedance2p5PreviewAspectRatio,
GenerateSeedance2p5PreviewRequest, KinoviSeedance2p5PreviewBatchCount, KinoviSeedance2p5PreviewAspectRatio,
KinoviSeedance2p5PreviewOutputResolution,
};
@@ -20,6 +20,7 @@ pub struct KinoviSeedance2p5PreviewDraftState {
pub aspect_ratio: KinoviSeedance2p5PreviewAspectRatio,
pub resolution: Option<KinoviSeedance2p5PreviewOutputResolution>,
pub duration_seconds: u8,
pub batch_count: KinoviSeedance2p5PreviewBatchCount,
pub unhandled_request_state: Option<KinoviSeedance2p5PreviewRemainingItems>,
}
@@ -67,6 +68,7 @@ impl KinoviSeedance2p5PreviewDraftState {
aspect_ratio: Some(self.aspect_ratio),
output_resolution: self.resolution,
duration_seconds: self.duration_seconds,
batch_count: Some(self.batch_count),
reference_image_urls,
reference_video_urls,
reference_audio_urls,
@@ -0,0 +1,96 @@
# Wan 3.0 pricing and September 23 promotion
Last verified: **2026-09-18**. Recheck after **2026-09-23** (starting September 24).
The code and tests intentionally use the promotional Wan 3.0 rates below.
Prices stay at these values until a reviewed code change; there is no automatic
date-based switch. Kinovi advertises 30% off until September 23 without specifying
the cutoff time or timezone. Confirm the live prices when updating.
[Kinovi promotion and pricing](https://kinovi.ai/models/wan3-text-to-video)
## Rates currently used
All values are **Kinovi credits per output second**. Each family's rates apply
to text-to-video, image-to-video, and reference-to-video. Reference media does
not add billed input seconds. Sources for regular Wan:
[text](https://kinovi.ai/models/wan3-text-to-video),
[image](https://kinovi.ai/models/wan3-image-to-video),
[reference](https://kinovi.ai/models/wan3-ref-to-video).
| Family | Resolution | Credits/second | Current status |
|---------------|------------|----------------|--------------------------------|
| Wan 3.0 | 480p | 9.98 | Promotional until Sep 23 |
| Wan 3.0 | 720p | 19.96 | Promotional until Sep 23 |
| Wan 3.0 | 1080p | 39.91 | Promotional until Sep 23 |
| Wan 3.0 Prime | 480p | 18.86 | No advertised Sep 23 promotion |
| Wan 3.0 Prime | 720p | 38.83 | No advertised Sep 23 promotion |
| Wan 3.0 Prime | 1080p | 77.66 | No advertised Sep 23 promotion |
Prime sources:
[text](https://kinovi.ai/models/wan3-prime-text-to-video),
[image](https://kinovi.ai/models/wan3-prime-image-to-video),
[reference](https://kinovi.ai/models/wan3-prime-ref-to-video).
Prime 480p for 30 seconds is **565.8 credits**; the original supplied reference
example's 568.8 was a typo. Recheck Prime independently; the regular Wan
promotion ending does not establish a Prime price change.
The promotional rates already include the discount. Total credits are the
selected rate multiplied by output seconds. Both pricing tiers currently use
the same credit rates; only their credit purchase rates differ. The client uses
192.98 credits/USD for Consumer and 243.16 for Enterprise, defined in
[pricing constants](src/pricing/constants.rs).
[KinoviPricingTier::cost_from_credits](src/pricing/kinovi_pricing_tier.rs) rounds
the total to hundredths of a credit and calculates fractional, floor, and ceiling
USD cents. Use those purchase rates for client costs; Kinovi's displayed USD
prices use a different conversion.
## Advertised non-promotional rates to recheck
These are the crossed-out prices shown alongside the promotion on September 18.
They are **reference values for the later update**, not active calculator rates
or a guarantee of what Kinovi will charge after the promotion.
The same values appear on the regular Wan
[text](https://kinovi.ai/models/wan3-text-to-video),
[image](https://kinovi.ai/models/wan3-image-to-video), and
[reference](https://kinovi.ai/models/wan3-ref-to-video) pricing pages.
| Resolution | Current promotional credits/second | Advertised non-promotional credits/second |
|------------|------------------------------------|-------------------------------------------|
| 480p | 9.98 | 14.2571 |
| 720p | 19.96 | 28.5143 |
| 1080p | 39.91 | 57.0143 |
## How to update after September 23
1. Reopen the six model pages above and confirm the promotion ended, the exact
credit rates, and total-credit rounding. Check consumer quotes for multiple
durations and every resolution; the web client uses the consumer workflow.
Record the verification date and evidence in this document.
2. Update `WAN_CREDIT_HUNDREDTHS_PER_SECOND` and `Wan3p0Model::calculate_costs`
in [wan_3p0.rs](src/generate/video/wan_3p0.rs). The current table is
`[998, 1996, 3991]` in 480p/720p/1080p order. **The advertised replacement
rates have four decimal places:** the existing hundredths representation
cannot preserve them. If confirmed, use a sufficient fixed-point scale
(for example 10,000), rename the constants to match their units, and adjust
the final divisor. Preserve rate precision until after multiplying by
duration; verify provider rounding before changing the shared credit
conversion. If both families change scale, rescale the Prime integers while
preserving Prime's monetary rates unless a separate price change is confirmed.
3. Update the literal expectations in
[wan_3p0_tests.rs](src/generate/video/wan_3p0_tests.rs): `WAN_CREDITS`,
`WAN_CENTS`, the `199.6` regular `reference_media_tests!` argument (720p/10s),
and the `99.8` regular image `check!` argument (720p/5s). The default-price
and seed/audio tests already read the literal tables. Keep expected prices
independent of production rate constants and assert both pricing tiers.
Update Prime expectations only if its verified monetary rates change.
4. Update this document and the dated comments beside the production constants
and test tables. Keep the promotional snapshot as historical context.
5. Run from the repository root:
```sh
cargo test --offline -p kinovi_web_client --lib wan_3p0_tests
cargo test --offline -p kinovi_web_client --lib
```
The Wan HTTP tests use a local server and need permission to bind localhost.
These commands leave the existing ignored live-generation tests ignored.
@@ -49,6 +49,7 @@ pub enum KinoviHappyHorse1p0OutputResolution {
pub enum KinoviHappyHorse1p0BatchCount {
One,
Two,
Three,
Four,
}
@@ -79,6 +80,7 @@ impl GenerateHappyHorse1p0Request {
let batch_multiplier: u64 = match self.batch_count {
None | Some(KinoviHappyHorse1p0BatchCount::One) => 1,
Some(KinoviHappyHorse1p0BatchCount::Two) => 2,
Some(KinoviHappyHorse1p0BatchCount::Three) => 3,
Some(KinoviHappyHorse1p0BatchCount::Four) => 4,
};
@@ -174,6 +176,7 @@ fn map_batch_count(bc: Option<KinoviHappyHorse1p0BatchCount>) -> KinoviBatchCoun
match bc {
Some(KinoviHappyHorse1p0BatchCount::One) | None => KinoviBatchCountRaw::One,
Some(KinoviHappyHorse1p0BatchCount::Two) => KinoviBatchCountRaw::Two,
Some(KinoviHappyHorse1p0BatchCount::Three) => KinoviBatchCountRaw::Three,
Some(KinoviHappyHorse1p0BatchCount::Four) => KinoviBatchCountRaw::Four,
}
}
@@ -275,11 +278,68 @@ mod tests {
mod batch_tests {
use super::*;
const BATCHES: [KinoviHappyHorse1p0BatchCount; 4] = [
KinoviHappyHorse1p0BatchCount::One,
KinoviHappyHorse1p0BatchCount::Two,
KinoviHappyHorse1p0BatchCount::Three,
KinoviHappyHorse1p0BatchCount::Four,
];
#[test]
fn batch_1_is_base() {
let base = r720(5).calculate_costs().kinovi_credits;
let explicit = make_request(5, None, Some(KinoviHappyHorse1p0BatchCount::One)).calculate_costs().kinovi_credits;
assert_eq!(base, explicit);
fn fixed_five_second_batch_prices() {
// Each pair is (credits, USD cents rounded up), for batches 1 through 4.
let cases = [
(KinoviHappyHorse1p0OutputResolution::SevenTwentyP, [
(165, 68), (330, 136), (495, 204), (660, 272),
]),
(KinoviHappyHorse1p0OutputResolution::TenEightyP, [
(330, 136), (660, 272), (990, 408), (1320, 544),
]),
];
for (resolution, prices) in cases {
for (batch, (credits, usd_cents)) in BATCHES.into_iter().zip(prices) {
let cost = make_request(5, Some(resolution), Some(batch)).calculate_costs();
assert_eq!(
(cost.kinovi_credits, cost.usd_cents_rounded_up),
(credits, usd_cents),
"{resolution:?} {batch:?}",
);
}
}
}
#[test]
fn all_batches_scale_every_resolution_and_round_the_total() {
let batches = [
(None, 1),
(Some(KinoviHappyHorse1p0BatchCount::One), 1),
(Some(KinoviHappyHorse1p0BatchCount::Two), 2),
(Some(KinoviHappyHorse1p0BatchCount::Three), 3),
(Some(KinoviHappyHorse1p0BatchCount::Four), 4),
];
for resolution in [
None,
Some(KinoviHappyHorse1p0OutputResolution::SevenTwentyP),
Some(KinoviHappyHorse1p0OutputResolution::TenEightyP),
] {
for duration in [3, 5, 15] {
let single = make_request(duration, resolution, None).calculate_costs();
for (batch, count) in batches {
let batched = make_request(duration, resolution, batch).calculate_costs();
let context = format!("{resolution:?} {duration}s {batch:?}");
let expected_credits = single.kinovi_credits * count;
assert_eq!(batched.kinovi_credits, expected_credits, "{context}: credits");
assert!(
(batched.usd_cents_fractional - single.usd_cents_fractional * count as f64).abs() < 1e-8,
"{context}: fractional cents",
);
// Happy Horse currently uses the legacy 243 credits/dollar conversion.
let numerator = expected_credits * 100;
assert_eq!(batched.usd_cents_rounded_up, numerator.div_ceil(243), "{context}: rounded up");
assert_eq!(batched.usd_cents_rounded_down, numerator / 243, "{context}: rounded down");
}
}
}
}
#[test]
@@ -68,6 +68,10 @@ pub enum KinoviSeedance2p0BatchCount {
Two,
Three,
Four,
Five,
Six,
Seven,
Eight,
}
#[derive(Debug, Clone, Copy)]
@@ -166,6 +170,10 @@ impl KinoviCostCalculatorTrait for GenerateSeedance2p0Request {
Some(KinoviSeedance2p0BatchCount::Two) => 2.0,
Some(KinoviSeedance2p0BatchCount::Three) => 3.0,
Some(KinoviSeedance2p0BatchCount::Four) => 4.0,
Some(KinoviSeedance2p0BatchCount::Five) => 5.0,
Some(KinoviSeedance2p0BatchCount::Six) => 6.0,
Some(KinoviSeedance2p0BatchCount::Seven) => 7.0,
Some(KinoviSeedance2p0BatchCount::Eight) => 8.0,
};
let output_seconds = f64::from(self.duration_seconds) * batch_multiplier;
@@ -274,6 +282,10 @@ fn map_batch_count(bc: Option<KinoviSeedance2p0BatchCount>) -> KinoviBatchCountR
Some(KinoviSeedance2p0BatchCount::Two) => KinoviBatchCountRaw::Two,
Some(KinoviSeedance2p0BatchCount::Three) => KinoviBatchCountRaw::Three,
Some(KinoviSeedance2p0BatchCount::Four) => KinoviBatchCountRaw::Four,
Some(KinoviSeedance2p0BatchCount::Five) => KinoviBatchCountRaw::Five,
Some(KinoviSeedance2p0BatchCount::Six) => KinoviBatchCountRaw::Six,
Some(KinoviSeedance2p0BatchCount::Seven) => KinoviBatchCountRaw::Seven,
Some(KinoviSeedance2p0BatchCount::Eight) => KinoviBatchCountRaw::Eight,
}
}
@@ -612,18 +624,165 @@ mod tests {
mod batch_tests {
use super::*;
use crate::test_utils::assert_batch_cost::assert_batch_cost;
const BATCHES: [KinoviSeedance2p0BatchCount; 8] = [
KinoviSeedance2p0BatchCount::One,
KinoviSeedance2p0BatchCount::Two,
KinoviSeedance2p0BatchCount::Three,
KinoviSeedance2p0BatchCount::Four,
KinoviSeedance2p0BatchCount::Five,
KinoviSeedance2p0BatchCount::Six,
KinoviSeedance2p0BatchCount::Seven,
KinoviSeedance2p0BatchCount::Eight,
];
// Each pair is (credits, USD cents rounded up), for batches 1 through 8.
// Pin literal totals so rate changes fail independently of scaling checks.
#[test]
fn fixed_five_second_batch_prices_without_video_reference() {
let cases = [
(KinoviSeedance2p0OutputResolution::FourEightyP, KinoviPricingTier::Consumer, [
(75.0, 39), (150.0, 78), (225.0, 117), (300.0, 156),
(375.0, 195), (450.0, 234), (525.0, 273), (600.0, 311),
]),
(KinoviSeedance2p0OutputResolution::SevenTwentyP, KinoviPricingTier::Consumer, [
(200.0, 104), (400.0, 208), (600.0, 311), (800.0, 415),
(1000.0, 519), (1200.0, 622), (1400.0, 726), (1600.0, 830),
]),
(KinoviSeedance2p0OutputResolution::TenEightyP, KinoviPricingTier::Consumer, [
(450.0, 234), (900.0, 467), (1350.0, 700), (1800.0, 933),
(2250.0, 1166), (2700.0, 1400), (3150.0, 1633), (3600.0, 1866),
]),
(KinoviSeedance2p0OutputResolution::FourK, KinoviPricingTier::Consumer, [
(1000.0, 519), (2000.0, 1037), (3000.0, 1555), (4000.0, 2073),
(5000.0, 2591), (6000.0, 3110), (7000.0, 3628), (8000.0, 4146),
]),
(KinoviSeedance2p0OutputResolution::FourEightyP, KinoviPricingTier::Enterprise, [
(75.0, 31), (150.0, 62), (225.0, 93), (300.0, 124),
(375.0, 155), (450.0, 186), (525.0, 216), (600.0, 247),
]),
(KinoviSeedance2p0OutputResolution::SevenTwentyP, KinoviPricingTier::Enterprise, [
(189.5, 78), (379.0, 156), (568.5, 234), (758.0, 312),
(947.5, 390), (1137.0, 468), (1326.5, 546), (1516.0, 624),
]),
(KinoviSeedance2p0OutputResolution::TenEightyP, KinoviPricingTier::Enterprise, [
(450.0, 186), (900.0, 371), (1350.0, 556), (1800.0, 741),
(2250.0, 926), (2700.0, 1111), (3150.0, 1296), (3600.0, 1481),
]),
(KinoviSeedance2p0OutputResolution::FourK, KinoviPricingTier::Enterprise, [
(1000.0, 412), (2000.0, 823), (3000.0, 1234), (4000.0, 1646),
(5000.0, 2057), (6000.0, 2468), (7000.0, 2879), (8000.0, 3291),
]),
];
for (resolution, tier, prices) in cases {
for (batch, (credits, usd_cents)) in BATCHES.into_iter().zip(prices) {
let request = build_request(5, Some(resolution), Some(batch));
let cost = request.calculate_costs(tier).total_cost;
assert_eq!(
(cost.kinovi_credits, cost.usd_cents_rounded_up),
(credits, usd_cents),
"{resolution:?} {tier:?} {batch:?}",
);
}
}
}
#[test]
fn batch_multiplies_at_both_tiers() {
fn fixed_five_second_batch_prices_with_video_reference() {
let cases = [
(KinoviSeedance2p0OutputResolution::FourEightyP, KinoviPricingTier::Consumer, [
(95.0, 50), (190.0, 99), (285.0, 148), (380.0, 197),
(475.0, 247), (570.0, 296), (665.0, 345), (760.0, 394),
]),
(KinoviSeedance2p0OutputResolution::SevenTwentyP, KinoviPricingTier::Consumer, [
(240.0, 125), (480.0, 249), (720.0, 374), (960.0, 498),
(1200.0, 622), (1440.0, 747), (1680.0, 871), (1920.0, 995),
]),
(KinoviSeedance2p0OutputResolution::TenEightyP, KinoviPricingTier::Consumer, [
(540.0, 280), (1080.0, 560), (1620.0, 840), (2160.0, 1120),
(2700.0, 1400), (3240.0, 1679), (3780.0, 1959), (4320.0, 2239),
]),
(KinoviSeedance2p0OutputResolution::FourK, KinoviPricingTier::Consumer, [
(1200.0, 622), (2400.0, 1244), (3600.0, 1866), (4800.0, 2488),
(6000.0, 3110), (7200.0, 3731), (8400.0, 4353), (9600.0, 4975),
]),
(KinoviSeedance2p0OutputResolution::FourEightyP, KinoviPricingTier::Enterprise, [
(95.0, 40), (190.0, 79), (285.0, 118), (380.0, 157),
(475.0, 196), (570.0, 235), (665.0, 274), (760.0, 313),
]),
(KinoviSeedance2p0OutputResolution::SevenTwentyP, KinoviPricingTier::Enterprise, [
(227.4, 94), (454.8, 188), (682.2, 281), (909.6, 375),
(1137.0, 468), (1364.4, 562), (1591.8, 655), (1819.2, 749),
]),
(KinoviSeedance2p0OutputResolution::TenEightyP, KinoviPricingTier::Enterprise, [
(540.0, 223), (1080.0, 445), (1620.0, 667), (2160.0, 889),
(2700.0, 1111), (3240.0, 1333), (3780.0, 1555), (4320.0, 1777),
]),
(KinoviSeedance2p0OutputResolution::FourK, KinoviPricingTier::Enterprise, [
(1200.0, 494), (2400.0, 988), (3600.0, 1481), (4800.0, 1975),
(6000.0, 2468), (7200.0, 2962), (8400.0, 3455), (9600.0, 3949),
]),
];
for (resolution, tier, prices) in cases {
for (batch, (credits, usd_cents)) in BATCHES.into_iter().zip(prices) {
let request = build_request(5, Some(resolution), Some(batch));
let request = with_video_ref(request);
let cost = request.calculate_costs(tier).total_cost;
assert_eq!(
(cost.kinovi_credits, cost.usd_cents_rounded_up),
(credits, usd_cents),
"{resolution:?} {tier:?} {batch:?}",
);
}
}
}
#[test]
fn all_batches_scale_every_resolution_and_surcharge_at_both_tiers() {
let batches = [
(None, 1),
(Some(KinoviSeedance2p0BatchCount::One), 1),
(Some(KinoviSeedance2p0BatchCount::Two), 2),
(Some(KinoviSeedance2p0BatchCount::Three), 3),
(Some(KinoviSeedance2p0BatchCount::Four), 4),
(Some(KinoviSeedance2p0BatchCount::Five), 5),
(Some(KinoviSeedance2p0BatchCount::Six), 6),
(Some(KinoviSeedance2p0BatchCount::Seven), 7),
(Some(KinoviSeedance2p0BatchCount::Eight), 8),
];
for tier in [KinoviPricingTier::Enterprise, KinoviPricingTier::Consumer] {
let base = r720(5).calculate_costs(tier).total_cost.kinovi_credits;
for (batch, multiplier) in [
(KinoviSeedance2p0BatchCount::Two, 2.0),
(KinoviSeedance2p0BatchCount::Three, 3.0),
(KinoviSeedance2p0BatchCount::Four, 4.0),
for resolution in [
None,
Some(KinoviSeedance2p0OutputResolution::FourEightyP),
Some(KinoviSeedance2p0OutputResolution::SevenTwentyP),
Some(KinoviSeedance2p0OutputResolution::TenEightyP),
Some(KinoviSeedance2p0OutputResolution::FourK),
] {
let batched = build_request(5, None, Some(batch)).calculate_costs(tier).total_cost.kinovi_credits;
assert_eq!(batched, base * multiplier, "{batch:?} at {tier:?}");
for duration in [4, 5, 15] {
for references in [
None,
Some(vec![]),
Some(vec!["https://example.com/ref.mp4".to_string()]),
Some(vec!["https://example.com/ref1.mp4".to_string(), "https://example.com/ref2.mp4".to_string()]),
] {
let mut request = build_request(duration, resolution, None);
request.reference_video_urls = references;
let single = request.calculate_costs(tier);
for (batch, count) in batches {
request.batch_count = batch;
let batched = request.calculate_costs(tier);
let context = format!("{tier:?} {resolution:?} {duration}s {batch:?} references={:?}", request.reference_video_urls);
assert_batch_cost(single.base_cost, batched.base_cost, count, tier, &context);
assert_batch_cost(single.total_cost, batched.total_cost, count, tier, &context);
match (single.video_reference_surcharge_cost, batched.video_reference_surcharge_cost) {
(None, None) => {},
(Some(single), Some(batched)) => assert_batch_cost(single, batched, count, tier, &context),
_ => panic!("{context}: batch count changed surcharge presence"),
}
}
}
}
}
}
}
@@ -66,6 +66,10 @@ pub enum KinoviSeedance2p0FastBatchCount {
Two,
Three,
Four,
Five,
Six,
Seven,
Eight,
}
#[derive(Debug, Clone, Copy)]
@@ -140,6 +144,10 @@ impl KinoviCostCalculatorTrait for GenerateSeedance2p0FastRequest {
Some(KinoviSeedance2p0FastBatchCount::Two) => 2.0,
Some(KinoviSeedance2p0FastBatchCount::Three) => 3.0,
Some(KinoviSeedance2p0FastBatchCount::Four) => 4.0,
Some(KinoviSeedance2p0FastBatchCount::Five) => 5.0,
Some(KinoviSeedance2p0FastBatchCount::Six) => 6.0,
Some(KinoviSeedance2p0FastBatchCount::Seven) => 7.0,
Some(KinoviSeedance2p0FastBatchCount::Eight) => 8.0,
};
let output_seconds = f64::from(self.duration_seconds) * batch_multiplier;
@@ -244,6 +252,10 @@ fn map_batch_count(bc: Option<KinoviSeedance2p0FastBatchCount>) -> KinoviBatchCo
Some(KinoviSeedance2p0FastBatchCount::Two) => KinoviBatchCountRaw::Two,
Some(KinoviSeedance2p0FastBatchCount::Three) => KinoviBatchCountRaw::Three,
Some(KinoviSeedance2p0FastBatchCount::Four) => KinoviBatchCountRaw::Four,
Some(KinoviSeedance2p0FastBatchCount::Five) => KinoviBatchCountRaw::Five,
Some(KinoviSeedance2p0FastBatchCount::Six) => KinoviBatchCountRaw::Six,
Some(KinoviSeedance2p0FastBatchCount::Seven) => KinoviBatchCountRaw::Seven,
Some(KinoviSeedance2p0FastBatchCount::Eight) => KinoviBatchCountRaw::Eight,
}
}
@@ -528,18 +540,131 @@ mod tests {
mod batch_tests {
use super::*;
use crate::test_utils::assert_batch_cost::assert_batch_cost;
const BATCHES: [KinoviSeedance2p0FastBatchCount; 8] = [
KinoviSeedance2p0FastBatchCount::One,
KinoviSeedance2p0FastBatchCount::Two,
KinoviSeedance2p0FastBatchCount::Three,
KinoviSeedance2p0FastBatchCount::Four,
KinoviSeedance2p0FastBatchCount::Five,
KinoviSeedance2p0FastBatchCount::Six,
KinoviSeedance2p0FastBatchCount::Seven,
KinoviSeedance2p0FastBatchCount::Eight,
];
// Each pair is (credits, USD cents rounded up), for batches 1 through 8.
// Pin literal totals so rate changes fail independently of scaling checks.
#[test]
fn fixed_five_second_batch_prices_without_video_reference() {
let cases = [
(KinoviSeedance2p0FastOutputResolution::FourEightyP, KinoviPricingTier::Consumer, [
(70.0, 37), (140.0, 73), (210.0, 109), (280.0, 146),
(350.0, 182), (420.0, 218), (490.0, 254), (560.0, 291),
]),
(KinoviSeedance2p0FastOutputResolution::SevenTwentyP, KinoviPricingTier::Consumer, [
(140.0, 73), (280.0, 146), (420.0, 218), (560.0, 291),
(700.0, 363), (840.0, 436), (980.0, 508), (1120.0, 581),
]),
(KinoviSeedance2p0FastOutputResolution::FourEightyP, KinoviPricingTier::Enterprise, [
(52.5, 22), (105.0, 44), (157.5, 65), (210.0, 87),
(262.5, 108), (315.0, 130), (367.5, 152), (420.0, 173),
]),
(KinoviSeedance2p0FastOutputResolution::SevenTwentyP, KinoviPricingTier::Enterprise, [
(105.0, 44), (210.0, 87), (315.0, 130), (420.0, 173),
(525.0, 216), (630.0, 260), (735.0, 303), (840.0, 346),
]),
];
for (resolution, tier, prices) in cases {
for (batch, (credits, usd_cents)) in BATCHES.into_iter().zip(prices) {
let request = build_request(5, Some(resolution), Some(batch));
let cost = request.calculate_costs(tier).total_cost;
assert_eq!(
(cost.kinovi_credits, cost.usd_cents_rounded_up),
(credits, usd_cents),
"{resolution:?} {tier:?} {batch:?}",
);
}
}
}
#[test]
fn batch_multiplies_at_both_tiers() {
fn fixed_five_second_batch_prices_with_video_reference() {
let cases = [
(KinoviSeedance2p0FastOutputResolution::FourEightyP, KinoviPricingTier::Consumer, [
(90.0, 47), (180.0, 94), (270.0, 140), (360.0, 187),
(450.0, 234), (540.0, 280), (630.0, 327), (720.0, 374),
]),
(KinoviSeedance2p0FastOutputResolution::SevenTwentyP, KinoviPricingTier::Consumer, [
(170.0, 89), (340.0, 177), (510.0, 265), (680.0, 353),
(850.0, 441), (1020.0, 529), (1190.0, 617), (1360.0, 705),
]),
(KinoviSeedance2p0FastOutputResolution::FourEightyP, KinoviPricingTier::Enterprise, [
(67.5, 28), (135.0, 56), (202.5, 84), (270.0, 112),
(337.5, 139), (405.0, 167), (472.5, 195), (540.0, 223),
]),
(KinoviSeedance2p0FastOutputResolution::SevenTwentyP, KinoviPricingTier::Enterprise, [
(127.5, 53), (255.0, 105), (382.5, 158), (510.0, 210),
(637.5, 263), (765.0, 315), (892.5, 368), (1020.0, 420),
]),
];
for (resolution, tier, prices) in cases {
for (batch, (credits, usd_cents)) in BATCHES.into_iter().zip(prices) {
let request = build_request(5, Some(resolution), Some(batch));
let request = with_video_ref(request);
let cost = request.calculate_costs(tier).total_cost;
assert_eq!(
(cost.kinovi_credits, cost.usd_cents_rounded_up),
(credits, usd_cents),
"{resolution:?} {tier:?} {batch:?}",
);
}
}
}
#[test]
fn all_batches_scale_every_resolution_and_surcharge_at_both_tiers() {
let batches = [
(None, 1),
(Some(KinoviSeedance2p0FastBatchCount::One), 1),
(Some(KinoviSeedance2p0FastBatchCount::Two), 2),
(Some(KinoviSeedance2p0FastBatchCount::Three), 3),
(Some(KinoviSeedance2p0FastBatchCount::Four), 4),
(Some(KinoviSeedance2p0FastBatchCount::Five), 5),
(Some(KinoviSeedance2p0FastBatchCount::Six), 6),
(Some(KinoviSeedance2p0FastBatchCount::Seven), 7),
(Some(KinoviSeedance2p0FastBatchCount::Eight), 8),
];
for tier in [KinoviPricingTier::Enterprise, KinoviPricingTier::Consumer] {
let base = r720(5).calculate_costs(tier).total_cost.kinovi_credits;
for (batch, multiplier) in [
(KinoviSeedance2p0FastBatchCount::Two, 2.0),
(KinoviSeedance2p0FastBatchCount::Three, 3.0),
(KinoviSeedance2p0FastBatchCount::Four, 4.0),
for resolution in [
None,
Some(KinoviSeedance2p0FastOutputResolution::FourEightyP),
Some(KinoviSeedance2p0FastOutputResolution::SevenTwentyP),
] {
let batched = build_request(5, None, Some(batch)).calculate_costs(tier).total_cost.kinovi_credits;
assert_eq!(batched, base * multiplier, "{batch:?} at {tier:?}");
for duration in [4, 5, 15] {
for references in [
None,
Some(vec![]),
Some(vec!["https://example.com/ref.mp4".to_string()]),
Some(vec!["https://example.com/ref1.mp4".to_string(), "https://example.com/ref2.mp4".to_string()]),
] {
let mut request = build_request(duration, resolution, None);
request.reference_video_urls = references;
let single = request.calculate_costs(tier);
for (batch, count) in batches {
request.batch_count = batch;
let batched = request.calculate_costs(tier);
let context = format!("{tier:?} {resolution:?} {duration}s {batch:?} references={:?}", request.reference_video_urls);
assert_batch_cost(single.base_cost, batched.base_cost, count, tier, &context);
assert_batch_cost(single.total_cost, batched.total_cost, count, tier, &context);
match (single.video_reference_surcharge_cost, batched.video_reference_surcharge_cost) {
(None, None) => {},
(Some(single), Some(batched)) => assert_batch_cost(single, batched, count, tier, &context),
_ => panic!("{context}: batch count changed surcharge presence"),
}
}
}
}
}
}
}
@@ -514,24 +514,131 @@ mod tests {
mod batch_tests {
use super::*;
use crate::test_utils::assert_batch_cost::assert_batch_cost;
const BATCHES: [KinoviSeedance2p0MiniBatchCount; 8] = [
KinoviSeedance2p0MiniBatchCount::One,
KinoviSeedance2p0MiniBatchCount::Two,
KinoviSeedance2p0MiniBatchCount::Three,
KinoviSeedance2p0MiniBatchCount::Four,
KinoviSeedance2p0MiniBatchCount::Five,
KinoviSeedance2p0MiniBatchCount::Six,
KinoviSeedance2p0MiniBatchCount::Seven,
KinoviSeedance2p0MiniBatchCount::Eight,
];
// Each pair is (credits, USD cents rounded up), for batches 1 through 8.
// Pin literal totals so rate changes fail independently of scaling checks.
#[test]
fn fixed_five_second_batch_prices_without_video_reference() {
let cases = [
(KinoviSeedance2p0MiniOutputResolution::FourEightyP, KinoviPricingTier::Consumer, [
(37.5, 20), (75.0, 39), (112.5, 59), (150.0, 78),
(187.5, 98), (225.0, 117), (262.5, 137), (300.0, 156),
]),
(KinoviSeedance2p0MiniOutputResolution::SevenTwentyP, KinoviPricingTier::Consumer, [
(100.0, 52), (200.0, 104), (300.0, 156), (400.0, 208),
(500.0, 260), (600.0, 311), (700.0, 363), (800.0, 415),
]),
(KinoviSeedance2p0MiniOutputResolution::FourEightyP, KinoviPricingTier::Enterprise, [
(15.0, 7), (30.0, 13), (45.0, 19), (60.0, 25),
(75.0, 31), (90.0, 38), (105.0, 44), (120.0, 50),
]),
(KinoviSeedance2p0MiniOutputResolution::SevenTwentyP, KinoviPricingTier::Enterprise, [
(40.0, 17), (80.0, 33), (120.0, 50), (160.0, 66),
(200.0, 83), (240.0, 99), (280.0, 116), (320.0, 132),
]),
];
for (resolution, tier, prices) in cases {
for (batch, (credits, usd_cents)) in BATCHES.into_iter().zip(prices) {
let request = build_request(5, Some(resolution), Some(batch));
let cost = request.calculate_costs(tier).total_cost;
assert_eq!(
(cost.kinovi_credits, cost.usd_cents_rounded_up),
(credits, usd_cents),
"{resolution:?} {tier:?} {batch:?}",
);
}
}
}
#[test]
fn batch_scales_total_linearly_at_both_tiers() {
let by_count = [
(KinoviSeedance2p0MiniBatchCount::One, 1.0),
(KinoviSeedance2p0MiniBatchCount::Two, 2.0),
(KinoviSeedance2p0MiniBatchCount::Three, 3.0),
(KinoviSeedance2p0MiniBatchCount::Four, 4.0),
(KinoviSeedance2p0MiniBatchCount::Five, 5.0),
(KinoviSeedance2p0MiniBatchCount::Six, 6.0),
(KinoviSeedance2p0MiniBatchCount::Seven, 7.0),
(KinoviSeedance2p0MiniBatchCount::Eight, 8.0),
fn fixed_five_second_batch_prices_with_video_reference() {
let cases = [
(KinoviSeedance2p0MiniOutputResolution::FourEightyP, KinoviPricingTier::Consumer, [
(47.5, 25), (95.0, 50), (142.5, 74), (190.0, 99),
(237.5, 124), (285.0, 148), (332.5, 173), (380.0, 197),
]),
(KinoviSeedance2p0MiniOutputResolution::SevenTwentyP, KinoviPricingTier::Consumer, [
(120.0, 63), (240.0, 125), (360.0, 187), (480.0, 249),
(600.0, 311), (720.0, 374), (840.0, 436), (960.0, 498),
]),
(KinoviSeedance2p0MiniOutputResolution::FourEightyP, KinoviPricingTier::Enterprise, [
(19.0, 8), (38.0, 16), (57.0, 24), (76.0, 32),
(95.0, 40), (114.0, 47), (133.0, 55), (152.0, 63),
]),
(KinoviSeedance2p0MiniOutputResolution::SevenTwentyP, KinoviPricingTier::Enterprise, [
(48.0, 20), (96.0, 40), (144.0, 60), (192.0, 79),
(240.0, 99), (288.0, 119), (336.0, 139), (384.0, 158),
]),
];
for (resolution, tier, prices) in cases {
for (batch, (credits, usd_cents)) in BATCHES.into_iter().zip(prices) {
let request = build_request(5, Some(resolution), Some(batch));
let request = with_video_ref(request);
let cost = request.calculate_costs(tier).total_cost;
assert_eq!(
(cost.kinovi_credits, cost.usd_cents_rounded_up),
(credits, usd_cents),
"{resolution:?} {tier:?} {batch:?}",
);
}
}
}
#[test]
fn all_batches_scale_every_resolution_and_surcharge_at_both_tiers() {
let batches = [
(None, 1),
(Some(KinoviSeedance2p0MiniBatchCount::One), 1),
(Some(KinoviSeedance2p0MiniBatchCount::Two), 2),
(Some(KinoviSeedance2p0MiniBatchCount::Three), 3),
(Some(KinoviSeedance2p0MiniBatchCount::Four), 4),
(Some(KinoviSeedance2p0MiniBatchCount::Five), 5),
(Some(KinoviSeedance2p0MiniBatchCount::Six), 6),
(Some(KinoviSeedance2p0MiniBatchCount::Seven), 7),
(Some(KinoviSeedance2p0MiniBatchCount::Eight), 8),
];
for tier in [KinoviPricingTier::Enterprise, KinoviPricingTier::Consumer] {
let base = r720(5).calculate_costs(tier).total_cost.kinovi_credits;
for (count, multiplier) in by_count {
let credits = build_request(5, None, Some(count)).calculate_costs(tier).total_cost.kinovi_credits;
assert_eq!(credits, base * multiplier, "batch {count:?} at {tier:?}");
for resolution in [
None,
Some(KinoviSeedance2p0MiniOutputResolution::FourEightyP),
Some(KinoviSeedance2p0MiniOutputResolution::SevenTwentyP),
] {
for duration in [4, 5, 15] {
for references in [
None,
Some(vec![]),
Some(vec!["https://example.com/ref.mp4".to_string()]),
Some(vec!["https://example.com/ref1.mp4".to_string(), "https://example.com/ref2.mp4".to_string()]),
] {
let mut request = build_request(duration, resolution, None);
request.reference_video_urls = references;
let single = request.calculate_costs(tier);
for (batch, count) in batches {
request.batch_count = batch;
let batched = request.calculate_costs(tier);
let context = format!("{tier:?} {resolution:?} {duration}s {batch:?} references={:?}", request.reference_video_urls);
assert_batch_cost(single.base_cost, batched.base_cost, count, tier, &context);
assert_batch_cost(single.total_cost, batched.total_cost, count, tier, &context);
match (single.video_reference_surcharge_cost, batched.video_reference_surcharge_cost) {
(None, None) => {},
(Some(single), Some(batched)) => assert_batch_cost(single, batched, count, tier, &context),
_ => panic!("{context}: batch count changed surcharge presence"),
}
}
}
}
}
}
}
@@ -48,6 +48,7 @@ pub struct GenerateSeedance2p5Request {
pub output_resolution: Option<KinoviSeedance2p5OutputResolution>,
pub duration_seconds: u8,
pub batch_count: Option<KinoviSeedance2p5BatchCount>,
/// CALCULATION-ONLY (never sent on the wire): the total seconds of
/// reference video input, summed across all reference videos. When video
@@ -137,6 +138,19 @@ pub enum KinoviSeedance2p5OutputFormat {
Mov,
}
/// Number of videos to generate in one request (1–8).
#[derive(Debug, Clone, Copy)]
pub enum KinoviSeedance2p5BatchCount {
One,
Two,
Three,
Four,
Five,
Six,
Seven,
Eight,
}
// ── Pricing ──
//
// Seedance 2.5 credit pricing (credits per billed second). Without video
@@ -147,7 +161,7 @@ pub enum KinoviSeedance2p5OutputFormat {
// |------------|----------------------|------------------------|
// | 480p | 26 | (no discount) |
// | 720p | 59 | (no discount) |
// | 1080p | 107.55 | 103.25 |
// | 1080p | 143.4 | 136.23 |
//
// With video references, the rate drops but the billed seconds are the
// output duration PLUS the total seconds of reference video input
@@ -160,11 +174,10 @@ pub enum KinoviSeedance2p5OutputFormat {
// |------------|----------------------|------------------------|
// | 480p | 16 | (no discount) |
// | 720p | 35 | (no discount) |
// | 1080p | 64.26 | 61.69 |
// | 1080p | 85.68 | 81.4 |
//
// The 1080p enterprise rates were spot-checked against the Kinovi site
// (2026-08-17): 4s / 10s / 30s without a reference video showed 413 /
// 1,032.5 / 3,097.5 credits. 480p and 720p have no separately negotiated
// The 1080p rates were last updated 2026-09-17 (previously 107.55 / 103.25
// and 64.26 / 61.69 credits/sec). 480p and 720p have no separately negotiated
// enterprise credit rate, so enterprise bills the same credits as consumer
// (the tiers still convert to USD at their own purchase rates). Default
// resolution (None) is 720p.
@@ -180,8 +193,8 @@ const SEEDANCE_2P5_720P: KinoviPricingRate = KinoviPricingRate {
maybe_enterprise_credits: None,
};
const SEEDANCE_2P5_1080P: KinoviPricingRate = KinoviPricingRate {
consumer_credits: 107.55,
maybe_enterprise_credits: Some(103.25),
consumer_credits: 143.4,
maybe_enterprise_credits: Some(136.23),
};
/// Per-second credit rates when video references are attached. These REPLACE
@@ -196,8 +209,8 @@ const SEEDANCE_2P5_720P_VIDEO_REF: KinoviPricingRate = KinoviPricingRate {
maybe_enterprise_credits: None,
};
const SEEDANCE_2P5_1080P_VIDEO_REF: KinoviPricingRate = KinoviPricingRate {
consumer_credits: 64.26,
maybe_enterprise_credits: Some(61.69),
consumer_credits: 85.68,
maybe_enterprise_credits: Some(81.4),
};
impl KinoviCostCalculatorTrait for GenerateSeedance2p5Request {
@@ -234,7 +247,18 @@ impl KinoviCostCalculatorTrait for GenerateSeedance2p5Request {
(rate, u64::from(self.duration_seconds))
};
let total_credits = rate.credits(tier) * billed_seconds as f64;
let batch_multiplier: f64 = match self.batch_count {
None | Some(KinoviSeedance2p5BatchCount::One) => 1.0,
Some(KinoviSeedance2p5BatchCount::Two) => 2.0,
Some(KinoviSeedance2p5BatchCount::Three) => 3.0,
Some(KinoviSeedance2p5BatchCount::Four) => 4.0,
Some(KinoviSeedance2p5BatchCount::Five) => 5.0,
Some(KinoviSeedance2p5BatchCount::Six) => 6.0,
Some(KinoviSeedance2p5BatchCount::Seven) => 7.0,
Some(KinoviSeedance2p5BatchCount::Eight) => 8.0,
};
let total_credits = rate.credits(tier) * billed_seconds as f64 * batch_multiplier;
tier.cost_from_credits(total_credits)
}
}
@@ -309,7 +333,7 @@ fn to_raw_request(req: GenerateSeedance2p5Request) -> WorkflowRunTaskRequest {
aspect_ratio,
output_resolution: Some(map_output_resolution(req.output_resolution)),
duration_seconds: req.duration_seconds,
batch_count: KinoviBatchCountRaw::One,
batch_count: map_batch_count(req.batch_count),
start_frame_url,
end_frame_url,
reference_image_urls,
@@ -355,6 +379,19 @@ fn map_output_resolution(res: Option<KinoviSeedance2p5OutputResolution>) -> Kino
}
}
fn map_batch_count(bc: Option<KinoviSeedance2p5BatchCount>) -> KinoviBatchCountRaw {
match bc {
Some(KinoviSeedance2p5BatchCount::One) | None => KinoviBatchCountRaw::One,
Some(KinoviSeedance2p5BatchCount::Two) => KinoviBatchCountRaw::Two,
Some(KinoviSeedance2p5BatchCount::Three) => KinoviBatchCountRaw::Three,
Some(KinoviSeedance2p5BatchCount::Four) => KinoviBatchCountRaw::Four,
Some(KinoviSeedance2p5BatchCount::Five) => KinoviBatchCountRaw::Five,
Some(KinoviSeedance2p5BatchCount::Six) => KinoviBatchCountRaw::Six,
Some(KinoviSeedance2p5BatchCount::Seven) => KinoviBatchCountRaw::Seven,
Some(KinoviSeedance2p5BatchCount::Eight) => KinoviBatchCountRaw::Eight,
}
}
// ── Tests ──
#[cfg(test)]
@@ -375,7 +412,7 @@ mod tests {
// ── No video references: output-duration billing ──
//
// 480p = 26 credits/sec and 720p = 59 credits/sec at both tiers (no
// negotiated enterprise discount); 1080p = 107.55 consumer / 103.25
// negotiated enterprise discount); 1080p = 143.4 consumer / 136.23
// enterprise credits/sec.
mod without_video_references {
@@ -401,24 +438,23 @@ mod tests {
#[test]
fn table_1080p() {
// Enterprise 103.25 credits/sec — the 4s / 10s / 30s values were
// verified against the Kinovi site (413 / 1,032.5 / 3,097.5).
assert_eq!(t2v_1080(4).calculate_enterprise_costs().kinovi_credits, 413.0);
assert_eq!(t2v_1080(5).calculate_enterprise_costs().kinovi_credits, 516.25);
assert_eq!(t2v_1080(10).calculate_enterprise_costs().kinovi_credits, 1032.5);
assert_eq!(t2v_1080(15).calculate_enterprise_costs().kinovi_credits, 1548.75);
assert_eq!(t2v_1080(20).calculate_enterprise_costs().kinovi_credits, 2065.0);
assert_eq!(t2v_1080(25).calculate_enterprise_costs().kinovi_credits, 2581.25);
assert_eq!(t2v_1080(30).calculate_enterprise_costs().kinovi_credits, 3097.5);
// Enterprise 136.23 credits/sec (2026-09-17).
assert_eq!(t2v_1080(4).calculate_enterprise_costs().kinovi_credits, 544.92);
assert_eq!(t2v_1080(5).calculate_enterprise_costs().kinovi_credits, 681.15);
assert_eq!(t2v_1080(10).calculate_enterprise_costs().kinovi_credits, 1362.3);
assert_eq!(t2v_1080(15).calculate_enterprise_costs().kinovi_credits, 2043.45);
assert_eq!(t2v_1080(20).calculate_enterprise_costs().kinovi_credits, 2724.6);
assert_eq!(t2v_1080(25).calculate_enterprise_costs().kinovi_credits, 3405.75);
assert_eq!(t2v_1080(30).calculate_enterprise_costs().kinovi_credits, 4086.9);
// Consumer 107.55 credits/sec.
assert_eq!(t2v_1080(4).calculate_consumer_costs().kinovi_credits, 430.2);
assert_eq!(t2v_1080(5).calculate_consumer_costs().kinovi_credits, 537.75);
assert_eq!(t2v_1080(10).calculate_consumer_costs().kinovi_credits, 1075.5);
assert_eq!(t2v_1080(15).calculate_consumer_costs().kinovi_credits, 1613.25);
assert_eq!(t2v_1080(20).calculate_consumer_costs().kinovi_credits, 2151.0);
assert_eq!(t2v_1080(25).calculate_consumer_costs().kinovi_credits, 2688.75);
assert_eq!(t2v_1080(30).calculate_consumer_costs().kinovi_credits, 3226.5);
// Consumer 143.4 credits/sec.
assert_eq!(t2v_1080(4).calculate_consumer_costs().kinovi_credits, 573.6);
assert_eq!(t2v_1080(5).calculate_consumer_costs().kinovi_credits, 717.0);
assert_eq!(t2v_1080(10).calculate_consumer_costs().kinovi_credits, 1434.0);
assert_eq!(t2v_1080(15).calculate_consumer_costs().kinovi_credits, 2151.0);
assert_eq!(t2v_1080(20).calculate_consumer_costs().kinovi_credits, 2868.0);
assert_eq!(t2v_1080(25).calculate_consumer_costs().kinovi_credits, 3585.0);
assert_eq!(t2v_1080(30).calculate_consumer_costs().kinovi_credits, 4302.0);
}
#[test]
@@ -456,7 +492,7 @@ mod tests {
let mut request_1080 = keyframe_request(10);
request_1080.output_resolution = Some(KinoviSeedance2p5OutputResolution::TenEightyP);
assert_eq!(request_1080.calculate_enterprise_costs().kinovi_credits, 1032.5);
assert_eq!(request_1080.calculate_enterprise_costs().kinovi_credits, 1362.3);
}
#[test]
@@ -489,7 +525,7 @@ mod tests {
// ── With video references: input seconds are billed too ──
//
// 480p = 16 credits/sec and 720p = 35 credits/sec at both tiers; 1080p
// = 64.26 consumer / 61.69 enterprise credits/sec. All over
// = 85.68 consumer / 81.4 enterprise credits/sec. All over
// (output duration + total_input_seconds) billed seconds.
mod with_video_references {
@@ -541,21 +577,21 @@ mod tests {
// seconds: billed seconds = 20 + clamp(input, 4, 30). E.g. a 10s
// input video + 20s generation = 30 billed seconds.
//
// Enterprise 61.69 credits/sec.
assert_eq!(video_ref_1080(20, Some(1)).calculate_enterprise_costs().kinovi_credits, 1480.56);
assert_eq!(video_ref_1080(20, Some(5)).calculate_enterprise_costs().kinovi_credits, 1542.25);
assert_eq!(video_ref_1080(20, Some(10)).calculate_enterprise_costs().kinovi_credits, 1850.7);
assert_eq!(video_ref_1080(20, Some(15)).calculate_enterprise_costs().kinovi_credits, 2159.15);
assert_eq!(video_ref_1080(20, Some(20)).calculate_enterprise_costs().kinovi_credits, 2467.6);
assert_eq!(video_ref_1080(20, Some(30)).calculate_enterprise_costs().kinovi_credits, 3084.5);
// Enterprise 81.4 credits/sec.
assert_eq!(video_ref_1080(20, Some(1)).calculate_enterprise_costs().kinovi_credits, 1953.6);
assert_eq!(video_ref_1080(20, Some(5)).calculate_enterprise_costs().kinovi_credits, 2035.0);
assert_eq!(video_ref_1080(20, Some(10)).calculate_enterprise_costs().kinovi_credits, 2442.0);
assert_eq!(video_ref_1080(20, Some(15)).calculate_enterprise_costs().kinovi_credits, 2849.0);
assert_eq!(video_ref_1080(20, Some(20)).calculate_enterprise_costs().kinovi_credits, 3256.0);
assert_eq!(video_ref_1080(20, Some(30)).calculate_enterprise_costs().kinovi_credits, 4070.0);
// Consumer 64.26 credits/sec.
assert_eq!(video_ref_1080(20, Some(1)).calculate_consumer_costs().kinovi_credits, 1542.24);
assert_eq!(video_ref_1080(20, Some(5)).calculate_consumer_costs().kinovi_credits, 1606.5);
assert_eq!(video_ref_1080(20, Some(10)).calculate_consumer_costs().kinovi_credits, 1927.8);
assert_eq!(video_ref_1080(20, Some(15)).calculate_consumer_costs().kinovi_credits, 2249.1);
assert_eq!(video_ref_1080(20, Some(20)).calculate_consumer_costs().kinovi_credits, 2570.4);
assert_eq!(video_ref_1080(20, Some(30)).calculate_consumer_costs().kinovi_credits, 3213.0);
// Consumer 85.68 credits/sec.
assert_eq!(video_ref_1080(20, Some(1)).calculate_consumer_costs().kinovi_credits, 2056.32);
assert_eq!(video_ref_1080(20, Some(5)).calculate_consumer_costs().kinovi_credits, 2142.0);
assert_eq!(video_ref_1080(20, Some(10)).calculate_consumer_costs().kinovi_credits, 2570.4);
assert_eq!(video_ref_1080(20, Some(15)).calculate_consumer_costs().kinovi_credits, 2998.8);
assert_eq!(video_ref_1080(20, Some(20)).calculate_consumer_costs().kinovi_credits, 3427.2);
assert_eq!(video_ref_1080(20, Some(30)).calculate_consumer_costs().kinovi_credits, 4284.0);
}
#[test]
@@ -574,9 +610,9 @@ mod tests {
// 30-second maximum so the estimate never undershoots the charge.
assert_eq!(video_ref_480(10, None).calculate_consumer_costs().kinovi_credits, 640.0);
assert_eq!(video_ref_720(10, None).calculate_consumer_costs().kinovi_credits, 1400.0);
// 1080p: 40 billed seconds at 61.69 enterprise / 64.26 consumer.
assert_eq!(video_ref_1080(10, None).calculate_enterprise_costs().kinovi_credits, 2467.6);
assert_eq!(video_ref_1080(10, None).calculate_consumer_costs().kinovi_credits, 2570.4);
// 1080p: 40 billed seconds at 81.4 enterprise / 85.68 consumer.
assert_eq!(video_ref_1080(10, None).calculate_enterprise_costs().kinovi_credits, 3256.0);
assert_eq!(video_ref_1080(10, None).calculate_consumer_costs().kinovi_credits, 3427.2);
}
#[test]
@@ -649,47 +685,151 @@ mod tests {
#[test]
fn enterprise_cents_1080p_4s() {
// 413 credits; 41300/243.16 = 169.8470 cents.
// 544.92 credits; 54492/243.16 = 224.0994 cents.
let cost = t2v_1080(4).calculate_enterprise_costs();
assert_eq!(cost.usd_cents_rounded_up, 170);
assert_eq!(cost.usd_cents_rounded_down, 169);
assert!((cost.usd_cents_fractional - 169.8470).abs() < FLOAT_TOLERANCE);
assert_eq!(cost.usd_cents_rounded_up, 225);
assert_eq!(cost.usd_cents_rounded_down, 224);
assert!((cost.usd_cents_fractional - 224.0994).abs() < FLOAT_TOLERANCE);
}
#[test]
fn consumer_cents_1080p_4s() {
// 430.2 credits; 43020/192.98 = 222.9247 cents.
// 573.6 credits; 57360/192.98 = 297.2329 cents.
let cost = t2v_1080(4).calculate_consumer_costs();
assert_eq!(cost.usd_cents_rounded_up, 223);
assert_eq!(cost.usd_cents_rounded_down, 222);
assert!((cost.usd_cents_fractional - 222.9247).abs() < FLOAT_TOLERANCE);
assert_eq!(cost.usd_cents_rounded_up, 298);
assert_eq!(cost.usd_cents_rounded_down, 297);
assert!((cost.usd_cents_fractional - 297.2329).abs() < FLOAT_TOLERANCE);
}
#[test]
fn enterprise_cents_1080p_30s() {
// 3097.5 credits; 309750/243.16 = 1273.8526 cents.
// 4086.9 credits; 408690/243.16 = 1680.7452 cents.
let cost = t2v_1080(30).calculate_enterprise_costs();
assert_eq!(cost.usd_cents_rounded_up, 1274);
assert_eq!(cost.usd_cents_rounded_down, 1273);
assert!((cost.usd_cents_fractional - 1273.8526).abs() < FLOAT_TOLERANCE);
assert_eq!(cost.usd_cents_rounded_up, 1681);
assert_eq!(cost.usd_cents_rounded_down, 1680);
assert!((cost.usd_cents_fractional - 1680.7452).abs() < FLOAT_TOLERANCE);
}
#[test]
fn enterprise_cents_1080p_video_ref_30_billed_seconds() {
// 61.69 × 30 = 1850.7 credits; 185070/243.16 = 761.1038 cents.
// 81.4 × 30 = 2442 credits; 244200/243.16 = 1004.2770 cents.
let cost = video_ref_1080(20, Some(10)).calculate_enterprise_costs();
assert_eq!(cost.usd_cents_rounded_up, 762);
assert_eq!(cost.usd_cents_rounded_down, 761);
assert!((cost.usd_cents_fractional - 761.1038).abs() < FLOAT_TOLERANCE);
assert_eq!(cost.usd_cents_rounded_up, 1005);
assert_eq!(cost.usd_cents_rounded_down, 1004);
assert!((cost.usd_cents_fractional - 1004.2770).abs() < FLOAT_TOLERANCE);
}
#[test]
fn consumer_cents_1080p_video_ref_30_billed_seconds() {
// 64.26 × 30 = 1927.8 credits; 192780/192.98 = 998.9636 cents.
// 85.68 × 30 = 2570.4 credits; 257040/192.98 = 1331.9515 cents.
let cost = video_ref_1080(20, Some(10)).calculate_consumer_costs();
assert_eq!(cost.usd_cents_rounded_up, 999);
assert_eq!(cost.usd_cents_rounded_down, 998);
assert!((cost.usd_cents_fractional - 998.9636).abs() < FLOAT_TOLERANCE);
assert_eq!(cost.usd_cents_rounded_up, 1332);
assert_eq!(cost.usd_cents_rounded_down, 1331);
assert!((cost.usd_cents_fractional - 1331.9515).abs() < FLOAT_TOLERANCE);
}
}
mod batch_price_tests {
use super::*;
const BATCHES: [KinoviSeedance2p5BatchCount; 8] = [
KinoviSeedance2p5BatchCount::One,
KinoviSeedance2p5BatchCount::Two,
KinoviSeedance2p5BatchCount::Three,
KinoviSeedance2p5BatchCount::Four,
KinoviSeedance2p5BatchCount::Five,
KinoviSeedance2p5BatchCount::Six,
KinoviSeedance2p5BatchCount::Seven,
KinoviSeedance2p5BatchCount::Eight,
];
// Each pair is (credits, USD cents rounded up), for batches 1 through 8.
// Pin literal totals so rate changes fail independently of scaling checks.
// Video-reference cases bill 5s output + 10s input per video.
#[test]
fn fixed_five_second_batch_prices_without_video_reference() {
let cases = [
(KinoviSeedance2p5OutputResolution::FourEightyP, KinoviPricingTier::Consumer, [
(130.0, 68), (260.0, 135), (390.0, 203), (520.0, 270),
(650.0, 337), (780.0, 405), (910.0, 472), (1040.0, 539),
]),
(KinoviSeedance2p5OutputResolution::SevenTwentyP, KinoviPricingTier::Consumer, [
(295.0, 153), (590.0, 306), (885.0, 459), (1180.0, 612),
(1475.0, 765), (1770.0, 918), (2065.0, 1071), (2360.0, 1223),
]),
(KinoviSeedance2p5OutputResolution::TenEightyP, KinoviPricingTier::Consumer, [
(717.0, 372), (1434.0, 744), (2151.0, 1115), (2868.0, 1487),
(3585.0, 1858), (4302.0, 2230), (5019.0, 2601), (5736.0, 2973),
]),
(KinoviSeedance2p5OutputResolution::FourEightyP, KinoviPricingTier::Enterprise, [
(130.0, 54), (260.0, 107), (390.0, 161), (520.0, 214),
(650.0, 268), (780.0, 321), (910.0, 375), (1040.0, 428),
]),
(KinoviSeedance2p5OutputResolution::SevenTwentyP, KinoviPricingTier::Enterprise, [
(295.0, 122), (590.0, 243), (885.0, 364), (1180.0, 486),
(1475.0, 607), (1770.0, 728), (2065.0, 850), (2360.0, 971),
]),
(KinoviSeedance2p5OutputResolution::TenEightyP, KinoviPricingTier::Enterprise, [
(681.15, 281), (1362.30, 561), (2043.45, 841), (2724.60, 1121),
(3405.75, 1401), (4086.90, 1681), (4768.05, 1961), (5449.20, 2241),
]),
];
for (resolution, tier, prices) in cases {
for (batch, (credits, usd_cents)) in BATCHES.into_iter().zip(prices) {
let mut request = text_to_video_request(5, Some(resolution));
request.batch_count = Some(batch);
let cost = request.calculate_costs(tier);
assert_eq!(
(cost.kinovi_credits, cost.usd_cents_rounded_up),
(credits, usd_cents),
"{resolution:?} {tier:?} {batch:?}",
);
}
}
}
#[test]
fn fixed_five_second_batch_prices_with_video_reference() {
let cases = [
(KinoviSeedance2p5OutputResolution::FourEightyP, KinoviPricingTier::Consumer, [
(240.0, 125), (480.0, 249), (720.0, 374), (960.0, 498),
(1200.0, 622), (1440.0, 747), (1680.0, 871), (1920.0, 995),
]),
(KinoviSeedance2p5OutputResolution::SevenTwentyP, KinoviPricingTier::Consumer, [
(525.0, 273), (1050.0, 545), (1575.0, 817), (2100.0, 1089),
(2625.0, 1361), (3150.0, 1633), (3675.0, 1905), (4200.0, 2177),
]),
(KinoviSeedance2p5OutputResolution::TenEightyP, KinoviPricingTier::Consumer, [
(1285.2, 666), (2570.4, 1332), (3855.6, 1998), (5140.8, 2664),
(6426.0, 3330), (7711.2, 3996), (8996.4, 4662), (10281.6, 5328),
]),
(KinoviSeedance2p5OutputResolution::FourEightyP, KinoviPricingTier::Enterprise, [
(240.0, 99), (480.0, 198), (720.0, 297), (960.0, 395),
(1200.0, 494), (1440.0, 593), (1680.0, 691), (1920.0, 790),
]),
(KinoviSeedance2p5OutputResolution::SevenTwentyP, KinoviPricingTier::Enterprise, [
(525.0, 216), (1050.0, 432), (1575.0, 648), (2100.0, 864),
(2625.0, 1080), (3150.0, 1296), (3675.0, 1512), (4200.0, 1728),
]),
(KinoviSeedance2p5OutputResolution::TenEightyP, KinoviPricingTier::Enterprise, [
(1221.0, 503), (2442.0, 1005), (3663.0, 1507), (4884.0, 2009),
(6105.0, 2511), (7326.0, 3013), (8547.0, 3515), (9768.0, 4018),
]),
];
for (resolution, tier, prices) in cases {
for (batch, (credits, usd_cents)) in BATCHES.into_iter().zip(prices) {
let mut request = text_to_video_request(5, Some(resolution));
request.batch_count = Some(batch);
set_reference_urls(&mut request, None, Some(vec!["https://example.com/ref.mp4".to_string()]), None);
request.total_input_seconds = Some(10);
let cost = request.calculate_costs(tier);
assert_eq!(
(cost.kinovi_credits, cost.usd_cents_rounded_up),
(credits, usd_cents),
"{resolution:?} {tier:?} {batch:?}",
);
}
}
}
}
@@ -697,10 +837,81 @@ mod tests {
mod relative_tests {
use super::*;
use crate::test_utils::assert_batch_cost::assert_batch_cost;
#[test]
fn all_batches_scale_every_resolution_modality_and_input_duration() {
let batches = [
(None, 1),
(Some(KinoviSeedance2p5BatchCount::One), 1),
(Some(KinoviSeedance2p5BatchCount::Two), 2),
(Some(KinoviSeedance2p5BatchCount::Three), 3),
(Some(KinoviSeedance2p5BatchCount::Four), 4),
(Some(KinoviSeedance2p5BatchCount::Five), 5),
(Some(KinoviSeedance2p5BatchCount::Six), 6),
(Some(KinoviSeedance2p5BatchCount::Seven), 7),
(Some(KinoviSeedance2p5BatchCount::Eight), 8),
];
for tier in [KinoviPricingTier::Enterprise, KinoviPricingTier::Consumer] {
for resolution in [
None,
Some(KinoviSeedance2p5OutputResolution::FourEightyP),
Some(KinoviSeedance2p5OutputResolution::SevenTwentyP),
Some(KinoviSeedance2p5OutputResolution::TenEightyP),
] {
for duration in [4, 5, 30] {
let text_request = text_to_video_request(duration, resolution);
let mut keyframe = keyframe_request(duration);
keyframe.output_resolution = resolution;
let mut image_audio = text_request.clone();
set_reference_urls(
&mut image_audio,
Some(vec!["https://example.com/image.png".to_string()]),
None,
Some(vec!["https://example.com/audio.mp3".to_string()]),
);
let mut empty_videos = text_request.clone();
set_reference_urls(&mut empty_videos, None, Some(vec![]), None);
let mut requests = vec![
("text".to_string(), text_request.clone()),
("keyframe".to_string(), keyframe),
("image/audio".to_string(), image_audio),
("empty videos".to_string(), empty_videos),
];
// Include unknown/zero inputs, the minimum, ordinary input, the
// maximum, and values clamped at either end of the billed range.
for input_seconds in [None, Some(0), Some(1), Some(4), Some(10), Some(30), Some(31), Some(u8::MAX)] {
let mut request = text_request.clone();
set_reference_urls(&mut request, None, Some(vec!["https://example.com/ref.mp4".to_string()]), None);
request.total_input_seconds = input_seconds;
requests.push((format!("video input={input_seconds:?}"), request));
}
for (modality, mut request) in requests {
let single = request.calculate_costs(tier);
for (batch, count) in batches {
request.batch_count = batch;
let batched = request.calculate_costs(tier);
let context = format!("{tier:?} {resolution:?} {duration}s {batch:?} {modality}");
assert_batch_cost(single, batched, count, tier, &context);
}
}
}
}
}
}
#[test]
fn batch_multiplies_output_and_reference_input_costs() {
let mut request = video_ref_480(5, Some(10));
request.batch_count = Some(KinoviSeedance2p5BatchCount::Eight);
assert_eq!(request.calculate_consumer_costs().kinovi_credits, 1920.0);
assert_eq!(request.calculate_enterprise_costs().kinovi_credits, 1920.0);
assert!(matches!(to_raw_request(request).batch_count, KinoviBatchCountRaw::Eight));
}
#[test]
fn video_reference_rate_is_cheaper_per_second() {
// 16 < 26, 35 < 59, and 61.69 < 103.25: the with-references rate is
// 16 < 26, 35 < 59, and 81.4 < 136.23: the with-references rate is
// lower per billed second (the input seconds are where the money
// goes). Compare at the minimum billed input (1s clamps to 4) —
// unknown or zero input assumes the 30-second maximum, which would
@@ -771,6 +982,7 @@ mod tests {
output_resolution: Option<KinoviSeedance2p5OutputResolution>,
) -> GenerateSeedance2p5Request {
GenerateSeedance2p5Request {
batch_count: None,
prompt: String::new(),
modality: KinoviSeedance2p5Modality::Reference {
aspect_ratio: None,
@@ -802,6 +1014,7 @@ mod tests {
fn keyframe_request(duration: u8) -> GenerateSeedance2p5Request {
GenerateSeedance2p5Request {
batch_count: None,
prompt: String::new(),
modality: KinoviSeedance2p5Modality::Keyframe {
start_frame_url: "https://example.com/start.png".to_string(),
@@ -864,6 +1077,7 @@ mod tests {
#[test]
fn keyframe_maps_frames_and_no_references() {
let raw = to_raw_request(GenerateSeedance2p5Request {
batch_count: None,
prompt: "Car drives into the sunset".to_string(),
modality: KinoviSeedance2p5Modality::Keyframe {
start_frame_url: "https://example.com/start.png".to_string(),
@@ -890,6 +1104,7 @@ mod tests {
#[test]
fn reference_maps_references_and_no_frames() {
let raw = to_raw_request(GenerateSeedance2p5Request {
batch_count: None,
prompt: "The t-rex @image1 eats the banana".to_string(),
modality: KinoviSeedance2p5Modality::Reference {
aspect_ratio: Some(KinoviSeedance2p5AspectRatio::UltraWide21x9),
@@ -920,6 +1135,7 @@ mod tests {
#[test]
fn resolution_1080p_maps_to_raw_1080p() {
let raw = to_raw_request(GenerateSeedance2p5Request {
batch_count: None,
prompt: "A car driving on the beach".to_string(),
modality: KinoviSeedance2p5Modality::Reference {
aspect_ratio: None,
@@ -942,6 +1158,7 @@ mod tests {
#[test]
fn reference_aspect_ratio_defaults_to_16x9() {
let raw = to_raw_request(GenerateSeedance2p5Request {
batch_count: None,
prompt: "Lightning hits a building".to_string(),
modality: KinoviSeedance2p5Modality::Reference {
aspect_ratio: None,
@@ -974,6 +1191,7 @@ mod tests {
session: &session,
host_override: None,
request: GenerateSeedance2p5Request {
batch_count: None,
prompt: "Lightning hits a building".to_string(),
modality: KinoviSeedance2p5Modality::Reference {
aspect_ratio: Some(KinoviSeedance2p5AspectRatio::Landscape16x9),
@@ -1008,6 +1226,7 @@ mod tests {
session: &session,
host_override: None,
request: GenerateSeedance2p5Request {
batch_count: None,
prompt: "A car driving on the beach".to_string(),
modality: KinoviSeedance2p5Modality::Reference {
aspect_ratio: Some(KinoviSeedance2p5AspectRatio::Landscape16x9),
@@ -1040,6 +1259,7 @@ mod tests {
session: &session,
host_override: None,
request: GenerateSeedance2p5Request {
batch_count: None,
prompt: "Car drives into the sunset".to_string(),
modality: KinoviSeedance2p5Modality::Keyframe {
start_frame_url: "https://static.seedance2-pro.com/materials/20260807/1786128486168-a2bf6132.png".to_string(),
@@ -1070,6 +1290,7 @@ mod tests {
session: &session,
host_override: None,
request: GenerateSeedance2p5Request {
batch_count: None,
prompt: "Car drives into the sunset".to_string(),
modality: KinoviSeedance2p5Modality::Keyframe {
start_frame_url: "https://static.seedance2-pro.com/materials/20260807/1786128486168-a2bf6132.png".to_string(),
@@ -1100,6 +1321,7 @@ mod tests {
session: &session,
host_override: None,
request: GenerateSeedance2p5Request {
batch_count: None,
prompt: "The t-rex @image1 eats the banana @image2".to_string(),
modality: KinoviSeedance2p5Modality::Reference {
aspect_ratio: Some(KinoviSeedance2p5AspectRatio::UltraWide21x9),
@@ -28,6 +28,7 @@ pub struct GenerateSeedance2p5PreviewRequest {
pub aspect_ratio: Option<KinoviSeedance2p5PreviewAspectRatio>,
pub output_resolution: Option<KinoviSeedance2p5PreviewOutputResolution>,
pub duration_seconds: u8,
pub batch_count: Option<KinoviSeedance2p5PreviewBatchCount>,
/// Reference images, referenced in prompts as @image1, @image2, etc.
pub reference_image_urls: Option<Vec<String>>,
/// Reference videos, referenced in prompts as @video1, @video2, etc.
@@ -58,6 +59,19 @@ pub enum KinoviSeedance2p5PreviewOutputResolution {
SevenTwentyP,
}
/// Number of videos to generate in one request (1–8).
#[derive(Debug, Clone, Copy)]
pub enum KinoviSeedance2p5PreviewBatchCount {
One,
Two,
Three,
Four,
Five,
Six,
Seven,
Eight,
}
// ── Pricing ──
//
// Seedance 2.5 Preview credit pricing (per second of output duration):
@@ -97,7 +111,18 @@ impl KinoviCostCalculatorTrait for GenerateSeedance2p5PreviewRequest {
Some(KinoviSeedance2p5PreviewOutputResolution::FourEightyP) => SEEDANCE_2P5_PREVIEW_480P,
Some(KinoviSeedance2p5PreviewOutputResolution::SevenTwentyP) | None => SEEDANCE_2P5_PREVIEW_720P,
};
let total_credits = rate.credits(tier) * f64::from(self.duration_seconds);
let batch_multiplier: f64 = match self.batch_count {
None | Some(KinoviSeedance2p5PreviewBatchCount::One) => 1.0,
Some(KinoviSeedance2p5PreviewBatchCount::Two) => 2.0,
Some(KinoviSeedance2p5PreviewBatchCount::Three) => 3.0,
Some(KinoviSeedance2p5PreviewBatchCount::Four) => 4.0,
Some(KinoviSeedance2p5PreviewBatchCount::Five) => 5.0,
Some(KinoviSeedance2p5PreviewBatchCount::Six) => 6.0,
Some(KinoviSeedance2p5PreviewBatchCount::Seven) => 7.0,
Some(KinoviSeedance2p5PreviewBatchCount::Eight) => 8.0,
};
let total_credits = rate.credits(tier) * f64::from(self.duration_seconds) * batch_multiplier;
tier.cost_from_credits(total_credits)
}
}
@@ -139,7 +164,7 @@ fn to_raw_request(req: GenerateSeedance2p5PreviewRequest) -> WorkflowRunTaskRequ
aspect_ratio: map_aspect_ratio(req.aspect_ratio),
output_resolution: Some(map_output_resolution(req.output_resolution)),
duration_seconds: req.duration_seconds,
batch_count: KinoviBatchCountRaw::One,
batch_count: map_batch_count(req.batch_count),
start_frame_url: None,
end_frame_url: None,
reference_image_urls: req.reference_image_urls,
@@ -174,6 +199,19 @@ fn map_output_resolution(res: Option<KinoviSeedance2p5PreviewOutputResolution>)
}
}
fn map_batch_count(bc: Option<KinoviSeedance2p5PreviewBatchCount>) -> KinoviBatchCountRaw {
match bc {
Some(KinoviSeedance2p5PreviewBatchCount::One) | None => KinoviBatchCountRaw::One,
Some(KinoviSeedance2p5PreviewBatchCount::Two) => KinoviBatchCountRaw::Two,
Some(KinoviSeedance2p5PreviewBatchCount::Three) => KinoviBatchCountRaw::Three,
Some(KinoviSeedance2p5PreviewBatchCount::Four) => KinoviBatchCountRaw::Four,
Some(KinoviSeedance2p5PreviewBatchCount::Five) => KinoviBatchCountRaw::Five,
Some(KinoviSeedance2p5PreviewBatchCount::Six) => KinoviBatchCountRaw::Six,
Some(KinoviSeedance2p5PreviewBatchCount::Seven) => KinoviBatchCountRaw::Seven,
Some(KinoviSeedance2p5PreviewBatchCount::Eight) => KinoviBatchCountRaw::Eight,
}
}
// ── Tests ──
#[cfg(test)]
@@ -507,6 +545,110 @@ mod tests {
}
}
mod batch_tests {
use super::*;
use crate::test_utils::assert_batch_cost::assert_batch_cost;
const BATCHES: [KinoviSeedance2p5PreviewBatchCount; 8] = [
KinoviSeedance2p5PreviewBatchCount::One,
KinoviSeedance2p5PreviewBatchCount::Two,
KinoviSeedance2p5PreviewBatchCount::Three,
KinoviSeedance2p5PreviewBatchCount::Four,
KinoviSeedance2p5PreviewBatchCount::Five,
KinoviSeedance2p5PreviewBatchCount::Six,
KinoviSeedance2p5PreviewBatchCount::Seven,
KinoviSeedance2p5PreviewBatchCount::Eight,
];
// Each pair is (credits, USD cents rounded up), for batches 1 through 8.
// Pin literal totals so rate changes fail independently of scaling checks.
#[test]
fn fixed_five_second_batch_prices_with_and_without_references() {
let cases = [
(KinoviSeedance2p5PreviewOutputResolution::FourEightyP, KinoviPricingTier::Consumer, [
(230.75, 120), (461.50, 240), (692.25, 359), (923.00, 479),
(1153.75, 598), (1384.50, 718), (1615.25, 838), (1846.00, 957),
]),
(KinoviSeedance2p5PreviewOutputResolution::SevenTwentyP, KinoviPricingTier::Consumer, [
(460.15, 239), (920.30, 477), (1380.45, 716), (1840.60, 954),
(2300.75, 1193), (2760.90, 1431), (3221.05, 1670), (3681.20, 1908),
]),
(KinoviSeedance2p5PreviewOutputResolution::FourEightyP, KinoviPricingTier::Enterprise, [
(210.65, 87), (421.30, 174), (631.95, 260), (842.60, 347),
(1053.25, 434), (1263.90, 520), (1474.55, 607), (1685.20, 694),
]),
(KinoviSeedance2p5PreviewOutputResolution::SevenTwentyP, KinoviPricingTier::Enterprise, [
(421.3, 174), (842.6, 347), (1263.9, 520), (1685.2, 694),
(2106.5, 867), (2527.8, 1040), (2949.1, 1213), (3370.4, 1387),
]),
];
for (resolution, tier, prices) in cases {
for has_references in [false, true] {
for (batch, (credits, usd_cents)) in BATCHES.into_iter().zip(prices) {
let mut request = build_request(5, Some(resolution));
request.batch_count = Some(batch);
if has_references {
request = with_all_reference_types(request);
}
let cost = request.calculate_costs(tier);
assert_eq!(
(cost.kinovi_credits, cost.usd_cents_rounded_up),
(credits, usd_cents),
"{resolution:?} {tier:?} {batch:?} references={has_references}",
);
}
}
}
}
#[test]
fn all_batches_scale_every_resolution_with_and_without_references() {
let batches = [
(None, 1),
(Some(KinoviSeedance2p5PreviewBatchCount::One), 1),
(Some(KinoviSeedance2p5PreviewBatchCount::Two), 2),
(Some(KinoviSeedance2p5PreviewBatchCount::Three), 3),
(Some(KinoviSeedance2p5PreviewBatchCount::Four), 4),
(Some(KinoviSeedance2p5PreviewBatchCount::Five), 5),
(Some(KinoviSeedance2p5PreviewBatchCount::Six), 6),
(Some(KinoviSeedance2p5PreviewBatchCount::Seven), 7),
(Some(KinoviSeedance2p5PreviewBatchCount::Eight), 8),
];
for tier in [KinoviPricingTier::Enterprise, KinoviPricingTier::Consumer] {
for resolution in [
None,
Some(KinoviSeedance2p5PreviewOutputResolution::FourEightyP),
Some(KinoviSeedance2p5PreviewOutputResolution::SevenTwentyP),
] {
for duration in [4, 5, 30] {
for has_references in [false, true] {
let mut request = build_request(duration, resolution);
if has_references {
request = with_all_reference_types(request);
}
let single = request.calculate_costs(tier);
for (batch, count) in batches {
request.batch_count = batch;
let batched = request.calculate_costs(tier);
let context = format!("{tier:?} {resolution:?} {duration}s {batch:?} references={has_references}");
assert_batch_cost(single, batched, count, tier, &context);
}
}
}
}
}
}
#[test]
fn batch_costs_and_request_count_agree() {
let mut request = r480(4);
request.batch_count = Some(KinoviSeedance2p5PreviewBatchCount::Eight);
assert_eq!(request.calculate_consumer_costs().kinovi_credits, 1476.8);
assert_eq!(request.calculate_enterprise_costs().kinovi_credits, 1348.16);
assert!(matches!(to_raw_request(request).batch_count, KinoviBatchCountRaw::Eight));
}
}
// ── Helpers ──
fn build_request(
@@ -514,6 +656,7 @@ mod tests {
output_resolution: Option<KinoviSeedance2p5PreviewOutputResolution>,
) -> GenerateSeedance2p5PreviewRequest {
GenerateSeedance2p5PreviewRequest {
batch_count: None,
prompt: String::new(),
aspect_ratio: None,
output_resolution,
@@ -556,6 +699,7 @@ mod tests {
session: &session,
host_override: None,
request: GenerateSeedance2p5PreviewRequest {
batch_count: None,
prompt: "A man is running from a t-rex".to_string(),
aspect_ratio: Some(KinoviSeedance2p5PreviewAspectRatio::Landscape16x9),
output_resolution: Some(KinoviSeedance2p5PreviewOutputResolution::FourEightyP),
@@ -582,6 +726,7 @@ mod tests {
session: &session,
host_override: None,
request: GenerateSeedance2p5PreviewRequest {
batch_count: None,
prompt: "A corgi and a shiba are playing chess against one another".to_string(),
aspect_ratio: None,
output_resolution: None,
@@ -0,0 +1,97 @@
//! Wan 3.0 image-to-video through the Kinovi consumer workflow endpoint.
use crate::creds::kinovi_web_session::KinoviWebSession;
use crate::error::kinovi_web_error::KinoviWebError;
use crate::generate::video::wan_3p0::{BUSINESS_TYPE, Wan3p0ApiParams, Wan3p0Model, invalid_field};
use crate::pricing::cost::kinovi_fractional_generation_cost::KinoviFractionalGenerationCost;
use crate::pricing::kinovi_cost_calculator_trait::KinoviCostCalculatorTrait;
use crate::pricing::kinovi_pricing_tier::KinoviPricingTier;
use crate::requests::kinovi_host::KinoviHost;
use crate::requests::workflow_run_task::workflow_run_task::{workflow_run_task_custom, WorkflowRunTaskCustomArgs};
pub use crate::generate::video::wan_3p0::{KinoviWan3p0AspectRatio, KinoviWan3p0OutputResolution};
pub use crate::requests::workflow_run_task::workflow_run_task::WorkflowRunTaskResponse as GenerateWan3p0ImageToVideoResponse;
pub struct GenerateWan3p0ImageToVideoArgs<'a> {
pub request: GenerateWan3p0ImageToVideoRequest,
pub session: &'a KinoviWebSession,
pub maybe_host_override: Option<KinoviHost>,
}
#[derive(Clone, Debug)]
pub struct GenerateWan3p0ImageToVideoRequest {
/// Up to 20,000 characters; may be empty when media is supplied.
pub prompt: String,
/// None resolves to 16:9.
pub maybe_aspect_ratio: Option<KinoviWan3p0AspectRatio>,
/// None resolves to 720p for both pricing and generation.
pub maybe_output_resolution: Option<KinoviWan3p0OutputResolution>,
/// Any whole-second duration from 2 through 30 (not just the UI presets).
pub duration_seconds: u8,
/// Optional noise seed, inclusive range 0..=2147483647.
pub maybe_seed: Option<u32>,
/// None keeps Kinovi's default generated soundtrack; false disables it.
pub maybe_generate_audio: Option<bool>,
/// Required for generation; optional so costs can be estimated without media.
pub maybe_start_frame_url: Option<String>,
/// Optional final keyframe. Requires a start frame.
pub maybe_end_frame_url: Option<String>,
}
impl Default for GenerateWan3p0ImageToVideoRequest {
fn default() -> Self {
Self {
prompt: String::new(),
maybe_aspect_ratio: None,
maybe_output_resolution: None,
duration_seconds: 5,
maybe_seed: None,
maybe_generate_audio: None,
maybe_start_frame_url: None,
maybe_end_frame_url: None,
}
}
}
impl KinoviCostCalculatorTrait for GenerateWan3p0ImageToVideoRequest {
type Cost = KinoviFractionalGenerationCost;
/// Prices output seconds only. Images, video/audio references, seed, and
/// aspect ratio do not change the credit charge. Both tiers use the same
/// credit rate and their respective credit-to-USD conversion.
fn calculate_costs(&self, tier: KinoviPricingTier) -> Self::Cost {
Wan3p0Model::ImageToVideo.calculate_costs(self.maybe_output_resolution, self.duration_seconds, tier)
}
}
impl GenerateWan3p0ImageToVideoRequest {
pub(super) fn build_api_params(self) -> Result<Wan3p0ApiParams, KinoviWebError> {
if self.maybe_end_frame_url.is_some() && self.maybe_start_frame_url.is_none() {
return Err(invalid_field("maybe_end_frame_url", "", "an end frame requires a start frame"));
}
let mut params = Wan3p0ApiParams::new(
Wan3p0Model::ImageToVideo,
self.prompt,
self.maybe_aspect_ratio,
self.maybe_output_resolution,
self.duration_seconds,
self.maybe_seed,
self.maybe_generate_audio,
)?;
params.uploaded_urls = Some(self.maybe_start_frame_url.into_iter().chain(self.maybe_end_frame_url).collect());
params.validate_media(self.duration_seconds, None)?;
Ok(params)
}
}
pub async fn generate_wan_3p0_image_to_video(
args: GenerateWan3p0ImageToVideoArgs<'_>,
) -> Result<GenerateWan3p0ImageToVideoResponse, KinoviWebError> {
workflow_run_task_custom(WorkflowRunTaskCustomArgs {
business_type: BUSINESS_TYPE,
api_params: args.request.build_api_params()?,
session: args.session,
host_override: args.maybe_host_override,
})
.await
}
@@ -0,0 +1,97 @@
//! Wan 3.0 Prime image-to-video through the Kinovi consumer workflow endpoint.
use crate::creds::kinovi_web_session::KinoviWebSession;
use crate::error::kinovi_web_error::KinoviWebError;
use crate::generate::video::wan_3p0::{BUSINESS_TYPE, Wan3p0ApiParams, Wan3p0Model, invalid_field};
use crate::pricing::cost::kinovi_fractional_generation_cost::KinoviFractionalGenerationCost;
use crate::pricing::kinovi_cost_calculator_trait::KinoviCostCalculatorTrait;
use crate::pricing::kinovi_pricing_tier::KinoviPricingTier;
use crate::requests::kinovi_host::KinoviHost;
use crate::requests::workflow_run_task::workflow_run_task::{workflow_run_task_custom, WorkflowRunTaskCustomArgs};
pub use crate::generate::video::wan_3p0::{KinoviWan3p0AspectRatio, KinoviWan3p0OutputResolution};
pub use crate::requests::workflow_run_task::workflow_run_task::WorkflowRunTaskResponse as GenerateWan3p0PrimeImageToVideoResponse;
pub struct GenerateWan3p0PrimeImageToVideoArgs<'a> {
pub request: GenerateWan3p0PrimeImageToVideoRequest,
pub session: &'a KinoviWebSession,
pub maybe_host_override: Option<KinoviHost>,
}
#[derive(Clone, Debug)]
pub struct GenerateWan3p0PrimeImageToVideoRequest {
/// Up to 20,000 characters; may be empty when media is supplied.
pub prompt: String,
/// None resolves to 16:9.
pub maybe_aspect_ratio: Option<KinoviWan3p0AspectRatio>,
/// None resolves to 720p for both pricing and generation.
pub maybe_output_resolution: Option<KinoviWan3p0OutputResolution>,
/// Any whole-second duration from 2 through 30 (not just the UI presets).
pub duration_seconds: u8,
/// Optional noise seed, inclusive range 0..=2147483647.
pub maybe_seed: Option<u32>,
/// None keeps Kinovi's default generated soundtrack; false disables it.
pub maybe_generate_audio: Option<bool>,
/// Required for generation; optional so costs can be estimated without media.
pub maybe_start_frame_url: Option<String>,
/// Optional final keyframe. Requires a start frame.
pub maybe_end_frame_url: Option<String>,
}
impl Default for GenerateWan3p0PrimeImageToVideoRequest {
fn default() -> Self {
Self {
prompt: String::new(),
maybe_aspect_ratio: None,
maybe_output_resolution: None,
duration_seconds: 5,
maybe_seed: None,
maybe_generate_audio: None,
maybe_start_frame_url: None,
maybe_end_frame_url: None,
}
}
}
impl KinoviCostCalculatorTrait for GenerateWan3p0PrimeImageToVideoRequest {
type Cost = KinoviFractionalGenerationCost;
/// Prices output seconds only. Images, video/audio references, seed, and
/// aspect ratio do not change the credit charge. Both tiers use the same
/// credit rate and their respective credit-to-USD conversion.
fn calculate_costs(&self, tier: KinoviPricingTier) -> Self::Cost {
Wan3p0Model::PrimeImageToVideo.calculate_costs(self.maybe_output_resolution, self.duration_seconds, tier)
}
}
impl GenerateWan3p0PrimeImageToVideoRequest {
pub(super) fn build_api_params(self) -> Result<Wan3p0ApiParams, KinoviWebError> {
if self.maybe_end_frame_url.is_some() && self.maybe_start_frame_url.is_none() {
return Err(invalid_field("maybe_end_frame_url", "", "an end frame requires a start frame"));
}
let mut params = Wan3p0ApiParams::new(
Wan3p0Model::PrimeImageToVideo,
self.prompt,
self.maybe_aspect_ratio,
self.maybe_output_resolution,
self.duration_seconds,
self.maybe_seed,
self.maybe_generate_audio,
)?;
params.uploaded_urls = Some(self.maybe_start_frame_url.into_iter().chain(self.maybe_end_frame_url).collect());
params.validate_media(self.duration_seconds, None)?;
Ok(params)
}
}
pub async fn generate_wan_3p0_prime_image_to_video(
args: GenerateWan3p0PrimeImageToVideoArgs<'_>,
) -> Result<GenerateWan3p0PrimeImageToVideoResponse, KinoviWebError> {
workflow_run_task_custom(WorkflowRunTaskCustomArgs {
business_type: BUSINESS_TYPE,
api_params: args.request.build_api_params()?,
session: args.session,
host_override: args.maybe_host_override,
})
.await
}
@@ -0,0 +1,105 @@
//! Wan 3.0 Prime ref-to-video through the Kinovi consumer workflow endpoint.
use crate::creds::kinovi_web_session::KinoviWebSession;
use crate::error::kinovi_web_error::KinoviWebError;
use crate::generate::video::wan_3p0::{BUSINESS_TYPE, Wan3p0ApiParams, Wan3p0Model};
use crate::pricing::cost::kinovi_fractional_generation_cost::KinoviFractionalGenerationCost;
use crate::pricing::kinovi_cost_calculator_trait::KinoviCostCalculatorTrait;
use crate::pricing::kinovi_pricing_tier::KinoviPricingTier;
use crate::requests::kinovi_host::KinoviHost;
use crate::requests::workflow_run_task::workflow_run_task::{workflow_run_task_custom, WorkflowRunTaskCustomArgs};
pub use crate::generate::video::wan_3p0::{KinoviWan3p0AspectRatio, KinoviWan3p0OutputResolution};
pub use crate::requests::workflow_run_task::workflow_run_task::WorkflowRunTaskResponse as GenerateWan3p0PrimeRefToVideoResponse;
pub struct GenerateWan3p0PrimeRefToVideoArgs<'a> {
pub request: GenerateWan3p0PrimeRefToVideoRequest,
pub session: &'a KinoviWebSession,
pub maybe_host_override: Option<KinoviHost>,
}
#[derive(Clone, Debug)]
pub struct GenerateWan3p0PrimeRefToVideoRequest {
/// Up to 20,000 characters; may be empty when media is supplied.
pub prompt: String,
/// None resolves to 16:9.
pub maybe_aspect_ratio: Option<KinoviWan3p0AspectRatio>,
/// None resolves to 720p for both pricing and generation.
pub maybe_output_resolution: Option<KinoviWan3p0OutputResolution>,
/// Any whole-second duration from 2 through 30 (not just the UI presets).
pub duration_seconds: u8,
/// Optional noise seed, inclusive range 0..=2147483647.
pub maybe_seed: Option<u32>,
/// None keeps Kinovi's default generated soundtrack; false disables it.
pub maybe_generate_audio: Option<bool>,
/// Up to 10 reference images, addressed as Image 1, Image 2, etc.
pub maybe_reference_image_urls: Option<Vec<String>>,
/// Up to 5 videos, each 1-15s, totaling at most 15s.
pub maybe_reference_video_urls: Option<Vec<String>>,
/// Up to 5 audio files, each 1-15s, totaling at most 15s.
/// Actual file durations are validated by Kinovi.
pub maybe_reference_audio_urls: Option<Vec<String>>,
/// Optional measured TOTAL reference-video duration, used only for validation.
/// Reference duration + output duration must not exceed 30s. This metadata
/// is not sent or billed. Without it, Kinovi validates the actual durations.
pub maybe_total_reference_video_duration_millis: Option<u32>,
}
impl Default for GenerateWan3p0PrimeRefToVideoRequest {
fn default() -> Self {
Self {
prompt: String::new(),
maybe_aspect_ratio: None,
maybe_output_resolution: None,
duration_seconds: 5,
maybe_seed: None,
maybe_generate_audio: None,
maybe_reference_image_urls: None,
maybe_reference_video_urls: None,
maybe_reference_audio_urls: None,
maybe_total_reference_video_duration_millis: None,
}
}
}
impl KinoviCostCalculatorTrait for GenerateWan3p0PrimeRefToVideoRequest {
type Cost = KinoviFractionalGenerationCost;
/// Prices output seconds only. Images, video/audio references, seed, and
/// aspect ratio do not change the credit charge. Both tiers use the same
/// credit rate and their respective credit-to-USD conversion.
fn calculate_costs(&self, tier: KinoviPricingTier) -> Self::Cost {
Wan3p0Model::PrimeRefToVideo.calculate_costs(self.maybe_output_resolution, self.duration_seconds, tier)
}
}
impl GenerateWan3p0PrimeRefToVideoRequest {
pub(super) fn build_api_params(self) -> Result<Wan3p0ApiParams, KinoviWebError> {
let mut params = Wan3p0ApiParams::new(
Wan3p0Model::PrimeRefToVideo,
self.prompt,
self.maybe_aspect_ratio,
self.maybe_output_resolution,
self.duration_seconds,
self.maybe_seed,
self.maybe_generate_audio,
)?;
params.uploaded_urls = self.maybe_reference_image_urls;
params.video_urls = self.maybe_reference_video_urls;
params.audio_urls = self.maybe_reference_audio_urls;
params.validate_media(self.duration_seconds, self.maybe_total_reference_video_duration_millis)?;
Ok(params)
}
}
pub async fn generate_wan_3p0_prime_ref_to_video(
args: GenerateWan3p0PrimeRefToVideoArgs<'_>,
) -> Result<GenerateWan3p0PrimeRefToVideoResponse, KinoviWebError> {
workflow_run_task_custom(WorkflowRunTaskCustomArgs {
business_type: BUSINESS_TYPE,
api_params: args.request.build_api_params()?,
session: args.session,
host_override: args.maybe_host_override,
})
.await
}
@@ -0,0 +1,87 @@
//! Wan 3.0 Prime text-to-video through the Kinovi consumer workflow endpoint.
use crate::creds::kinovi_web_session::KinoviWebSession;
use crate::error::kinovi_web_error::KinoviWebError;
use crate::generate::video::wan_3p0::{BUSINESS_TYPE, Wan3p0ApiParams, Wan3p0Model};
use crate::pricing::cost::kinovi_fractional_generation_cost::KinoviFractionalGenerationCost;
use crate::pricing::kinovi_cost_calculator_trait::KinoviCostCalculatorTrait;
use crate::pricing::kinovi_pricing_tier::KinoviPricingTier;
use crate::requests::kinovi_host::KinoviHost;
use crate::requests::workflow_run_task::workflow_run_task::{workflow_run_task_custom, WorkflowRunTaskCustomArgs};
pub use crate::generate::video::wan_3p0::{KinoviWan3p0AspectRatio, KinoviWan3p0OutputResolution};
pub use crate::requests::workflow_run_task::workflow_run_task::WorkflowRunTaskResponse as GenerateWan3p0PrimeTextToVideoResponse;
pub struct GenerateWan3p0PrimeTextToVideoArgs<'a> {
pub request: GenerateWan3p0PrimeTextToVideoRequest,
pub session: &'a KinoviWebSession,
pub maybe_host_override: Option<KinoviHost>,
}
#[derive(Clone, Debug)]
pub struct GenerateWan3p0PrimeTextToVideoRequest {
/// Required prompt, up to 20,000 characters.
pub prompt: String,
/// None resolves to 16:9.
pub maybe_aspect_ratio: Option<KinoviWan3p0AspectRatio>,
/// None resolves to 720p for both pricing and generation.
pub maybe_output_resolution: Option<KinoviWan3p0OutputResolution>,
/// Any whole-second duration from 2 through 30 (not just the UI presets).
pub duration_seconds: u8,
/// Optional noise seed, inclusive range 0..=2147483647.
pub maybe_seed: Option<u32>,
/// None keeps Kinovi's default generated soundtrack; false disables it.
pub maybe_generate_audio: Option<bool>,
}
impl Default for GenerateWan3p0PrimeTextToVideoRequest {
fn default() -> Self {
Self {
prompt: String::new(),
maybe_aspect_ratio: None,
maybe_output_resolution: None,
duration_seconds: 5,
maybe_seed: None,
maybe_generate_audio: None,
}
}
}
impl KinoviCostCalculatorTrait for GenerateWan3p0PrimeTextToVideoRequest {
type Cost = KinoviFractionalGenerationCost;
/// Prices output seconds only. Images, video/audio references, seed, and
/// aspect ratio do not change the credit charge. Both tiers use the same
/// credit rate and their respective credit-to-USD conversion.
fn calculate_costs(&self, tier: KinoviPricingTier) -> Self::Cost {
Wan3p0Model::PrimeTextToVideo.calculate_costs(self.maybe_output_resolution, self.duration_seconds, tier)
}
}
impl GenerateWan3p0PrimeTextToVideoRequest {
pub(super) fn build_api_params(self) -> Result<Wan3p0ApiParams, KinoviWebError> {
let mut params = Wan3p0ApiParams::new(
Wan3p0Model::PrimeTextToVideo,
self.prompt,
self.maybe_aspect_ratio,
self.maybe_output_resolution,
self.duration_seconds,
self.maybe_seed,
self.maybe_generate_audio,
)?;
params.validate_media(self.duration_seconds, None)?;
Ok(params)
}
}
pub async fn generate_wan_3p0_prime_text_to_video(
args: GenerateWan3p0PrimeTextToVideoArgs<'_>,
) -> Result<GenerateWan3p0PrimeTextToVideoResponse, KinoviWebError> {
workflow_run_task_custom(WorkflowRunTaskCustomArgs {
business_type: BUSINESS_TYPE,
api_params: args.request.build_api_params()?,
session: args.session,
host_override: args.maybe_host_override,
})
.await
}
@@ -0,0 +1,105 @@
//! Wan 3.0 ref-to-video through the Kinovi consumer workflow endpoint.
use crate::creds::kinovi_web_session::KinoviWebSession;
use crate::error::kinovi_web_error::KinoviWebError;
use crate::generate::video::wan_3p0::{BUSINESS_TYPE, Wan3p0ApiParams, Wan3p0Model};
use crate::pricing::cost::kinovi_fractional_generation_cost::KinoviFractionalGenerationCost;
use crate::pricing::kinovi_cost_calculator_trait::KinoviCostCalculatorTrait;
use crate::pricing::kinovi_pricing_tier::KinoviPricingTier;
use crate::requests::kinovi_host::KinoviHost;
use crate::requests::workflow_run_task::workflow_run_task::{workflow_run_task_custom, WorkflowRunTaskCustomArgs};
pub use crate::generate::video::wan_3p0::{KinoviWan3p0AspectRatio, KinoviWan3p0OutputResolution};
pub use crate::requests::workflow_run_task::workflow_run_task::WorkflowRunTaskResponse as GenerateWan3p0RefToVideoResponse;
pub struct GenerateWan3p0RefToVideoArgs<'a> {
pub request: GenerateWan3p0RefToVideoRequest,
pub session: &'a KinoviWebSession,
pub maybe_host_override: Option<KinoviHost>,
}
#[derive(Clone, Debug)]
pub struct GenerateWan3p0RefToVideoRequest {
/// Up to 20,000 characters; may be empty when media is supplied.
pub prompt: String,
/// None resolves to 16:9.
pub maybe_aspect_ratio: Option<KinoviWan3p0AspectRatio>,
/// None resolves to 720p for both pricing and generation.
pub maybe_output_resolution: Option<KinoviWan3p0OutputResolution>,
/// Any whole-second duration from 2 through 30 (not just the UI presets).
pub duration_seconds: u8,
/// Optional noise seed, inclusive range 0..=2147483647.
pub maybe_seed: Option<u32>,
/// None keeps Kinovi's default generated soundtrack; false disables it.
pub maybe_generate_audio: Option<bool>,
/// Up to 10 reference images, addressed as Image 1, Image 2, etc.
pub maybe_reference_image_urls: Option<Vec<String>>,
/// Up to 5 videos, each 1-15s, totaling at most 15s.
pub maybe_reference_video_urls: Option<Vec<String>>,
/// Up to 5 audio files, each 1-15s, totaling at most 15s.
/// Actual file durations are validated by Kinovi.
pub maybe_reference_audio_urls: Option<Vec<String>>,
/// Optional measured TOTAL reference-video duration, used only for validation.
/// Reference duration + output duration must not exceed 30s. This metadata
/// is not sent or billed. Without it, Kinovi validates the actual durations.
pub maybe_total_reference_video_duration_millis: Option<u32>,
}
impl Default for GenerateWan3p0RefToVideoRequest {
fn default() -> Self {
Self {
prompt: String::new(),
maybe_aspect_ratio: None,
maybe_output_resolution: None,
duration_seconds: 5,
maybe_seed: None,
maybe_generate_audio: None,
maybe_reference_image_urls: None,
maybe_reference_video_urls: None,
maybe_reference_audio_urls: None,
maybe_total_reference_video_duration_millis: None,
}
}
}
impl KinoviCostCalculatorTrait for GenerateWan3p0RefToVideoRequest {
type Cost = KinoviFractionalGenerationCost;
/// Prices output seconds only. Images, video/audio references, seed, and
/// aspect ratio do not change the credit charge. Both tiers use the same
/// credit rate and their respective credit-to-USD conversion.
fn calculate_costs(&self, tier: KinoviPricingTier) -> Self::Cost {
Wan3p0Model::RefToVideo.calculate_costs(self.maybe_output_resolution, self.duration_seconds, tier)
}
}
impl GenerateWan3p0RefToVideoRequest {
pub(super) fn build_api_params(self) -> Result<Wan3p0ApiParams, KinoviWebError> {
let mut params = Wan3p0ApiParams::new(
Wan3p0Model::RefToVideo,
self.prompt,
self.maybe_aspect_ratio,
self.maybe_output_resolution,
self.duration_seconds,
self.maybe_seed,
self.maybe_generate_audio,
)?;
params.uploaded_urls = self.maybe_reference_image_urls;
params.video_urls = self.maybe_reference_video_urls;
params.audio_urls = self.maybe_reference_audio_urls;
params.validate_media(self.duration_seconds, self.maybe_total_reference_video_duration_millis)?;
Ok(params)
}
}
pub async fn generate_wan_3p0_ref_to_video(
args: GenerateWan3p0RefToVideoArgs<'_>,
) -> Result<GenerateWan3p0RefToVideoResponse, KinoviWebError> {
workflow_run_task_custom(WorkflowRunTaskCustomArgs {
business_type: BUSINESS_TYPE,
api_params: args.request.build_api_params()?,
session: args.session,
host_override: args.maybe_host_override,
})
.await
}
@@ -0,0 +1,87 @@
//! Wan 3.0 text-to-video through the Kinovi consumer workflow endpoint.
use crate::creds::kinovi_web_session::KinoviWebSession;
use crate::error::kinovi_web_error::KinoviWebError;
use crate::generate::video::wan_3p0::{BUSINESS_TYPE, Wan3p0ApiParams, Wan3p0Model};
use crate::pricing::cost::kinovi_fractional_generation_cost::KinoviFractionalGenerationCost;
use crate::pricing::kinovi_cost_calculator_trait::KinoviCostCalculatorTrait;
use crate::pricing::kinovi_pricing_tier::KinoviPricingTier;
use crate::requests::kinovi_host::KinoviHost;
use crate::requests::workflow_run_task::workflow_run_task::{workflow_run_task_custom, WorkflowRunTaskCustomArgs};
pub use crate::generate::video::wan_3p0::{KinoviWan3p0AspectRatio, KinoviWan3p0OutputResolution};
pub use crate::requests::workflow_run_task::workflow_run_task::WorkflowRunTaskResponse as GenerateWan3p0TextToVideoResponse;
pub struct GenerateWan3p0TextToVideoArgs<'a> {
pub request: GenerateWan3p0TextToVideoRequest,
pub session: &'a KinoviWebSession,
pub maybe_host_override: Option<KinoviHost>,
}
#[derive(Clone, Debug)]
pub struct GenerateWan3p0TextToVideoRequest {
/// Required prompt, up to 20,000 characters.
pub prompt: String,
/// None resolves to 16:9.
pub maybe_aspect_ratio: Option<KinoviWan3p0AspectRatio>,
/// None resolves to 720p for both pricing and generation.
pub maybe_output_resolution: Option<KinoviWan3p0OutputResolution>,
/// Any whole-second duration from 2 through 30 (not just the UI presets).
pub duration_seconds: u8,
/// Optional noise seed, inclusive range 0..=2147483647.
pub maybe_seed: Option<u32>,
/// None keeps Kinovi's default generated soundtrack; false disables it.
pub maybe_generate_audio: Option<bool>,
}
impl Default for GenerateWan3p0TextToVideoRequest {
fn default() -> Self {
Self {
prompt: String::new(),
maybe_aspect_ratio: None,
maybe_output_resolution: None,
duration_seconds: 5,
maybe_seed: None,
maybe_generate_audio: None,
}
}
}
impl KinoviCostCalculatorTrait for GenerateWan3p0TextToVideoRequest {
type Cost = KinoviFractionalGenerationCost;
/// Prices output seconds only. Images, video/audio references, seed, and
/// aspect ratio do not change the credit charge. Both tiers use the same
/// credit rate and their respective credit-to-USD conversion.
fn calculate_costs(&self, tier: KinoviPricingTier) -> Self::Cost {
Wan3p0Model::TextToVideo.calculate_costs(self.maybe_output_resolution, self.duration_seconds, tier)
}
}
impl GenerateWan3p0TextToVideoRequest {
pub(super) fn build_api_params(self) -> Result<Wan3p0ApiParams, KinoviWebError> {
let mut params = Wan3p0ApiParams::new(
Wan3p0Model::TextToVideo,
self.prompt,
self.maybe_aspect_ratio,
self.maybe_output_resolution,
self.duration_seconds,
self.maybe_seed,
self.maybe_generate_audio,
)?;
params.validate_media(self.duration_seconds, None)?;
Ok(params)
}
}
pub async fn generate_wan_3p0_text_to_video(
args: GenerateWan3p0TextToVideoArgs<'_>,
) -> Result<GenerateWan3p0TextToVideoResponse, KinoviWebError> {
workflow_run_task_custom(WorkflowRunTaskCustomArgs {
business_type: BUSINESS_TYPE,
api_params: args.request.build_api_params()?,
session: args.session,
host_override: args.maybe_host_override,
})
.await
}
@@ -4,3 +4,13 @@ pub mod generate_seedance_2p0_fast;
pub mod generate_seedance_2p0_mini;
pub mod generate_seedance_2p5;
pub mod generate_seedance_2p5_preview;
pub mod generate_wan_3p0_text_to_video;
pub mod generate_wan_3p0_image_to_video;
pub mod generate_wan_3p0_ref_to_video;
pub mod generate_wan_3p0_prime_text_to_video;
pub mod generate_wan_3p0_prime_image_to_video;
pub mod generate_wan_3p0_prime_ref_to_video;
pub mod wan_3p0;
#[cfg(test)]
mod wan_3p0_tests;
@@ -0,0 +1,245 @@
//! Shared options and wire format for Wan 3.0 and Wan 3.0 Prime.
//!
//! Schema: https://kinovi.ai/models/wan3-ref-to-video (checked 2026-09-18).
//! All modes bill only OUTPUT seconds; reference media does not add a surcharge.
//!
//! Output accepts any whole-second duration from 2 through 30. Reference
//! videos must each be 1-15s and total at most 15s; their total plus output
//! duration must not exceed 30s. Thus 10s of video references allows at most
//! 20s of output. Audio and image references do not consume this video budget.
//! The optional measured reference duration enables local validation; otherwise
//! Kinovi checks actual file durations. This client does not probe media URLs.
//!
//! Pricing sources (all three modalities share each family's rates):
//! - https://kinovi.ai/models/wan3-text-to-video
//! - https://kinovi.ai/models/wan3-prime-ref-to-video
//! Pricing maintenance and the September 23 promotion: `costs_wan_3p0.md`
//! at the crate root.
use serde_derive::Serialize;
use crate::error::kinovi_web_client_error::KinoviWebClientError;
use crate::error::kinovi_web_error::KinoviWebError;
use crate::pricing::cost::kinovi_fractional_generation_cost::KinoviFractionalGenerationCost;
use crate::pricing::kinovi_pricing_tier::KinoviPricingTier;
pub(super) const BUSINESS_TYPE: &str = "wan3-video-generation";
pub const MAX_SEED: u32 = 2_147_483_647;
pub const MIN_DURATION_SECONDS: u8 = 2;
pub const MAX_DURATION_SECONDS: u8 = 30;
pub const MAX_REFERENCE_VIDEO_DURATION_MILLIS: u32 = 15_000;
// Hundredths of a credit per OUTPUT second, as observed 2026-09-18.
// Regular Wan intentionally uses the promotional rates (30% off until 2026-09-23).
// TODO(2026-09-24): Recheck live pricing using costs_wan_3p0.md at the crate root.
// That note records advertised replacement rates, precision needs, and test updates.
// Keep these promotional rates until a reviewed update; no automatic date switch.
// Prime 480p is 18.86/s, so 30s costs 565.8, not 568.8 credits.
// No separate enterprise credit discount has been supplied for these models.
const WAN_CREDIT_HUNDREDTHS_PER_SECOND: [u64; 3] = [998, 1996, 3991];
const PRIME_CREDIT_HUNDREDTHS_PER_SECOND: [u64; 3] = [1886, 3883, 7766];
/// Shared by all six Wan operations. Defaults to 16:9.
/// `adaptive` is REST-only for reference-to-video and is not a Studio option.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum KinoviWan3p0AspectRatio {
#[default]
Landscape16x9,
Portrait9x16,
Landscape4x3,
Portrait3x4,
Square1x1,
}
/// Shared by regular and Prime Wan. Defaults to 720p, pinned on the wire.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum KinoviWan3p0OutputResolution {
FourEightyP,
#[default]
SevenTwentyP,
TenEightyP,
}
#[derive(Clone, Copy, Debug)]
pub(super) enum Wan3p0Model {
TextToVideo,
ImageToVideo,
RefToVideo,
PrimeTextToVideo,
PrimeImageToVideo,
PrimeRefToVideo,
}
/// Consumer tRPC shape, verified against the 2026-09-18 captures. Images
/// use `uploadedUrls`; videos use BOTH `videoUrl` and `videoUrls`.
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct Wan3p0ApiParams {
model: &'static str,
content_mode: &'static str,
face_blur_mode: &'static str,
prompt: String,
#[serde(skip_serializing_if = "Option::is_none")]
mode: Option<&'static str>,
aspect_ratio: &'static str,
duration: String,
output_resolution: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
seed: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
audio: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(super) uploaded_urls: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
video_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(super) video_urls: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(super) audio_urls: Option<Vec<String>>,
}
impl Wan3p0Model {
pub(super) fn calculate_costs(
self,
resolution: Option<KinoviWan3p0OutputResolution>,
duration_seconds: u8,
tier: KinoviPricingTier,
) -> KinoviFractionalGenerationCost {
let rates = match self {
Self::TextToVideo | Self::ImageToVideo | Self::RefToVideo => WAN_CREDIT_HUNDREDTHS_PER_SECOND,
Self::PrimeTextToVideo | Self::PrimeImageToVideo | Self::PrimeRefToVideo => PRIME_CREDIT_HUNDREDTHS_PER_SECOND,
};
let index = match resolution.unwrap_or_default() {
KinoviWan3p0OutputResolution::FourEightyP => 0,
KinoviWan3p0OutputResolution::SevenTwentyP => 1,
KinoviWan3p0OutputResolution::TenEightyP => 2,
};
let credit_hundredths = rates[index] * u64::from(duration_seconds);
tier.cost_from_credits(credit_hundredths as f64 / 100.0)
}
}
impl Wan3p0ApiParams {
pub(super) fn new(
model: Wan3p0Model,
prompt: String,
aspect_ratio: Option<KinoviWan3p0AspectRatio>,
resolution: Option<KinoviWan3p0OutputResolution>,
duration_seconds: u8,
seed: Option<u32>,
audio: Option<bool>,
) -> Result<Self, KinoviWebError> {
if !(MIN_DURATION_SECONDS..=MAX_DURATION_SECONDS).contains(&duration_seconds) {
return Err(invalid_field("duration_seconds", duration_seconds, "must be between 2 and 30"));
}
if let Some(seed) = seed {
if seed > MAX_SEED {
return Err(invalid_field("maybe_seed", seed, "must be between 0 and 2147483647"));
}
}
if prompt.chars().count() > 20_000 {
return Err(invalid_field("prompt", prompt.chars().count(), "must not exceed 20000 characters"));
}
let (model, mode) = match model {
Wan3p0Model::TextToVideo => ("wan3.0-text-to-video", None),
Wan3p0Model::ImageToVideo => ("wan3.0-image-to-video", Some("keyframe")),
Wan3p0Model::RefToVideo => ("wan3.0-ref-to-video", Some("reference")),
Wan3p0Model::PrimeTextToVideo => ("wan3.0-prime-text-to-video", None),
Wan3p0Model::PrimeImageToVideo => ("wan3.0-prime-image-to-video", Some("keyframe")),
Wan3p0Model::PrimeRefToVideo => ("wan3.0-prime-ref-to-video", Some("reference")),
};
Ok(Self {
model,
content_mode: "normal",
face_blur_mode: "off",
prompt,
mode,
aspect_ratio: match aspect_ratio.unwrap_or_default() {
KinoviWan3p0AspectRatio::Landscape16x9 => "16:9",
KinoviWan3p0AspectRatio::Portrait9x16 => "9:16",
KinoviWan3p0AspectRatio::Landscape4x3 => "4:3",
KinoviWan3p0AspectRatio::Portrait3x4 => "3:4",
KinoviWan3p0AspectRatio::Square1x1 => "1:1",
},
duration: format!("{duration_seconds}s"),
output_resolution: match resolution.unwrap_or_default() {
KinoviWan3p0OutputResolution::FourEightyP => "480p",
KinoviWan3p0OutputResolution::SevenTwentyP => "720p",
KinoviWan3p0OutputResolution::TenEightyP => "1080p",
},
seed,
audio,
uploaded_urls: None,
video_url: None,
video_urls: None,
audio_urls: None,
})
}
pub(super) fn validate_media(
&mut self,
duration_seconds: u8,
maybe_total_reference_video_duration_millis: Option<u32>,
) -> Result<(), KinoviWebError> {
self.uploaded_urls = self.uploaded_urls.take().filter(|urls| !urls.is_empty());
self.video_urls = self.video_urls.take().filter(|urls| !urls.is_empty());
self.audio_urls = self.audio_urls.take().filter(|urls| !urls.is_empty());
let image_count = self.uploaded_urls.as_ref().map_or(0, Vec::len);
let video_count = self.video_urls.as_ref().map_or(0, Vec::len);
let audio_count = self.audio_urls.as_ref().map_or(0, Vec::len);
let max_images = if self.mode == Some("keyframe") { 2 } else { 10 };
for (field, count, maximum) in [
("maybe_reference_image_urls", image_count, max_images),
("maybe_reference_video_urls", video_count, 5),
("maybe_reference_audio_urls", audio_count, 5),
] {
if count > maximum {
return Err(invalid_field(field, count, format!("at most {maximum} items are supported")));
}
}
if self.mode == Some("keyframe") && image_count == 0 {
return Err(invalid_field("maybe_start_frame_url", "", "image-to-video requires a start frame"));
}
if self.prompt.trim().is_empty() && image_count + video_count + audio_count == 0 {
return Err(invalid_field("prompt", "", "a prompt is required when no media is supplied"));
}
for urls in [&self.uploaded_urls, &self.video_urls, &self.audio_urls].into_iter().flatten() {
if urls.iter().any(|url| url.trim().is_empty()) {
return Err(invalid_field("media_url", "", "media URLs must not be empty"));
}
}
if video_count > 0 {
// Without measured metadata, only enforce the known lower bound:
// each reference video must be at least 1s. Kinovi validates actual
// media durations; the client does not download/probe URLs here.
let minimum_millis = video_count as u32 * 1_000;
let input_millis = maybe_total_reference_video_duration_millis.unwrap_or(minimum_millis);
if !(minimum_millis..=MAX_REFERENCE_VIDEO_DURATION_MILLIS).contains(&input_millis) {
return Err(invalid_field(
"maybe_total_reference_video_duration_millis",
input_millis,
"reference videos must each be 1-15s and total at most 15s",
));
}
if input_millis + u32::from(duration_seconds) * 1_000 > 30_000 {
return Err(invalid_field(
"duration_seconds",
duration_seconds,
"reference-video duration plus output duration must not exceed 30s",
));
}
self.video_url = self.video_urls.as_ref().and_then(|urls| urls.first()).cloned();
}
Ok(())
}
}
pub(super) fn invalid_field(field: &'static str, value: impl ToString, reason: impl Into<String>) -> KinoviWebError {
KinoviWebClientError::InvalidRequestField {
field,
raw_value: value.to_string(),
reason: reason.into(),
}
.into()
}
@@ -0,0 +1,10 @@
{
"model": "wan3.0-ref-to-video",
"contentMode": "normal",
"faceBlurMode": "off",
"prompt": "A corgi eats a watermelon",
"mode": "reference",
"aspectRatio": "16:9",
"duration": "5s",
"outputResolution": "720p"
}
@@ -0,0 +1,9 @@
{
"model": "wan3.0-text-to-video",
"contentMode": "normal",
"faceBlurMode": "off",
"prompt": "A shiba eats a pie",
"aspectRatio": "16:9",
"duration": "5s",
"outputResolution": "720p"
}
@@ -0,0 +1,13 @@
{
"model": "wan3.0-prime-image-to-video",
"contentMode": "normal",
"faceBlurMode": "off",
"prompt": "A lighthouse over the coast",
"mode": "keyframe",
"aspectRatio": "16:9",
"duration": "5s",
"outputResolution": "720p",
"uploadedUrls": [
"https://static.seedance2-pro.com/materials/20260918/1789739927378-438b78db.png"
]
}
@@ -0,0 +1,21 @@
{
"model": "wan3.0-ref-to-video",
"contentMode": "normal",
"faceBlurMode": "off",
"prompt": "The Image 2 is on the beach Video 1 . There's a lighthouse Image 1 . The shiba says Audio 1",
"mode": "reference",
"aspectRatio": "4:3",
"duration": "5s",
"outputResolution": "1080p",
"videoUrl": "https://static.seedance2-pro.com/materials/20260918/1789740067508-9b952e67.mp4",
"videoUrls": [
"https://static.seedance2-pro.com/materials/20260918/1789740067508-9b952e67.mp4"
],
"audioUrls": [
"https://static.seedance2-pro.com/materials/20260918/1789740228370-3f5d9623.wav"
],
"uploadedUrls": [
"https://static.seedance2-pro.com/materials/20260918/1789740059548-4149c254.png",
"https://static.seedance2-pro.com/materials/20260918/1789740077221-9702e67d.png"
]
}
@@ -0,0 +1,14 @@
{
"model": "wan3.0-prime-ref-to-video",
"contentMode": "normal",
"faceBlurMode": "off",
"prompt": "There's a shop full of lanterns. the camera orbits.",
"mode": "reference",
"aspectRatio": "16:9",
"duration": "10s",
"outputResolution": "480p",
"seed": 2028741236,
"uploadedUrls": [
"https://static.seedance2-pro.com/materials/20260918/1789740399001-2bb62f21.jpg"
]
}
@@ -0,0 +1,9 @@
{
"model": "wan3.0-prime-text-to-video",
"contentMode": "normal",
"faceBlurMode": "off",
"prompt": "A bowl of soup is knocked over by a knight",
"aspectRatio": "16:9",
"duration": "5s",
"outputResolution": "720p"
}
@@ -0,0 +1,15 @@
{
"model": "wan3.0-prime-image-to-video",
"contentMode": "normal",
"faceBlurMode": "off",
"prompt": "The lanterns blow in the breeze above the water",
"mode": "keyframe",
"aspectRatio": "16:9",
"duration": "4s",
"outputResolution": "1080p",
"seed": 1878107760,
"uploadedUrls": [
"https://static.seedance2-pro.com/materials/20260918/1789740716787-bf6a26f8.jpg",
"https://static.seedance2-pro.com/materials/20260918/1789740722438-0101d158.jpg"
]
}
@@ -0,0 +1,523 @@
use std::time::Duration;
use serde_json::{json, Value};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
use tokio::task::JoinHandle;
use tokio::time::timeout;
use super::generate_wan_3p0_image_to_video::*;
use super::generate_wan_3p0_prime_image_to_video::*;
use super::generate_wan_3p0_prime_ref_to_video::*;
use super::generate_wan_3p0_prime_text_to_video::*;
use super::generate_wan_3p0_ref_to_video::*;
use super::generate_wan_3p0_text_to_video::*;
use super::wan_3p0::{KinoviWan3p0AspectRatio, KinoviWan3p0OutputResolution, Wan3p0ApiParams};
use crate::creds::kinovi_web_session::KinoviWebSession;
use crate::error::kinovi_web_client_error::KinoviWebClientError;
use crate::error::kinovi_web_error::KinoviWebError;
use crate::pricing::kinovi_cost_calculator_trait::KinoviCostCalculatorTrait;
use crate::pricing::kinovi_pricing_tier::KinoviPricingTier;
use crate::requests::kinovi_host::KinoviHost;
const RESOLUTIONS: [KinoviWan3p0OutputResolution; 3] = [
KinoviWan3p0OutputResolution::FourEightyP,
KinoviWan3p0OutputResolution::SevenTwentyP,
KinoviWan3p0OutputResolution::TenEightyP,
];
// Literal price snapshots from the supplied observations and Kinovi's model
// pages, checked 2026-09-18. Keep independent of the calculator's rate tables.
// Columns are 480p, 720p, 1080p; all three modalities have the same rates.
// WAN_CREDITS and WAN_CENTS intentionally assert promotional prices until a
// reviewed update after 2026-09-23. See costs_wan_3p0.md at the crate root for
// advertised replacement rates and all expectations that need to change.
const WAN_CREDITS: [(u8, [f64; 3]); 9] = [
(2, [19.96, 39.92, 79.82]),
(5, [49.9, 99.8, 199.55]),
(8, [79.84, 159.68, 319.28]),
(10, [99.8, 199.6, 399.1]),
(15, [149.7, 299.4, 598.65]),
(20, [199.6, 399.2, 798.2]),
(22, [219.56, 439.12, 878.02]),
(25, [249.5, 499.0, 997.75]),
(30, [299.4, 598.8, 1197.3]),
];
const PRIME_CREDITS: [(u8, [f64; 3]); 9] = [
(2, [37.72, 77.66, 155.32]),
(5, [94.3, 194.15, 388.3]),
(8, [150.88, 310.64, 621.28]),
(10, [188.6, 388.3, 776.6]),
(15, [282.9, 582.45, 1164.9]),
(20, [377.2, 776.6, 1553.2]),
(22, [414.92, 854.26, 1708.52]),
(25, [471.5, 970.75, 1941.5]),
// The supplied Prime reference 480p/30s figure said 568.8; docs say 565.8.
(30, [565.8, 1164.9, 2329.8]),
];
// Thirty-second USD cents, (rounded down, rounded up), consumer then enterprise.
const WAN_CENTS: [[(u64, u64); 3]; 2] = [[(155, 156), (310, 311), (620, 621)], [(123, 124), (246, 247), (492, 493)]];
const PRIME_CENTS: [[(u64, u64); 3]; 2] =
[[(293, 294), (603, 604), (1207, 1208)], [(232, 233), (479, 480), (958, 959)]];
// Only sanitized apiParams are included; the original captures contain cookies.
const CAPTURES: [&str; 7] = [
include_str!("wan_3p0_test_data/01_wan_ref_to_video_without_ref.json"),
include_str!("wan_3p0_test_data/02_wan_text_to_video.json"),
include_str!("wan_3p0_test_data/03_wan_image_to_video.json"),
include_str!("wan_3p0_test_data/04_wan_ref_to_video_full.json"),
include_str!("wan_3p0_test_data/05_wan_prime_ref_to_video.json"),
include_str!("wan_3p0_test_data/06_wan_prime_text_to_video.json"),
include_str!("wan_3p0_test_data/07_wan_prime_image_to_video.json"),
];
macro_rules! operation_tests {
($module:ident, $request:ident, $args:ident, $generate:ident, $model:literal,
$credits:ident, $cents:ident, {$($field:ident: $value:expr),* $(,)?}) => {
mod $module {
use super::*;
#[test]
fn literal_credit_prices_at_every_resolution_and_observed_duration() {
for tier in [KinoviPricingTier::Consumer, KinoviPricingTier::Enterprise] {
for (duration, expected) in $credits {
for (resolution, credits) in RESOLUTIONS.into_iter().zip(expected) {
// Cost estimates work before media has been uploaded.
let request = $request {
duration_seconds: duration,
maybe_output_resolution: Some(resolution),
..Default::default()
};
assert_eq!(request.calculate_costs(tier).kinovi_credits, credits,
"{tier:?}, {resolution:?}, {duration}s");
}
}
}
}
#[test]
fn literal_usd_cent_prices_for_both_tiers() {
for (tier, expected) in [KinoviPricingTier::Consumer, KinoviPricingTier::Enterprise]
.into_iter().zip($cents)
{
for (resolution, (down, up)) in RESOLUTIONS.into_iter().zip(expected) {
let request = $request {
duration_seconds: 30,
maybe_output_resolution: Some(resolution),
..Default::default()
};
let cost = request.calculate_costs(tier);
assert_eq!((cost.usd_cents_rounded_down, cost.usd_cents_rounded_up), (down, up));
}
}
}
#[test]
fn defaults_match_the_priced_resolution() {
let request = valid_request();
assert_eq!(request.calculate_consumer_costs().kinovi_credits, $credits[1].1[1]);
let params = serde_json::to_value(request.build_api_params().unwrap()).unwrap();
assert_eq!(params["model"], $model);
assert_eq!(params["duration"], "5s");
assert_eq!(params["outputResolution"], "720p");
assert_eq!(params["aspectRatio"], "16:9");
assert!(params.get("seed").is_none());
assert!(params.get("audio").is_none());
}
#[test]
fn all_whole_second_durations_and_aspect_ratios_serialize() {
for duration in 2..=30 {
let mut request = valid_request();
request.duration_seconds = duration;
let params = serde_json::to_value(request.build_api_params().unwrap()).unwrap();
assert_eq!(params["duration"], format!("{duration}s"));
}
for (maybe_aspect_ratio, expected) in [
(KinoviWan3p0AspectRatio::Landscape16x9, "16:9"),
(KinoviWan3p0AspectRatio::Portrait9x16, "9:16"),
(KinoviWan3p0AspectRatio::Landscape4x3, "4:3"),
(KinoviWan3p0AspectRatio::Portrait3x4, "3:4"),
(KinoviWan3p0AspectRatio::Square1x1, "1:1"),
] {
let mut request = valid_request();
request.maybe_aspect_ratio = Some(maybe_aspect_ratio);
let params = serde_json::to_value(request.build_api_params().unwrap()).unwrap();
assert_eq!(params["aspectRatio"], expected);
}
for (resolution, expected) in RESOLUTIONS.into_iter().zip(["480p", "720p", "1080p"]) {
let mut request = valid_request();
request.maybe_output_resolution = Some(resolution);
let params = serde_json::to_value(request.build_api_params().unwrap()).unwrap();
assert_eq!(params["outputResolution"], expected);
}
}
#[test]
fn seed_and_audio_flags_preserve_values_without_changing_price() {
for (seed, audio) in [(0, false), (2_147_483_647, true)] {
let mut request = valid_request();
request.maybe_seed = Some(seed);
request.maybe_generate_audio = Some(audio);
assert_eq!(request.calculate_consumer_costs().kinovi_credits, $credits[1].1[1]);
let params = serde_json::to_value(request.build_api_params().unwrap()).unwrap();
assert_eq!(params["seed"], seed);
assert_eq!(params["audio"], audio);
}
}
#[test]
fn invalid_duration_seed_and_prompt_are_rejected() {
for duration in [0, 1, 31, 255] {
let mut request = valid_request();
request.duration_seconds = duration;
assert_invalid(request.build_api_params(), "duration_seconds");
}
for seed in [2_147_483_648, u32::MAX] {
let mut request = valid_request();
request.maybe_seed = Some(seed);
assert_invalid(request.build_api_params(), "maybe_seed");
}
let mut request = valid_request();
request.prompt = "猫".repeat(20_000);
assert!(request.clone().build_api_params().is_ok());
request.prompt.push('猫');
assert_invalid(request.build_api_params(), "prompt");
}
#[tokio::test]
async fn public_generate_sends_workflow_request_and_returns_task_ids() {
let (host, server) = stub_server().await;
let session = KinoviWebSession::from_cookies_string("test-session=local".to_owned());
let request = valid_request();
let params = serde_json::to_value(request.clone().build_api_params().unwrap()).unwrap();
let result = timeout(Duration::from_secs(10), $generate($args {
request,
session: &session,
maybe_host_override: Some(host),
})).await.unwrap().unwrap();
assert_eq!(result.task_id, "wan-task");
assert_eq!(result.order_id, "wan-order");
assert_eq!(result.task_ids, Some(vec!["wan-task".to_owned()]));
assert_eq!(result.order_ids, Some(vec!["wan-order".to_owned()]));
let (headers, body) = timeout(Duration::from_secs(10), server).await.unwrap().unwrap();
assert!(headers.starts_with("POST /api/trpc/workflow.runTask?batch=1 HTTP/1.1\r\n"));
assert_eq!(body, json!({"0": {"json": {
"businessType": "wan3-video-generation", "apiParams": params,
}}}));
}
fn valid_request() -> $request {
$request {
prompt: "A lighthouse on the coast".to_owned(),
$($field: $value,)*
..Default::default()
}
}
}
};
}
operation_tests!(
text,
GenerateWan3p0TextToVideoRequest,
GenerateWan3p0TextToVideoArgs,
generate_wan_3p0_text_to_video,
"wan3.0-text-to-video",
WAN_CREDITS,
WAN_CENTS,
{}
);
operation_tests!(image, GenerateWan3p0ImageToVideoRequest, GenerateWan3p0ImageToVideoArgs,
generate_wan_3p0_image_to_video, "wan3.0-image-to-video", WAN_CREDITS, WAN_CENTS,
{ maybe_start_frame_url: Some("https://example.com/start.png".to_owned()) });
operation_tests!(
reference,
GenerateWan3p0RefToVideoRequest,
GenerateWan3p0RefToVideoArgs,
generate_wan_3p0_ref_to_video,
"wan3.0-ref-to-video",
WAN_CREDITS,
WAN_CENTS,
{}
);
operation_tests!(
prime_text,
GenerateWan3p0PrimeTextToVideoRequest,
GenerateWan3p0PrimeTextToVideoArgs,
generate_wan_3p0_prime_text_to_video,
"wan3.0-prime-text-to-video",
PRIME_CREDITS,
PRIME_CENTS,
{}
);
operation_tests!(prime_image, GenerateWan3p0PrimeImageToVideoRequest, GenerateWan3p0PrimeImageToVideoArgs,
generate_wan_3p0_prime_image_to_video, "wan3.0-prime-image-to-video", PRIME_CREDITS, PRIME_CENTS,
{ maybe_start_frame_url: Some("https://example.com/start.png".to_owned()) });
operation_tests!(
prime_reference,
GenerateWan3p0PrimeRefToVideoRequest,
GenerateWan3p0PrimeRefToVideoArgs,
generate_wan_3p0_prime_ref_to_video,
"wan3.0-prime-ref-to-video",
PRIME_CREDITS,
PRIME_CENTS,
{}
);
#[test]
fn payloads_match_all_seven_consumer_captures() {
for fixture in CAPTURES {
let expected: Value = serde_json::from_str(fixture).unwrap();
assert_eq!(params_from_capture(&expected), expected);
}
// Capture 03's filename says Wan image, but its actual model is Prime.
// Exercise regular image too, using the same documented keyframe shape.
let mut regular_image: Value = serde_json::from_str(CAPTURES[2]).unwrap();
regular_image["model"] = json!("wan3.0-image-to-video");
assert_eq!(params_from_capture(&regular_image), regular_image);
}
macro_rules! reference_media_tests {
($module:ident, $request:ident, $credits:literal) => {
mod $module {
use super::*;
#[test]
fn references_are_optional_and_empty_lists_are_omitted() {
let request = $request {
prompt: "A ship at sea".to_owned(),
duration_seconds: 30,
maybe_reference_image_urls: Some(vec![]),
maybe_reference_video_urls: Some(vec![]),
maybe_reference_audio_urls: Some(vec![]),
..Default::default()
};
let params = serde_json::to_value(request.build_api_params().unwrap()).unwrap();
for field in ["uploadedUrls", "videoUrl", "videoUrls", "audioUrls"] {
assert!(params.get(field).is_none(), "{field}");
}
assert_invalid($request::default().build_api_params(), "prompt");
}
#[test]
fn reference_duration_and_output_share_thirty_seconds() {
let request = $request {
duration_seconds: 20,
maybe_reference_video_urls: Some(urls(1)),
maybe_total_reference_video_duration_millis: Some(10_000),
..Default::default()
};
assert!(request.clone().build_api_params().is_ok());
for millis in [0, 999, 10_001, 15_001, u32::MAX] {
let mut invalid = request.clone();
invalid.maybe_total_reference_video_duration_millis = Some(millis);
assert!(invalid.build_api_params().is_err(), "{millis}ms");
}
let mut request = request;
request.duration_seconds = 15;
request.maybe_total_reference_video_duration_millis = Some(15_000);
assert!(request.clone().build_api_params().is_ok());
request.duration_seconds = 16;
assert_invalid(request.clone().build_api_params(), "duration_seconds");
request.maybe_total_reference_video_duration_millis = None;
request.duration_seconds = 29;
assert!(request.clone().build_api_params().is_ok());
request.duration_seconds = 30;
assert_invalid(request.build_api_params(), "duration_seconds");
}
#[test]
fn all_reference_types_have_independent_limits_and_no_surcharge() {
let request = $request {
duration_seconds: 10,
maybe_reference_image_urls: Some(urls(10)),
maybe_reference_video_urls: Some(urls(5)),
maybe_reference_audio_urls: Some(urls(5)),
maybe_total_reference_video_duration_millis: Some(15_000),
..Default::default()
};
assert_eq!(request.calculate_consumer_costs().kinovi_credits, $credits);
let params = serde_json::to_value(request.clone().build_api_params().unwrap()).unwrap();
assert_eq!(params["uploadedUrls"], json!(urls(10)));
assert_eq!(params["videoUrls"], json!(urls(5)));
assert_eq!(params["videoUrl"], urls(1)[0]);
assert_eq!(params["audioUrls"], json!(urls(5)));
assert!(params.get("maybeTotalReferenceVideoDurationMillis").is_none());
assert!(params.get("imageUrls").is_none());
let mut invalid = request.clone();
invalid.maybe_reference_image_urls = Some(urls(11));
assert_invalid(invalid.build_api_params(), "maybe_reference_image_urls");
let mut invalid = request.clone();
invalid.maybe_reference_video_urls = Some(urls(6));
assert_invalid(invalid.build_api_params(), "maybe_reference_video_urls");
let mut invalid = request.clone();
invalid.maybe_reference_audio_urls = Some(urls(6));
assert_invalid(invalid.build_api_params(), "maybe_reference_audio_urls");
let mut invalid = request.clone();
invalid.maybe_total_reference_video_duration_millis = Some(4_999);
assert_invalid(invalid.build_api_params(), "maybe_total_reference_video_duration_millis");
let mut invalid = request;
invalid.maybe_reference_audio_urls = Some(vec![" ".to_owned()]);
assert_invalid(invalid.build_api_params(), "media_url");
}
}
};
}
reference_media_tests!(reference_media, GenerateWan3p0RefToVideoRequest, 199.6);
reference_media_tests!(prime_reference_media, GenerateWan3p0PrimeRefToVideoRequest, 388.3);
#[test]
fn keyframes_require_a_start_image_and_preserve_start_end_order() {
macro_rules! check {
($request:ident, $credits:literal) => {
let mut request = $request {
prompt: "Waves".to_owned(),
..Default::default()
};
assert_invalid(request.clone().build_api_params(), "maybe_start_frame_url");
request.maybe_end_frame_url = Some("https://example.com/end.png".to_owned());
assert_invalid(request.clone().build_api_params(), "maybe_end_frame_url");
request.maybe_start_frame_url = Some("https://example.com/start.png".to_owned());
request.prompt.clear();
assert_eq!(request.calculate_consumer_costs().kinovi_credits, $credits);
let params = serde_json::to_value(request.build_api_params().unwrap()).unwrap();
assert_eq!(params["uploadedUrls"], json!(["https://example.com/start.png", "https://example.com/end.png",]));
assert!(params.get("imageUrls").is_none());
};
}
check!(GenerateWan3p0ImageToVideoRequest, 99.8);
check!(GenerateWan3p0PrimeImageToVideoRequest, 194.15);
}
#[test]
fn text_operations_require_a_prompt() {
assert_invalid(GenerateWan3p0TextToVideoRequest::default().build_api_params(), "prompt");
assert_invalid(GenerateWan3p0PrimeTextToVideoRequest::default().build_api_params(), "prompt");
}
fn params_from_capture(value: &Value) -> Value {
macro_rules! build {
($request:ident, {$($field:ident: $extra:expr),* $(,)?}) => {
$request {
prompt: value["prompt"].as_str().unwrap().to_owned(),
maybe_aspect_ratio: Some(match value["aspectRatio"].as_str().unwrap() {
"16:9" => KinoviWan3p0AspectRatio::Landscape16x9,
"4:3" => KinoviWan3p0AspectRatio::Landscape4x3,
other => panic!("unexpected fixture aspect ratio: {other}"),
}),
maybe_output_resolution: Some(match value["outputResolution"].as_str().unwrap() {
"480p" => KinoviWan3p0OutputResolution::FourEightyP,
"720p" => KinoviWan3p0OutputResolution::SevenTwentyP,
"1080p" => KinoviWan3p0OutputResolution::TenEightyP,
other => panic!("unexpected fixture resolution: {other}"),
}),
duration_seconds: value["duration"].as_str().unwrap().trim_end_matches('s').parse().unwrap(),
maybe_seed: value["seed"].as_u64().map(|seed| seed as u32),
$($field: $extra,)*
..Default::default()
}.build_api_params().unwrap()
};
}
macro_rules! image {
($request:ident) => {
build!($request, {
maybe_start_frame_url: value["uploadedUrls"][0].as_str().map(str::to_owned),
maybe_end_frame_url: value["uploadedUrls"][1].as_str().map(str::to_owned),
})
};
}
macro_rules! reference {
($request:ident) => {
build!($request, {
maybe_reference_image_urls: capture_urls(value, "uploadedUrls"),
maybe_reference_video_urls: capture_urls(value, "videoUrls"),
maybe_reference_audio_urls: capture_urls(value, "audioUrls"),
})
};
}
let params = match value["model"].as_str().unwrap() {
"wan3.0-text-to-video" => build!(GenerateWan3p0TextToVideoRequest, {}),
"wan3.0-image-to-video" => image!(GenerateWan3p0ImageToVideoRequest),
"wan3.0-ref-to-video" => reference!(GenerateWan3p0RefToVideoRequest),
"wan3.0-prime-text-to-video" => build!(GenerateWan3p0PrimeTextToVideoRequest, {}),
"wan3.0-prime-image-to-video" => image!(GenerateWan3p0PrimeImageToVideoRequest),
"wan3.0-prime-ref-to-video" => reference!(GenerateWan3p0PrimeRefToVideoRequest),
other => panic!("unexpected fixture model: {other}"),
};
serde_json::to_value(params).unwrap()
}
async fn stub_server() -> (KinoviHost, JoinHandle<(String, Value)>) {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = format!("http://{}", listener.local_addr().unwrap());
let server = tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
let mut bytes = Vec::new();
let (headers, body_start, body_len) = loop {
let mut chunk = [0; 4096];
let length = stream.read(&mut chunk).await.unwrap();
assert_ne!(length, 0, "request ended before headers");
bytes.extend_from_slice(&chunk[..length]);
if let Some(end) = bytes.windows(4).position(|window| window == b"\r\n\r\n") {
let headers = String::from_utf8(bytes[..end + 4].to_vec()).unwrap();
let body_len: usize = headers
.lines()
.find_map(|line| {
let (name, value) = line.split_once(':')?;
name.eq_ignore_ascii_case("content-length").then(|| value.trim().parse().unwrap())
})
.unwrap();
break (headers, end + 4, body_len);
}
};
while bytes.len() < body_start + body_len {
let mut chunk = [0; 4096];
let length = stream.read(&mut chunk).await.unwrap();
assert_ne!(length, 0, "request ended before body");
bytes.extend_from_slice(&chunk[..length]);
}
let body = serde_json::from_slice(&bytes[body_start..body_start + body_len]).unwrap();
let response = json!([{"result": {"data": {"json": {
"taskId": "wan-task", "orderId": "wan-order",
"taskIds": ["wan-task"], "orderIds": ["wan-order"], "violationWarning": false,
}}}}])
.to_string();
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
response.len(),
response,
);
stream.write_all(response.as_bytes()).await.unwrap();
(headers, body)
});
(
KinoviHost::CustomHost {
api_host: address.clone(),
cdn_host: address,
},
server,
)
}
fn capture_urls(value: &Value, field: &str) -> Option<Vec<String>> {
value[field]
.as_array()
.map(|urls| urls.iter().map(|url| url.as_str().unwrap().to_owned()).collect())
}
fn urls(count: usize) -> Vec<String> {
(0..count).map(|index| format!("https://example.com/reference-{index}")).collect()
}
fn assert_invalid(result: Result<Wan3p0ApiParams, KinoviWebError>, field: &str) {
match result.unwrap_err() {
KinoviWebError::Client(KinoviWebClientError::InvalidRequestField {
field: actual,
..
}) => {
assert_eq!(actual, field);
},
other => panic!("expected invalid {field}, got {other:?}"),
}
}
@@ -816,6 +816,30 @@ mod tests {
assert!(json.contains(r#""batchCount":8"#), "{json}");
}
#[test]
fn batch_count_serializes_for_all_video_models() {
for model in [
KinoviModelTypeRaw::Seedance2Pro,
KinoviModelTypeRaw::Seedance2Fast,
KinoviModelTypeRaw::Seedance2Mini,
KinoviModelTypeRaw::Seedance2p5,
KinoviModelTypeRaw::Seedance2p5Preview,
KinoviModelTypeRaw::HappyHorse1p0,
] {
let batches = match model {
KinoviModelTypeRaw::HappyHorse1p0 => [(KinoviBatchCountRaw::One, None), (KinoviBatchCountRaw::Three, Some(3))],
_ => [(KinoviBatchCountRaw::One, None), (KinoviBatchCountRaw::Eight, Some(8))],
};
for (batch, expected) in batches {
let mut request = mini_request(KinoviAspectRatioRaw::Landscape16x9, None, batch);
request.model_type = model;
let body = serde_json::to_value(build_batch_request(request)).unwrap();
let params = &body["0"]["json"]["apiParams"];
assert_eq!(params.get("batchCount"), expected.map(serde_json::Value::from).as_ref(), "{model:?}");
}
}
}
#[test]
fn non_mini_still_uses_resolution_field() {
let mut req = mini_request(KinoviAspectRatioRaw::Landscape16x9, None, KinoviBatchCountRaw::One);
@@ -0,0 +1,34 @@
use crate::pricing::cost::kinovi_fractional_generation_cost::KinoviFractionalGenerationCost;
use crate::pricing::kinovi_pricing_tier::KinoviPricingTier;
const FLOAT_TOLERANCE: f64 = 1e-8;
/// Check scaling of credits and fractional cents, then independently compute
/// whole cents from the complete batch using integer arithmetic. Multiplying
/// a single video's already-rounded cents can overcharge a batch.
pub(crate) fn assert_batch_cost(
single: KinoviFractionalGenerationCost,
batched: KinoviFractionalGenerationCost,
count: u64,
tier: KinoviPricingTier,
context: &str,
) {
let expected_credit_hundredths = (single.kinovi_credits * 100.0).round() as u64 * count;
let expected_credits = expected_credit_hundredths as f64 / 100.0;
let expected_fractional_cents = single.usd_cents_fractional * count as f64;
assert_eq!(batched.kinovi_credits, expected_credits, "{context}: credits");
assert!(
(batched.usd_cents_fractional - expected_fractional_cents).abs() < FLOAT_TOLERANCE,
"{context}: fractional cents: {} != {expected_fractional_cents}",
batched.usd_cents_fractional,
);
// Credit purchase rates expressed in hundredths of a credit per dollar.
let credits_per_dollar_hundredths = match tier {
KinoviPricingTier::Consumer => 19_298,
KinoviPricingTier::Enterprise => 24_316,
};
let numerator = expected_credit_hundredths * 100;
assert_eq!(batched.usd_cents_rounded_up, numerator.div_ceil(credits_per_dollar_hundredths), "{context}: rounded up");
assert_eq!(batched.usd_cents_rounded_down, numerator / credits_per_dollar_hundredths, "{context}: rounded down");
}
@@ -1,3 +1,6 @@
#[cfg(test)]
pub (crate) mod assert_batch_cost;
#[cfg(test)]
pub (crate) mod get_test_cookies;
@@ -27,7 +27,7 @@ pub fn happy_horse_video_models() -> Vec<OmniGenVideoModelDetails> {
CommonResolution::TenEightyP,
]),
resolution_default: Some(CommonResolution::SevenTwentyP),
batch_size_options: Some(vec![1, 2, 4]),
batch_size_options: Some(vec![1, 2, 3, 4]),
batch_size_default: Some(1),
duration_seconds_min: Some(3),
duration_seconds_max: Some(15),
@@ -51,7 +51,7 @@ pub fn seedance_2p5_video_models() -> Vec<OmniGenVideoModelDetails> {
bitrate_default: Some(CommonBitrate::Normal),
output_format_options: Some(vec![CommonVideoOutputFormat::Mp4, CommonVideoOutputFormat::Mov]),
output_format_default: Some(CommonVideoOutputFormat::Mp4),
batch_size_options: Some(vec![1]),
batch_size_options: Some(vec![1, 2, 3, 4]),
batch_size_default: Some(1),
duration_seconds_min: Some(4),
duration_seconds_max: Some(30),
@@ -103,7 +103,7 @@ pub fn seedance_2p5_video_models() -> Vec<OmniGenVideoModelDetails> {
CommonVideoOutputFormat::Mov
]),
output_format_default: Some(CommonVideoOutputFormat::Mp4),
batch_size_options: Some(vec![1]),
batch_size_options: Some(vec![1, 2, 3, 4]),
batch_size_default: Some(1),
duration_seconds_min: Some(4),
duration_seconds_max: Some(30),
@@ -156,3 +156,31 @@ fn build_omni_gen_video_model_providers() -> Vec<OmniGenVideoModelProviderDetail
providers
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn kinovi_batch_options_are_one_through_four() {
let models = build_omni_gen_video_models();
for model in [
CommonVideoModel::Seedance2p5,
CommonVideoModel::Seedance2p0,
CommonVideoModel::Seedance2p0Fast,
CommonVideoModel::Seedance2p0Mini,
CommonVideoModel::Seedance2p5Ultra,
CommonVideoModel::Seedance2p0BytePlus,
CommonVideoModel::Seedance2p0BytePlusFast,
CommonVideoModel::Seedance2p0BytePlusMini,
CommonVideoModel::Seedance2p0BytePlusUltra,
CommonVideoModel::Seedance2p0BytePlusUltraFast,
CommonVideoModel::Seedance2p0BytePlusUltraMini,
CommonVideoModel::HappyHorse1p0,
] {
let details = models.iter().find(|details| details.model == model).unwrap();
assert_eq!(details.batch_size_options.as_deref(), Some([1, 2, 3, 4].as_slice()), "{model:?}");
assert_eq!(details.batch_size_default, Some(1), "{model:?}");
}
}
}
@@ -137,3 +137,58 @@ fn to_omni_gen_request(request: OmniApiVideoGenerateRequest) -> OmniGenVideoCost
estimate_only: None,
}
}
#[cfg(test)]
mod tests {
use artcraft_router::api::router_provider::RouterProvider;
use enums::common::generation::common_video_model::CommonVideoModel;
use serde_json::json;
use crate::http_server::endpoints::omni_gen::generate::video::helpers::hydrate_router_request::hydrate_to_router_request;
use crate::http_server::endpoints::omni_gen::shared_utils::video::validate_video_request::validate_video_request as validate_omni_gen;
use super::*;
#[test]
fn kinovi_batches_reach_shared_generation_with_the_full_retail_price() {
// Both handlers use this hydration and ArtCraft pricing path. The API
// handler additionally converts its request into the omni_gen shape.
for (model, expected_prices) in [
(CommonVideoModel::Seedance2p5, [134, 268, 401, 535]),
(CommonVideoModel::Seedance2p0, [93, 185, 278, 370]),
(CommonVideoModel::Seedance2p0Fast, [64, 127, 191, 255]),
(CommonVideoModel::Seedance2p0Mini, [45, 89, 134, 178]),
(CommonVideoModel::Seedance2p5Ultra, [158, 316, 474, 632]),
(CommonVideoModel::Seedance2p0BytePlus, [125, 250, 375, 500]),
(CommonVideoModel::Seedance2p0BytePlusFast, [100, 200, 300, 400]),
(CommonVideoModel::Seedance2p0BytePlusMini, [46, 91, 137, 182]),
(CommonVideoModel::Seedance2p0BytePlusUltra, [125, 250, 375, 500]),
(CommonVideoModel::Seedance2p0BytePlusUltraFast, [100, 200, 300, 400]),
(CommonVideoModel::Seedance2p0BytePlusUltraMini, [46, 91, 137, 182]),
(CommonVideoModel::HappyHorse1p0, [85, 171, 256, 342]),
] {
for (index, price) in expected_prices.iter().copied().enumerate() {
let batch = index as u16 + 1;
let body = json!({
"model": model,
"duration_seconds": 5,
"resolution": "seven_twenty_p",
"video_batch_count": batch,
});
let api_request: OmniApiVideoGenerateRequest = serde_json::from_value(body.clone()).unwrap();
validate_video_request(&api_request).unwrap();
let converted_request = to_omni_gen_request(api_request);
let gen_request: OmniGenVideoCostAndGenerateRequest = serde_json::from_value(body).unwrap();
for request in [gen_request, converted_request] {
validate_omni_gen(&request).unwrap();
let builder = hydrate_to_router_request(&request).unwrap();
assert_eq!(builder.video_batch_count, Some(batch), "{model:?}");
assert!(matches!(builder.provider, RouterProvider::Artcraft));
let cost = builder.build2().unwrap().estimate_cost().unwrap();
assert_eq!(cost.cost_in_credits, Some(price), "{model:?}, batch {batch}");
assert_eq!(cost.cost_in_usd_cents, Some(price), "{model:?}, batch {batch}");
}
}
}
}
}
@@ -1,4 +1,5 @@
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::endpoints::omni_gen::shared_utils::video::validate_kinovi_batch_count::validate_kinovi_batch_count;
use crate::http_server::endpoints::omni_api::shared_utils::video::validate_seedance_2p0_limits::validate_seedance_2p0_limits;
use crate::http_server::endpoints::omni_api::shared_utils::video::validate_when_image_required::validate_when_image_required;
use artcraft_api_defs::omni_api::generate_requests::omni_api_video_generate_request::OmniApiVideoGenerateRequest;
@@ -7,6 +8,7 @@ use artcraft_api_defs::omni_api::generate_requests::omni_api_video_generate_requ
pub fn validate_video_request(
request: &OmniApiVideoGenerateRequest,
) -> Result<(), CommonWebError> {
validate_kinovi_batch_count(request.model, request.video_batch_count)?;
validate_when_image_required(request)?;
validate_seedance_2p0_limits(request)?;
Ok(())
@@ -75,7 +75,10 @@ pub fn hydrate_to_router_request(
},
duration_seconds: request.duration_seconds,
video_batch_count: request.video_batch_count,
generate_audio: request.generate_audio,
// Seedance 2.0 / 2.5 always generate sound. Kinovi honours an explicit
// `generate_audio: false`, and the frontend was sending one, so the flag
// is dropped here and never reaches Kinovi for these models.
generate_audio: if is_seedance_2x(&model) { None } else { request.generate_audio },
// Set by the handler after probing reference video durations (only
// models that bill input seconds use it).
total_reference_video_input_seconds: None,
@@ -84,6 +87,24 @@ pub fn hydrate_to_router_request(
})
}
fn is_seedance_2x(model: &RouterVideoModel) -> bool {
matches!(
model,
RouterVideoModel::Seedance2p0
| RouterVideoModel::Seedance2p0Fast
| RouterVideoModel::Seedance2p0Mini
| RouterVideoModel::Seedance2p0BytePlus
| RouterVideoModel::Seedance2p0BytePlusFast
| RouterVideoModel::Seedance2p0BytePlusMini
| RouterVideoModel::Seedance2p0BytePlusUltra
| RouterVideoModel::Seedance2p0BytePlusUltraFast
| RouterVideoModel::Seedance2p0BytePlusUltraMini
| RouterVideoModel::Seedance2p5Preview
| RouterVideoModel::Seedance2p5
| RouterVideoModel::Seedance2p5Ultra
)
}
fn convert_model(
model: &CommonVideoModelEnum,
) -> Result<RouterVideoModel, CommonWebError> {
@@ -144,7 +165,7 @@ mod tests {
use serde_json::{json, Value};
#[tokio::test]
async fn artcraft_http_request_reaches_kinovi_with_format_and_audio() {
async fn artcraft_http_request_reaches_kinovi_with_format_and_without_audio_flag() {
// No media inputs or send: finalization needs a client but makes no HTTP calls.
let client = RouterClient::KinoviWeb(RouterKinoviWebClient::new(
KinoviWebSession::from_cookies_string(String::new()),
@@ -183,7 +204,7 @@ mod tests {
});
let expected_format = output_format.as_ref().and_then(Value::as_str).unwrap_or("mp4");
assert_eq!(actual_format, Some(expected_format), "model: {model}");
assert_eq!(request.maybe_generate_audio, generate_audio);
assert_eq!(request.maybe_generate_audio, None, "generate_audio: {generate_audio:?}");
}
}
}
@@ -210,6 +231,44 @@ mod tests {
}
}
mod generate_audio {
use super::*;
#[test]
fn seedance_2x_drops_generate_audio() {
for model in [
CommonVideoModelEnum::Seedance2p0,
CommonVideoModelEnum::Seedance2p0Fast,
CommonVideoModelEnum::Seedance2p0Mini,
CommonVideoModelEnum::Seedance2p5,
CommonVideoModelEnum::Seedance2p5Ultra,
] {
for generate_audio in [None, Some(true), Some(false)] {
let request = OmniGenVideoCostAndGenerateRequest {
model: Some(model),
generate_audio,
..base_request()
};
let builder = hydrate_to_router_request(&request).expect("hydrate should succeed");
assert_eq!(builder.generate_audio, None, "model: {model:?}, generate_audio: {generate_audio:?}");
}
}
}
#[test]
fn other_models_keep_generate_audio() {
for generate_audio in [None, Some(true), Some(false)] {
let request = OmniGenVideoCostAndGenerateRequest {
model: Some(CommonVideoModelEnum::Veo3p1),
generate_audio,
..base_request()
};
let builder = hydrate_to_router_request(&request).expect("hydrate should succeed");
assert_eq!(builder.generate_audio, generate_audio);
}
}
}
mod bitrate_hydration {
use super::*;
@@ -12,8 +12,8 @@ use enums::common::generation::common_video_model::CommonVideoModel;
use artcraft_api_keys::ArtcraftApiKey;
use crate::http_server::endpoints::omni_gen::generate::video::tests::support::{
base_generate_request, to_omni_api_request, ExpectedCredits, Seconds, TestHarness,
STARTING_CREDITS,
base_generate_request, stub_traffic, to_omni_api_request, ExpectedCredits, Seconds,
TestHarness, STARTING_CREDITS,
};
mod billing_parity {
@@ -46,6 +46,26 @@ mod billing_parity {
}
}
mod generate_audio {
use super::*;
/// Seedance 2.0 / 2.5 always produce sound. Even when a caller explicitly
/// sends `generate_audio: false`, neither endpoint forwards the flag to
/// Kinovi — the key must be absent from the captured request body so Kinovi
/// falls back to its default of sound on.
#[tokio::test]
#[cfg_attr(feature = "skip_database_tests", ignore)]
async fn seedance_2p0_never_forwards_generate_audio_on_either_endpoint() {
assert_endpoints_omit_generate_audio(CommonVideoModel::Seedance2p0).await;
}
#[tokio::test]
#[cfg_attr(feature = "skip_database_tests", ignore)]
async fn seedance_2p5_never_forwards_generate_audio_on_either_endpoint() {
assert_endpoints_omit_generate_audio(CommonVideoModel::Seedance2p5).await;
}
}
mod url_ingestion {
use super::*;
@@ -195,3 +215,47 @@ async fn assert_endpoints_bill_identically(
"{:?}: omni_api debited the wrong amount", model,
);
}
/// Run `model` with an explicit `generate_audio: false` through omni_gen
/// (session cookie) and omni_api (API key); assert both succeed and neither
/// captured Kinovi request carries a `generate_audio` key.
async fn assert_endpoints_omit_generate_audio(model: CommonVideoModel) {
let harness = TestHarness::create().await;
// omni_gen, session-cookie user.
let web_user = harness.create_funded_user(STARTING_CREDITS).await;
let mut request = base_generate_request(model);
let web_prompt = format!("audio flag test web {}", request.idempotency_token.as_deref().unwrap());
request.prompt = Some(web_prompt.clone());
request.generate_audio = Some(false);
let response = harness
.post_generate(&web_user, request)
.await
.unwrap_or_else(|err| panic!("{:?}: omni_gen generation failed: {:?}", model, err));
assert!(response.success);
// omni_api, API-key user.
let api_user = harness.create_funded_user(STARTING_CREDITS).await;
let api_key = harness.create_api_key(&api_user).await;
let mut request = base_generate_request(model);
let api_prompt = format!("audio flag test api {}", request.idempotency_token.as_deref().unwrap());
request.prompt = Some(api_prompt.clone());
request.generate_audio = Some(false);
let response = harness
.post_generate_via_api_key(&api_key, request)
.await
.unwrap_or_else(|err| panic!("{:?}: omni_api generation failed: {:?}", model, err));
assert!(response.success);
let traffic = stub_traffic().lock().unwrap();
for (endpoint, prompt) in [("omni_gen", &web_prompt), ("omni_api", &api_prompt)] {
let params = traffic.workflows.iter()
.map(|request| &request["0"]["json"]["apiParams"])
.find(|params| params["prompt"].as_str() == Some(prompt))
.unwrap_or_else(|| panic!("{:?}: no captured Kinovi request for {}", model, endpoint));
assert!(
params.get("generate_audio").is_none(),
"{:?}: {} forwarded generate_audio to Kinovi: {}", model, endpoint, params,
);
}
}
@@ -74,7 +74,8 @@ async fn quicktime_uploads_are_video_references_with_mp4_or_mov_output() {
.find(|params| params["prompt"].as_str() == Some(&prompt))
.expect("captured Kinovi request");
assert_eq!(params["output_format"], expected);
assert_eq!(params["generate_audio"], true);
// Seedance 2.x never forwards the audio flag; Kinovi defaults to sound on.
assert!(params.get("generate_audio").is_none(), "{}", params);
assert_eq!(params["mode"], "reference");
assert!(params["videoUrls"][0].as_str().is_some_and(|url| url.ends_with(".mov")), "{}", params);
assert_eq!(traffic.uploads.get("fixture.mov").unwrap(), &bytes);
@@ -88,8 +88,8 @@ mod seedance_2p0_fast {
}
}
/// Every batch size 1-10 at every resolution (5s).
/// Batches over the platform max of 4 downgrade to 4 (3 upgrades to 4); execution and billing agree.
/// Every supported batch size 1-4 at every resolution (5s).
/// Batches 1-4 are preserved; larger batches are rejected before billing.
/// 1080p and 4K downgrade to 720p (and price accordingly).
#[tokio::test]
#[cfg_attr(feature = "skip_database_tests", ignore)]
@@ -101,42 +101,18 @@ mod seedance_2p0_fast {
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(2), ExpectedCredits(71)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(3), ExpectedCredits(107)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(4), ExpectedCredits(143)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(5), ExpectedCredits(143)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(6), ExpectedCredits(143)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(7), ExpectedCredits(143)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(8), ExpectedCredits(143)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(9), ExpectedCredits(143)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(10), ExpectedCredits(143)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(1), ExpectedCredits(64)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(2), ExpectedCredits(127)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(3), ExpectedCredits(191)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(4), ExpectedCredits(255)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(5), ExpectedCredits(255)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(6), ExpectedCredits(255)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(7), ExpectedCredits(255)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(8), ExpectedCredits(255)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(9), ExpectedCredits(255)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(10), ExpectedCredits(255)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(1), ExpectedCredits(64)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(2), ExpectedCredits(127)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(3), ExpectedCredits(191)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(4), ExpectedCredits(255)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(5), ExpectedCredits(255)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(6), ExpectedCredits(255)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(7), ExpectedCredits(255)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(8), ExpectedCredits(255)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(9), ExpectedCredits(255)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(10), ExpectedCredits(255)),
(Some(CommonResolution::FourK), Seconds(5), Batch(1), ExpectedCredits(64)),
(Some(CommonResolution::FourK), Seconds(5), Batch(2), ExpectedCredits(127)),
(Some(CommonResolution::FourK), Seconds(5), Batch(3), ExpectedCredits(191)),
(Some(CommonResolution::FourK), Seconds(5), Batch(4), ExpectedCredits(255)),
(Some(CommonResolution::FourK), Seconds(5), Batch(5), ExpectedCredits(255)),
(Some(CommonResolution::FourK), Seconds(5), Batch(6), ExpectedCredits(255)),
(Some(CommonResolution::FourK), Seconds(5), Batch(7), ExpectedCredits(255)),
(Some(CommonResolution::FourK), Seconds(5), Batch(8), ExpectedCredits(255)),
(Some(CommonResolution::FourK), Seconds(5), Batch(9), ExpectedCredits(255)),
(Some(CommonResolution::FourK), Seconds(5), Batch(10), ExpectedCredits(255)),
];
for (resolution, seconds, batch, expected) in cases {
@@ -156,27 +132,15 @@ mod seedance_2p0_fast {
(Some(CommonResolution::FourEightyP), Seconds(6), Batch(2), ExpectedCredits(86)),
(Some(CommonResolution::FourEightyP), Seconds(7), Batch(3), ExpectedCredits(150)),
(Some(CommonResolution::FourEightyP), Seconds(9), Batch(4), ExpectedCredits(257)),
(Some(CommonResolution::FourEightyP), Seconds(11), Batch(5), ExpectedCredits(314)),
(Some(CommonResolution::FourEightyP), Seconds(13), Batch(8), ExpectedCredits(371)),
(Some(CommonResolution::FourEightyP), Seconds(15), Batch(10), ExpectedCredits(428)),
(Some(CommonResolution::SevenTwentyP), Seconds(6), Batch(2), ExpectedCredits(153)),
(Some(CommonResolution::SevenTwentyP), Seconds(7), Batch(3), ExpectedCredits(267)),
(Some(CommonResolution::SevenTwentyP), Seconds(9), Batch(4), ExpectedCredits(458)),
(Some(CommonResolution::SevenTwentyP), Seconds(11), Batch(5), ExpectedCredits(560)),
(Some(CommonResolution::SevenTwentyP), Seconds(13), Batch(8), ExpectedCredits(662)),
(Some(CommonResolution::SevenTwentyP), Seconds(15), Batch(10), ExpectedCredits(764)),
(Some(CommonResolution::TenEightyP), Seconds(6), Batch(2), ExpectedCredits(153)),
(Some(CommonResolution::TenEightyP), Seconds(7), Batch(3), ExpectedCredits(267)),
(Some(CommonResolution::TenEightyP), Seconds(9), Batch(4), ExpectedCredits(458)),
(Some(CommonResolution::TenEightyP), Seconds(11), Batch(5), ExpectedCredits(560)),
(Some(CommonResolution::TenEightyP), Seconds(13), Batch(8), ExpectedCredits(662)),
(Some(CommonResolution::TenEightyP), Seconds(15), Batch(10), ExpectedCredits(764)),
(Some(CommonResolution::FourK), Seconds(6), Batch(2), ExpectedCredits(153)),
(Some(CommonResolution::FourK), Seconds(7), Batch(3), ExpectedCredits(267)),
(Some(CommonResolution::FourK), Seconds(9), Batch(4), ExpectedCredits(458)),
(Some(CommonResolution::FourK), Seconds(11), Batch(5), ExpectedCredits(560)),
(Some(CommonResolution::FourK), Seconds(13), Batch(8), ExpectedCredits(662)),
(Some(CommonResolution::FourK), Seconds(15), Batch(10), ExpectedCredits(764)),
];
for (resolution, seconds, batch, expected) in cases {
@@ -252,8 +216,8 @@ mod seedance_2p0_fast {
}
/// With video references Fast switches to its with-references rate card (480p 8.41, 720p 15.60 cents/s, ceil-rounded). Charges are asserted on the refunded ledger entry (the fixture media is unreachable).
/// Every batch size 1-10 at every resolution (5s).
/// Batches over the platform max of 4 downgrade to 4 (3 upgrades to 4); execution and billing agree.
/// Every supported batch size 1-4 at every resolution (5s).
/// Batches 1-4 are preserved; larger batches are rejected before billing.
#[tokio::test]
#[cfg_attr(feature = "skip_database_tests", ignore)]
async fn video_references_charge_every_batch_size_at_every_resolution() {
@@ -264,42 +228,18 @@ mod seedance_2p0_fast {
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(2), ExpectedCredits(85)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(3), ExpectedCredits(127)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(4), ExpectedCredits(169)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(5), ExpectedCredits(169)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(6), ExpectedCredits(169)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(7), ExpectedCredits(169)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(8), ExpectedCredits(169)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(9), ExpectedCredits(169)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(10), ExpectedCredits(169)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(1), ExpectedCredits(78)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(2), ExpectedCredits(156)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(3), ExpectedCredits(234)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(4), ExpectedCredits(312)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(5), ExpectedCredits(312)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(6), ExpectedCredits(312)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(7), ExpectedCredits(312)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(8), ExpectedCredits(312)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(9), ExpectedCredits(312)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(10), ExpectedCredits(312)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(1), ExpectedCredits(78)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(2), ExpectedCredits(156)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(3), ExpectedCredits(234)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(4), ExpectedCredits(312)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(5), ExpectedCredits(312)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(6), ExpectedCredits(312)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(7), ExpectedCredits(312)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(8), ExpectedCredits(312)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(9), ExpectedCredits(312)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(10), ExpectedCredits(312)),
(Some(CommonResolution::FourK), Seconds(5), Batch(1), ExpectedCredits(78)),
(Some(CommonResolution::FourK), Seconds(5), Batch(2), ExpectedCredits(156)),
(Some(CommonResolution::FourK), Seconds(5), Batch(3), ExpectedCredits(234)),
(Some(CommonResolution::FourK), Seconds(5), Batch(4), ExpectedCredits(312)),
(Some(CommonResolution::FourK), Seconds(5), Batch(5), ExpectedCredits(312)),
(Some(CommonResolution::FourK), Seconds(5), Batch(6), ExpectedCredits(312)),
(Some(CommonResolution::FourK), Seconds(5), Batch(7), ExpectedCredits(312)),
(Some(CommonResolution::FourK), Seconds(5), Batch(8), ExpectedCredits(312)),
(Some(CommonResolution::FourK), Seconds(5), Batch(9), ExpectedCredits(312)),
(Some(CommonResolution::FourK), Seconds(5), Batch(10), ExpectedCredits(312)),
];
for (resolution, seconds, batch, expected) in cases {
@@ -336,27 +276,15 @@ mod seedance_2p0_fast {
(Some(CommonResolution::FourEightyP), Seconds(6), Batch(2), ExpectedCredits(101)),
(Some(CommonResolution::FourEightyP), Seconds(7), Batch(3), ExpectedCredits(177)),
(Some(CommonResolution::FourEightyP), Seconds(9), Batch(4), ExpectedCredits(303)),
(Some(CommonResolution::FourEightyP), Seconds(11), Batch(5), ExpectedCredits(371)),
(Some(CommonResolution::FourEightyP), Seconds(13), Batch(8), ExpectedCredits(438)),
(Some(CommonResolution::FourEightyP), Seconds(15), Batch(10), ExpectedCredits(505)),
(Some(CommonResolution::SevenTwentyP), Seconds(6), Batch(2), ExpectedCredits(188)),
(Some(CommonResolution::SevenTwentyP), Seconds(7), Batch(3), ExpectedCredits(328)),
(Some(CommonResolution::SevenTwentyP), Seconds(9), Batch(4), ExpectedCredits(562)),
(Some(CommonResolution::SevenTwentyP), Seconds(11), Batch(5), ExpectedCredits(687)),
(Some(CommonResolution::SevenTwentyP), Seconds(13), Batch(8), ExpectedCredits(812)),
(Some(CommonResolution::SevenTwentyP), Seconds(15), Batch(10), ExpectedCredits(936)),
(Some(CommonResolution::TenEightyP), Seconds(6), Batch(2), ExpectedCredits(188)),
(Some(CommonResolution::TenEightyP), Seconds(7), Batch(3), ExpectedCredits(328)),
(Some(CommonResolution::TenEightyP), Seconds(9), Batch(4), ExpectedCredits(562)),
(Some(CommonResolution::TenEightyP), Seconds(11), Batch(5), ExpectedCredits(687)),
(Some(CommonResolution::TenEightyP), Seconds(13), Batch(8), ExpectedCredits(812)),
(Some(CommonResolution::TenEightyP), Seconds(15), Batch(10), ExpectedCredits(936)),
(Some(CommonResolution::FourK), Seconds(6), Batch(2), ExpectedCredits(188)),
(Some(CommonResolution::FourK), Seconds(7), Batch(3), ExpectedCredits(328)),
(Some(CommonResolution::FourK), Seconds(9), Batch(4), ExpectedCredits(562)),
(Some(CommonResolution::FourK), Seconds(11), Batch(5), ExpectedCredits(687)),
(Some(CommonResolution::FourK), Seconds(13), Batch(8), ExpectedCredits(812)),
(Some(CommonResolution::FourK), Seconds(15), Batch(10), ExpectedCredits(936)),
];
for (resolution, seconds, batch, expected) in cases {
@@ -438,8 +366,8 @@ mod seedance_2p0_bp_fast {
}
}
/// Every batch size 1-10 at every resolution (5s).
/// Batches over the platform max of 4 downgrade to 4 (3 upgrades to 4); execution and billing agree.
/// Every supported batch size 1-4 at every resolution (5s).
/// Batches 1-4 are preserved; larger batches are rejected before billing.
/// 1080p and 4K downgrade to 720p (and price accordingly).
#[tokio::test]
#[cfg_attr(feature = "skip_database_tests", ignore)]
@@ -451,42 +379,18 @@ mod seedance_2p0_bp_fast {
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(2), ExpectedCredits(90)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(3), ExpectedCredits(135)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(4), ExpectedCredits(180)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(5), ExpectedCredits(180)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(6), ExpectedCredits(180)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(7), ExpectedCredits(180)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(8), ExpectedCredits(180)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(9), ExpectedCredits(180)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(10), ExpectedCredits(180)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(1), ExpectedCredits(100)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(2), ExpectedCredits(200)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(3), ExpectedCredits(300)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(4), ExpectedCredits(400)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(5), ExpectedCredits(400)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(6), ExpectedCredits(400)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(7), ExpectedCredits(400)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(8), ExpectedCredits(400)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(9), ExpectedCredits(400)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(10), ExpectedCredits(400)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(1), ExpectedCredits(100)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(2), ExpectedCredits(200)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(3), ExpectedCredits(300)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(4), ExpectedCredits(400)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(5), ExpectedCredits(400)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(6), ExpectedCredits(400)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(7), ExpectedCredits(400)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(8), ExpectedCredits(400)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(9), ExpectedCredits(400)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(10), ExpectedCredits(400)),
(Some(CommonResolution::FourK), Seconds(5), Batch(1), ExpectedCredits(100)),
(Some(CommonResolution::FourK), Seconds(5), Batch(2), ExpectedCredits(200)),
(Some(CommonResolution::FourK), Seconds(5), Batch(3), ExpectedCredits(300)),
(Some(CommonResolution::FourK), Seconds(5), Batch(4), ExpectedCredits(400)),
(Some(CommonResolution::FourK), Seconds(5), Batch(5), ExpectedCredits(400)),
(Some(CommonResolution::FourK), Seconds(5), Batch(6), ExpectedCredits(400)),
(Some(CommonResolution::FourK), Seconds(5), Batch(7), ExpectedCredits(400)),
(Some(CommonResolution::FourK), Seconds(5), Batch(8), ExpectedCredits(400)),
(Some(CommonResolution::FourK), Seconds(5), Batch(9), ExpectedCredits(400)),
(Some(CommonResolution::FourK), Seconds(5), Batch(10), ExpectedCredits(400)),
];
for (resolution, seconds, batch, expected) in cases {
@@ -506,27 +410,15 @@ mod seedance_2p0_bp_fast {
(Some(CommonResolution::FourEightyP), Seconds(6), Batch(2), ExpectedCredits(108)),
(Some(CommonResolution::FourEightyP), Seconds(7), Batch(3), ExpectedCredits(189)),
(Some(CommonResolution::FourEightyP), Seconds(9), Batch(4), ExpectedCredits(324)),
(Some(CommonResolution::FourEightyP), Seconds(11), Batch(5), ExpectedCredits(396)),
(Some(CommonResolution::FourEightyP), Seconds(13), Batch(8), ExpectedCredits(468)),
(Some(CommonResolution::FourEightyP), Seconds(15), Batch(10), ExpectedCredits(540)),
(Some(CommonResolution::SevenTwentyP), Seconds(6), Batch(2), ExpectedCredits(240)),
(Some(CommonResolution::SevenTwentyP), Seconds(7), Batch(3), ExpectedCredits(420)),
(Some(CommonResolution::SevenTwentyP), Seconds(9), Batch(4), ExpectedCredits(720)),
(Some(CommonResolution::SevenTwentyP), Seconds(11), Batch(5), ExpectedCredits(880)),
(Some(CommonResolution::SevenTwentyP), Seconds(13), Batch(8), ExpectedCredits(1040)),
(Some(CommonResolution::SevenTwentyP), Seconds(15), Batch(10), ExpectedCredits(1200)),
(Some(CommonResolution::TenEightyP), Seconds(6), Batch(2), ExpectedCredits(240)),
(Some(CommonResolution::TenEightyP), Seconds(7), Batch(3), ExpectedCredits(420)),
(Some(CommonResolution::TenEightyP), Seconds(9), Batch(4), ExpectedCredits(720)),
(Some(CommonResolution::TenEightyP), Seconds(11), Batch(5), ExpectedCredits(880)),
(Some(CommonResolution::TenEightyP), Seconds(13), Batch(8), ExpectedCredits(1040)),
(Some(CommonResolution::TenEightyP), Seconds(15), Batch(10), ExpectedCredits(1200)),
(Some(CommonResolution::FourK), Seconds(6), Batch(2), ExpectedCredits(240)),
(Some(CommonResolution::FourK), Seconds(7), Batch(3), ExpectedCredits(420)),
(Some(CommonResolution::FourK), Seconds(9), Batch(4), ExpectedCredits(720)),
(Some(CommonResolution::FourK), Seconds(11), Batch(5), ExpectedCredits(880)),
(Some(CommonResolution::FourK), Seconds(13), Batch(8), ExpectedCredits(1040)),
(Some(CommonResolution::FourK), Seconds(15), Batch(10), ExpectedCredits(1200)),
];
for (resolution, seconds, batch, expected) in cases {
@@ -602,8 +494,8 @@ mod seedance_2p0_bp_fast {
}
/// The BytePlus Fast rate card is flat: reference videos never change the price. Charges are asserted on the refunded ledger entry (the fixture media is unreachable).
/// Every batch size 1-10 at every resolution (5s).
/// Batches over the platform max of 4 downgrade to 4 (3 upgrades to 4); execution and billing agree.
/// Every supported batch size 1-4 at every resolution (5s).
/// Batches 1-4 are preserved; larger batches are rejected before billing.
#[tokio::test]
#[cfg_attr(feature = "skip_database_tests", ignore)]
async fn video_references_charge_every_batch_size_at_every_resolution() {
@@ -614,42 +506,18 @@ mod seedance_2p0_bp_fast {
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(2), ExpectedCredits(97)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(3), ExpectedCredits(145)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(4), ExpectedCredits(193)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(5), ExpectedCredits(193)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(6), ExpectedCredits(193)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(7), ExpectedCredits(193)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(8), ExpectedCredits(193)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(9), ExpectedCredits(193)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(10), ExpectedCredits(193)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(1), ExpectedCredits(103)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(2), ExpectedCredits(205)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(3), ExpectedCredits(308)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(4), ExpectedCredits(410)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(5), ExpectedCredits(410)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(6), ExpectedCredits(410)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(7), ExpectedCredits(410)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(8), ExpectedCredits(410)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(9), ExpectedCredits(410)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(10), ExpectedCredits(410)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(1), ExpectedCredits(103)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(2), ExpectedCredits(205)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(3), ExpectedCredits(308)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(4), ExpectedCredits(410)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(5), ExpectedCredits(410)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(6), ExpectedCredits(410)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(7), ExpectedCredits(410)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(8), ExpectedCredits(410)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(9), ExpectedCredits(410)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(10), ExpectedCredits(410)),
(Some(CommonResolution::FourK), Seconds(5), Batch(1), ExpectedCredits(103)),
(Some(CommonResolution::FourK), Seconds(5), Batch(2), ExpectedCredits(205)),
(Some(CommonResolution::FourK), Seconds(5), Batch(3), ExpectedCredits(308)),
(Some(CommonResolution::FourK), Seconds(5), Batch(4), ExpectedCredits(410)),
(Some(CommonResolution::FourK), Seconds(5), Batch(5), ExpectedCredits(410)),
(Some(CommonResolution::FourK), Seconds(5), Batch(6), ExpectedCredits(410)),
(Some(CommonResolution::FourK), Seconds(5), Batch(7), ExpectedCredits(410)),
(Some(CommonResolution::FourK), Seconds(5), Batch(8), ExpectedCredits(410)),
(Some(CommonResolution::FourK), Seconds(5), Batch(9), ExpectedCredits(410)),
(Some(CommonResolution::FourK), Seconds(5), Batch(10), ExpectedCredits(410)),
];
for (resolution, seconds, batch, expected) in cases {
@@ -686,27 +554,15 @@ mod seedance_2p0_bp_fast {
(Some(CommonResolution::FourEightyP), Seconds(6), Batch(2), ExpectedCredits(116)),
(Some(CommonResolution::FourEightyP), Seconds(7), Batch(3), ExpectedCredits(203)),
(Some(CommonResolution::FourEightyP), Seconds(9), Batch(4), ExpectedCredits(348)),
(Some(CommonResolution::FourEightyP), Seconds(11), Batch(5), ExpectedCredits(425)),
(Some(CommonResolution::FourEightyP), Seconds(13), Batch(8), ExpectedCredits(502)),
(Some(CommonResolution::FourEightyP), Seconds(15), Batch(10), ExpectedCredits(579)),
(Some(CommonResolution::SevenTwentyP), Seconds(6), Batch(2), ExpectedCredits(246)),
(Some(CommonResolution::SevenTwentyP), Seconds(7), Batch(3), ExpectedCredits(431)),
(Some(CommonResolution::SevenTwentyP), Seconds(9), Batch(4), ExpectedCredits(738)),
(Some(CommonResolution::SevenTwentyP), Seconds(11), Batch(5), ExpectedCredits(902)),
(Some(CommonResolution::SevenTwentyP), Seconds(13), Batch(8), ExpectedCredits(1066)),
(Some(CommonResolution::SevenTwentyP), Seconds(15), Batch(10), ExpectedCredits(1230)),
(Some(CommonResolution::TenEightyP), Seconds(6), Batch(2), ExpectedCredits(246)),
(Some(CommonResolution::TenEightyP), Seconds(7), Batch(3), ExpectedCredits(431)),
(Some(CommonResolution::TenEightyP), Seconds(9), Batch(4), ExpectedCredits(738)),
(Some(CommonResolution::TenEightyP), Seconds(11), Batch(5), ExpectedCredits(902)),
(Some(CommonResolution::TenEightyP), Seconds(13), Batch(8), ExpectedCredits(1066)),
(Some(CommonResolution::TenEightyP), Seconds(15), Batch(10), ExpectedCredits(1230)),
(Some(CommonResolution::FourK), Seconds(6), Batch(2), ExpectedCredits(246)),
(Some(CommonResolution::FourK), Seconds(7), Batch(3), ExpectedCredits(431)),
(Some(CommonResolution::FourK), Seconds(9), Batch(4), ExpectedCredits(738)),
(Some(CommonResolution::FourK), Seconds(11), Batch(5), ExpectedCredits(902)),
(Some(CommonResolution::FourK), Seconds(13), Batch(8), ExpectedCredits(1066)),
(Some(CommonResolution::FourK), Seconds(15), Batch(10), ExpectedCredits(1230)),
];
for (resolution, seconds, batch, expected) in cases {
@@ -804,8 +660,8 @@ mod seedance_2p0_bpu_fast {
}
}
/// Every batch size 1-10 at every resolution (5s).
/// Batches over the platform max of 4 downgrade to 4 (3 upgrades to 4); execution and billing agree.
/// Every supported batch size 1-4 at every resolution (5s).
/// Batches 1-4 are preserved; larger batches are rejected before billing.
/// 1080p and 4K downgrade to 720p (and price accordingly).
#[tokio::test]
#[cfg_attr(feature = "skip_database_tests", ignore)]
@@ -817,42 +673,18 @@ mod seedance_2p0_bpu_fast {
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(2), ExpectedCredits(90)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(3), ExpectedCredits(135)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(4), ExpectedCredits(180)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(5), ExpectedCredits(180)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(6), ExpectedCredits(180)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(7), ExpectedCredits(180)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(8), ExpectedCredits(180)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(9), ExpectedCredits(180)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(10), ExpectedCredits(180)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(1), ExpectedCredits(100)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(2), ExpectedCredits(200)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(3), ExpectedCredits(300)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(4), ExpectedCredits(400)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(5), ExpectedCredits(400)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(6), ExpectedCredits(400)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(7), ExpectedCredits(400)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(8), ExpectedCredits(400)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(9), ExpectedCredits(400)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(10), ExpectedCredits(400)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(1), ExpectedCredits(100)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(2), ExpectedCredits(200)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(3), ExpectedCredits(300)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(4), ExpectedCredits(400)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(5), ExpectedCredits(400)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(6), ExpectedCredits(400)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(7), ExpectedCredits(400)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(8), ExpectedCredits(400)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(9), ExpectedCredits(400)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(10), ExpectedCredits(400)),
(Some(CommonResolution::FourK), Seconds(5), Batch(1), ExpectedCredits(100)),
(Some(CommonResolution::FourK), Seconds(5), Batch(2), ExpectedCredits(200)),
(Some(CommonResolution::FourK), Seconds(5), Batch(3), ExpectedCredits(300)),
(Some(CommonResolution::FourK), Seconds(5), Batch(4), ExpectedCredits(400)),
(Some(CommonResolution::FourK), Seconds(5), Batch(5), ExpectedCredits(400)),
(Some(CommonResolution::FourK), Seconds(5), Batch(6), ExpectedCredits(400)),
(Some(CommonResolution::FourK), Seconds(5), Batch(7), ExpectedCredits(400)),
(Some(CommonResolution::FourK), Seconds(5), Batch(8), ExpectedCredits(400)),
(Some(CommonResolution::FourK), Seconds(5), Batch(9), ExpectedCredits(400)),
(Some(CommonResolution::FourK), Seconds(5), Batch(10), ExpectedCredits(400)),
];
for (resolution, seconds, batch, expected) in cases {
@@ -872,27 +704,15 @@ mod seedance_2p0_bpu_fast {
(Some(CommonResolution::FourEightyP), Seconds(6), Batch(2), ExpectedCredits(108)),
(Some(CommonResolution::FourEightyP), Seconds(7), Batch(3), ExpectedCredits(189)),
(Some(CommonResolution::FourEightyP), Seconds(9), Batch(4), ExpectedCredits(324)),
(Some(CommonResolution::FourEightyP), Seconds(11), Batch(5), ExpectedCredits(396)),
(Some(CommonResolution::FourEightyP), Seconds(13), Batch(8), ExpectedCredits(468)),
(Some(CommonResolution::FourEightyP), Seconds(15), Batch(10), ExpectedCredits(540)),
(Some(CommonResolution::SevenTwentyP), Seconds(6), Batch(2), ExpectedCredits(240)),
(Some(CommonResolution::SevenTwentyP), Seconds(7), Batch(3), ExpectedCredits(420)),
(Some(CommonResolution::SevenTwentyP), Seconds(9), Batch(4), ExpectedCredits(720)),
(Some(CommonResolution::SevenTwentyP), Seconds(11), Batch(5), ExpectedCredits(880)),
(Some(CommonResolution::SevenTwentyP), Seconds(13), Batch(8), ExpectedCredits(1040)),
(Some(CommonResolution::SevenTwentyP), Seconds(15), Batch(10), ExpectedCredits(1200)),
(Some(CommonResolution::TenEightyP), Seconds(6), Batch(2), ExpectedCredits(240)),
(Some(CommonResolution::TenEightyP), Seconds(7), Batch(3), ExpectedCredits(420)),
(Some(CommonResolution::TenEightyP), Seconds(9), Batch(4), ExpectedCredits(720)),
(Some(CommonResolution::TenEightyP), Seconds(11), Batch(5), ExpectedCredits(880)),
(Some(CommonResolution::TenEightyP), Seconds(13), Batch(8), ExpectedCredits(1040)),
(Some(CommonResolution::TenEightyP), Seconds(15), Batch(10), ExpectedCredits(1200)),
(Some(CommonResolution::FourK), Seconds(6), Batch(2), ExpectedCredits(240)),
(Some(CommonResolution::FourK), Seconds(7), Batch(3), ExpectedCredits(420)),
(Some(CommonResolution::FourK), Seconds(9), Batch(4), ExpectedCredits(720)),
(Some(CommonResolution::FourK), Seconds(11), Batch(5), ExpectedCredits(880)),
(Some(CommonResolution::FourK), Seconds(13), Batch(8), ExpectedCredits(1040)),
(Some(CommonResolution::FourK), Seconds(15), Batch(10), ExpectedCredits(1200)),
];
for (resolution, seconds, batch, expected) in cases {
@@ -968,8 +788,8 @@ mod seedance_2p0_bpu_fast {
}
/// The BytePlus Fast rate card is flat: reference videos never change the price. Charges are asserted on the refunded ledger entry (the fixture media is unreachable).
/// Every batch size 1-10 at every resolution (5s).
/// Batches over the platform max of 4 downgrade to 4 (3 upgrades to 4); execution and billing agree.
/// Every supported batch size 1-4 at every resolution (5s).
/// Batches 1-4 are preserved; larger batches are rejected before billing.
#[tokio::test]
#[cfg_attr(feature = "skip_database_tests", ignore)]
async fn video_references_charge_every_batch_size_at_every_resolution() {
@@ -980,42 +800,18 @@ mod seedance_2p0_bpu_fast {
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(2), ExpectedCredits(97)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(3), ExpectedCredits(145)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(4), ExpectedCredits(193)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(5), ExpectedCredits(193)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(6), ExpectedCredits(193)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(7), ExpectedCredits(193)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(8), ExpectedCredits(193)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(9), ExpectedCredits(193)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(10), ExpectedCredits(193)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(1), ExpectedCredits(103)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(2), ExpectedCredits(205)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(3), ExpectedCredits(308)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(4), ExpectedCredits(410)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(5), ExpectedCredits(410)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(6), ExpectedCredits(410)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(7), ExpectedCredits(410)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(8), ExpectedCredits(410)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(9), ExpectedCredits(410)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(10), ExpectedCredits(410)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(1), ExpectedCredits(103)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(2), ExpectedCredits(205)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(3), ExpectedCredits(308)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(4), ExpectedCredits(410)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(5), ExpectedCredits(410)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(6), ExpectedCredits(410)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(7), ExpectedCredits(410)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(8), ExpectedCredits(410)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(9), ExpectedCredits(410)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(10), ExpectedCredits(410)),
(Some(CommonResolution::FourK), Seconds(5), Batch(1), ExpectedCredits(103)),
(Some(CommonResolution::FourK), Seconds(5), Batch(2), ExpectedCredits(205)),
(Some(CommonResolution::FourK), Seconds(5), Batch(3), ExpectedCredits(308)),
(Some(CommonResolution::FourK), Seconds(5), Batch(4), ExpectedCredits(410)),
(Some(CommonResolution::FourK), Seconds(5), Batch(5), ExpectedCredits(410)),
(Some(CommonResolution::FourK), Seconds(5), Batch(6), ExpectedCredits(410)),
(Some(CommonResolution::FourK), Seconds(5), Batch(7), ExpectedCredits(410)),
(Some(CommonResolution::FourK), Seconds(5), Batch(8), ExpectedCredits(410)),
(Some(CommonResolution::FourK), Seconds(5), Batch(9), ExpectedCredits(410)),
(Some(CommonResolution::FourK), Seconds(5), Batch(10), ExpectedCredits(410)),
];
for (resolution, seconds, batch, expected) in cases {
@@ -1052,27 +848,15 @@ mod seedance_2p0_bpu_fast {
(Some(CommonResolution::FourEightyP), Seconds(6), Batch(2), ExpectedCredits(116)),
(Some(CommonResolution::FourEightyP), Seconds(7), Batch(3), ExpectedCredits(203)),
(Some(CommonResolution::FourEightyP), Seconds(9), Batch(4), ExpectedCredits(348)),
(Some(CommonResolution::FourEightyP), Seconds(11), Batch(5), ExpectedCredits(425)),
(Some(CommonResolution::FourEightyP), Seconds(13), Batch(8), ExpectedCredits(502)),
(Some(CommonResolution::FourEightyP), Seconds(15), Batch(10), ExpectedCredits(579)),
(Some(CommonResolution::SevenTwentyP), Seconds(6), Batch(2), ExpectedCredits(246)),
(Some(CommonResolution::SevenTwentyP), Seconds(7), Batch(3), ExpectedCredits(431)),
(Some(CommonResolution::SevenTwentyP), Seconds(9), Batch(4), ExpectedCredits(738)),
(Some(CommonResolution::SevenTwentyP), Seconds(11), Batch(5), ExpectedCredits(902)),
(Some(CommonResolution::SevenTwentyP), Seconds(13), Batch(8), ExpectedCredits(1066)),
(Some(CommonResolution::SevenTwentyP), Seconds(15), Batch(10), ExpectedCredits(1230)),
(Some(CommonResolution::TenEightyP), Seconds(6), Batch(2), ExpectedCredits(246)),
(Some(CommonResolution::TenEightyP), Seconds(7), Batch(3), ExpectedCredits(431)),
(Some(CommonResolution::TenEightyP), Seconds(9), Batch(4), ExpectedCredits(738)),
(Some(CommonResolution::TenEightyP), Seconds(11), Batch(5), ExpectedCredits(902)),
(Some(CommonResolution::TenEightyP), Seconds(13), Batch(8), ExpectedCredits(1066)),
(Some(CommonResolution::TenEightyP), Seconds(15), Batch(10), ExpectedCredits(1230)),
(Some(CommonResolution::FourK), Seconds(6), Batch(2), ExpectedCredits(246)),
(Some(CommonResolution::FourK), Seconds(7), Batch(3), ExpectedCredits(431)),
(Some(CommonResolution::FourK), Seconds(9), Batch(4), ExpectedCredits(738)),
(Some(CommonResolution::FourK), Seconds(11), Batch(5), ExpectedCredits(902)),
(Some(CommonResolution::FourK), Seconds(13), Batch(8), ExpectedCredits(1066)),
(Some(CommonResolution::FourK), Seconds(15), Batch(10), ExpectedCredits(1230)),
];
for (resolution, seconds, batch, expected) in cases {
@@ -12,7 +12,7 @@
//!
//! The Mini tier offers 480p and 720p; 1080p and 4K requests downgrade to
//! 720p on both execution and billing, which the tables below pin. Batches
//! of 1-8 bill in full (once, batch 8 executed in full but billed as 4).
//! of 1-4 bill in full; larger batches are rejected before billing.
use enums::common::generation::common_resolution::CommonResolution;
use enums::common::generation::common_video_model::CommonVideoModel;
@@ -91,8 +91,8 @@ mod seedance_2p0_mini {
}
}
/// Every batch size 1-10 at every resolution (5s).
/// Mini supports batches of 1-8 and bills them in full; 9-10 clamp to 8.
/// Every supported batch size 1-4 at every resolution (5s).
/// Mini supports batches of 1-4 and bills them in full.
/// 1080p and 4K downgrade to 720p (and price accordingly).
#[tokio::test]
#[cfg_attr(feature = "skip_database_tests", ignore)]
@@ -104,42 +104,18 @@ mod seedance_2p0_mini {
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(2), ExpectedCredits(35)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(3), ExpectedCredits(52)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(4), ExpectedCredits(69)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(5), ExpectedCredits(87)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(6), ExpectedCredits(104)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(7), ExpectedCredits(121)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(8), ExpectedCredits(138)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(9), ExpectedCredits(138)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(10), ExpectedCredits(138)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(1), ExpectedCredits(45)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(2), ExpectedCredits(89)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(3), ExpectedCredits(134)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(4), ExpectedCredits(178)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(5), ExpectedCredits(223)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(6), ExpectedCredits(267)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(7), ExpectedCredits(312)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(8), ExpectedCredits(356)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(9), ExpectedCredits(356)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(10), ExpectedCredits(356)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(1), ExpectedCredits(45)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(2), ExpectedCredits(89)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(3), ExpectedCredits(134)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(4), ExpectedCredits(178)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(5), ExpectedCredits(223)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(6), ExpectedCredits(267)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(7), ExpectedCredits(312)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(8), ExpectedCredits(356)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(9), ExpectedCredits(356)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(10), ExpectedCredits(356)),
(Some(CommonResolution::FourK), Seconds(5), Batch(1), ExpectedCredits(45)),
(Some(CommonResolution::FourK), Seconds(5), Batch(2), ExpectedCredits(89)),
(Some(CommonResolution::FourK), Seconds(5), Batch(3), ExpectedCredits(134)),
(Some(CommonResolution::FourK), Seconds(5), Batch(4), ExpectedCredits(178)),
(Some(CommonResolution::FourK), Seconds(5), Batch(5), ExpectedCredits(223)),
(Some(CommonResolution::FourK), Seconds(5), Batch(6), ExpectedCredits(267)),
(Some(CommonResolution::FourK), Seconds(5), Batch(7), ExpectedCredits(312)),
(Some(CommonResolution::FourK), Seconds(5), Batch(8), ExpectedCredits(356)),
(Some(CommonResolution::FourK), Seconds(5), Batch(9), ExpectedCredits(356)),
(Some(CommonResolution::FourK), Seconds(5), Batch(10), ExpectedCredits(356)),
];
for (resolution, seconds, batch, expected) in cases {
@@ -159,27 +135,15 @@ mod seedance_2p0_mini {
(Some(CommonResolution::FourEightyP), Seconds(6), Batch(2), ExpectedCredits(42)),
(Some(CommonResolution::FourEightyP), Seconds(7), Batch(3), ExpectedCredits(73)),
(Some(CommonResolution::FourEightyP), Seconds(9), Batch(4), ExpectedCredits(125)),
(Some(CommonResolution::FourEightyP), Seconds(11), Batch(5), ExpectedCredits(190)),
(Some(CommonResolution::FourEightyP), Seconds(13), Batch(8), ExpectedCredits(359)),
(Some(CommonResolution::FourEightyP), Seconds(15), Batch(10), ExpectedCredits(414)),
(Some(CommonResolution::SevenTwentyP), Seconds(6), Batch(2), ExpectedCredits(107)),
(Some(CommonResolution::SevenTwentyP), Seconds(7), Batch(3), ExpectedCredits(187)),
(Some(CommonResolution::SevenTwentyP), Seconds(9), Batch(4), ExpectedCredits(321)),
(Some(CommonResolution::SevenTwentyP), Seconds(11), Batch(5), ExpectedCredits(490)),
(Some(CommonResolution::SevenTwentyP), Seconds(13), Batch(8), ExpectedCredits(926)),
(Some(CommonResolution::SevenTwentyP), Seconds(15), Batch(10), ExpectedCredits(1068)),
(Some(CommonResolution::TenEightyP), Seconds(6), Batch(2), ExpectedCredits(107)),
(Some(CommonResolution::TenEightyP), Seconds(7), Batch(3), ExpectedCredits(187)),
(Some(CommonResolution::TenEightyP), Seconds(9), Batch(4), ExpectedCredits(321)),
(Some(CommonResolution::TenEightyP), Seconds(11), Batch(5), ExpectedCredits(490)),
(Some(CommonResolution::TenEightyP), Seconds(13), Batch(8), ExpectedCredits(926)),
(Some(CommonResolution::TenEightyP), Seconds(15), Batch(10), ExpectedCredits(1068)),
(Some(CommonResolution::FourK), Seconds(6), Batch(2), ExpectedCredits(107)),
(Some(CommonResolution::FourK), Seconds(7), Batch(3), ExpectedCredits(187)),
(Some(CommonResolution::FourK), Seconds(9), Batch(4), ExpectedCredits(321)),
(Some(CommonResolution::FourK), Seconds(11), Batch(5), ExpectedCredits(490)),
(Some(CommonResolution::FourK), Seconds(13), Batch(8), ExpectedCredits(926)),
(Some(CommonResolution::FourK), Seconds(15), Batch(10), ExpectedCredits(1068)),
];
for (resolution, seconds, batch, expected) in cases {
@@ -255,8 +219,8 @@ mod seedance_2p0_mini {
}
/// With video references Mini adds its per-second surcharge before the single ceil-rounding. Charges are asserted on the refunded ledger entry (the fixture media is unreachable).
/// Every batch size 1-10 at every resolution (5s).
/// Mini supports batches of 1-8 and bills them in full; 9-10 clamp to 8.
/// Every supported batch size 1-4 at every resolution (5s).
/// Mini supports batches of 1-4 and bills them in full.
#[tokio::test]
#[cfg_attr(feature = "skip_database_tests", ignore)]
async fn video_references_charge_every_batch_size_at_every_resolution() {
@@ -267,42 +231,18 @@ mod seedance_2p0_mini {
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(2), ExpectedCredits(44)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(3), ExpectedCredits(66)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(4), ExpectedCredits(88)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(5), ExpectedCredits(109)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(6), ExpectedCredits(131)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(7), ExpectedCredits(153)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(8), ExpectedCredits(175)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(9), ExpectedCredits(175)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(10), ExpectedCredits(175)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(1), ExpectedCredits(54)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(2), ExpectedCredits(108)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(3), ExpectedCredits(161)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(4), ExpectedCredits(215)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(5), ExpectedCredits(268)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(6), ExpectedCredits(322)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(7), ExpectedCredits(375)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(8), ExpectedCredits(429)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(9), ExpectedCredits(429)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(10), ExpectedCredits(429)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(1), ExpectedCredits(54)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(2), ExpectedCredits(108)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(3), ExpectedCredits(161)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(4), ExpectedCredits(215)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(5), ExpectedCredits(268)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(6), ExpectedCredits(322)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(7), ExpectedCredits(375)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(8), ExpectedCredits(429)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(9), ExpectedCredits(429)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(10), ExpectedCredits(429)),
(Some(CommonResolution::FourK), Seconds(5), Batch(1), ExpectedCredits(54)),
(Some(CommonResolution::FourK), Seconds(5), Batch(2), ExpectedCredits(108)),
(Some(CommonResolution::FourK), Seconds(5), Batch(3), ExpectedCredits(161)),
(Some(CommonResolution::FourK), Seconds(5), Batch(4), ExpectedCredits(215)),
(Some(CommonResolution::FourK), Seconds(5), Batch(5), ExpectedCredits(268)),
(Some(CommonResolution::FourK), Seconds(5), Batch(6), ExpectedCredits(322)),
(Some(CommonResolution::FourK), Seconds(5), Batch(7), ExpectedCredits(375)),
(Some(CommonResolution::FourK), Seconds(5), Batch(8), ExpectedCredits(429)),
(Some(CommonResolution::FourK), Seconds(5), Batch(9), ExpectedCredits(429)),
(Some(CommonResolution::FourK), Seconds(5), Batch(10), ExpectedCredits(429)),
];
for (resolution, seconds, batch, expected) in cases {
@@ -339,27 +279,15 @@ mod seedance_2p0_mini {
(Some(CommonResolution::FourEightyP), Seconds(6), Batch(2), ExpectedCredits(53)),
(Some(CommonResolution::FourEightyP), Seconds(7), Batch(3), ExpectedCredits(92)),
(Some(CommonResolution::FourEightyP), Seconds(9), Batch(4), ExpectedCredits(157)),
(Some(CommonResolution::FourEightyP), Seconds(11), Batch(5), ExpectedCredits(240)),
(Some(CommonResolution::FourEightyP), Seconds(13), Batch(8), ExpectedCredits(453)),
(Some(CommonResolution::FourEightyP), Seconds(15), Batch(10), ExpectedCredits(523)),
(Some(CommonResolution::SevenTwentyP), Seconds(6), Batch(2), ExpectedCredits(129)),
(Some(CommonResolution::SevenTwentyP), Seconds(7), Batch(3), ExpectedCredits(225)),
(Some(CommonResolution::SevenTwentyP), Seconds(9), Batch(4), ExpectedCredits(386)),
(Some(CommonResolution::SevenTwentyP), Seconds(11), Batch(5), ExpectedCredits(589)),
(Some(CommonResolution::SevenTwentyP), Seconds(13), Batch(8), ExpectedCredits(1113)),
(Some(CommonResolution::SevenTwentyP), Seconds(15), Batch(10), ExpectedCredits(1285)),
(Some(CommonResolution::TenEightyP), Seconds(6), Batch(2), ExpectedCredits(129)),
(Some(CommonResolution::TenEightyP), Seconds(7), Batch(3), ExpectedCredits(225)),
(Some(CommonResolution::TenEightyP), Seconds(9), Batch(4), ExpectedCredits(386)),
(Some(CommonResolution::TenEightyP), Seconds(11), Batch(5), ExpectedCredits(589)),
(Some(CommonResolution::TenEightyP), Seconds(13), Batch(8), ExpectedCredits(1113)),
(Some(CommonResolution::TenEightyP), Seconds(15), Batch(10), ExpectedCredits(1285)),
(Some(CommonResolution::FourK), Seconds(6), Batch(2), ExpectedCredits(129)),
(Some(CommonResolution::FourK), Seconds(7), Batch(3), ExpectedCredits(225)),
(Some(CommonResolution::FourK), Seconds(9), Batch(4), ExpectedCredits(386)),
(Some(CommonResolution::FourK), Seconds(11), Batch(5), ExpectedCredits(589)),
(Some(CommonResolution::FourK), Seconds(13), Batch(8), ExpectedCredits(1113)),
(Some(CommonResolution::FourK), Seconds(15), Batch(10), ExpectedCredits(1285)),
];
for (resolution, seconds, batch, expected) in cases {
@@ -442,8 +370,8 @@ mod seedance_2p0_bp_mini {
}
}
/// Every batch size 1-10 at every resolution (5s).
/// Mini supports batches of 1-8 and bills them in full; 9-10 clamp to 8.
/// Every supported batch size 1-4 at every resolution (5s).
/// Mini supports batches of 1-4 and bills them in full.
/// 1080p and 4K downgrade to 720p (and price accordingly).
#[tokio::test]
#[cfg_attr(feature = "skip_database_tests", ignore)]
@@ -455,42 +383,18 @@ mod seedance_2p0_bp_mini {
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(2), ExpectedCredits(36)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(3), ExpectedCredits(54)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(4), ExpectedCredits(71)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(5), ExpectedCredits(89)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(6), ExpectedCredits(107)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(7), ExpectedCredits(125)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(8), ExpectedCredits(142)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(9), ExpectedCredits(142)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(10), ExpectedCredits(142)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(1), ExpectedCredits(46)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(2), ExpectedCredits(91)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(3), ExpectedCredits(137)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(4), ExpectedCredits(182)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(5), ExpectedCredits(228)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(6), ExpectedCredits(273)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(7), ExpectedCredits(319)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(8), ExpectedCredits(364)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(9), ExpectedCredits(364)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(10), ExpectedCredits(364)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(1), ExpectedCredits(46)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(2), ExpectedCredits(91)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(3), ExpectedCredits(137)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(4), ExpectedCredits(182)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(5), ExpectedCredits(228)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(6), ExpectedCredits(273)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(7), ExpectedCredits(319)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(8), ExpectedCredits(364)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(9), ExpectedCredits(364)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(10), ExpectedCredits(364)),
(Some(CommonResolution::FourK), Seconds(5), Batch(1), ExpectedCredits(46)),
(Some(CommonResolution::FourK), Seconds(5), Batch(2), ExpectedCredits(91)),
(Some(CommonResolution::FourK), Seconds(5), Batch(3), ExpectedCredits(137)),
(Some(CommonResolution::FourK), Seconds(5), Batch(4), ExpectedCredits(182)),
(Some(CommonResolution::FourK), Seconds(5), Batch(5), ExpectedCredits(228)),
(Some(CommonResolution::FourK), Seconds(5), Batch(6), ExpectedCredits(273)),
(Some(CommonResolution::FourK), Seconds(5), Batch(7), ExpectedCredits(319)),
(Some(CommonResolution::FourK), Seconds(5), Batch(8), ExpectedCredits(364)),
(Some(CommonResolution::FourK), Seconds(5), Batch(9), ExpectedCredits(364)),
(Some(CommonResolution::FourK), Seconds(5), Batch(10), ExpectedCredits(364)),
];
for (resolution, seconds, batch, expected) in cases {
@@ -510,27 +414,15 @@ mod seedance_2p0_bp_mini {
(Some(CommonResolution::FourEightyP), Seconds(6), Batch(2), ExpectedCredits(43)),
(Some(CommonResolution::FourEightyP), Seconds(7), Batch(3), ExpectedCredits(75)),
(Some(CommonResolution::FourEightyP), Seconds(9), Batch(4), ExpectedCredits(128)),
(Some(CommonResolution::FourEightyP), Seconds(11), Batch(5), ExpectedCredits(196)),
(Some(CommonResolution::FourEightyP), Seconds(13), Batch(8), ExpectedCredits(370)),
(Some(CommonResolution::FourEightyP), Seconds(15), Batch(10), ExpectedCredits(426)),
(Some(CommonResolution::SevenTwentyP), Seconds(6), Batch(2), ExpectedCredits(110)),
(Some(CommonResolution::SevenTwentyP), Seconds(7), Batch(3), ExpectedCredits(192)),
(Some(CommonResolution::SevenTwentyP), Seconds(9), Batch(4), ExpectedCredits(328)),
(Some(CommonResolution::SevenTwentyP), Seconds(11), Batch(5), ExpectedCredits(501)),
(Some(CommonResolution::SevenTwentyP), Seconds(13), Batch(8), ExpectedCredits(947)),
(Some(CommonResolution::SevenTwentyP), Seconds(15), Batch(10), ExpectedCredits(1092)),
(Some(CommonResolution::TenEightyP), Seconds(6), Batch(2), ExpectedCredits(110)),
(Some(CommonResolution::TenEightyP), Seconds(7), Batch(3), ExpectedCredits(192)),
(Some(CommonResolution::TenEightyP), Seconds(9), Batch(4), ExpectedCredits(328)),
(Some(CommonResolution::TenEightyP), Seconds(11), Batch(5), ExpectedCredits(501)),
(Some(CommonResolution::TenEightyP), Seconds(13), Batch(8), ExpectedCredits(947)),
(Some(CommonResolution::TenEightyP), Seconds(15), Batch(10), ExpectedCredits(1092)),
(Some(CommonResolution::FourK), Seconds(6), Batch(2), ExpectedCredits(110)),
(Some(CommonResolution::FourK), Seconds(7), Batch(3), ExpectedCredits(192)),
(Some(CommonResolution::FourK), Seconds(9), Batch(4), ExpectedCredits(328)),
(Some(CommonResolution::FourK), Seconds(11), Batch(5), ExpectedCredits(501)),
(Some(CommonResolution::FourK), Seconds(13), Batch(8), ExpectedCredits(947)),
(Some(CommonResolution::FourK), Seconds(15), Batch(10), ExpectedCredits(1092)),
];
for (resolution, seconds, batch, expected) in cases {
@@ -606,8 +498,8 @@ mod seedance_2p0_bp_mini {
}
/// With video references Mini adds its per-second surcharge before the single ceil-rounding. Charges are asserted on the refunded ledger entry (the fixture media is unreachable).
/// Every batch size 1-10 at every resolution (5s).
/// Mini supports batches of 1-8 and bills them in full; 9-10 clamp to 8.
/// Every supported batch size 1-4 at every resolution (5s).
/// Mini supports batches of 1-4 and bills them in full.
#[tokio::test]
#[cfg_attr(feature = "skip_database_tests", ignore)]
async fn video_references_charge_every_batch_size_at_every_resolution() {
@@ -618,42 +510,18 @@ mod seedance_2p0_bp_mini {
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(2), ExpectedCredits(45)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(3), ExpectedCredits(68)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(4), ExpectedCredits(90)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(5), ExpectedCredits(113)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(6), ExpectedCredits(135)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(7), ExpectedCredits(158)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(8), ExpectedCredits(180)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(9), ExpectedCredits(180)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(10), ExpectedCredits(180)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(1), ExpectedCredits(55)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(2), ExpectedCredits(110)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(3), ExpectedCredits(165)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(4), ExpectedCredits(219)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(5), ExpectedCredits(274)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(6), ExpectedCredits(329)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(7), ExpectedCredits(384)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(8), ExpectedCredits(438)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(9), ExpectedCredits(438)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(10), ExpectedCredits(438)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(1), ExpectedCredits(55)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(2), ExpectedCredits(110)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(3), ExpectedCredits(165)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(4), ExpectedCredits(219)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(5), ExpectedCredits(274)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(6), ExpectedCredits(329)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(7), ExpectedCredits(384)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(8), ExpectedCredits(438)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(9), ExpectedCredits(438)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(10), ExpectedCredits(438)),
(Some(CommonResolution::FourK), Seconds(5), Batch(1), ExpectedCredits(55)),
(Some(CommonResolution::FourK), Seconds(5), Batch(2), ExpectedCredits(110)),
(Some(CommonResolution::FourK), Seconds(5), Batch(3), ExpectedCredits(165)),
(Some(CommonResolution::FourK), Seconds(5), Batch(4), ExpectedCredits(219)),
(Some(CommonResolution::FourK), Seconds(5), Batch(5), ExpectedCredits(274)),
(Some(CommonResolution::FourK), Seconds(5), Batch(6), ExpectedCredits(329)),
(Some(CommonResolution::FourK), Seconds(5), Batch(7), ExpectedCredits(384)),
(Some(CommonResolution::FourK), Seconds(5), Batch(8), ExpectedCredits(438)),
(Some(CommonResolution::FourK), Seconds(5), Batch(9), ExpectedCredits(438)),
(Some(CommonResolution::FourK), Seconds(5), Batch(10), ExpectedCredits(438)),
];
for (resolution, seconds, batch, expected) in cases {
@@ -690,27 +558,15 @@ mod seedance_2p0_bp_mini {
(Some(CommonResolution::FourEightyP), Seconds(6), Batch(2), ExpectedCredits(54)),
(Some(CommonResolution::FourEightyP), Seconds(7), Batch(3), ExpectedCredits(95)),
(Some(CommonResolution::FourEightyP), Seconds(9), Batch(4), ExpectedCredits(162)),
(Some(CommonResolution::FourEightyP), Seconds(11), Batch(5), ExpectedCredits(248)),
(Some(CommonResolution::FourEightyP), Seconds(13), Batch(8), ExpectedCredits(468)),
(Some(CommonResolution::FourEightyP), Seconds(15), Batch(10), ExpectedCredits(540)),
(Some(CommonResolution::SevenTwentyP), Seconds(6), Batch(2), ExpectedCredits(132)),
(Some(CommonResolution::SevenTwentyP), Seconds(7), Batch(3), ExpectedCredits(230)),
(Some(CommonResolution::SevenTwentyP), Seconds(9), Batch(4), ExpectedCredits(395)),
(Some(CommonResolution::SevenTwentyP), Seconds(11), Batch(5), ExpectedCredits(603)),
(Some(CommonResolution::SevenTwentyP), Seconds(13), Batch(8), ExpectedCredits(1139)),
(Some(CommonResolution::SevenTwentyP), Seconds(15), Batch(10), ExpectedCredits(1314)),
(Some(CommonResolution::TenEightyP), Seconds(6), Batch(2), ExpectedCredits(132)),
(Some(CommonResolution::TenEightyP), Seconds(7), Batch(3), ExpectedCredits(230)),
(Some(CommonResolution::TenEightyP), Seconds(9), Batch(4), ExpectedCredits(395)),
(Some(CommonResolution::TenEightyP), Seconds(11), Batch(5), ExpectedCredits(603)),
(Some(CommonResolution::TenEightyP), Seconds(13), Batch(8), ExpectedCredits(1139)),
(Some(CommonResolution::TenEightyP), Seconds(15), Batch(10), ExpectedCredits(1314)),
(Some(CommonResolution::FourK), Seconds(6), Batch(2), ExpectedCredits(132)),
(Some(CommonResolution::FourK), Seconds(7), Batch(3), ExpectedCredits(230)),
(Some(CommonResolution::FourK), Seconds(9), Batch(4), ExpectedCredits(395)),
(Some(CommonResolution::FourK), Seconds(11), Batch(5), ExpectedCredits(603)),
(Some(CommonResolution::FourK), Seconds(13), Batch(8), ExpectedCredits(1139)),
(Some(CommonResolution::FourK), Seconds(15), Batch(10), ExpectedCredits(1314)),
];
for (resolution, seconds, batch, expected) in cases {
@@ -791,8 +647,8 @@ mod seedance_2p0_bpu_mini {
}
}
/// Every batch size 1-10 at every resolution (5s).
/// Mini supports batches of 1-8 and bills them in full; 9-10 clamp to 8.
/// Every supported batch size 1-4 at every resolution (5s).
/// Mini supports batches of 1-4 and bills them in full.
/// 1080p and 4K downgrade to 720p (and price accordingly).
#[tokio::test]
#[cfg_attr(feature = "skip_database_tests", ignore)]
@@ -804,42 +660,18 @@ mod seedance_2p0_bpu_mini {
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(2), ExpectedCredits(36)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(3), ExpectedCredits(54)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(4), ExpectedCredits(71)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(5), ExpectedCredits(89)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(6), ExpectedCredits(107)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(7), ExpectedCredits(125)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(8), ExpectedCredits(142)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(9), ExpectedCredits(142)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(10), ExpectedCredits(142)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(1), ExpectedCredits(46)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(2), ExpectedCredits(91)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(3), ExpectedCredits(137)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(4), ExpectedCredits(182)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(5), ExpectedCredits(228)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(6), ExpectedCredits(273)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(7), ExpectedCredits(319)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(8), ExpectedCredits(364)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(9), ExpectedCredits(364)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(10), ExpectedCredits(364)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(1), ExpectedCredits(46)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(2), ExpectedCredits(91)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(3), ExpectedCredits(137)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(4), ExpectedCredits(182)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(5), ExpectedCredits(228)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(6), ExpectedCredits(273)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(7), ExpectedCredits(319)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(8), ExpectedCredits(364)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(9), ExpectedCredits(364)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(10), ExpectedCredits(364)),
(Some(CommonResolution::FourK), Seconds(5), Batch(1), ExpectedCredits(46)),
(Some(CommonResolution::FourK), Seconds(5), Batch(2), ExpectedCredits(91)),
(Some(CommonResolution::FourK), Seconds(5), Batch(3), ExpectedCredits(137)),
(Some(CommonResolution::FourK), Seconds(5), Batch(4), ExpectedCredits(182)),
(Some(CommonResolution::FourK), Seconds(5), Batch(5), ExpectedCredits(228)),
(Some(CommonResolution::FourK), Seconds(5), Batch(6), ExpectedCredits(273)),
(Some(CommonResolution::FourK), Seconds(5), Batch(7), ExpectedCredits(319)),
(Some(CommonResolution::FourK), Seconds(5), Batch(8), ExpectedCredits(364)),
(Some(CommonResolution::FourK), Seconds(5), Batch(9), ExpectedCredits(364)),
(Some(CommonResolution::FourK), Seconds(5), Batch(10), ExpectedCredits(364)),
];
for (resolution, seconds, batch, expected) in cases {
@@ -859,27 +691,15 @@ mod seedance_2p0_bpu_mini {
(Some(CommonResolution::FourEightyP), Seconds(6), Batch(2), ExpectedCredits(43)),
(Some(CommonResolution::FourEightyP), Seconds(7), Batch(3), ExpectedCredits(75)),
(Some(CommonResolution::FourEightyP), Seconds(9), Batch(4), ExpectedCredits(128)),
(Some(CommonResolution::FourEightyP), Seconds(11), Batch(5), ExpectedCredits(196)),
(Some(CommonResolution::FourEightyP), Seconds(13), Batch(8), ExpectedCredits(370)),
(Some(CommonResolution::FourEightyP), Seconds(15), Batch(10), ExpectedCredits(426)),
(Some(CommonResolution::SevenTwentyP), Seconds(6), Batch(2), ExpectedCredits(110)),
(Some(CommonResolution::SevenTwentyP), Seconds(7), Batch(3), ExpectedCredits(192)),
(Some(CommonResolution::SevenTwentyP), Seconds(9), Batch(4), ExpectedCredits(328)),
(Some(CommonResolution::SevenTwentyP), Seconds(11), Batch(5), ExpectedCredits(501)),
(Some(CommonResolution::SevenTwentyP), Seconds(13), Batch(8), ExpectedCredits(947)),
(Some(CommonResolution::SevenTwentyP), Seconds(15), Batch(10), ExpectedCredits(1092)),
(Some(CommonResolution::TenEightyP), Seconds(6), Batch(2), ExpectedCredits(110)),
(Some(CommonResolution::TenEightyP), Seconds(7), Batch(3), ExpectedCredits(192)),
(Some(CommonResolution::TenEightyP), Seconds(9), Batch(4), ExpectedCredits(328)),
(Some(CommonResolution::TenEightyP), Seconds(11), Batch(5), ExpectedCredits(501)),
(Some(CommonResolution::TenEightyP), Seconds(13), Batch(8), ExpectedCredits(947)),
(Some(CommonResolution::TenEightyP), Seconds(15), Batch(10), ExpectedCredits(1092)),
(Some(CommonResolution::FourK), Seconds(6), Batch(2), ExpectedCredits(110)),
(Some(CommonResolution::FourK), Seconds(7), Batch(3), ExpectedCredits(192)),
(Some(CommonResolution::FourK), Seconds(9), Batch(4), ExpectedCredits(328)),
(Some(CommonResolution::FourK), Seconds(11), Batch(5), ExpectedCredits(501)),
(Some(CommonResolution::FourK), Seconds(13), Batch(8), ExpectedCredits(947)),
(Some(CommonResolution::FourK), Seconds(15), Batch(10), ExpectedCredits(1092)),
];
for (resolution, seconds, batch, expected) in cases {
@@ -955,8 +775,8 @@ mod seedance_2p0_bpu_mini {
}
/// With video references Mini adds its per-second surcharge before the single ceil-rounding. Charges are asserted on the refunded ledger entry (the fixture media is unreachable).
/// Every batch size 1-10 at every resolution (5s).
/// Mini supports batches of 1-8 and bills them in full; 9-10 clamp to 8.
/// Every supported batch size 1-4 at every resolution (5s).
/// Mini supports batches of 1-4 and bills them in full.
#[tokio::test]
#[cfg_attr(feature = "skip_database_tests", ignore)]
async fn video_references_charge_every_batch_size_at_every_resolution() {
@@ -967,42 +787,18 @@ mod seedance_2p0_bpu_mini {
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(2), ExpectedCredits(45)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(3), ExpectedCredits(68)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(4), ExpectedCredits(90)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(5), ExpectedCredits(113)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(6), ExpectedCredits(135)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(7), ExpectedCredits(158)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(8), ExpectedCredits(180)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(9), ExpectedCredits(180)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(10), ExpectedCredits(180)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(1), ExpectedCredits(55)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(2), ExpectedCredits(110)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(3), ExpectedCredits(165)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(4), ExpectedCredits(219)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(5), ExpectedCredits(274)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(6), ExpectedCredits(329)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(7), ExpectedCredits(384)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(8), ExpectedCredits(438)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(9), ExpectedCredits(438)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(10), ExpectedCredits(438)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(1), ExpectedCredits(55)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(2), ExpectedCredits(110)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(3), ExpectedCredits(165)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(4), ExpectedCredits(219)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(5), ExpectedCredits(274)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(6), ExpectedCredits(329)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(7), ExpectedCredits(384)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(8), ExpectedCredits(438)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(9), ExpectedCredits(438)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(10), ExpectedCredits(438)),
(Some(CommonResolution::FourK), Seconds(5), Batch(1), ExpectedCredits(55)),
(Some(CommonResolution::FourK), Seconds(5), Batch(2), ExpectedCredits(110)),
(Some(CommonResolution::FourK), Seconds(5), Batch(3), ExpectedCredits(165)),
(Some(CommonResolution::FourK), Seconds(5), Batch(4), ExpectedCredits(219)),
(Some(CommonResolution::FourK), Seconds(5), Batch(5), ExpectedCredits(274)),
(Some(CommonResolution::FourK), Seconds(5), Batch(6), ExpectedCredits(329)),
(Some(CommonResolution::FourK), Seconds(5), Batch(7), ExpectedCredits(384)),
(Some(CommonResolution::FourK), Seconds(5), Batch(8), ExpectedCredits(438)),
(Some(CommonResolution::FourK), Seconds(5), Batch(9), ExpectedCredits(438)),
(Some(CommonResolution::FourK), Seconds(5), Batch(10), ExpectedCredits(438)),
];
for (resolution, seconds, batch, expected) in cases {
@@ -1039,27 +835,15 @@ mod seedance_2p0_bpu_mini {
(Some(CommonResolution::FourEightyP), Seconds(6), Batch(2), ExpectedCredits(54)),
(Some(CommonResolution::FourEightyP), Seconds(7), Batch(3), ExpectedCredits(95)),
(Some(CommonResolution::FourEightyP), Seconds(9), Batch(4), ExpectedCredits(162)),
(Some(CommonResolution::FourEightyP), Seconds(11), Batch(5), ExpectedCredits(248)),
(Some(CommonResolution::FourEightyP), Seconds(13), Batch(8), ExpectedCredits(468)),
(Some(CommonResolution::FourEightyP), Seconds(15), Batch(10), ExpectedCredits(540)),
(Some(CommonResolution::SevenTwentyP), Seconds(6), Batch(2), ExpectedCredits(132)),
(Some(CommonResolution::SevenTwentyP), Seconds(7), Batch(3), ExpectedCredits(230)),
(Some(CommonResolution::SevenTwentyP), Seconds(9), Batch(4), ExpectedCredits(395)),
(Some(CommonResolution::SevenTwentyP), Seconds(11), Batch(5), ExpectedCredits(603)),
(Some(CommonResolution::SevenTwentyP), Seconds(13), Batch(8), ExpectedCredits(1139)),
(Some(CommonResolution::SevenTwentyP), Seconds(15), Batch(10), ExpectedCredits(1314)),
(Some(CommonResolution::TenEightyP), Seconds(6), Batch(2), ExpectedCredits(132)),
(Some(CommonResolution::TenEightyP), Seconds(7), Batch(3), ExpectedCredits(230)),
(Some(CommonResolution::TenEightyP), Seconds(9), Batch(4), ExpectedCredits(395)),
(Some(CommonResolution::TenEightyP), Seconds(11), Batch(5), ExpectedCredits(603)),
(Some(CommonResolution::TenEightyP), Seconds(13), Batch(8), ExpectedCredits(1139)),
(Some(CommonResolution::TenEightyP), Seconds(15), Batch(10), ExpectedCredits(1314)),
(Some(CommonResolution::FourK), Seconds(6), Batch(2), ExpectedCredits(132)),
(Some(CommonResolution::FourK), Seconds(7), Batch(3), ExpectedCredits(230)),
(Some(CommonResolution::FourK), Seconds(9), Batch(4), ExpectedCredits(395)),
(Some(CommonResolution::FourK), Seconds(11), Batch(5), ExpectedCredits(603)),
(Some(CommonResolution::FourK), Seconds(13), Batch(8), ExpectedCredits(1139)),
(Some(CommonResolution::FourK), Seconds(15), Batch(10), ExpectedCredits(1314)),
];
for (resolution, seconds, batch, expected) in cases {
@@ -1089,7 +873,6 @@ mod premium {
(Some(CommonResolution::SevenTwentyP), Seconds(10), Batch(1), ExpectedCredits(89), ExpectedCredits(91), CreditsDelta(2)),
(Some(CommonResolution::SevenTwentyP), Seconds(15), Batch(1), ExpectedCredits(134), ExpectedCredits(137), CreditsDelta(3)),
(Some(CommonResolution::FourEightyP), Seconds(15), Batch(1), ExpectedCredits(52), ExpectedCredits(54), CreditsDelta(2)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(8), ExpectedCredits(356), ExpectedCredits(364), CreditsDelta(8)),
];
for (resolution, seconds, batch, base, variant, delta) in cases {
@@ -1113,7 +896,6 @@ mod premium {
(Some(CommonResolution::SevenTwentyP), Seconds(10), Batch(1), ExpectedCredits(89), ExpectedCredits(91), CreditsDelta(2)),
(Some(CommonResolution::SevenTwentyP), Seconds(15), Batch(1), ExpectedCredits(134), ExpectedCredits(137), CreditsDelta(3)),
(Some(CommonResolution::FourEightyP), Seconds(15), Batch(1), ExpectedCredits(52), ExpectedCredits(54), CreditsDelta(2)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(8), ExpectedCredits(356), ExpectedCredits(364), CreditsDelta(8)),
];
for (resolution, seconds, batch, base, variant, delta) in cases {
@@ -91,8 +91,8 @@ mod seedance_2p0 {
}
}
/// Every batch size 1-10 at every resolution (5s).
/// Batches over the platform max of 4 downgrade to 4 (3 upgrades to 4); execution and billing agree.
/// Every supported batch size 1-4 at every resolution (5s).
/// Batches 1-4 are preserved; larger batches are rejected before billing.
#[tokio::test]
#[cfg_attr(feature = "skip_database_tests", ignore)]
async fn charges_every_batch_size_at_every_resolution() {
@@ -103,42 +103,18 @@ mod seedance_2p0 {
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(2), ExpectedCredits(78)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(3), ExpectedCredits(117)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(4), ExpectedCredits(155)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(5), ExpectedCredits(155)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(6), ExpectedCredits(155)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(7), ExpectedCredits(155)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(8), ExpectedCredits(155)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(9), ExpectedCredits(155)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(10), ExpectedCredits(155)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(1), ExpectedCredits(93)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(2), ExpectedCredits(185)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(3), ExpectedCredits(278)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(4), ExpectedCredits(370)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(5), ExpectedCredits(370)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(6), ExpectedCredits(370)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(7), ExpectedCredits(370)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(8), ExpectedCredits(370)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(9), ExpectedCredits(370)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(10), ExpectedCredits(370)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(1), ExpectedCredits(233)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(2), ExpectedCredits(466)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(3), ExpectedCredits(699)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(4), ExpectedCredits(933)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(5), ExpectedCredits(933)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(6), ExpectedCredits(933)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(7), ExpectedCredits(933)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(8), ExpectedCredits(933)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(9), ExpectedCredits(933)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(10), ExpectedCredits(933)),
(Some(CommonResolution::FourK), Seconds(5), Batch(1), ExpectedCredits(463)),
(Some(CommonResolution::FourK), Seconds(5), Batch(2), ExpectedCredits(925)),
(Some(CommonResolution::FourK), Seconds(5), Batch(3), ExpectedCredits(1388)),
(Some(CommonResolution::FourK), Seconds(5), Batch(4), ExpectedCredits(1850)),
(Some(CommonResolution::FourK), Seconds(5), Batch(5), ExpectedCredits(1850)),
(Some(CommonResolution::FourK), Seconds(5), Batch(6), ExpectedCredits(1850)),
(Some(CommonResolution::FourK), Seconds(5), Batch(7), ExpectedCredits(1850)),
(Some(CommonResolution::FourK), Seconds(5), Batch(8), ExpectedCredits(1850)),
(Some(CommonResolution::FourK), Seconds(5), Batch(9), ExpectedCredits(1850)),
(Some(CommonResolution::FourK), Seconds(5), Batch(10), ExpectedCredits(1850)),
];
for (resolution, seconds, batch, expected) in cases {
@@ -158,27 +134,15 @@ mod seedance_2p0 {
(Some(CommonResolution::FourEightyP), Seconds(6), Batch(2), ExpectedCredits(93)),
(Some(CommonResolution::FourEightyP), Seconds(7), Batch(3), ExpectedCredits(163)),
(Some(CommonResolution::FourEightyP), Seconds(9), Batch(4), ExpectedCredits(280)),
(Some(CommonResolution::FourEightyP), Seconds(11), Batch(5), ExpectedCredits(342)),
(Some(CommonResolution::FourEightyP), Seconds(13), Batch(8), ExpectedCredits(404)),
(Some(CommonResolution::FourEightyP), Seconds(15), Batch(10), ExpectedCredits(466)),
(Some(CommonResolution::SevenTwentyP), Seconds(6), Batch(2), ExpectedCredits(222)),
(Some(CommonResolution::SevenTwentyP), Seconds(7), Batch(3), ExpectedCredits(389)),
(Some(CommonResolution::SevenTwentyP), Seconds(9), Batch(4), ExpectedCredits(666)),
(Some(CommonResolution::SevenTwentyP), Seconds(11), Batch(5), ExpectedCredits(814)),
(Some(CommonResolution::SevenTwentyP), Seconds(13), Batch(8), ExpectedCredits(962)),
(Some(CommonResolution::SevenTwentyP), Seconds(15), Batch(10), ExpectedCredits(1110)),
(Some(CommonResolution::TenEightyP), Seconds(6), Batch(2), ExpectedCredits(560)),
(Some(CommonResolution::TenEightyP), Seconds(7), Batch(3), ExpectedCredits(979)),
(Some(CommonResolution::TenEightyP), Seconds(9), Batch(4), ExpectedCredits(1679)),
(Some(CommonResolution::TenEightyP), Seconds(11), Batch(5), ExpectedCredits(2052)),
(Some(CommonResolution::TenEightyP), Seconds(13), Batch(8), ExpectedCredits(2425)),
(Some(CommonResolution::TenEightyP), Seconds(15), Batch(10), ExpectedCredits(2798)),
(Some(CommonResolution::FourK), Seconds(6), Batch(2), ExpectedCredits(1110)),
(Some(CommonResolution::FourK), Seconds(7), Batch(3), ExpectedCredits(1943)),
(Some(CommonResolution::FourK), Seconds(9), Batch(4), ExpectedCredits(3330)),
(Some(CommonResolution::FourK), Seconds(11), Batch(5), ExpectedCredits(4070)),
(Some(CommonResolution::FourK), Seconds(13), Batch(8), ExpectedCredits(4810)),
(Some(CommonResolution::FourK), Seconds(15), Batch(10), ExpectedCredits(5550)),
];
for (resolution, seconds, batch, expected) in cases {
@@ -254,8 +218,8 @@ mod seedance_2p0 {
}
/// With video references Seedance 2.0 switches to its with-references rate card (480p 8.81, 720p 22.05, 1080p 51.10, 4K 113.80 cents/s, ceil-rounded). The unreachable fixture media fails the upload after billing, so the exact charge is asserted on the refunded ledger entry.
/// Every batch size 1-10 at every resolution (5s).
/// Batches over the platform max of 4 downgrade to 4 (3 upgrades to 4); execution and billing agree.
/// Every supported batch size 1-4 at every resolution (5s).
/// Batches 1-4 are preserved; larger batches are rejected before billing.
#[tokio::test]
#[cfg_attr(feature = "skip_database_tests", ignore)]
async fn video_references_charge_every_batch_size_at_every_resolution() {
@@ -266,42 +230,18 @@ mod seedance_2p0 {
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(2), ExpectedCredits(89)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(3), ExpectedCredits(133)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(4), ExpectedCredits(177)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(5), ExpectedCredits(177)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(6), ExpectedCredits(177)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(7), ExpectedCredits(177)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(8), ExpectedCredits(177)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(9), ExpectedCredits(177)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(10), ExpectedCredits(177)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(1), ExpectedCredits(111)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(2), ExpectedCredits(221)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(3), ExpectedCredits(331)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(4), ExpectedCredits(441)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(5), ExpectedCredits(441)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(6), ExpectedCredits(441)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(7), ExpectedCredits(441)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(8), ExpectedCredits(441)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(9), ExpectedCredits(441)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(10), ExpectedCredits(441)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(1), ExpectedCredits(256)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(2), ExpectedCredits(511)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(3), ExpectedCredits(767)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(4), ExpectedCredits(1022)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(5), ExpectedCredits(1022)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(6), ExpectedCredits(1022)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(7), ExpectedCredits(1022)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(8), ExpectedCredits(1022)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(9), ExpectedCredits(1022)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(10), ExpectedCredits(1022)),
(Some(CommonResolution::FourK), Seconds(5), Batch(1), ExpectedCredits(569)),
(Some(CommonResolution::FourK), Seconds(5), Batch(2), ExpectedCredits(1138)),
(Some(CommonResolution::FourK), Seconds(5), Batch(3), ExpectedCredits(1707)),
(Some(CommonResolution::FourK), Seconds(5), Batch(4), ExpectedCredits(2276)),
(Some(CommonResolution::FourK), Seconds(5), Batch(5), ExpectedCredits(2276)),
(Some(CommonResolution::FourK), Seconds(5), Batch(6), ExpectedCredits(2276)),
(Some(CommonResolution::FourK), Seconds(5), Batch(7), ExpectedCredits(2276)),
(Some(CommonResolution::FourK), Seconds(5), Batch(8), ExpectedCredits(2276)),
(Some(CommonResolution::FourK), Seconds(5), Batch(9), ExpectedCredits(2276)),
(Some(CommonResolution::FourK), Seconds(5), Batch(10), ExpectedCredits(2276)),
];
for (resolution, seconds, batch, expected) in cases {
@@ -338,27 +278,15 @@ mod seedance_2p0 {
(Some(CommonResolution::FourEightyP), Seconds(6), Batch(2), ExpectedCredits(106)),
(Some(CommonResolution::FourEightyP), Seconds(7), Batch(3), ExpectedCredits(186)),
(Some(CommonResolution::FourEightyP), Seconds(9), Batch(4), ExpectedCredits(318)),
(Some(CommonResolution::FourEightyP), Seconds(11), Batch(5), ExpectedCredits(388)),
(Some(CommonResolution::FourEightyP), Seconds(13), Batch(8), ExpectedCredits(459)),
(Some(CommonResolution::FourEightyP), Seconds(15), Batch(10), ExpectedCredits(529)),
(Some(CommonResolution::SevenTwentyP), Seconds(6), Batch(2), ExpectedCredits(265)),
(Some(CommonResolution::SevenTwentyP), Seconds(7), Batch(3), ExpectedCredits(464)),
(Some(CommonResolution::SevenTwentyP), Seconds(9), Batch(4), ExpectedCredits(794)),
(Some(CommonResolution::SevenTwentyP), Seconds(11), Batch(5), ExpectedCredits(971)),
(Some(CommonResolution::SevenTwentyP), Seconds(13), Batch(8), ExpectedCredits(1147)),
(Some(CommonResolution::SevenTwentyP), Seconds(15), Batch(10), ExpectedCredits(1323)),
(Some(CommonResolution::TenEightyP), Seconds(6), Batch(2), ExpectedCredits(614)),
(Some(CommonResolution::TenEightyP), Seconds(7), Batch(3), ExpectedCredits(1074)),
(Some(CommonResolution::TenEightyP), Seconds(9), Batch(4), ExpectedCredits(1840)),
(Some(CommonResolution::TenEightyP), Seconds(11), Batch(5), ExpectedCredits(2249)),
(Some(CommonResolution::TenEightyP), Seconds(13), Batch(8), ExpectedCredits(2658)),
(Some(CommonResolution::TenEightyP), Seconds(15), Batch(10), ExpectedCredits(3066)),
(Some(CommonResolution::FourK), Seconds(6), Batch(2), ExpectedCredits(1366)),
(Some(CommonResolution::FourK), Seconds(7), Batch(3), ExpectedCredits(2390)),
(Some(CommonResolution::FourK), Seconds(9), Batch(4), ExpectedCredits(4097)),
(Some(CommonResolution::FourK), Seconds(11), Batch(5), ExpectedCredits(5008)),
(Some(CommonResolution::FourK), Seconds(13), Batch(8), ExpectedCredits(5918)),
(Some(CommonResolution::FourK), Seconds(15), Batch(10), ExpectedCredits(6828)),
];
for (resolution, seconds, batch, expected) in cases {
@@ -463,8 +391,8 @@ mod seedance_2p0_bp {
}
}
/// Every batch size 1-10 at every resolution (5s).
/// Batches over the platform max of 4 downgrade to 4 (3 upgrades to 4); execution and billing agree.
/// Every supported batch size 1-4 at every resolution (5s).
/// Batches 1-4 are preserved; larger batches are rejected before billing.
#[tokio::test]
#[cfg_attr(feature = "skip_database_tests", ignore)]
async fn charges_every_batch_size_at_every_resolution() {
@@ -475,42 +403,18 @@ mod seedance_2p0_bp {
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(2), ExpectedCredits(100)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(3), ExpectedCredits(150)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(4), ExpectedCredits(200)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(5), ExpectedCredits(200)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(6), ExpectedCredits(200)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(7), ExpectedCredits(200)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(8), ExpectedCredits(200)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(9), ExpectedCredits(200)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(10), ExpectedCredits(200)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(1), ExpectedCredits(125)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(2), ExpectedCredits(250)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(3), ExpectedCredits(375)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(4), ExpectedCredits(500)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(5), ExpectedCredits(500)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(6), ExpectedCredits(500)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(7), ExpectedCredits(500)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(8), ExpectedCredits(500)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(9), ExpectedCredits(500)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(10), ExpectedCredits(500)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(1), ExpectedCredits(250)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(2), ExpectedCredits(500)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(3), ExpectedCredits(750)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(4), ExpectedCredits(1000)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(5), ExpectedCredits(1000)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(6), ExpectedCredits(1000)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(7), ExpectedCredits(1000)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(8), ExpectedCredits(1000)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(9), ExpectedCredits(1000)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(10), ExpectedCredits(1000)),
(Some(CommonResolution::FourK), Seconds(5), Batch(1), ExpectedCredits(475)),
(Some(CommonResolution::FourK), Seconds(5), Batch(2), ExpectedCredits(950)),
(Some(CommonResolution::FourK), Seconds(5), Batch(3), ExpectedCredits(1425)),
(Some(CommonResolution::FourK), Seconds(5), Batch(4), ExpectedCredits(1900)),
(Some(CommonResolution::FourK), Seconds(5), Batch(5), ExpectedCredits(1900)),
(Some(CommonResolution::FourK), Seconds(5), Batch(6), ExpectedCredits(1900)),
(Some(CommonResolution::FourK), Seconds(5), Batch(7), ExpectedCredits(1900)),
(Some(CommonResolution::FourK), Seconds(5), Batch(8), ExpectedCredits(1900)),
(Some(CommonResolution::FourK), Seconds(5), Batch(9), ExpectedCredits(1900)),
(Some(CommonResolution::FourK), Seconds(5), Batch(10), ExpectedCredits(1900)),
];
for (resolution, seconds, batch, expected) in cases {
@@ -530,27 +434,15 @@ mod seedance_2p0_bp {
(Some(CommonResolution::FourEightyP), Seconds(6), Batch(2), ExpectedCredits(120)),
(Some(CommonResolution::FourEightyP), Seconds(7), Batch(3), ExpectedCredits(210)),
(Some(CommonResolution::FourEightyP), Seconds(9), Batch(4), ExpectedCredits(360)),
(Some(CommonResolution::FourEightyP), Seconds(11), Batch(5), ExpectedCredits(440)),
(Some(CommonResolution::FourEightyP), Seconds(13), Batch(8), ExpectedCredits(520)),
(Some(CommonResolution::FourEightyP), Seconds(15), Batch(10), ExpectedCredits(600)),
(Some(CommonResolution::SevenTwentyP), Seconds(6), Batch(2), ExpectedCredits(300)),
(Some(CommonResolution::SevenTwentyP), Seconds(7), Batch(3), ExpectedCredits(525)),
(Some(CommonResolution::SevenTwentyP), Seconds(9), Batch(4), ExpectedCredits(900)),
(Some(CommonResolution::SevenTwentyP), Seconds(11), Batch(5), ExpectedCredits(1100)),
(Some(CommonResolution::SevenTwentyP), Seconds(13), Batch(8), ExpectedCredits(1300)),
(Some(CommonResolution::SevenTwentyP), Seconds(15), Batch(10), ExpectedCredits(1500)),
(Some(CommonResolution::TenEightyP), Seconds(6), Batch(2), ExpectedCredits(600)),
(Some(CommonResolution::TenEightyP), Seconds(7), Batch(3), ExpectedCredits(1050)),
(Some(CommonResolution::TenEightyP), Seconds(9), Batch(4), ExpectedCredits(1800)),
(Some(CommonResolution::TenEightyP), Seconds(11), Batch(5), ExpectedCredits(2200)),
(Some(CommonResolution::TenEightyP), Seconds(13), Batch(8), ExpectedCredits(2600)),
(Some(CommonResolution::TenEightyP), Seconds(15), Batch(10), ExpectedCredits(3000)),
(Some(CommonResolution::FourK), Seconds(6), Batch(2), ExpectedCredits(1140)),
(Some(CommonResolution::FourK), Seconds(7), Batch(3), ExpectedCredits(1995)),
(Some(CommonResolution::FourK), Seconds(9), Batch(4), ExpectedCredits(3420)),
(Some(CommonResolution::FourK), Seconds(11), Batch(5), ExpectedCredits(4180)),
(Some(CommonResolution::FourK), Seconds(13), Batch(8), ExpectedCredits(4940)),
(Some(CommonResolution::FourK), Seconds(15), Batch(10), ExpectedCredits(5700)),
];
for (resolution, seconds, batch, expected) in cases {
@@ -626,8 +518,8 @@ mod seedance_2p0_bp {
}
/// The BytePlus rate cards are flat below 4K: reference videos only change the 4K price (a 23.00 cents/s surcharge). Charges are asserted on the refunded ledger entry (the fixture media is unreachable).
/// Every batch size 1-10 at every resolution (5s).
/// Batches over the platform max of 4 downgrade to 4 (3 upgrades to 4); execution and billing agree.
/// Every supported batch size 1-4 at every resolution (5s).
/// Batches 1-4 are preserved; larger batches are rejected before billing.
#[tokio::test]
#[cfg_attr(feature = "skip_database_tests", ignore)]
async fn video_references_charge_every_batch_size_at_every_resolution() {
@@ -638,42 +530,18 @@ mod seedance_2p0_bp {
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(2), ExpectedCredits(102)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(3), ExpectedCredits(153)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(4), ExpectedCredits(204)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(5), ExpectedCredits(204)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(6), ExpectedCredits(204)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(7), ExpectedCredits(204)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(8), ExpectedCredits(204)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(9), ExpectedCredits(204)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(10), ExpectedCredits(204)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(1), ExpectedCredits(129)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(2), ExpectedCredits(257)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(3), ExpectedCredits(386)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(4), ExpectedCredits(514)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(5), ExpectedCredits(514)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(6), ExpectedCredits(514)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(7), ExpectedCredits(514)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(8), ExpectedCredits(514)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(9), ExpectedCredits(514)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(10), ExpectedCredits(514)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(1), ExpectedCredits(289)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(2), ExpectedCredits(578)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(3), ExpectedCredits(867)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(4), ExpectedCredits(1156)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(5), ExpectedCredits(1156)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(6), ExpectedCredits(1156)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(7), ExpectedCredits(1156)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(8), ExpectedCredits(1156)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(9), ExpectedCredits(1156)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(10), ExpectedCredits(1156)),
(Some(CommonResolution::FourK), Seconds(5), Batch(1), ExpectedCredits(595)),
(Some(CommonResolution::FourK), Seconds(5), Batch(2), ExpectedCredits(1190)),
(Some(CommonResolution::FourK), Seconds(5), Batch(3), ExpectedCredits(1785)),
(Some(CommonResolution::FourK), Seconds(5), Batch(4), ExpectedCredits(2380)),
(Some(CommonResolution::FourK), Seconds(5), Batch(5), ExpectedCredits(2380)),
(Some(CommonResolution::FourK), Seconds(5), Batch(6), ExpectedCredits(2380)),
(Some(CommonResolution::FourK), Seconds(5), Batch(7), ExpectedCredits(2380)),
(Some(CommonResolution::FourK), Seconds(5), Batch(8), ExpectedCredits(2380)),
(Some(CommonResolution::FourK), Seconds(5), Batch(9), ExpectedCredits(2380)),
(Some(CommonResolution::FourK), Seconds(5), Batch(10), ExpectedCredits(2380)),
];
for (resolution, seconds, batch, expected) in cases {
@@ -710,27 +578,15 @@ mod seedance_2p0_bp {
(Some(CommonResolution::FourEightyP), Seconds(6), Batch(2), ExpectedCredits(123)),
(Some(CommonResolution::FourEightyP), Seconds(7), Batch(3), ExpectedCredits(215)),
(Some(CommonResolution::FourEightyP), Seconds(9), Batch(4), ExpectedCredits(368)),
(Some(CommonResolution::FourEightyP), Seconds(11), Batch(5), ExpectedCredits(449)),
(Some(CommonResolution::FourEightyP), Seconds(13), Batch(8), ExpectedCredits(531)),
(Some(CommonResolution::FourEightyP), Seconds(15), Batch(10), ExpectedCredits(612)),
(Some(CommonResolution::SevenTwentyP), Seconds(6), Batch(2), ExpectedCredits(309)),
(Some(CommonResolution::SevenTwentyP), Seconds(7), Batch(3), ExpectedCredits(540)),
(Some(CommonResolution::SevenTwentyP), Seconds(9), Batch(4), ExpectedCredits(926)),
(Some(CommonResolution::SevenTwentyP), Seconds(11), Batch(5), ExpectedCredits(1131)),
(Some(CommonResolution::SevenTwentyP), Seconds(13), Batch(8), ExpectedCredits(1337)),
(Some(CommonResolution::SevenTwentyP), Seconds(15), Batch(10), ExpectedCredits(1542)),
(Some(CommonResolution::TenEightyP), Seconds(6), Batch(2), ExpectedCredits(694)),
(Some(CommonResolution::TenEightyP), Seconds(7), Batch(3), ExpectedCredits(1214)),
(Some(CommonResolution::TenEightyP), Seconds(9), Batch(4), ExpectedCredits(2081)),
(Some(CommonResolution::TenEightyP), Seconds(11), Batch(5), ExpectedCredits(2544)),
(Some(CommonResolution::TenEightyP), Seconds(13), Batch(8), ExpectedCredits(3006)),
(Some(CommonResolution::TenEightyP), Seconds(15), Batch(10), ExpectedCredits(3468)),
(Some(CommonResolution::FourK), Seconds(6), Batch(2), ExpectedCredits(1428)),
(Some(CommonResolution::FourK), Seconds(7), Batch(3), ExpectedCredits(2499)),
(Some(CommonResolution::FourK), Seconds(9), Batch(4), ExpectedCredits(4284)),
(Some(CommonResolution::FourK), Seconds(11), Batch(5), ExpectedCredits(5236)),
(Some(CommonResolution::FourK), Seconds(13), Batch(8), ExpectedCredits(6188)),
(Some(CommonResolution::FourK), Seconds(15), Batch(10), ExpectedCredits(7140)),
];
for (resolution, seconds, batch, expected) in cases {
@@ -828,8 +684,8 @@ mod seedance_2p0_bpu {
}
}
/// Every batch size 1-10 at every resolution (5s).
/// Batches over the platform max of 4 downgrade to 4 (3 upgrades to 4); execution and billing agree.
/// Every supported batch size 1-4 at every resolution (5s).
/// Batches 1-4 are preserved; larger batches are rejected before billing.
#[tokio::test]
#[cfg_attr(feature = "skip_database_tests", ignore)]
async fn charges_every_batch_size_at_every_resolution() {
@@ -840,42 +696,18 @@ mod seedance_2p0_bpu {
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(2), ExpectedCredits(100)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(3), ExpectedCredits(150)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(4), ExpectedCredits(200)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(5), ExpectedCredits(200)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(6), ExpectedCredits(200)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(7), ExpectedCredits(200)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(8), ExpectedCredits(200)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(9), ExpectedCredits(200)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(10), ExpectedCredits(200)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(1), ExpectedCredits(125)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(2), ExpectedCredits(250)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(3), ExpectedCredits(375)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(4), ExpectedCredits(500)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(5), ExpectedCredits(500)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(6), ExpectedCredits(500)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(7), ExpectedCredits(500)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(8), ExpectedCredits(500)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(9), ExpectedCredits(500)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(10), ExpectedCredits(500)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(1), ExpectedCredits(250)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(2), ExpectedCredits(500)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(3), ExpectedCredits(750)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(4), ExpectedCredits(1000)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(5), ExpectedCredits(1000)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(6), ExpectedCredits(1000)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(7), ExpectedCredits(1000)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(8), ExpectedCredits(1000)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(9), ExpectedCredits(1000)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(10), ExpectedCredits(1000)),
(Some(CommonResolution::FourK), Seconds(5), Batch(1), ExpectedCredits(475)),
(Some(CommonResolution::FourK), Seconds(5), Batch(2), ExpectedCredits(950)),
(Some(CommonResolution::FourK), Seconds(5), Batch(3), ExpectedCredits(1425)),
(Some(CommonResolution::FourK), Seconds(5), Batch(4), ExpectedCredits(1900)),
(Some(CommonResolution::FourK), Seconds(5), Batch(5), ExpectedCredits(1900)),
(Some(CommonResolution::FourK), Seconds(5), Batch(6), ExpectedCredits(1900)),
(Some(CommonResolution::FourK), Seconds(5), Batch(7), ExpectedCredits(1900)),
(Some(CommonResolution::FourK), Seconds(5), Batch(8), ExpectedCredits(1900)),
(Some(CommonResolution::FourK), Seconds(5), Batch(9), ExpectedCredits(1900)),
(Some(CommonResolution::FourK), Seconds(5), Batch(10), ExpectedCredits(1900)),
];
for (resolution, seconds, batch, expected) in cases {
@@ -895,27 +727,15 @@ mod seedance_2p0_bpu {
(Some(CommonResolution::FourEightyP), Seconds(6), Batch(2), ExpectedCredits(120)),
(Some(CommonResolution::FourEightyP), Seconds(7), Batch(3), ExpectedCredits(210)),
(Some(CommonResolution::FourEightyP), Seconds(9), Batch(4), ExpectedCredits(360)),
(Some(CommonResolution::FourEightyP), Seconds(11), Batch(5), ExpectedCredits(440)),
(Some(CommonResolution::FourEightyP), Seconds(13), Batch(8), ExpectedCredits(520)),
(Some(CommonResolution::FourEightyP), Seconds(15), Batch(10), ExpectedCredits(600)),
(Some(CommonResolution::SevenTwentyP), Seconds(6), Batch(2), ExpectedCredits(300)),
(Some(CommonResolution::SevenTwentyP), Seconds(7), Batch(3), ExpectedCredits(525)),
(Some(CommonResolution::SevenTwentyP), Seconds(9), Batch(4), ExpectedCredits(900)),
(Some(CommonResolution::SevenTwentyP), Seconds(11), Batch(5), ExpectedCredits(1100)),
(Some(CommonResolution::SevenTwentyP), Seconds(13), Batch(8), ExpectedCredits(1300)),
(Some(CommonResolution::SevenTwentyP), Seconds(15), Batch(10), ExpectedCredits(1500)),
(Some(CommonResolution::TenEightyP), Seconds(6), Batch(2), ExpectedCredits(600)),
(Some(CommonResolution::TenEightyP), Seconds(7), Batch(3), ExpectedCredits(1050)),
(Some(CommonResolution::TenEightyP), Seconds(9), Batch(4), ExpectedCredits(1800)),
(Some(CommonResolution::TenEightyP), Seconds(11), Batch(5), ExpectedCredits(2200)),
(Some(CommonResolution::TenEightyP), Seconds(13), Batch(8), ExpectedCredits(2600)),
(Some(CommonResolution::TenEightyP), Seconds(15), Batch(10), ExpectedCredits(3000)),
(Some(CommonResolution::FourK), Seconds(6), Batch(2), ExpectedCredits(1140)),
(Some(CommonResolution::FourK), Seconds(7), Batch(3), ExpectedCredits(1995)),
(Some(CommonResolution::FourK), Seconds(9), Batch(4), ExpectedCredits(3420)),
(Some(CommonResolution::FourK), Seconds(11), Batch(5), ExpectedCredits(4180)),
(Some(CommonResolution::FourK), Seconds(13), Batch(8), ExpectedCredits(4940)),
(Some(CommonResolution::FourK), Seconds(15), Batch(10), ExpectedCredits(5700)),
];
for (resolution, seconds, batch, expected) in cases {
@@ -991,8 +811,8 @@ mod seedance_2p0_bpu {
}
/// The BytePlus rate cards are flat below 4K: reference videos only change the 4K price (a 23.00 cents/s surcharge). Charges are asserted on the refunded ledger entry (the fixture media is unreachable).
/// Every batch size 1-10 at every resolution (5s).
/// Batches over the platform max of 4 downgrade to 4 (3 upgrades to 4); execution and billing agree.
/// Every supported batch size 1-4 at every resolution (5s).
/// Batches 1-4 are preserved; larger batches are rejected before billing.
#[tokio::test]
#[cfg_attr(feature = "skip_database_tests", ignore)]
async fn video_references_charge_every_batch_size_at_every_resolution() {
@@ -1003,42 +823,18 @@ mod seedance_2p0_bpu {
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(2), ExpectedCredits(102)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(3), ExpectedCredits(153)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(4), ExpectedCredits(204)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(5), ExpectedCredits(204)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(6), ExpectedCredits(204)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(7), ExpectedCredits(204)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(8), ExpectedCredits(204)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(9), ExpectedCredits(204)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(10), ExpectedCredits(204)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(1), ExpectedCredits(129)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(2), ExpectedCredits(257)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(3), ExpectedCredits(386)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(4), ExpectedCredits(514)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(5), ExpectedCredits(514)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(6), ExpectedCredits(514)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(7), ExpectedCredits(514)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(8), ExpectedCredits(514)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(9), ExpectedCredits(514)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(10), ExpectedCredits(514)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(1), ExpectedCredits(289)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(2), ExpectedCredits(578)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(3), ExpectedCredits(867)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(4), ExpectedCredits(1156)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(5), ExpectedCredits(1156)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(6), ExpectedCredits(1156)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(7), ExpectedCredits(1156)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(8), ExpectedCredits(1156)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(9), ExpectedCredits(1156)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(10), ExpectedCredits(1156)),
(Some(CommonResolution::FourK), Seconds(5), Batch(1), ExpectedCredits(595)),
(Some(CommonResolution::FourK), Seconds(5), Batch(2), ExpectedCredits(1190)),
(Some(CommonResolution::FourK), Seconds(5), Batch(3), ExpectedCredits(1785)),
(Some(CommonResolution::FourK), Seconds(5), Batch(4), ExpectedCredits(2380)),
(Some(CommonResolution::FourK), Seconds(5), Batch(5), ExpectedCredits(2380)),
(Some(CommonResolution::FourK), Seconds(5), Batch(6), ExpectedCredits(2380)),
(Some(CommonResolution::FourK), Seconds(5), Batch(7), ExpectedCredits(2380)),
(Some(CommonResolution::FourK), Seconds(5), Batch(8), ExpectedCredits(2380)),
(Some(CommonResolution::FourK), Seconds(5), Batch(9), ExpectedCredits(2380)),
(Some(CommonResolution::FourK), Seconds(5), Batch(10), ExpectedCredits(2380)),
];
for (resolution, seconds, batch, expected) in cases {
@@ -1075,27 +871,15 @@ mod seedance_2p0_bpu {
(Some(CommonResolution::FourEightyP), Seconds(6), Batch(2), ExpectedCredits(123)),
(Some(CommonResolution::FourEightyP), Seconds(7), Batch(3), ExpectedCredits(215)),
(Some(CommonResolution::FourEightyP), Seconds(9), Batch(4), ExpectedCredits(368)),
(Some(CommonResolution::FourEightyP), Seconds(11), Batch(5), ExpectedCredits(449)),
(Some(CommonResolution::FourEightyP), Seconds(13), Batch(8), ExpectedCredits(531)),
(Some(CommonResolution::FourEightyP), Seconds(15), Batch(10), ExpectedCredits(612)),
(Some(CommonResolution::SevenTwentyP), Seconds(6), Batch(2), ExpectedCredits(309)),
(Some(CommonResolution::SevenTwentyP), Seconds(7), Batch(3), ExpectedCredits(540)),
(Some(CommonResolution::SevenTwentyP), Seconds(9), Batch(4), ExpectedCredits(926)),
(Some(CommonResolution::SevenTwentyP), Seconds(11), Batch(5), ExpectedCredits(1131)),
(Some(CommonResolution::SevenTwentyP), Seconds(13), Batch(8), ExpectedCredits(1337)),
(Some(CommonResolution::SevenTwentyP), Seconds(15), Batch(10), ExpectedCredits(1542)),
(Some(CommonResolution::TenEightyP), Seconds(6), Batch(2), ExpectedCredits(694)),
(Some(CommonResolution::TenEightyP), Seconds(7), Batch(3), ExpectedCredits(1214)),
(Some(CommonResolution::TenEightyP), Seconds(9), Batch(4), ExpectedCredits(2081)),
(Some(CommonResolution::TenEightyP), Seconds(11), Batch(5), ExpectedCredits(2544)),
(Some(CommonResolution::TenEightyP), Seconds(13), Batch(8), ExpectedCredits(3006)),
(Some(CommonResolution::TenEightyP), Seconds(15), Batch(10), ExpectedCredits(3468)),
(Some(CommonResolution::FourK), Seconds(6), Batch(2), ExpectedCredits(1428)),
(Some(CommonResolution::FourK), Seconds(7), Batch(3), ExpectedCredits(2499)),
(Some(CommonResolution::FourK), Seconds(9), Batch(4), ExpectedCredits(4284)),
(Some(CommonResolution::FourK), Seconds(11), Batch(5), ExpectedCredits(5236)),
(Some(CommonResolution::FourK), Seconds(13), Batch(8), ExpectedCredits(6188)),
(Some(CommonResolution::FourK), Seconds(15), Batch(10), ExpectedCredits(7140)),
];
for (resolution, seconds, batch, expected) in cases {
@@ -1146,7 +930,6 @@ mod premium {
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(4), ExpectedCredits(370), ExpectedCredits(500), CreditsDelta(130)),
// Batch 8 is accepted but downgrades to the platform max of 4 on both
// execution and billing, so it prices identically to batch 4.
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(8), ExpectedCredits(370), ExpectedCredits(500), CreditsDelta(130)),
// 4K premium: the base tier prices at 92.50 cents/s while the BytePlus
// tiers price at 95.0 cents/s.
(Some(CommonResolution::FourK), Seconds(5), Batch(1), ExpectedCredits(463), ExpectedCredits(475), CreditsDelta(12)),
@@ -1177,7 +960,6 @@ mod premium {
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(4), ExpectedCredits(370), ExpectedCredits(500), CreditsDelta(130)),
// Batch 8 is accepted but downgrades to the platform max of 4 on both
// execution and billing, so it prices identically to batch 4.
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(8), ExpectedCredits(370), ExpectedCredits(500), CreditsDelta(130)),
// 4K premium: the base tier prices at 92.50 cents/s while the BytePlus
// tiers price at 95.0 cents/s.
(Some(CommonResolution::FourK), Seconds(5), Batch(1), ExpectedCredits(463), ExpectedCredits(475), CreditsDelta(12)),
@@ -4,16 +4,15 @@
//!
//! Rate cards (ceil-rounded once):
//! - Seedance 2.5: 480p 11.76954733 ¢/s, 720p 26.70781893 ¢/s,
//! 1080p 45.85869386 ¢/s; with video refs 480p 7.24279835 ¢/s,
//! 720p 15.84362140 ¢/s, 1080p 27.39973680 ¢/s over (output + input)
//! 1080p 61.62732357 ¢/s; with video refs 480p 7.24279835 ¢/s,
//! 720p 15.84362140 ¢/s, 1080p 38.49728574 ¢/s over (output + input)
//! seconds
//! - Seedance 2.5 Ultra: 480p 13.90946502 ¢/s, 720p 31.56378601 ¢/s,
//! 1080p 50.10486922 ¢/s; with video refs 480p 8.55967078 ¢/s,
//! 720p 18.72427984 ¢/s, 1080p 29.93674947 ¢/s
//! 1080p 67.22980753 ¢/s; with video refs 480p 8.55967078 ¢/s,
//! 720p 18.72427984 ¢/s, 1080p 41.84487580 ¢/s
//!
//! Both models offer 480p/720p/1080p (4K downgrades to 1080p), durations
//! run 4-30s, and every request generates a single video regardless of the
//! requested batch size.
//! run 4-30s, and batches of 1-4 bill the full batch.
use enums::common::generation::common_resolution::CommonResolution;
use enums::common::generation::common_video_model::CommonVideoModel;
@@ -90,60 +89,60 @@ mod seedance_2p5 {
(Some(CommonResolution::SevenTwentyP), Seconds(28), Batch(1), ExpectedCredits(748)),
(Some(CommonResolution::SevenTwentyP), Seconds(29), Batch(1), ExpectedCredits(775)),
(Some(CommonResolution::SevenTwentyP), Seconds(30), Batch(1), ExpectedCredits(802)),
(Some(CommonResolution::TenEightyP), Seconds(4), Batch(1), ExpectedCredits(184)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(1), ExpectedCredits(230)),
(Some(CommonResolution::TenEightyP), Seconds(6), Batch(1), ExpectedCredits(276)),
(Some(CommonResolution::TenEightyP), Seconds(7), Batch(1), ExpectedCredits(322)),
(Some(CommonResolution::TenEightyP), Seconds(8), Batch(1), ExpectedCredits(367)),
(Some(CommonResolution::TenEightyP), Seconds(9), Batch(1), ExpectedCredits(413)),
(Some(CommonResolution::TenEightyP), Seconds(10), Batch(1), ExpectedCredits(459)),
(Some(CommonResolution::TenEightyP), Seconds(11), Batch(1), ExpectedCredits(505)),
(Some(CommonResolution::TenEightyP), Seconds(12), Batch(1), ExpectedCredits(551)),
(Some(CommonResolution::TenEightyP), Seconds(13), Batch(1), ExpectedCredits(597)),
(Some(CommonResolution::TenEightyP), Seconds(14), Batch(1), ExpectedCredits(643)),
(Some(CommonResolution::TenEightyP), Seconds(15), Batch(1), ExpectedCredits(688)),
(Some(CommonResolution::TenEightyP), Seconds(16), Batch(1), ExpectedCredits(734)),
(Some(CommonResolution::TenEightyP), Seconds(17), Batch(1), ExpectedCredits(780)),
(Some(CommonResolution::TenEightyP), Seconds(18), Batch(1), ExpectedCredits(826)),
(Some(CommonResolution::TenEightyP), Seconds(19), Batch(1), ExpectedCredits(872)),
(Some(CommonResolution::TenEightyP), Seconds(20), Batch(1), ExpectedCredits(918)),
(Some(CommonResolution::TenEightyP), Seconds(21), Batch(1), ExpectedCredits(964)),
(Some(CommonResolution::TenEightyP), Seconds(22), Batch(1), ExpectedCredits(1009)),
(Some(CommonResolution::TenEightyP), Seconds(23), Batch(1), ExpectedCredits(1055)),
(Some(CommonResolution::TenEightyP), Seconds(24), Batch(1), ExpectedCredits(1101)),
(Some(CommonResolution::TenEightyP), Seconds(25), Batch(1), ExpectedCredits(1147)),
(Some(CommonResolution::TenEightyP), Seconds(26), Batch(1), ExpectedCredits(1193)),
(Some(CommonResolution::TenEightyP), Seconds(27), Batch(1), ExpectedCredits(1239)),
(Some(CommonResolution::TenEightyP), Seconds(28), Batch(1), ExpectedCredits(1285)),
(Some(CommonResolution::TenEightyP), Seconds(29), Batch(1), ExpectedCredits(1330)),
(Some(CommonResolution::TenEightyP), Seconds(30), Batch(1), ExpectedCredits(1376)),
(Some(CommonResolution::FourK), Seconds(4), Batch(1), ExpectedCredits(184)),
(Some(CommonResolution::FourK), Seconds(5), Batch(1), ExpectedCredits(230)),
(Some(CommonResolution::FourK), Seconds(6), Batch(1), ExpectedCredits(276)),
(Some(CommonResolution::FourK), Seconds(7), Batch(1), ExpectedCredits(322)),
(Some(CommonResolution::FourK), Seconds(8), Batch(1), ExpectedCredits(367)),
(Some(CommonResolution::FourK), Seconds(9), Batch(1), ExpectedCredits(413)),
(Some(CommonResolution::FourK), Seconds(10), Batch(1), ExpectedCredits(459)),
(Some(CommonResolution::FourK), Seconds(11), Batch(1), ExpectedCredits(505)),
(Some(CommonResolution::FourK), Seconds(12), Batch(1), ExpectedCredits(551)),
(Some(CommonResolution::FourK), Seconds(13), Batch(1), ExpectedCredits(597)),
(Some(CommonResolution::FourK), Seconds(14), Batch(1), ExpectedCredits(643)),
(Some(CommonResolution::FourK), Seconds(15), Batch(1), ExpectedCredits(688)),
(Some(CommonResolution::FourK), Seconds(16), Batch(1), ExpectedCredits(734)),
(Some(CommonResolution::FourK), Seconds(17), Batch(1), ExpectedCredits(780)),
(Some(CommonResolution::FourK), Seconds(18), Batch(1), ExpectedCredits(826)),
(Some(CommonResolution::FourK), Seconds(19), Batch(1), ExpectedCredits(872)),
(Some(CommonResolution::FourK), Seconds(20), Batch(1), ExpectedCredits(918)),
(Some(CommonResolution::FourK), Seconds(21), Batch(1), ExpectedCredits(964)),
(Some(CommonResolution::FourK), Seconds(22), Batch(1), ExpectedCredits(1009)),
(Some(CommonResolution::FourK), Seconds(23), Batch(1), ExpectedCredits(1055)),
(Some(CommonResolution::FourK), Seconds(24), Batch(1), ExpectedCredits(1101)),
(Some(CommonResolution::FourK), Seconds(25), Batch(1), ExpectedCredits(1147)),
(Some(CommonResolution::FourK), Seconds(26), Batch(1), ExpectedCredits(1193)),
(Some(CommonResolution::FourK), Seconds(27), Batch(1), ExpectedCredits(1239)),
(Some(CommonResolution::FourK), Seconds(28), Batch(1), ExpectedCredits(1285)),
(Some(CommonResolution::FourK), Seconds(29), Batch(1), ExpectedCredits(1330)),
(Some(CommonResolution::FourK), Seconds(30), Batch(1), ExpectedCredits(1376)),
(Some(CommonResolution::TenEightyP), Seconds(4), Batch(1), ExpectedCredits(247)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(1), ExpectedCredits(309)),
(Some(CommonResolution::TenEightyP), Seconds(6), Batch(1), ExpectedCredits(370)),
(Some(CommonResolution::TenEightyP), Seconds(7), Batch(1), ExpectedCredits(432)),
(Some(CommonResolution::TenEightyP), Seconds(8), Batch(1), ExpectedCredits(494)),
(Some(CommonResolution::TenEightyP), Seconds(9), Batch(1), ExpectedCredits(555)),
(Some(CommonResolution::TenEightyP), Seconds(10), Batch(1), ExpectedCredits(617)),
(Some(CommonResolution::TenEightyP), Seconds(11), Batch(1), ExpectedCredits(678)),
(Some(CommonResolution::TenEightyP), Seconds(12), Batch(1), ExpectedCredits(740)),
(Some(CommonResolution::TenEightyP), Seconds(13), Batch(1), ExpectedCredits(802)),
(Some(CommonResolution::TenEightyP), Seconds(14), Batch(1), ExpectedCredits(863)),
(Some(CommonResolution::TenEightyP), Seconds(15), Batch(1), ExpectedCredits(925)),
(Some(CommonResolution::TenEightyP), Seconds(16), Batch(1), ExpectedCredits(987)),
(Some(CommonResolution::TenEightyP), Seconds(17), Batch(1), ExpectedCredits(1048)),
(Some(CommonResolution::TenEightyP), Seconds(18), Batch(1), ExpectedCredits(1110)),
(Some(CommonResolution::TenEightyP), Seconds(19), Batch(1), ExpectedCredits(1171)),
(Some(CommonResolution::TenEightyP), Seconds(20), Batch(1), ExpectedCredits(1233)),
(Some(CommonResolution::TenEightyP), Seconds(21), Batch(1), ExpectedCredits(1295)),
(Some(CommonResolution::TenEightyP), Seconds(22), Batch(1), ExpectedCredits(1356)),
(Some(CommonResolution::TenEightyP), Seconds(23), Batch(1), ExpectedCredits(1418)),
(Some(CommonResolution::TenEightyP), Seconds(24), Batch(1), ExpectedCredits(1480)),
(Some(CommonResolution::TenEightyP), Seconds(25), Batch(1), ExpectedCredits(1541)),
(Some(CommonResolution::TenEightyP), Seconds(26), Batch(1), ExpectedCredits(1603)),
(Some(CommonResolution::TenEightyP), Seconds(27), Batch(1), ExpectedCredits(1664)),
(Some(CommonResolution::TenEightyP), Seconds(28), Batch(1), ExpectedCredits(1726)),
(Some(CommonResolution::TenEightyP), Seconds(29), Batch(1), ExpectedCredits(1788)),
(Some(CommonResolution::TenEightyP), Seconds(30), Batch(1), ExpectedCredits(1849)),
(Some(CommonResolution::FourK), Seconds(4), Batch(1), ExpectedCredits(247)),
(Some(CommonResolution::FourK), Seconds(5), Batch(1), ExpectedCredits(309)),
(Some(CommonResolution::FourK), Seconds(6), Batch(1), ExpectedCredits(370)),
(Some(CommonResolution::FourK), Seconds(7), Batch(1), ExpectedCredits(432)),
(Some(CommonResolution::FourK), Seconds(8), Batch(1), ExpectedCredits(494)),
(Some(CommonResolution::FourK), Seconds(9), Batch(1), ExpectedCredits(555)),
(Some(CommonResolution::FourK), Seconds(10), Batch(1), ExpectedCredits(617)),
(Some(CommonResolution::FourK), Seconds(11), Batch(1), ExpectedCredits(678)),
(Some(CommonResolution::FourK), Seconds(12), Batch(1), ExpectedCredits(740)),
(Some(CommonResolution::FourK), Seconds(13), Batch(1), ExpectedCredits(802)),
(Some(CommonResolution::FourK), Seconds(14), Batch(1), ExpectedCredits(863)),
(Some(CommonResolution::FourK), Seconds(15), Batch(1), ExpectedCredits(925)),
(Some(CommonResolution::FourK), Seconds(16), Batch(1), ExpectedCredits(987)),
(Some(CommonResolution::FourK), Seconds(17), Batch(1), ExpectedCredits(1048)),
(Some(CommonResolution::FourK), Seconds(18), Batch(1), ExpectedCredits(1110)),
(Some(CommonResolution::FourK), Seconds(19), Batch(1), ExpectedCredits(1171)),
(Some(CommonResolution::FourK), Seconds(20), Batch(1), ExpectedCredits(1233)),
(Some(CommonResolution::FourK), Seconds(21), Batch(1), ExpectedCredits(1295)),
(Some(CommonResolution::FourK), Seconds(22), Batch(1), ExpectedCredits(1356)),
(Some(CommonResolution::FourK), Seconds(23), Batch(1), ExpectedCredits(1418)),
(Some(CommonResolution::FourK), Seconds(24), Batch(1), ExpectedCredits(1480)),
(Some(CommonResolution::FourK), Seconds(25), Batch(1), ExpectedCredits(1541)),
(Some(CommonResolution::FourK), Seconds(26), Batch(1), ExpectedCredits(1603)),
(Some(CommonResolution::FourK), Seconds(27), Batch(1), ExpectedCredits(1664)),
(Some(CommonResolution::FourK), Seconds(28), Batch(1), ExpectedCredits(1726)),
(Some(CommonResolution::FourK), Seconds(29), Batch(1), ExpectedCredits(1788)),
(Some(CommonResolution::FourK), Seconds(30), Batch(1), ExpectedCredits(1849)),
];
for (resolution, seconds, batch, expected) in cases {
@@ -153,8 +152,8 @@ mod seedance_2p5 {
}
}
/// Every batch size 1-10 at every resolution (5s).
/// Seedance 2.5 generates a single video per request: every requested batch size 1-10 bills exactly one video.
/// Every supported batch size 1-4 at every resolution (5s).
/// Seedance 2.5 bills every video in the batch, rounding once after multiplying.
/// Seedance 2.5 offers 480p, 720p, and 1080p; 4K downgrades to 1080p (and prices accordingly).
#[tokio::test]
#[cfg_attr(feature = "skip_database_tests", ignore)]
@@ -163,45 +162,21 @@ mod seedance_2p5 {
let cases: &[(Option<CommonResolution>, Seconds, Batch, ExpectedCredits)] = &[
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(1), ExpectedCredits(59)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(2), ExpectedCredits(59)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(3), ExpectedCredits(59)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(4), ExpectedCredits(59)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(5), ExpectedCredits(59)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(6), ExpectedCredits(59)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(7), ExpectedCredits(59)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(8), ExpectedCredits(59)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(9), ExpectedCredits(59)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(10), ExpectedCredits(59)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(2), ExpectedCredits(118)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(3), ExpectedCredits(177)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(4), ExpectedCredits(236)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(1), ExpectedCredits(134)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(2), ExpectedCredits(134)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(3), ExpectedCredits(134)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(4), ExpectedCredits(134)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(5), ExpectedCredits(134)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(6), ExpectedCredits(134)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(7), ExpectedCredits(134)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(8), ExpectedCredits(134)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(9), ExpectedCredits(134)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(10), ExpectedCredits(134)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(1), ExpectedCredits(230)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(2), ExpectedCredits(230)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(3), ExpectedCredits(230)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(4), ExpectedCredits(230)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(5), ExpectedCredits(230)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(6), ExpectedCredits(230)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(7), ExpectedCredits(230)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(8), ExpectedCredits(230)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(9), ExpectedCredits(230)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(10), ExpectedCredits(230)),
(Some(CommonResolution::FourK), Seconds(5), Batch(1), ExpectedCredits(230)),
(Some(CommonResolution::FourK), Seconds(5), Batch(2), ExpectedCredits(230)),
(Some(CommonResolution::FourK), Seconds(5), Batch(3), ExpectedCredits(230)),
(Some(CommonResolution::FourK), Seconds(5), Batch(4), ExpectedCredits(230)),
(Some(CommonResolution::FourK), Seconds(5), Batch(5), ExpectedCredits(230)),
(Some(CommonResolution::FourK), Seconds(5), Batch(6), ExpectedCredits(230)),
(Some(CommonResolution::FourK), Seconds(5), Batch(7), ExpectedCredits(230)),
(Some(CommonResolution::FourK), Seconds(5), Batch(8), ExpectedCredits(230)),
(Some(CommonResolution::FourK), Seconds(5), Batch(9), ExpectedCredits(230)),
(Some(CommonResolution::FourK), Seconds(5), Batch(10), ExpectedCredits(230)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(2), ExpectedCredits(268)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(3), ExpectedCredits(401)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(4), ExpectedCredits(535)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(1), ExpectedCredits(309)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(2), ExpectedCredits(617)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(3), ExpectedCredits(925)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(4), ExpectedCredits(1233)),
(Some(CommonResolution::FourK), Seconds(5), Batch(1), ExpectedCredits(309)),
(Some(CommonResolution::FourK), Seconds(5), Batch(2), ExpectedCredits(617)),
(Some(CommonResolution::FourK), Seconds(5), Batch(3), ExpectedCredits(925)),
(Some(CommonResolution::FourK), Seconds(5), Batch(4), ExpectedCredits(1233)),
];
for (resolution, seconds, batch, expected) in cases {
@@ -211,37 +186,25 @@ mod seedance_2p5 {
}
}
/// Spot checks across duration x batch x resolution (batch always bills as one video).
/// Spot checks across duration x batch x resolution.
#[tokio::test]
#[cfg_attr(feature = "skip_database_tests", ignore)]
async fn charges_spot_checked_combinations() {
let harness = TestHarness::create().await;
let cases: &[(Option<CommonResolution>, Seconds, Batch, ExpectedCredits)] = &[
(Some(CommonResolution::FourEightyP), Seconds(6), Batch(2), ExpectedCredits(71)),
(Some(CommonResolution::FourEightyP), Seconds(7), Batch(3), ExpectedCredits(83)),
(Some(CommonResolution::FourEightyP), Seconds(9), Batch(4), ExpectedCredits(106)),
(Some(CommonResolution::FourEightyP), Seconds(11), Batch(5), ExpectedCredits(130)),
(Some(CommonResolution::FourEightyP), Seconds(13), Batch(8), ExpectedCredits(154)),
(Some(CommonResolution::FourEightyP), Seconds(15), Batch(10), ExpectedCredits(177)),
(Some(CommonResolution::SevenTwentyP), Seconds(6), Batch(2), ExpectedCredits(161)),
(Some(CommonResolution::SevenTwentyP), Seconds(7), Batch(3), ExpectedCredits(187)),
(Some(CommonResolution::SevenTwentyP), Seconds(9), Batch(4), ExpectedCredits(241)),
(Some(CommonResolution::SevenTwentyP), Seconds(11), Batch(5), ExpectedCredits(294)),
(Some(CommonResolution::SevenTwentyP), Seconds(13), Batch(8), ExpectedCredits(348)),
(Some(CommonResolution::SevenTwentyP), Seconds(15), Batch(10), ExpectedCredits(401)),
(Some(CommonResolution::TenEightyP), Seconds(6), Batch(2), ExpectedCredits(276)),
(Some(CommonResolution::TenEightyP), Seconds(7), Batch(3), ExpectedCredits(322)),
(Some(CommonResolution::TenEightyP), Seconds(9), Batch(4), ExpectedCredits(413)),
(Some(CommonResolution::TenEightyP), Seconds(11), Batch(5), ExpectedCredits(505)),
(Some(CommonResolution::TenEightyP), Seconds(13), Batch(8), ExpectedCredits(597)),
(Some(CommonResolution::TenEightyP), Seconds(15), Batch(10), ExpectedCredits(688)),
(Some(CommonResolution::FourK), Seconds(6), Batch(2), ExpectedCredits(276)),
(Some(CommonResolution::FourK), Seconds(7), Batch(3), ExpectedCredits(322)),
(Some(CommonResolution::FourK), Seconds(9), Batch(4), ExpectedCredits(413)),
(Some(CommonResolution::FourK), Seconds(11), Batch(5), ExpectedCredits(505)),
(Some(CommonResolution::FourK), Seconds(13), Batch(8), ExpectedCredits(597)),
(Some(CommonResolution::FourK), Seconds(15), Batch(10), ExpectedCredits(688)),
(Some(CommonResolution::FourEightyP), Seconds(6), Batch(2), ExpectedCredits(142)),
(Some(CommonResolution::FourEightyP), Seconds(7), Batch(3), ExpectedCredits(248)),
(Some(CommonResolution::FourEightyP), Seconds(9), Batch(4), ExpectedCredits(424)),
(Some(CommonResolution::SevenTwentyP), Seconds(6), Batch(2), ExpectedCredits(321)),
(Some(CommonResolution::SevenTwentyP), Seconds(7), Batch(3), ExpectedCredits(561)),
(Some(CommonResolution::SevenTwentyP), Seconds(9), Batch(4), ExpectedCredits(962)),
(Some(CommonResolution::TenEightyP), Seconds(6), Batch(2), ExpectedCredits(740)),
(Some(CommonResolution::TenEightyP), Seconds(7), Batch(3), ExpectedCredits(1295)),
(Some(CommonResolution::TenEightyP), Seconds(9), Batch(4), ExpectedCredits(2219)),
(Some(CommonResolution::FourK), Seconds(6), Batch(2), ExpectedCredits(740)),
(Some(CommonResolution::FourK), Seconds(7), Batch(3), ExpectedCredits(1295)),
(Some(CommonResolution::FourK), Seconds(9), Batch(4), ExpectedCredits(2219)),
];
for (resolution, seconds, batch, expected) in cases {
@@ -313,60 +276,60 @@ mod seedance_2p5 {
(Some(CommonResolution::SevenTwentyP), Seconds(28), Batch(1), ExpectedCredits(919)),
(Some(CommonResolution::SevenTwentyP), Seconds(29), Batch(1), ExpectedCredits(935)),
(Some(CommonResolution::SevenTwentyP), Seconds(30), Batch(1), ExpectedCredits(951)),
(Some(CommonResolution::TenEightyP), Seconds(4), Batch(1), ExpectedCredits(932)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(1), ExpectedCredits(959)),
(Some(CommonResolution::TenEightyP), Seconds(6), Batch(1), ExpectedCredits(987)),
(Some(CommonResolution::TenEightyP), Seconds(7), Batch(1), ExpectedCredits(1014)),
(Some(CommonResolution::TenEightyP), Seconds(8), Batch(1), ExpectedCredits(1042)),
(Some(CommonResolution::TenEightyP), Seconds(9), Batch(1), ExpectedCredits(1069)),
(Some(CommonResolution::TenEightyP), Seconds(10), Batch(1), ExpectedCredits(1096)),
(Some(CommonResolution::TenEightyP), Seconds(11), Batch(1), ExpectedCredits(1124)),
(Some(CommonResolution::TenEightyP), Seconds(12), Batch(1), ExpectedCredits(1151)),
(Some(CommonResolution::TenEightyP), Seconds(13), Batch(1), ExpectedCredits(1179)),
(Some(CommonResolution::TenEightyP), Seconds(14), Batch(1), ExpectedCredits(1206)),
(Some(CommonResolution::TenEightyP), Seconds(15), Batch(1), ExpectedCredits(1233)),
(Some(CommonResolution::TenEightyP), Seconds(16), Batch(1), ExpectedCredits(1261)),
(Some(CommonResolution::TenEightyP), Seconds(17), Batch(1), ExpectedCredits(1288)),
(Some(CommonResolution::TenEightyP), Seconds(18), Batch(1), ExpectedCredits(1316)),
(Some(CommonResolution::TenEightyP), Seconds(19), Batch(1), ExpectedCredits(1343)),
(Some(CommonResolution::TenEightyP), Seconds(20), Batch(1), ExpectedCredits(1370)),
(Some(CommonResolution::TenEightyP), Seconds(21), Batch(1), ExpectedCredits(1398)),
(Some(CommonResolution::TenEightyP), Seconds(22), Batch(1), ExpectedCredits(1425)),
(Some(CommonResolution::TenEightyP), Seconds(23), Batch(1), ExpectedCredits(1453)),
(Some(CommonResolution::TenEightyP), Seconds(24), Batch(1), ExpectedCredits(1480)),
(Some(CommonResolution::TenEightyP), Seconds(25), Batch(1), ExpectedCredits(1507)),
(Some(CommonResolution::TenEightyP), Seconds(26), Batch(1), ExpectedCredits(1535)),
(Some(CommonResolution::TenEightyP), Seconds(27), Batch(1), ExpectedCredits(1562)),
(Some(CommonResolution::TenEightyP), Seconds(28), Batch(1), ExpectedCredits(1590)),
(Some(CommonResolution::TenEightyP), Seconds(29), Batch(1), ExpectedCredits(1617)),
(Some(CommonResolution::TenEightyP), Seconds(30), Batch(1), ExpectedCredits(1644)),
(Some(CommonResolution::FourK), Seconds(4), Batch(1), ExpectedCredits(932)),
(Some(CommonResolution::FourK), Seconds(5), Batch(1), ExpectedCredits(959)),
(Some(CommonResolution::FourK), Seconds(6), Batch(1), ExpectedCredits(987)),
(Some(CommonResolution::FourK), Seconds(7), Batch(1), ExpectedCredits(1014)),
(Some(CommonResolution::FourK), Seconds(8), Batch(1), ExpectedCredits(1042)),
(Some(CommonResolution::FourK), Seconds(9), Batch(1), ExpectedCredits(1069)),
(Some(CommonResolution::FourK), Seconds(10), Batch(1), ExpectedCredits(1096)),
(Some(CommonResolution::FourK), Seconds(11), Batch(1), ExpectedCredits(1124)),
(Some(CommonResolution::FourK), Seconds(12), Batch(1), ExpectedCredits(1151)),
(Some(CommonResolution::FourK), Seconds(13), Batch(1), ExpectedCredits(1179)),
(Some(CommonResolution::FourK), Seconds(14), Batch(1), ExpectedCredits(1206)),
(Some(CommonResolution::FourK), Seconds(15), Batch(1), ExpectedCredits(1233)),
(Some(CommonResolution::FourK), Seconds(16), Batch(1), ExpectedCredits(1261)),
(Some(CommonResolution::FourK), Seconds(17), Batch(1), ExpectedCredits(1288)),
(Some(CommonResolution::FourK), Seconds(18), Batch(1), ExpectedCredits(1316)),
(Some(CommonResolution::FourK), Seconds(19), Batch(1), ExpectedCredits(1343)),
(Some(CommonResolution::FourK), Seconds(20), Batch(1), ExpectedCredits(1370)),
(Some(CommonResolution::FourK), Seconds(21), Batch(1), ExpectedCredits(1398)),
(Some(CommonResolution::FourK), Seconds(22), Batch(1), ExpectedCredits(1425)),
(Some(CommonResolution::FourK), Seconds(23), Batch(1), ExpectedCredits(1453)),
(Some(CommonResolution::FourK), Seconds(24), Batch(1), ExpectedCredits(1480)),
(Some(CommonResolution::FourK), Seconds(25), Batch(1), ExpectedCredits(1507)),
(Some(CommonResolution::FourK), Seconds(26), Batch(1), ExpectedCredits(1535)),
(Some(CommonResolution::FourK), Seconds(27), Batch(1), ExpectedCredits(1562)),
(Some(CommonResolution::FourK), Seconds(28), Batch(1), ExpectedCredits(1590)),
(Some(CommonResolution::FourK), Seconds(29), Batch(1), ExpectedCredits(1617)),
(Some(CommonResolution::FourK), Seconds(30), Batch(1), ExpectedCredits(1644)),
(Some(CommonResolution::TenEightyP), Seconds(4), Batch(1), ExpectedCredits(1309)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(1), ExpectedCredits(1348)),
(Some(CommonResolution::TenEightyP), Seconds(6), Batch(1), ExpectedCredits(1386)),
(Some(CommonResolution::TenEightyP), Seconds(7), Batch(1), ExpectedCredits(1425)),
(Some(CommonResolution::TenEightyP), Seconds(8), Batch(1), ExpectedCredits(1463)),
(Some(CommonResolution::TenEightyP), Seconds(9), Batch(1), ExpectedCredits(1502)),
(Some(CommonResolution::TenEightyP), Seconds(10), Batch(1), ExpectedCredits(1540)),
(Some(CommonResolution::TenEightyP), Seconds(11), Batch(1), ExpectedCredits(1579)),
(Some(CommonResolution::TenEightyP), Seconds(12), Batch(1), ExpectedCredits(1617)),
(Some(CommonResolution::TenEightyP), Seconds(13), Batch(1), ExpectedCredits(1656)),
(Some(CommonResolution::TenEightyP), Seconds(14), Batch(1), ExpectedCredits(1694)),
(Some(CommonResolution::TenEightyP), Seconds(15), Batch(1), ExpectedCredits(1733)),
(Some(CommonResolution::TenEightyP), Seconds(16), Batch(1), ExpectedCredits(1771)),
(Some(CommonResolution::TenEightyP), Seconds(17), Batch(1), ExpectedCredits(1810)),
(Some(CommonResolution::TenEightyP), Seconds(18), Batch(1), ExpectedCredits(1848)),
(Some(CommonResolution::TenEightyP), Seconds(19), Batch(1), ExpectedCredits(1887)),
(Some(CommonResolution::TenEightyP), Seconds(20), Batch(1), ExpectedCredits(1925)),
(Some(CommonResolution::TenEightyP), Seconds(21), Batch(1), ExpectedCredits(1964)),
(Some(CommonResolution::TenEightyP), Seconds(22), Batch(1), ExpectedCredits(2002)),
(Some(CommonResolution::TenEightyP), Seconds(23), Batch(1), ExpectedCredits(2041)),
(Some(CommonResolution::TenEightyP), Seconds(24), Batch(1), ExpectedCredits(2079)),
(Some(CommonResolution::TenEightyP), Seconds(25), Batch(1), ExpectedCredits(2118)),
(Some(CommonResolution::TenEightyP), Seconds(26), Batch(1), ExpectedCredits(2156)),
(Some(CommonResolution::TenEightyP), Seconds(27), Batch(1), ExpectedCredits(2195)),
(Some(CommonResolution::TenEightyP), Seconds(28), Batch(1), ExpectedCredits(2233)),
(Some(CommonResolution::TenEightyP), Seconds(29), Batch(1), ExpectedCredits(2272)),
(Some(CommonResolution::TenEightyP), Seconds(30), Batch(1), ExpectedCredits(2310)),
(Some(CommonResolution::FourK), Seconds(4), Batch(1), ExpectedCredits(1309)),
(Some(CommonResolution::FourK), Seconds(5), Batch(1), ExpectedCredits(1348)),
(Some(CommonResolution::FourK), Seconds(6), Batch(1), ExpectedCredits(1386)),
(Some(CommonResolution::FourK), Seconds(7), Batch(1), ExpectedCredits(1425)),
(Some(CommonResolution::FourK), Seconds(8), Batch(1), ExpectedCredits(1463)),
(Some(CommonResolution::FourK), Seconds(9), Batch(1), ExpectedCredits(1502)),
(Some(CommonResolution::FourK), Seconds(10), Batch(1), ExpectedCredits(1540)),
(Some(CommonResolution::FourK), Seconds(11), Batch(1), ExpectedCredits(1579)),
(Some(CommonResolution::FourK), Seconds(12), Batch(1), ExpectedCredits(1617)),
(Some(CommonResolution::FourK), Seconds(13), Batch(1), ExpectedCredits(1656)),
(Some(CommonResolution::FourK), Seconds(14), Batch(1), ExpectedCredits(1694)),
(Some(CommonResolution::FourK), Seconds(15), Batch(1), ExpectedCredits(1733)),
(Some(CommonResolution::FourK), Seconds(16), Batch(1), ExpectedCredits(1771)),
(Some(CommonResolution::FourK), Seconds(17), Batch(1), ExpectedCredits(1810)),
(Some(CommonResolution::FourK), Seconds(18), Batch(1), ExpectedCredits(1848)),
(Some(CommonResolution::FourK), Seconds(19), Batch(1), ExpectedCredits(1887)),
(Some(CommonResolution::FourK), Seconds(20), Batch(1), ExpectedCredits(1925)),
(Some(CommonResolution::FourK), Seconds(21), Batch(1), ExpectedCredits(1964)),
(Some(CommonResolution::FourK), Seconds(22), Batch(1), ExpectedCredits(2002)),
(Some(CommonResolution::FourK), Seconds(23), Batch(1), ExpectedCredits(2041)),
(Some(CommonResolution::FourK), Seconds(24), Batch(1), ExpectedCredits(2079)),
(Some(CommonResolution::FourK), Seconds(25), Batch(1), ExpectedCredits(2118)),
(Some(CommonResolution::FourK), Seconds(26), Batch(1), ExpectedCredits(2156)),
(Some(CommonResolution::FourK), Seconds(27), Batch(1), ExpectedCredits(2195)),
(Some(CommonResolution::FourK), Seconds(28), Batch(1), ExpectedCredits(2233)),
(Some(CommonResolution::FourK), Seconds(29), Batch(1), ExpectedCredits(2272)),
(Some(CommonResolution::FourK), Seconds(30), Batch(1), ExpectedCredits(2310)),
];
for (resolution, seconds, batch, expected) in cases {
@@ -377,8 +340,8 @@ mod seedance_2p5 {
}
/// With video references the per-second rate drops but billed seconds = output duration + probed input seconds. The fixture media is unreachable, so the probe fails open to the worst-case 30 input seconds, the upload then fails, and the exact charge is asserted on the refunded ledger entry.
/// Every batch size 1-10 at every resolution (5s).
/// Seedance 2.5 generates a single video per request: every requested batch size 1-10 bills exactly one video.
/// Every supported batch size 1-4 at every resolution (5s).
/// Seedance 2.5 bills every video in the batch, rounding once after multiplying.
#[tokio::test]
#[cfg_attr(feature = "skip_database_tests", ignore)]
async fn video_references_charge_every_batch_size_at_every_resolution() {
@@ -386,45 +349,21 @@ mod seedance_2p5 {
let cases: &[(Option<CommonResolution>, Seconds, Batch, ExpectedCredits)] = &[
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(1), ExpectedCredits(254)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(2), ExpectedCredits(254)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(3), ExpectedCredits(254)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(4), ExpectedCredits(254)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(5), ExpectedCredits(254)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(6), ExpectedCredits(254)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(7), ExpectedCredits(254)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(8), ExpectedCredits(254)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(9), ExpectedCredits(254)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(10), ExpectedCredits(254)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(2), ExpectedCredits(507)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(3), ExpectedCredits(761)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(4), ExpectedCredits(1014)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(1), ExpectedCredits(555)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(2), ExpectedCredits(555)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(3), ExpectedCredits(555)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(4), ExpectedCredits(555)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(5), ExpectedCredits(555)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(6), ExpectedCredits(555)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(7), ExpectedCredits(555)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(8), ExpectedCredits(555)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(9), ExpectedCredits(555)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(10), ExpectedCredits(555)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(1), ExpectedCredits(959)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(2), ExpectedCredits(959)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(3), ExpectedCredits(959)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(4), ExpectedCredits(959)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(5), ExpectedCredits(959)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(6), ExpectedCredits(959)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(7), ExpectedCredits(959)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(8), ExpectedCredits(959)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(9), ExpectedCredits(959)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(10), ExpectedCredits(959)),
(Some(CommonResolution::FourK), Seconds(5), Batch(1), ExpectedCredits(959)),
(Some(CommonResolution::FourK), Seconds(5), Batch(2), ExpectedCredits(959)),
(Some(CommonResolution::FourK), Seconds(5), Batch(3), ExpectedCredits(959)),
(Some(CommonResolution::FourK), Seconds(5), Batch(4), ExpectedCredits(959)),
(Some(CommonResolution::FourK), Seconds(5), Batch(5), ExpectedCredits(959)),
(Some(CommonResolution::FourK), Seconds(5), Batch(6), ExpectedCredits(959)),
(Some(CommonResolution::FourK), Seconds(5), Batch(7), ExpectedCredits(959)),
(Some(CommonResolution::FourK), Seconds(5), Batch(8), ExpectedCredits(959)),
(Some(CommonResolution::FourK), Seconds(5), Batch(9), ExpectedCredits(959)),
(Some(CommonResolution::FourK), Seconds(5), Batch(10), ExpectedCredits(959)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(2), ExpectedCredits(1110)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(3), ExpectedCredits(1664)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(4), ExpectedCredits(2219)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(1), ExpectedCredits(1348)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(2), ExpectedCredits(2695)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(3), ExpectedCredits(4043)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(4), ExpectedCredits(5390)),
(Some(CommonResolution::FourK), Seconds(5), Batch(1), ExpectedCredits(1348)),
(Some(CommonResolution::FourK), Seconds(5), Batch(2), ExpectedCredits(2695)),
(Some(CommonResolution::FourK), Seconds(5), Batch(3), ExpectedCredits(4043)),
(Some(CommonResolution::FourK), Seconds(5), Batch(4), ExpectedCredits(5390)),
];
for (resolution, seconds, batch, expected) in cases {
@@ -442,30 +381,18 @@ mod seedance_2p5 {
let harness = TestHarness::create().await;
let cases: &[(Option<CommonResolution>, Seconds, Batch, ExpectedCredits)] = &[
(Some(CommonResolution::FourEightyP), Seconds(6), Batch(2), ExpectedCredits(261)),
(Some(CommonResolution::FourEightyP), Seconds(7), Batch(3), ExpectedCredits(268)),
(Some(CommonResolution::FourEightyP), Seconds(9), Batch(4), ExpectedCredits(283)),
(Some(CommonResolution::FourEightyP), Seconds(11), Batch(5), ExpectedCredits(297)),
(Some(CommonResolution::FourEightyP), Seconds(13), Batch(8), ExpectedCredits(312)),
(Some(CommonResolution::FourEightyP), Seconds(15), Batch(10), ExpectedCredits(326)),
(Some(CommonResolution::SevenTwentyP), Seconds(6), Batch(2), ExpectedCredits(571)),
(Some(CommonResolution::SevenTwentyP), Seconds(7), Batch(3), ExpectedCredits(587)),
(Some(CommonResolution::SevenTwentyP), Seconds(9), Batch(4), ExpectedCredits(618)),
(Some(CommonResolution::SevenTwentyP), Seconds(11), Batch(5), ExpectedCredits(650)),
(Some(CommonResolution::SevenTwentyP), Seconds(13), Batch(8), ExpectedCredits(682)),
(Some(CommonResolution::SevenTwentyP), Seconds(15), Batch(10), ExpectedCredits(713)),
(Some(CommonResolution::TenEightyP), Seconds(6), Batch(2), ExpectedCredits(987)),
(Some(CommonResolution::TenEightyP), Seconds(7), Batch(3), ExpectedCredits(1014)),
(Some(CommonResolution::TenEightyP), Seconds(9), Batch(4), ExpectedCredits(1069)),
(Some(CommonResolution::TenEightyP), Seconds(11), Batch(5), ExpectedCredits(1124)),
(Some(CommonResolution::TenEightyP), Seconds(13), Batch(8), ExpectedCredits(1179)),
(Some(CommonResolution::TenEightyP), Seconds(15), Batch(10), ExpectedCredits(1233)),
(Some(CommonResolution::FourK), Seconds(6), Batch(2), ExpectedCredits(987)),
(Some(CommonResolution::FourK), Seconds(7), Batch(3), ExpectedCredits(1014)),
(Some(CommonResolution::FourK), Seconds(9), Batch(4), ExpectedCredits(1069)),
(Some(CommonResolution::FourK), Seconds(11), Batch(5), ExpectedCredits(1124)),
(Some(CommonResolution::FourK), Seconds(13), Batch(8), ExpectedCredits(1179)),
(Some(CommonResolution::FourK), Seconds(15), Batch(10), ExpectedCredits(1233)),
(Some(CommonResolution::FourEightyP), Seconds(6), Batch(2), ExpectedCredits(522)),
(Some(CommonResolution::FourEightyP), Seconds(7), Batch(3), ExpectedCredits(804)),
(Some(CommonResolution::FourEightyP), Seconds(9), Batch(4), ExpectedCredits(1130)),
(Some(CommonResolution::SevenTwentyP), Seconds(6), Batch(2), ExpectedCredits(1141)),
(Some(CommonResolution::SevenTwentyP), Seconds(7), Batch(3), ExpectedCredits(1759)),
(Some(CommonResolution::SevenTwentyP), Seconds(9), Batch(4), ExpectedCredits(2472)),
(Some(CommonResolution::TenEightyP), Seconds(6), Batch(2), ExpectedCredits(2772)),
(Some(CommonResolution::TenEightyP), Seconds(7), Batch(3), ExpectedCredits(4274)),
(Some(CommonResolution::TenEightyP), Seconds(9), Batch(4), ExpectedCredits(6006)),
(Some(CommonResolution::FourK), Seconds(6), Batch(2), ExpectedCredits(2772)),
(Some(CommonResolution::FourK), Seconds(7), Batch(3), ExpectedCredits(4274)),
(Some(CommonResolution::FourK), Seconds(9), Batch(4), ExpectedCredits(6006)),
];
for (resolution, seconds, batch, expected) in cases {
@@ -546,60 +473,60 @@ mod seedance_2p5_u {
(Some(CommonResolution::SevenTwentyP), Seconds(28), Batch(1), ExpectedCredits(884)),
(Some(CommonResolution::SevenTwentyP), Seconds(29), Batch(1), ExpectedCredits(916)),
(Some(CommonResolution::SevenTwentyP), Seconds(30), Batch(1), ExpectedCredits(947)),
(Some(CommonResolution::TenEightyP), Seconds(4), Batch(1), ExpectedCredits(201)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(1), ExpectedCredits(251)),
(Some(CommonResolution::TenEightyP), Seconds(6), Batch(1), ExpectedCredits(301)),
(Some(CommonResolution::TenEightyP), Seconds(7), Batch(1), ExpectedCredits(351)),
(Some(CommonResolution::TenEightyP), Seconds(8), Batch(1), ExpectedCredits(401)),
(Some(CommonResolution::TenEightyP), Seconds(9), Batch(1), ExpectedCredits(451)),
(Some(CommonResolution::TenEightyP), Seconds(10), Batch(1), ExpectedCredits(502)),
(Some(CommonResolution::TenEightyP), Seconds(11), Batch(1), ExpectedCredits(552)),
(Some(CommonResolution::TenEightyP), Seconds(12), Batch(1), ExpectedCredits(602)),
(Some(CommonResolution::TenEightyP), Seconds(13), Batch(1), ExpectedCredits(652)),
(Some(CommonResolution::TenEightyP), Seconds(14), Batch(1), ExpectedCredits(702)),
(Some(CommonResolution::TenEightyP), Seconds(15), Batch(1), ExpectedCredits(752)),
(Some(CommonResolution::TenEightyP), Seconds(16), Batch(1), ExpectedCredits(802)),
(Some(CommonResolution::TenEightyP), Seconds(17), Batch(1), ExpectedCredits(852)),
(Some(CommonResolution::TenEightyP), Seconds(18), Batch(1), ExpectedCredits(902)),
(Some(CommonResolution::TenEightyP), Seconds(19), Batch(1), ExpectedCredits(952)),
(Some(CommonResolution::TenEightyP), Seconds(20), Batch(1), ExpectedCredits(1003)),
(Some(CommonResolution::TenEightyP), Seconds(21), Batch(1), ExpectedCredits(1053)),
(Some(CommonResolution::TenEightyP), Seconds(22), Batch(1), ExpectedCredits(1103)),
(Some(CommonResolution::TenEightyP), Seconds(23), Batch(1), ExpectedCredits(1153)),
(Some(CommonResolution::TenEightyP), Seconds(24), Batch(1), ExpectedCredits(1203)),
(Some(CommonResolution::TenEightyP), Seconds(25), Batch(1), ExpectedCredits(1253)),
(Some(CommonResolution::TenEightyP), Seconds(26), Batch(1), ExpectedCredits(1303)),
(Some(CommonResolution::TenEightyP), Seconds(27), Batch(1), ExpectedCredits(1353)),
(Some(CommonResolution::TenEightyP), Seconds(28), Batch(1), ExpectedCredits(1403)),
(Some(CommonResolution::TenEightyP), Seconds(29), Batch(1), ExpectedCredits(1454)),
(Some(CommonResolution::TenEightyP), Seconds(30), Batch(1), ExpectedCredits(1504)),
(Some(CommonResolution::FourK), Seconds(4), Batch(1), ExpectedCredits(201)),
(Some(CommonResolution::FourK), Seconds(5), Batch(1), ExpectedCredits(251)),
(Some(CommonResolution::FourK), Seconds(6), Batch(1), ExpectedCredits(301)),
(Some(CommonResolution::FourK), Seconds(7), Batch(1), ExpectedCredits(351)),
(Some(CommonResolution::FourK), Seconds(8), Batch(1), ExpectedCredits(401)),
(Some(CommonResolution::FourK), Seconds(9), Batch(1), ExpectedCredits(451)),
(Some(CommonResolution::FourK), Seconds(10), Batch(1), ExpectedCredits(502)),
(Some(CommonResolution::FourK), Seconds(11), Batch(1), ExpectedCredits(552)),
(Some(CommonResolution::FourK), Seconds(12), Batch(1), ExpectedCredits(602)),
(Some(CommonResolution::FourK), Seconds(13), Batch(1), ExpectedCredits(652)),
(Some(CommonResolution::FourK), Seconds(14), Batch(1), ExpectedCredits(702)),
(Some(CommonResolution::FourK), Seconds(15), Batch(1), ExpectedCredits(752)),
(Some(CommonResolution::FourK), Seconds(16), Batch(1), ExpectedCredits(802)),
(Some(CommonResolution::FourK), Seconds(17), Batch(1), ExpectedCredits(852)),
(Some(CommonResolution::FourK), Seconds(18), Batch(1), ExpectedCredits(902)),
(Some(CommonResolution::FourK), Seconds(19), Batch(1), ExpectedCredits(952)),
(Some(CommonResolution::FourK), Seconds(20), Batch(1), ExpectedCredits(1003)),
(Some(CommonResolution::FourK), Seconds(21), Batch(1), ExpectedCredits(1053)),
(Some(CommonResolution::FourK), Seconds(22), Batch(1), ExpectedCredits(1103)),
(Some(CommonResolution::FourK), Seconds(23), Batch(1), ExpectedCredits(1153)),
(Some(CommonResolution::FourK), Seconds(24), Batch(1), ExpectedCredits(1203)),
(Some(CommonResolution::FourK), Seconds(25), Batch(1), ExpectedCredits(1253)),
(Some(CommonResolution::FourK), Seconds(26), Batch(1), ExpectedCredits(1303)),
(Some(CommonResolution::FourK), Seconds(27), Batch(1), ExpectedCredits(1353)),
(Some(CommonResolution::FourK), Seconds(28), Batch(1), ExpectedCredits(1403)),
(Some(CommonResolution::FourK), Seconds(29), Batch(1), ExpectedCredits(1454)),
(Some(CommonResolution::FourK), Seconds(30), Batch(1), ExpectedCredits(1504)),
(Some(CommonResolution::TenEightyP), Seconds(4), Batch(1), ExpectedCredits(269)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(1), ExpectedCredits(337)),
(Some(CommonResolution::TenEightyP), Seconds(6), Batch(1), ExpectedCredits(404)),
(Some(CommonResolution::TenEightyP), Seconds(7), Batch(1), ExpectedCredits(471)),
(Some(CommonResolution::TenEightyP), Seconds(8), Batch(1), ExpectedCredits(538)),
(Some(CommonResolution::TenEightyP), Seconds(9), Batch(1), ExpectedCredits(606)),
(Some(CommonResolution::TenEightyP), Seconds(10), Batch(1), ExpectedCredits(673)),
(Some(CommonResolution::TenEightyP), Seconds(11), Batch(1), ExpectedCredits(740)),
(Some(CommonResolution::TenEightyP), Seconds(12), Batch(1), ExpectedCredits(807)),
(Some(CommonResolution::TenEightyP), Seconds(13), Batch(1), ExpectedCredits(874)),
(Some(CommonResolution::TenEightyP), Seconds(14), Batch(1), ExpectedCredits(942)),
(Some(CommonResolution::TenEightyP), Seconds(15), Batch(1), ExpectedCredits(1009)),
(Some(CommonResolution::TenEightyP), Seconds(16), Batch(1), ExpectedCredits(1076)),
(Some(CommonResolution::TenEightyP), Seconds(17), Batch(1), ExpectedCredits(1143)),
(Some(CommonResolution::TenEightyP), Seconds(18), Batch(1), ExpectedCredits(1211)),
(Some(CommonResolution::TenEightyP), Seconds(19), Batch(1), ExpectedCredits(1278)),
(Some(CommonResolution::TenEightyP), Seconds(20), Batch(1), ExpectedCredits(1345)),
(Some(CommonResolution::TenEightyP), Seconds(21), Batch(1), ExpectedCredits(1412)),
(Some(CommonResolution::TenEightyP), Seconds(22), Batch(1), ExpectedCredits(1480)),
(Some(CommonResolution::TenEightyP), Seconds(23), Batch(1), ExpectedCredits(1547)),
(Some(CommonResolution::TenEightyP), Seconds(24), Batch(1), ExpectedCredits(1614)),
(Some(CommonResolution::TenEightyP), Seconds(25), Batch(1), ExpectedCredits(1681)),
(Some(CommonResolution::TenEightyP), Seconds(26), Batch(1), ExpectedCredits(1748)),
(Some(CommonResolution::TenEightyP), Seconds(27), Batch(1), ExpectedCredits(1816)),
(Some(CommonResolution::TenEightyP), Seconds(28), Batch(1), ExpectedCredits(1883)),
(Some(CommonResolution::TenEightyP), Seconds(29), Batch(1), ExpectedCredits(1950)),
(Some(CommonResolution::TenEightyP), Seconds(30), Batch(1), ExpectedCredits(2017)),
(Some(CommonResolution::FourK), Seconds(4), Batch(1), ExpectedCredits(269)),
(Some(CommonResolution::FourK), Seconds(5), Batch(1), ExpectedCredits(337)),
(Some(CommonResolution::FourK), Seconds(6), Batch(1), ExpectedCredits(404)),
(Some(CommonResolution::FourK), Seconds(7), Batch(1), ExpectedCredits(471)),
(Some(CommonResolution::FourK), Seconds(8), Batch(1), ExpectedCredits(538)),
(Some(CommonResolution::FourK), Seconds(9), Batch(1), ExpectedCredits(606)),
(Some(CommonResolution::FourK), Seconds(10), Batch(1), ExpectedCredits(673)),
(Some(CommonResolution::FourK), Seconds(11), Batch(1), ExpectedCredits(740)),
(Some(CommonResolution::FourK), Seconds(12), Batch(1), ExpectedCredits(807)),
(Some(CommonResolution::FourK), Seconds(13), Batch(1), ExpectedCredits(874)),
(Some(CommonResolution::FourK), Seconds(14), Batch(1), ExpectedCredits(942)),
(Some(CommonResolution::FourK), Seconds(15), Batch(1), ExpectedCredits(1009)),
(Some(CommonResolution::FourK), Seconds(16), Batch(1), ExpectedCredits(1076)),
(Some(CommonResolution::FourK), Seconds(17), Batch(1), ExpectedCredits(1143)),
(Some(CommonResolution::FourK), Seconds(18), Batch(1), ExpectedCredits(1211)),
(Some(CommonResolution::FourK), Seconds(19), Batch(1), ExpectedCredits(1278)),
(Some(CommonResolution::FourK), Seconds(20), Batch(1), ExpectedCredits(1345)),
(Some(CommonResolution::FourK), Seconds(21), Batch(1), ExpectedCredits(1412)),
(Some(CommonResolution::FourK), Seconds(22), Batch(1), ExpectedCredits(1480)),
(Some(CommonResolution::FourK), Seconds(23), Batch(1), ExpectedCredits(1547)),
(Some(CommonResolution::FourK), Seconds(24), Batch(1), ExpectedCredits(1614)),
(Some(CommonResolution::FourK), Seconds(25), Batch(1), ExpectedCredits(1681)),
(Some(CommonResolution::FourK), Seconds(26), Batch(1), ExpectedCredits(1748)),
(Some(CommonResolution::FourK), Seconds(27), Batch(1), ExpectedCredits(1816)),
(Some(CommonResolution::FourK), Seconds(28), Batch(1), ExpectedCredits(1883)),
(Some(CommonResolution::FourK), Seconds(29), Batch(1), ExpectedCredits(1950)),
(Some(CommonResolution::FourK), Seconds(30), Batch(1), ExpectedCredits(2017)),
];
for (resolution, seconds, batch, expected) in cases {
@@ -609,8 +536,8 @@ mod seedance_2p5_u {
}
}
/// Every batch size 1-10 at every resolution (5s).
/// Seedance 2.5 generates a single video per request: every requested batch size 1-10 bills exactly one video.
/// Every supported batch size 1-4 at every resolution (5s).
/// Seedance 2.5 bills every video in the batch, rounding once after multiplying.
/// Seedance 2.5 Ultra offers 480p, 720p, and 1080p; 4K downgrades to 1080p (and prices accordingly).
#[tokio::test]
#[cfg_attr(feature = "skip_database_tests", ignore)]
@@ -619,45 +546,21 @@ mod seedance_2p5_u {
let cases: &[(Option<CommonResolution>, Seconds, Batch, ExpectedCredits)] = &[
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(1), ExpectedCredits(70)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(2), ExpectedCredits(70)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(3), ExpectedCredits(70)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(4), ExpectedCredits(70)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(5), ExpectedCredits(70)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(6), ExpectedCredits(70)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(7), ExpectedCredits(70)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(8), ExpectedCredits(70)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(9), ExpectedCredits(70)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(10), ExpectedCredits(70)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(2), ExpectedCredits(140)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(3), ExpectedCredits(209)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(4), ExpectedCredits(279)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(1), ExpectedCredits(158)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(2), ExpectedCredits(158)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(3), ExpectedCredits(158)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(4), ExpectedCredits(158)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(5), ExpectedCredits(158)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(6), ExpectedCredits(158)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(7), ExpectedCredits(158)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(8), ExpectedCredits(158)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(9), ExpectedCredits(158)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(10), ExpectedCredits(158)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(1), ExpectedCredits(251)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(2), ExpectedCredits(251)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(3), ExpectedCredits(251)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(4), ExpectedCredits(251)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(5), ExpectedCredits(251)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(6), ExpectedCredits(251)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(7), ExpectedCredits(251)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(8), ExpectedCredits(251)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(9), ExpectedCredits(251)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(10), ExpectedCredits(251)),
(Some(CommonResolution::FourK), Seconds(5), Batch(1), ExpectedCredits(251)),
(Some(CommonResolution::FourK), Seconds(5), Batch(2), ExpectedCredits(251)),
(Some(CommonResolution::FourK), Seconds(5), Batch(3), ExpectedCredits(251)),
(Some(CommonResolution::FourK), Seconds(5), Batch(4), ExpectedCredits(251)),
(Some(CommonResolution::FourK), Seconds(5), Batch(5), ExpectedCredits(251)),
(Some(CommonResolution::FourK), Seconds(5), Batch(6), ExpectedCredits(251)),
(Some(CommonResolution::FourK), Seconds(5), Batch(7), ExpectedCredits(251)),
(Some(CommonResolution::FourK), Seconds(5), Batch(8), ExpectedCredits(251)),
(Some(CommonResolution::FourK), Seconds(5), Batch(9), ExpectedCredits(251)),
(Some(CommonResolution::FourK), Seconds(5), Batch(10), ExpectedCredits(251)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(2), ExpectedCredits(316)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(3), ExpectedCredits(474)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(4), ExpectedCredits(632)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(1), ExpectedCredits(337)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(2), ExpectedCredits(673)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(3), ExpectedCredits(1009)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(4), ExpectedCredits(1345)),
(Some(CommonResolution::FourK), Seconds(5), Batch(1), ExpectedCredits(337)),
(Some(CommonResolution::FourK), Seconds(5), Batch(2), ExpectedCredits(673)),
(Some(CommonResolution::FourK), Seconds(5), Batch(3), ExpectedCredits(1009)),
(Some(CommonResolution::FourK), Seconds(5), Batch(4), ExpectedCredits(1345)),
];
for (resolution, seconds, batch, expected) in cases {
@@ -667,37 +570,25 @@ mod seedance_2p5_u {
}
}
/// Spot checks across duration x batch x resolution (batch always bills as one video).
/// Spot checks across duration x batch x resolution.
#[tokio::test]
#[cfg_attr(feature = "skip_database_tests", ignore)]
async fn charges_spot_checked_combinations() {
let harness = TestHarness::create().await;
let cases: &[(Option<CommonResolution>, Seconds, Batch, ExpectedCredits)] = &[
(Some(CommonResolution::FourEightyP), Seconds(6), Batch(2), ExpectedCredits(84)),
(Some(CommonResolution::FourEightyP), Seconds(7), Batch(3), ExpectedCredits(98)),
(Some(CommonResolution::FourEightyP), Seconds(9), Batch(4), ExpectedCredits(126)),
(Some(CommonResolution::FourEightyP), Seconds(11), Batch(5), ExpectedCredits(154)),
(Some(CommonResolution::FourEightyP), Seconds(13), Batch(8), ExpectedCredits(181)),
(Some(CommonResolution::FourEightyP), Seconds(15), Batch(10), ExpectedCredits(209)),
(Some(CommonResolution::SevenTwentyP), Seconds(6), Batch(2), ExpectedCredits(190)),
(Some(CommonResolution::SevenTwentyP), Seconds(7), Batch(3), ExpectedCredits(221)),
(Some(CommonResolution::SevenTwentyP), Seconds(9), Batch(4), ExpectedCredits(285)),
(Some(CommonResolution::SevenTwentyP), Seconds(11), Batch(5), ExpectedCredits(348)),
(Some(CommonResolution::SevenTwentyP), Seconds(13), Batch(8), ExpectedCredits(411)),
(Some(CommonResolution::SevenTwentyP), Seconds(15), Batch(10), ExpectedCredits(474)),
(Some(CommonResolution::TenEightyP), Seconds(6), Batch(2), ExpectedCredits(301)),
(Some(CommonResolution::TenEightyP), Seconds(7), Batch(3), ExpectedCredits(351)),
(Some(CommonResolution::TenEightyP), Seconds(9), Batch(4), ExpectedCredits(451)),
(Some(CommonResolution::TenEightyP), Seconds(11), Batch(5), ExpectedCredits(552)),
(Some(CommonResolution::TenEightyP), Seconds(13), Batch(8), ExpectedCredits(652)),
(Some(CommonResolution::TenEightyP), Seconds(15), Batch(10), ExpectedCredits(752)),
(Some(CommonResolution::FourK), Seconds(6), Batch(2), ExpectedCredits(301)),
(Some(CommonResolution::FourK), Seconds(7), Batch(3), ExpectedCredits(351)),
(Some(CommonResolution::FourK), Seconds(9), Batch(4), ExpectedCredits(451)),
(Some(CommonResolution::FourK), Seconds(11), Batch(5), ExpectedCredits(552)),
(Some(CommonResolution::FourK), Seconds(13), Batch(8), ExpectedCredits(652)),
(Some(CommonResolution::FourK), Seconds(15), Batch(10), ExpectedCredits(752)),
(Some(CommonResolution::FourEightyP), Seconds(6), Batch(2), ExpectedCredits(167)),
(Some(CommonResolution::FourEightyP), Seconds(7), Batch(3), ExpectedCredits(293)),
(Some(CommonResolution::FourEightyP), Seconds(9), Batch(4), ExpectedCredits(501)),
(Some(CommonResolution::SevenTwentyP), Seconds(6), Batch(2), ExpectedCredits(379)),
(Some(CommonResolution::SevenTwentyP), Seconds(7), Batch(3), ExpectedCredits(663)),
(Some(CommonResolution::SevenTwentyP), Seconds(9), Batch(4), ExpectedCredits(1137)),
(Some(CommonResolution::TenEightyP), Seconds(6), Batch(2), ExpectedCredits(807)),
(Some(CommonResolution::TenEightyP), Seconds(7), Batch(3), ExpectedCredits(1412)),
(Some(CommonResolution::TenEightyP), Seconds(9), Batch(4), ExpectedCredits(2421)),
(Some(CommonResolution::FourK), Seconds(6), Batch(2), ExpectedCredits(807)),
(Some(CommonResolution::FourK), Seconds(7), Batch(3), ExpectedCredits(1412)),
(Some(CommonResolution::FourK), Seconds(9), Batch(4), ExpectedCredits(2421)),
];
for (resolution, seconds, batch, expected) in cases {
@@ -769,60 +660,60 @@ mod seedance_2p5_u {
(Some(CommonResolution::SevenTwentyP), Seconds(28), Batch(1), ExpectedCredits(1087)),
(Some(CommonResolution::SevenTwentyP), Seconds(29), Batch(1), ExpectedCredits(1105)),
(Some(CommonResolution::SevenTwentyP), Seconds(30), Batch(1), ExpectedCredits(1124)),
(Some(CommonResolution::TenEightyP), Seconds(4), Batch(1), ExpectedCredits(1018)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(1), ExpectedCredits(1048)),
(Some(CommonResolution::TenEightyP), Seconds(6), Batch(1), ExpectedCredits(1078)),
(Some(CommonResolution::TenEightyP), Seconds(7), Batch(1), ExpectedCredits(1108)),
(Some(CommonResolution::TenEightyP), Seconds(8), Batch(1), ExpectedCredits(1138)),
(Some(CommonResolution::TenEightyP), Seconds(9), Batch(1), ExpectedCredits(1168)),
(Some(CommonResolution::TenEightyP), Seconds(10), Batch(1), ExpectedCredits(1198)),
(Some(CommonResolution::TenEightyP), Seconds(11), Batch(1), ExpectedCredits(1228)),
(Some(CommonResolution::TenEightyP), Seconds(12), Batch(1), ExpectedCredits(1258)),
(Some(CommonResolution::TenEightyP), Seconds(13), Batch(1), ExpectedCredits(1288)),
(Some(CommonResolution::TenEightyP), Seconds(14), Batch(1), ExpectedCredits(1318)),
(Some(CommonResolution::TenEightyP), Seconds(15), Batch(1), ExpectedCredits(1348)),
(Some(CommonResolution::TenEightyP), Seconds(16), Batch(1), ExpectedCredits(1378)),
(Some(CommonResolution::TenEightyP), Seconds(17), Batch(1), ExpectedCredits(1408)),
(Some(CommonResolution::TenEightyP), Seconds(18), Batch(1), ExpectedCredits(1437)),
(Some(CommonResolution::TenEightyP), Seconds(19), Batch(1), ExpectedCredits(1467)),
(Some(CommonResolution::TenEightyP), Seconds(20), Batch(1), ExpectedCredits(1497)),
(Some(CommonResolution::TenEightyP), Seconds(21), Batch(1), ExpectedCredits(1527)),
(Some(CommonResolution::TenEightyP), Seconds(22), Batch(1), ExpectedCredits(1557)),
(Some(CommonResolution::TenEightyP), Seconds(23), Batch(1), ExpectedCredits(1587)),
(Some(CommonResolution::TenEightyP), Seconds(24), Batch(1), ExpectedCredits(1617)),
(Some(CommonResolution::TenEightyP), Seconds(25), Batch(1), ExpectedCredits(1647)),
(Some(CommonResolution::TenEightyP), Seconds(26), Batch(1), ExpectedCredits(1677)),
(Some(CommonResolution::TenEightyP), Seconds(27), Batch(1), ExpectedCredits(1707)),
(Some(CommonResolution::TenEightyP), Seconds(28), Batch(1), ExpectedCredits(1737)),
(Some(CommonResolution::TenEightyP), Seconds(29), Batch(1), ExpectedCredits(1767)),
(Some(CommonResolution::TenEightyP), Seconds(30), Batch(1), ExpectedCredits(1797)),
(Some(CommonResolution::FourK), Seconds(4), Batch(1), ExpectedCredits(1018)),
(Some(CommonResolution::FourK), Seconds(5), Batch(1), ExpectedCredits(1048)),
(Some(CommonResolution::FourK), Seconds(6), Batch(1), ExpectedCredits(1078)),
(Some(CommonResolution::FourK), Seconds(7), Batch(1), ExpectedCredits(1108)),
(Some(CommonResolution::FourK), Seconds(8), Batch(1), ExpectedCredits(1138)),
(Some(CommonResolution::FourK), Seconds(9), Batch(1), ExpectedCredits(1168)),
(Some(CommonResolution::FourK), Seconds(10), Batch(1), ExpectedCredits(1198)),
(Some(CommonResolution::FourK), Seconds(11), Batch(1), ExpectedCredits(1228)),
(Some(CommonResolution::FourK), Seconds(12), Batch(1), ExpectedCredits(1258)),
(Some(CommonResolution::FourK), Seconds(13), Batch(1), ExpectedCredits(1288)),
(Some(CommonResolution::FourK), Seconds(14), Batch(1), ExpectedCredits(1318)),
(Some(CommonResolution::FourK), Seconds(15), Batch(1), ExpectedCredits(1348)),
(Some(CommonResolution::FourK), Seconds(16), Batch(1), ExpectedCredits(1378)),
(Some(CommonResolution::FourK), Seconds(17), Batch(1), ExpectedCredits(1408)),
(Some(CommonResolution::FourK), Seconds(18), Batch(1), ExpectedCredits(1437)),
(Some(CommonResolution::FourK), Seconds(19), Batch(1), ExpectedCredits(1467)),
(Some(CommonResolution::FourK), Seconds(20), Batch(1), ExpectedCredits(1497)),
(Some(CommonResolution::FourK), Seconds(21), Batch(1), ExpectedCredits(1527)),
(Some(CommonResolution::FourK), Seconds(22), Batch(1), ExpectedCredits(1557)),
(Some(CommonResolution::FourK), Seconds(23), Batch(1), ExpectedCredits(1587)),
(Some(CommonResolution::FourK), Seconds(24), Batch(1), ExpectedCredits(1617)),
(Some(CommonResolution::FourK), Seconds(25), Batch(1), ExpectedCredits(1647)),
(Some(CommonResolution::FourK), Seconds(26), Batch(1), ExpectedCredits(1677)),
(Some(CommonResolution::FourK), Seconds(27), Batch(1), ExpectedCredits(1707)),
(Some(CommonResolution::FourK), Seconds(28), Batch(1), ExpectedCredits(1737)),
(Some(CommonResolution::FourK), Seconds(29), Batch(1), ExpectedCredits(1767)),
(Some(CommonResolution::FourK), Seconds(30), Batch(1), ExpectedCredits(1797)),
(Some(CommonResolution::TenEightyP), Seconds(4), Batch(1), ExpectedCredits(1423)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(1), ExpectedCredits(1465)),
(Some(CommonResolution::TenEightyP), Seconds(6), Batch(1), ExpectedCredits(1507)),
(Some(CommonResolution::TenEightyP), Seconds(7), Batch(1), ExpectedCredits(1549)),
(Some(CommonResolution::TenEightyP), Seconds(8), Batch(1), ExpectedCredits(1591)),
(Some(CommonResolution::TenEightyP), Seconds(9), Batch(1), ExpectedCredits(1632)),
(Some(CommonResolution::TenEightyP), Seconds(10), Batch(1), ExpectedCredits(1674)),
(Some(CommonResolution::TenEightyP), Seconds(11), Batch(1), ExpectedCredits(1716)),
(Some(CommonResolution::TenEightyP), Seconds(12), Batch(1), ExpectedCredits(1758)),
(Some(CommonResolution::TenEightyP), Seconds(13), Batch(1), ExpectedCredits(1800)),
(Some(CommonResolution::TenEightyP), Seconds(14), Batch(1), ExpectedCredits(1842)),
(Some(CommonResolution::TenEightyP), Seconds(15), Batch(1), ExpectedCredits(1884)),
(Some(CommonResolution::TenEightyP), Seconds(16), Batch(1), ExpectedCredits(1925)),
(Some(CommonResolution::TenEightyP), Seconds(17), Batch(1), ExpectedCredits(1967)),
(Some(CommonResolution::TenEightyP), Seconds(18), Batch(1), ExpectedCredits(2009)),
(Some(CommonResolution::TenEightyP), Seconds(19), Batch(1), ExpectedCredits(2051)),
(Some(CommonResolution::TenEightyP), Seconds(20), Batch(1), ExpectedCredits(2093)),
(Some(CommonResolution::TenEightyP), Seconds(21), Batch(1), ExpectedCredits(2135)),
(Some(CommonResolution::TenEightyP), Seconds(22), Batch(1), ExpectedCredits(2176)),
(Some(CommonResolution::TenEightyP), Seconds(23), Batch(1), ExpectedCredits(2218)),
(Some(CommonResolution::TenEightyP), Seconds(24), Batch(1), ExpectedCredits(2260)),
(Some(CommonResolution::TenEightyP), Seconds(25), Batch(1), ExpectedCredits(2302)),
(Some(CommonResolution::TenEightyP), Seconds(26), Batch(1), ExpectedCredits(2344)),
(Some(CommonResolution::TenEightyP), Seconds(27), Batch(1), ExpectedCredits(2386)),
(Some(CommonResolution::TenEightyP), Seconds(28), Batch(1), ExpectedCredits(2428)),
(Some(CommonResolution::TenEightyP), Seconds(29), Batch(1), ExpectedCredits(2469)),
(Some(CommonResolution::TenEightyP), Seconds(30), Batch(1), ExpectedCredits(2511)),
(Some(CommonResolution::FourK), Seconds(4), Batch(1), ExpectedCredits(1423)),
(Some(CommonResolution::FourK), Seconds(5), Batch(1), ExpectedCredits(1465)),
(Some(CommonResolution::FourK), Seconds(6), Batch(1), ExpectedCredits(1507)),
(Some(CommonResolution::FourK), Seconds(7), Batch(1), ExpectedCredits(1549)),
(Some(CommonResolution::FourK), Seconds(8), Batch(1), ExpectedCredits(1591)),
(Some(CommonResolution::FourK), Seconds(9), Batch(1), ExpectedCredits(1632)),
(Some(CommonResolution::FourK), Seconds(10), Batch(1), ExpectedCredits(1674)),
(Some(CommonResolution::FourK), Seconds(11), Batch(1), ExpectedCredits(1716)),
(Some(CommonResolution::FourK), Seconds(12), Batch(1), ExpectedCredits(1758)),
(Some(CommonResolution::FourK), Seconds(13), Batch(1), ExpectedCredits(1800)),
(Some(CommonResolution::FourK), Seconds(14), Batch(1), ExpectedCredits(1842)),
(Some(CommonResolution::FourK), Seconds(15), Batch(1), ExpectedCredits(1884)),
(Some(CommonResolution::FourK), Seconds(16), Batch(1), ExpectedCredits(1925)),
(Some(CommonResolution::FourK), Seconds(17), Batch(1), ExpectedCredits(1967)),
(Some(CommonResolution::FourK), Seconds(18), Batch(1), ExpectedCredits(2009)),
(Some(CommonResolution::FourK), Seconds(19), Batch(1), ExpectedCredits(2051)),
(Some(CommonResolution::FourK), Seconds(20), Batch(1), ExpectedCredits(2093)),
(Some(CommonResolution::FourK), Seconds(21), Batch(1), ExpectedCredits(2135)),
(Some(CommonResolution::FourK), Seconds(22), Batch(1), ExpectedCredits(2176)),
(Some(CommonResolution::FourK), Seconds(23), Batch(1), ExpectedCredits(2218)),
(Some(CommonResolution::FourK), Seconds(24), Batch(1), ExpectedCredits(2260)),
(Some(CommonResolution::FourK), Seconds(25), Batch(1), ExpectedCredits(2302)),
(Some(CommonResolution::FourK), Seconds(26), Batch(1), ExpectedCredits(2344)),
(Some(CommonResolution::FourK), Seconds(27), Batch(1), ExpectedCredits(2386)),
(Some(CommonResolution::FourK), Seconds(28), Batch(1), ExpectedCredits(2428)),
(Some(CommonResolution::FourK), Seconds(29), Batch(1), ExpectedCredits(2469)),
(Some(CommonResolution::FourK), Seconds(30), Batch(1), ExpectedCredits(2511)),
];
for (resolution, seconds, batch, expected) in cases {
@@ -833,8 +724,8 @@ mod seedance_2p5_u {
}
/// With video references the per-second rate drops but billed seconds = output duration + probed input seconds. The fixture media is unreachable, so the probe fails open to the worst-case 30 input seconds, the upload then fails, and the exact charge is asserted on the refunded ledger entry.
/// Every batch size 1-10 at every resolution (5s).
/// Seedance 2.5 generates a single video per request: every requested batch size 1-10 bills exactly one video.
/// Every supported batch size 1-4 at every resolution (5s).
/// Seedance 2.5 bills every video in the batch, rounding once after multiplying.
#[tokio::test]
#[cfg_attr(feature = "skip_database_tests", ignore)]
async fn video_references_charge_every_batch_size_at_every_resolution() {
@@ -842,45 +733,21 @@ mod seedance_2p5_u {
let cases: &[(Option<CommonResolution>, Seconds, Batch, ExpectedCredits)] = &[
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(1), ExpectedCredits(300)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(2), ExpectedCredits(300)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(3), ExpectedCredits(300)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(4), ExpectedCredits(300)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(5), ExpectedCredits(300)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(6), ExpectedCredits(300)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(7), ExpectedCredits(300)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(8), ExpectedCredits(300)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(9), ExpectedCredits(300)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(10), ExpectedCredits(300)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(2), ExpectedCredits(600)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(3), ExpectedCredits(899)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(4), ExpectedCredits(1199)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(1), ExpectedCredits(656)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(2), ExpectedCredits(656)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(3), ExpectedCredits(656)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(4), ExpectedCredits(656)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(5), ExpectedCredits(656)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(6), ExpectedCredits(656)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(7), ExpectedCredits(656)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(8), ExpectedCredits(656)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(9), ExpectedCredits(656)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(10), ExpectedCredits(656)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(1), ExpectedCredits(1048)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(2), ExpectedCredits(1048)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(3), ExpectedCredits(1048)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(4), ExpectedCredits(1048)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(5), ExpectedCredits(1048)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(6), ExpectedCredits(1048)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(7), ExpectedCredits(1048)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(8), ExpectedCredits(1048)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(9), ExpectedCredits(1048)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(10), ExpectedCredits(1048)),
(Some(CommonResolution::FourK), Seconds(5), Batch(1), ExpectedCredits(1048)),
(Some(CommonResolution::FourK), Seconds(5), Batch(2), ExpectedCredits(1048)),
(Some(CommonResolution::FourK), Seconds(5), Batch(3), ExpectedCredits(1048)),
(Some(CommonResolution::FourK), Seconds(5), Batch(4), ExpectedCredits(1048)),
(Some(CommonResolution::FourK), Seconds(5), Batch(5), ExpectedCredits(1048)),
(Some(CommonResolution::FourK), Seconds(5), Batch(6), ExpectedCredits(1048)),
(Some(CommonResolution::FourK), Seconds(5), Batch(7), ExpectedCredits(1048)),
(Some(CommonResolution::FourK), Seconds(5), Batch(8), ExpectedCredits(1048)),
(Some(CommonResolution::FourK), Seconds(5), Batch(9), ExpectedCredits(1048)),
(Some(CommonResolution::FourK), Seconds(5), Batch(10), ExpectedCredits(1048)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(2), ExpectedCredits(1311)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(3), ExpectedCredits(1967)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(4), ExpectedCredits(2622)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(1), ExpectedCredits(1465)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(2), ExpectedCredits(2930)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(3), ExpectedCredits(4394)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(4), ExpectedCredits(5859)),
(Some(CommonResolution::FourK), Seconds(5), Batch(1), ExpectedCredits(1465)),
(Some(CommonResolution::FourK), Seconds(5), Batch(2), ExpectedCredits(2930)),
(Some(CommonResolution::FourK), Seconds(5), Batch(3), ExpectedCredits(4394)),
(Some(CommonResolution::FourK), Seconds(5), Batch(4), ExpectedCredits(5859)),
];
for (resolution, seconds, batch, expected) in cases {
@@ -898,30 +765,18 @@ mod seedance_2p5_u {
let harness = TestHarness::create().await;
let cases: &[(Option<CommonResolution>, Seconds, Batch, ExpectedCredits)] = &[
(Some(CommonResolution::FourEightyP), Seconds(6), Batch(2), ExpectedCredits(309)),
(Some(CommonResolution::FourEightyP), Seconds(7), Batch(3), ExpectedCredits(317)),
(Some(CommonResolution::FourEightyP), Seconds(9), Batch(4), ExpectedCredits(334)),
(Some(CommonResolution::FourEightyP), Seconds(11), Batch(5), ExpectedCredits(351)),
(Some(CommonResolution::FourEightyP), Seconds(13), Batch(8), ExpectedCredits(369)),
(Some(CommonResolution::FourEightyP), Seconds(15), Batch(10), ExpectedCredits(386)),
(Some(CommonResolution::SevenTwentyP), Seconds(6), Batch(2), ExpectedCredits(675)),
(Some(CommonResolution::SevenTwentyP), Seconds(7), Batch(3), ExpectedCredits(693)),
(Some(CommonResolution::SevenTwentyP), Seconds(9), Batch(4), ExpectedCredits(731)),
(Some(CommonResolution::SevenTwentyP), Seconds(11), Batch(5), ExpectedCredits(768)),
(Some(CommonResolution::SevenTwentyP), Seconds(13), Batch(8), ExpectedCredits(806)),
(Some(CommonResolution::SevenTwentyP), Seconds(15), Batch(10), ExpectedCredits(843)),
(Some(CommonResolution::TenEightyP), Seconds(6), Batch(2), ExpectedCredits(1078)),
(Some(CommonResolution::TenEightyP), Seconds(7), Batch(3), ExpectedCredits(1108)),
(Some(CommonResolution::TenEightyP), Seconds(9), Batch(4), ExpectedCredits(1168)),
(Some(CommonResolution::TenEightyP), Seconds(11), Batch(5), ExpectedCredits(1228)),
(Some(CommonResolution::TenEightyP), Seconds(13), Batch(8), ExpectedCredits(1288)),
(Some(CommonResolution::TenEightyP), Seconds(15), Batch(10), ExpectedCredits(1348)),
(Some(CommonResolution::FourK), Seconds(6), Batch(2), ExpectedCredits(1078)),
(Some(CommonResolution::FourK), Seconds(7), Batch(3), ExpectedCredits(1108)),
(Some(CommonResolution::FourK), Seconds(9), Batch(4), ExpectedCredits(1168)),
(Some(CommonResolution::FourK), Seconds(11), Batch(5), ExpectedCredits(1228)),
(Some(CommonResolution::FourK), Seconds(13), Batch(8), ExpectedCredits(1288)),
(Some(CommonResolution::FourK), Seconds(15), Batch(10), ExpectedCredits(1348)),
(Some(CommonResolution::FourEightyP), Seconds(6), Batch(2), ExpectedCredits(617)),
(Some(CommonResolution::FourEightyP), Seconds(7), Batch(3), ExpectedCredits(951)),
(Some(CommonResolution::FourEightyP), Seconds(9), Batch(4), ExpectedCredits(1336)),
(Some(CommonResolution::SevenTwentyP), Seconds(6), Batch(2), ExpectedCredits(1349)),
(Some(CommonResolution::SevenTwentyP), Seconds(7), Batch(3), ExpectedCredits(2079)),
(Some(CommonResolution::SevenTwentyP), Seconds(9), Batch(4), ExpectedCredits(2921)),
(Some(CommonResolution::TenEightyP), Seconds(6), Batch(2), ExpectedCredits(3013)),
(Some(CommonResolution::TenEightyP), Seconds(7), Batch(3), ExpectedCredits(4645)),
(Some(CommonResolution::TenEightyP), Seconds(9), Batch(4), ExpectedCredits(6528)),
(Some(CommonResolution::FourK), Seconds(6), Batch(2), ExpectedCredits(3013)),
(Some(CommonResolution::FourK), Seconds(7), Batch(3), ExpectedCredits(4645)),
(Some(CommonResolution::FourK), Seconds(9), Batch(4), ExpectedCredits(6528)),
];
for (resolution, seconds, batch, expected) in cases {
@@ -1059,7 +914,7 @@ mod input_video_duration_billing {
// 6.5s → 7 → 37 billed × 7.24279835 = 267.98 → 268
// 10.5s → 11 → 41 billed × 7.24279835 = 296.95 → 297
// 6.5s → 7 → 37 billed × 15.84362140 = 586.21 → 587 (720p)
// 6.5s → 7 → 37 billed × 27.39973680 = 1013.79 → 1014 (1080p)
// 6.5s → 7 → 37 billed × 38.49728574 = 1424.40 → 1425 (1080p)
assert_real_input_videos_charge(
&harness, CommonVideoModel::Seedance2p5, Some(CommonResolution::FourEightyP),
Seconds(30), &[4_500], ExpectedCredits(254),
@@ -1078,7 +933,7 @@ mod input_video_duration_billing {
).await;
assert_real_input_videos_charge(
&harness, CommonVideoModel::Seedance2p5, Some(CommonResolution::TenEightyP),
Seconds(30), &[6_500], ExpectedCredits(1014),
Seconds(30), &[6_500], ExpectedCredits(1425),
).await;
}
@@ -1122,12 +977,12 @@ mod input_video_duration_billing {
let harness = TestHarness::create().await;
// 30s output, ultra with-refs 480p 8.55967078 ¢/s / 720p 18.72427984 ¢/s
// / 1080p 29.93674947 ¢/s:
// / 1080p 41.84487580 ¢/s:
// 2.5s → total 3 → clamps to 4 → 34 billed → 291.03 → 292
// 10.5s → 11 → 41 billed → 350.95 → 351
// 2.5s + 6.5s → 3+7=10 → 40 billed → 342.39 → 343 (total-based, no per-video clamp)
// 6.5s → 7 → 37 billed × 18.72427984 = 692.80 → 693 (720p)
// 6.5s → 7 → 37 billed × 29.93674947 = 1107.66 → 1108 (1080p)
// 6.5s → 7 → 37 billed × 41.84487580 = 1548.26 → 1549 (1080p)
assert_real_input_videos_charge(
&harness, CommonVideoModel::Seedance2p5Ultra, Some(CommonResolution::FourEightyP),
Seconds(30), &[2_500], ExpectedCredits(292),
@@ -1146,7 +1001,7 @@ mod input_video_duration_billing {
).await;
assert_real_input_videos_charge(
&harness, CommonVideoModel::Seedance2p5Ultra, Some(CommonResolution::TenEightyP),
Seconds(30), &[6_500], ExpectedCredits(1108),
Seconds(30), &[6_500], ExpectedCredits(1549),
).await;
}
}
@@ -1172,10 +1027,10 @@ mod premium {
(Some(CommonResolution::SevenTwentyP), Seconds(10), Batch(1), ExpectedCredits(268), ExpectedCredits(316), CreditsDelta(48)),
(Some(CommonResolution::SevenTwentyP), Seconds(15), Batch(1), ExpectedCredits(401), ExpectedCredits(474), CreditsDelta(73)),
(Some(CommonResolution::SevenTwentyP), Seconds(30), Batch(1), ExpectedCredits(802), ExpectedCredits(947), CreditsDelta(145)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(1), ExpectedCredits(230), ExpectedCredits(251), CreditsDelta(21)),
(Some(CommonResolution::TenEightyP), Seconds(30), Batch(1), ExpectedCredits(1376), ExpectedCredits(1504), CreditsDelta(128)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(1), ExpectedCredits(309), ExpectedCredits(337), CreditsDelta(28)),
(Some(CommonResolution::TenEightyP), Seconds(30), Batch(1), ExpectedCredits(1849), ExpectedCredits(2017), CreditsDelta(168)),
// 4K downgrades to 1080p on both models, so the premium matches 1080p.
(Some(CommonResolution::FourK), Seconds(5), Batch(1), ExpectedCredits(230), ExpectedCredits(251), CreditsDelta(21)),
(Some(CommonResolution::FourK), Seconds(5), Batch(1), ExpectedCredits(309), ExpectedCredits(337), CreditsDelta(28)),
];
for (resolution, seconds, batch, base, variant, delta) in cases {
@@ -1,3 +1,4 @@
pub(crate) mod validate_kinovi_batch_count;
mod validate_seedance_2p0_limits;
pub mod validate_video_request;
mod validate_when_image_required;
mod validate_when_image_required;
@@ -0,0 +1,99 @@
use enums::common::generation::common_video_model::CommonVideoModel;
use crate::http_server::common_responses::common_web_error::CommonWebError;
/// Keep the public batch limit aligned with ArtCraft billing before the
/// execution builder reaches Kinovi, whose Seedance API accepts larger batches.
pub(crate) fn validate_kinovi_batch_count(
model: Option<CommonVideoModel>,
video_batch_count: Option<u16>,
) -> Result<(), CommonWebError> {
if matches!(model, Some(
CommonVideoModel::Seedance2p5
| CommonVideoModel::Seedance2p0
| CommonVideoModel::Seedance2p0Fast
| CommonVideoModel::Seedance2p0Mini
| CommonVideoModel::Seedance2p5Ultra
| CommonVideoModel::Seedance2p0BytePlus
| CommonVideoModel::Seedance2p0BytePlusFast
| CommonVideoModel::Seedance2p0BytePlusMini
| CommonVideoModel::Seedance2p0BytePlusUltra
| CommonVideoModel::Seedance2p0BytePlusUltraFast
| CommonVideoModel::Seedance2p0BytePlusUltraMini
| CommonVideoModel::HappyHorse1p0
)) && !(1..=4).contains(&video_batch_count.unwrap_or(1)) {
return Err(CommonWebError::BadInputWithSimpleMessage(
"video_batch_count must be between 1 and 4 for this model".to_string(),
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use artcraft_api_defs::omni_api::generate_requests::omni_api_video_generate_request::OmniApiVideoGenerateRequest;
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
use serde_json::json;
use crate::http_server::endpoints::omni_api::shared_utils::video::validate_video_request::validate_video_request as validate_omni_api;
use crate::http_server::endpoints::omni_gen::shared_utils::video::validate_video_request::validate_video_request as validate_omni_gen;
use super::*;
const MODELS: [CommonVideoModel; 12] = [
CommonVideoModel::Seedance2p5,
CommonVideoModel::Seedance2p0,
CommonVideoModel::Seedance2p0Fast,
CommonVideoModel::Seedance2p0Mini,
CommonVideoModel::Seedance2p5Ultra,
CommonVideoModel::Seedance2p0BytePlus,
CommonVideoModel::Seedance2p0BytePlusFast,
CommonVideoModel::Seedance2p0BytePlusMini,
CommonVideoModel::Seedance2p0BytePlusUltra,
CommonVideoModel::Seedance2p0BytePlusUltraFast,
CommonVideoModel::Seedance2p0BytePlusUltraMini,
CommonVideoModel::HappyHorse1p0,
];
#[test]
fn both_endpoints_accept_batches_one_through_four_and_default() {
for model in MODELS {
for batch in [None, Some(1), Some(2), Some(3), Some(4)] {
for result in validate_both(model, batch) {
assert!(result.is_ok(), "{:?}, batch {:?}: {:?}", model, batch, result);
}
}
}
}
#[test]
fn both_endpoints_reject_zero_and_batches_above_four() {
for model in MODELS {
for batch in [0, 5, 6, 7, 8, u16::MAX] {
for result in validate_both(model, Some(batch)) {
assert!(matches!(result, Err(CommonWebError::BadInputWithSimpleMessage(_))),
"{:?}, batch {}", model, batch);
}
}
}
}
#[test]
fn other_models_keep_their_existing_validation() {
for model in [
CommonVideoModel::Seedance2p5Preview,
CommonVideoModel::Veo3p1,
] {
for result in validate_both(model, Some(8)) {
assert!(result.is_ok(), "{:?}: {:?}", model, result);
}
}
}
fn validate_both(model: CommonVideoModel, batch: Option<u16>) -> [Result<(), CommonWebError>; 2] {
let json = json!({"model": model, "video_batch_count": batch});
let omni_gen: OmniGenVideoCostAndGenerateRequest = serde_json::from_value(json.clone()).unwrap();
let omni_api: OmniApiVideoGenerateRequest = serde_json::from_value(json).unwrap();
[validate_omni_gen(&omni_gen), validate_omni_api(&omni_api)]
}
}
@@ -1,4 +1,5 @@
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::endpoints::omni_gen::shared_utils::video::validate_kinovi_batch_count::validate_kinovi_batch_count;
use crate::http_server::endpoints::omni_gen::shared_utils::video::validate_seedance_2p0_limits::validate_seedance_2p0_limits;
use crate::http_server::endpoints::omni_gen::shared_utils::video::validate_when_image_required::validate_when_image_required;
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
@@ -7,6 +8,7 @@ use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost
pub fn validate_video_request(
request: &OmniGenVideoCostAndGenerateRequest,
) -> Result<(), CommonWebError> {
validate_kinovi_batch_count(request.model, request.video_batch_count)?;
validate_when_image_required(request)?;
validate_seedance_2p0_limits(request)?;
Ok(())
+1 -1
View File
@@ -60,7 +60,7 @@
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="icon" type="image/svg+xml" href="/artcraft-icon.svg" />
<link rel="stylesheet" href="/src/styles.css" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 116.34 97.5">
<defs>
<style>
.cls-1 {
fill: #4e7bfb;
}
</style>
</defs>
<g id="Layer_1-2" data-name="Layer 1">
<path class="cls-1" d="M104.28,49.49L81.55,0h-31.23l-3.17,4.76L14.75,53.63,0,75.85l21.55,21.55,63.79-36.94,16.99,37.04,14.01-21.74-12.06-26.27ZM32.89,65.66l32.42-48.87,10.91,23.77-43.32,25.09Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 483 B

@@ -0,0 +1,9 @@
export const AUTH_LABEL_CLASSES =
"block font-mono text-[11px] font-semibold leading-4 uppercase tracking-[0.12em] text-white/70";
export const AUTH_INPUT_CLASSES =
"h-10 w-full rounded-[3px] border border-white/15 bg-white/5 px-4 py-2 text-sm font-normal leading-5 text-white placeholder-white/20 outline-none transition-colors focus:border-white/40";
export const AUTH_PASSWORD_INPUT_CLASSES = `${AUTH_INPUT_CLASSES} pr-12`;
export const AUTH_FORM_PADDING = "px-6 py-10 sm:px-10 sm:py-12";
@@ -1,11 +1,11 @@
import { AUTH_FORM_PADDING } from "./auth-form-styles";
import { ReactNode } from "react";
import { Outlet, useNavigate, Navigate } from "react-router-dom";
import { ArrowLeftIcon, LoaderCircleIcon } from "lucide-react";
import { TruchetPattern } from "@storyteller/ui-vfx";
import { Outlet, Navigate, Link } from "react-router-dom";
import { LoaderCircleIcon } from "lucide-react";
import { AuthShowcase } from "./auth-showcase";
import { AuthPageFrame } from "./auth-page-frame";
import { useMediaQuery } from "../ui/use-media-query";
import { useSession } from "../../lib/session";
import { Reveal, RevealGroup } from "../motion/reveal";
/**
* Persistent shell for the auth pages. Rendered as a layout route so the
@@ -18,52 +18,21 @@ export const AuthLayout = () => {
// Only mount the showcase on wide screens (matches the `lg` breakpoint) so
// mobile never downloads the demo videos.
const showShowcase = useMediaQuery("(min-width: 1024px)");
const navigate = useNavigate();
const { loggedIn, authChecked } = useSession();
const handleBack = () => {
navigate("/");
};
// Already signed in? Never show the auth form — go straight home.
if (loggedIn) {
return <Navigate to="/" replace />;
}
return (
<div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-ui-background p-4 text-white">
<div
aria-hidden
className="pointer-events-none absolute inset-0 z-0"
style={{
maskImage:
"radial-gradient(ellipse 70% 60% at 50% 40%, black 25%, transparent 80%)",
WebkitMaskImage:
"radial-gradient(ellipse 70% 60% at 50% 40%, black 25%, transparent 80%)",
}}
>
<TruchetPattern
intensity={0.5}
className="absolute inset-0 h-full w-full"
/>
</div>
<div className="relative z-10 flex w-full max-w-5xl overflow-hidden border border-white/15 bg-[#101014] lg:min-h-[640px]">
<AuthPageFrame wide>
{/* Form pane — only the inner content swaps per route (via Outlet), so
this pane and the showcase beside it stay mounted across the
login/signup toggle. */}
<div className="relative flex w-full flex-col lg:w-1/2">
<button
type="button"
onClick={handleBack}
aria-label="Go back"
className="absolute left-5 top-5 z-20 flex h-9 w-9 items-center justify-center text-white/60 transition-colors hover:bg-white/10 hover:text-white"
>
<ArrowLeftIcon />
</button>
<div className="flex flex-1 flex-col justify-center px-8 py-10 sm:px-10">
<div className="mx-auto w-full max-w-sm">
<div className={`flex flex-1 flex-col justify-center ${AUTH_FORM_PADDING}`}>
<div className="w-full">
{authChecked ? (
<Outlet />
) : (
@@ -76,44 +45,39 @@ export const AuthLayout = () => {
</div>
</div>
<div className="px-8 pb-8 text-center text-xs text-white/20">
&copy; {new Date().getFullYear()} ArtCraft. All rights reserved.
</div>
</div>
{/* Showcase pane (desktop only) */}
{showShowcase && (
<div className="relative lg:w-1/2">
<div className="relative border-l border-white/15 lg:w-1/2">
<AuthShowcase />
</div>
)}
</div>
</div>
</AuthPageFrame>
);
};
interface AuthHeaderProps {
title: string;
title: ReactNode;
subtitle: string;
}
export const AuthHeader = ({ title, subtitle }: AuthHeaderProps) => (
<RevealGroup inView={false} stagger={0.08} className="mb-8 text-center">
<Reveal y={10}>
<img
src="/images/artcraft-icon.png"
alt="ArtCraft"
className="mx-auto mb-6 h-12 w-auto select-none pointer-events-none"
draggable={false}
/>
</Reveal>
<Reveal as="h1" y={10} className="mb-2 text-2xl font-semibold">
<div className="mb-8 text-left">
<Link
to="/"
aria-label="ArtCraft home"
className="mb-8 inline-flex rounded-sm transition-opacity hover:opacity-80 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-white/60"
>
<img src="/artcraft-icon.svg" alt="ArtCraft" className="h-8 w-8" />
</Link>
<h1 className="mb-3 text-balance font-display text-3xl leading-[1.05] tracking-tight sm:text-4xl">
{title}
</Reveal>
<Reveal as="p" y={10} className="text-sm text-white/60">
</h1>
<p className="text-sm leading-relaxed text-white/60">
{subtitle}
</Reveal>
</RevealGroup>
</p>
</div>
);
export const AuthFooter = ({ children }: { children: ReactNode }) => (
@@ -0,0 +1,22 @@
import { type ReactNode } from "react";
import { twMerge } from "tailwind-merge";
import { FrameCornerMarks } from "../ui/frame-corner-marks";
/** A contained section frame shared by the auth pages. */
export function AuthPageFrame({ children, wide = false }: {
children: ReactNode;
wide?: boolean;
}) {
return (
<div className="relative isolate flex min-h-dvh items-center justify-center overflow-hidden bg-ui-background p-4 text-white sm:py-10">
<div className={twMerge("relative w-full", wide ? "max-w-lg lg:max-w-5xl" : "max-w-lg")}>
<div className="relative border border-white/20 bg-ui-controls">
<div className={twMerge("flex", wide ? "lg:min-h-[560px]" : "flex-col")}>
{children}
</div>
</div>
<FrameCornerMarks />
</div>
</div>
);
}
@@ -3,7 +3,7 @@ const FEATURED_VIDEO_URL =
export const AuthShowcase = () => {
return (
<div className="absolute inset-2 overflow-hidden bg-black">
<div className="absolute inset-0 overflow-hidden bg-ui-controls">
{/* Muted background video (no controls) cropped to cover the pane */}
<video
src={FEATURED_VIDEO_URL}
@@ -1,3 +1,4 @@
import { AUTH_LABEL_CLASSES, AUTH_INPUT_CLASSES, AUTH_PASSWORD_INPUT_CLASSES } from "./auth-form-styles";
import { EyeIcon, EyeOffIcon, LoaderCircleIcon } from "lucide-react";
import { DynamicIcon } from "@storyteller/icons";
import { Button } from "@storyteller/ui-button";
@@ -14,7 +15,6 @@ import {
import { refreshSession } from "../../lib/session";
import { hasActiveSubscription } from "../../lib/billing";
import { GoogleLoginButton } from "./GoogleLoginButton";
import { Reveal, RevealGroup } from "../motion/reveal";
interface SignupFormProps {
onSuccess: (isNewUser?: boolean) => void;
@@ -98,7 +98,7 @@ export const SignupForm = ({
};
return (
<div className={`space-y-4 ${className}`}>
<div className={className}>
<form
className="space-y-4"
onSubmit={(e) => {
@@ -111,19 +111,11 @@ export const SignupForm = ({
{error}
</div>
)}
{/* Fields cascade in as their own beat, leading with the email field on
an almost-zero delay so the primary input lands immediately — the
entrance never gates typing (inputs stay focusable mid-cascade).
Mirrors the login page. */}
<RevealGroup
inView={false}
delayChildren={0.04}
stagger={0.08}
<div
className="space-y-4"
>
<Reveal className="space-y-2">
<label className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/70">
<div className="space-y-2">
<label className={AUTH_LABEL_CLASSES}>
Email
</label>
<Input
@@ -132,13 +124,13 @@ export const SignupForm = ({
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
autoFocus={autoFocus}
inputClassName="w-full bg-ui-controls border border-white/15 focus:border-white/40 rounded-[3px] px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
inputClassName={AUTH_INPUT_CLASSES}
/>
</Reveal>
</div>
<Reveal className="space-y-2">
<div className="space-y-2">
<div className="flex justify-between items-center">
<label className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/70">
<label className={AUTH_LABEL_CLASSES}>
Password
</label>
</div>
@@ -148,7 +140,7 @@ export const SignupForm = ({
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Min. 8 characters"
inputClassName="w-full bg-ui-controls border border-white/15 focus:border-white/40 rounded-[3px] px-4 py-3 text-white placeholder-white/20 outline-none transition-colors pr-12"
inputClassName={AUTH_PASSWORD_INPUT_CLASSES}
/>
<button
type="button"
@@ -159,9 +151,9 @@ export const SignupForm = ({
<DynamicIcon icon={showPassword ? EyeOffIcon : EyeIcon} />
</button>
</div>
</Reveal>
</div>
<Reveal className="pt-2">
<div className="pt-2">
<Button
className="w-full justify-center h-10"
type="submit"
@@ -173,36 +165,25 @@ export const SignupForm = ({
"Create account"
)}
</Button>
</Reveal>
</RevealGroup>
</div>
</div>
</form>
{/* Secondary sign-up options continue the same cascade just after the
fields (delay continues from the form group's three children above:
0.04 + 3 × 0.08). */}
<RevealGroup
inView={false}
delayChildren={0.28}
stagger={0.08}
className="space-y-4"
>
<Reveal className="relative flex items-center justify-center py-2">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-white/10" />
</div>
<span className="relative bg-[#101014] px-4 font-mono text-[11px] uppercase tracking-[0.12em] text-white/40">
<div>
<div className="my-6 flex items-center gap-4 before:h-px before:flex-1 before:bg-white/15 after:h-px after:flex-1 after:bg-white/15">
<span className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/40">
or
</span>
</Reveal>
</div>
<Reveal>
<div>
<GoogleLoginButton
mode="signup"
onSuccess={handleGoogleSuccess}
onError={handleGoogleError}
/>
</Reveal>
</RevealGroup>
</div>
</div>
</div>
);
};
@@ -0,0 +1,25 @@
import { type ReactNode } from "react";
import { FrameCornerMarks } from "../ui/frame-corner-marks";
interface CreateEmptyStateProps {
title: string;
subtitle: string;
children?: ReactNode;
compact?: boolean;
}
/** A quiet editorial section with a fine border and corner registration marks. */
export function CreateEmptyState({ title, subtitle, children, compact = false }: CreateEmptyStateProps) {
return (
<section className={`create-empty-section relative w-full max-w-3xl px-6 text-left sm:px-10 ${compact ? "py-8" : "py-10 sm:py-14"}`}>
<h1 className={`text-balance font-display leading-[1.05] tracking-[-0.035em] text-ui-ink ${compact ? "text-2xl" : "text-3xl md:text-5xl"}`}>
{title}
</h1>
<p className="mt-4 max-w-xl text-pretty text-sm leading-relaxed text-white/60 sm:text-base">
{subtitle}
</p>
{children && <div className="mt-6">{children}</div>}
<FrameCornerMarks />
</section>
);
}
@@ -1,9 +1,8 @@
import { CreateEmptyState } from "./CreateEmptyState";
import { type ReactNode, useState } from "react";
import { LoaderCircleIcon } from "lucide-react";
import { isMobile } from "react-device-detect";
import { type PopoverItem } from "@storyteller/ui-popover";
import { TabSelector } from "@storyteller/ui-tab-selector";
import { TruchetPattern } from "@storyteller/ui-vfx";
import Seo from "../../components/seo";
import { useIsMobile } from "../ui/use-mobile";
import {
@@ -20,7 +19,7 @@ interface CreateMediaPageShellProps {
// SEO
title: string;
description: string;
// Auth state — `authChecked` gates the initial spinner so we don't flash
// Auth state - `authChecked` gates the initial spinner so we don't flash
// logged-out chrome while the session resolves. Pages stay viewable for
// logged-out users; the signup CTA modal is triggered at generate time.
authChecked: boolean;
@@ -41,8 +40,6 @@ interface CreateMediaPageShellProps {
// Model selector
modelItems: PopoverItem[];
onModelChange: (item: PopoverItem) => void;
// Glow orb overrides (optional - defaults provided)
glowOrbs?: ReactNode;
// Children slots
gridContent: ReactNode;
promptBox: ReactNode;
@@ -69,7 +66,6 @@ export function CreateMediaPageShell({
emptyStateSubtitle,
emptyStateCta,
bottomOffset,
glowOrbs,
gridContent,
promptBox,
promptForm,
@@ -103,9 +99,9 @@ export function CreateMediaPageShell({
<Seo title={title} description={description} />
{/* Flex (not absolute) keeps the toggle cluster from overlapping
the tabs — the tabs stay centered via the equal flex-1 sides and
the tabs - the tabs stay centered via the equal flex-1 sides and
just shift left if the cluster outgrows its half. */}
<div className="flex items-center border-b border-ui-panel-border px-3 py-2">
<div className="flex flex-wrap items-center gap-y-2 border-b border-ui-panel-border bg-ui-panel px-3 py-2">
<div className="flex-1" />
<TabSelector
tabs={MOBILE_TABS}
@@ -132,13 +128,8 @@ export function CreateMediaPageShell({
<div className="px-3">{gridContent}</div>
</div>
) : (
<div className="flex h-full flex-col items-center justify-center px-6 text-center">
<span className="text-lg font-semibold text-white">
{emptyStateTitle}
</span>
<span className="pt-1 text-sm text-white/60">
Your generations will appear here.
</span>
<div className="flex h-full items-center justify-center overflow-y-auto px-6 py-8">
<CreateEmptyState compact title={emptyStateTitle} subtitle="Your generations will appear here." />
</div>
)}
</div>
@@ -150,51 +141,16 @@ export function CreateMediaPageShell({
}
return (
<div className="flex h-full w-full bg-ui-background text-white">
<div className="relative isolate flex h-full w-full bg-ui-background text-white">
<Seo title={title} description={description} />
{/* Decorative background, empty state only. Not rendered on mobile
devices: the truchet uses mask-image, a full-screen GPU layer that
iOS Safari must re-rasterize when an overlay (mobile menu, modal)
composites over it, causing multi-second hangs. (This is distinct
from backdrop-filter.) We gate on the actual device
(react-device-detect's user-agent check) rather than a viewport
breakpoint so the layers never mount on a real phone/tablet, and a
narrow desktop window still gets the decoration. */}
{!hasContent && !isMobile && (
<>
{glowOrbs}
<div
aria-hidden
className="pointer-events-none fixed inset-0 z-0"
style={{
maskImage:
"radial-gradient(ellipse 70% 60% at 50% 50%, black 20%, transparent 80%)",
WebkitMaskImage:
"radial-gradient(ellipse 70% 60% at 50% 50%, black 20%, transparent 80%)",
}}
>
<TruchetPattern
intensity={0.5}
className="absolute inset-0 h-full w-full"
/>
</div>
</>
)}
<div className="relative z-[1] h-full w-full">
<div className="flex h-full w-full flex-col">
{!hasContent && (
<div className="flex flex-1 items-center justify-center">
<div className="animate-fade-in-up relative z-20 mb-32 flex flex-col items-center justify-center text-center">
<h1 className="text-5xl font-semibold text-white md:text-7xl">
{emptyStateTitle}
</h1>
<span className="pt-2 text-lg text-white/80 md:text-xl">
{emptyStateSubtitle}
</span>
{emptyStateCta && <div className="pt-6">{emptyStateCta}</div>}
</div>
<div className="flex flex-1 items-center justify-center px-6" style={{ paddingBottom: bottomOffset }}>
<CreateEmptyState title={emptyStateTitle} subtitle={emptyStateSubtitle}>
{emptyStateCta}
</CreateEmptyState>
</div>
)}
@@ -45,11 +45,11 @@ import {
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex items-center justify-between px-4 py-3 border-b border-white/10 last:border-0">
<span className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/60">
<div className="flex items-start justify-between gap-4 px-4 py-3 border-b border-white/10 last:border-0">
<span className="shrink-0 pt-0.5 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/60">
{label}
</span>
<span className="text-sm text-white font-medium flex items-center gap-2">
<span className="min-w-0 text-right text-sm text-white font-medium flex items-center justify-end gap-2">
{value}
</span>
</div>
@@ -426,7 +426,14 @@ export function LightboxDetails({
{createdAt && (
<InfoRow
label="Created"
value={dayjs(createdAt).format("MMMM D, YYYY h:mm:ss A")}
value={
<time dateTime={createdAt} className="flex flex-col items-end gap-0.5 tabular-nums">
<span>{dayjs(createdAt).format("MMM D, YYYY")}</span>
<span className="text-xs text-white/60 whitespace-nowrap">
{dayjs(createdAt).format("h:mm:ss A")}
</span>
</time>
}
/>
)}
</div>
@@ -357,7 +357,7 @@ export function Lightbox({
<Modal
isOpen={isOpen}
onClose={onClose}
className="h-[90vh] sm:h-[680px] w-full sm:w-[1100px] max-w-[95vw] max-h-[90vh] p-0 border-white/15"
className="h-[90dvh] sm:h-[92dvh] w-full sm:w-[1440px] max-w-[96vw] max-h-[92dvh] p-0 border-white/15"
backdropClassName="!bg-black/80"
showClose={false}
>
@@ -1,5 +1,6 @@
import { type ReactNode } from "react";
import { twMerge } from "tailwind-merge";
import { NEUTRAL_BUTTON_HOVER_CLASSES } from "@storyteller/ui-button";
import { ChevronRightIcon } from "lucide-react";
interface MobileFieldButtonProps {
@@ -27,7 +28,8 @@ export function MobileFieldButton({
disabled={disabled}
className={twMerge(
"flex w-full items-center gap-3 border border-ui-panel-border bg-ui-controls px-3.5 py-3 text-left transition-colors",
"hover:bg-ui-controls/80 active:scale-[0.99] disabled:cursor-not-allowed disabled:opacity-50",
NEUTRAL_BUTTON_HOVER_CLASSES,
"disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-ui-controls disabled:hover:border-ui-panel-border",
className,
)}
>
@@ -1,6 +1,6 @@
import { useMemo } from "react";
import { Link, useLocation } from "react-router-dom";
import { motion, useReducedMotion } from "framer-motion";
import { SidebarActiveIndicator } from "./sidebar-active-indicator";
import {
BoxIcon,
CircleHelpIcon,
@@ -161,7 +161,6 @@ function NavMenuItem({
pathname: string;
onClick: () => void;
}) {
const reduceMotion = useReducedMotion();
const active = !item.external && isActive(pathname, item.href);
const inner = (
<>
@@ -187,21 +186,7 @@ function NavMenuItem({
);
return (
<SidebarMenuItem>
{/* Brand accent bar that glides between nav rows as the active route
changes (shared-element layout animation via `layoutId`). One is
mounted at a time, so framer-motion tweens it from the old row to the
new one. */}
{active && (
<motion.span
layoutId="sidebar-active-indicator"
className="pointer-events-none absolute inset-y-1.5 left-0 z-10 w-0.5 bg-white"
transition={
reduceMotion
? { duration: 0 }
: { type: "spring", stiffness: 520, damping: 40, mass: 0.7 }
}
/>
)}
{active && <SidebarActiveIndicator />}
<SidebarMenuButton asChild isActive={active} tooltip={item.label}>
{item.external ? (
<a
@@ -1,3 +1,4 @@
import { SidebarActiveIndicator } from "./sidebar-active-indicator";
import { useMemo, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { AnimatePresence, motion } from "framer-motion";
@@ -90,6 +91,7 @@ export function LibraryFoldersNav({
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem>
{onUnsorted && <SidebarActiveIndicator />}
<SidebarMenuButton asChild isActive={onUnsorted} tooltip="Library">
<Link to="/library" onClick={onNavClick}>
<Grid3x3Icon />
@@ -98,6 +100,7 @@ export function LibraryFoldersNav({
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
{onFolderless && <SidebarActiveIndicator />}
<SidebarMenuButton
asChild
isActive={onFolderless}
@@ -110,6 +113,7 @@ export function LibraryFoldersNav({
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
{onFolders && !activeFolderId && <SidebarActiveIndicator />}
<SidebarMenuButton
asChild
isActive={onFolders && !activeFolderId}
@@ -147,8 +151,9 @@ export function LibraryFoldersNav({
<ul className="flex w-full min-w-0 flex-col gap-0.5">
{rootFolders.map((folder) => (
<SidebarMenuItem key={folder.id}>
{onFolders && activeRootId === folder.id && <SidebarActiveIndicator />}
<SidebarMenuButton
isActive={activeRootId === folder.id}
isActive={onFolders && activeRootId === folder.id}
tooltip={folder.name}
data-folder-id={folder.id}
onClick={() => goToFolder(folder.id)}
@@ -1,3 +1,4 @@
import { SidebarActiveIndicator } from "./sidebar-active-indicator";
import { useMemo, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { AnimatePresence, motion } from "framer-motion";
@@ -48,6 +49,7 @@ export function LibraryTagsNav({
return (
<>
<SidebarMenuItem>
{pathname === "/library/tags" && <SidebarActiveIndicator />}
<SidebarMenuButton
asChild
isActive={pathname === "/library/tags"}
@@ -85,6 +87,7 @@ export function LibraryTagsNav({
<ul className="flex w-full min-w-0 flex-col gap-0.5">
{topTags.map((tag) => (
<SidebarMenuItem key={tag.token}>
{pathname === `/library/${tag.token}` && <SidebarActiveIndicator />}
<SidebarMenuButton
isActive={pathname === `/library/${tag.token}`}
tooltip={tag.value}
@@ -24,16 +24,19 @@ export function MobileBottomNav() {
const { pathname } = useLocation();
return (
<nav className="flex shrink-0 items-stretch border-t border-ui-panel-border bg-ui-panel pb-[env(safe-area-inset-bottom)]">
<nav aria-label="Main navigation" className="flex shrink-0 items-stretch border-t border-ui-panel-border bg-ui-panel pb-[env(safe-area-inset-bottom)]">
{ITEMS.map((item) => {
const active = isActive(pathname, item.href);
return (
<Link
key={item.href}
to={item.href}
aria-current={active ? "page" : undefined}
className={twMerge(
"flex flex-1 flex-col items-center justify-center gap-1 py-2 font-mono text-[10px] font-semibold uppercase tracking-[0.12em] transition-colors",
active ? "text-white" : "text-base-fg/55 hover:text-base-fg/80",
"flex min-h-14 flex-1 flex-col items-center justify-center gap-1 border-t-2 py-2 font-mono text-[10px] font-semibold uppercase tracking-[0.12em] transition-colors",
active
? "border-primary bg-primary/10 text-ui-accent-ink"
: "border-transparent text-base-fg/70 hover:text-base-fg",
)}
>
<DynamicIcon icon={item.icon} className="h-5 w-5" />
@@ -0,0 +1,18 @@
import { motion, useReducedMotion } from "framer-motion";
export function SidebarActiveIndicator() {
const reduceMotion = useReducedMotion();
return (
<motion.span
aria-hidden="true"
layoutId="sidebar-active-indicator"
className="pointer-events-none absolute inset-y-1.5 left-0 z-10 w-0.5 bg-white"
transition={
reduceMotion
? { duration: 0 }
: { type: "spring", stiffness: 520, damping: 40, mass: 0.7 }
}
/>
);
}
@@ -1,5 +1,6 @@
import { useLocation, Link } from "react-router-dom";
import { Fragment } from "react";
import { ChevronRightIcon } from "lucide-react";
type Crumb = { label: string; href?: string };
@@ -77,26 +78,27 @@ export function Breadcrumbs() {
if (crumbs.length === 0) return null;
return (
<nav className="hidden md:flex items-center gap-1.5 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/45 min-w-0">
<nav aria-label="Breadcrumb" className="hidden md:flex items-center gap-1.5 hud-label text-base-fg/70 min-w-0">
{crumbs.map((crumb, i) => {
const isLast = i === crumbs.length - 1;
return (
<Fragment key={`${crumb.label}-${i}`}>
{i > 0 && (
<span className="text-white/25" aria-hidden>
›
</span>
<ChevronRightIcon
aria-hidden="true"
className="h-3 w-3 shrink-0 text-base-fg/50"
/>
)}
{crumb.href && !isLast ? (
<Link
to={crumb.href}
className="truncate hover:text-white/70 transition-colors"
className="truncate px-1.5 py-1 hover:bg-ui-ink hover:text-ui-panel transition-colors"
>
{crumb.label}
</Link>
) : (
<span
className={isLast ? "text-white/85 truncate" : "truncate"}
className={isLast ? "bg-ui-ink px-1.5 py-1 text-ui-panel truncate" : "truncate"}
aria-current={isLast ? "page" : undefined}
>
{crumb.label}
@@ -24,7 +24,7 @@ export function TopBar() {
const showSelectToggle = showViewToggle;
return (
<header className="sticky top-0 z-20 flex items-center gap-3 border-b border-white/15 bg-ui-background px-3 pb-4 pt-3 sm:pt-6">
<header className="sticky top-0 z-20 flex items-center gap-3 border-b border-ui-border bg-ui-panel px-3 py-3 sm:px-5">
{/* Left: sidebar trigger (mobile only) + logo (when sidebar closed) + breadcrumbs */}
<div className="flex items-center gap-2 min-w-0 shrink-0">
<SidebarTrigger className="md:hidden" />
@@ -0,0 +1,8 @@
const CORNERS = ["top-0 left-0", "top-0 right-0", "bottom-0 left-0", "bottom-0 right-0"];
/** Decorative registration marks for a positioned panel. */
export function FrameCornerMarks() {
return CORNERS.map((corner) => (
<span key={corner} aria-hidden="true" className={`frame-corner-mark ${corner}`} />
));
}
@@ -1,5 +1,6 @@
import { CreateEmptyState } from "../../components/generation-gallery/CreateEmptyState";
import { useCallback, useEffect, useRef, useState } from "react";
import { LoaderCircleIcon, SparklesIcon } from "lucide-react";
import { LoaderCircleIcon } from "lucide-react";
import {
PromptBoxVFX,
VFXResultCard,
@@ -267,7 +268,7 @@ export default function CreateVFX() {
className="relative z-10 flex h-full items-center justify-center px-3 sm:px-6"
style={{ paddingBottom: Math.max(promptBoxHeight + 32, 240) }}
>
<EmptyState
<CreateEmptyState
title="No background changes yet"
subtitle="Upload a source video and a reference image, then optionally add a prompt."
/>
@@ -330,18 +331,3 @@ export default function CreateVFX() {
</div>
);
}
interface EmptyStateProps {
title: string;
subtitle: string;
}
const EmptyState = ({ title, subtitle }: EmptyStateProps) => (
<div className="flex max-w-md flex-col items-center gap-4 text-center">
<div className="flex h-12 w-12 items-center justify-center border border-white/15 bg-white/5">
<SparklesIcon className="text-2xl text-white/40" />
</div>
<h3 className="text-2xl font-bold text-white">{title}</h3>
<p className="text-sm text-white/60 max-w-xs">{subtitle}</p>
</div>
);
@@ -1,3 +1,4 @@
import { AUTH_LABEL_CLASSES, AUTH_INPUT_CLASSES, AUTH_FORM_PADDING } from "../../components/auth/auth-form-styles";
import { ArrowLeftIcon, LoaderCircleIcon, MailIcon } from "lucide-react";
import { Button } from "@storyteller/ui-button";
import { Input } from "@storyteller/ui-input";
@@ -5,6 +6,8 @@ import { useState } from "react";
import { Link } from "react-router-dom";
import { PasswordResetApi } from "@storyteller/api";
import { AuthHeader } from "../../components/auth/auth-layout";
import { AuthPageFrame } from "../../components/auth/auth-page-frame";
import Seo from "../../components/seo";
const ForgotPassword = () => {
@@ -41,28 +44,17 @@ const ForgotPassword = () => {
};
return (
<div className="relative min-h-screen bg-ui-background text-white overflow-hidden flex flex-col">
<AuthPageFrame>
<Seo
title="Reset Password - ArtCraft"
description="Reset your ArtCraft password."
/>
<main className="relative z-10 flex-1 flex items-center justify-center p-4">
<div className="w-full max-w-md bg-[#101014] border border-white/15 p-6 py-8">
<main className={AUTH_FORM_PADDING}>
<div className="w-full">
{!submitted ? (
<>
<div className="text-center mb-8">
<img
src="/images/artcraft-icon.png"
alt="ArtCraft"
className="mx-auto mb-6 h-12 w-auto select-none pointer-events-none"
draggable={false}
/>
<h1 className="text-2xl font-semibold mb-2">Reset Password</h1>
<p className="text-white/60 text-sm">
Enter your email to receive reset instructions
</p>
</div>
<AuthHeader title={<>Reset your <span className="font-serif-italic">password.</span></>} subtitle="Enter your email or username to receive a reset code." />
<form
className="space-y-4"
@@ -78,7 +70,7 @@ const ForgotPassword = () => {
)}
<div className="space-y-2">
<label className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/60 ml-1">
<label className={AUTH_LABEL_CLASSES}>
Email or Username
</label>
<Input
@@ -87,7 +79,7 @@ const ForgotPassword = () => {
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
inputClassName="w-full bg-ui-controls border border-white/15 focus:border-white/40 px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
inputClassName={AUTH_INPUT_CLASSES}
/>
</div>
@@ -134,18 +126,14 @@ const ForgotPassword = () => {
<div className="mt-8 text-center text-sm">
<Link
to="/login"
className="text-white/40 hover:text-white transition-colors flex items-center justify-center gap-2"
className="text-white/60 hover:text-white transition-colors flex items-center justify-center gap-2"
>
<ArrowLeftIcon /> Back to Log in
</Link>
</div>
</div>
</main>
<div className="relative z-10 py-6 text-center text-white/20 text-xs">
&copy; {new Date().getFullYear()} ArtCraft. All rights reserved.
</div>
</div>
</AuthPageFrame>
);
};
@@ -1,3 +1,4 @@
import { AUTH_LABEL_CLASSES, AUTH_INPUT_CLASSES, AUTH_PASSWORD_INPUT_CLASSES, AUTH_FORM_PADDING } from "../../components/auth/auth-form-styles";
import {
ArrowLeftIcon,
CircleCheckIcon,
@@ -12,6 +13,8 @@ import { useState } from "react";
import { Link, useNavigate, useSearchParams } from "react-router-dom";
import { PasswordResetApi, BillingApi } from "@storyteller/api";
import { AuthHeader } from "../../components/auth/auth-layout";
import { AuthPageFrame } from "../../components/auth/auth-page-frame";
import Seo from "../../components/seo";
const VerifyReset = () => {
@@ -102,30 +105,17 @@ const VerifyReset = () => {
};
return (
<div className="relative min-h-screen bg-ui-background text-white overflow-hidden flex flex-col">
<AuthPageFrame>
<Seo
title="Verify Password Reset - ArtCraft"
description="Enter your verification code and new password."
/>
<main className="relative z-10 flex-1 flex items-center justify-center p-4">
<div className="w-full max-w-md bg-[#101014] border border-white/15 p-6 py-8">
<main className={AUTH_FORM_PADDING}>
<div className="w-full">
{!success ? (
<>
<div className="text-center mb-8">
<img
src="/images/artcraft-icon.png"
alt="ArtCraft"
className="mx-auto mb-6 h-12 w-auto select-none pointer-events-none"
draggable={false}
/>
<h1 className="text-2xl font-semibold mb-2">
Password Reset Verification
</h1>
<p className="text-white/60 text-sm">
Enter the code sent to your email address.
</p>
</div>
<AuthHeader title={<>Set a new <span className="font-serif-italic">password.</span></>} subtitle="Enter your reset code and choose a new password." />
<form
className="space-y-4"
@@ -142,7 +132,7 @@ const VerifyReset = () => {
{/* Verification Code */}
<div className="space-y-2">
<label className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/60 ml-1">
<label className={AUTH_LABEL_CLASSES}>
Verification Code
</label>
<Input
@@ -152,10 +142,10 @@ const VerifyReset = () => {
onChange={(e) => setVerificationCode(e.target.value)}
placeholder="Enter verification code"
isError={!!fieldErrors.verificationCode}
inputClassName="w-full bg-ui-controls border border-white/15 focus:border-white/40 px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
inputClassName={AUTH_INPUT_CLASSES}
/>
{fieldErrors.verificationCode && (
<p className="text-red-400 text-xs ml-1">
<p className="text-red-400 text-xs">
{fieldErrors.verificationCode}
</p>
)}
@@ -163,7 +153,7 @@ const VerifyReset = () => {
{/* New Password */}
<div className="space-y-2">
<label className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/60 ml-1">
<label className={AUTH_LABEL_CLASSES}>
New Password
</label>
<div className="relative">
@@ -174,7 +164,7 @@ const VerifyReset = () => {
onChange={(e) => setNewPassword(e.target.value)}
placeholder="Enter new password"
isError={!!fieldErrors.newPassword}
inputClassName="w-full bg-ui-controls border border-white/15 focus:border-white/40 px-4 py-3 text-white placeholder-white/20 outline-none transition-colors pr-12"
inputClassName={AUTH_PASSWORD_INPUT_CLASSES}
/>
<button
type="button"
@@ -187,7 +177,7 @@ const VerifyReset = () => {
</button>
</div>
{fieldErrors.newPassword && (
<p className="text-red-400 text-xs ml-1">
<p className="text-red-400 text-xs">
{fieldErrors.newPassword}
</p>
)}
@@ -195,7 +185,7 @@ const VerifyReset = () => {
{/* Verify New Password */}
<div className="space-y-2">
<label className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/60 ml-1">
<label className={AUTH_LABEL_CLASSES}>
Verify New Password
</label>
<div className="relative">
@@ -206,7 +196,7 @@ const VerifyReset = () => {
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="Enter new password again"
isError={!!fieldErrors.confirmPassword}
inputClassName="w-full bg-ui-controls border border-white/15 focus:border-white/40 px-4 py-3 text-white placeholder-white/20 outline-none transition-colors pr-12"
inputClassName={AUTH_PASSWORD_INPUT_CLASSES}
/>
<button
type="button"
@@ -221,7 +211,7 @@ const VerifyReset = () => {
</button>
</div>
{fieldErrors.confirmPassword && (
<p className="text-red-400 text-xs ml-1">
<p className="text-red-400 text-xs">
{fieldErrors.confirmPassword}
</p>
)}
@@ -246,7 +236,7 @@ const VerifyReset = () => {
<div className="mt-8 text-center text-sm">
<Link
to="/forgot-password"
className="text-white/40 hover:text-white transition-colors flex items-center justify-center gap-2"
className="text-white/60 hover:text-white transition-colors flex items-center justify-center gap-2"
>
<ArrowLeftIcon /> Request a new code
</Link>
@@ -277,11 +267,7 @@ const VerifyReset = () => {
)}
</div>
</main>
<div className="relative z-10 py-6 text-center text-white/20 text-xs">
&copy; {new Date().getFullYear()} ArtCraft. All rights reserved.
</div>
</div>
</AuthPageFrame>
);
};
@@ -22,7 +22,7 @@ type AppCard = {
href: string;
icon: LucideIcon;
iconTile: string;
hoverBorder: string;
hoverStyle: string;
badge?: string;
};
@@ -33,7 +33,7 @@ const APPS: AppCard[] = [
href: "/create-image",
icon: ImageIcon,
iconTile: "border-blue-400/40 bg-blue-500/20 text-blue-300",
hoverBorder: "hover:border-blue-400/60",
hoverStyle: "hover:border-blue-400/60 hover:bg-blue-500/10",
},
{
label: "Video",
@@ -41,7 +41,7 @@ const APPS: AppCard[] = [
href: "/create-video",
icon: VideoIcon,
iconTile: "border-purple-400/40 bg-purple-500/20 text-purple-300",
hoverBorder: "hover:border-purple-400/60",
hoverStyle: "hover:border-purple-400/60 hover:bg-purple-500/10",
},
{
label: "Audio",
@@ -49,7 +49,7 @@ const APPS: AppCard[] = [
href: "/create-audio",
icon: MusicIcon,
iconTile: "border-pink-400/40 bg-pink-500/20 text-pink-300",
hoverBorder: "hover:border-pink-400/60",
hoverStyle: "hover:border-pink-400/60 hover:bg-pink-500/10",
},
{
label: "3D Object",
@@ -57,7 +57,7 @@ const APPS: AppCard[] = [
href: "/create-object",
icon: BoxIcon,
iconTile: "border-cyan-400/40 bg-cyan-500/20 text-cyan-300",
hoverBorder: "hover:border-cyan-400/60",
hoverStyle: "hover:border-cyan-400/60 hover:bg-cyan-500/10",
},
{
label: "3D World",
@@ -65,7 +65,7 @@ const APPS: AppCard[] = [
href: "/create-world",
icon: GlobeIcon,
iconTile: "border-teal-400/40 bg-teal-500/20 text-teal-300",
hoverBorder: "hover:border-teal-400/60",
hoverStyle: "hover:border-teal-400/60 hover:bg-teal-500/10",
},
{
label: "Edit 3D",
@@ -73,7 +73,7 @@ const APPS: AppCard[] = [
href: "/edit-3d",
icon: BoxIcon,
iconTile: "border-amber-400/40 bg-amber-500/20 text-amber-300",
hoverBorder: "hover:border-amber-400/60",
hoverStyle: "hover:border-amber-400/60 hover:bg-amber-500/10",
},
{
label: "Background Change",
@@ -81,7 +81,7 @@ const APPS: AppCard[] = [
href: "/background-change",
icon: WandSparklesIcon,
iconTile: "border-emerald-400/40 bg-emerald-500/20 text-emerald-300",
hoverBorder: "hover:border-emerald-400/60",
hoverStyle: "hover:border-emerald-400/60 hover:bg-emerald-500/10",
},
{
label: "Edit Video",
@@ -89,7 +89,7 @@ const APPS: AppCard[] = [
href: "/video-editor",
icon: FilmIcon,
iconTile: "border-rose-400/40 bg-rose-500/20 text-rose-300",
hoverBorder: "hover:border-rose-400/60",
hoverStyle: "hover:border-rose-400/60 hover:bg-rose-500/10",
badge: "BETA",
},
{
@@ -98,7 +98,7 @@ const APPS: AppCard[] = [
href: "/moodboard",
icon: GroupIcon,
iconTile: "border-indigo-400/40 bg-indigo-500/20 text-indigo-300",
hoverBorder: "hover:border-indigo-400/60",
hoverStyle: "hover:border-indigo-400/60 hover:bg-indigo-500/10",
badge: "BETA",
},
{
@@ -107,33 +107,38 @@ const APPS: AppCard[] = [
href: "/frame-extractor",
icon: ImagesIcon,
iconTile: "border-orange-400/40 bg-orange-500/20 text-orange-300",
hoverBorder: "hover:border-orange-400/60",
hoverStyle: "hover:border-orange-400/60 hover:bg-orange-500/10",
badge: "NEW",
},
];
export function Home() {
return (
<div className="min-h-full px-6 sm:px-10 py-10 sm:py-16 max-w-6xl mx-auto w-full">
<div className="min-h-full px-5 py-8 sm:px-8 sm:py-12 max-w-6xl mx-auto w-full">
<Seo
title="ArtCraft - Create AI Images and Video"
description="Generate AI images and video with ArtCraft."
/>
<p className="hud-label mb-4 text-ui-accent-ink">Your creative workspace</p>
<Reveal
as="h1"
inView={false}
y={20}
className="text-center font-display text-4xl sm:text-6xl mx-auto font-semibold tracking-tight"
className="max-w-3xl font-display text-3xl leading-tight sm:text-5xl tracking-tight"
>
What will you <span className="text-primary">craft</span> today?
What will you <span className="text-ui-accent-ink">craft</span> today?
</Reveal>
<section className="py-12">
<p className="mt-4 max-w-xl text-sm leading-relaxed text-base-fg/70 sm:text-base">
Start with an idea. Choose a tool to bring it to life.
</p>
<section className="mt-8 border-t border-ui-border pt-6 sm:mt-10">
<Reveal
as="h2"
inView={false}
delay={0.08}
className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/60 mb-4"
className="hud-label text-base-fg/70 mb-4"
>
Create
</Reveal>
@@ -141,33 +146,29 @@ export function Home() {
inView={false}
delayChildren={0.12}
stagger={0.06}
className="grid gap-3 auto-rows-fr sm:grid-cols-2 lg:grid-cols-3"
className="grid gap-3 auto-rows-fr md:grid-cols-2 xl:grid-cols-3"
>
{APPS.map((app) => (
<Reveal key={app.href} y={20}>
<Link
to={app.href}
className={`group relative flex h-full border rounded-[3px] border-white/10 bg-white/5 p-5 transition-colors duration-150 ${app.hoverBorder}`}
className={`group relative flex h-full rounded-[3px] border border-ui-border bg-white/5 p-5 transition-colors duration-150 focus-visible:border-primary ${app.hoverStyle}`}
>
<div className="relative flex w-full items-start gap-4">
<div
className={`flex h-11 w-11 shrink-0 items-center justify-center border ${app.iconTile}`}
className={`flex h-10 w-10 shrink-0 items-center justify-center border ${app.iconTile}`}
>
<DynamicIcon icon={app.icon} className="text-base" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<h3 className=" text-sm font-bold uppercase tracking-[0.08em] text-white truncate">
<div className="flex flex-wrap items-center gap-2 min-w-0">
<h3 className="text-base font-bold uppercase tracking-tight text-ui-ink">
{app.label}
</h3>
{app.badge && (
<span
className={`shrink-0 border px-1.5 py-0.5 font-mono text-[9px] font-semibold uppercase tracking-[0.12em] leading-none ${
app.badge === "NEW"
? "border-purple-400/40 text-purple-300"
: "border-amber-400/40 text-amber-300"
}`}
className="shrink-0 border border-primary/30 px-1.5 py-1 font-mono text-[9px] font-medium tracking-wider leading-none text-ui-accent-ink"
>
{app.badge}
</span>
@@ -177,7 +178,7 @@ export function Home() {
<ArrowRightIcon className="text-xs" />
</span>
</div>
<p className="mt-1 text-sm text-white/55 leading-snug">
<p className="mt-2 text-sm text-base-fg/70 leading-relaxed">
{app.description}
</p>
</div>
@@ -1375,7 +1375,7 @@ export default function Library() {
</span>
))}
<span className="text-white/30">/</span>
<h1 className="text-lg sm:text-xl font-medium text-white truncate max-w-[16rem]">
<h1 className="font-sans text-sm font-semibold tracking-normal [font-stretch:normal] text-white truncate max-w-[16rem]">
{activeFolder?.name}
</h1>
<button
@@ -1405,11 +1405,11 @@ export default function Library() {
Tags
</button>
<span className="text-white/30">/</span>
<h1 className="text-lg sm:text-xl font-medium text-white truncate max-w-[16rem]">
<h1 className="font-sans text-sm font-semibold tracking-normal [font-stretch:normal] text-white truncate max-w-[16rem]">
{activeTag?.value ?? "Tag"}
</h1>
{activeTag && (
<span className="text-white/40 text-sm pt-1 ps-1.5">
<span className="text-white/40 text-xs ps-1.5">
{activeTag.useCount} file
{activeTag.useCount === 1 ? "" : "s"}
</span>
@@ -1,3 +1,4 @@
import { AUTH_LABEL_CLASSES, AUTH_INPUT_CLASSES, AUTH_PASSWORD_INPUT_CLASSES } from "../../components/auth/auth-form-styles";
import { EyeIcon, EyeOffIcon, LoaderCircleIcon } from "lucide-react";
import { DynamicIcon } from "@storyteller/icons";
import { Button } from "@storyteller/ui-button";
@@ -11,7 +12,6 @@ import {
GoogleLoginButton,
} from "../../components/auth";
import Seo from "../../components/seo";
import { Reveal, RevealGroup } from "../../components/motion/reveal";
import { refreshSession } from "../../lib/session";
import { hasActiveSubscription } from "../../lib/billing";
@@ -70,7 +70,7 @@ const Login = () => {
title="Login - ArtCraft"
description="Login to your ArtCraft account."
/>
<AuthHeader title="Welcome Back" subtitle="Log in to your account" />
<AuthHeader title={<>Welcome <span className="font-serif-italic">back.</span></>} subtitle="Log in to your creative workspace." />
<form
className="space-y-4"
@@ -84,20 +84,11 @@ const Login = () => {
{error}
</div>
)}
{/* The form fields cascade in as their own beat, distinct from the
shell/header settling. We lead with the email field on an almost-zero
delay so the primary input lands immediately — the entrance never
gates typing: inputs stay focusable and accept keystrokes while the
password/button below are still cascading in. */}
<RevealGroup
inView={false}
delayChildren={0.04}
stagger={0.08}
<div
className="space-y-4"
>
<Reveal className="space-y-2">
<label className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/70 ml-1">
<div className="space-y-2">
<label className={AUTH_LABEL_CLASSES}>
Email or Username
</label>
<Input
@@ -105,12 +96,12 @@ const Login = () => {
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com or username"
inputClassName="w-full bg-ui-controls border border-white/15 focus:border-white/40 px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
inputClassName={AUTH_INPUT_CLASSES}
/>
</Reveal>
<Reveal className="space-y-2">
<div className="flex justify-between items-center ml-1">
<label className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/70">
</div>
<div className="space-y-2">
<div className="flex justify-between items-center">
<label className={AUTH_LABEL_CLASSES}>
Password
</label>
<Link
@@ -126,7 +117,7 @@ const Login = () => {
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Min. 8 characters"
inputClassName="w-full bg-ui-controls border border-white/15 focus:border-white/40 px-4 py-3 text-white placeholder-white/20 outline-none transition-colors pr-12"
inputClassName={AUTH_PASSWORD_INPUT_CLASSES}
/>
<button
type="button"
@@ -137,9 +128,9 @@ const Login = () => {
<DynamicIcon icon={showPassword ? EyeOffIcon : EyeIcon} />
</button>
</div>
</Reveal>
</div>
<Reveal className="pt-2">
<div className="pt-2">
<Button
className="w-full justify-center h-10"
type="submit"
@@ -151,32 +142,25 @@ const Login = () => {
"Log in"
)}
</Button>
</Reveal>
</RevealGroup>
</form>
{/* Secondary sign-in options pick up the same cascade just after the
fields (delay continues from the form group's three children above:
0.04 + 3 × 0.08). */}
<RevealGroup inView={false} delayChildren={0.28} stagger={0.08}>
<Reveal className="relative my-6 flex items-center justify-center">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-white/15" />
</div>
<span className="relative bg-[#101014] px-4 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/40">
</div>
</form>
<div>
<div className="my-6 flex items-center gap-4 before:h-px before:flex-1 before:bg-white/15 after:h-px after:flex-1 after:bg-white/15">
<span className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/40">
or
</span>
</Reveal>
</div>
<Reveal>
<div>
<GoogleLoginButton
mode="login"
onSuccess={handleGoogleSuccess}
onError={handleGoogleError}
/>
</Reveal>
</div>
<Reveal>
<div>
<AuthFooter>
Don't have an account?{" "}
<Link
@@ -186,8 +170,8 @@ const Login = () => {
Sign up
</Link>
</AuthFooter>
</Reveal>
</RevealGroup>
</div>
</div>
</>
);
};
@@ -22,7 +22,7 @@ export function SceneSplashModal({
<Modal
isOpen={isOpen}
onClose={close}
className="w-full max-w-3xl overflow-hidden border border-white/15 bg-ui-modal p-0"
className="w-full max-w-3xl overflow-hidden border border-white/15 bg-ui-controls p-0"
childPadding={false}
backdropClassName="!bg-black/80"
closeOnOutsideClick
@@ -1,86 +1,12 @@
import { useEffect, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { CoinsIcon } from "lucide-react";
import { ReactNode, useEffect, useState } from "react";
import { Link, useSearchParams } from "react-router-dom";
import { CoinsIcon, TagIcon } from "lucide-react";
import { Button } from "@storyteller/ui-button";
import { BillingApi, UsersApi } from "@storyteller/api";
import { PricingTable, PROMO_PCT } from "@storyteller/ui-pricing-table";
import "@storyteller/ui-pricing-table/styles.css";
import Seo from "../../components/seo";
import {
PricingTable,
PricingPromoBanner,
} from "@storyteller/ui-pricing-table";
import { CreditsModal } from "../../components/credits-modal";
import { Reveal } from "../../components/motion/reveal";
const SeedanceBanner = () => (
<div className="flex flex-col gap-5">
<div className="flex flex-wrap items-center gap-2">
<span className="inline-flex items-center font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white bg-white/10 border border-white/30 px-3 py-1">
Early access
</span>
<span className="inline-flex items-center font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/65 bg-white/5 border border-white/15 px-3 py-1">
Available today in ArtCraft
</span>
</div>
<div>
<h1 className="text-3xl md:text-4xl lg:text-5xl tracking-[-0.035em] font-medium leading-[1.05] mb-3">
Seedance 2.0 is <span className="font-serif-italic">here</span>
</h1>
<p className="text-white/55 text-base md:text-lg leading-relaxed">
Generate jaw-dropping AI videos with Seedance 2.0 before it's available
anywhere else. Every paid plan includes video credits, so you can start
creating right now.
</p>
</div>
<div className="relative w-full overflow-hidden bg-[#080808] border border-white/15">
<div style={{ paddingTop: "56.25%" }} className="relative">
<iframe
src="https://player.vimeo.com/video/1169289718?autoplay=1&muted=1&loop=1&background=0&byline=0&portrait=0&title=0"
className="absolute inset-0 w-full h-full"
allow="autoplay; fullscreen; picture-in-picture"
allowFullScreen
title="Seedance in ArtCraft"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="bg-[#101014] border border-white/30 p-4">
<div className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white mb-1">
Seedance video credits
</div>
<div className="text-white/55 text-sm leading-snug">
Included with every paid ArtCraft plan
</div>
</div>
<div className="bg-[#101014] border border-white/15 p-4">
<div className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/70 mb-1">
First in the world
</div>
<div className="text-white/55 text-sm leading-snug">
Seedance launches in ArtCraft ahead of anywhere else
</div>
</div>
</div>
</div>
);
// Compact "top up" CTA shown above the plan table for users who already have a
// plan, so returning subscribers can buy one-time credit packs without scrolling.
const BuyMoreCreditsCta = ({ onClick }: { onClick: () => void }) => (
<div className="mb-8 flex flex-col items-center text-center">
<p className="text-base text-white/65">Need more credits?</p>
<Button
variant="secondary"
className="mt-3 gap-2 border border-white/15 bg-transparent hover:bg-white/10 px-5 py-2 h-11 text-white"
onClick={onClick}
>
<CoinsIcon className="text-[13px]" />
Buy more credits
</Button>
</div>
);
const Pricing = () => {
const [searchParams] = useSearchParams();
@@ -118,85 +44,153 @@ const Pricing = () => {
title="Pricing - ArtCraft"
description="Simple, transparent pricing for ArtCraft. Start for free and scale as you grow."
/>
<main className="relative z-10 px-4 sm:px-8 pt-10">
{isSeedanceRef ? (
<div className="max-w-6xl mx-auto grid grid-cols-1 lg:grid-cols-[5fr_7fr] gap-8 xl:gap-12 items-start">
<SeedanceBanner />
<div className="w-full">
{hasPlan && (
<BuyMoreCreditsCta onClick={() => setCreditsModalOpen(true)} />
)}
<PricingTable
showHeader={false}
unifiedTheme
compact
showSeedanceFeatures
showEnterprise
/>
<main className="pb-16">
{!isSeedanceRef && (
<div className="border-t border-white/15 bg-primary text-white">
<div className="mx-auto flex max-w-[1280px] flex-wrap items-center justify-between gap-x-6 gap-y-1 px-6 py-2.5 md:px-10">
<p className="pricing-hud flex items-center gap-2 font-bold">
<TagIcon aria-hidden className="h-3.5 w-3.5" />
Limited-time offer
</p>
<p className="text-sm">
Save {PROMO_PCT}% on every plan, monthly or yearly — lock in the
lowest price today.
</p>
</div>
</div>
) : (
<div className="max-w-6xl mx-auto">
<PricingPromoBanner className="mb-10 sm:mb-12" />
<Reveal
as="div"
inView={false}
y={20}
className="text-center mb-14"
>
<h1 className="text-4xl sm:text-5xl md:text-6xl tracking-[-0.035em] font-medium leading-[1.02] mb-5">
Invest in <span className="font-serif-italic">yourself</span>.
</h1>
<p className="max-w-xl mx-auto text-base sm:text-lg text-white/55 leading-relaxed">
Get a ton of generations and invest in a tool you'll always own.
Your subscription helps keep ArtCraft free and open for
everyone.
</p>
</Reveal>
{hasPlan && (
<BuyMoreCreditsCta onClick={() => setCreditsModalOpen(true)} />
)}
<PricingTable
showHeader={false}
unifiedTheme
showSeedanceFeatures
showEnterprise
/>
</div>
)}
</main>
{isLoggedIn && !hasPlan && (
<div className="relative z-10 flex flex-col items-center px-4 pb-4 sm:px-8 pt-6">
<div className="inline-flex items-center gap-2 text-white/40">
<div className="h-px w-8 bg-white/20" />
<span className="text-sm">Or</span>
<div className="h-px w-8 bg-white/20" />
<PricingSection>
<SectionLabel
index="01"
label={isSeedanceRef ? "Early access" : "Pricing"}
annotation={
isSeedanceRef
? "Available today in ArtCraft"
: "Free & open source · Subscriptions optional†"
}
/>
<div className="px-6 py-14 md:px-10 md:py-20">
<h1 className="max-w-3xl font-display text-4xl font-medium leading-[1.02] tracking-[-0.035em] sm:text-5xl md:text-6xl">
{isSeedanceRef ? (
<>
Seedance 2.0 is{" "}
<span className="font-serif-italic">here</span>.
</>
) : (
"Pick a plan"
)}
</h1>
<p className="mt-5 max-w-xl text-lg leading-relaxed text-white/55">
{isSeedanceRef
? "Generate jaw-dropping AI videos with Seedance 2.0 before it's available anywhere else. Every paid plan includes video credits, so you can start creating right now."
: "Get a ton of generations and invest in a tool you'll always own. Your subscription helps keep ArtCraft free and open for everyone."}
</p>
{hasPlan && (
<Button
variant="secondary"
className="mt-6 gap-2 rounded-none text-[11px]"
onClick={() => setCreditsModalOpen(true)}
>
<CoinsIcon aria-hidden className="h-4 w-4" />
Buy more credits
</Button>
)}
</div>
<p className="mt-3 text-base text-white/65">
Purchase one-time credit packs
</p>
<Button
variant="secondary"
className="mt-4 gap-2 border border-white/15 bg-transparent hover:bg-white/10 px-5 py-2 h-11 text-white"
onClick={() => setCreditsModalOpen(true)}
>
<CoinsIcon className="text-[13px]" />
Buy Credits
</Button>
</div>
)}
{/* Footnote */}
<div className="relative z-10 max-w-3xl mx-auto px-4 sm:px-8 pb-16 text-center mt-6">
<p className="text-sm text-white/45 leading-relaxed">
&dagger; ArtCraft can be used without paying for a subscription. You
can bring your own compute and third party subscriptions. We hope
you'll subscribe, though, as that helps accelerate our development.
</p>
</div>
</PricingSection>
{isSeedanceRef && (
<PricingSection>
<div className="grid lg:grid-cols-[3fr_2fr]">
<figure className="min-w-0">
<figcaption className="pricing-hud flex justify-between border-b border-white/15 px-6 py-2.5 text-white/55 md:px-8">
<span>Seedance in ArtCraft</span>
<span>Reel</span>
</figcaption>
<div className="relative aspect-video overflow-hidden bg-black">
<iframe
src="https://player.vimeo.com/video/1169289718?autoplay=1&muted=1&loop=1&background=0&byline=0&portrait=0&title=0"
className="absolute inset-0 h-full w-full"
allow="autoplay; fullscreen; picture-in-picture"
allowFullScreen
title="Seedance in ArtCraft"
/>
</div>
</figure>
<div className="grid border-t border-white/15 sm:grid-cols-2 lg:grid-cols-1 lg:border-l lg:border-t-0">
<div className="p-6 md:p-8">
<p className="pricing-hud text-primary">
Seedance video credits
</p>
<p className="mt-3 leading-relaxed text-white/55">
Included with every paid ArtCraft plan.
</p>
</div>
<div className="border-t border-white/15 p-6 sm:border-l sm:border-t-0 md:p-8 lg:border-l-0 lg:border-t">
<p className="pricing-hud text-white/55">
First in the world
</p>
<p className="mt-3 leading-relaxed text-white/55">
Seedance launches in ArtCraft ahead of anywhere else.
</p>
</div>
</div>
</div>
</PricingSection>
)}
<PricingSection id="plans">
<SectionLabel
index="02"
label="Choose your plan"
annotation="Every paid plan includes video credits"
/>
<PricingTable
showHeader={false}
websiteLayout
showSeedanceFeatures
showEnterprise
/>
</PricingSection>
<PricingSection id="credits">
<div className="grid md:grid-cols-2">
<div className="p-6 md:p-10">
<p className="pricing-hud text-white/40">
{isLoggedIn ? "Need more credits?" : "Already have an account?"}
</p>
<h2 className="mt-4 font-display text-2xl font-medium tracking-[-0.02em]">
One-time credit packs
</h2>
<p className="mt-2 max-w-md leading-relaxed text-white/55">
Top up without changing your plan. Credit packs never expire.
</p>
{isLoggedIn ? (
<Button
variant="secondary"
className="mt-6 gap-2 rounded-none text-[11px]"
onClick={() => setCreditsModalOpen(true)}
>
<CoinsIcon aria-hidden className="h-4 w-4 text-primary" />
Buy credits
</Button>
) : (
<Link
to="/login?from=%2Fpricing"
className="pricing-hud mt-6 inline-flex border border-white/15 px-4 py-3 hover:bg-white/10"
>
Sign in to buy credits
</Link>
)}
</div>
<div className="border-t border-white/15 p-6 md:border-l md:border-t-0 md:p-10">
<p className="pricing-hud text-white/40">† Footnote</p>
<p className="mt-4 max-w-md leading-relaxed text-white/55">
ArtCraft can be used without paying for a subscription. You can
bring your own compute and third-party subscriptions. We hope
you’ll subscribe, though, as that helps accelerate our
development.
</p>
</div>
</div>
</PricingSection>
</main>
{isLoggedIn && (
<CreditsModal
isOpen={creditsModalOpen}
@@ -207,4 +201,40 @@ const Pricing = () => {
);
};
function PricingSection({
id,
children,
}: {
id?: string;
children: ReactNode;
}) {
return (
<section id={id} className="border-t border-white/15">
<div className="relative mx-auto max-w-[1280px] border-x border-white/15">
{children}
</div>
</section>
);
}
function SectionLabel({
index,
label,
annotation,
}: {
index: string;
label: string;
annotation: string;
}) {
return (
<div className="pricing-hud flex items-center justify-between gap-4 border-b border-white/15 px-6 py-3 text-white/55 md:px-10">
<p>
<span className="text-white/40">{index} / </span>
{label}
</p>
<p className="hidden text-white/40 sm:block">{annotation}</p>
</div>
);
}
export default Pricing;
@@ -145,7 +145,7 @@ export default function Referrals() {
<span className="inline-block font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/60 mb-5">
Share the craft
</span>
<h1 className="text-5xl tracking-[-0.035em] font-medium leading-[1.02] mb-5">
<h1 className="font-display text-3xl sm:text-4xl tracking-tight leading-[1.02] mb-5">
Referrals
</h1>
<p className="mx-auto text-base sm:text-lg text-white/55 leading-relaxed">
@@ -1,3 +1,4 @@
import { AUTH_LABEL_CLASSES, AUTH_PASSWORD_INPUT_CLASSES, AUTH_FORM_PADDING } from "../../components/auth/auth-form-styles";
import { EyeIcon, EyeOffIcon, LoaderCircleIcon } from "lucide-react";
import { DynamicIcon } from "@storyteller/icons";
import { Button } from "@storyteller/ui-button";
@@ -6,6 +7,8 @@ import { useState } from "react";
import { useNavigate, Navigate } from "react-router-dom";
import { UsersApi } from "@storyteller/api";
import { useSession, refreshSession } from "../../lib/session";
import { AuthHeader } from "../../components/auth/auth-layout";
import { AuthPageFrame } from "../../components/auth/auth-page-frame";
import Seo from "../../components/seo";
import { toast } from "../../components/toast/toast";
@@ -66,26 +69,15 @@ const SetPassword = () => {
};
return (
<div className="relative min-h-screen bg-ui-background text-white overflow-hidden flex flex-col">
<AuthPageFrame>
<Seo
title="Set Password - ArtCraft"
description="Set a password for your ArtCraft account."
/>
<main className="relative z-10 flex-1 flex items-center justify-center p-4">
<div className="w-full max-w-md bg-[#101014] border border-white/15 p-6 py-8">
<div className="text-center mb-8">
<img
src="/images/artcraft-icon.png"
alt="ArtCraft"
className="mx-auto mb-6 h-12 w-auto select-none pointer-events-none"
draggable={false}
/>
<h1 className="text-2xl font-semibold mb-2">Set a password</h1>
<p className="text-white/60 text-sm">
Create a password so you can also log into the desktop app
</p>
</div>
<main className={AUTH_FORM_PADDING}>
<div className="w-full">
<AuthHeader title={<>Set your <span className="font-serif-italic">password.</span></>} subtitle="Create a password to sign in on the web and desktop." />
<form
className="space-y-4"
@@ -101,7 +93,7 @@ const SetPassword = () => {
)}
<div className="space-y-2">
<label className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/60 ml-1">
<label className={AUTH_LABEL_CLASSES}>
Password
</label>
<div className="relative">
@@ -111,7 +103,7 @@ const SetPassword = () => {
onChange={(e) => setPassword(e.target.value)}
placeholder="Min. 8 characters"
autoFocus
inputClassName="w-full bg-ui-controls border border-white/15 focus:border-white/40 px-4 py-3 text-white placeholder-white/20 outline-none transition-colors pr-12"
inputClassName={AUTH_PASSWORD_INPUT_CLASSES}
/>
<button
type="button"
@@ -125,7 +117,7 @@ const SetPassword = () => {
</div>
<div className="space-y-2">
<label className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/60 ml-1">
<label className={AUTH_LABEL_CLASSES}>
Confirm password
</label>
<div className="relative">
@@ -134,7 +126,7 @@ const SetPassword = () => {
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="Re-enter your password"
inputClassName="w-full bg-ui-controls border border-white/15 focus:border-white/40 px-4 py-3 text-white placeholder-white/20 outline-none transition-colors pr-12"
inputClassName={AUTH_PASSWORD_INPUT_CLASSES}
/>
<button
type="button"
@@ -165,11 +157,7 @@ const SetPassword = () => {
</form>
</div>
</main>
<div className="relative z-10 py-6 text-center text-white/20 text-xs">
&copy; {new Date().getFullYear()} ArtCraft. All rights reserved.
</div>
</div>
</AuthPageFrame>
);
};
@@ -1,7 +1,6 @@
import { Link, useNavigate } from "react-router-dom";
import { AuthHeader, AuthFooter, SignupForm } from "../../components/auth";
import Seo from "../../components/seo";
import { Reveal } from "../../components/motion/reveal";
const Signup = () => {
const navigate = useNavigate();
@@ -12,17 +11,13 @@ const Signup = () => {
title="Sign Up - ArtCraft"
description="Create your ArtCraft account."
/>
<AuthHeader title="Create an Account" subtitle="Join thousands of creators" />
{/* SignupForm runs its own field cascade internally (see signup-form.tsx).
The footer picks up the tail of that cascade — the form's six staggered
steps land by ~0.36s, so the footer follows at ~0.44s. */}
<AuthHeader title={<>Make it <span className="font-serif-italic">yours.</span></>} subtitle="Create your account and start crafting." />
<SignupForm
onSuccess={() => navigate("/welcome")}
signupSource="artcraft"
/>
<Reveal inView={false} delay={0.44}>
<div>
<AuthFooter>
Already have an account?{" "}
<Link
@@ -32,7 +27,7 @@ const Signup = () => {
Log in
</Link>
</AuthFooter>
</Reveal>
</div>
</>
);
};
@@ -44,12 +44,12 @@ const Support = () => {
"Get help with ArtCraft: browse the FAQ, join our Discord community, or email us directly.";
return (
<div className="relative min-h-screen bg-ui-background text-white">
<div className="relative min-h-full bg-ui-background text-white">
<Seo title={title} description={description} />
<main className="relative z-10 mx-auto w-full max-w-4xl px-4 sm:px-6 pt-24 sm:pt-32 pb-20">
<header className="mb-8 sm:mb-10 flex flex-col items-center text-center gap-3">
<h1 className="text-3xl sm:text-5xl font-semibold tracking-[-0.02em]">
<main className="relative z-10 mx-auto w-full max-w-4xl px-5 py-8 sm:px-8 sm:py-12">
<header className="mb-8 flex flex-col gap-3 border-b border-ui-border pb-8">
<h1 className="font-display text-3xl sm:text-4xl tracking-tight">
Support
</h1>
<p className="text-[15px] sm:text-base text-white/60 max-w-xl">
+41 -6
View File
@@ -7,21 +7,22 @@
/* Sidebar tokens (consumed by tailwind colors in tailwind.config.js).
Brutalist system: near-black surfaces, white/15 hairlines, white
accent for selection/focus. */
color-scheme: dark;
--sidebar-bg: #1e1f22;
--sidebar-fg: rgba(255, 255, 255, 0.72);
--sidebar-border: rgba(255, 255, 255, 0.15);
--sidebar-accent: rgba(255, 255, 255, 0.08);
--sidebar-accent-fg: #ffffff;
--sidebar-ring: rgba(255, 255, 255, 0.4);
--sidebar-ring: #74aaff;
/* shadcn design tokens consumed by @storyteller/ui-video-editor.
Dark-theme palette (the editor only runs in dark mode today).
--primary matches the webapp's brand blue (#2d81ff). */
--background: 240 5% 8%;
--background: 225 10% 8%;
--foreground: 0 0% 98%;
--card: 240 11% 7%;
--card-foreground: 0 0% 98%;
--popover: 240 4% 5%;
--popover: 240 11% 7%;
--popover-foreground: 0 0% 98%;
--primary: 216 100% 59%;
--primary-foreground: 0 0% 98%;
@@ -35,7 +36,7 @@
--destructive-foreground: 0 0% 98%;
--border: 0 0% 100% / 0.15;
--input: 0 0% 100% / 0.15;
--ring: 0 0% 100% / 0.6;
--ring: 217 100% 73%;
/* ── Motion tokens ──────────────────────────────────────────────────
Shared easing curves + durations so motion across the app feels like
@@ -144,6 +145,7 @@
h1,
h2 {
font-stretch: 118%;
font-weight: 620;
}
body {
@@ -156,12 +158,22 @@
"Helvetica Neue",
Arial,
sans-serif;
color: #ffffff;
background-color: #0b0b0c;
color: #f2f1ee;
background-color: #121316;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
::selection {
background: #2d81ff;
color: #ffffff;
}
:focus-visible {
outline: 2px solid #74aaff;
outline-offset: 3px;
}
/* Mono HUD micro-label, matching the marketing site's .hud-label. */
.hud-label {
font-family:
@@ -514,3 +526,26 @@
vertical-align: -0.2em;
}
}
/* Fine registration marks echo the landing page section corners. */
.frame-corner-mark {
position: absolute;
width: 11px;
height: 11px;
margin: -5px;
pointer-events: none;
background:
linear-gradient(rgba(242, 241, 238, 0.4), rgba(242, 241, 238, 0.4)) center / 1px 11px no-repeat,
linear-gradient(rgba(242, 241, 238, 0.4), rgba(242, 241, 238, 0.4)) center / 11px 1px no-repeat;
}
/* Contained section borders match the landing without decorative extensions. */
.create-empty-section {
margin-inline: 12px;
border: 1px solid rgba(242, 241, 238, 0.2);
background: rgba(242, 241, 238, 0.015);
}
@media (min-width: 640px) {
.create-empty-section { margin-inline: 32px; }
}
@@ -1,6 +1,6 @@
const { createGlobPatternsForDependencies } = require("@nx/react/tailwind");
const { join } = require("path");
import colors, { teal } from "tailwindcss/colors";
import colors from "tailwindcss/colors";
/** @type {import('tailwindcss').Config} */
module.exports = {
@@ -167,7 +167,7 @@ module.exports = {
900: "#3d3d3d",
950: "#3E3E41",
},
"base-fg": "rgb(255 255 255 / <alpha-value>)",
"base-fg": "rgb(242 241 238 / <alpha-value>)",
sidebar: {
DEFAULT: "var(--sidebar-bg)",
foreground: "var(--sidebar-fg)",
@@ -180,17 +180,18 @@ module.exports = {
// Brutalist system: near-black surfaces with white/15 hairline
// borders, aligned with artcraft-website + artcraft-website-next.
background: "#121316",
panel: "#0b0b0c",
panel: "#101014",
"panel-border": "rgb(255 255 255 / 0.15)",
border: "rgb(255 255 255 / 0.15)",
// Raised control surface — a step above the #0b0b0c page/panel bg
// (matches the sidebar's #1e1f22) so buttons and inputs read as
// surfaces instead of blending into the background.
// Raised controls stay distinct from the website-matched panel surface.
controls: "#1e1f22",
"controls-button": "#1e1f22",
"controls-border": "rgb(255 255 255 / 0.15)",
divider: "rgb(255 255 255 / 0.15)",
modal: "#0b0b0c",
modal: "#101014",
ink: "#f2f1ee",
"accent-ink": "#74aaff",
sunken: "#060607",
},
"axis-x": "#D33242",
"axis-y": "#308752",
+3 -2
View File
@@ -68,8 +68,9 @@ Then read values where they're used:
The panel picks up new groups automatically — no panel changes needed.
Registered groups: Intro (page-intro choreography beats; `src/lib/intro.ts`),
Wordmark (logo-A optics, blade tuck, contrast scrim;
`src/components/landing/hero-wordmark.tsx`), Galaxy layout, Galaxy motion,
Galaxy pointer, Galaxy look (hero galaxy;
`src/components/landing/hero-wordmark.tsx`), Galaxy layout, Galaxy mobile
(small-screen spiral profile — replaces the layout dials below its area
threshold), Galaxy motion, Galaxy pointer, Galaxy look (hero galaxy;
`src/components/landing/hero-galaxy-tunables.ts`) and Ruler layout, Ruler
motion, Ruler look (scroll ruler;
`src/components/ruler/ruler-tunables.ts`).
@@ -2,8 +2,8 @@
# Base directory: frontend/apps/artcraft-website-next
# (this app is standalone — its own package-lock.json, not the Nx workspace)
# Manage the build command and publish directory here; leave those UI fields empty.
# The Next.js runtime handles routing, the /cdn-media and /ext-media proxy
# rewrites, and the legacy-route redirects declared in next.config.ts.
# The Next.js runtime handles routing, the /cdn-media proxy rewrite, and the
# legacy-route redirects declared in next.config.ts. Other media is in public/.
[build]
command = "npm run build"
Loaded 100 of 298 files, more files were not shown because too many files have changed in this diff. Show more