Compare commits

..
Author SHA1 Message Date
Tanish Manku 2b369cfae3 below-fold: fluid stage width + release slate strip
Stage width policy for everything under the poster: the content cap
is now a root token, --stage-max: clamp(1280px, 72vw, 1600px) —
sections genuinely widen on big monitors instead of donating
everything past 1280px to margin. All four cap sites (section shell,
hero bands, footer) consume the token; live-tunable via the new Stage
group (stage-width.tsx writes the root var on knob changes).

The announce strip becomes a release slate: "Open-source AI studio"
on the left; on the right a mono release stamp (New · 2026.09) and
model chips — Seedance 2.5, Nano Banana 2 — that deep-link into the
webapp surface where each model runs (+ more → the webapp). Data
lives in RELEASE_SLATE (landing-data.ts): one place to update per
release.

Claude-Session: https://claude.ai/code/session_013vwLpYJbWEqTJg9PGV6B6H
2026-09-14 19:58:19 -07:00
Tanish Manku d11a9dc65d hero: revert the slate/specimen experiment — the poster is settled
Scope correction: "under the wordmark" meant the announce strip
("Open-source AI studio") and everything below, not the hero poster's
own sales stack. The viewfinder slate, specimen headline, and proof
strip are removed and the hero returns to its settled form.

Kept from the detour: the pre-flight WebGL probe (real robustness
fix) and the 35-clip showcase pool.

Claude-Session: https://claude.ai/code/session_013vwLpYJbWEqTJg9PGV6B6H
2026-09-14 19:46:51 -07:00
Tanish Manku 26f1e29c23 hero: cut flank telemetry (filler on a product site); clip pool -> 35
The flank stat stacks are gone — live galaxy readouts expressed
nothing about ArtCraft to a visitor; the card spiral itself is the
expressive element, and this is a product site with a creative blend,
not a creative showcase. The telemetry channel shrinks to the scene
clock (still driving the slate timecode and doubling as the chrome's
liveness gate); the flank tunables and the galaxy's extra per-frame
publishes go with it.

Showcase pool: 17 -> 35 clips (001-035 all verified 200 on the CDN;
036 404s). At 51 cards that's ~1.5 cards per clip — most cards now
unique on screen.

Claude-Session: https://claude.ai/code/session_013vwLpYJbWEqTJg9PGV6B6H
2026-09-14 18:29:20 -07:00
Tanish Manku af55f7eb6f 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-14 18:18:25 -07:00
Tanish Manku 505c45ffdb hero: viewfinder slate — fluid specimen copy, live flank telemetry
The sales layer under the wordmark, redesigned against the
center-blob-with-scalable-margins failure mode: on wide stages the
composition now *uses* the width instead of donating it to margin.

- Fluid poster type: the headline/subline scale with the viewport
  (cqw-style clamp; knobs Headline max rem / vw slope) so their ratio
  to the ARTCRAFT wordmark is a designed constant at every width. The
  serif phrase breaks as a unit, giving a clean two-line lockup when
  the stage outgrows one line. Sizes ride CSS variables on a `contents`
  wrapper — RevealManager ends reveals with clearProps:"all", which
  wipes inline styles on the [data-reveal] elements themselves.

- Specimen life on the display letters (variable Archivo): an intro
  settle (letters arrive at the wordmark's poster extreme, wdth 125 /
  wght 700, and cascade down to the display setting on the copy beat;
  clock-driven so the tuner replay rewinds it) and a cursor field
  afterwards (gaussian width-bulge under the pointer — the galaxy's
  mouse-weight idea spoken in typography).

- Viewfinder slate chrome, the product's own metaphor (a viewport
  where shots are composed): corner framing brackets + a REC/SC/TC
  timecode line running on the galaxy's scene clock (lg+), and live
  telemetry chips at the far flanks (xl+) — cards riding, clips
  playing, FPS, spin θ, lens (boost) — real values published by the
  galaxy frame loop through src/lib/hero-telemetry.ts and written
  imperatively at a throttled rate. Chrome hides entirely while the
  galaxy isn't running (reduced motion, WebGL failure): a dead
  instrument gets no slate. Right edge clears the scroll ruler's rail.

- Proof line promoted to a full-width ruled strip at the poster's
  bottom edge (justify-between at md+) — the composition's horizontal
  anchor; extra right padding under an active ruler so it clears the
  section queue.

Everything is tunable under the new "Hero slate" group (documented in
TUNER.md). Verified via Playwright Firefox screenshots at 3360×1380
and 1440×860.

Claude-Session: https://claude.ai/code/session_013vwLpYJbWEqTJg9PGV6B6H
2026-09-14 18:06:43 -07:00
335 changed files with 1796 additions and 11416 deletions

No files matched your search

@@ -1,12 +0,0 @@
{
"db_name": "MySQL",
"query": "\nINSERT INTO media_files\nSET\n token = ?,\n\n media_class = ?,\n media_type = ?,\n maybe_project_type = ?,\n\n origin_category = ?,\n origin_product_category = ?,\n\n maybe_engine_category = ?,\n maybe_animation_type = ?,\n\n maybe_mime_type = ?,\n file_size_bytes = ?,\n\n checksum_sha2 = ?,\n\n maybe_prompt_token = ?,\n maybe_batch_token = ?,\n\n maybe_title = ?,\n maybe_duration_millis = ?,\n maybe_frame_width = ?,\n maybe_frame_height = ?,\n\n public_bucket_directory_hash = ?,\n maybe_public_bucket_prefix = ?,\n maybe_public_bucket_extension = ?,\n\n maybe_creator_user_token = ?,\n maybe_creator_anonymous_visitor_token = ?,\n creator_ip_address = ?,\n\n creator_set_visibility = ?,\n\n maybe_scene_source_media_file_token = ?,\n\n is_intermediate_system_file = ?,\n is_user_upload = ?,\n\n maybe_generation_provider = ?,\n\n maybe_creator_file_synthetic_id = ?,\n maybe_creator_category_synthetic_id = ?,\n\n maybe_origin_model_type = NULL,\n maybe_origin_model_token = NULL\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 30
},
"nullable": []
},
"hash": "026a3ab39a1ba09a891cebd840ab1c956e1bcedd07006ee34473bd4f1d0ff67a"
}
Generated
-4
View File
@@ -1348,7 +1348,6 @@ dependencies = [
"hostname 0.4.0",
"log",
"server_environment",
"tempfile",
"workspace-build-acceleration",
]
@@ -3240,7 +3239,6 @@ dependencies = [
"ffprobe",
"log",
"tempdir",
"tempfile",
"test_utils",
"workspace-build-acceleration",
]
@@ -7921,12 +7919,10 @@ dependencies = [
"easyenv",
"enums",
"errors",
"ffmpeg_utils",
"hashing",
"jobs_common",
"kinovi_web_client",
"log",
"mimetypes",
"mysql_queries",
"pager",
"rand 0.9.2",
@@ -6,7 +6,6 @@ use enums::common::generation::common_bitrate::CommonBitrate;
use enums::common::generation::common_quality::CommonQuality;
use enums::common::generation::common_resolution::CommonResolution;
use enums::common::generation::common_video_model::CommonVideoModel;
use enums::common::generation::common_video_output_format::CommonVideoOutputFormat;
use tokens::tokens::characters::CharacterToken;
use tokens::tokens::media_files::MediaFileToken;
@@ -80,11 +79,6 @@ pub struct OmniApiVideoGenerateRequest {
/// Not all models support this; models that don't simply ignore it.
pub bitrate: Option<CommonBitrate>,
/// Output container for Seedance 2.5 (including Ultra). Other models ignore it.
/// Use "mp4" or "mov" (QuickTime). Omitted or null defaults to MP4.
#[serde(rename = "output_format", skip_serializing_if = "Option::is_none")]
pub maybe_output_format: Option<CommonVideoOutputFormat>,
/// The quality to use.
pub quality: Option<CommonQuality>,
@@ -6,7 +6,6 @@ use enums::common::generation::common_bitrate::CommonBitrate;
use enums::common::generation::common_quality::CommonQuality;
use enums::common::generation::common_resolution::CommonResolution;
use enums::common::generation::common_video_model::CommonVideoModel;
use enums::common::generation::common_video_output_format::CommonVideoOutputFormat;
use tokens::tokens::characters::CharacterToken;
use tokens::tokens::media_files::MediaFileToken;
@@ -56,11 +55,6 @@ pub struct OmniGenVideoCostAndGenerateRequest {
/// Not all models support this; models that don't simply ignore it.
pub bitrate: Option<CommonBitrate>,
/// Output container for Seedance 2.5 (including Ultra). Other models ignore it.
/// Use "mp4" or "mov" (QuickTime). Omitted or null defaults to MP4.
#[serde(rename = "output_format", skip_serializing_if = "Option::is_none")]
pub maybe_output_format: Option<CommonVideoOutputFormat>,
/// The quality to use.
pub quality: Option<CommonQuality>,
@@ -2,7 +2,6 @@ use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
use enums::common::generation::common_quality::CommonQuality;
use enums::common::generation::common_resolution::CommonResolution;
use enums::common::generation::common_video_model::CommonVideoModel;
use enums::common::generation::common_video_output_format::CommonVideoOutputFormat;
use enums::common::generation::model_creator::ModelCreator;
use enums::common::generation_provider::GenerationProvider;
use serde_derive::{Deserialize, Serialize};
@@ -157,12 +156,6 @@ pub struct OmniGenVideoModelDetails {
#[serde(skip_serializing_if = "Option::is_none")]
pub bitrate_default: Option<CommonBitrate>,
#[serde(skip_serializing_if = "Option::is_none")]
pub output_format_options: Option<Vec<CommonVideoOutputFormat>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub output_format_default: Option<CommonVideoOutputFormat>,
#[serde(skip_serializing_if = "Option::is_none")]
pub quality_options: Option<Vec<CommonQuality>>,
@@ -236,8 +229,6 @@ impl Default for OmniGenVideoModelDetails {
resolution_default: None,
bitrate_options: None,
bitrate_default: None,
output_format_options: None,
output_format_default: None,
quality_options: None,
default_quality: None,
duration_seconds_min: None,
@@ -41,7 +41,7 @@ pub async fn upload_media_file_from_file<P: AsRef<Path>>(
Err(err) => Err(StorytellerError::Api(err)),
}
}
Some(FileExtension::Mp4 | FileExtension::Mov) => {
Some(FileExtension::Mp4) => {
match upload_video_media_file_from_file(UploadVideoFromFileArgs {
api_host,
maybe_creds,
@@ -13,6 +13,5 @@ pub mod router_quality;
pub mod router_resolution;
pub mod router_splat_model;
pub mod router_video_model;
pub mod router_video_output_format;
pub mod video_list_ref;
pub mod video_ref;
@@ -1,10 +0,0 @@
use serde_derive::{Deserialize, Serialize};
/// Output video container. Supported by Seedance 2.5 on Kinovi and Artcraft.
/// Other models/providers ignore this option.
#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum RouterVideoOutputFormat {
Mp4,
Mov,
}
@@ -4,7 +4,6 @@ use crate::api::router_aspect_ratio::RouterAspectRatio;
use crate::api::router_bitrate::RouterBitrate;
use crate::api::router_resolution::RouterResolution;
use crate::api::router_video_model::RouterVideoModel;
use crate::api::router_video_output_format::RouterVideoOutputFormat;
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::router_provider::RouterProvider;
@@ -126,10 +125,6 @@ pub struct GenerateVideoRequestBuilder {
/// Not all models support this; models that don't simply ignore it.
pub bitrate: Option<RouterBitrate>,
/// Output container for Seedance 2.5 on Kinovi and Artcraft.
/// None keeps the provider default; unsupported models/providers ignore it.
pub maybe_output_format: Option<RouterVideoOutputFormat>,
/// How many seconds to generate.
pub duration_seconds: Option<u16>,
@@ -173,7 +168,6 @@ impl Default for GenerateVideoRequestBuilder {
resolution: None,
aspect_ratio: None,
bitrate: None,
maybe_output_format: None,
duration_seconds: None,
video_batch_count: None,
generate_audio: None,
@@ -3,12 +3,10 @@ use enums::common::generation::common_aspect_ratio::CommonAspectRatio as CommonA
use enums::common::generation::common_bitrate::CommonBitrate as CommonBitrateEnum;
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
use enums::common::generation::common_video_output_format::CommonVideoOutputFormat;
use crate::api::router_aspect_ratio::RouterAspectRatio;
use crate::api::router_bitrate::RouterBitrate;
use crate::api::router_resolution::RouterResolution;
use crate::api::router_video_output_format::RouterVideoOutputFormat;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
@@ -57,13 +55,6 @@ pub fn build_artcraft_omni_video_request(
let duration_seconds = plan_duration(builder.duration_seconds.take(), strategy)?;
let bitrate = plan_bitrate(builder.bitrate.take());
let prompt = builder.prompt.take();
let supports_output_format = matches!(model, CommonVideoModelEnum::Seedance2p5 | CommonVideoModelEnum::Seedance2p5Ultra);
let maybe_output_format = builder.maybe_output_format
.filter(|_| supports_output_format)
.map(|format| match format {
RouterVideoOutputFormat::Mp4 => CommonVideoOutputFormat::Mp4,
RouterVideoOutputFormat::Mov => CommonVideoOutputFormat::Mov,
});
let start_frame = resolve_image_ref(builder.start_frame.take())?;
let end_frame = resolve_image_ref(builder.end_frame.take())?;
@@ -86,7 +77,6 @@ pub fn build_artcraft_omni_video_request(
resolution,
aspect_ratio,
bitrate,
maybe_output_format,
duration_seconds: duration_seconds.map(|d| d as u16),
video_batch_count: Some(batch_count),
negative_prompt: None,
@@ -327,71 +317,6 @@ pub fn plan_duration(
mod tests {
use super::*;
mod output_format {
use super::*;
use crate::api::router_provider::RouterProvider;
use crate::api::router_video_model::RouterVideoModel;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
#[test]
fn seedance_2p5_preserves_output_format_and_audio_over_http() {
for model in [RouterVideoModel::Seedance2p5, RouterVideoModel::Seedance2p5Ultra] {
for maybe_output_format in [None, Some(RouterVideoOutputFormat::Mp4), Some(RouterVideoOutputFormat::Mov)] {
for generate_audio in [None, Some(true), Some(false)] {
let builder = GenerateVideoRequestBuilder {
provider: RouterProvider::Artcraft,
model,
maybe_output_format,
generate_audio,
..Default::default()
};
let request = match builder.build2().unwrap() {
VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p5(state)) => state.request,
VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p5Ultra(state)) => state.request,
other => panic!("Unexpected request: {:?}", other),
};
let json = serde_json::to_value(&request).unwrap();
let expected = match maybe_output_format {
None => None,
Some(RouterVideoOutputFormat::Mp4) => Some("mp4"),
Some(RouterVideoOutputFormat::Mov) => Some("mov"),
};
assert_eq!(json.get("output_format").and_then(|v| v.as_str()), expected);
if expected.is_none() {
assert!(json.get("output_format").is_none());
}
let decoded: OmniGenVideoCostAndGenerateRequest = serde_json::from_value(json).unwrap();
assert_eq!(decoded.maybe_output_format, request.maybe_output_format);
assert_eq!(decoded.generate_audio, generate_audio);
}
}
}
}
#[test]
fn unsupported_models_ignore_output_format_even_with_error_strategy() {
for model in [RouterVideoModel::Seedance2p0, RouterVideoModel::Seedance2p0Fast, RouterVideoModel::Seedance2p5Preview] {
for format in [RouterVideoOutputFormat::Mp4, RouterVideoOutputFormat::Mov] {
let builder = GenerateVideoRequestBuilder {
provider: RouterProvider::Artcraft,
model,
maybe_output_format: Some(format),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..Default::default()
};
let request = match builder.build2().unwrap() {
VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p0(state)) => state.request,
VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p0Fast(state)) => state.request,
VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p5Preview(state)) => state.request,
other => panic!("Unexpected request: {:?}", other),
};
assert!(serde_json::to_value(request).unwrap().get("output_format").is_none());
}
}
}
}
mod bitrate_translation {
use super::*;
@@ -59,7 +59,6 @@ mod tests {
resolution: None,
aspect_ratio: None,
bitrate: None,
maybe_output_format: None,
quality: None,
duration_seconds: Some(5),
video_batch_count: Some(1),
@@ -49,7 +49,6 @@ pub fn build_artcraft_seedance_2p5(mut builder: GenerateVideoRequestBuilder) ->
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();
let mut request = build_artcraft_omni_video_request(
builder,
@@ -59,7 +58,6 @@ pub fn build_artcraft_seedance_2p5(mut builder: GenerateVideoRequestBuilder) ->
)?;
request.duration_seconds = duration_seconds;
request.video_batch_count = Some(1);
request.generate_audio = generate_audio;
let state = ArtcraftSeedance2p5RequestState { request, total_input_seconds };
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p5(state)))
@@ -94,11 +94,11 @@ mod tests {
#[test]
fn table_1080p() {
// 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);
// 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);
}
#[test]
@@ -121,16 +121,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);
// 38.49728574 ¢/s × 40 = 1539.89 → 1540¢.
assert_eq!(cents_with_video_refs(Some(RouterResolution::TenEightyP), 30, Some(10)), 1540);
// 27.39973680 ¢/s × 40 = 1095.99 → 1096¢.
assert_eq!(cents_with_video_refs(Some(RouterResolution::TenEightyP), 30, Some(10)), 1096);
}
#[test]
fn table_1080p_with_ten_input_seconds() {
// 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);
// 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);
}
#[test]
@@ -49,7 +49,6 @@ pub fn build_artcraft_seedance_2p5_u(mut builder: GenerateVideoRequestBuilder) -
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();
let mut request = build_artcraft_omni_video_request(
builder,
@@ -59,7 +58,6 @@ pub fn build_artcraft_seedance_2p5_u(mut builder: GenerateVideoRequestBuilder) -
)?;
request.duration_seconds = duration_seconds;
request.video_batch_count = Some(1);
request.generate_audio = generate_audio;
let state = ArtcraftSeedance2p5UltraRequestState { request, total_input_seconds };
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p5Ultra(state)))
@@ -95,11 +95,11 @@ mod tests {
#[test]
fn table_1080p() {
// 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);
// 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);
}
#[test]
@@ -141,16 +141,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);
// 41.84487580 ¢/s × 40 = 1673.80 → 1674¢.
assert_eq!(cents_with_video_refs(Some(RouterResolution::TenEightyP), 30, Some(10)), 1674);
// 29.93674947 ¢/s × 40 = 1197.47 → 1198¢.
assert_eq!(cents_with_video_refs(Some(RouterResolution::TenEightyP), 30, Some(10)), 1198);
}
#[test]
fn table_1080p_with_ten_input_seconds() {
// 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);
// 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);
}
#[test]
@@ -137,15 +137,14 @@ 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 (updated 2026-09-17).
const SEEDANCE_2P5_CENTS_PER_SECOND_1080P: f64 = 61.62732357;
/// Seedance 2.5 — 1080p price, USD cents per second.
const SEEDANCE_2P5_CENTS_PER_SECOND_1080P: f64 = 45.85869386;
/// 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
/// (updated 2026-09-17).
const SEEDANCE_2P5_VIDEO_REFERENCE_CENTS_PER_SECOND_1080P: f64 = 38.49728574;
/// Seedance 2.5 — 1080p price with video references, USD cents per second.
const SEEDANCE_2P5_VIDEO_REFERENCE_CENTS_PER_SECOND_1080P: f64 = 27.39973680;
/// ArtCraft's price (USD cents) for Seedance 2.5.
///
@@ -200,15 +199,14 @@ pub fn seedance_2p5_usd_cents(
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 (updated 2026-09-17).
const SEEDANCE_2P5_ULTRA_CENTS_PER_SECOND_1080P: f64 = 67.22980753;
/// 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 — 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 (updated 2026-09-17).
const SEEDANCE_2P5_ULTRA_VIDEO_REFERENCE_CENTS_PER_SECOND_1080P: f64 = 41.84487580;
/// 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;
/// ArtCraft's price (USD cents) for Seedance 2.5 Ultra.
///
@@ -47,7 +47,6 @@ fn do_build_kinovi_seedance_2p0(mut builder: GenerateVideoRequestBuilder) -> Res
duration_seconds,
bitrate,
prompt,
maybe_generate_audio: builder.generate_audio,
unhandled_request_state: Some(unhandled_request_state),
})
}
@@ -65,7 +65,6 @@ impl KinoviSeedance2p0CostState {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
};
// Enterprise tier: what generations actually cost us (our discounted
@@ -277,7 +276,6 @@ mod tests {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
}
.calculate_enterprise_costs();
(costs.total_cost.kinovi_credits.round() as u64, costs.total_cost.usd_cents_rounded_up)
@@ -656,7 +654,6 @@ mod tests {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
},
}
}
@@ -28,8 +28,6 @@ pub struct KinoviSeedance2p0DraftState {
pub batch_count: KinoviSeedance2p0BatchCount,
pub bitrate: Option<KinoviSeedance2p0Bitrate>,
pub maybe_generate_audio: Option<bool>,
// Pending types that need to be queried.
pub unhandled_request_state: Option<KinoviSeedance2p0RemainingItems>,
}
@@ -98,7 +96,6 @@ impl KinoviSeedance2p0DraftState {
character_ids,
use_face_blur_hack: None,
bitrate: self.bitrate,
maybe_generate_audio: self.maybe_generate_audio,
};
Ok(KinoviSeedance2p0RequestState { request })
@@ -47,7 +47,6 @@ fn do_build_kinovi_seedance_2p0_fast(mut builder: GenerateVideoRequestBuilder) -
duration_seconds,
bitrate,
prompt,
maybe_generate_audio: builder.generate_audio,
unhandled_request_state: Some(unhandled_request_state),
})
}
@@ -65,7 +65,6 @@ impl KinoviSeedance2p0FastCostState {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
};
// Enterprise tier: what generations actually cost us (our discounted
@@ -451,7 +450,6 @@ mod tests {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
},
}
}
@@ -25,7 +25,6 @@ pub struct KinoviSeedance2p0FastDraftState {
pub duration_seconds: u8,
pub batch_count: KinoviSeedance2p0FastBatchCount,
pub bitrate: Option<KinoviSeedance2p0FastBitrate>,
pub maybe_generate_audio: Option<bool>,
pub unhandled_request_state: Option<KinoviSeedance2p0FastRemainingItems>,
}
@@ -94,7 +93,6 @@ impl KinoviSeedance2p0FastDraftState {
character_ids,
use_face_blur_hack: None,
bitrate: self.bitrate,
maybe_generate_audio: self.maybe_generate_audio,
};
Ok(KinoviSeedance2p0FastRequestState { request })
@@ -47,7 +47,6 @@ fn do_build_kinovi_seedance_2p0_mini(mut builder: GenerateVideoRequestBuilder) -
duration_seconds,
bitrate,
prompt,
maybe_generate_audio: builder.generate_audio,
unhandled_request_state: Some(unhandled_request_state),
})
}
@@ -65,7 +65,6 @@ impl KinoviSeedance2p0MiniCostState {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
};
// Enterprise tier: what generations actually cost us (our discounted
@@ -381,7 +380,6 @@ mod tests {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
},
}
}
@@ -25,7 +25,6 @@ pub struct KinoviSeedance2p0MiniDraftState {
pub duration_seconds: u8,
pub batch_count: KinoviSeedance2p0MiniBatchCount,
pub bitrate: Option<KinoviSeedance2p0MiniBitrate>,
pub maybe_generate_audio: Option<bool>,
pub unhandled_request_state: Option<KinoviSeedance2p0MiniRemainingItems>,
}
@@ -94,7 +93,6 @@ impl KinoviSeedance2p0MiniDraftState {
character_ids,
use_face_blur_hack: None,
bitrate: self.bitrate,
maybe_generate_audio: self.maybe_generate_audio,
};
Ok(KinoviSeedance2p0MiniRequestState { request })
@@ -1,14 +1,12 @@
use kinovi_web_client::generate::video::generate_seedance_2p5::{
KinoviSeedance2p5AspectRatio as KinoviAspectRatio,
KinoviSeedance2p5Bitrate,
KinoviSeedance2p5OutputFormat,
KinoviSeedance2p5OutputResolution as KinoviOutputResolution,
};
use crate::api::router_aspect_ratio::RouterAspectRatio;
use crate::api::router_bitrate::RouterBitrate;
use crate::api::router_resolution::RouterResolution;
use crate::api::router_video_output_format::RouterVideoOutputFormat;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
@@ -88,11 +86,6 @@ fn do_build_kinovi_seedance_2p5(mut builder: GenerateVideoRequestBuilder) -> Res
prompt,
total_input_seconds,
maybe_bitrate,
maybe_output_format: builder.maybe_output_format.map(|format| match format {
RouterVideoOutputFormat::Mp4 => KinoviSeedance2p5OutputFormat::Mp4,
RouterVideoOutputFormat::Mov => KinoviSeedance2p5OutputFormat::Mov,
}),
maybe_generate_audio: builder.generate_audio,
unhandled_request_state: Some(unhandled_request_state),
})
}
@@ -282,7 +275,6 @@ mod tests {
use crate::generate::generate_video::video_generation_draft::VideoGenerationDraftRequest;
use crate::generate::generate_video::video_generation_draft_context::VideoGenerationDraftContext;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
use super::*;
@@ -319,42 +311,6 @@ mod tests {
}
}
#[tokio::test]
async fn output_options_survive_router_finalization() {
let client = RouterClient::KinoviWeb(RouterKinoviWebClient::new(
KinoviWebSession::from_cookies_string(String::new()),
));
let context = VideoGenerationDraftContext { client: Some(&client), ..Default::default() };
for model in [RouterVideoModel::Seedance2p0, RouterVideoModel::Seedance2p0Fast, RouterVideoModel::Seedance2p0Mini, RouterVideoModel::Seedance2p5] {
for generate_audio in [None, Some(false), Some(true)] {
for maybe_output_format in [None, Some(RouterVideoOutputFormat::Mp4), Some(RouterVideoOutputFormat::Mov)] {
let builder = GenerateVideoRequestBuilder {
model, generate_audio, maybe_output_format, ..base_builder()
};
let VideoGenerationDraftOrRequest::Draft(draft) = builder.build2().unwrap() else {
panic!("expected Kinovi draft");
};
let finalized = draft.finalize(context.clone()).await.unwrap();
let actual_audio = match finalized {
VideoGenerationRequest::KinoviSeedance2p0(state) => state.request.maybe_generate_audio,
VideoGenerationRequest::KinoviSeedance2p0Fast(state) => state.request.maybe_generate_audio,
VideoGenerationRequest::KinoviSeedance2p0Mini(state) => state.request.maybe_generate_audio,
VideoGenerationRequest::KinoviSeedance2p5(state) => {
let actual_format = state.request.maybe_output_format.map(|format| match format {
KinoviSeedance2p5OutputFormat::Mp4 => RouterVideoOutputFormat::Mp4,
KinoviSeedance2p5OutputFormat::Mov => RouterVideoOutputFormat::Mov,
});
assert_eq!(actual_format, maybe_output_format);
state.request.maybe_generate_audio
}
_ => panic!("expected a Seedance request"),
};
assert_eq!(actual_audio, generate_audio);
}
}
}
}
mod materialized_field_conversions {
use super::*;
@@ -92,15 +92,13 @@ impl KinoviSeedance2p5CostState {
total_input_seconds,
use_face_blur_hack: None,
maybe_bitrate: None,
maybe_output_format: None,
maybe_generate_audio: None,
};
// Enterprise tier: what generations actually cost us (our discounted
// 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 (136.23/sec) and rounds to the
// there; 1080p bills fractional credits (103.25/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 +123,7 @@ mod tests {
use super::*;
// ── Credits without video references (26/sec at 480p, 59/sec at 720p,
// 136.23/sec at 1080p enterprise) ──
// 103.25/sec at 1080p enterprise) ──
mod credits_without_video_references {
use super::*;
@@ -145,11 +143,11 @@ mod tests {
#[test]
fn credits_1080p() {
// 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);
// 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);
}
#[test]
@@ -164,7 +162,7 @@ mod tests {
}
// ── Credits with video references (16/sec at 480p, 35/sec at 720p,
// 81.4/sec at 1080p enterprise, over output duration + input seconds) ──
// 61.69/sec at 1080p enterprise, over output duration + input seconds) ──
mod credits_with_video_references {
use super::*;
@@ -173,8 +171,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);
// 81.4/sec enterprise × 40 = 3256 credits.
assert_eq!(credits(Some(KinoviOutputResolution::TenEightyP), 30, true, Some(10)), 3256);
// 61.69/sec enterprise × 40 = 2467.6 → 2468 credits.
assert_eq!(credits(Some(KinoviOutputResolution::TenEightyP), 30, true, Some(10)), 2468);
}
#[test]
@@ -1,6 +1,6 @@
use kinovi_web_client::generate::video::generate_seedance_2p5::{
GenerateSeedance2p5Request, KinoviSeedance2p5AspectRatio, KinoviSeedance2p5Bitrate, KinoviSeedance2p5Modality,
KinoviSeedance2p5OutputFormat, KinoviSeedance2p5OutputResolution,
KinoviSeedance2p5OutputResolution,
};
use crate::api::audio_list_ref::AudioListRef;
@@ -33,8 +33,6 @@ pub struct KinoviSeedance2p5DraftState {
pub total_input_seconds: Option<u8>,
pub maybe_bitrate: Option<KinoviSeedance2p5Bitrate>,
pub maybe_output_format: Option<KinoviSeedance2p5OutputFormat>,
pub maybe_generate_audio: Option<bool>,
pub unhandled_request_state: Option<KinoviSeedance2p5RemainingItems>,
}
@@ -116,8 +114,6 @@ impl KinoviSeedance2p5DraftState {
total_input_seconds: self.total_input_seconds,
use_face_blur_hack: None,
maybe_bitrate: self.maybe_bitrate,
maybe_output_format: self.maybe_output_format,
maybe_generate_audio: self.maybe_generate_audio,
};
Ok(KinoviSeedance2p5RequestState { request })
@@ -19,7 +19,9 @@ pub(crate) async fn upload_to_kinovi_web(
source_url: &str,
maybe_local_path: Option<&Path>,
) -> Result<String, ArtcraftRouterError> {
let extension = upload_extension(source_url, maybe_local_path);
let extension = extract_extension_from_url_str(source_url, &ExtractExtensions::All)
.map(|ext| ext.without_period().to_string())
.unwrap_or_else(|| "jpg".to_string());
let file_bytes = match maybe_local_path {
Some(local_path) => match std::fs::read(local_path) {
@@ -56,25 +58,3 @@ pub(crate) async fn upload_to_kinovi_web(
Ok(upload_response.public_url)
}
/// Keep the reference container when uploading, including QuickTime/MOV.
fn upload_extension(source_url: &str, maybe_local_path: Option<&Path>) -> String {
extract_extension_from_url_str(source_url, &ExtractExtensions::All)
.map(|ext| ext.without_period().to_string())
.or_else(|| maybe_local_path
.and_then(Path::extension)
.and_then(|ext| ext.to_str())
.map(str::to_ascii_lowercase))
.unwrap_or_else(|| "jpg".to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn quicktime_upload_keeps_mov_extension() {
assert_eq!(upload_extension("https://example.com/reference.MOV?token=test", None), "mov");
assert_eq!(upload_extension("https://example.com/reference.mov", Some(Path::new("/tmp/cached.bin"))), "mov");
assert_eq!(upload_extension("https://example.com/reference", Some(Path::new("/tmp/reference.MOV"))), "mov");
}
}
@@ -352,7 +352,6 @@ pub fn base_video_request() -> GenerateVideoRequestBuilder {
resolution: None,
aspect_ratio: None,
bitrate: None,
maybe_output_format: None,
duration_seconds: None,
video_batch_count: None,
generate_audio: None,
@@ -130,8 +130,6 @@ pub async fn generate_happy_horse_1p0(
character_ids: None,
use_face_blur_hack: Some(false),
bitrate: None,
maybe_output_format: None,
maybe_generate_audio: None,
};
let raw_response = workflow_run_task(WorkflowRunTaskArgs {
@@ -38,8 +38,6 @@ pub struct GenerateSeedance2p0Request {
/// Output video bitrate. None defaults to "standard"; `High` requests a
/// higher bitrate. Does not affect cost.
pub bitrate: Option<KinoviSeedance2p0Bitrate>,
/// Whether to generate audio. None leaves the provider default unchanged.
pub maybe_generate_audio: Option<bool>,
}
// ── Enums ──
@@ -223,8 +221,6 @@ pub async fn generate_seedance_2p0(
character_ids: req.character_ids,
use_face_blur_hack: req.use_face_blur_hack,
bitrate: map_bitrate(req.bitrate),
maybe_output_format: None,
maybe_generate_audio: req.maybe_generate_audio,
};
let raw_response = workflow_run_task(WorkflowRunTaskArgs {
@@ -732,7 +728,6 @@ mod tests {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
}
}
@@ -805,7 +800,6 @@ mod tests {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
},
}).await?;
println!("t2v default — task_id={}, order_id={}", result.task_id, result.order_id);
@@ -837,7 +831,6 @@ mod tests {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
},
}).await?;
println!("t2v 1080p — task_id={}, order_id={}", result.task_id, result.order_id);
@@ -872,7 +865,6 @@ mod tests {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
},
}).await?;
println!("t2v 21:9 — task_id={}, order_id={}", result.task_id, result.order_id);
@@ -905,7 +897,6 @@ mod tests {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
},
}).await?;
println!("keyframe 21:9 — task_id={}, order_id={}", result.task_id, result.order_id);
@@ -942,7 +933,6 @@ mod tests {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
},
}).await?;
println!("keyframe — task_id={}, order_id={}", result.task_id, result.order_id);
@@ -976,7 +966,6 @@ mod tests {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
},
}).await?;
println!("keyframe start+end — task_id={}, order_id={}", result.task_id, result.order_id);
@@ -1014,7 +1003,6 @@ mod tests {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
},
}).await?;
println!("image ref — task_id={}, order_id={}", result.task_id, result.order_id);
@@ -1050,7 +1038,6 @@ mod tests {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
},
}).await?;
println!("4K image ref — task_id={}, order_id={}", result.task_id, result.order_id);
@@ -1089,7 +1076,6 @@ mod tests {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
},
}).await?;
println!("video ref — task_id={}, order_id={}", result.task_id, result.order_id);
@@ -1125,7 +1111,6 @@ mod tests {
character_ids: Some(vec![STEAMPUNK_CLOWN_ID.to_string()]),
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
},
}).await?;
println!("character ref — task_id={}, order_id={}", result.task_id, result.order_id);
@@ -1157,7 +1142,6 @@ mod tests {
character_ids: Some(vec![STEAMPUNK_CLOWN_ID.to_string(), MOCHI_ID.to_string()]),
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
},
}).await?;
println!("two characters — task_id={}, order_id={}", result.task_id, result.order_id);
@@ -38,8 +38,6 @@ pub struct GenerateSeedance2p0FastRequest {
/// Output video bitrate. None defaults to "standard"; `High` requests a
/// higher bitrate. Does not affect cost.
pub bitrate: Option<KinoviSeedance2p0FastBitrate>,
/// Whether to generate audio. None leaves the provider default unchanged.
pub maybe_generate_audio: Option<bool>,
}
// ── Enums ──
@@ -197,8 +195,6 @@ pub async fn generate_seedance_2p0_fast(
character_ids: req.character_ids,
use_face_blur_hack: req.use_face_blur_hack,
bitrate: map_bitrate(req.bitrate),
maybe_output_format: None,
maybe_generate_audio: req.maybe_generate_audio,
};
let raw_response = workflow_run_task(WorkflowRunTaskArgs {
@@ -652,7 +648,6 @@ mod tests {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
}
}
@@ -717,7 +712,6 @@ mod tests {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
},
}).await?;
println!("t2v fast default — task_id={}, order_id={}", result.task_id, result.order_id);
@@ -749,7 +743,6 @@ mod tests {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
},
}).await?;
println!("t2v fast 480p — task_id={}, order_id={}", result.task_id, result.order_id);
@@ -784,7 +777,6 @@ mod tests {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
},
}).await?;
println!("fast t2v 21:9 480p — task_id={}, order_id={}", result.task_id, result.order_id);
@@ -817,7 +809,6 @@ mod tests {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
},
}).await?;
println!("fast keyframe 21:9 — task_id={}, order_id={}", result.task_id, result.order_id);
@@ -854,7 +845,6 @@ mod tests {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
},
}).await?;
println!("fast keyframe — task_id={}, order_id={}", result.task_id, result.order_id);
@@ -892,7 +882,6 @@ mod tests {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
},
}).await?;
println!("fast image ref — task_id={}, order_id={}", result.task_id, result.order_id);
@@ -930,7 +919,6 @@ mod tests {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
},
}).await?;
println!("fast video ref — task_id={}, order_id={}", result.task_id, result.order_id);
@@ -966,7 +954,6 @@ mod tests {
character_ids: Some(vec![MOCHI_ID.to_string()]),
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
},
}).await?;
println!("fast character — task_id={}, order_id={}", result.task_id, result.order_id);
@@ -998,7 +985,6 @@ mod tests {
character_ids: Some(vec![STEAMPUNK_CLOWN_ID.to_string(), MOCHI_ID.to_string()]),
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
},
}).await?;
println!("fast two characters — task_id={}, order_id={}", result.task_id, result.order_id);
@@ -38,8 +38,6 @@ pub struct GenerateSeedance2p0MiniRequest {
/// Output video bitrate. None defaults to "normal"; `High` requests a
/// higher bitrate. Does not affect cost.
pub bitrate: Option<KinoviSeedance2p0MiniBitrate>,
/// Whether to generate audio. None leaves the provider default unchanged.
pub maybe_generate_audio: Option<bool>,
}
// ── Enums ──
@@ -224,8 +222,6 @@ fn to_raw_request(req: GenerateSeedance2p0MiniRequest) -> WorkflowRunTaskRequest
character_ids: req.character_ids,
use_face_blur_hack: req.use_face_blur_hack,
bitrate: map_bitrate(req.bitrate),
maybe_output_format: None,
maybe_generate_audio: req.maybe_generate_audio,
}
}
@@ -646,7 +642,6 @@ mod tests {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
}
}
@@ -700,7 +695,6 @@ mod tests {
character_ids: None,
use_face_blur_hack: None,
bitrate: Some(KinoviSeedance2p0MiniBitrate::High),
maybe_generate_audio: None,
}
}
@@ -775,7 +769,6 @@ mod tests {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
}
}
}
@@ -812,7 +805,6 @@ mod tests {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
},
}).await?;
println!("mini t2v 720p — task_id={}, order_id={}", result.task_id, result.order_id);
@@ -844,7 +836,6 @@ mod tests {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
},
}).await?;
println!("mini t2v 480p — task_id={}, order_id={}", result.task_id, result.order_id);
@@ -877,7 +868,6 @@ mod tests {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
},
}).await?;
println!("mini reference-to-video 720p — task_id={}, order_id={}", result.task_id, result.order_id);
@@ -910,7 +900,6 @@ mod tests {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
},
}).await?;
println!("mini video ref 480p — task_id={}, order_id={}", result.task_id, result.order_id);
@@ -7,7 +7,7 @@ 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, KinoviAspectRatioRaw, KinoviBatchCountRaw, KinoviBitrateRaw, KinoviModelTypeRaw,
KinoviOutputFormatRaw, KinoviOutputResolutionRaw, WorkflowRunTaskArgs, WorkflowRunTaskRequest,
KinoviOutputResolutionRaw, WorkflowRunTaskArgs, WorkflowRunTaskRequest,
};
// ── Constants ──
@@ -64,12 +64,6 @@ pub struct GenerateSeedance2p5Request {
/// Output video bitrate. None keeps the standard bitrate; `High` requests
/// a higher bitrate. This does not select a container or lossless codec.
pub maybe_bitrate: Option<KinoviSeedance2p5Bitrate>,
/// Output container. None leaves the provider default unchanged.
pub maybe_output_format: Option<KinoviSeedance2p5OutputFormat>,
/// Whether to generate audio. None leaves the provider default unchanged.
pub maybe_generate_audio: Option<bool>,
}
// ── Modality ──
@@ -130,13 +124,6 @@ pub enum KinoviSeedance2p5Bitrate {
High,
}
/// Output container for Seedance 2.5.
#[derive(Debug, Clone, Copy)]
pub enum KinoviSeedance2p5OutputFormat {
Mp4,
Mov,
}
// ── Pricing ──
//
// Seedance 2.5 credit pricing (credits per billed second). Without video
@@ -147,7 +134,7 @@ pub enum KinoviSeedance2p5OutputFormat {
// |------------|----------------------|------------------------|
// | 480p | 26 | (no discount) |
// | 720p | 59 | (no discount) |
// | 1080p | 143.4 | 136.23 |
// | 1080p | 107.55 | 103.25 |
//
// With video references, the rate drops but the billed seconds are the
// output duration PLUS the total seconds of reference video input
@@ -160,10 +147,11 @@ pub enum KinoviSeedance2p5OutputFormat {
// |------------|----------------------|------------------------|
// | 480p | 16 | (no discount) |
// | 720p | 35 | (no discount) |
// | 1080p | 85.68 | 81.4 |
// | 1080p | 64.26 | 61.69 |
//
// 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
// 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
// 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.
@@ -179,8 +167,8 @@ const SEEDANCE_2P5_720P: KinoviPricingRate = KinoviPricingRate {
maybe_enterprise_credits: None,
};
const SEEDANCE_2P5_1080P: KinoviPricingRate = KinoviPricingRate {
consumer_credits: 143.4,
maybe_enterprise_credits: Some(136.23),
consumer_credits: 107.55,
maybe_enterprise_credits: Some(103.25),
};
/// Per-second credit rates when video references are attached. These REPLACE
@@ -195,8 +183,8 @@ const SEEDANCE_2P5_720P_VIDEO_REF: KinoviPricingRate = KinoviPricingRate {
maybe_enterprise_credits: None,
};
const SEEDANCE_2P5_1080P_VIDEO_REF: KinoviPricingRate = KinoviPricingRate {
consumer_credits: 85.68,
maybe_enterprise_credits: Some(81.4),
consumer_credits: 64.26,
maybe_enterprise_credits: Some(61.69),
};
impl KinoviCostCalculatorTrait for GenerateSeedance2p5Request {
@@ -317,11 +305,6 @@ fn to_raw_request(req: GenerateSeedance2p5Request) -> WorkflowRunTaskRequest {
character_ids: None,
use_face_blur_hack: req.use_face_blur_hack,
bitrate: map_bitrate(req.maybe_bitrate),
maybe_output_format: req.maybe_output_format.map(|format| match format {
KinoviSeedance2p5OutputFormat::Mp4 => KinoviOutputFormatRaw::Mp4,
KinoviSeedance2p5OutputFormat::Mov => KinoviOutputFormatRaw::Mov,
}),
maybe_generate_audio: req.maybe_generate_audio,
}
}
@@ -374,7 +357,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 = 143.4 consumer / 136.23
// negotiated enterprise discount); 1080p = 107.55 consumer / 103.25
// enterprise credits/sec.
mod without_video_references {
@@ -400,23 +383,24 @@ mod tests {
#[test]
fn table_1080p() {
// 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);
// 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);
// 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);
// 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);
}
#[test]
@@ -454,7 +438,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, 1362.3);
assert_eq!(request_1080.calculate_enterprise_costs().kinovi_credits, 1032.5);
}
#[test]
@@ -487,7 +471,7 @@ mod tests {
// ── With video references: input seconds are billed too ──
//
// 480p = 16 credits/sec and 720p = 35 credits/sec at both tiers; 1080p
// = 85.68 consumer / 81.4 enterprise credits/sec. All over
// = 64.26 consumer / 61.69 enterprise credits/sec. All over
// (output duration + total_input_seconds) billed seconds.
mod with_video_references {
@@ -539,21 +523,21 @@ mod tests {
// seconds: billed seconds = 20 + clamp(input, 4, 30). E.g. a 10s
// input video + 20s generation = 30 billed seconds.
//
// 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);
// 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);
// 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);
// 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);
}
#[test]
@@ -572,9 +556,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 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);
// 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);
}
#[test]
@@ -647,47 +631,47 @@ mod tests {
#[test]
fn enterprise_cents_1080p_4s() {
// 544.92 credits; 54492/243.16 = 224.0994 cents.
// 413 credits; 41300/243.16 = 169.8470 cents.
let cost = t2v_1080(4).calculate_enterprise_costs();
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);
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);
}
#[test]
fn consumer_cents_1080p_4s() {
// 573.6 credits; 57360/192.98 = 297.2329 cents.
// 430.2 credits; 43020/192.98 = 222.9247 cents.
let cost = t2v_1080(4).calculate_consumer_costs();
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);
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);
}
#[test]
fn enterprise_cents_1080p_30s() {
// 4086.9 credits; 408690/243.16 = 1680.7452 cents.
// 3097.5 credits; 309750/243.16 = 1273.8526 cents.
let cost = t2v_1080(30).calculate_enterprise_costs();
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);
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);
}
#[test]
fn enterprise_cents_1080p_video_ref_30_billed_seconds() {
// 81.4 × 30 = 2442 credits; 244200/243.16 = 1004.2770 cents.
// 61.69 × 30 = 1850.7 credits; 185070/243.16 = 761.1038 cents.
let cost = video_ref_1080(20, Some(10)).calculate_enterprise_costs();
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);
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);
}
#[test]
fn consumer_cents_1080p_video_ref_30_billed_seconds() {
// 85.68 × 30 = 2570.4 credits; 257040/192.98 = 1331.9515 cents.
// 64.26 × 30 = 1927.8 credits; 192780/192.98 = 998.9636 cents.
let cost = video_ref_1080(20, Some(10)).calculate_consumer_costs();
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);
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);
}
}
@@ -698,7 +682,7 @@ mod tests {
#[test]
fn video_reference_rate_is_cheaper_per_second() {
// 16 < 26, 35 < 59, and 81.4 < 136.23: the with-references rate is
// 16 < 26, 35 < 59, and 61.69 < 103.25: 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
@@ -781,8 +765,6 @@ mod tests {
total_input_seconds: None,
use_face_blur_hack: None,
maybe_bitrate: None,
maybe_output_format: None,
maybe_generate_audio: None,
}
}
@@ -810,8 +792,6 @@ mod tests {
total_input_seconds: None,
use_face_blur_hack: None,
maybe_bitrate: None,
maybe_output_format: None,
maybe_generate_audio: None,
}
}
@@ -872,8 +852,6 @@ mod tests {
total_input_seconds: None,
use_face_blur_hack: None,
maybe_bitrate: None,
maybe_output_format: None,
maybe_generate_audio: None,
});
assert!(matches!(raw.model_type, KinoviModelTypeRaw::Seedance2p5));
@@ -900,14 +878,10 @@ mod tests {
total_input_seconds: Some(7),
use_face_blur_hack: None,
maybe_bitrate: Some(KinoviSeedance2p5Bitrate::High),
maybe_output_format: Some(KinoviSeedance2p5OutputFormat::Mov),
maybe_generate_audio: Some(false),
});
assert!(matches!(raw.aspect_ratio, KinoviAspectRatioRaw::UltraWide21x9));
assert!(matches!(raw.bitrate, Some(KinoviBitrateRaw::High)));
assert!(matches!(raw.maybe_output_format, Some(KinoviOutputFormatRaw::Mov)));
assert_eq!(raw.maybe_generate_audio, Some(false));
assert!(raw.start_frame_url.is_none());
assert!(raw.end_frame_url.is_none());
assert_eq!(raw.reference_image_urls.as_deref().map(|urls| urls.len()), Some(1));
@@ -930,8 +904,6 @@ mod tests {
total_input_seconds: None,
use_face_blur_hack: None,
maybe_bitrate: None,
maybe_output_format: None,
maybe_generate_audio: None,
});
assert!(matches!(raw.output_resolution, Some(KinoviOutputResolutionRaw::TenEightyP)));
@@ -952,8 +924,6 @@ mod tests {
total_input_seconds: None,
use_face_blur_hack: None,
maybe_bitrate: None,
maybe_output_format: None,
maybe_generate_audio: None,
});
assert!(matches!(raw.aspect_ratio, KinoviAspectRatioRaw::Landscape16x9));
@@ -984,8 +954,6 @@ mod tests {
total_input_seconds: None,
use_face_blur_hack: None,
maybe_bitrate: None,
maybe_output_format: None,
maybe_generate_audio: None,
},
}).await?;
println!("t2v 480p — task_id={}, order_id={}", result.task_id, result.order_id);
@@ -1018,8 +986,6 @@ mod tests {
total_input_seconds: None,
use_face_blur_hack: None,
maybe_bitrate: None,
maybe_output_format: None,
maybe_generate_audio: None,
},
}).await?;
println!("t2v 1080p — task_id={}, order_id={}", result.task_id, result.order_id);
@@ -1048,8 +1014,6 @@ mod tests {
total_input_seconds: None,
use_face_blur_hack: None,
maybe_bitrate: None,
maybe_output_format: None,
maybe_generate_audio: None,
},
}).await?;
println!("keyframe 1080p — task_id={}, order_id={}", result.task_id, result.order_id);
@@ -1078,8 +1042,6 @@ mod tests {
total_input_seconds: None,
use_face_blur_hack: None,
maybe_bitrate: None,
maybe_output_format: None,
maybe_generate_audio: None,
},
}).await?;
println!("keyframe 480p — task_id={}, order_id={}", result.task_id, result.order_id);
@@ -1113,8 +1075,6 @@ mod tests {
total_input_seconds: None,
use_face_blur_hack: None,
maybe_bitrate: None,
maybe_output_format: None,
maybe_generate_audio: None,
},
}).await?;
println!("reference 480p — task_id={}, order_id={}", result.task_id, result.order_id);
@@ -148,8 +148,6 @@ fn to_raw_request(req: GenerateSeedance2p5PreviewRequest) -> WorkflowRunTaskRequ
character_ids: None,
use_face_blur_hack: req.use_face_blur_hack,
bitrate: None,
maybe_output_format: None,
maybe_generate_audio: None,
}
}
@@ -68,10 +68,6 @@ pub (super) struct ApiParams {
pub batch_count: Option<u8>,
#[serde(rename = "bitrate_mode", skip_serializing_if = "Option::is_none")]
pub bitrate_mode: Option<&'static str>,
#[serde(rename = "output_format", skip_serializing_if = "Option::is_none")]
pub maybe_output_format: Option<&'static str>,
#[serde(rename = "generate_audio", skip_serializing_if = "Option::is_none")]
pub maybe_generate_audio: Option<bool>,
}
#[derive(Deserialize, Debug)]
@@ -74,12 +74,6 @@ pub struct WorkflowRunTaskRequest {
/// Output video bitrate. None defaults to "standard" (the field is omitted);
/// `High` sends `bitrate_mode: "high"`. Does not affect cost.
pub bitrate: Option<KinoviBitrateRaw>,
/// Output container, supported only by Seedance 2.5. None omits the field.
pub maybe_output_format: Option<KinoviOutputFormatRaw>,
/// Audio generation for Seedance 2.0 and 2.5. None keeps the provider default.
pub maybe_generate_audio: Option<bool>,
}
impl std::fmt::Debug for WorkflowRunTaskRequest {
@@ -99,8 +93,6 @@ impl std::fmt::Debug for WorkflowRunTaskRequest {
.field("output_resolution", &self.output_resolution)
.field("use_face_blur_hack", &self.use_face_blur_hack)
.field("bitrate", &self.bitrate)
.field("maybe_output_format", &self.maybe_output_format)
.field("maybe_generate_audio", &self.maybe_generate_audio)
.finish()
}
}
@@ -116,22 +108,6 @@ impl std::fmt::Debug for WorkflowRunTaskArgs<'_> {
// --- Public enums ---
/// Output container for Seedance 2.5.
#[derive(Debug, Clone, Copy)]
pub enum KinoviOutputFormatRaw {
Mp4,
Mov,
}
impl KinoviOutputFormatRaw {
fn as_api_str(self) -> &'static str {
match self {
Self::Mp4 => "mp4",
Self::Mov => "mov",
}
}
}
/// Video resolution / aspect ratio.
#[derive(Debug, Clone, Copy)]
pub enum KinoviAspectRatioRaw {
@@ -613,15 +589,6 @@ fn build_batch_request(req: WorkflowRunTaskRequest) -> BatchRequest {
audio_urls,
batch_count,
bitrate_mode: req.bitrate.and_then(|bitrate| bitrate.as_api_str()),
maybe_output_format: match req.model_type {
KinoviModelTypeRaw::Seedance2p5 => req.maybe_output_format.map(KinoviOutputFormatRaw::as_api_str),
_ => None,
},
maybe_generate_audio: match req.model_type {
KinoviModelTypeRaw::Seedance2Pro | KinoviModelTypeRaw::Seedance2Fast
| KinoviModelTypeRaw::Seedance2Mini | KinoviModelTypeRaw::Seedance2p5 => req.maybe_generate_audio,
_ => None,
},
},
},
},
@@ -683,8 +650,6 @@ mod tests {
audio_urls: None,
batch_count: None,
bitrate_mode,
maybe_output_format: None,
maybe_generate_audio: None,
}
}
}
@@ -736,8 +701,6 @@ mod tests {
audio_urls: None,
batch_count: None,
bitrate_mode: None,
maybe_output_format: None,
maybe_generate_audio: None,
}
}
}
@@ -772,8 +735,6 @@ mod tests {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_output_format: None,
maybe_generate_audio: None,
}
}
@@ -881,8 +842,6 @@ mod tests {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_output_format: None,
maybe_generate_audio: None,
}
}
@@ -988,51 +947,6 @@ mod tests {
mod seedance_2p5_shape_tests {
use super::*;
#[test]
fn optional_output_settings_preserve_omission_and_explicit_values() {
for (format, expected) in [(None, None), (Some(KinoviOutputFormatRaw::Mp4), Some("mp4")), (Some(KinoviOutputFormatRaw::Mov), Some("mov"))] {
for audio in [None, Some(false), Some(true)] {
let mut request = base_request("A boat on a lake", 4);
request.maybe_output_format = format;
request.maybe_generate_audio = audio;
let body = serde_json::to_value(build_batch_request(request)).unwrap();
let params = &body["0"]["json"]["apiParams"];
assert_eq!(params.get("output_format"), expected.map(serde_json::Value::from).as_ref());
assert_eq!(params.get("generate_audio"), audio.map(serde_json::Value::from).as_ref());
}
}
}
#[test]
fn audio_setting_applies_to_seedance_2p0_without_output_format() {
for model in [KinoviModelTypeRaw::Seedance2Pro, KinoviModelTypeRaw::Seedance2Fast, KinoviModelTypeRaw::Seedance2Mini] {
for audio in [None, Some(false), Some(true)] {
let mut request = base_request("A boat on a lake", 4);
request.model_type = model;
request.maybe_output_format = Some(KinoviOutputFormatRaw::Mov);
request.maybe_generate_audio = audio;
let body = serde_json::to_value(build_batch_request(request)).unwrap();
let params = &body["0"]["json"]["apiParams"];
assert!(params.get("output_format").is_none());
assert_eq!(params.get("generate_audio"), audio.map(serde_json::Value::from).as_ref());
}
}
}
#[test]
fn unrelated_models_omit_unsupported_output_settings() {
for model in [KinoviModelTypeRaw::HappyHorse1p0, KinoviModelTypeRaw::Seedance2p5Preview] {
let mut request = base_request("A boat on a lake", 4);
request.model_type = model;
request.maybe_output_format = Some(KinoviOutputFormatRaw::Mov);
request.maybe_generate_audio = Some(false);
let body = serde_json::to_value(build_batch_request(request)).unwrap();
let params = &body["0"]["json"]["apiParams"];
assert!(params.get("output_format").is_none());
assert!(params.get("generate_audio").is_none());
}
}
fn base_request(prompt: &str, duration_seconds: u8) -> WorkflowRunTaskRequest {
WorkflowRunTaskRequest {
model_type: KinoviModelTypeRaw::Seedance2p5,
@@ -1049,8 +963,6 @@ mod tests {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_output_format: None,
maybe_generate_audio: None,
}
}
@@ -1203,8 +1115,6 @@ mod tests {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_output_format: None,
maybe_generate_audio: None,
}
}
@@ -1295,8 +1205,6 @@ mod tests {
use_face_blur_hack: None,
bitrate: None,
output_resolution: None,
maybe_output_format: None,
maybe_generate_audio: None,
},
};
let result = workflow_run_task(args).await?;
@@ -1331,8 +1239,6 @@ mod tests {
use_face_blur_hack: None,
bitrate: None,
output_resolution: None,
maybe_output_format: None,
maybe_generate_audio: None,
},
};
let result = workflow_run_task(args).await?;
@@ -1369,8 +1275,6 @@ mod tests {
use_face_blur_hack: None,
bitrate: None,
output_resolution: None,
maybe_output_format: None,
maybe_generate_audio: None,
},
};
let result = workflow_run_task(args).await?;
@@ -1406,8 +1310,6 @@ mod tests {
use_face_blur_hack: None,
bitrate: None,
output_resolution: None,
maybe_output_format: None,
maybe_generate_audio: None,
},
};
let result = workflow_run_task(args).await?;
@@ -1445,8 +1347,6 @@ mod tests {
use_face_blur_hack: None,
bitrate: None,
output_resolution: None,
maybe_output_format: None,
maybe_generate_audio: None,
},
};
let result = workflow_run_task(args).await?;
@@ -1501,8 +1401,6 @@ mod tests {
use_face_blur_hack: None,
bitrate: None,
output_resolution: None,
maybe_output_format: None,
maybe_generate_audio: None,
},
};
let result = workflow_run_task(args).await?;
@@ -1556,8 +1454,6 @@ mod tests {
use_face_blur_hack: None,
bitrate: None,
output_resolution: None,
maybe_output_format: None,
maybe_generate_audio: None,
},
};
let result = workflow_run_task(args).await?;
@@ -1611,8 +1507,6 @@ mod tests {
use_face_blur_hack: None,
bitrate: None,
output_resolution: None,
maybe_output_format: None,
maybe_generate_audio: None,
},
};
let result = workflow_run_task(args).await?;
@@ -1675,8 +1569,6 @@ mod tests {
use_face_blur_hack: None,
bitrate: None,
output_resolution: None,
maybe_output_format: None,
maybe_generate_audio: None,
},
};
let result = workflow_run_task(args).await?;
@@ -1732,8 +1624,6 @@ mod tests {
use_face_blur_hack: None,
bitrate: None,
output_resolution: None,
maybe_output_format: None,
maybe_generate_audio: None,
},
};
let result = workflow_run_task(args).await?;
@@ -1774,8 +1664,6 @@ mod tests {
use_face_blur_hack: None,
bitrate: None,
output_resolution: None,
maybe_output_format: None,
maybe_generate_audio: None,
},
};
let result = workflow_run_task(args).await?;
@@ -1810,8 +1698,6 @@ mod tests {
use_face_blur_hack: None,
bitrate: None,
output_resolution: None,
maybe_output_format: None,
maybe_generate_audio: None,
},
};
let result = workflow_run_task(args).await?;
@@ -1848,8 +1734,6 @@ mod tests {
use_face_blur_hack: None,
bitrate: None,
output_resolution: None,
maybe_output_format: None,
maybe_generate_audio: None,
},
};
let result = workflow_run_task(args).await?;
@@ -1887,8 +1771,6 @@ mod tests {
use_face_blur_hack: None,
bitrate: None,
output_resolution: None,
maybe_output_format: None,
maybe_generate_audio: None,
},
};
let result = workflow_run_task(args).await?;
@@ -1927,8 +1809,6 @@ mod tests {
use_face_blur_hack: Some(false),
bitrate: None,
output_resolution: Some(KinoviOutputResolutionRaw::TenEightyP),
maybe_output_format: None,
maybe_generate_audio: None,
},
};
let result = workflow_run_task(args).await?;
@@ -1981,8 +1861,6 @@ mod tests {
use_face_blur_hack: Some(false),
bitrate: None,
output_resolution: None,
maybe_output_format: None,
maybe_generate_audio: None,
},
};
let result = workflow_run_task(args).await?;
@@ -2035,8 +1913,6 @@ mod tests {
use_face_blur_hack: Some(false),
bitrate: None,
output_resolution: Some(KinoviOutputResolutionRaw::TenEightyP),
maybe_output_format: None,
maybe_generate_audio: None,
},
};
let result = workflow_run_task(args).await?;
@@ -2081,8 +1957,6 @@ mod tests {
output_resolution,
use_face_blur_hack: None,
bitrate: None,
maybe_output_format: None,
maybe_generate_audio: None,
},
}
}
@@ -2189,8 +2063,6 @@ mod tests {
use_face_blur_hack: None,
bitrate: None,
output_resolution: Some(KinoviOutputResolutionRaw::FourK),
maybe_output_format: None,
maybe_generate_audio: None,
},
};
let result = workflow_run_task(args).await?;
@@ -4,8 +4,7 @@ use serde_json::{json, Value};
#[derive(Debug)]
pub enum ThumbnailTaskInputMimeType {
MP4,
MOV,
MP4
}
pub enum ThumbnailTaskOutputMimeType {
@@ -18,7 +17,6 @@ impl ThumbnailTaskInputMimeType {
fn as_str(&self) -> &str {
match self {
ThumbnailTaskInputMimeType::MP4 => "video/mp4",
ThumbnailTaskInputMimeType::MOV => "video/quicktime",
}
}
@@ -28,7 +26,7 @@ impl ThumbnailTaskInputMimeType {
fn to_output_mimetypes(&self) -> Vec<ThumbnailTaskOutputMimeType> {
match self {
ThumbnailTaskInputMimeType::MP4 | ThumbnailTaskInputMimeType::MOV => vec![
ThumbnailTaskInputMimeType::MP4 => vec![
ThumbnailTaskOutputMimeType::JPEG,
ThumbnailTaskOutputMimeType::GIF,
],
@@ -213,21 +211,3 @@ impl ThumbnailTask{
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn quicktime_thumbnail_uses_correct_source_mime_type() {
let task = ThumbnailTaskBuilder::new_for_source_mimetype(ThumbnailTaskInputMimeType::MOV)
.with_bucket("test-bucket")
.with_path("video.mov")
.with_event_id("test-event")
.build(ThumbnailTaskOutputMimeType::JPEG)
.unwrap();
let json = task.request_json();
assert_eq!(json["input"]["type"], "video/quicktime");
assert_eq!(json["input"]["path"], "video.mov");
}
}
@@ -120,7 +120,6 @@ pub async fn run(state: &ArtcraftState, args: GenerateVideoArgs) -> anyhow::Resu
resolution: None,
aspect_ratio: None,
bitrate: None,
maybe_output_format: None,
quality: None,
duration_seconds: Some(duration),
video_batch_count: None,
@@ -428,7 +428,6 @@ async fn generate(
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
},
session,
host_override: None,
@@ -459,7 +458,6 @@ async fn generate(
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
},
session,
host_override: None,
+1 -1
View File
@@ -31,4 +31,4 @@ hostname.workspace = true
log = "0.4.14"
[dev-dependencies]
tempfile.workspace = true
# None yet
+3 -20
View File
@@ -5,8 +5,6 @@ use log::{info, warn};
use errors::{anyhow, AnyhowResult};
use server_environment::ServerEnvironment;
use crate::config_search_directories::{config_search_directories, find_repository_root};
pub struct BootstrapArgs<'a, P: AsRef<Path>> {
/// The name of the application or service
pub app_name: &'a str,
@@ -16,7 +14,6 @@ pub struct BootstrapArgs<'a, P: AsRef<Path>> {
pub default_logging_override: Option<&'a str>,
/// Where to look for env conf files.
/// Relative paths are also searched from an enclosing artcraft or artcraft-services checkout.
pub config_search_directories: &'a [P],
/// If true, ignore the root '.env' file.
@@ -116,16 +113,12 @@ pub fn bootstrap<P: AsRef<Path>>(args: BootstrapArgs<'_, P>) -> AnyhowResult<Con
}
fn load_env_config_files<P: AsRef<Path>>(server_environment: ServerEnvironment, args: &BootstrapArgs<'_, P>) -> AnyhowResult<()> {
let maybe_current_directory = std::env::current_dir().ok();
let maybe_repository_root = maybe_current_directory.as_deref().and_then(find_repository_root);
let search_directories = config_search_directories(args.config_search_directories, maybe_repository_root);
let secrets_filename = format!("{}.development-secrets.env", &args.app_name);
let env_config_file_names = match server_environment {
ServerEnvironment::Development => vec![
format!("{}.common.env", &args.app_name),
format!("{}.development.env", &args.app_name),
secrets_filename.clone(), // NB: .gitignore these files
format!("{}.development-secrets.env", &args.app_name), // NB: .gitignore these files
],
ServerEnvironment::Production => vec![
format!("{}.common.env", &args.app_name),
@@ -136,19 +129,9 @@ fn load_env_config_files<P: AsRef<Path>>(server_environment: ServerEnvironment,
for env_config_file in env_config_file_names.into_iter() {
info!("Loading environment variable config file: {}", &env_config_file);
let mut was_read = easyenv::maybe_read_env_config_from_filename_and_paths(
let was_read = easyenv::maybe_read_env_config_from_filename_and_paths(
&env_config_file,
&search_directories)?;
// Development backups fill missing variables, even when a local config file exists.
// dotenv preserves values already set in the environment or earlier config files.
if server_environment == ServerEnvironment::Development && env_config_file == secrets_filename {
if let Some(root) = maybe_repository_root {
was_read |= easyenv::maybe_read_env_config_from_filename_and_paths(
&env_config_file,
&[root.join("secrets/artcraft-backup")])?;
}
}
args.config_search_directories)?;
if was_read {
info!("Environment config file `{}` was read.", &env_config_file);
@@ -1,33 +0,0 @@
use std::path::{Path, PathBuf};
/// Keep caller-supplied paths first, then resolve relative paths from this checkout.
pub(crate) fn config_search_directories<P: AsRef<Path>>(
paths: &[P],
maybe_repository_root: Option<&Path>,
) -> Vec<PathBuf> {
let mut directories = paths.iter()
.map(|path| path.as_ref().to_path_buf())
.collect::<Vec<_>>();
if let Some(root) = maybe_repository_root {
for path in paths {
if path.as_ref().is_relative() {
let directory = root.join(path);
if !directories.contains(&directory) {
directories.push(directory);
}
}
}
}
directories
}
/// Use the enclosing checkout, never a sibling repository's configuration.
pub(crate) fn find_repository_root(current_directory: &Path) -> Option<&Path> {
current_directory.ancestors().find(|directory| {
matches!(directory.file_name().and_then(|name| name.to_str()), Some("artcraft" | "artcraft-services"))
&& directory.join("Cargo.toml").is_file()
&& directory.join("crates").is_dir()
})
}
+1 -3
View File
@@ -18,6 +18,4 @@
#![allow(dead_code)]
#![allow(non_snake_case)]
pub mod bootstrap;
mod config_search_directories;
pub mod bootstrap;
@@ -1,131 +0,0 @@
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use bootstrap::bootstrap::{bootstrap, BootstrapArgs};
use tempfile::TempDir;
const CONFIG_DIRECTORY: &str = "crates/service/job/test_job/config";
const BACKUP_DIRECTORY: &str = "secrets/artcraft-backup";
const CHILD_PROCESS: &str = "BOOTSTRAP_CONFIG_TEST_CHILD";
const VALUE: &str = "BOOTSTRAP_CONFIG_TEST_VALUE";
const MISSING_VALUE: &str = "BOOTSTRAP_CONFIG_TEST_MISSING_VALUE";
const COMMON_VALUE: &str = "BOOTSTRAP_CONFIG_TEST_COMMON_VALUE";
mod development {
use super::*;
#[test]
fn loads_checkout_configs_and_backup_secrets_from_roots_and_subdirectories() {
for name in ["artcraft", "artcraft-services"] {
let (_temporary, root) = checkout(name);
write_config(&root, CONFIG_DIRECTORY, "common", &format!("{COMMON_VALUE}=common\n"));
write_config(&root, BACKUP_DIRECTORY, "development-secrets", &format!("{VALUE}=backup\n"));
// The other checkout must not supply this process's secrets.
let sibling_name = if name == "artcraft" { "artcraft-services" } else { "artcraft" };
let sibling = root.parent().unwrap().join(sibling_name);
write_config(&sibling, BACKUP_DIRECTORY, "development-secrets", &format!("{VALUE}=wrong-checkout\n"));
// Nested directories can themselves be named artcraft without being the repo root.
let nested = root.join("crates/api_clients/artcraft/test_client");
fs::create_dir_all(&nested).unwrap();
for directory in [&root, &root.join(CONFIG_DIRECTORY), &nested] {
run_bootstrap(directory, "development", "backup", "common", false);
}
}
}
#[test]
fn environment_and_local_configs_take_precedence_but_backup_fills_missing_values() {
let (_temporary, root) = checkout("artcraft-services");
write_config(&root, CONFIG_DIRECTORY, "development-secrets", &format!("{VALUE}=local\n"));
write_config(&root, BACKUP_DIRECTORY, "development-secrets",
&format!("{VALUE}=backup\n{MISSING_VALUE}=backup-only\n"));
run_bootstrap(&root, "development", "local", "", true);
run_bootstrap(&root, "development", "environment", "", true);
}
}
mod deployment {
use super::*;
#[test]
fn production_does_not_read_development_secrets() {
let (_temporary, root) = checkout("artcraft-services");
write_config(&root, CONFIG_DIRECTORY, "production", &format!("{VALUE}=production\n"));
write_config(&root, BACKUP_DIRECTORY, "development-secrets", &format!("{MISSING_VALUE}=backup-only\n"));
run_bootstrap(&root.join(CONFIG_DIRECTORY), "production", "production", "", false);
}
#[test]
fn configured_paths_still_work_outside_named_checkouts() {
let temporary = TempDir::new().unwrap();
let root = temporary.path().join("deployment");
write_config(&root, "config", "development-secrets", &format!("{VALUE}=deployment\n"));
write_config(&root, BACKUP_DIRECTORY, "development-secrets", &format!("{MISSING_VALUE}=backup-only\n"));
run_bootstrap(&root, "development", "deployment", "", false);
}
}
// Run bootstrap in a subprocess so environment and logger changes cannot affect other tests.
#[test]
fn bootstrap_child_process() {
if env::var_os(CHILD_PROCESS).is_none() {
return;
}
bootstrap(BootstrapArgs {
app_name: "bootstrap-test",
default_logging_override: None,
config_search_directories: &[".", "./config", CONFIG_DIRECTORY],
ignore_legacy_dot_env_file: true,
}).unwrap();
assert_eq!(env::var(VALUE).unwrap(), env::var("EXPECTED_VALUE").unwrap());
assert_eq!(env::var(COMMON_VALUE).unwrap_or_default(), env::var("EXPECTED_COMMON_VALUE").unwrap());
if env::var("EXPECT_BACKUP_VALUE").unwrap() == "true" {
assert_eq!(env::var(MISSING_VALUE).unwrap(), "backup-only");
} else {
assert!(env::var_os(MISSING_VALUE).is_none());
}
}
fn run_bootstrap(directory: &Path, environment: &str, expected: &str, expected_common: &str, expect_backup: bool) {
let mut command = Command::new(env::current_exe().unwrap());
command.args(["--exact", "bootstrap_child_process", "--nocapture"])
.current_dir(directory)
.env_clear()
.env(CHILD_PROCESS, "1")
.env("RUST_LOG", "error")
.env("SERVER_ENVIRONMENT", environment)
.env("EXPECTED_VALUE", expected)
.env("EXPECTED_COMMON_VALUE", expected_common)
.env("EXPECT_BACKUP_VALUE", expect_backup.to_string());
if expected == "environment" {
command.env(VALUE, "environment");
}
let output = command.output().unwrap();
assert!(output.status.success(), "bootstrap failed in {directory:?}:\n{}\n{}",
String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr));
}
fn checkout(name: &str) -> (TempDir, PathBuf) {
let temporary = TempDir::new().unwrap();
let root = temporary.path().join(name);
fs::create_dir_all(root.join(CONFIG_DIRECTORY)).unwrap();
fs::write(root.join("Cargo.toml"), "[workspace]\n").unwrap();
(temporary, root)
}
fn write_config(root: &Path, directory: &str, suffix: &str, contents: &str) {
let directory = root.join(directory);
fs::create_dir_all(&directory).unwrap();
fs::write(directory.join(format!("bootstrap-test.{suffix}.env")), contents).unwrap();
}
-1
View File
@@ -27,7 +27,6 @@ errors.workspace = true
anyhow.workspace = true
ffprobe = "0.4.0"
log.workspace = true
tempfile.workspace = true
[dev-dependencies]
tempdir.workspace = true
@@ -1,25 +1,18 @@
use std::io::Write;
use errors::AnyhowResult;
use std::path::Path;
use std::str::FromStr;
use errors::AnyhowResult;
use ffprobe::FfProbe;
use tempfile::NamedTempFile;
#[derive(Debug, Default)]
pub struct VideoInfo {
pub dimensions: Option<VideoDimensions>,
pub duration: Option<VideoDuration>,
pub frame_rate: Option<VideoFrameRate>,
}
#[derive(Debug)]
pub struct VideoDimensions {
pub width: u64,
pub height: u64,
}
#[derive(Debug)]
pub struct VideoDuration {
/// We have to convert ffprobe's seconds (with decimal) into milliseconds.
/// We use a u32 as this can hold 49 days of milliseconds.
@@ -29,7 +22,6 @@ pub struct VideoDuration {
pub seconds_original: String,
}
#[derive(Debug)]
pub struct VideoFrameRate {
pub fps: f32,
/// This is the original value returned by ffprobe (for debugging).
@@ -41,41 +33,56 @@ pub fn ffprobe_get_info(
) -> AnyhowResult<VideoInfo>
{
let result = ffprobe::ffprobe(video_path)?;
Ok(info_from_probe(result))
}
/// Probe downloaded/uploaded bytes without changing their container or audio.
/// A seekable temporary file lets ffprobe inspect MOV metadata at the end of the file.
pub fn ffprobe_get_info_from_bytes(bytes: &[u8]) -> AnyhowResult<VideoInfo> {
let mut file = NamedTempFile::new()?;
file.write_all(bytes)?;
ffprobe_get_info(file.path())
}
let mut maybe_dimensions = None;
let mut maybe_duration = None;
let mut maybe_frame_rate = None;
fn info_from_probe(result: FfProbe) -> VideoInfo {
let Some(stream) = result.streams.iter().find(|stream| {
stream.codec_type.as_deref() == Some("video") && stream.disposition.attached_pic == 0
}) else {
return VideoInfo::default();
let video_streams = result.streams.iter()
.filter(|stream| stream.codec_type.as_deref() == Some("video"));
for stream in video_streams {
// Grab the first video stream with dimensions and duration.
if let (Some(width), Some(height), Some(duration)) =
(stream.width, stream.height, stream.duration.as_deref())
{
maybe_dimensions = Some(VideoDimensions {
width: width.unsigned_abs(),
height: height.unsigned_abs(),
});
maybe_duration = Some(duration.to_string());
// avg_frame_rate vs r_frame_rate: https://github.com/eugeneware/ffprobe/issues/7
maybe_frame_rate = Some(stream.avg_frame_rate.to_string());
}
}
let maybe_duration = match maybe_duration {
None => None,
Some(duration) => {
let millis = parse_seconds(&duration)?;
Some(VideoDuration {
millis,
seconds_original: duration,
})
}
};
// MOV files can carry duration only at the container level. Keep dimensions
// even when the stream-level duration is absent.
let dimensions = stream.width.zip(stream.height)
.filter(|(width, height)| *width > 0 && *height > 0)
.map(|(width, height)| VideoDimensions { width: width as u64, height: height as u64 });
let duration = [stream.duration.as_deref(), result.format.duration.as_deref()]
.into_iter().flatten()
.find_map(|seconds| parse_seconds(seconds).ok().map(|millis| VideoDuration {
millis,
seconds_original: seconds.to_string(),
}));
let frame_rate = [&stream.avg_frame_rate, &stream.r_frame_rate].into_iter()
.find_map(|rate| parse_fps(rate).ok()
.filter(|fps| fps.is_finite() && *fps > 0.0)
.map(|fps| VideoFrameRate { fps, fps_original: rate.to_string() }));
let maybe_frame_rate = match maybe_frame_rate {
None => None,
Some(frame_rate) => {
let fps = parse_fps(&frame_rate)?;
Some(VideoFrameRate {
fps,
fps_original: frame_rate,
})
}
};
VideoInfo { dimensions, duration, frame_rate }
Ok(VideoInfo {
dimensions: maybe_dimensions,
duration: maybe_duration,
frame_rate: maybe_frame_rate,
})
}
fn parse_seconds(ffprobe_seconds: &str) -> AnyhowResult<u32> {
@@ -125,56 +132,6 @@ pub mod tests {
assert_eq!(info.frame_rate.unwrap().fps, 30.0);
}
mod quicktime {
use super::super::{ffprobe_get_info_from_bytes, info_from_probe};
use ffprobe::{FfProbe, Format, Stream};
use std::process::Command;
use tempdir::TempDir;
#[test]
fn container_duration_does_not_discard_dimensions() {
let info = info_from_probe(FfProbe {
streams: vec![Stream {
codec_type: Some("video".into()),
width: Some(320),
height: Some(180),
duration: None,
avg_frame_rate: "0/0".into(),
r_frame_rate: "24/1".into(),
..Default::default()
}],
format: Format { duration: Some("1.250".into()), ..Default::default() },
});
let dimensions = info.dimensions.unwrap();
assert_eq!((dimensions.width, dimensions.height), (320, 180));
assert_eq!(info.duration.unwrap().millis, 1250);
assert_eq!(info.frame_rate.unwrap().fps, 24.0);
}
#[test]
fn probes_mov_with_pcm_audio_without_modifying_bytes() {
let dir = TempDir::new("quicktime-metadata-test").unwrap();
let path = dir.path().join("reference.mov");
let output = Command::new("ffmpeg").args([
"-hide_banner", "-loglevel", "error", "-nostdin",
"-f", "lavfi", "-i", "color=c=blue:s=160x90:r=24",
"-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000",
"-t", "1", "-c:v", "libx264", "-pix_fmt", "yuv420p",
"-c:a", "pcm_s16le", "-ac", "2",
]).arg(&path).output().unwrap();
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
let bytes = std::fs::read(&path).unwrap();
let info = ffprobe_get_info_from_bytes(&bytes).unwrap();
let dimensions = info.dimensions.unwrap();
assert_eq!((dimensions.width, dimensions.height), (160, 90));
assert_eq!(info.duration.unwrap().millis, 1000);
assert_eq!(info.frame_rate.unwrap().fps, 24.0);
assert_eq!(std::fs::read(&path).unwrap(), bytes);
let probe = ffprobe::ffprobe(&path).unwrap();
assert!(probe.streams.iter().any(|s| s.codec_name.as_deref() == Some("pcm_s16le")));
}
}
mod parse_seconds {
use super::*;
@@ -22,7 +22,6 @@ const KNOWN_AUDIO_EXTENSIONS: &[Extension] = &[
/// Known video extensions.
const KNOWN_VIDEO_EXTENSIONS: &[Extension] = &[
Extension::from_static("mp4", ".mp4"),
Extension::from_static("mov", ".mov"),
Extension::from_static("webm", ".webm"),
];
@@ -40,7 +39,7 @@ pub enum ExtractExtensions {
/// Accept only known audio extensions (wav, mp3).
KnownAudio,
/// Accept only known video extensions (mp4, mov, webm).
/// Accept only known video extensions (mp4, webm).
KnownVideo,
/// Accept any known media extension (image, audio, or video).
@@ -159,14 +158,6 @@ mod tests {
assert_eq!(ext.unwrap().without_period(), "mp4");
}
#[test]
fn quicktime_is_a_known_video_and_media_extension() {
for accept in [ExtractExtensions::KnownVideo, ExtractExtensions::KnownMedia] {
let ext = extract_extension_from_url_str("https://example.com/clip.MOV?token=test", &accept);
assert_eq!(ext.unwrap().without_period(), "mov");
}
}
#[test]
fn known_video_rejects_mp3() {
let ext = extract_extension_from_url_str("https://example.com/song.mp3", &ExtractExtensions::KnownVideo);
@@ -62,8 +62,6 @@ pub struct InsertMediaFileFromUploadArgs<'a> {
pub maybe_title: Option<&'a str>,
pub maybe_duration_millis: Option<u64>,
pub maybe_frame_width: Option<u32>,
pub maybe_frame_height: Option<u32>,
pub maybe_scene_source_media_file_token: Option<&'a MediaFileToken>,
pub is_intermediate_system_file: bool,
@@ -157,8 +155,6 @@ SET
maybe_title = ?,
maybe_duration_millis = ?,
maybe_frame_width = ?,
maybe_frame_height = ?,
public_bucket_directory_hash = ?,
maybe_public_bucket_prefix = ?,
@@ -209,8 +205,6 @@ SET
args.maybe_title,
args.maybe_duration_millis,
args.maybe_frame_width,
args.maybe_frame_height,
args.public_bucket_directory_hash,
args.maybe_public_bucket_prefix,
@@ -57,10 +57,6 @@ pub enum UserFeatureFlag {
/// Access to Minimax with debugging priority (admins)
#[serde(rename = "minimax_p")]
MinimaxPriority,
/// Access to QuickTime video output options
#[serde(rename = "use_qt")]
CanUseQuicktime,
}
// TODO(bt, 2022-12-21): This desperately needs MySQL integration tests!
@@ -83,7 +79,6 @@ impl UserFeatureFlag {
Self::ApiKey => "api_key",
Self::Minimax => "minimax",
Self::MinimaxPriority => "minimax_p",
Self::CanUseQuicktime => "use_qt",
}
}
@@ -100,7 +95,6 @@ impl UserFeatureFlag {
"api_key" => Ok(Self::ApiKey),
"minimax" => Ok(Self::Minimax),
"minimax_p" => Ok(Self::MinimaxPriority),
"use_qt" => Ok(Self::CanUseQuicktime),
_ => Err(format!("invalid value: {:?}", value)),
}
}
@@ -118,7 +112,6 @@ impl UserFeatureFlag {
Self::ApiKey => "API Keys",
Self::Minimax => "Minimax",
Self::MinimaxPriority => "Minimax (Debugging Priority)",
Self::CanUseQuicktime => "QuickTime Video Output",
}
}
@@ -135,7 +128,6 @@ impl UserFeatureFlag {
Self::ApiKey => "Access to API key creation",
Self::Minimax => "Access to Minimax",
Self::MinimaxPriority => "Access to Minimax with debugging priority (admins)",
Self::CanUseQuicktime => "Access to QuickTime video output options for Seedance 2.5",
}
}
@@ -154,7 +146,6 @@ impl UserFeatureFlag {
Self::ApiKey,
Self::Minimax,
Self::MinimaxPriority,
Self::CanUseQuicktime,
])
}
}
@@ -180,7 +171,6 @@ mod tests {
assert_serialization(UserFeatureFlag::ApiKey, "api_key");
assert_serialization(UserFeatureFlag::Minimax, "minimax");
assert_serialization(UserFeatureFlag::MinimaxPriority, "minimax_p");
assert_serialization(UserFeatureFlag::CanUseQuicktime, "use_qt");
}
#[test]
@@ -196,7 +186,6 @@ mod tests {
assert_eq!(UserFeatureFlag::ApiKey.to_str(), "api_key");
assert_eq!(UserFeatureFlag::Minimax.to_str(), "minimax");
assert_eq!(UserFeatureFlag::MinimaxPriority.to_str(), "minimax_p");
assert_eq!(UserFeatureFlag::CanUseQuicktime.to_str(), "use_qt");
}
#[test]
@@ -212,14 +201,13 @@ mod tests {
assert_eq!(UserFeatureFlag::from_str("api_key").unwrap(), UserFeatureFlag::ApiKey);
assert_eq!(UserFeatureFlag::from_str("minimax").unwrap(), UserFeatureFlag::Minimax);
assert_eq!(UserFeatureFlag::from_str("minimax_p").unwrap(), UserFeatureFlag::MinimaxPriority);
assert_eq!(UserFeatureFlag::from_str("use_qt").unwrap(), UserFeatureFlag::CanUseQuicktime);
assert!(UserFeatureFlag::from_str("foo").is_err());
}
#[test]
fn all_variants() {
let mut variants = UserFeatureFlag::all_variants();
assert_eq!(variants.len(), 12);
assert_eq!(variants.len(), 11);
assert_eq!(variants.pop_first(), Some(UserFeatureFlag::ExploreMedia));
assert_eq!(variants.pop_first(), Some(UserFeatureFlag::Studio));
assert_eq!(variants.pop_first(), Some(UserFeatureFlag::Upload3d));
@@ -231,7 +219,6 @@ mod tests {
assert_eq!(variants.pop_first(), Some(UserFeatureFlag::ApiKey));
assert_eq!(variants.pop_first(), Some(UserFeatureFlag::Minimax));
assert_eq!(variants.pop_first(), Some(UserFeatureFlag::MinimaxPriority));
assert_eq!(variants.pop_first(), Some(UserFeatureFlag::CanUseQuicktime));
assert_eq!(variants.pop_first(), None);
}
}
@@ -1,162 +0,0 @@
use std::collections::BTreeSet;
#[cfg(test)]
use strum::EnumCount;
#[cfg(test)]
use strum::EnumIter;
use utoipa::ToSchema;
use crate::error::enum_error::EnumError;
/// Maximum serialized string length for database storage.
pub const MAX_LENGTH: usize = 16;
/// Output video containers for generation. Unset options retain the provider default.
/// Models without output-format support ignore this option.
///
/// NB: Keep the max serialized length to 16 characters.
#[cfg_attr(test, derive(EnumIter, EnumCount))]
#[derive(Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum CommonVideoOutputFormat {
#[serde(rename = "mp4")]
Mp4,
#[serde(rename = "mov")]
Mov,
}
impl_enum_display_and_debug_using_to_str!(CommonVideoOutputFormat);
impl_mysql_enum_coders!(CommonVideoOutputFormat);
impl_mysql_from_row!(CommonVideoOutputFormat);
impl CommonVideoOutputFormat {
pub fn to_str(&self) -> &'static str {
match self {
Self::Mp4 => "mp4",
Self::Mov => "mov",
}
}
pub fn from_str(value: &str) -> Result<Self, EnumError> {
match value {
"mp4" => Ok(Self::Mp4),
"mov" => Ok(Self::Mov),
_ => Err(EnumError::CouldNotConvertFromString(value.to_string())),
}
}
pub fn all_variants() -> BTreeSet<Self> {
BTreeSet::from([
Self::Mp4,
Self::Mov,
])
}
}
#[cfg(test)]
mod tests {
use crate::common::generation::common_video_output_format::CommonVideoOutputFormat;
use crate::common::generation::common_video_output_format::MAX_LENGTH;
use crate::test_helpers::assert_serialization;
mod explicit_checks {
use super::*;
use crate::error::enum_error::EnumError;
#[test]
fn test_serialization() {
assert_serialization(CommonVideoOutputFormat::Mp4, "mp4");
assert_serialization(CommonVideoOutputFormat::Mov, "mov");
}
#[test]
fn to_str() {
assert_eq!(CommonVideoOutputFormat::Mp4.to_str(), "mp4");
assert_eq!(CommonVideoOutputFormat::Mov.to_str(), "mov");
}
#[test]
fn from_str() {
assert_eq!(CommonVideoOutputFormat::from_str("mp4").unwrap(), CommonVideoOutputFormat::Mp4);
assert_eq!(CommonVideoOutputFormat::from_str("mov").unwrap(), CommonVideoOutputFormat::Mov);
}
#[test]
fn from_str_err() {
let result = CommonVideoOutputFormat::from_str("invalid");
assert!(result.is_err());
if let Err(EnumError::CouldNotConvertFromString(value)) = result {
assert_eq!(value, "invalid");
} else {
panic!("Expected EnumError::CouldNotConvertFromString");
}
}
#[test]
fn all_variants() {
let mut variants = CommonVideoOutputFormat::all_variants();
assert_eq!(variants.len(), 2);
assert_eq!(variants.pop_first(), Some(CommonVideoOutputFormat::Mp4));
assert_eq!(variants.pop_first(), Some(CommonVideoOutputFormat::Mov));
assert_eq!(variants.pop_first(), None);
}
}
mod mechanical_checks {
use super::*;
#[test]
fn variant_length() {
use strum::IntoEnumIterator;
assert_eq!(CommonVideoOutputFormat::all_variants().len(), CommonVideoOutputFormat::iter().len());
}
#[test]
fn round_trip() {
for variant in CommonVideoOutputFormat::all_variants() {
assert_eq!(variant, CommonVideoOutputFormat::from_str(variant.to_str()).unwrap());
assert_eq!(variant, CommonVideoOutputFormat::from_str(&format!("{}", variant)).unwrap());
assert_eq!(variant, CommonVideoOutputFormat::from_str(&format!("{:?}", variant)).unwrap());
}
}
#[test]
fn serialized_length_ok_for_database() {
for variant in CommonVideoOutputFormat::all_variants() {
let serialized = variant.to_str();
assert!(serialized.len() > 0, "variant {:?} is too short", variant);
assert!(serialized.len() <= MAX_LENGTH, "variant {:?} is too long via to_str()", variant);
}
for variant in CommonVideoOutputFormat::all_variants() {
let json = serde_json::to_string(&variant).unwrap().replace('"', "");
assert!(json.len() <= MAX_LENGTH, "variant {:?} is too long via JSON: {:?}", variant, json);
}
}
#[test]
fn serialized_names_must_not_contain_dots() {
for variant in CommonVideoOutputFormat::all_variants() {
let to_str_value = variant.to_str();
assert!(!to_str_value.contains('.'), "to_str() for {:?} contains a dot: {:?}", variant, to_str_value);
let json_value = serde_json::to_string(&variant).unwrap().replace('"', "");
assert!(!json_value.contains('.'), "JSON serialization for {:?} contains a dot: {:?}", variant, json_value);
}
}
#[test]
fn serialized_names_must_only_contain_lowercase_alphanumeric_and_underscore() {
let valid_pattern = regex::Regex::new(r"^[a-z0-9_]+$").unwrap();
for variant in CommonVideoOutputFormat::all_variants() {
let to_str_value = variant.to_str();
assert!(valid_pattern.is_match(to_str_value),
"to_str() for {:?} contains invalid characters: {:?} (only a-z, 0-9, _ allowed)", variant, to_str_value);
let json_value = serde_json::to_string(&variant).unwrap().replace('"', "");
assert!(valid_pattern.is_match(&json_value),
"JSON serialization for {:?} contains invalid characters: {:?} (only a-z, 0-9, _ allowed)", variant, json_value);
}
}
}
}
@@ -15,4 +15,3 @@ pub mod model_creator;
pub mod common_resolution;
pub mod common_splat_model;
pub mod common_video_model;
pub mod common_video_output_format;
@@ -27,8 +27,6 @@ cloud_storage.workspace = true
concurrency.workspace = true
easyenv.workspace = true
enums.workspace = true
mimetypes.workspace = true
ffmpeg_utils.workspace = true
errors.workspace = true
hashing = { path = "../../../lib/files/hashing" }
jobs_common = { path = "../../../lib/jobs_common" }
@@ -9,8 +9,6 @@ use enums::by_table::media_files::media_file_origin_product_category::MediaFileO
use enums::by_table::media_files::media_file_type::MediaFileType;
use enums::common::generation_provider::GenerationProvider;
use errors::AnyhowResult;
use ffmpeg_utils::ffprobe::ffprobe_get_info::{ffprobe_get_info_from_bytes, VideoInfo};
use mimetypes::mimetype_for_bytes::get_mimetype_for_bytes;
use hashing::sha256::sha256_hash_bytes::sha256_hash_bytes;
use mysql_queries::queries::generic_inference::api_providers::kinovi_web::list_pending_kinovi_web_video_jobs::PendingKinoviWebJob;
use mysql_queries::queries::generic_inference::job::select_inference_job_status_for_update::select_inference_job_status_for_update;
@@ -23,6 +21,7 @@ use crate::job_dependencies::JobDependencies;
use crate::jobs::order_processing_job::is_job_status_terminal::is_job_status_terminal;
const PREFIX: &str = "artcraft_";
const SUFFIX: &str = ".mp4";
/// Download the completed video, upload to bucket, create media file record, and mark job done.
pub async fn process_successful_video_job(
@@ -53,7 +52,7 @@ pub async fn process_successful_video_job(
);
// Download the video bytes.
let video_bytes: Vec<u8> = match deps.download_client.get(video_url).send().await.and_then(|resp| resp.error_for_status()) {
let video_bytes: Vec<u8> = match deps.download_client.get(video_url).send().await {
Ok(resp) => match resp.bytes().await {
Ok(bytes) => bytes.to_vec(),
Err(err) => {
@@ -83,22 +82,12 @@ pub async fn process_successful_video_job(
order.order_id
);
let (suffix, mimetype, media_file_type) = video_metadata(&video_bytes)?;
let (video_bytes, probe_result) = tokio::task::spawn_blocking(move || {
let info = ffprobe_get_info_from_bytes(&video_bytes);
(video_bytes, info)
}).await?;
let video_info = probe_result.unwrap_or_else(|err| {
warn!("Could not probe video for order {}: {:?}", order.order_id, err);
VideoInfo::default()
});
// Hash the video.
let checksum = sha256_hash_bytes(&video_bytes)
.map_err(|err| anyhow!("error hashing video: {:?}", err))?;
// Build the bucket path.
let bucket_path = MediaFileBucketPath::generate_new(Some(PREFIX), Some(suffix));
let bucket_path = MediaFileBucketPath::generate_new(Some(PREFIX), Some(SUFFIX));
let object_path = bucket_path.get_full_object_path_str();
@@ -110,7 +99,7 @@ pub async fn process_successful_video_job(
// Upload to public bucket.
let upload_result = deps
.public_bucket_client
.upload_file_with_content_type_process(object_path, &video_bytes, mimetype)
.upload_file_with_content_type_process(object_path, &video_bytes, "video/mp4")
.await;
if let Err(err) = upload_result {
@@ -128,15 +117,10 @@ pub async fn process_successful_video_job(
order.order_id
);
// Prefer the downloaded file's dimensions; provider fields are a fallback.
let maybe_result = order.results.iter().find(|result| result.url == video_url);
let maybe_frame_width = video_info.dimensions.as_ref()
.and_then(|d| u32::try_from(d.width).ok())
.or_else(|| maybe_result.and_then(|result| result.maybe_width));
let maybe_frame_height = video_info.dimensions.as_ref()
.and_then(|d| u32::try_from(d.height).ok())
.or_else(|| maybe_result.and_then(|result| result.maybe_height));
let maybe_duration_millis = video_info.duration.map(|duration| u64::from(duration.millis));
// Optionally extract frame dimensions from the order results.
// Persist the integer dimensions when the API provides them, NULL otherwise.
let maybe_frame_width = order.results.first().and_then(|r| r.maybe_width);
let maybe_frame_height = order.results.first().and_then(|r| r.maybe_height);
// Insert media file record.
let media_file_result = MediaFileInsertBuilder::new()
@@ -146,7 +130,6 @@ pub async fn process_successful_video_job(
.file_size_bytes(video_bytes.len() as u64)
.maybe_creator_anonymous_visitor(job.maybe_creator_anonymous_visitor_token.as_ref())
.maybe_creator_user(job.maybe_creator_user_token.as_ref())
.maybe_duration_millis(maybe_duration_millis)
.maybe_frame_height(maybe_frame_height)
.maybe_frame_width(maybe_frame_width)
.maybe_generation_provider(Some(GenerationProvider::Artcraft))
@@ -156,8 +139,8 @@ pub async fn process_successful_video_job(
.media_file_class(MediaFileClass::Video)
.media_file_origin_category(MediaFileOriginCategory::Inference)
.media_file_origin_product_category(MediaFileOriginProductCategory::VideoGeneration)
.media_file_type(media_file_type)
.mime_type(mimetype)
.media_file_type(MediaFileType::Mp4)
.mime_type("video/mp4")
.public_bucket_directory_hash(&bucket_path)
.insert_pool(&deps.mysql_pool)
.await;
@@ -263,34 +246,3 @@ async fn finalize_success(
Ok(())
}
/// Detect the actual container instead of labeling every provider result as MP4.
fn video_metadata(bytes: &[u8]) -> AnyhowResult<(&'static str, &'static str, MediaFileType)> {
match get_mimetype_for_bytes(bytes) {
Some("video/quicktime") => Ok((".mov", "video/quicktime", MediaFileType::Mov)),
Some("video/mp4") => Ok((".mp4", "video/mp4", MediaFileType::Mp4)),
other => Err(anyhow!("Unsupported Kinovi video result MIME type: {:?}", other)),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detects_quicktime_result() {
let bytes = b"\x00\x00\x00\x14ftypqt \x00\x00\x02\x00qt ";
assert_eq!(video_metadata(bytes).unwrap(), (".mov", "video/quicktime", MediaFileType::Mov));
}
#[test]
fn detects_mp4_result() {
let bytes = b"\x00\x00\x00\x20ftypisom\x00\x00\x02\x00isomiso2avc1mp41";
assert_eq!(video_metadata(bytes).unwrap(), (".mp4", "video/mp4", MediaFileType::Mp4));
}
#[test]
fn rejects_error_page_as_video() {
assert!(video_metadata(b"<html>Download failed</html>").is_err());
}
}
@@ -3,7 +3,6 @@ use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
use enums::common::generation::common_bitrate::CommonBitrate;
use enums::common::generation::common_resolution::CommonResolution;
use enums::common::generation::common_video_model::CommonVideoModel;
use enums::common::generation::common_video_output_format::CommonVideoOutputFormat;
use enums::common::generation::model_creator::ModelCreator;
/// The Seedance 2.5 family of video models.
@@ -49,8 +48,6 @@ pub fn seedance_2p5_video_models() -> Vec<OmniGenVideoModelDetails> {
CommonBitrate::High,
]),
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_default: Some(1),
duration_seconds_min: Some(4),
@@ -98,11 +95,6 @@ pub fn seedance_2p5_video_models() -> Vec<OmniGenVideoModelDetails> {
CommonBitrate::High,
]),
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_default: Some(1),
duration_seconds_min: Some(4),
@@ -159,23 +151,3 @@ pub fn seedance_2p5_video_models() -> Vec<OmniGenVideoModelDetails> {
models
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn output_format_config_matches_supported_models() {
for model in seedance_2p5_video_models() {
let json = serde_json::to_value(&model).unwrap();
if matches!(model.model, CommonVideoModel::Seedance2p5 | CommonVideoModel::Seedance2p5Ultra) {
assert_eq!(json["output_format_options"], json!(["mp4", "mov"]));
assert_eq!(json["output_format_default"], "mp4");
} else {
assert!(json.get("output_format_options").is_none());
assert!(json.get("output_format_default").is_none());
}
}
}
}
@@ -310,7 +310,6 @@ use enums::common::generation::common_model_class::CommonModelClass;
use enums::common::generation::common_model_type::CommonModelType;
use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
use enums::common::generation::common_bitrate::CommonBitrate;
use enums::common::generation::common_video_output_format::CommonVideoOutputFormat;
use enums::common::generation::common_generation_mode::CommonGenerationMode;
use enums::common::generation::common_resolution::CommonResolution;
use enums::common::generation_provider::GenerationProvider;
@@ -817,7 +816,6 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
CommonBitrate,
CommonGenerationMode,
CommonResolution,
CommonVideoOutputFormat,
PromptContextSemanticType,
PromptType,
MediaFileOriginModelType,
@@ -52,7 +52,6 @@ pub async fn estimate_video_cost_handler(
resolution: router_resolution,
aspect_ratio: router_aspect_ratio,
bitrate: None,
maybe_output_format: None,
duration_seconds: request.duration_seconds,
video_batch_count: request.video_batch_count,
generate_audio: request.generate_audio,
@@ -258,8 +258,6 @@ pub async fn upload_snapshot_media_file_handler(
maybe_mime_type: Some(&mimetype),
file_size_bytes: file_size_bytes as u64,
maybe_duration_millis: None,
maybe_frame_width: None,
maybe_frame_height: None,
sha256_checksum: &hash,
maybe_title: Some("Snapshot"),
maybe_scene_source_media_file_token: None,
@@ -153,8 +153,6 @@ pub async fn save_new_project(args: SaveNewProjectArgs<'_>) -> Result<MediaFileT
maybe_mime_type: Some(PROJECT_MIMETYPE),
file_size_bytes: file_bytes.len() as u64,
maybe_duration_millis: None,
maybe_frame_width: None,
maybe_frame_height: None,
sha256_checksum: &sha256_checksum,
maybe_title: maybe_title.as_deref(),
maybe_scene_source_media_file_token: None,
@@ -332,8 +332,6 @@ pub async fn upload_audio_media_file_handler(
maybe_mime_type: Some(&mimetype),
file_size_bytes: file_size_bytes as u64,
maybe_duration_millis,
maybe_frame_width: None,
maybe_frame_height: None,
sha256_checksum: &hash,
maybe_title: maybe_title.as_deref(),
maybe_scene_source_media_file_token: None,
@@ -1,4 +1,3 @@
use std::convert::TryFrom;
use std::collections::HashSet;
use std::sync::Arc;
@@ -12,7 +11,6 @@ use enums::by_table::media_files::media_file_origin_category::MediaFileOriginCat
use enums::by_table::media_files::media_file_type::MediaFileType;
use enums::common::visibility::Visibility;
use hashing::sha256::sha256_hash_bytes::sha256_hash_bytes;
use ffmpeg_utils::ffprobe::ffprobe_get_info::{ffprobe_get_info_from_bytes, VideoInfo};
use http_server_common::request::get_request_ip::get_request_ip;
use media::decode_basic_audio_info::decode_basic_audio_bytes_info;
use mimetypes::mimetype_for_bytes::get_mimetype_for_bytes;
@@ -196,7 +194,7 @@ pub async fn process_upload_media_file(
let do_audio_decode = match mimetype {
// TODO: Revisit when Safari can send us this metadata consistently
"audio/mp4" | "video/mp4" | "video/quicktime" => false,
"audio/mp4" | "video/mp4" => false,
"audio/opus" => {
// TODO/FIXME(bt, 2023-05-19): Symphonia is currently broken for Firefox's opus.
// We're on an off-master branch that may resolve the problem in the future, but for now
@@ -282,18 +280,6 @@ pub async fn process_upload_media_file(
};
let media_file_class = media_file_type.to_media_class();
let video_info = if media_file_class == MediaFileClass::Video {
ffprobe_get_info_from_bytes(&bytes).unwrap_or_else(|err| {
warn!("Could not probe uploaded video: {:?}", err);
VideoInfo::default()
})
} else {
VideoInfo::default()
};
if let Some(duration) = &video_info.duration {
maybe_duration_millis = Some(u64::from(duration.millis));
}
let upload_type = match upload_media_request.media_source {
MediaFileUploadSource::Unknown => UploadType::Filesystem,
@@ -363,8 +349,6 @@ pub async fn process_upload_media_file(
.mime_type(mime_type)
.file_size_bytes(file_size_bytes as u64)
.maybe_duration_millis(maybe_duration_millis)
.maybe_frame_width(video_info.dimensions.as_ref().and_then(|d| u32::try_from(d.width).ok()))
.maybe_frame_height(video_info.dimensions.as_ref().and_then(|d| u32::try_from(d.height).ok()))
.checksum_sha2(&hash)
.maybe_title(upload_media_request.title.as_deref())
.maybe_origin_filename(upload_media_request.file_name.as_deref())
@@ -33,7 +33,7 @@ static ALLOWED_MIME_TYPES : Lazy<HashSet<&'static str>> = Lazy::new(|| {
// Video
"video/mp4",
"video/webm",
"video/quicktime",
//"video/quicktime",
// Image
"image/gif",
"image/jpeg",
@@ -304,8 +304,6 @@ pub async fn upload_image_media_file_handler(
maybe_batch_token: maybe_batch_token.as_ref(),
file_size_bytes: file_size_bytes as u64,
maybe_duration_millis: None,
maybe_frame_width: None,
maybe_frame_height: None,
sha256_checksum: &hash,
maybe_title: maybe_title.as_deref(),
maybe_scene_source_media_file_token: None,
@@ -224,8 +224,6 @@ pub async fn upload_new_engine_asset_media_file_handler(
maybe_mime_type: Some(file_info.mimetype),
file_size_bytes: file_info.file_size_bytes as u64,
maybe_duration_millis: file_info.maybe_duration_millis,
maybe_frame_width: None,
maybe_frame_height: None,
sha256_checksum: &file_info.sha256_checksum,
maybe_scene_source_media_file_token: None,
is_intermediate_system_file: false, // NB: is_user_upload = TRUE
@@ -240,8 +240,6 @@ pub async fn upload_new_scene_media_file_handler(
maybe_mime_type: Some(MIMETYPE),
file_size_bytes: file_size_bytes as u64,
maybe_duration_millis: None,
maybe_frame_width: None,
maybe_frame_height: None,
sha256_checksum: &hash,
maybe_title: maybe_title.as_deref(),
maybe_scene_source_media_file_token: None,
@@ -271,8 +271,6 @@ pub async fn upload_pmx_media_file_handler(
maybe_mime_type: Some("application/octet-stream"),
file_size_bytes: pmx_details.file_size_bytes,
maybe_duration_millis,
maybe_frame_width: None,
maybe_frame_height: None,
sha256_checksum: &pmx_details.sha256_checksum,
maybe_scene_source_media_file_token: None,
is_intermediate_system_file: false, // NB: is_user_upload = TRUE
@@ -233,8 +233,6 @@ pub async fn upload_scene_snapshot_media_file_handler(
maybe_mime_type: Some(MIMETYPE),
file_size_bytes: file_size_bytes as u64,
maybe_duration_millis: None,
maybe_frame_width: None,
maybe_frame_height: None,
sha256_checksum: &hash,
maybe_title: maybe_title.as_deref(),
maybe_scene_source_media_file_token: maybe_scene_source_media_file_token.as_ref(),
@@ -1,4 +1,3 @@
use std::convert::TryFrom;
use std::collections::HashSet;
use std::io::Read;
use std::path::PathBuf;
@@ -18,8 +17,9 @@ use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path:
use enums::by_table::media_files::media_file_class::MediaFileClass;
use enums::by_table::media_files::media_file_type::MediaFileType;
use enums::common::visibility::Visibility;
use ffmpeg_utils::ffmpeg::ffmpeg_transcode_to_mp4::{ffmpeg_transcode_to_mp4, FfmpegTranscodeToMp4Args};
use ffmpeg_utils::ffmpeg::ffmpeg_trim_and_resample::{ffmpeg_trim_and_resample, Args};
use ffmpeg_utils::ffprobe::ffprobe_get_info::{ffprobe_get_info, VideoInfo};
use ffmpeg_utils::ffprobe::ffprobe_get_info::ffprobe_get_info;
use filesys::file_read_bytes::file_read_bytes;
use filesys::path_to_string::path_to_string;
use hashing::sha256::sha256_hash_bytes::sha256_hash_bytes;
@@ -35,7 +35,6 @@ use tokens::tokens::prompts::PromptToken;
use crate::http_server::endpoints::media_files::upload::common_utils::read_upload_session::read_upload_session;
use crate::http_server::endpoints::media_files::upload::upload_error::MediaFileUploadError;
use crate::http_server::endpoints::media_files::upload::common_utils::try_parse_generation_provider::try_parse_generation_provider;
use crate::http_server::user_lookup::user_session::session_utils::lookup::user_session_feature_flags::UserSessionFeatureFlags;
use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format;
use crate::state::server_state::ServerState;
@@ -120,8 +119,14 @@ pub struct UploadNewVideoMediaFileSuccessResponse {
static ALLOWED_MIME_TYPES : Lazy<HashSet<&'static str>> = Lazy::new(|| {
HashSet::from([
// Video
"video/mp4",
"video/quicktime", // Preserve MOV video and audio without transcoding.
"video/mp4", // NB: Only mp4 for now.
])
});
static TRANSCODE_MIME_TYPES : Lazy<HashSet<&'static str>> = Lazy::new(|| {
HashSet::from([
// Video
"video/quicktime", // .qt quicktime files
])
});
@@ -231,7 +236,9 @@ pub async fn upload_new_video_media_file_handler(
MediaFileUploadError::BadInput("Could not determine mimetype for file".to_string())
})?;
if !ALLOWED_MIME_TYPES.contains(mimetype.as_str()) {
let needs_transcode = TRANSCODE_MIME_TYPES.contains(mimetype.as_str());
if !ALLOWED_MIME_TYPES.contains(mimetype.as_str()) && !needs_transcode {
// NB: Don't let our error message inject malicious strings
let filtered_mimetype = mimetype
.chars()
@@ -241,24 +248,10 @@ pub async fn upload_new_video_media_file_handler(
return Err(MediaFileUploadError::BadInput(format!("unpermitted mime type: {}", &filtered_mimetype)));
}
if mimetype == "video/quicktime" {
let maybe_feature_flags = match session_auth.maybe_header_session.as_ref() {
Some(session) => session.maybe_feature_flags.as_deref(),
None => session_auth.maybe_cookie_session.as_ref()
.and_then(|session| session.maybe_feature_flags.as_deref()),
};
let user_feature_flags = UserSessionFeatureFlags::new(maybe_feature_flags);
if !user_feature_flags.can_use_quicktime() {
return Err(MediaFileUploadError::NotAuthorizedVerbose(
"QuickTime uploads are not enabled for this account".to_string(),
));
}
}
// ==================== ORIGINAL FILE BOOKKEEPING ==================== //
// Hold on to the user's original file (path + mimetype + extension) so we
// can upload it as a `_original.{ext}` sibling later if we end up
// trimming or resampling it.
// transcoding and/or resampling it.
let original_file_path: PathBuf = form.file.file.path().to_path_buf();
let original_mimetype: String = mimetype.clone();
@@ -274,10 +267,49 @@ pub async fn upload_new_video_media_file_handler(
MediaFileUploadError::ServerError
})?;
// Preserve the source container and audio unless the caller requests an edit.
let mut final_upload_file_path = form.file.file.path().to_path_buf();
// ==================== OPTIONAL TRANSCODE TO MP4 ==================== //
// Some accepted source mime types (e.g. QuickTime .mov) are not the canonical
// distribution format we want for the media file record. Transcode them to
// mp4 in a tempdir so we can resample / upload mp4 instead.
// Tempdirs we need to keep alive across the upload step. They get dropped at
// function exit which cleans up the on-disk files.
let mut _transcode_tempdir_ref = None;
let mut _resample_tempdir_ref = None;
let mut final_upload_file_path = form.file.file.path().to_path_buf();
if needs_transcode {
let transcode_tempdir = server_state.temp_dir_creator.new_tempdir("ffmpeg_transcode")
.map_err(|err| {
error!("Problem creating transcode temp dir: {:?}", err);
MediaFileUploadError::ServerError
})?;
let transcoded_path = transcode_tempdir.path().join("transcoded.mp4");
info!("Transcoding {} → mp4", &original_mimetype);
ffmpeg_transcode_to_mp4(FfmpegTranscodeToMp4Args {
video_input_path: form.file.file.path(),
video_output_path: &transcoded_path,
}).map_err(|err| {
error!("Problem transcoding video to mp4: {:?}", err);
MediaFileUploadError::ServerError
})?;
// The canonical file is now the transcoded mp4. Read its bytes and pin
// its mimetype to mp4 so all the downstream metadata reflects the
// transcoded file rather than the user's original upload.
file_bytes = file_read_bytes(&transcoded_path)
.map_err(|e| {
error!("Problem reading transcoded mp4: {:?}", e);
MediaFileUploadError::ServerError
})?;
mimetype = "video/mp4".to_string();
final_upload_file_path = transcoded_path;
_transcode_tempdir_ref = Some(transcode_tempdir);
}
// ==================== OPTIONAL VIDEO RESAMPLE ==================== //
let should_resample = form.maybe_resample_fps.is_some()
@@ -298,7 +330,8 @@ pub async fn upload_new_video_media_file_handler(
let maybe_start_offset = form.maybe_trim_start_millis.map(|millis| Duration::from_millis(millis.0));
let maybe_end_offset = form.maybe_trim_end_millis.map(|millis| Duration::from_millis(millis.0));
// Explicit trimming/resampling produces MP4; the original is retained below.
// NB: Resample reads from `final_upload_file_path` (which is the transcoded
// mp4 if we transcoded above, or the user's original upload otherwise).
ffmpeg_trim_and_resample(Args {
video_input_path: &final_upload_file_path,
video_output_path: &video_output_path,
@@ -327,18 +360,23 @@ pub async fn upload_new_video_media_file_handler(
_resample_tempdir_ref = Some(frame_temp_dir); // NB: Keep from going out of scope
}
// Retain the unmodified original when the caller requests an edit.
let should_upload_original = should_resample;
// True if we touched the user's file in any way (transcoded, resampled, or both).
// We'll upload their unmodified original alongside the canonical file when so.
let should_upload_original = needs_transcode || should_resample;
// ==================== OTHER FILE METADATA ==================== //
let video_info = ffprobe_get_info(&final_upload_file_path).unwrap_or_else(|err| {
warn!("Could not probe uploaded video: {:?}", err);
VideoInfo::default()
});
let maybe_duration_millis = video_info.duration.map(|duration| u64::from(duration.millis));
let maybe_frame_width = video_info.dimensions.as_ref().and_then(|d| u32::try_from(d.width).ok());
let maybe_frame_height = video_info.dimensions.as_ref().and_then(|d| u32::try_from(d.height).ok());
let mut maybe_duration_millis = None;
match ffprobe_get_info(&final_upload_file_path) {
Ok(video_info) => {
maybe_duration_millis = video_info.duration
.map(|duration| duration.millis as u64);
}
Err(error) => {
warn!("Error reading video dimensions with ffprobe: {:?}", error);
}
}
let maybe_filename = form.file.file_name.as_deref()
.as_deref()
@@ -385,7 +423,7 @@ pub async fn upload_new_video_media_file_handler(
})?;
// ==================== UPLOAD ORIGINAL SIBLING ==================== //
// If we trimmed or resampled the user's upload, also upload the
// If we transcoded and/or resampled the user's upload, also upload the
// unmodified original alongside the canonical file as `_original.{ext}`
// (sharing the same object hash / directory). The media file record points
// at the canonical file, not this sibling.
@@ -469,8 +507,6 @@ pub async fn upload_new_video_media_file_handler(
maybe_mime_type: Some(&mimetype),
file_size_bytes: file_size_bytes as u64,
maybe_duration_millis,
maybe_frame_width,
maybe_frame_height,
sha256_checksum: &hash,
maybe_title: maybe_title.as_deref(),
maybe_scene_source_media_file_token,
@@ -489,12 +525,7 @@ pub async fn upload_new_video_media_file_handler(
info!("new media file id: {} token: {:?}", record_id, &token);
let thumbnail_mimetype = if mimetype == "video/quicktime" {
ThumbnailTaskInputMimeType::MOV
} else {
ThumbnailTaskInputMimeType::MP4
};
let thumbnail_task_result = ThumbnailTaskBuilder::new_for_source_mimetype(thumbnail_mimetype)
let thumbnail_task_result = ThumbnailTaskBuilder::new_for_source_mimetype(ThumbnailTaskInputMimeType::MP4)
.with_bucket(server_state.public_bucket_client.bucket_name().as_str())
.with_path(&*path_to_string(public_upload_path.to_full_object_pathbuf()))
.with_output_suffix("thumb")
@@ -236,8 +236,6 @@ pub async fn upload_video_media_file_handler(
maybe_mime_type: Some(mimetype),
file_size_bytes: file_size_bytes as u64,
maybe_duration_millis: Some(mp4_info.duration_millis as u64),
maybe_frame_width: None,
maybe_frame_height: None,
sha256_checksum: &hash,
maybe_title: upload_media_request.title.as_deref(),
maybe_scene_source_media_file_token: None,
@@ -128,7 +128,6 @@ fn to_omni_gen_request(request: OmniApiVideoGenerateRequest) -> OmniGenVideoCost
resolution: request.resolution,
aspect_ratio: request.aspect_ratio,
bitrate: request.bitrate,
maybe_output_format: request.maybe_output_format,
quality: request.quality,
duration_seconds: request.duration_seconds,
video_batch_count: request.video_batch_count,
@@ -1,5 +1,3 @@
use std::convert::TryFrom;
use ffmpeg_utils::ffprobe::ffprobe_get_info::{ffprobe_get_info_from_bytes, VideoInfo};
use std::collections::HashSet;
use log::{info, warn};
@@ -73,15 +71,6 @@ pub async fn upload_url_to_media_file(
format!("Unpermitted mime type for URL {}: {}", url, filtered_mimetype)));
}
let video_info = if matches!(kind, MediaUploadKind::Video) {
ffprobe_get_info_from_bytes(&file_bytes).unwrap_or_else(|err| {
warn!("Could not probe uploaded video URL: {:?}", err);
VideoInfo::default()
})
} else {
VideoInfo::default()
};
// ==================== OTHER FILE METADATA ==================== //
let extension = mimetype_to_extension(&mimetype)
@@ -136,9 +125,7 @@ pub async fn upload_url_to_media_file(
maybe_batch_token: None,
file_size_bytes,
// NB: Duration would require probing the file on disk; URL inputs skip it.
maybe_duration_millis: video_info.duration.map(|duration| u64::from(duration.millis)),
maybe_frame_width: video_info.dimensions.as_ref().and_then(|d| u32::try_from(d.width).ok()),
maybe_frame_height: video_info.dimensions.as_ref().and_then(|d| u32::try_from(d.height).ok()),
maybe_duration_millis: None,
sha256_checksum: &hash,
maybe_title: None,
maybe_scene_source_media_file_token: None,
@@ -172,7 +159,6 @@ static IMAGE_MIME_TYPES: Lazy<HashSet<&'static str>> = Lazy::new(|| {
static VIDEO_MIME_TYPES: Lazy<HashSet<&'static str>> = Lazy::new(|| {
HashSet::from([
"video/mp4",
"video/quicktime",
])
});
@@ -88,7 +88,6 @@ mod tests {
resolution: None,
aspect_ratio: None,
bitrate: None,
maybe_output_format: None,
quality: None,
duration_seconds: Some(5),
video_batch_count: Some(1),
@@ -70,7 +70,6 @@ mod tests {
resolution: None,
aspect_ratio: None,
bitrate: None,
maybe_output_format: None,
quality: None,
duration_seconds: Some(5),
video_batch_count: Some(1),
@@ -296,8 +296,6 @@ pub async fn omni_upload_audio_media_file_handler(
maybe_mime_type: Some(&mimetype),
file_size_bytes: file_size_bytes as u64,
maybe_duration_millis,
maybe_frame_width: None,
maybe_frame_height: None,
sha256_checksum: &hash,
maybe_title: maybe_title.as_deref(),
maybe_scene_source_media_file_token: None,
@@ -282,8 +282,6 @@ pub async fn omni_upload_image_media_file_handler(
maybe_batch_token: maybe_batch_token.as_ref(),
file_size_bytes: file_size_bytes as u64,
maybe_duration_millis: None,
maybe_frame_width: None,
maybe_frame_height: None,
sha256_checksum: &hash,
maybe_title: maybe_title.as_deref(),
maybe_scene_source_media_file_token: None,
@@ -1,4 +1,3 @@
use std::convert::TryFrom;
use std::collections::HashSet;
use std::io::Read;
use std::path::PathBuf;
@@ -19,8 +18,9 @@ use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path:
use enums::by_table::media_files::media_file_class::MediaFileClass;
use enums::by_table::media_files::media_file_type::MediaFileType;
use enums::common::visibility::Visibility;
use ffmpeg_utils::ffmpeg::ffmpeg_transcode_to_mp4::{ffmpeg_transcode_to_mp4, FfmpegTranscodeToMp4Args};
use ffmpeg_utils::ffmpeg::ffmpeg_trim_and_resample::{ffmpeg_trim_and_resample, Args};
use ffmpeg_utils::ffprobe::ffprobe_get_info::{ffprobe_get_info, VideoInfo};
use ffmpeg_utils::ffprobe::ffprobe_get_info::ffprobe_get_info;
use filesys::file_read_bytes::file_read_bytes;
use filesys::path_to_string::path_to_string;
use hashing::sha256::sha256_hash_bytes::sha256_hash_bytes;
@@ -108,8 +108,14 @@ pub struct OmniUploadVideoMediaFileForm {
static ALLOWED_MIME_TYPES : Lazy<HashSet<&'static str>> = Lazy::new(|| {
HashSet::from([
// Video
"video/mp4",
"video/quicktime", // Preserve MOV video and audio without transcoding.
"video/mp4", // NB: Only mp4 for now.
])
});
static TRANSCODE_MIME_TYPES : Lazy<HashSet<&'static str>> = Lazy::new(|| {
HashSet::from([
// Video
"video/quicktime", // .qt quicktime files
])
});
@@ -212,7 +218,9 @@ pub async fn omni_upload_video_media_file_handler(
MediaFileUploadError::BadInput("Could not determine mimetype for file".to_string())
})?;
if !ALLOWED_MIME_TYPES.contains(mimetype.as_str()) {
let needs_transcode = TRANSCODE_MIME_TYPES.contains(mimetype.as_str());
if !ALLOWED_MIME_TYPES.contains(mimetype.as_str()) && !needs_transcode {
// NB: Don't let our error message inject malicious strings
let filtered_mimetype = mimetype
.chars()
@@ -225,7 +233,7 @@ pub async fn omni_upload_video_media_file_handler(
// ==================== ORIGINAL FILE BOOKKEEPING ==================== //
// Hold on to the user's original file (path + mimetype + extension) so we
// can upload it as a `_original.{ext}` sibling later if we end up
// trimming or resampling it.
// transcoding and/or resampling it.
let original_file_path: PathBuf = form.file.file.path().to_path_buf();
let original_mimetype: String = mimetype.clone();
@@ -241,10 +249,44 @@ pub async fn omni_upload_video_media_file_handler(
MediaFileUploadError::ServerError
})?;
// Preserve the source container and audio unless the caller requests an edit.
let mut final_upload_file_path = form.file.file.path().to_path_buf();
// ==================== OPTIONAL TRANSCODE TO MP4 ==================== //
// Some accepted source mime types (e.g. QuickTime .mov) are not the canonical
// distribution format we want for the media file record. Transcode them to
// mp4 in a tempdir so we can resample / upload mp4 instead.
let mut _transcode_tempdir_ref = None;
let mut _resample_tempdir_ref = None;
let mut final_upload_file_path = form.file.file.path().to_path_buf();
if needs_transcode {
let transcode_tempdir = server_state.temp_dir_creator.new_tempdir("ffmpeg_transcode")
.map_err(|err| {
error!("Problem creating transcode temp dir: {:?}", err);
MediaFileUploadError::ServerError
})?;
let transcoded_path = transcode_tempdir.path().join("transcoded.mp4");
info!("Transcoding {} → mp4", &original_mimetype);
ffmpeg_transcode_to_mp4(FfmpegTranscodeToMp4Args {
video_input_path: form.file.file.path(),
video_output_path: &transcoded_path,
}).map_err(|err| {
error!("Problem transcoding video to mp4: {:?}", err);
MediaFileUploadError::ServerError
})?;
file_bytes = file_read_bytes(&transcoded_path)
.map_err(|e| {
error!("Problem reading transcoded mp4: {:?}", e);
MediaFileUploadError::ServerError
})?;
mimetype = "video/mp4".to_string();
final_upload_file_path = transcoded_path;
_transcode_tempdir_ref = Some(transcode_tempdir);
}
// ==================== OPTIONAL VIDEO RESAMPLE ==================== //
let should_resample = form.maybe_resample_fps.is_some()
@@ -292,18 +334,22 @@ pub async fn omni_upload_video_media_file_handler(
_resample_tempdir_ref = Some(frame_temp_dir); // NB: Keep from going out of scope
}
// Retain the unmodified original when the caller requests an edit.
let should_upload_original = should_resample;
// True if we touched the user's file in any way (transcoded, resampled, or both).
let should_upload_original = needs_transcode || should_resample;
// ==================== OTHER FILE METADATA ==================== //
let video_info = ffprobe_get_info(&final_upload_file_path).unwrap_or_else(|err| {
warn!("Could not probe uploaded video: {:?}", err);
VideoInfo::default()
});
let maybe_duration_millis = video_info.duration.map(|duration| u64::from(duration.millis));
let maybe_frame_width = video_info.dimensions.as_ref().and_then(|d| u32::try_from(d.width).ok());
let maybe_frame_height = video_info.dimensions.as_ref().and_then(|d| u32::try_from(d.height).ok());
let mut maybe_duration_millis = None;
match ffprobe_get_info(&final_upload_file_path) {
Ok(video_info) => {
maybe_duration_millis = video_info.duration
.map(|duration| duration.millis as u64);
}
Err(error) => {
warn!("Error reading video dimensions with ffprobe: {:?}", error);
}
}
let maybe_filename = form.file.file_name.as_deref()
.as_deref()
@@ -350,7 +396,7 @@ pub async fn omni_upload_video_media_file_handler(
})?;
// ==================== UPLOAD ORIGINAL SIBLING ==================== //
// If we trimmed or resampled the user's upload, also upload the
// If we transcoded and/or resampled the user's upload, also upload the
// unmodified original alongside the canonical file as `_original.{ext}`.
if should_upload_original {
@@ -431,8 +477,6 @@ pub async fn omni_upload_video_media_file_handler(
maybe_mime_type: Some(&mimetype),
file_size_bytes: file_size_bytes as u64,
maybe_duration_millis,
maybe_frame_width,
maybe_frame_height,
sha256_checksum: &hash,
maybe_title: maybe_title.as_deref(),
maybe_scene_source_media_file_token,
@@ -451,12 +495,7 @@ pub async fn omni_upload_video_media_file_handler(
info!("new media file id: {} token: {:?}", record_id, &token);
let thumbnail_mimetype = if mimetype == "video/quicktime" {
ThumbnailTaskInputMimeType::MOV
} else {
ThumbnailTaskInputMimeType::MP4
};
let thumbnail_task_result = ThumbnailTaskBuilder::new_for_source_mimetype(thumbnail_mimetype)
let thumbnail_task_result = ThumbnailTaskBuilder::new_for_source_mimetype(ThumbnailTaskInputMimeType::MP4)
.with_bucket(server_state.public_bucket_client.bucket_name().as_str())
.with_path(&*path_to_string(public_upload_path.to_full_object_pathbuf()))
.with_output_suffix("thumb")
@@ -271,7 +271,6 @@ mod tests {
resolution: None,
aspect_ratio: None,
bitrate: None,
maybe_output_format: None,
quality: None,
duration_seconds: None,
video_batch_count: None,
@@ -6,7 +6,6 @@ use artcraft_router::api::router_aspect_ratio::RouterAspectRatio;
use artcraft_router::api::router_bitrate::RouterBitrate;
use artcraft_router::api::router_resolution::RouterResolution;
use artcraft_router::api::router_video_model::RouterVideoModel;
use artcraft_router::api::router_video_output_format::RouterVideoOutputFormat;
use artcraft_router::api::image_list_ref::ImageListRef;
use artcraft_router::api::image_ref::ImageRef;
use artcraft_router::api::router_provider::RouterProvider;
@@ -17,7 +16,6 @@ use enums::common::generation::common_aspect_ratio::CommonAspectRatio as CommonA
use enums::common::generation::common_bitrate::CommonBitrate as CommonBitrateEnum;
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
use enums::common::generation::common_video_output_format::CommonVideoOutputFormat;
pub fn hydrate_to_router_request(
request: &OmniGenVideoCostAndGenerateRequest,
@@ -65,20 +63,9 @@ pub fn hydrate_to_router_request(
resolution,
aspect_ratio,
bitrate,
maybe_output_format: if matches!(model, RouterVideoModel::Seedance2p5 | RouterVideoModel::Seedance2p5Ultra) {
Some(match request.maybe_output_format.unwrap_or(CommonVideoOutputFormat::Mp4) {
CommonVideoOutputFormat::Mp4 => RouterVideoOutputFormat::Mp4,
CommonVideoOutputFormat::Mov => RouterVideoOutputFormat::Mov,
})
} else {
None
},
duration_seconds: request.duration_seconds,
video_batch_count: request.video_batch_count,
// 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 },
generate_audio: 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,
@@ -87,24 +74,6 @@ 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> {
@@ -153,122 +122,6 @@ fn convert_bitrate(
mod tests {
use super::*;
mod output_format {
use super::*;
use artcraft_router::client::router_client::RouterClient;
use artcraft_router::client::router_kinovi_web_client::RouterKinoviWebClient;
use artcraft_router::generate::generate_video::video_generation_draft_context::VideoGenerationDraftContext;
use artcraft_router::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use artcraft_router::generate::generate_video::video_generation_request::VideoGenerationRequest;
use kinovi_web_client::creds::kinovi_web_session::KinoviWebSession;
use kinovi_web_client::generate::video::generate_seedance_2p5::KinoviSeedance2p5OutputFormat;
use serde_json::{json, Value};
#[tokio::test]
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()),
));
let context = VideoGenerationDraftContext { client: Some(&client), ..Default::default() };
for model in ["seedance_2p5", "seedance_2p5_u"] {
for output_format in [None, Some(Value::Null), Some(json!("mp4")), Some(json!("mov"))] {
for generate_audio in [None, Some(true), Some(false)] {
let mut body = json!({
"model": model,
"generate_audio": generate_audio,
});
if let Some(format) = &output_format {
body["output_format"] = format.clone();
}
let request: OmniGenVideoCostAndGenerateRequest = serde_json::from_value(body).unwrap();
let mut builder = hydrate_to_router_request(&request).unwrap();
builder.provider = RouterProvider::KinoviWeb;
// Mirror run_pipeline_v2: Ultra executes as the base model on
// its own account, while billing retains the original model.
if matches!(builder.model, RouterVideoModel::Seedance2p5Ultra) {
builder.model = RouterVideoModel::Seedance2p5;
}
let draft = match builder.build2().unwrap() {
VideoGenerationDraftOrRequest::Draft(draft) => draft,
other => panic!("Unexpected request: {:?}", other),
};
let finalized = draft.finalize(context.clone()).await.unwrap();
let request = match finalized {
VideoGenerationRequest::KinoviSeedance2p5(state) => state.request,
other => panic!("Unexpected request: {:?}", other),
};
let actual_format = request.maybe_output_format.map(|format| match format {
KinoviSeedance2p5OutputFormat::Mp4 => "mp4",
KinoviSeedance2p5OutputFormat::Mov => "mov",
});
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, None, "generate_audio: {generate_audio:?}");
}
}
}
}
#[test]
fn unsupported_model_ignores_format() {
for model in ["seedance_2p0", "seedance_2p5_preview"] {
let request: OmniGenVideoCostAndGenerateRequest = serde_json::from_value(json!({
"model": model, "output_format": "mov"
})).unwrap();
assert!(hydrate_to_router_request(&request).unwrap().maybe_output_format.is_none());
}
}
#[test]
fn invalid_format_is_rejected() {
for output_format in [json!("webm"), json!("MP4"), json!(42)] {
let request = serde_json::from_value::<OmniGenVideoCostAndGenerateRequest>(json!({
"model": "seedance_2p5", "output_format": output_format,
}));
assert!(request.is_err());
}
}
}
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::*;
@@ -314,7 +167,6 @@ mod tests {
resolution: None,
aspect_ratio: None,
bitrate: None,
maybe_output_format: None,
quality: None,
duration_seconds: None,
video_batch_count: None,
@@ -29,5 +29,3 @@ mod mcp_session_tests;
mod omni_api_parity_tests;
mod seedance_2p0;
mod seedance_2p5;
mod quicktime_tests;
@@ -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, stub_traffic, to_omni_api_request, ExpectedCredits, Seconds,
TestHarness, STARTING_CREDITS,
base_generate_request, to_omni_api_request, ExpectedCredits, Seconds, TestHarness,
STARTING_CREDITS,
};
mod billing_parity {
@@ -46,26 +46,6 @@ 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::*;
@@ -215,47 +195,3 @@ 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,
);
}
}
@@ -1,179 +0,0 @@
use std::process::Command;
use actix_web::cookie::Cookie;
use actix_web::http::StatusCode;
use actix_web::{test, web, App};
use enums::by_table::media_files::media_file_class::MediaFileClass;
use enums::by_table::media_files::media_file_type::MediaFileType;
use enums::common::generation::common_resolution::CommonResolution;
use enums::common::generation::common_video_model::CommonVideoModel;
use enums::common::generation::common_video_output_format::CommonVideoOutputFormat;
use mysql_queries::queries::media_files::get::get_media_file::get_media_file;
use mysql_queries::queries::users::user::update::set_user_feature_flags::{set_user_feature_flags, SetUserFeatureFlagArgs};
use mysql_testing::fixtures::users::{create_test_user, TestUser};
use tokens::tokens::media_files::MediaFileToken;
use crate::http_server::endpoints::media_files::upload::upload_generic::upload_media_file_handler::upload_media_file_handler;
use crate::http_server::endpoints::media_files::upload::upload_video_new::upload_new_video_media_file_handler::upload_new_video_media_file_handler;
use crate::http_server::endpoints::omni_api::upload::omni_upload_video_media_file_handler::omni_upload_video_media_file_handler;
use crate::http_server::endpoints::omni_gen::generate::video::tests::support::{base_generate_request, stub_traffic, TestHarness};
const BOUNDARY: &str = "artcraft-quicktime-test-boundary";
#[actix_web::test]
#[cfg_attr(feature = "skip_database_tests", ignore)]
async fn quicktime_uploads_are_video_references_with_mp4_or_mov_output() {
let harness = TestHarness::create().await;
let user = harness.create_funded_user(10_000).await;
enable_quicktime(&harness, &user).await;
let api_key = harness.create_api_key(&user).await;
let bytes = quicktime_fixture();
let app = test::init_service(App::new()
.app_data(web::Data::new(harness.server_state.clone()))
.route("/new-video", web::post().to(upload_new_video_media_file_handler))
.route("/omni-upload", web::post().to(omni_upload_video_media_file_handler))
.route("/generic-upload", web::post().to(upload_media_file_handler))
).await;
for route in ["/new-video", "/omni-upload", "/generic-upload"] {
let idempotency = base_generate_request(CommonVideoModel::Seedance2p5).idempotency_token.unwrap();
let body = multipart_body(&idempotency, &bytes);
let request = test::TestRequest::post().uri(route)
.insert_header(("Authorization", format!("Bearer {}", api_key.as_str_be_careful())))
.insert_header(("Content-Type", format!("multipart/form-data; boundary={BOUNDARY}")))
.peer_addr("127.0.0.1:9999".parse().unwrap())
.set_payload(body).to_request();
let response = test::call_service(&app, request).await;
let status = response.status();
let body = test::read_body(response).await;
assert!(status.is_success(), "{}: {}", route, String::from_utf8_lossy(&body));
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
let token = MediaFileToken::new_from_str(json["media_file_token"].as_str().unwrap());
let media = get_media_file(&token, false, &harness.pool).await.unwrap().unwrap();
assert_eq!(media.media_class, MediaFileClass::Video);
assert_eq!(media.media_type, MediaFileType::Mov);
assert_eq!(media.maybe_mime_type.as_deref(), Some("video/quicktime"));
assert_eq!(media.maybe_public_bucket_extension.as_deref(), Some(".mov"));
assert_eq!(media.maybe_duration_millis, Some(1000));
for (format, expected) in [(None, "mp4"), (Some(CommonVideoOutputFormat::Mp4), "mp4"), (Some(CommonVideoOutputFormat::Mov), "mov")] {
let mut request = base_generate_request(CommonVideoModel::Seedance2p5);
let prompt = format!("QuickTime reference test {}", request.idempotency_token.as_deref().unwrap());
request.prompt = Some(prompt.clone());
request.reference_video_media_tokens = Some(vec![token.clone()]);
request.maybe_output_format = format;
request.generate_audio = Some(true);
request.duration_seconds = Some(4);
request.resolution = Some(CommonResolution::FourEightyP);
let response = harness.post_generate(&user, request).await.expect("submit MOV video reference");
assert!(response.success);
let traffic = stub_traffic().lock().unwrap();
let params = traffic.workflows.iter()
.map(|request| &request["0"]["json"]["apiParams"])
.find(|params| params["prompt"].as_str() == Some(&prompt))
.expect("captured Kinovi request");
assert_eq!(params["output_format"], expected);
// 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);
}
}
}
#[actix_web::test]
#[cfg_attr(feature = "skip_database_tests", ignore)]
async fn new_video_quicktime_uploads_require_feature_flag_for_every_auth_type() {
let harness = TestHarness::create().await;
let mov_bytes = quicktime_fixture();
let mp4_bytes = video_fixture("mp4", "aac");
let app = test::init_service(App::new()
.app_data(web::Data::new(harness.server_state.clone()))
.route("/new-video", web::post().to(upload_new_video_media_file_handler))
).await;
for enabled in [false, true] {
let user = create_test_user(&harness.pool).await.unwrap();
if enabled {
enable_quicktime(&harness, &user).await;
}
let api_key = harness.create_api_key(&user).await;
let mcp_session = harness.create_mcp_session(&user).await;
let cookie = harness.server_state.session_cookie_manager
.create_cookie(&user.session_token, &user.user_token).unwrap();
for auth in ["anonymous", "cookie", "api_key", "mcp"] {
for (is_quicktime, bytes) in [(true, &mov_bytes), (false, &mp4_bytes)] {
let idempotency = base_generate_request(CommonVideoModel::Seedance2p5).idempotency_token.unwrap();
// Even MOV bytes claim to be MP4: authorization must use the detected
// container, not the filename or multipart Content-Type.
let body = multipart_body_for_file(&idempotency, bytes, "reference.mp4", "video/mp4");
let mut request = test::TestRequest::post().uri("/new-video")
.insert_header(("Content-Type", format!("multipart/form-data; boundary={BOUNDARY}")))
.peer_addr("127.0.0.1:9999".parse().unwrap())
.set_payload(body);
request = match auth {
"cookie" => request.cookie(Cookie::new("session", cookie.value().to_string())),
"api_key" => request.insert_header(("Authorization", format!("Bearer {}", api_key.as_str_be_careful()))),
"mcp" => request.insert_header(("Authorization", format!("Bearer {}", mcp_session.as_str()))),
_ => request,
};
let response = test::call_service(&app, request.to_request()).await;
let status = response.status();
let body = test::read_body(response).await;
let expected = if is_quicktime && (!enabled || auth == "anonymous") {
StatusCode::UNAUTHORIZED
} else {
StatusCode::OK
};
assert_eq!(status, expected,
"enabled={}, auth={}, quicktime={}: {}", enabled, auth, is_quicktime, String::from_utf8_lossy(&body));
}
}
}
}
async fn enable_quicktime(harness: &TestHarness, user: &TestUser) {
set_user_feature_flags(SetUserFeatureFlagArgs {
subject_user_token: &user.user_token,
maybe_feature_flags: Some("use_qt"),
ip_address: "127.0.0.1",
maybe_mod_user_token: None,
mysql_pool: &harness.pool,
}).await.unwrap();
}
fn quicktime_fixture() -> Vec<u8> {
video_fixture("mov", "pcm_s16le")
}
fn video_fixture(extension: &str, audio_codec: &str) -> Vec<u8> {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(format!("reference.{extension}"));
let output = Command::new("ffmpeg").args([
"-hide_banner", "-loglevel", "error", "-nostdin",
"-f", "lavfi", "-i", "color=c=blue:s=160x90:r=24",
"-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000",
"-t", "1", "-c:v", "libx264", "-pix_fmt", "yuv420p",
"-c:a", audio_codec, "-ac", "2",
]).arg(&path).output().unwrap();
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
std::fs::read(path).unwrap()
}
fn multipart_body(idempotency_token: &str, bytes: &[u8]) -> Vec<u8> {
multipart_body_for_file(idempotency_token, bytes, "reference.mov", "video/quicktime")
}
fn multipart_body_for_file(idempotency_token: &str, bytes: &[u8], filename: &str, mimetype: &str) -> Vec<u8> {
let mut body = format!(
"--{BOUNDARY}\r\nContent-Disposition: form-data; name=\"uuid_idempotency_token\"\r\n\r\n{idempotency_token}\r\n\
--{BOUNDARY}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"{filename}\"\r\nContent-Type: {mimetype}\r\n\r\n"
).into_bytes();
body.extend_from_slice(bytes);
body.extend_from_slice(format!("\r\n--{BOUNDARY}--\r\n").as_bytes());
body
}
@@ -4,12 +4,12 @@
//!
//! Rate cards (ceil-rounded once):
//! - Seedance 2.5: 480p 11.76954733 ¢/s, 720p 26.70781893 ¢/s,
//! 1080p 61.62732357 ¢/s; with video refs 480p 7.24279835 ¢/s,
//! 720p 15.84362140 ¢/s, 1080p 38.49728574 ¢/s over (output + input)
//! 1080p 45.85869386 ¢/s; with video refs 480p 7.24279835 ¢/s,
//! 720p 15.84362140 ¢/s, 1080p 27.39973680 ¢/s over (output + input)
//! seconds
//! - Seedance 2.5 Ultra: 480p 13.90946502 ¢/s, 720p 31.56378601 ¢/s,
//! 1080p 67.22980753 ¢/s; with video refs 480p 8.55967078 ¢/s,
//! 720p 18.72427984 ¢/s, 1080p 41.84487580 ¢/s
//! 1080p 50.10486922 ¢/s; with video refs 480p 8.55967078 ¢/s,
//! 720p 18.72427984 ¢/s, 1080p 29.93674947 ¢/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
@@ -90,60 +90,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(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)),
(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)),
];
for (resolution, seconds, batch, expected) in cases {
@@ -182,26 +182,26 @@ mod seedance_2p5 {
(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(309)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(2), ExpectedCredits(309)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(3), ExpectedCredits(309)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(4), ExpectedCredits(309)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(5), ExpectedCredits(309)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(6), ExpectedCredits(309)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(7), ExpectedCredits(309)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(8), ExpectedCredits(309)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(9), ExpectedCredits(309)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(10), ExpectedCredits(309)),
(Some(CommonResolution::FourK), Seconds(5), Batch(1), ExpectedCredits(309)),
(Some(CommonResolution::FourK), Seconds(5), Batch(2), ExpectedCredits(309)),
(Some(CommonResolution::FourK), Seconds(5), Batch(3), ExpectedCredits(309)),
(Some(CommonResolution::FourK), Seconds(5), Batch(4), ExpectedCredits(309)),
(Some(CommonResolution::FourK), Seconds(5), Batch(5), ExpectedCredits(309)),
(Some(CommonResolution::FourK), Seconds(5), Batch(6), ExpectedCredits(309)),
(Some(CommonResolution::FourK), Seconds(5), Batch(7), ExpectedCredits(309)),
(Some(CommonResolution::FourK), Seconds(5), Batch(8), ExpectedCredits(309)),
(Some(CommonResolution::FourK), Seconds(5), Batch(9), ExpectedCredits(309)),
(Some(CommonResolution::FourK), Seconds(5), Batch(10), ExpectedCredits(309)),
(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)),
];
for (resolution, seconds, batch, expected) in cases {
@@ -230,18 +230,18 @@ mod seedance_2p5 {
(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(370)),
(Some(CommonResolution::TenEightyP), Seconds(7), Batch(3), ExpectedCredits(432)),
(Some(CommonResolution::TenEightyP), Seconds(9), Batch(4), ExpectedCredits(555)),
(Some(CommonResolution::TenEightyP), Seconds(11), Batch(5), ExpectedCredits(678)),
(Some(CommonResolution::TenEightyP), Seconds(13), Batch(8), ExpectedCredits(802)),
(Some(CommonResolution::TenEightyP), Seconds(15), Batch(10), ExpectedCredits(925)),
(Some(CommonResolution::FourK), Seconds(6), Batch(2), ExpectedCredits(370)),
(Some(CommonResolution::FourK), Seconds(7), Batch(3), ExpectedCredits(432)),
(Some(CommonResolution::FourK), Seconds(9), Batch(4), ExpectedCredits(555)),
(Some(CommonResolution::FourK), Seconds(11), Batch(5), ExpectedCredits(678)),
(Some(CommonResolution::FourK), Seconds(13), Batch(8), ExpectedCredits(802)),
(Some(CommonResolution::FourK), Seconds(15), Batch(10), ExpectedCredits(925)),
(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)),
];
for (resolution, seconds, batch, expected) in cases {
@@ -313,60 +313,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(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)),
(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)),
];
for (resolution, seconds, batch, expected) in cases {
@@ -405,26 +405,26 @@ mod seedance_2p5 {
(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(1348)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(2), ExpectedCredits(1348)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(3), ExpectedCredits(1348)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(4), ExpectedCredits(1348)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(5), ExpectedCredits(1348)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(6), ExpectedCredits(1348)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(7), ExpectedCredits(1348)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(8), ExpectedCredits(1348)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(9), ExpectedCredits(1348)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(10), ExpectedCredits(1348)),
(Some(CommonResolution::FourK), Seconds(5), Batch(1), ExpectedCredits(1348)),
(Some(CommonResolution::FourK), Seconds(5), Batch(2), ExpectedCredits(1348)),
(Some(CommonResolution::FourK), Seconds(5), Batch(3), ExpectedCredits(1348)),
(Some(CommonResolution::FourK), Seconds(5), Batch(4), ExpectedCredits(1348)),
(Some(CommonResolution::FourK), Seconds(5), Batch(5), ExpectedCredits(1348)),
(Some(CommonResolution::FourK), Seconds(5), Batch(6), ExpectedCredits(1348)),
(Some(CommonResolution::FourK), Seconds(5), Batch(7), ExpectedCredits(1348)),
(Some(CommonResolution::FourK), Seconds(5), Batch(8), ExpectedCredits(1348)),
(Some(CommonResolution::FourK), Seconds(5), Batch(9), ExpectedCredits(1348)),
(Some(CommonResolution::FourK), Seconds(5), Batch(10), ExpectedCredits(1348)),
(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)),
];
for (resolution, seconds, batch, expected) in cases {
@@ -454,18 +454,18 @@ mod seedance_2p5 {
(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(1386)),
(Some(CommonResolution::TenEightyP), Seconds(7), Batch(3), ExpectedCredits(1425)),
(Some(CommonResolution::TenEightyP), Seconds(9), Batch(4), ExpectedCredits(1502)),
(Some(CommonResolution::TenEightyP), Seconds(11), Batch(5), ExpectedCredits(1579)),
(Some(CommonResolution::TenEightyP), Seconds(13), Batch(8), ExpectedCredits(1656)),
(Some(CommonResolution::TenEightyP), Seconds(15), Batch(10), ExpectedCredits(1733)),
(Some(CommonResolution::FourK), Seconds(6), Batch(2), ExpectedCredits(1386)),
(Some(CommonResolution::FourK), Seconds(7), Batch(3), ExpectedCredits(1425)),
(Some(CommonResolution::FourK), Seconds(9), Batch(4), ExpectedCredits(1502)),
(Some(CommonResolution::FourK), Seconds(11), Batch(5), ExpectedCredits(1579)),
(Some(CommonResolution::FourK), Seconds(13), Batch(8), ExpectedCredits(1656)),
(Some(CommonResolution::FourK), Seconds(15), Batch(10), ExpectedCredits(1733)),
(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)),
];
for (resolution, seconds, batch, expected) in cases {
@@ -546,60 +546,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(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)),
(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)),
];
for (resolution, seconds, batch, expected) in cases {
@@ -638,26 +638,26 @@ mod seedance_2p5_u {
(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(337)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(2), ExpectedCredits(337)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(3), ExpectedCredits(337)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(4), ExpectedCredits(337)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(5), ExpectedCredits(337)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(6), ExpectedCredits(337)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(7), ExpectedCredits(337)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(8), ExpectedCredits(337)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(9), ExpectedCredits(337)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(10), ExpectedCredits(337)),
(Some(CommonResolution::FourK), Seconds(5), Batch(1), ExpectedCredits(337)),
(Some(CommonResolution::FourK), Seconds(5), Batch(2), ExpectedCredits(337)),
(Some(CommonResolution::FourK), Seconds(5), Batch(3), ExpectedCredits(337)),
(Some(CommonResolution::FourK), Seconds(5), Batch(4), ExpectedCredits(337)),
(Some(CommonResolution::FourK), Seconds(5), Batch(5), ExpectedCredits(337)),
(Some(CommonResolution::FourK), Seconds(5), Batch(6), ExpectedCredits(337)),
(Some(CommonResolution::FourK), Seconds(5), Batch(7), ExpectedCredits(337)),
(Some(CommonResolution::FourK), Seconds(5), Batch(8), ExpectedCredits(337)),
(Some(CommonResolution::FourK), Seconds(5), Batch(9), ExpectedCredits(337)),
(Some(CommonResolution::FourK), Seconds(5), Batch(10), ExpectedCredits(337)),
(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)),
];
for (resolution, seconds, batch, expected) in cases {
@@ -686,18 +686,18 @@ mod seedance_2p5_u {
(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(404)),
(Some(CommonResolution::TenEightyP), Seconds(7), Batch(3), ExpectedCredits(471)),
(Some(CommonResolution::TenEightyP), Seconds(9), Batch(4), ExpectedCredits(606)),
(Some(CommonResolution::TenEightyP), Seconds(11), Batch(5), ExpectedCredits(740)),
(Some(CommonResolution::TenEightyP), Seconds(13), Batch(8), ExpectedCredits(874)),
(Some(CommonResolution::TenEightyP), Seconds(15), Batch(10), ExpectedCredits(1009)),
(Some(CommonResolution::FourK), Seconds(6), Batch(2), ExpectedCredits(404)),
(Some(CommonResolution::FourK), Seconds(7), Batch(3), ExpectedCredits(471)),
(Some(CommonResolution::FourK), Seconds(9), Batch(4), ExpectedCredits(606)),
(Some(CommonResolution::FourK), Seconds(11), Batch(5), ExpectedCredits(740)),
(Some(CommonResolution::FourK), Seconds(13), Batch(8), ExpectedCredits(874)),
(Some(CommonResolution::FourK), Seconds(15), Batch(10), ExpectedCredits(1009)),
(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)),
];
for (resolution, seconds, batch, expected) in cases {
@@ -769,60 +769,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(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)),
(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)),
];
for (resolution, seconds, batch, expected) in cases {
@@ -861,26 +861,26 @@ mod seedance_2p5_u {
(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(1465)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(2), ExpectedCredits(1465)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(3), ExpectedCredits(1465)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(4), ExpectedCredits(1465)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(5), ExpectedCredits(1465)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(6), ExpectedCredits(1465)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(7), ExpectedCredits(1465)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(8), ExpectedCredits(1465)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(9), ExpectedCredits(1465)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(10), ExpectedCredits(1465)),
(Some(CommonResolution::FourK), Seconds(5), Batch(1), ExpectedCredits(1465)),
(Some(CommonResolution::FourK), Seconds(5), Batch(2), ExpectedCredits(1465)),
(Some(CommonResolution::FourK), Seconds(5), Batch(3), ExpectedCredits(1465)),
(Some(CommonResolution::FourK), Seconds(5), Batch(4), ExpectedCredits(1465)),
(Some(CommonResolution::FourK), Seconds(5), Batch(5), ExpectedCredits(1465)),
(Some(CommonResolution::FourK), Seconds(5), Batch(6), ExpectedCredits(1465)),
(Some(CommonResolution::FourK), Seconds(5), Batch(7), ExpectedCredits(1465)),
(Some(CommonResolution::FourK), Seconds(5), Batch(8), ExpectedCredits(1465)),
(Some(CommonResolution::FourK), Seconds(5), Batch(9), ExpectedCredits(1465)),
(Some(CommonResolution::FourK), Seconds(5), Batch(10), ExpectedCredits(1465)),
(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)),
];
for (resolution, seconds, batch, expected) in cases {
@@ -910,18 +910,18 @@ mod seedance_2p5_u {
(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(1507)),
(Some(CommonResolution::TenEightyP), Seconds(7), Batch(3), ExpectedCredits(1549)),
(Some(CommonResolution::TenEightyP), Seconds(9), Batch(4), ExpectedCredits(1632)),
(Some(CommonResolution::TenEightyP), Seconds(11), Batch(5), ExpectedCredits(1716)),
(Some(CommonResolution::TenEightyP), Seconds(13), Batch(8), ExpectedCredits(1800)),
(Some(CommonResolution::TenEightyP), Seconds(15), Batch(10), ExpectedCredits(1884)),
(Some(CommonResolution::FourK), Seconds(6), Batch(2), ExpectedCredits(1507)),
(Some(CommonResolution::FourK), Seconds(7), Batch(3), ExpectedCredits(1549)),
(Some(CommonResolution::FourK), Seconds(9), Batch(4), ExpectedCredits(1632)),
(Some(CommonResolution::FourK), Seconds(11), Batch(5), ExpectedCredits(1716)),
(Some(CommonResolution::FourK), Seconds(13), Batch(8), ExpectedCredits(1800)),
(Some(CommonResolution::FourK), Seconds(15), Batch(10), ExpectedCredits(1884)),
(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)),
];
for (resolution, seconds, batch, expected) in cases {
@@ -1059,7 +1059,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 × 38.49728574 = 1424.40 → 1425 (1080p)
// 6.5s → 7 → 37 billed × 27.39973680 = 1013.79 → 1014 (1080p)
assert_real_input_videos_charge(
&harness, CommonVideoModel::Seedance2p5, Some(CommonResolution::FourEightyP),
Seconds(30), &[4_500], ExpectedCredits(254),
@@ -1078,7 +1078,7 @@ mod input_video_duration_billing {
).await;
assert_real_input_videos_charge(
&harness, CommonVideoModel::Seedance2p5, Some(CommonResolution::TenEightyP),
Seconds(30), &[6_500], ExpectedCredits(1425),
Seconds(30), &[6_500], ExpectedCredits(1014),
).await;
}
@@ -1122,12 +1122,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 41.84487580 ¢/s:
// / 1080p 29.93674947 ¢/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 × 41.84487580 = 1548.26 → 1549 (1080p)
// 6.5s → 7 → 37 billed × 29.93674947 = 1107.66 → 1108 (1080p)
assert_real_input_videos_charge(
&harness, CommonVideoModel::Seedance2p5Ultra, Some(CommonResolution::FourEightyP),
Seconds(30), &[2_500], ExpectedCredits(292),
@@ -1146,7 +1146,7 @@ mod input_video_duration_billing {
).await;
assert_real_input_videos_charge(
&harness, CommonVideoModel::Seedance2p5Ultra, Some(CommonResolution::TenEightyP),
Seconds(30), &[6_500], ExpectedCredits(1549),
Seconds(30), &[6_500], ExpectedCredits(1108),
).await;
}
}
@@ -1172,10 +1172,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(309), ExpectedCredits(337), CreditsDelta(28)),
(Some(CommonResolution::TenEightyP), Seconds(30), Batch(1), ExpectedCredits(1849), ExpectedCredits(2017), CreditsDelta(168)),
(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)),
// 4K downgrades to 1080p on both models, so the premium matches 1080p.
(Some(CommonResolution::FourK), Seconds(5), Batch(1), ExpectedCredits(309), ExpectedCredits(337), CreditsDelta(28)),
(Some(CommonResolution::FourK), Seconds(5), Batch(1), ExpectedCredits(230), ExpectedCredits(251), CreditsDelta(21)),
];
for (resolution, seconds, batch, base, variant, delta) in cases {
@@ -3,10 +3,10 @@
//! ever leave the process), and helpers for driving the generate handler
//! with dummy Actix HTTP requests as a fixture user.
use std::collections::{HashMap, HashSet};
use std::collections::HashSet;
use std::convert::TryFrom;
use std::marker::PhantomData;
use std::sync::{Arc, Mutex, OnceLock};
use std::sync::Arc;
use std::time::Duration;
use actix_web::cookie::Cookie;
@@ -15,7 +15,6 @@ use actix_web::web::{Data, Json};
use actix_web::HttpRequest;
use chrono::Utc;
use sqlx::MySqlPool;
use serde_json::Value;
use actix_artcraft::sessions::anonymous_visitor_tracking::avt_cookie_manager::AvtCookieManager;
use actix_artcraft::sessions::user_sessions::http_user_session_manager::HttpUserSessionManager;
@@ -324,7 +323,6 @@ pub fn base_generate_request(model: CommonVideoModel) -> OmniGenVideoCostAndGene
resolution: None,
aspect_ratio: None,
bitrate: None,
maybe_output_format: None,
quality: None,
duration_seconds: None,
video_batch_count: None,
@@ -350,29 +348,16 @@ fn uuid_v4_like() -> String {
/// BOTH the Kinovi host-override and media-CDN-override env vars exactly
/// once, before returning.
fn process_stub_kinovi_base_url() -> &'static String {
use std::sync::OnceLock;
static STUB: OnceLock<String> = OnceLock::new();
STUB.get_or_init(|| {
let base_url = spawn_stub_server();
std::env::set_var(ENV_KINOVI_CUSTOM_API_HOST, &base_url);
std::env::set_var(ENV_KINOVI_CUSTOM_CDN_HOST, &base_url);
std::env::set_var("THUMBNAIL_GENERATOR_API_URL", format!("{base_url}/thumbnails"));
std::env::set_var("THUMBNAIL_GENERATOR_API_BASIC_AUTH", "");
base_url
})
}
/// Captured local stub traffic for assertions about provider payloads and exact upload bytes.
#[derive(Default)]
pub struct StubTraffic {
pub uploads: HashMap<String, Vec<u8>>,
pub workflows: Vec<Value>,
}
pub fn stub_traffic() -> &'static Mutex<StubTraffic> {
static TRAFFIC: OnceLock<Mutex<StubTraffic>> = OnceLock::new();
TRAFFIC.get_or_init(Default::default)
}
/// A stub for BOTH the Kinovi API and the media CDN. Runs on plain OS
/// threads so it is independent of any tokio runtime and serves connections
/// until the test process exits.
@@ -419,15 +404,8 @@ fn spawn_stub_server() -> String {
match stream.read(&mut buffer[read_total..]) {
Ok(n) if n > 0 => {
read_total += n;
if let Some(header_end) = buffer[..read_total].windows(4).position(|w| w == b"\r\n\r\n") {
let headers = String::from_utf8_lossy(&buffer[..header_end]);
let content_length = headers.lines().find_map(|line| {
let (name, value) = line.split_once(':')?;
name.eq_ignore_ascii_case("content-length").then(|| value.trim().parse::<usize>().ok()).flatten()
}).unwrap_or(0);
if read_total >= header_end + 4 + content_length {
break;
}
if buffer[..read_total].windows(4).any(|w| w == b"\r\n\r\n") && read_total > 4 {
break;
}
}
_ => break,
@@ -435,19 +413,9 @@ fn spawn_stub_server() -> String {
}
let head = String::from_utf8_lossy(&buffer[..read_total]).to_string();
let request_line = head.lines().next().unwrap_or_default().to_string();
let body_start = buffer[..read_total].windows(4).position(|w| w == b"\r\n\r\n")
.map(|index| index + 4).unwrap_or(read_total);
let request_body = &buffer[body_start..read_total];
let filename = request_line.split_whitespace().nth(1).unwrap_or("")
.split('?').next().unwrap_or("").rsplit('/').next().unwrap_or("").to_string();
let maybe_uploaded = stub_traffic().lock().unwrap().uploads.get(&filename).cloned();
let (status, content_type, body): (&str, &str, Vec<u8>) =
if request_line.starts_with("GET") && maybe_uploaded.is_some() {
let mime = if filename.ends_with(".mov") { "video/quicktime" } else { "video/mp4" };
("200 OK", mime, maybe_uploaded.unwrap())
} else if request_line.starts_with("GET") {
if request_line.starts_with("GET") {
match extract_fixture_duration_millis(&request_line) {
Some(millis) => ("200 OK", "video/mp4", fixture_video_bytes(millis)),
// URL-ingested uploads land under the `video_` bucket prefix
@@ -460,17 +428,12 @@ fn spawn_stub_server() -> String {
None => ("404 Not Found", "text/plain", b"not found".to_vec()),
}
} else if request_line.starts_with("PUT") {
stub_traffic().lock().unwrap().uploads.insert(filename, request_body.to_vec());
("200 OK", "text/plain", Vec::new())
} else if request_line.contains("uploads.signedUploadUrl") {
let extension = if head.contains("mov") { "mov" } else { "mp4" };
let upload_url = format!("{base_url}/upload-target/materials/test/fixture.{extension}");
let upload_url = format!("{base_url}/upload-target/materials/test/fixture.mp4");
let json = format!(r#"[{{"result":{{"data":{{"json":"{upload_url}"}}}}}}]"#);
("200 OK", "application/json", json.into_bytes())
} else {
if let Ok(payload) = serde_json::from_slice(request_body) {
stub_traffic().lock().unwrap().workflows.push(payload);
}
let order_number = ORDER_COUNTER.fetch_add(1, Ordering::Relaxed);
let json = format!(
r#"[{{"result":{{"data":{{"json":{{"taskId":"task_test_{process_prefix:016x}_{order_number:04}","orderId":"ord_test_{process_prefix:016x}_{order_number:04}","violationWarning":false}}}}}}}}]"#,
@@ -582,6 +545,7 @@ fn build_test_server_state(pool: MySqlPool, media_cdn_override_url: String) -> S
auto_gc_bucket_client: LegacyBucketClient,
redis_rate_limiters: crate::state::server_state::RedisRateLimiters,
}
use std::sync::OnceLock;
static DUMMIES: OnceLock<ExpensiveDummies> = OnceLock::new();
let dummies = DUMMIES.get_or_init(|| retry_keychain_flake(|| {
// Bucket uploads (e.g. URL-input ingestion) PUT to the stub, which
@@ -1024,7 +988,6 @@ pub fn to_omni_api_request(request: OmniGenVideoCostAndGenerateRequest) -> OmniA
resolution: request.resolution,
aspect_ratio: request.aspect_ratio,
bitrate: request.bitrate,
maybe_output_format: request.maybe_output_format,
quality: request.quality,
duration_seconds: request.duration_seconds,
video_batch_count: request.video_batch_count,
@@ -42,15 +42,6 @@ pub async fn omni_gen_video_models_handler(
remove_models(&mut response, &MINIMAX_GATED_MODELS);
}
if !can_use_quicktime(&http_request, &server_state).await? {
for model in &mut response.models {
if matches!(model.model, CommonVideoModel::Seedance2p5 | CommonVideoModel::Seedance2p5Ultra) {
model.output_format_options = None;
model.output_format_default = None;
}
}
}
Ok(Json(response))
}
@@ -86,34 +77,6 @@ pub async fn can_see_minimax(
Ok(can_use_minimax || can_use_minimax_priority)
}
/// Whether the (optional) logged-in user holds the QuickTime feature flag.
/// Anonymous requests do not expose the output-format options.
pub async fn can_use_quicktime(
http_request: &HttpRequest,
server_state: &ServerState,
) -> Result<bool, CommonWebError> {
let mut mysql_connection = server_state.mysql_pool.acquire().await?;
let maybe_user_session = server_state
.session_checker
.maybe_get_user_session_from_connection(&http_request, &mut mysql_connection)
.await
.map_err(|e| {
warn!("Session checker error: {:?}", e);
CommonWebError::from(e)
})?;
let user_session = match maybe_user_session {
Some(session) => session,
None => return Ok(false),
};
let user_feature_flags =
UserSessionFeatureFlags::new(user_session.maybe_feature_flags.as_deref());
Ok(user_feature_flags.can_use_quicktime())
}
/// Strip the given models from both the top-level model list and every
/// provider's model list.
fn remove_models(response: &mut OmniGenVideoModelsResponse, hidden_models: &[CommonVideoModel]) {
@@ -77,7 +77,6 @@ mod tests {
resolution: None,
aspect_ratio: None,
bitrate: None,
maybe_output_format: None,
quality: None,
duration_seconds: Some(5),
video_batch_count: Some(1),
@@ -57,7 +57,6 @@ mod tests {
resolution: None,
aspect_ratio: None,
bitrate: None,
maybe_output_format: None,
quality: None,
duration_seconds: Some(5),
video_batch_count: Some(1),
@@ -109,10 +109,6 @@ impl UserSessionFeatureFlags {
pub fn can_use_minimax_priority(&self) -> bool {
self.feature_flags.contains(&UserFeatureFlag::MinimaxPriority)
}
pub fn can_use_quicktime(&self) -> bool {
self.feature_flags.contains(&UserFeatureFlag::CanUseQuicktime)
}
}
#[cfg(test)]
+1 -9
View File
@@ -35,19 +35,11 @@ environment, install dependencies, and free the dev port first):
./script/website/unix_frontend_webapp_dev.sh # artcraft-webapp (user dashboard), port 4201
```
Run the webapp from **artcraft-services**, not the separate `artcraft` desktop
checkout. The webapp launcher also works from another directory when invoked
by its full or relative path:
```bash
../artcraft-services/script/website/unix_frontend_webapp_dev.sh
```
Or directly from this directory:
```bash
nx dev artcraft-website
npx nx dev @frontend/artcraft-webapp
nx dev artcraft-webapp
```
## Building
+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/svg+xml" href="/artcraft-icon.svg" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="stylesheet" href="/src/styles.css" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
@@ -1,13 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 483 B

@@ -1,9 +0,0 @@
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, Navigate, Link } from "react-router-dom";
import { LoaderCircleIcon } from "lucide-react";
import { Outlet, useNavigate, Navigate } from "react-router-dom";
import { ArrowLeftIcon, LoaderCircleIcon } from "lucide-react";
import { TruchetPattern } from "@storyteller/ui-vfx";
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,21 +18,52 @@ 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 (
<AuthPageFrame wide>
<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]">
{/* 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">
<div className={`flex flex-1 flex-col justify-center ${AUTH_FORM_PADDING}`}>
<div className="w-full">
<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">
{authChecked ? (
<Outlet />
) : (
@@ -45,39 +76,44 @@ 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 border-l border-white/15 lg:w-1/2">
<div className="relative lg:w-1/2">
<AuthShowcase />
</div>
)}
</AuthPageFrame>
</div>
</div>
);
};
interface AuthHeaderProps {
title: ReactNode;
title: string;
subtitle: string;
}
export const AuthHeader = ({ title, subtitle }: AuthHeaderProps) => (
<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">
<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">
{title}
</h1>
<p className="text-sm leading-relaxed text-white/60">
</Reveal>
<Reveal as="p" y={10} className="text-sm text-white/60">
{subtitle}
</p>
</div>
</Reveal>
</RevealGroup>
);
export const AuthFooter = ({ children }: { children: ReactNode }) => (
@@ -1,22 +0,0 @@
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-0 overflow-hidden bg-ui-controls">
<div className="absolute inset-2 overflow-hidden bg-black">
{/* Muted background video (no controls) cropped to cover the pane */}
<video
src={FEATURED_VIDEO_URL}
@@ -1,4 +1,3 @@
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";
@@ -15,6 +14,7 @@ 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={className}>
<div className={`space-y-4 ${className}`}>
<form
className="space-y-4"
onSubmit={(e) => {
@@ -111,11 +111,19 @@ export const SignupForm = ({
{error}
</div>
)}
<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}
className="space-y-4"
>
<div className="space-y-2">
<label className={AUTH_LABEL_CLASSES}>
<Reveal className="space-y-2">
<label className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/70">
Email
</label>
<Input
@@ -124,13 +132,13 @@ export const SignupForm = ({
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
autoFocus={autoFocus}
inputClassName={AUTH_INPUT_CLASSES}
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"
/>
</div>
</Reveal>
<div className="space-y-2">
<Reveal className="space-y-2">
<div className="flex justify-between items-center">
<label className={AUTH_LABEL_CLASSES}>
<label className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/70">
Password
</label>
</div>
@@ -140,7 +148,7 @@ export const SignupForm = ({
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Min. 8 characters"
inputClassName={AUTH_PASSWORD_INPUT_CLASSES}
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"
/>
<button
type="button"
@@ -151,9 +159,9 @@ export const SignupForm = ({
<DynamicIcon icon={showPassword ? EyeOffIcon : EyeIcon} />
</button>
</div>
</div>
</Reveal>
<div className="pt-2">
<Reveal className="pt-2">
<Button
className="w-full justify-center h-10"
type="submit"
@@ -165,25 +173,36 @@ export const SignupForm = ({
"Create account"
)}
</Button>
</div>
</div>
</Reveal>
</RevealGroup>
</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">
{/* 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">
or
</span>
</div>
</Reveal>
<div>
<Reveal>
<GoogleLoginButton
mode="signup"
onSuccess={handleGoogleSuccess}
onError={handleGoogleError}
/>
</div>
</div>
</Reveal>
</RevealGroup>
</div>
);
};
@@ -1,25 +0,0 @@
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,8 +1,9 @@
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 {
@@ -19,7 +20,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;
@@ -40,6 +41,8 @@ interface CreateMediaPageShellProps {
// Model selector
modelItems: PopoverItem[];
onModelChange: (item: PopoverItem) => void;
// Glow orb overrides (optional - defaults provided)
glowOrbs?: ReactNode;
// Children slots
gridContent: ReactNode;
promptBox: ReactNode;
@@ -66,6 +69,7 @@ export function CreateMediaPageShell({
emptyStateSubtitle,
emptyStateCta,
bottomOffset,
glowOrbs,
gridContent,
promptBox,
promptForm,
@@ -99,9 +103,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 flex-wrap items-center gap-y-2 border-b border-ui-panel-border bg-ui-panel px-3 py-2">
<div className="flex items-center border-b border-ui-panel-border px-3 py-2">
<div className="flex-1" />
<TabSelector
tabs={MOBILE_TABS}
@@ -128,8 +132,13 @@ export function CreateMediaPageShell({
<div className="px-3">{gridContent}</div>
</div>
) : (
<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 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>
)}
</div>
@@ -141,16 +150,51 @@ export function CreateMediaPageShell({
}
return (
<div className="relative isolate flex h-full w-full bg-ui-background text-white">
<div className="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 px-6" style={{ paddingBottom: bottomOffset }}>
<CreateEmptyState title={emptyStateTitle} subtitle={emptyStateSubtitle}>
{emptyStateCta}
</CreateEmptyState>
<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>
)}
@@ -45,11 +45,11 @@ import {
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
return (
<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">
<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">
{label}
</span>
<span className="min-w-0 text-right text-sm text-white font-medium flex items-center justify-end gap-2">
<span className="text-sm text-white font-medium flex items-center gap-2">
{value}
</span>
</div>
@@ -426,14 +426,7 @@ export function LightboxDetails({
{createdAt && (
<InfoRow
label="Created"
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>
}
value={dayjs(createdAt).format("MMMM D, YYYY h:mm:ss A")}
/>
)}
</div>
@@ -357,7 +357,7 @@ export function Lightbox({
<Modal
isOpen={isOpen}
onClose={onClose}
className="h-[90dvh] sm:h-[92dvh] w-full sm:w-[1440px] max-w-[96vw] max-h-[92dvh] p-0 border-white/15"
className="h-[90vh] sm:h-[680px] w-full sm:w-[1100px] max-w-[95vw] max-h-[90vh] p-0 border-white/15"
backdropClassName="!bg-black/80"
showClose={false}
>
Loaded 100 of 335 files, more files were not shown because too many files have changed in this diff. Show more