Compare commits

...
Author SHA1 Message Date
bflatastic d7e6b44663 fix: video promptbox layout bugs 2026-04-20 07:28:58 +07:00
bflatastic 6a2719c742 website download link update 2026-04-20 06:57:24 +07:00
Brandon Thomas 38219a4abf link website omni video jobs to ledger entries for refunds (#1402) 2026-04-19 15:03:20 -04:00
Brandon Thomas 3fc002d793 quick refunds hack for kinovi (#1401)
code is actually solid, but the old omni pipeline itself is a hack on
top of an incomplete artcraft_router, so this will be replaced when
we're done.
2026-04-19 13:51:57 -04:00
Brandon Thomas c0c17b27b5 1080p support (quick) (#1400) 2026-04-18 18:15:35 -04:00
Brandon Thomas b68b8f7d71 simplify artcraft_router (#1399) 2026-04-18 08:40:49 -04:00
Brandon Thomas 3d9b4be7ab rename field (#1398) 2026-04-18 08:10:36 -04:00
Brandon Thomas 489390c881 refacor kinovi generate_video request (#1397) 2026-04-18 07:48:52 -04:00
Brandon Thomas f5338611ac seedance 2.0 resolution options (#1396) 2026-04-17 20:51:17 -04:00
Brandon Thomas 11fc5746ec seedance 1080p (#1395) 2026-04-17 20:31:01 -04:00
Tanish Manku 47383daba8 Experimental: Storyboard page (#1394) 2026-04-17 15:32:04 -07:00
Tanish Manku 9166985418 Experimental Menu: Hidden experimental settings menu via 7x version click (#1393) 2026-04-17 14:17:34 -07:00
BFlat 8fc488551f website: bug fixes (#1391)
## Website bug fixes: Seedance Fast + Characters + Toasts

### Create Video page
- Switching between Seedance and Seedance Fast no longer loses your
attached references
- Seedance Fast now supports Characters (was missing from the UI)
- Characters work in both Keyframe and Reference modes
- Fixed a bug where typing `@Bob2` could accidentally attach an older
character named `@Bob`

### Task queue notifications
- No more ghost "job complete" toasts every time you reload the site
- Cleaner wording: "Image creation complete" instead of "Image complete
— Image Generation"
- Character jobs now say "Character" instead of the generic "Task"

### Toast redesign
- Matches the prompt box style instead of the old bright green
- Success = green circle with a white checkmark, error = red circle with
a white exclamation
- Centered on mobile, stays top-right on desktop
- Message no longer awkwardly wraps onto multiple lines on mobile
2026-04-17 15:39:20 +07:00
Brandon Thomas 3a9cebc1b8 omni character support (#1390) 2026-04-17 03:48:35 -04:00
48 changed files with 3989 additions and 870 deletions
@@ -37,6 +37,9 @@ pub struct Seedance2p0MultiFunctionVideoGenRequest {
/// Video aspect ratio / resolution.
pub aspect_ratio: Option<Seedance2p0AspectRatio>,
/// Output resolution quality tier (480p, 720p, 1080p).
pub output_resolution: Option<Seedance2p0OutputResolution>,
/// Duration in seconds (4–15).
pub duration_seconds: Option<u8>,
@@ -67,6 +70,17 @@ pub enum Seedance2p0BatchCount {
Four,
}
#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum Seedance2p0OutputResolution {
/// 480p
FourEightyP,
/// 720p (default)
SevenTwentyP,
/// 1080p
TenEightyP,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct Seedance2p0MultiFunctionVideoGenResponse {
pub success: bool,
@@ -1,7 +1,7 @@
use artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::Seedance2p0BatchCount;
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
use seedance2pro_client::requests::generate_video::generate_video::{KinoviBatchCount, GenerateVideoArgs, KinoviModelType, KinoviResolution};
use seedance2pro_client::requests::generate_video::generate_video::{GenerateVideoRequest, KinoviBatchCount, KinoviModelType, KinoviOutputResolution, KinoviAspectRatio};
use crate::api::common_resolution::CommonResolution;
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_seedance2p0::PlanArtcraftSeedance2p0;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
@@ -16,30 +16,29 @@ pub (crate) fn estimate_video_cost_artcraft_seedance2p0(
Seedance2p0BatchCount::Four => KinoviBatchCount::Four,
};
// TODO: Make a better client that doesn't require this.
// A dummy session is sufficient — cost estimation does not make any network calls.
let dummy_session = Seedance2ProSession::from_cookies_string(String::new());
let output_resolution = plan.resolution.map(map_common_resolution_to_kinovi);
let args = GenerateVideoArgs {
session: &dummy_session,
let request = GenerateVideoRequest {
model_type: KinoviModelType::Seedance2Pro,
prompt: String::new(),
resolution: KinoviResolution::Square1x1,
aspect_ratio: KinoviAspectRatio::Square1x1,
duration_seconds,
batch_count,
output_resolution,
// TODO: This is a cost factor
reference_video_urls: None,
// NB: These do not contribute to costs in the Seedance2 integration
start_frame_url: None,
end_frame_url: None,
reference_image_urls: None,
reference_video_urls: None,
reference_audio_urls: None,
character_ids: None,
use_face_blur_hack: None,
host_override: None,
};
let cost_in_usd_cents = args.estimate_cost_in_usd_cents();
let cost_in_usd_cents = request.estimate_cost_in_usd_cents();
VideoGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
@@ -51,14 +50,29 @@ pub (crate) fn estimate_video_cost_artcraft_seedance2p0(
}
}
fn map_common_resolution_to_kinovi(resolution: CommonResolution) -> KinoviOutputResolution {
match resolution {
CommonResolution::FourEightyP => KinoviOutputResolution::FourEightyP,
CommonResolution::SevenTwentyP => KinoviOutputResolution::SevenTwentyP,
CommonResolution::TenEightyP => KinoviOutputResolution::TenEightyP,
// For resolutions that don't map directly, default to 720p
_ => KinoviOutputResolution::SevenTwentyP,
}
}
#[cfg(test)]
mod tests {
use crate::api::common_resolution::CommonResolution;
use crate::api::common_video_model::CommonVideoModel;
use crate::api::provider::Provider;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
fn estimate_usd_cents(duration_seconds: u16, video_batch_count: u16) -> u64 {
fn estimate_usd_cents(
duration_seconds: u16,
video_batch_count: u16,
resolution: Option<CommonResolution>,
) -> u64 {
let request = GenerateVideoRequest {
model: CommonVideoModel::Seedance2p0,
provider: Provider::Artcraft,
@@ -70,7 +84,7 @@ mod tests {
reference_videos: None,
reference_audio: None,
reference_character_tokens: None,
resolution: None,
resolution,
aspect_ratio: None,
duration_seconds: Some(duration_seconds),
video_batch_count: Some(video_batch_count),
@@ -85,23 +99,105 @@ mod tests {
.expect("cost_in_usd_cents should be present")
}
// -- 720p (default / legacy pricing: 250 credits/$1) --
#[test]
fn test_estimate_cost_usd_cents() {
// Batch 1:
assert_eq!(estimate_usd_cents(4, 1), 64);
assert_eq!(estimate_usd_cents(5, 1), 80);
assert_eq!(estimate_usd_cents(6, 1), 96);
assert_eq!(estimate_usd_cents(7, 1), 112);
assert_eq!(estimate_usd_cents(15, 1), 240);
fn pro_720p_batch_1() {
assert_eq!(estimate_usd_cents(4, 1, Some(CommonResolution::SevenTwentyP)), 64);
assert_eq!(estimate_usd_cents(5, 1, Some(CommonResolution::SevenTwentyP)), 80);
assert_eq!(estimate_usd_cents(6, 1, Some(CommonResolution::SevenTwentyP)), 96);
assert_eq!(estimate_usd_cents(7, 1, Some(CommonResolution::SevenTwentyP)), 112);
assert_eq!(estimate_usd_cents(15, 1, Some(CommonResolution::SevenTwentyP)), 240);
}
// Batch 2 = 2×
assert_eq!(estimate_usd_cents(4, 2), 128);
assert_eq!(estimate_usd_cents(5, 2), 160);
assert_eq!(estimate_usd_cents(15, 2), 480);
#[test]
fn pro_720p_batch_2() {
assert_eq!(estimate_usd_cents(4, 2, Some(CommonResolution::SevenTwentyP)), 128);
assert_eq!(estimate_usd_cents(5, 2, Some(CommonResolution::SevenTwentyP)), 160);
assert_eq!(estimate_usd_cents(15, 2, Some(CommonResolution::SevenTwentyP)), 480);
}
// Batch 4 = 4×
assert_eq!(estimate_usd_cents(4, 4), 256);
assert_eq!(estimate_usd_cents(5, 4), 320);
assert_eq!(estimate_usd_cents(15, 4), 960);
#[test]
fn pro_720p_batch_4() {
assert_eq!(estimate_usd_cents(4, 4, Some(CommonResolution::SevenTwentyP)), 256);
assert_eq!(estimate_usd_cents(5, 4, Some(CommonResolution::SevenTwentyP)), 320);
assert_eq!(estimate_usd_cents(15, 4, Some(CommonResolution::SevenTwentyP)), 960);
}
#[test]
fn pro_none_resolution_same_as_720p() {
assert_eq!(
estimate_usd_cents(5, 1, None),
estimate_usd_cents(5, 1, Some(CommonResolution::SevenTwentyP)),
);
}
// -- 480p (new pricing: 193 credits/$1) --
#[test]
fn pro_480p_batch_1() {
assert_eq!(estimate_usd_cents(4, 1, Some(CommonResolution::FourEightyP)), 31);
assert_eq!(estimate_usd_cents(5, 1, Some(CommonResolution::FourEightyP)), 39);
assert_eq!(estimate_usd_cents(10, 1, Some(CommonResolution::FourEightyP)), 78);
assert_eq!(estimate_usd_cents(15, 1, Some(CommonResolution::FourEightyP)), 117);
}
#[test]
fn pro_480p_batch_2() {
assert_eq!(estimate_usd_cents(5, 2, Some(CommonResolution::FourEightyP)), 78);
}
#[test]
fn pro_480p_batch_4() {
assert_eq!(estimate_usd_cents(5, 4, Some(CommonResolution::FourEightyP)), 155);
}
// -- 1080p (new pricing: 193 credits/$1) --
#[test]
fn pro_1080p_batch_1() {
assert_eq!(estimate_usd_cents(4, 1, Some(CommonResolution::TenEightyP)), 187);
assert_eq!(estimate_usd_cents(5, 1, Some(CommonResolution::TenEightyP)), 233);
assert_eq!(estimate_usd_cents(10, 1, Some(CommonResolution::TenEightyP)), 466);
assert_eq!(estimate_usd_cents(15, 1, Some(CommonResolution::TenEightyP)), 699);
}
#[test]
fn pro_1080p_batch_2() {
assert_eq!(estimate_usd_cents(5, 2, Some(CommonResolution::TenEightyP)), 466);
}
#[test]
fn pro_1080p_batch_4() {
assert_eq!(estimate_usd_cents(5, 4, Some(CommonResolution::TenEightyP)), 933);
}
// -- Relative pricing --
#[test]
fn pro_480p_cheaper_than_720p_cheaper_than_1080p() {
let c480 = estimate_usd_cents(5, 1, Some(CommonResolution::FourEightyP));
let c720 = estimate_usd_cents(5, 1, Some(CommonResolution::SevenTwentyP));
let c1080 = estimate_usd_cents(5, 1, Some(CommonResolution::TenEightyP));
assert!(c480 < c720, "480p ({}) should be cheaper than 720p ({})", c480, c720);
assert!(c720 < c1080, "720p ({}) should be cheaper than 1080p ({})", c720, c1080);
}
#[test]
fn cost_scales_with_duration() {
let c4 = estimate_usd_cents(4, 1, Some(CommonResolution::SevenTwentyP));
let c10 = estimate_usd_cents(10, 1, Some(CommonResolution::SevenTwentyP));
let c15 = estimate_usd_cents(15, 1, Some(CommonResolution::SevenTwentyP));
assert!(c4 < c10);
assert!(c10 < c15);
}
#[test]
fn cost_scales_with_batch() {
let b1 = estimate_usd_cents(5, 1, Some(CommonResolution::TenEightyP));
let b2 = estimate_usd_cents(5, 2, Some(CommonResolution::TenEightyP));
let b4 = estimate_usd_cents(5, 4, Some(CommonResolution::TenEightyP));
assert!(b1 < b2);
assert!(b2 < b4);
}
}
@@ -1,7 +1,7 @@
use artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::Seedance2p0BatchCount;
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
use seedance2pro_client::requests::generate_video::generate_video::{KinoviBatchCount, GenerateVideoArgs, KinoviModelType, KinoviResolution};
use seedance2pro_client::requests::generate_video::generate_video::{GenerateVideoRequest, KinoviBatchCount, KinoviModelType, KinoviOutputResolution, KinoviAspectRatio};
use crate::api::common_resolution::CommonResolution;
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_seedance2p0::PlanArtcraftSeedance2p0;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
@@ -16,26 +16,29 @@ pub(crate) fn estimate_video_cost_artcraft_seedance2p0_fast(
Seedance2p0BatchCount::Four => KinoviBatchCount::Four,
};
let dummy_session = Seedance2ProSession::from_cookies_string(String::new());
let output_resolution = plan.resolution.map(map_common_resolution_to_kinovi);
let args = GenerateVideoArgs {
session: &dummy_session,
model_type: KinoviModelType::Seedance2Fast, // <-- Fast, not Pro
let request = GenerateVideoRequest {
model_type: KinoviModelType::Seedance2Fast,
prompt: String::new(),
resolution: KinoviResolution::Square1x1,
aspect_ratio: KinoviAspectRatio::Square1x1,
duration_seconds,
batch_count,
output_resolution,
// TODO: This is a cost factor
reference_video_urls: None,
// NB: These do not contribute to costs in the Seedance2 integration
start_frame_url: None,
end_frame_url: None,
reference_image_urls: None,
reference_video_urls: None,
reference_audio_urls: None,
character_ids: None,
use_face_blur_hack: None,
host_override: None,
};
let cost_in_usd_cents = args.estimate_cost_in_usd_cents();
let cost_in_usd_cents = request.estimate_cost_in_usd_cents();
VideoGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
@@ -47,14 +50,29 @@ pub(crate) fn estimate_video_cost_artcraft_seedance2p0_fast(
}
}
fn map_common_resolution_to_kinovi(resolution: CommonResolution) -> KinoviOutputResolution {
match resolution {
CommonResolution::FourEightyP => KinoviOutputResolution::FourEightyP,
CommonResolution::SevenTwentyP => KinoviOutputResolution::SevenTwentyP,
CommonResolution::TenEightyP => KinoviOutputResolution::TenEightyP,
// For resolutions that don't map directly, default to 720p
_ => KinoviOutputResolution::SevenTwentyP,
}
}
#[cfg(test)]
mod tests {
use crate::api::common_resolution::CommonResolution;
use crate::api::common_video_model::CommonVideoModel;
use crate::api::provider::Provider;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
fn estimate_usd_cents(duration_seconds: u16, video_batch_count: u16) -> u64 {
fn estimate_usd_cents(
duration_seconds: u16,
video_batch_count: u16,
resolution: Option<CommonResolution>,
) -> u64 {
let request = GenerateVideoRequest {
model: CommonVideoModel::Seedance2p0Fast,
provider: Provider::Artcraft,
@@ -66,7 +84,7 @@ mod tests {
reference_videos: None,
reference_audio: None,
reference_character_tokens: None,
resolution: None,
resolution,
aspect_ratio: None,
duration_seconds: Some(duration_seconds),
video_batch_count: Some(video_batch_count),
@@ -81,7 +99,42 @@ mod tests {
.expect("cost_in_usd_cents should be present")
}
fn estimate_pro_usd_cents(duration_seconds: u16, video_batch_count: u16) -> u64 {
fn estimate_usd_cents_downgrade(
duration_seconds: u16,
video_batch_count: u16,
resolution: Option<CommonResolution>,
) -> u64 {
let request = GenerateVideoRequest {
model: CommonVideoModel::Seedance2p0Fast,
provider: Provider::Artcraft,
prompt: None,
negative_prompt: None,
start_frame: None,
end_frame: None,
reference_images: None,
reference_videos: None,
reference_audio: None,
reference_character_tokens: None,
resolution,
aspect_ratio: None,
duration_seconds: Some(duration_seconds),
video_batch_count: Some(video_batch_count),
generate_audio: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayLessDowngrade,
idempotency_token: None,
};
request.build()
.expect("build should succeed")
.estimate_costs()
.cost_in_usd_cents
.expect("cost_in_usd_cents should be present")
}
fn estimate_pro_usd_cents(
duration_seconds: u16,
video_batch_count: u16,
resolution: Option<CommonResolution>,
) -> u64 {
let request = GenerateVideoRequest {
model: CommonVideoModel::Seedance2p0,
provider: Provider::Artcraft,
@@ -93,7 +146,7 @@ mod tests {
reference_videos: None,
reference_audio: None,
reference_character_tokens: None,
resolution: None,
resolution,
aspect_ratio: None,
duration_seconds: Some(duration_seconds),
video_batch_count: Some(video_batch_count),
@@ -108,56 +161,133 @@ mod tests {
.expect("cost_in_usd_cents should be present")
}
// -- 720p (default / legacy pricing: 220 credits/$1) --
#[test]
fn cost_is_present() {
assert!(estimate_usd_cents(5, 1) > 0);
fn fast_720p_batch_1() {
assert_eq!(estimate_usd_cents(4, 1, Some(CommonResolution::SevenTwentyP)), 51);
assert_eq!(estimate_usd_cents(5, 1, Some(CommonResolution::SevenTwentyP)), 64);
assert_eq!(estimate_usd_cents(10, 1, Some(CommonResolution::SevenTwentyP)), 127);
assert_eq!(estimate_usd_cents(15, 1, Some(CommonResolution::SevenTwentyP)), 191);
}
#[test]
fn cost_batch_1() {
assert_eq!(estimate_usd_cents(4, 1), 51);
assert_eq!(estimate_usd_cents(5, 1), 64);
assert_eq!(estimate_usd_cents(10, 1), 127);
assert_eq!(estimate_usd_cents(15, 1), 191);
fn fast_720p_batch_2() {
assert_eq!(estimate_usd_cents(5, 2, Some(CommonResolution::SevenTwentyP)), 127);
}
#[test]
fn cost_batch_2() {
assert_eq!(estimate_usd_cents(5, 2), 127);
fn fast_720p_batch_4() {
assert_eq!(estimate_usd_cents(5, 4, Some(CommonResolution::SevenTwentyP)), 255);
}
#[test]
fn cost_batch_4() {
assert_eq!(estimate_usd_cents(5, 4), 255);
fn fast_none_resolution_same_as_720p() {
assert_eq!(
estimate_usd_cents(5, 1, None),
estimate_usd_cents(5, 1, Some(CommonResolution::SevenTwentyP)),
);
}
// -- 480p (new pricing: 193 credits/$1) --
#[test]
fn fast_480p_batch_1() {
assert_eq!(estimate_usd_cents(4, 1, Some(CommonResolution::FourEightyP)), 21);
assert_eq!(estimate_usd_cents(5, 1, Some(CommonResolution::FourEightyP)), 26);
assert_eq!(estimate_usd_cents(10, 1, Some(CommonResolution::FourEightyP)), 52);
assert_eq!(estimate_usd_cents(15, 1, Some(CommonResolution::FourEightyP)), 78);
}
#[test]
fn fast_480p_batch_2() {
assert_eq!(estimate_usd_cents(5, 2, Some(CommonResolution::FourEightyP)), 52);
}
#[test]
fn fast_480p_batch_4() {
assert_eq!(estimate_usd_cents(5, 4, Some(CommonResolution::FourEightyP)), 104);
}
// -- 1080p is not supported by Fast; ErrorOut rejects it --
#[test]
fn fast_1080p_error_out_rejects() {
let request = GenerateVideoRequest {
model: CommonVideoModel::Seedance2p0Fast,
provider: Provider::Artcraft,
prompt: None,
negative_prompt: None,
start_frame: None,
end_frame: None,
reference_images: None,
reference_videos: None,
reference_audio: None,
reference_character_tokens: None,
resolution: Some(CommonResolution::TenEightyP),
aspect_ratio: None,
duration_seconds: Some(5),
video_batch_count: Some(1),
generate_audio: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
idempotency_token: None,
};
assert!(request.build().is_err());
}
// -- 1080p rounds down to 720p with PayLessDowngrade --
#[test]
fn fast_1080p_downgraded_same_as_720p() {
for duration in [4, 5, 10, 15] {
assert_eq!(
estimate_usd_cents_downgrade(duration, 1, Some(CommonResolution::TenEightyP)),
estimate_usd_cents(duration, 1, Some(CommonResolution::SevenTwentyP)),
);
}
}
// -- Relative pricing --
#[test]
fn fast_480p_cheaper_than_720p() {
let c480 = estimate_usd_cents(5, 1, Some(CommonResolution::FourEightyP));
let c720 = estimate_usd_cents(5, 1, Some(CommonResolution::SevenTwentyP));
assert!(c480 < c720, "480p ({}) should be cheaper than 720p ({})", c480, c720);
}
#[test]
fn fast_is_cheaper_than_pro() {
for duration in [4, 5, 10, 15] {
let fast = estimate_usd_cents(duration, 1);
let pro = estimate_pro_usd_cents(duration, 1);
assert!(
fast < pro,
"Fast ({}) should be cheaper than Pro ({}) at {}s",
fast, pro, duration,
);
for res in [
Some(CommonResolution::FourEightyP),
Some(CommonResolution::SevenTwentyP),
] {
for duration in [4, 5, 10, 15] {
let fast = estimate_usd_cents(duration, 1, res);
let pro = estimate_pro_usd_cents(duration, 1, res);
assert!(
fast < pro,
"Fast ({}) should be cheaper than Pro ({}) at {}s {:?}",
fast, pro, duration, res,
);
}
}
}
#[test]
fn cost_scales_with_duration() {
let c4 = estimate_usd_cents(4, 1);
let c10 = estimate_usd_cents(10, 1);
let c15 = estimate_usd_cents(15, 1);
let c4 = estimate_usd_cents(4, 1, Some(CommonResolution::SevenTwentyP));
let c10 = estimate_usd_cents(10, 1, Some(CommonResolution::SevenTwentyP));
let c15 = estimate_usd_cents(15, 1, Some(CommonResolution::SevenTwentyP));
assert!(c4 < c10);
assert!(c10 < c15);
}
#[test]
fn cost_scales_with_batch() {
let b1 = estimate_usd_cents(5, 1);
let b2 = estimate_usd_cents(5, 2);
let b4 = estimate_usd_cents(5, 4);
let b1 = estimate_usd_cents(5, 1, Some(CommonResolution::SevenTwentyP));
let b2 = estimate_usd_cents(5, 2, Some(CommonResolution::SevenTwentyP));
let b4 = estimate_usd_cents(5, 4, Some(CommonResolution::SevenTwentyP));
assert!(b1 < b2);
assert!(b2 < b4);
}
@@ -1,34 +1,11 @@
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
use seedance2pro_client::requests::generate_video::generate_video::{GenerateVideoArgs, KinoviModelType, KinoviResolution};
use crate::generate::generate_video::plan::seedance2pro::plan_generate_video_seedance2pro_seedance2p0::PlanSeedance2proSeedance2p0;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
pub(crate) fn estimate_video_cost_seedance2pro_seedance2p0(
plan: &PlanSeedance2proSeedance2p0,
) -> VideoGenerationCostEstimate {
// A dummy session is sufficient — cost estimation does not make any network calls.
let dummy_session = Seedance2ProSession::from_cookies_string(String::new());
let args = GenerateVideoArgs {
session: &dummy_session,
model_type: KinoviModelType::Seedance2Pro,
prompt: String::new(),
resolution: KinoviResolution::Square1x1, // Resolution does not affect cost
duration_seconds: plan.duration_seconds,
batch_count: plan.batch_count,
start_frame_url: None,
end_frame_url: None,
reference_image_urls: None,
reference_video_urls: None,
reference_audio_urls: None,
character_ids: None,
use_face_blur_hack: None,
host_override: None,
};
let cost_in_credits = args.estimate_credits();
let cost_in_usd_cents = args.estimate_cost_in_usd_cents();
let cost_in_credits = plan.request.estimate_credits();
let cost_in_usd_cents = plan.request.estimate_cost_in_usd_cents();
VideoGenerationCostEstimate {
cost_in_credits: Some(cost_in_credits as u64),
@@ -39,3 +16,146 @@ pub(crate) fn estimate_video_cost_seedance2pro_seedance2p0(
has_watermark: false,
}
}
#[cfg(test)]
mod tests {
use crate::api::common_resolution::CommonResolution;
use crate::api::common_video_model::CommonVideoModel;
use crate::api::provider::Provider;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
fn estimate_usd_cents(
duration_seconds: u16,
video_batch_count: u16,
resolution: Option<CommonResolution>,
) -> u64 {
let request = GenerateVideoRequest {
model: CommonVideoModel::Seedance2p0,
provider: Provider::Seedance2Pro,
prompt: None,
negative_prompt: None,
start_frame: None,
end_frame: None,
reference_images: None,
reference_videos: None,
reference_audio: None,
reference_character_tokens: None,
resolution,
aspect_ratio: None,
duration_seconds: Some(duration_seconds),
video_batch_count: Some(video_batch_count),
generate_audio: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
idempotency_token: None,
};
request.build()
.expect("build should succeed")
.estimate_costs()
.cost_in_usd_cents
.expect("cost_in_usd_cents should be present")
}
// -- 720p (default / legacy pricing: 250 credits/$1) --
#[test]
fn pro_720p_batch_1() {
assert_eq!(estimate_usd_cents(4, 1, Some(CommonResolution::SevenTwentyP)), 64);
assert_eq!(estimate_usd_cents(5, 1, Some(CommonResolution::SevenTwentyP)), 80);
assert_eq!(estimate_usd_cents(6, 1, Some(CommonResolution::SevenTwentyP)), 96);
assert_eq!(estimate_usd_cents(7, 1, Some(CommonResolution::SevenTwentyP)), 112);
assert_eq!(estimate_usd_cents(10, 1, Some(CommonResolution::SevenTwentyP)), 160);
assert_eq!(estimate_usd_cents(15, 1, Some(CommonResolution::SevenTwentyP)), 240);
}
#[test]
fn pro_720p_batch_2() {
assert_eq!(estimate_usd_cents(4, 2, Some(CommonResolution::SevenTwentyP)), 128);
assert_eq!(estimate_usd_cents(5, 2, Some(CommonResolution::SevenTwentyP)), 160);
assert_eq!(estimate_usd_cents(15, 2, Some(CommonResolution::SevenTwentyP)), 480);
}
#[test]
fn pro_720p_batch_4() {
assert_eq!(estimate_usd_cents(4, 4, Some(CommonResolution::SevenTwentyP)), 256);
assert_eq!(estimate_usd_cents(5, 4, Some(CommonResolution::SevenTwentyP)), 320);
assert_eq!(estimate_usd_cents(15, 4, Some(CommonResolution::SevenTwentyP)), 960);
}
#[test]
fn pro_none_resolution_same_as_720p() {
assert_eq!(
estimate_usd_cents(5, 1, None),
estimate_usd_cents(5, 1, Some(CommonResolution::SevenTwentyP)),
);
}
// -- 480p (new pricing: 193 credits/$1) --
#[test]
fn pro_480p_batch_1() {
assert_eq!(estimate_usd_cents(4, 1, Some(CommonResolution::FourEightyP)), 31);
assert_eq!(estimate_usd_cents(5, 1, Some(CommonResolution::FourEightyP)), 39);
assert_eq!(estimate_usd_cents(10, 1, Some(CommonResolution::FourEightyP)), 78);
assert_eq!(estimate_usd_cents(15, 1, Some(CommonResolution::FourEightyP)), 117);
}
#[test]
fn pro_480p_batch_2() {
assert_eq!(estimate_usd_cents(5, 2, Some(CommonResolution::FourEightyP)), 78);
}
#[test]
fn pro_480p_batch_4() {
assert_eq!(estimate_usd_cents(5, 4, Some(CommonResolution::FourEightyP)), 155);
}
// -- 1080p (new pricing: 193 credits/$1) --
#[test]
fn pro_1080p_batch_1() {
assert_eq!(estimate_usd_cents(4, 1, Some(CommonResolution::TenEightyP)), 187);
assert_eq!(estimate_usd_cents(5, 1, Some(CommonResolution::TenEightyP)), 233);
assert_eq!(estimate_usd_cents(10, 1, Some(CommonResolution::TenEightyP)), 466);
assert_eq!(estimate_usd_cents(15, 1, Some(CommonResolution::TenEightyP)), 699);
}
#[test]
fn pro_1080p_batch_2() {
assert_eq!(estimate_usd_cents(5, 2, Some(CommonResolution::TenEightyP)), 466);
}
#[test]
fn pro_1080p_batch_4() {
assert_eq!(estimate_usd_cents(5, 4, Some(CommonResolution::TenEightyP)), 933);
}
// -- Relative pricing --
#[test]
fn pro_480p_cheaper_than_720p_cheaper_than_1080p() {
let c480 = estimate_usd_cents(5, 1, Some(CommonResolution::FourEightyP));
let c720 = estimate_usd_cents(5, 1, Some(CommonResolution::SevenTwentyP));
let c1080 = estimate_usd_cents(5, 1, Some(CommonResolution::TenEightyP));
assert!(c480 < c720, "480p ({}) should be cheaper than 720p ({})", c480, c720);
assert!(c720 < c1080, "720p ({}) should be cheaper than 1080p ({})", c720, c1080);
}
#[test]
fn cost_scales_with_duration() {
let c4 = estimate_usd_cents(4, 1, Some(CommonResolution::SevenTwentyP));
let c10 = estimate_usd_cents(10, 1, Some(CommonResolution::SevenTwentyP));
let c15 = estimate_usd_cents(15, 1, Some(CommonResolution::SevenTwentyP));
assert!(c4 < c10);
assert!(c10 < c15);
}
#[test]
fn cost_scales_with_batch() {
let b1 = estimate_usd_cents(5, 1, Some(CommonResolution::TenEightyP));
let b2 = estimate_usd_cents(5, 2, Some(CommonResolution::TenEightyP));
let b4 = estimate_usd_cents(5, 4, Some(CommonResolution::TenEightyP));
assert!(b1 < b2);
assert!(b2 < b4);
}
}
@@ -1,34 +1,11 @@
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
use seedance2pro_client::requests::generate_video::generate_video::{GenerateVideoArgs, KinoviModelType, KinoviResolution};
use crate::generate::generate_video::plan::seedance2pro::plan_generate_video_seedance2pro_seedance2p0_fast::PlanSeedance2proSeedance2p0Fast;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
pub(crate) fn estimate_video_cost_seedance2pro_seedance2p0_fast(
plan: &PlanSeedance2proSeedance2p0Fast,
) -> VideoGenerationCostEstimate {
// A dummy session is sufficient — cost estimation does not make any network calls.
let dummy_session = Seedance2ProSession::from_cookies_string(String::new());
let args = GenerateVideoArgs {
session: &dummy_session,
model_type: KinoviModelType::Seedance2Fast, // <-- Fast, not Pro
prompt: String::new(),
resolution: KinoviResolution::Square1x1, // Resolution does not affect cost
duration_seconds: plan.duration_seconds,
batch_count: plan.batch_count,
start_frame_url: None,
end_frame_url: None,
reference_image_urls: None,
reference_video_urls: None,
reference_audio_urls: None,
character_ids: None,
use_face_blur_hack: None,
host_override: None,
};
let cost_in_credits = args.estimate_credits();
let cost_in_usd_cents = args.estimate_cost_in_usd_cents();
let cost_in_credits = plan.request.estimate_credits();
let cost_in_usd_cents = plan.request.estimate_cost_in_usd_cents();
VideoGenerationCostEstimate {
cost_in_credits: Some(cost_in_credits as u64),
@@ -42,12 +19,17 @@ pub(crate) fn estimate_video_cost_seedance2pro_seedance2p0_fast(
#[cfg(test)]
mod tests {
use crate::api::common_resolution::CommonResolution;
use crate::api::common_video_model::CommonVideoModel;
use crate::api::provider::Provider;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
fn estimate_usd_cents(duration_seconds: u16, video_batch_count: u16) -> u64 {
fn estimate_usd_cents(
duration_seconds: u16,
video_batch_count: u16,
resolution: Option<CommonResolution>,
) -> u64 {
let request = GenerateVideoRequest {
model: CommonVideoModel::Seedance2p0Fast,
provider: Provider::Seedance2Pro,
@@ -59,7 +41,7 @@ mod tests {
reference_videos: None,
reference_audio: None,
reference_character_tokens: None,
resolution: None,
resolution,
aspect_ratio: None,
duration_seconds: Some(duration_seconds),
video_batch_count: Some(video_batch_count),
@@ -74,36 +56,12 @@ mod tests {
.expect("cost_in_usd_cents should be present")
}
#[test]
fn cost_is_present() {
let cents = estimate_usd_cents(5, 1);
assert!(cents > 0);
}
#[test]
fn cost_batch_1() {
// Seedance2Fast: 28 credits/sec, 220 credits/$1 (with rounding)
assert_eq!(estimate_usd_cents(4, 1), 51);
assert_eq!(estimate_usd_cents(5, 1), 64);
assert_eq!(estimate_usd_cents(10, 1), 127);
assert_eq!(estimate_usd_cents(15, 1), 191);
}
#[test]
fn cost_batch_2() {
assert_eq!(estimate_usd_cents(5, 2), 127);
}
#[test]
fn cost_batch_4() {
assert_eq!(estimate_usd_cents(5, 4), 255);
}
#[test]
fn fast_is_cheaper_than_pro() {
let fast = estimate_usd_cents(5, 1);
let pro_request = GenerateVideoRequest {
fn estimate_pro_usd_cents(
duration_seconds: u16,
video_batch_count: u16,
resolution: Option<CommonResolution>,
) -> u64 {
let request = GenerateVideoRequest {
model: CommonVideoModel::Seedance2p0,
provider: Provider::Seedance2Pro,
prompt: None,
@@ -114,16 +72,140 @@ mod tests {
reference_videos: None,
reference_audio: None,
reference_character_tokens: None,
resolution: None,
resolution,
aspect_ratio: None,
duration_seconds: Some(5),
video_batch_count: Some(1),
duration_seconds: Some(duration_seconds),
video_batch_count: Some(video_batch_count),
generate_audio: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
idempotency_token: None,
};
let pro = pro_request.build().unwrap().estimate_costs().cost_in_usd_cents.unwrap();
request.build()
.expect("build should succeed")
.estimate_costs()
.cost_in_usd_cents
.expect("cost_in_usd_cents should be present")
}
assert!(fast < pro, "Fast ({}) should be cheaper than Pro ({})", fast, pro);
// -- 720p (default / legacy pricing: 220 credits/$1) --
#[test]
fn fast_720p_batch_1() {
assert_eq!(estimate_usd_cents(4, 1, Some(CommonResolution::SevenTwentyP)), 51);
assert_eq!(estimate_usd_cents(5, 1, Some(CommonResolution::SevenTwentyP)), 64);
assert_eq!(estimate_usd_cents(10, 1, Some(CommonResolution::SevenTwentyP)), 127);
assert_eq!(estimate_usd_cents(15, 1, Some(CommonResolution::SevenTwentyP)), 191);
}
#[test]
fn fast_720p_batch_2() {
assert_eq!(estimate_usd_cents(5, 2, Some(CommonResolution::SevenTwentyP)), 127);
}
#[test]
fn fast_720p_batch_4() {
assert_eq!(estimate_usd_cents(5, 4, Some(CommonResolution::SevenTwentyP)), 255);
}
#[test]
fn fast_none_resolution_same_as_720p() {
assert_eq!(
estimate_usd_cents(5, 1, None),
estimate_usd_cents(5, 1, Some(CommonResolution::SevenTwentyP)),
);
}
// -- 480p (new pricing: 193 credits/$1) --
#[test]
fn fast_480p_batch_1() {
assert_eq!(estimate_usd_cents(4, 1, Some(CommonResolution::FourEightyP)), 21);
assert_eq!(estimate_usd_cents(5, 1, Some(CommonResolution::FourEightyP)), 26);
assert_eq!(estimate_usd_cents(10, 1, Some(CommonResolution::FourEightyP)), 52);
assert_eq!(estimate_usd_cents(15, 1, Some(CommonResolution::FourEightyP)), 78);
}
#[test]
fn fast_480p_batch_2() {
assert_eq!(estimate_usd_cents(5, 2, Some(CommonResolution::FourEightyP)), 52);
}
#[test]
fn fast_480p_batch_4() {
assert_eq!(estimate_usd_cents(5, 4, Some(CommonResolution::FourEightyP)), 104);
}
// -- 1080p maps to 720p for Fast (Kinovi doesn't support 1080p fast) --
#[test]
fn fast_1080p_same_as_720p() {
for duration in [4, 5, 10, 15] {
assert_eq!(
estimate_usd_cents(duration, 1, Some(CommonResolution::TenEightyP)),
estimate_usd_cents(duration, 1, Some(CommonResolution::SevenTwentyP)),
);
}
}
#[test]
fn fast_1080p_batch_2() {
assert_eq!(
estimate_usd_cents(5, 2, Some(CommonResolution::TenEightyP)),
estimate_usd_cents(5, 2, Some(CommonResolution::SevenTwentyP)),
);
}
#[test]
fn fast_1080p_batch_4() {
assert_eq!(
estimate_usd_cents(5, 4, Some(CommonResolution::TenEightyP)),
estimate_usd_cents(5, 4, Some(CommonResolution::SevenTwentyP)),
);
}
// -- Relative pricing --
#[test]
fn fast_480p_cheaper_than_720p() {
let c480 = estimate_usd_cents(5, 1, Some(CommonResolution::FourEightyP));
let c720 = estimate_usd_cents(5, 1, Some(CommonResolution::SevenTwentyP));
assert!(c480 < c720, "480p ({}) should be cheaper than 720p ({})", c480, c720);
}
#[test]
fn fast_is_cheaper_than_pro() {
for res in [
Some(CommonResolution::FourEightyP),
Some(CommonResolution::SevenTwentyP),
Some(CommonResolution::TenEightyP),
] {
for duration in [4, 5, 10, 15] {
let fast = estimate_usd_cents(duration, 1, res);
let pro = estimate_pro_usd_cents(duration, 1, res);
assert!(
fast < pro,
"Fast ({}) should be cheaper than Pro ({}) at {}s {:?}",
fast, pro, duration, res,
);
}
}
}
#[test]
fn cost_scales_with_duration() {
let c4 = estimate_usd_cents(4, 1, Some(CommonResolution::SevenTwentyP));
let c10 = estimate_usd_cents(10, 1, Some(CommonResolution::SevenTwentyP));
let c15 = estimate_usd_cents(15, 1, Some(CommonResolution::SevenTwentyP));
assert!(c4 < c10);
assert!(c10 < c15);
}
#[test]
fn cost_scales_with_batch() {
let b1 = estimate_usd_cents(5, 1, Some(CommonResolution::TenEightyP));
let b2 = estimate_usd_cents(5, 2, Some(CommonResolution::TenEightyP));
let b4 = estimate_usd_cents(5, 4, Some(CommonResolution::TenEightyP));
assert!(b1 < b2);
assert!(b2 < b4);
}
}
@@ -5,13 +5,23 @@ use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraf
use crate::generate::generate_video::generate_video_response::{
ArtcraftVideoResponsePayload, GenerateVideoResponse,
};
use artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::Seedance2p0MultiFunctionVideoGenRequest;
use crate::api::common_resolution::CommonResolution as CommonResolutionRouter;
use artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::{
Seedance2p0MultiFunctionVideoGenRequest, Seedance2p0OutputResolution,
};
use artcraft_client::endpoints::generate::video::multi_function::seedance_2p0_multi_function_video_gen::seedance_2p0_multi_function_video_gen;
pub async fn execute_artcraft_seedance2p0(
plan: &PlanArtcraftSeedance2p0,
artcraft_client: &RouterArtcraftClient,
) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
let output_resolution = plan.resolution.map(|r| match r {
CommonResolutionRouter::FourEightyP => Seedance2p0OutputResolution::FourEightyP,
CommonResolutionRouter::SevenTwentyP => Seedance2p0OutputResolution::SevenTwentyP,
CommonResolutionRouter::TenEightyP => Seedance2p0OutputResolution::TenEightyP,
_ => Seedance2p0OutputResolution::SevenTwentyP,
});
let request = Seedance2p0MultiFunctionVideoGenRequest {
uuid_idempotency_token: plan.idempotency_token.clone(),
prompt: plan.prompt.clone(),
@@ -22,6 +32,7 @@ pub async fn execute_artcraft_seedance2p0(
reference_audio_media_tokens: plan.reference_audio.clone(),
reference_character_tokens: plan.reference_characters.clone(),
aspect_ratio: plan.aspect_ratio,
output_resolution,
duration_seconds: plan.duration_seconds,
batch_count: Some(plan.batch_count),
};
@@ -1,3 +1,4 @@
use crate::api::common_resolution::CommonResolution as CommonResolutionRouter;
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
@@ -5,9 +6,11 @@ use crate::generate::generate_video::generate_video_response::{
ArtcraftVideoResponsePayload, GenerateVideoResponse,
};
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_seedance2p0::PlanArtcraftSeedance2p0;
use artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::{Seedance2p0AspectRatio, Seedance2p0BatchCount};
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
use artcraft_client::endpoints::omni_gen::generate::video::omni_gen_video::omni_gen_video_generate;
use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
use enums::common::generation::common_resolution::CommonResolution;
use enums::common::generation::common_video_model::CommonVideoModel;
/// Execute Seedance 2.0 Fast via the Artcraft omni-gen video endpoint.
@@ -19,34 +22,45 @@ pub async fn execute_artcraft_seedance2p0_fast(
artcraft_client: &RouterArtcraftClient,
) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
let aspect_ratio = plan.aspect_ratio.map(|ar| match ar {
artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::Seedance2p0AspectRatio::Landscape16x9 => CommonAspectRatio::WideSixteenByNine,
artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::Seedance2p0AspectRatio::Portrait9x16 => CommonAspectRatio::TallNineBySixteen,
artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::Seedance2p0AspectRatio::Square1x1 => CommonAspectRatio::Square,
artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::Seedance2p0AspectRatio::Standard4x3 => CommonAspectRatio::WideFourByThree,
artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::Seedance2p0AspectRatio::Portrait3x4 => CommonAspectRatio::TallThreeByFour,
Seedance2p0AspectRatio::Landscape16x9 => CommonAspectRatio::WideSixteenByNine,
Seedance2p0AspectRatio::Portrait9x16 => CommonAspectRatio::TallNineBySixteen,
Seedance2p0AspectRatio::Square1x1 => CommonAspectRatio::Square,
Seedance2p0AspectRatio::Standard4x3 => CommonAspectRatio::WideFourByThree,
Seedance2p0AspectRatio::Portrait3x4 => CommonAspectRatio::TallThreeByFour,
});
let resolution = plan.resolution.map(|r| match r {
CommonResolutionRouter::FourEightyP => CommonResolution::FourEightyP,
CommonResolutionRouter::SevenTwentyP => CommonResolution::SevenTwentyP,
CommonResolutionRouter::TenEightyP => CommonResolution::TenEightyP,
CommonResolutionRouter::HalfK => CommonResolution::HalfK,
CommonResolutionRouter::OneK => CommonResolution::OneK,
CommonResolutionRouter::TwoK => CommonResolution::TwoK,
CommonResolutionRouter::ThreeK => CommonResolution::ThreeK,
CommonResolutionRouter::FourK => CommonResolution::FourK,
});
let request = OmniGenVideoCostAndGenerateRequest {
idempotency_token: Some(plan.idempotency_token.clone()),
model: Some(CommonVideoModel::Seedance2p0Fast),
prompt: plan.prompt.clone(),
negative_prompt: None,
start_frame_image_media_token: plan.start_frame.clone(),
end_frame_image_media_token: plan.end_frame.clone(),
reference_image_media_tokens: plan.reference_images.clone(),
reference_video_media_tokens: plan.reference_videos.clone(),
reference_audio_media_tokens: plan.reference_audio.clone(),
reference_character_tokens: None,
resolution: None,
reference_character_tokens: plan.reference_characters.clone(),
resolution,
aspect_ratio,
quality: None,
duration_seconds: plan.duration_seconds.map(|d| d as u16),
video_batch_count: Some(match plan.batch_count {
artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::Seedance2p0BatchCount::One => 1,
artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::Seedance2p0BatchCount::Two => 2,
artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::Seedance2p0BatchCount::Four => 4,
Seedance2p0BatchCount::One => 1,
Seedance2p0BatchCount::Two => 2,
Seedance2p0BatchCount::Four => 4,
}),
quality: None,
generate_audio: None,
negative_prompt: None,
};
let response = omni_gen_video_generate(
@@ -63,3 +77,160 @@ pub async fn execute_artcraft_seedance2p0_fast(
all_inference_job_tokens: all_tokens,
}))
}
#[cfg(test)]
mod tests {
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::common_resolution::CommonResolution;
use crate::api::common_video_model::CommonVideoModel;
use crate::api::image_ref::ImageRef;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
use crate::test_helpers::{base_video_request, get_artcraft_client};
use tokens::tokens::media_files::MediaFileToken;
fn fast_request() -> GenerateVideoRequest {
GenerateVideoRequest {
model: CommonVideoModel::Seedance2p0Fast,
prompt: Some("a corgi walking through a cyberpunk city at night".to_string()),
..base_video_request()
}
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_text_to_video_fast_720p_wide() {
let client = get_artcraft_client();
let request = GenerateVideoRequest {
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
resolution: Some(CommonResolution::SevenTwentyP),
video_batch_count: Some(1),
..fast_request()
};
let plan = request.build().unwrap();
let result = plan.generate_video(&client).await;
println!("Result: {:?}", result);
let response = result.expect("generate_video request failed");
let payload = response.get_artcraft_payload().expect("expected Artcraft payload");
println!("Job token: {:?}", payload.inference_job_token);
assert_eq!(1, 2); // NB: Intentional failure to inspect the response above.
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_text_to_video_fast_480p_square() {
let client = get_artcraft_client();
let request = GenerateVideoRequest {
aspect_ratio: Some(CommonAspectRatio::Square),
resolution: Some(CommonResolution::FourEightyP),
video_batch_count: Some(1),
..fast_request()
};
let plan = request.build().unwrap();
let result = plan.generate_video(&client).await;
println!("Result: {:?}", result);
let response = result.expect("generate_video request failed");
let payload = response.get_artcraft_payload().expect("expected Artcraft payload");
println!("Job token: {:?}", payload.inference_job_token);
assert_eq!(1, 2);
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_image_to_video_fast_with_start_keyframe() {
let client = get_artcraft_client();
let request = GenerateVideoRequest {
start_frame: Some(ImageRef::MediaFileToken(
MediaFileToken::new("mf_test_keyframe_placeholder".to_string()),
)),
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
resolution: Some(CommonResolution::SevenTwentyP),
video_batch_count: Some(1),
..fast_request()
};
let plan = request.build().unwrap();
let result = plan.generate_video(&client).await;
println!("Result: {:?}", result);
let response = result.expect("generate_video request failed");
let payload = response.get_artcraft_payload().expect("expected Artcraft payload");
println!("Job token: {:?}", payload.inference_job_token);
assert_eq!(1, 2);
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_text_to_video_fast_batch_2() {
let client = get_artcraft_client();
let request = GenerateVideoRequest {
aspect_ratio: Some(CommonAspectRatio::TallNineBySixteen),
resolution: Some(CommonResolution::SevenTwentyP),
video_batch_count: Some(2),
..fast_request()
};
let plan = request.build().unwrap();
let result = plan.generate_video(&client).await;
println!("Result: {:?}", result);
let response = result.expect("generate_video request failed");
let payload = response.get_artcraft_payload().expect("expected Artcraft payload");
println!("Job tokens ({} total):", payload.all_inference_job_tokens.len());
for token in &payload.all_inference_job_tokens {
println!(" {:?}", token);
}
assert_eq!(1, 2);
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_text_to_video_fast_with_reference_images() {
let client = get_artcraft_client();
let request = GenerateVideoRequest {
reference_images: Some(crate::api::image_list_ref::ImageListRef::MediaFileTokens(vec![
MediaFileToken::new("mf_test_ref_image_placeholder".to_string()),
])),
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
resolution: Some(CommonResolution::SevenTwentyP),
video_batch_count: Some(1),
..fast_request()
};
let plan = request.build().unwrap();
let result = plan.generate_video(&client).await;
println!("Result: {:?}", result);
let response = result.expect("generate_video request failed");
let payload = response.get_artcraft_payload().expect("expected Artcraft payload");
println!("Job token: {:?}", payload.inference_job_token);
assert_eq!(1, 2);
}
#[test]
fn plan_includes_resolution() {
let request = GenerateVideoRequest {
resolution: Some(CommonResolution::FourEightyP),
..fast_request()
};
let plan = request.build().unwrap();
let VideoGenerationPlan::ArtcraftSeedance2p0Fast(p) = plan else { panic!("wrong variant") };
assert!(matches!(p.resolution, Some(CommonResolution::FourEightyP)));
}
#[test]
fn plan_includes_characters() {
use crate::api::character_list_ref::CharacterListRef;
use tokens::tokens::characters::CharacterToken;
let request = GenerateVideoRequest {
reference_character_tokens: Some(CharacterListRef::CharacterTokens(vec![
CharacterToken::new("char_test123".to_string()),
])),
..fast_request()
};
let plan = request.build().unwrap();
let VideoGenerationPlan::ArtcraftSeedance2p0Fast(p) = plan else { panic!("wrong variant") };
assert!(p.reference_characters.is_some());
assert_eq!(p.reference_characters.unwrap().len(), 1);
}
}
@@ -7,7 +7,7 @@ use crate::generate::generate_video::generate_video_response::{
use crate::generate::generate_video::plan::seedance2pro::plan_generate_video_seedance2pro_seedance2p0::PlanSeedance2proSeedance2p0;
use crate::utils::download_file::download_file;
use seedance2pro_client::requests::generate_video::generate_video::{
generate_video, GenerateVideoArgs, KinoviModelType,
generate_video, GenerateVideoArgs,
};
use seedance2pro_client::requests::prepare_file_upload::prepare_file_upload::{
prepare_file_upload, PrepareFileUploadArgs,
@@ -26,27 +26,23 @@ pub async fn execute_seedance2pro_seedance2p0(
let session = &seedance2pro_client.session;
// Upload media files to seedance2pro CDN
let start_frame_url = upload_optional_url(session, plan.start_frame_url.as_deref()).await?;
let end_frame_url = upload_optional_url(session, plan.end_frame_url.as_deref()).await?;
let reference_image_urls = upload_optional_url_list(session, plan.reference_image_urls.as_deref()).await?;
let reference_video_urls = upload_optional_url_list(session, plan.reference_video_urls.as_deref()).await?;
let reference_audio_urls = upload_optional_url_list(session, plan.reference_audio_urls.as_deref()).await?;
let start_frame_url = upload_optional_url(session, plan.request.start_frame_url.as_deref()).await?;
let end_frame_url = upload_optional_url(session, plan.request.end_frame_url.as_deref()).await?;
let reference_image_urls = upload_optional_url_list(session, plan.request.reference_image_urls.as_deref()).await?;
let reference_video_urls = upload_optional_url_list(session, plan.request.reference_video_urls.as_deref()).await?;
let reference_audio_urls = upload_optional_url_list(session, plan.request.reference_audio_urls.as_deref()).await?;
let mut request = plan.request.clone();
request.start_frame_url = start_frame_url;
request.end_frame_url = end_frame_url;
request.reference_image_urls = reference_image_urls;
request.reference_video_urls = reference_video_urls;
request.reference_audio_urls = reference_audio_urls;
let args = GenerateVideoArgs {
session,
model_type: KinoviModelType::Seedance2Pro,
prompt: plan.prompt.clone().unwrap_or_default(),
resolution: plan.resolution,
duration_seconds: plan.duration_seconds,
batch_count: plan.batch_count,
start_frame_url,
end_frame_url,
reference_image_urls,
reference_video_urls,
reference_audio_urls,
character_ids: None,
use_face_blur_hack: None,
host_override: None,
request,
};
let response = generate_video(args)
@@ -1,15 +1,15 @@
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
use crate::generate::generate_video::execute::seedance2pro::execute_seedance2pro_seedance2p0::{
upload_optional_url, upload_optional_url_list,
};
use crate::generate::generate_video::generate_video_response::{
GenerateVideoResponse, Seedance2proVideoResponsePayload,
};
use crate::generate::generate_video::plan::seedance2pro::plan_generate_video_seedance2pro_seedance2p0_fast::PlanSeedance2proSeedance2p0Fast;
use crate::generate::generate_video::execute::seedance2pro::execute_seedance2pro_seedance2p0::{
upload_optional_url, upload_optional_url_list,
};
use seedance2pro_client::requests::generate_video::generate_video::{
generate_video, GenerateVideoArgs, KinoviModelType,
generate_video, GenerateVideoArgs,
};
/// Execute Seedance 2.0 Fast via the Seedance2Pro/Kinovi provider.
@@ -23,27 +23,23 @@ pub async fn execute_seedance2pro_seedance2p0_fast(
let session = &seedance2pro_client.session;
// Upload media files to seedance2pro CDN
let start_frame_url = upload_optional_url(session, plan.start_frame_url.as_deref()).await?;
let end_frame_url = upload_optional_url(session, plan.end_frame_url.as_deref()).await?;
let reference_image_urls = upload_optional_url_list(session, plan.reference_image_urls.as_deref()).await?;
let reference_video_urls = upload_optional_url_list(session, plan.reference_video_urls.as_deref()).await?;
let reference_audio_urls = upload_optional_url_list(session, plan.reference_audio_urls.as_deref()).await?;
let start_frame_url = upload_optional_url(session, plan.request.start_frame_url.as_deref()).await?;
let end_frame_url = upload_optional_url(session, plan.request.end_frame_url.as_deref()).await?;
let reference_image_urls = upload_optional_url_list(session, plan.request.reference_image_urls.as_deref()).await?;
let reference_video_urls = upload_optional_url_list(session, plan.request.reference_video_urls.as_deref()).await?;
let reference_audio_urls = upload_optional_url_list(session, plan.request.reference_audio_urls.as_deref()).await?;
let mut request = plan.request.clone();
request.start_frame_url = start_frame_url;
request.end_frame_url = end_frame_url;
request.reference_image_urls = reference_image_urls;
request.reference_video_urls = reference_video_urls;
request.reference_audio_urls = reference_audio_urls;
let args = GenerateVideoArgs {
session,
model_type: KinoviModelType::Seedance2Fast, // <-- Fast, not Pro
prompt: plan.prompt.clone().unwrap_or_default(),
resolution: plan.resolution,
duration_seconds: plan.duration_seconds,
batch_count: plan.batch_count,
start_frame_url,
end_frame_url,
reference_image_urls,
reference_video_urls,
reference_audio_urls,
character_ids: None,
use_face_blur_hack: None,
host_override: None,
request,
};
let response = generate_video(args)
@@ -1,5 +1,6 @@
use crate::api::audio_list_ref::AudioListRef;
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::common_resolution::CommonResolution;
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::video_list_ref::VideoListRef;
@@ -24,6 +25,7 @@ pub struct PlanArtcraftSeedance2p0 {
pub reference_audio: Option<Vec<MediaFileToken>>,
pub reference_characters: Option<Vec<CharacterToken>>,
pub aspect_ratio: Option<Seedance2p0AspectRatio>,
pub resolution: Option<CommonResolution>,
pub duration_seconds: Option<u8>,
pub batch_count: Seedance2p0BatchCount,
pub idempotency_token: String,
@@ -41,6 +43,7 @@ pub fn plan_generate_video_artcraft_seedance2p0(
let reference_audio = resolve_audio_list_ref(request.reference_audio.clone())?;
let aspect_ratio = plan_aspect_ratio(request.aspect_ratio, strategy)?;
let resolution = plan_output_resolution(request.resolution, strategy)?;
let batch_count = plan_batch_count(request.video_batch_count, strategy)?;
let duration_seconds = plan_duration(request.duration_seconds, strategy)?;
@@ -53,6 +56,7 @@ pub fn plan_generate_video_artcraft_seedance2p0(
reference_audio,
reference_characters: resolve_character_list_ref(request.reference_character_tokens.clone()),
aspect_ratio,
resolution,
duration_seconds,
batch_count,
idempotency_token: request.get_or_generate_idempotency_token(),
@@ -228,6 +232,45 @@ fn plan_duration(
}
}
// Seedance 2.0 Pro supports output resolutions: 480p, 720p, 1080p.
fn plan_output_resolution(
resolution: Option<CommonResolution>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<CommonResolution>, ArtcraftRouterError> {
match resolution {
None => Ok(None),
// Direct mappings
Some(CommonResolution::FourEightyP)
| Some(CommonResolution::SevenTwentyP)
| Some(CommonResolution::TenEightyP) => Ok(resolution),
// Mismatches
Some(unsupported) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "resolution",
value: format!("{:?}", unsupported),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade => {
// HalfK → 480p (up); OneK/TwoK/ThreeK/FourK → 1080p (max)
Ok(Some(match unsupported {
CommonResolution::HalfK => CommonResolution::FourEightyP,
_ => CommonResolution::TenEightyP,
}))
}
RequestMismatchMitigationStrategy::PayLessDowngrade => {
// HalfK → 480p (min); OneK/TwoK/ThreeK/FourK → 1080p (closest below)
Ok(Some(match unsupported {
CommonResolution::HalfK => CommonResolution::FourEightyP,
_ => CommonResolution::TenEightyP,
}))
}
},
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1,5 +1,6 @@
use crate::api::audio_list_ref::AudioListRef;
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::common_resolution::CommonResolution;
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::video_list_ref::VideoListRef;
@@ -27,6 +28,7 @@ pub fn plan_generate_video_artcraft_seedance2p0_fast(
let reference_audio = resolve_audio_list_ref(request.reference_audio.clone())?;
let aspect_ratio = plan_aspect_ratio(request.aspect_ratio, strategy)?;
let resolution = plan_output_resolution(request.resolution, strategy)?;
let batch_count = plan_batch_count(request.video_batch_count, strategy)?;
let duration_seconds = plan_duration(request.duration_seconds, strategy)?;
@@ -39,6 +41,7 @@ pub fn plan_generate_video_artcraft_seedance2p0_fast(
reference_audio,
reference_characters: resolve_character_list_ref(request.reference_character_tokens.clone()),
aspect_ratio,
resolution,
duration_seconds,
batch_count,
idempotency_token: request.get_or_generate_idempotency_token(),
@@ -199,6 +202,44 @@ fn plan_duration(
}
}
// Seedance 2.0 Fast supports output resolutions: 480p and 720p only.
fn plan_output_resolution(
resolution: Option<CommonResolution>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<CommonResolution>, ArtcraftRouterError> {
match resolution {
None => Ok(None),
// Direct mappings
Some(CommonResolution::FourEightyP)
| Some(CommonResolution::SevenTwentyP) => Ok(resolution),
// Mismatches
Some(unsupported) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "resolution",
value: format!("{:?}", unsupported),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade => {
// HalfK → 480p (up); TenEightyP/OneK+ → 720p (max)
Ok(Some(match unsupported {
CommonResolution::HalfK => CommonResolution::FourEightyP,
_ => CommonResolution::SevenTwentyP,
}))
}
RequestMismatchMitigationStrategy::PayLessDowngrade => {
// HalfK → 480p (min); TenEightyP/OneK+ → 720p (closest below)
Ok(Some(match unsupported {
CommonResolution::HalfK => CommonResolution::FourEightyP,
_ => CommonResolution::SevenTwentyP,
}))
}
},
}
}
#[cfg(test)]
mod tests {
use crate::api::common_aspect_ratio::CommonAspectRatio;
@@ -8,19 +8,16 @@ use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
use seedance2pro_client::requests::generate_video::generate_video::{KinoviBatchCount, KinoviResolution};
use seedance2pro_client::requests::generate_video::generate_video::{
GenerateVideoRequest as SeedanceGenerateVideoRequest,
KinoviBatchCount, KinoviModelType, KinoviOutputResolution, KinoviAspectRatio,
};
use crate::api::common_resolution::CommonResolution;
#[derive(Debug, Clone)]
pub struct PlanSeedance2proSeedance2p0 {
pub prompt: Option<String>,
pub start_frame_url: Option<String>,
pub end_frame_url: Option<String>,
pub reference_image_urls: Option<Vec<String>>,
pub reference_video_urls: Option<Vec<String>>,
pub reference_audio_urls: Option<Vec<String>>,
pub resolution: KinoviResolution,
pub duration_seconds: u8,
pub batch_count: KinoviBatchCount,
pub request: SeedanceGenerateVideoRequest,
}
pub fn plan_generate_video_seedance2pro_seedance2p0(
@@ -34,23 +31,40 @@ pub fn plan_generate_video_seedance2pro_seedance2p0(
let reference_video_urls = resolve_video_list_ref_urls(request.reference_videos.clone())?;
let reference_audio_urls = resolve_audio_list_ref_urls(request.reference_audio.clone())?;
let resolution = plan_resolution(request.aspect_ratio, strategy)?;
let aspect_ratio = plan_aspect_ratio(request.aspect_ratio, strategy)?;
let batch_count = plan_batch_count(request.video_batch_count, strategy)?;
let duration_seconds = plan_duration(request.duration_seconds, strategy)?;
let output_resolution = request.resolution.map(map_common_resolution_to_kinovi);
Ok(VideoGenerationPlan::Seedance2proSeedance2p0(PlanSeedance2proSeedance2p0 {
prompt: request.prompt.clone(),
start_frame_url,
end_frame_url,
reference_image_urls,
reference_video_urls,
reference_audio_urls,
resolution,
duration_seconds,
batch_count,
request: SeedanceGenerateVideoRequest {
model_type: KinoviModelType::Seedance2Pro,
prompt: request.prompt.clone().unwrap_or_default(),
aspect_ratio,
output_resolution,
duration_seconds,
batch_count,
start_frame_url,
end_frame_url,
reference_image_urls,
reference_video_urls,
reference_audio_urls,
character_ids: None,
use_face_blur_hack: None,
},
}))
}
fn map_common_resolution_to_kinovi(resolution: CommonResolution) -> KinoviOutputResolution {
match resolution {
CommonResolution::FourEightyP => KinoviOutputResolution::FourEightyP,
CommonResolution::SevenTwentyP => KinoviOutputResolution::SevenTwentyP,
CommonResolution::TenEightyP => KinoviOutputResolution::TenEightyP,
_ => KinoviOutputResolution::SevenTwentyP,
}
}
fn resolve_image_ref_url(
image_ref: Option<ImageRef>,
) -> Result<Option<String>, ArtcraftRouterError> {
@@ -104,29 +118,29 @@ fn resolve_audio_list_ref_urls(
//
// All supported ratios cost the same, so PayMoreUpgrade and PayLessDowngrade both
// select the nearest match rather than rounding in a specific direction.
fn plan_resolution(
fn plan_aspect_ratio(
aspect_ratio: Option<CommonAspectRatio>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<KinoviResolution, ArtcraftRouterError> {
) -> Result<KinoviAspectRatio, ArtcraftRouterError> {
match aspect_ratio {
// No preference or auto — default to landscape
None
| Some(CommonAspectRatio::Auto)
| Some(CommonAspectRatio::Auto2k)
| Some(CommonAspectRatio::Auto4k) => Ok(KinoviResolution::Landscape16x9),
| Some(CommonAspectRatio::Auto4k) => Ok(KinoviAspectRatio::Landscape16x9),
// Direct mappings
Some(CommonAspectRatio::WideSixteenByNine) | Some(CommonAspectRatio::Wide) => {
Ok(KinoviResolution::Landscape16x9)
Ok(KinoviAspectRatio::Landscape16x9)
}
Some(CommonAspectRatio::TallNineBySixteen) | Some(CommonAspectRatio::Tall) => {
Ok(KinoviResolution::Portrait9x16)
Ok(KinoviAspectRatio::Portrait9x16)
}
Some(CommonAspectRatio::Square) | Some(CommonAspectRatio::SquareHd) => {
Ok(KinoviResolution::Square1x1)
Ok(KinoviAspectRatio::Square1x1)
}
Some(CommonAspectRatio::WideFourByThree) => Ok(KinoviResolution::Standard4x3),
Some(CommonAspectRatio::TallThreeByFour) => Ok(KinoviResolution::Portrait3x4),
Some(CommonAspectRatio::WideFourByThree) => Ok(KinoviAspectRatio::Standard4x3),
Some(CommonAspectRatio::TallThreeByFour) => Ok(KinoviAspectRatio::Portrait3x4),
// Mismatches — apply strategy
Some(unsupported) => match strategy {
@@ -145,15 +159,15 @@ fn plan_resolution(
}
/// Pick the nearest supported resolution by AR value (width / height).
fn nearest_resolution(aspect_ratio: CommonAspectRatio) -> KinoviResolution {
fn nearest_resolution(aspect_ratio: CommonAspectRatio) -> KinoviAspectRatio {
match aspect_ratio {
CommonAspectRatio::WideFiveByFour => KinoviResolution::Standard4x3, // 1.25, nearest 1.33
CommonAspectRatio::WideThreeByTwo => KinoviResolution::Standard4x3, // 1.50, nearest 1.33
CommonAspectRatio::WideTwentyOneByNine => KinoviResolution::Landscape16x9, // 2.33, nearest 1.78
CommonAspectRatio::TallFourByFive => KinoviResolution::Portrait3x4, // 0.80, nearest 0.75
CommonAspectRatio::TallTwoByThree => KinoviResolution::Portrait3x4, // 0.67, nearest 0.75
CommonAspectRatio::TallNineByTwentyOne => KinoviResolution::Portrait9x16, // 0.43, nearest 0.56
_ => KinoviResolution::Square1x1,
CommonAspectRatio::WideFiveByFour => KinoviAspectRatio::Standard4x3, // 1.25, nearest 1.33
CommonAspectRatio::WideThreeByTwo => KinoviAspectRatio::Standard4x3, // 1.50, nearest 1.33
CommonAspectRatio::WideTwentyOneByNine => KinoviAspectRatio::Landscape16x9, // 2.33, nearest 1.78
CommonAspectRatio::TallFourByFive => KinoviAspectRatio::Portrait3x4, // 0.80, nearest 0.75
CommonAspectRatio::TallTwoByThree => KinoviAspectRatio::Portrait3x4, // 0.67, nearest 0.75
CommonAspectRatio::TallNineByTwentyOne => KinoviAspectRatio::Portrait9x16, // 0.43, nearest 0.56
_ => KinoviAspectRatio::Square1x1,
}
}
@@ -8,19 +8,16 @@ use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
use seedance2pro_client::requests::generate_video::generate_video::{KinoviBatchCount, KinoviResolution};
use seedance2pro_client::requests::generate_video::generate_video::{
GenerateVideoRequest as SeedanceGenerateVideoRequest,
KinoviBatchCount, KinoviModelType, KinoviOutputResolution, KinoviAspectRatio,
};
use crate::api::common_resolution::CommonResolution;
#[derive(Debug, Clone)]
pub struct PlanSeedance2proSeedance2p0Fast {
pub prompt: Option<String>,
pub start_frame_url: Option<String>,
pub end_frame_url: Option<String>,
pub reference_image_urls: Option<Vec<String>>,
pub reference_video_urls: Option<Vec<String>>,
pub reference_audio_urls: Option<Vec<String>>,
pub resolution: KinoviResolution,
pub duration_seconds: u8,
pub batch_count: KinoviBatchCount,
pub request: SeedanceGenerateVideoRequest,
}
pub fn plan_generate_video_seedance2pro_seedance2p0_fast(
@@ -34,23 +31,40 @@ pub fn plan_generate_video_seedance2pro_seedance2p0_fast(
let reference_video_urls = resolve_video_list_ref_urls(request.reference_videos.clone())?;
let reference_audio_urls = resolve_audio_list_ref_urls(request.reference_audio.clone())?;
let resolution = plan_resolution(request.aspect_ratio, strategy)?;
let aspect_ratio = plan_aspect_ratio(request.aspect_ratio, strategy)?;
let batch_count = plan_batch_count(request.video_batch_count, strategy)?;
let duration_seconds = plan_duration(request.duration_seconds, strategy)?;
let output_resolution = request.resolution.map(map_common_resolution_to_kinovi);
Ok(VideoGenerationPlan::Seedance2proSeedance2p0Fast(PlanSeedance2proSeedance2p0Fast {
prompt: request.prompt.clone(),
start_frame_url,
end_frame_url,
reference_image_urls,
reference_video_urls,
reference_audio_urls,
resolution,
duration_seconds,
batch_count,
request: SeedanceGenerateVideoRequest {
model_type: KinoviModelType::Seedance2Fast,
prompt: request.prompt.clone().unwrap_or_default(),
aspect_ratio,
output_resolution,
duration_seconds,
batch_count,
start_frame_url,
end_frame_url,
reference_image_urls,
reference_video_urls,
reference_audio_urls,
character_ids: None,
use_face_blur_hack: None,
},
}))
}
fn map_common_resolution_to_kinovi(resolution: CommonResolution) -> KinoviOutputResolution {
match resolution {
CommonResolution::FourEightyP => KinoviOutputResolution::FourEightyP,
CommonResolution::SevenTwentyP => KinoviOutputResolution::SevenTwentyP,
CommonResolution::TenEightyP => KinoviOutputResolution::SevenTwentyP, // NB: Kinovi doesn't have 1080p fast currently
_ => KinoviOutputResolution::SevenTwentyP,
}
}
fn resolve_image_ref_url(
image_ref: Option<ImageRef>,
) -> Result<Option<String>, ArtcraftRouterError> {
@@ -99,27 +113,27 @@ fn resolve_audio_list_ref_urls(
}
}
fn plan_resolution(
fn plan_aspect_ratio(
aspect_ratio: Option<CommonAspectRatio>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<KinoviResolution, ArtcraftRouterError> {
) -> Result<KinoviAspectRatio, ArtcraftRouterError> {
match aspect_ratio {
None
| Some(CommonAspectRatio::Auto)
| Some(CommonAspectRatio::Auto2k)
| Some(CommonAspectRatio::Auto4k) => Ok(KinoviResolution::Landscape16x9),
| Some(CommonAspectRatio::Auto4k) => Ok(KinoviAspectRatio::Landscape16x9),
Some(CommonAspectRatio::WideSixteenByNine) | Some(CommonAspectRatio::Wide) => {
Ok(KinoviResolution::Landscape16x9)
Ok(KinoviAspectRatio::Landscape16x9)
}
Some(CommonAspectRatio::TallNineBySixteen) | Some(CommonAspectRatio::Tall) => {
Ok(KinoviResolution::Portrait9x16)
Ok(KinoviAspectRatio::Portrait9x16)
}
Some(CommonAspectRatio::Square) | Some(CommonAspectRatio::SquareHd) => {
Ok(KinoviResolution::Square1x1)
Ok(KinoviAspectRatio::Square1x1)
}
Some(CommonAspectRatio::WideFourByThree) => Ok(KinoviResolution::Standard4x3),
Some(CommonAspectRatio::TallThreeByFour) => Ok(KinoviResolution::Portrait3x4),
Some(CommonAspectRatio::WideFourByThree) => Ok(KinoviAspectRatio::Standard4x3),
Some(CommonAspectRatio::TallThreeByFour) => Ok(KinoviAspectRatio::Portrait3x4),
Some(unsupported) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
@@ -136,15 +150,15 @@ fn plan_resolution(
}
}
fn nearest_resolution(aspect_ratio: CommonAspectRatio) -> KinoviResolution {
fn nearest_resolution(aspect_ratio: CommonAspectRatio) -> KinoviAspectRatio {
match aspect_ratio {
CommonAspectRatio::WideFiveByFour => KinoviResolution::Standard4x3,
CommonAspectRatio::WideThreeByTwo => KinoviResolution::Standard4x3,
CommonAspectRatio::WideTwentyOneByNine => KinoviResolution::Landscape16x9,
CommonAspectRatio::TallFourByFive => KinoviResolution::Portrait3x4,
CommonAspectRatio::TallTwoByThree => KinoviResolution::Portrait3x4,
CommonAspectRatio::TallNineByTwentyOne => KinoviResolution::Portrait9x16,
_ => KinoviResolution::Square1x1,
CommonAspectRatio::WideFiveByFour => KinoviAspectRatio::Standard4x3,
CommonAspectRatio::WideThreeByTwo => KinoviAspectRatio::Standard4x3,
CommonAspectRatio::WideTwentyOneByNine => KinoviAspectRatio::Landscape16x9,
CommonAspectRatio::TallFourByFive => KinoviAspectRatio::Portrait3x4,
CommonAspectRatio::TallTwoByThree => KinoviAspectRatio::Portrait3x4,
CommonAspectRatio::TallNineByTwentyOne => KinoviAspectRatio::Portrait9x16,
_ => KinoviAspectRatio::Square1x1,
}
}
File diff suppressed because it is too large Load Diff
@@ -28,6 +28,8 @@ pub (super) struct ApiParams {
pub model: &'static str,
pub duration: String,
pub mode: &'static str,
#[serde(rename = "outputResolution", skip_serializing_if = "Option::is_none")]
pub output_resolution: Option<&'static str>,
#[serde(rename = "faceBlurMode", skip_serializing_if = "Option::is_none")]
pub face_blur_mode: Option<&'static str>,
#[serde(rename = "characterIds", skip_serializing_if = "Option::is_none")]
@@ -74,6 +74,12 @@ fn build_omni_gen_video_models() -> Vec<OmniGenVideoModelDetails> {
CommonAspectRatio::TallNineBySixteen,
]),
aspect_ratio_default: Some(CommonAspectRatio::WideSixteenByNine),
resolution_options: Some(vec![
CommonResolution::FourEightyP,
CommonResolution::SevenTwentyP,
CommonResolution::TenEightyP,
]),
resolution_default: Some(CommonResolution::SevenTwentyP),
batch_size_options: Some(vec![1, 2, 4]),
batch_size_default: Some(1),
duration_seconds_min: Some(4),
@@ -107,6 +113,11 @@ fn build_omni_gen_video_models() -> Vec<OmniGenVideoModelDetails> {
CommonAspectRatio::TallNineBySixteen,
]),
aspect_ratio_default: Some(CommonAspectRatio::WideSixteenByNine),
resolution_options: Some(vec![
CommonResolution::FourEightyP,
CommonResolution::SevenTwentyP,
]),
resolution_default: Some(CommonResolution::SevenTwentyP),
batch_size_options: Some(vec![1, 2, 4]),
batch_size_default: Some(1),
duration_seconds_min: Some(4),
@@ -14,7 +14,7 @@ use actix_web::web::Json;
use actix_web::{web, HttpRequest};
use artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::{
Seedance2p0AspectRatio, Seedance2p0BatchCount, Seedance2p0MultiFunctionVideoGenRequest,
Seedance2p0MultiFunctionVideoGenResponse,
Seedance2p0MultiFunctionVideoGenResponse, Seedance2p0OutputResolution,
};
use enums::by_table::prompt_context_items::prompt_context_semantic_type::PromptContextSemanticType;
use enums::by_table::prompts::prompt_type::PromptType;
@@ -39,7 +39,7 @@ use pager::notification::notification_details_builder::NotificationDetailsBuilde
use pager::notification::notification_urgency::NotificationUrgency;
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
use seedance2pro_client::requests::generate_video::generate_video::{
generate_video, KinoviBatchCount, GenerateVideoArgs, GenerateVideoResponse, KinoviModelType, KinoviResolution,
generate_video, KinoviBatchCount, GenerateVideoArgs, GenerateVideoRequest, GenerateVideoResponse, KinoviModelType, KinoviOutputResolution, KinoviAspectRatio,
};
use seedance2pro_client::requests::prepare_file_upload::prepare_file_upload::{
prepare_file_upload, PrepareFileUploadArgs,
@@ -163,11 +163,12 @@ pub async fn seedance_2p0_multi_function_video_gen_handler(
// --- Calculate cost and charge wallet upfront (before uploads) ---
let resolution = map_resolution(request.aspect_ratio);
let aspect_ratio = map_resolution(request.aspect_ratio);
let output_resolution = map_output_resolution(request.output_resolution);
let batch_count = map_batch_count(request.batch_count);
let duration_seconds = request.duration_seconds.unwrap_or(5).clamp(4, 15);
let cost_in_cents = estimate_cost_upfront(resolution, batch_count, duration_seconds);
let cost_in_cents = estimate_cost_upfront(aspect_ratio, output_resolution, batch_count, duration_seconds);
let apriori_job_token = InferenceJobToken::generate();
@@ -198,7 +199,8 @@ pub async fn seedance_2p0_multi_function_video_gen_handler(
&whitelist_session,
&request,
&file_urls_by_token,
resolution,
aspect_ratio,
output_resolution,
batch_count,
duration_seconds,
kinovi_character_ids.clone(),
@@ -220,7 +222,8 @@ pub async fn seedance_2p0_multi_function_video_gen_handler(
&regular_session,
&request,
&file_urls_by_token,
resolution,
aspect_ratio,
output_resolution,
batch_count,
duration_seconds,
kinovi_character_ids.clone(),
@@ -257,7 +260,8 @@ pub async fn seedance_2p0_multi_function_video_gen_handler(
&regular_session,
&request,
&file_urls_by_token,
resolution,
aspect_ratio,
output_resolution,
batch_count,
duration_seconds,
kinovi_character_ids,
@@ -458,17 +462,25 @@ pub async fn seedance_2p0_multi_function_video_gen_handler(
// ======================== Helpers ========================
fn map_resolution(aspect_ratio: Option<Seedance2p0AspectRatio>) -> KinoviResolution {
fn map_resolution(aspect_ratio: Option<Seedance2p0AspectRatio>) -> KinoviAspectRatio {
match aspect_ratio {
Some(Seedance2p0AspectRatio::Landscape16x9) => KinoviResolution::Landscape16x9,
Some(Seedance2p0AspectRatio::Portrait9x16) => KinoviResolution::Portrait9x16,
Some(Seedance2p0AspectRatio::Square1x1) => KinoviResolution::Square1x1,
Some(Seedance2p0AspectRatio::Standard4x3) => KinoviResolution::Standard4x3,
Some(Seedance2p0AspectRatio::Portrait3x4) => KinoviResolution::Portrait3x4,
None => KinoviResolution::Landscape16x9,
Some(Seedance2p0AspectRatio::Landscape16x9) => KinoviAspectRatio::Landscape16x9,
Some(Seedance2p0AspectRatio::Portrait9x16) => KinoviAspectRatio::Portrait9x16,
Some(Seedance2p0AspectRatio::Square1x1) => KinoviAspectRatio::Square1x1,
Some(Seedance2p0AspectRatio::Standard4x3) => KinoviAspectRatio::Standard4x3,
Some(Seedance2p0AspectRatio::Portrait3x4) => KinoviAspectRatio::Portrait3x4,
None => KinoviAspectRatio::Landscape16x9,
}
}
fn map_output_resolution(output_resolution: Option<Seedance2p0OutputResolution>) -> Option<KinoviOutputResolution> {
output_resolution.map(|r| match r {
Seedance2p0OutputResolution::FourEightyP => KinoviOutputResolution::FourEightyP,
Seedance2p0OutputResolution::SevenTwentyP => KinoviOutputResolution::SevenTwentyP,
Seedance2p0OutputResolution::TenEightyP => KinoviOutputResolution::TenEightyP,
})
}
fn map_batch_count(batch_count: Option<Seedance2p0BatchCount>) -> KinoviBatchCount {
match batch_count {
Some(Seedance2p0BatchCount::One) | None => KinoviBatchCount::One,
@@ -478,16 +490,20 @@ fn map_batch_count(batch_count: Option<Seedance2p0BatchCount>) -> KinoviBatchCou
}
/// Estimate the cost without needing uploaded URLs. We construct a temporary
/// `GenerateVideoArgs` with dummy values for the session and URL fields.
fn estimate_cost_upfront(resolution: KinoviResolution, batch_count: KinoviBatchCount, duration_seconds: u8) -> u64 {
let dummy_session = Seedance2ProSession::from_cookies_string(String::new());
let args = GenerateVideoArgs {
session: &dummy_session,
/// `GenerateVideoRequest` with dummy values for the URL fields.
fn estimate_cost_upfront(
aspect_ratio: KinoviAspectRatio,
output_resolution: Option<KinoviOutputResolution>,
batch_count: KinoviBatchCount,
duration_seconds: u8,
) -> u64 {
let request = GenerateVideoRequest {
model_type: KinoviModelType::Seedance2Pro,
prompt: String::new(),
resolution,
aspect_ratio,
duration_seconds,
batch_count,
output_resolution,
start_frame_url: None,
end_frame_url: None,
reference_image_urls: None,
@@ -495,9 +511,8 @@ fn estimate_cost_upfront(resolution: KinoviResolution, batch_count: KinoviBatchC
reference_audio_urls: None,
character_ids: None,
use_face_blur_hack: None,
host_override: None,
};
args.estimate_cost_in_usd_cents()
request.estimate_cost_in_usd_cents()
}
/// Uploads all media files and calls generate_video using the given session.
@@ -506,7 +521,8 @@ async fn upload_and_generate(
session: &Seedance2ProSession,
request: &Seedance2p0MultiFunctionVideoGenRequest,
file_urls_by_token: &HashMap<MediaFileToken, Url>,
resolution: KinoviResolution,
aspect_ratio: KinoviAspectRatio,
output_resolution: Option<KinoviOutputResolution>,
batch_count: KinoviBatchCount,
duration_seconds: u8,
kinovi_character_ids: Option<Vec<String>>,
@@ -574,19 +590,22 @@ async fn upload_and_generate(
let video_gen_args = GenerateVideoArgs {
session,
model_type: KinoviModelType::Seedance2Pro,
prompt,
resolution,
duration_seconds,
batch_count,
start_frame_url,
end_frame_url,
reference_image_urls,
reference_video_urls,
reference_audio_urls,
character_ids: kinovi_character_ids,
use_face_blur_hack: None,
host_override: None,
request: GenerateVideoRequest {
model_type: KinoviModelType::Seedance2Pro,
prompt,
aspect_ratio,
output_resolution,
duration_seconds,
batch_count,
start_frame_url,
end_frame_url,
reference_image_urls,
reference_video_urls,
reference_audio_urls,
character_ids: kinovi_character_ids,
use_face_blur_hack: None,
},
};
let gen_response = generate_video(video_gen_args).await
@@ -37,7 +37,7 @@ pub struct DistilledVideoRequest {
pub cost: VideoGenerationCostEstimate,
/// Execution plan as computed by the execution provider — what we hand to the router.
plan: VideoGenerationPlan,
pub plan: VideoGenerationPlan,
/// The provider used for execution (Fal, Seedance2Pro, etc.).
pub execution_provider: Provider,
@@ -3,6 +3,8 @@
use log::warn;
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
use artcraft_router::client::router_client::RouterClient;
use artcraft_router::client::router_fal_client::RouterFalClient;
use artcraft_router::generate::generate_video::generate_video_response::GenerateVideoResponse;
use enums::common::generation::common_generation_mode::CommonGenerationMode;
@@ -18,11 +20,11 @@ pub(super) async fn execute_generation_fal(
request: &OmniGenVideoCostAndGenerateRequest,
server_state: &ServerState,
) -> Result<GenerationResult, AdvancedCommonWebError> {
let fal_client = artcraft_router::client::router_fal_client::RouterFalClient::new(
let fal_client = RouterFalClient::new(
server_state.fal.api_key.clone(),
server_state.fal.webhook_url.clone(),
);
let router_client = artcraft_router::client::router_client::RouterClient::Fal(fal_client);
let router_client = RouterClient::Fal(fal_client);
let generation_response = distilled.plan().generate_video(&router_client)
.await
@@ -11,6 +11,7 @@ use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost
use artcraft_router::api::provider::Provider;
use enums::common::generation::common_generation_mode::CommonGenerationMode;
use tokens::tokens::media_files::MediaFileToken;
use tokens::tokens::wallet_ledger_entries::WalletLedgerEntryToken;
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
use crate::state::server_state::ServerState;
@@ -40,6 +41,9 @@ pub async fn execute_generation(
request: &OmniGenVideoCostAndGenerateRequest,
server_state: &ServerState,
media_file_hydration_map: Option<&HashMap<MediaFileToken, Url>>,
kinovi_character_ids: Option<Vec<String>>,
maybe_wallet_ledger_entry_token: Option<&WalletLedgerEntryToken>,
mysql_connection: &mut sqlx::pool::PoolConnection<sqlx::MySql>,
) -> Result<GenerationResult, AdvancedCommonWebError> {
match distilled.execution_provider {
Provider::Seedance2Pro => {
@@ -48,6 +52,9 @@ pub async fn execute_generation(
request,
server_state,
media_file_hydration_map,
kinovi_character_ids,
maybe_wallet_ledger_entry_token,
mysql_connection,
).await
}
_ => {
@@ -5,27 +5,28 @@
use std::collections::HashMap;
use log::{info, warn};
use log::{error, info, warn};
use url::Url;
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
use enums::common::generation::common_generation_mode::CommonGenerationMode;
use enums::common::generation::common_resolution::CommonResolution;
use enums::common::generation::common_video_model::CommonVideoModel;
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
use seedance2pro_client::requests::generate_video::generate_video::{
generate_video, GenerateVideoArgs, KinoviBatchCount, KinoviModelType, KinoviResolution,
};
use seedance2pro_client::requests::generate_video::generate_video::{generate_video, GenerateVideoArgs, GenerateVideoRequest, KinoviBatchCount, KinoviModelType, KinoviAspectRatio, KinoviOutputResolution};
use seedance2pro_client::requests::prepare_file_upload::prepare_file_upload::{
prepare_file_upload, PrepareFileUploadArgs,
};
use seedance2pro_client::requests::upload_file::upload_file::{upload_file, UploadFileArgs};
use tokens::tokens::media_files::MediaFileToken;
use tokens::tokens::wallet_ledger_entries::WalletLedgerEntryToken;
use url_utils::extension::extract_extension_from_url::{
extract_extension_from_url, ExtractExtensions,
};
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
use crate::http_server::endpoint_helpers::refund_wallet_after_api_failure::refund_wallet_after_api_failure;
use crate::state::server_state::ServerState;
use crate::util::http_download_url_to_bytes::http_download_url_to_bytes;
@@ -37,6 +38,34 @@ pub(super) async fn execute_generation_kinovi(
request: &OmniGenVideoCostAndGenerateRequest,
server_state: &ServerState,
media_file_hydration_map: Option<&HashMap<MediaFileToken, Url>>,
kinovi_character_ids: Option<Vec<String>>,
maybe_wallet_ledger_entry_token: Option<&WalletLedgerEntryToken>,
mysql_connection: &mut sqlx::pool::PoolConnection<sqlx::MySql>,
) -> Result<GenerationResult, AdvancedCommonWebError> {
let result = execute_generation_kinovi_inner(
request,
server_state,
media_file_hydration_map,
kinovi_character_ids,
).await;
if let Err(ref err) = result {
if let Some(ledger_entry_token) = maybe_wallet_ledger_entry_token {
warn!("Kinovi generation failed, issuing refund for ledger entry {}: {:?}", ledger_entry_token.as_str(), err);
if let Err(refund_err) = refund_wallet_after_api_failure(ledger_entry_token, mysql_connection).await {
error!("Failed to refund wallet after Kinovi failure: {:?}", refund_err);
}
}
}
result
}
async fn execute_generation_kinovi_inner(
request: &OmniGenVideoCostAndGenerateRequest,
server_state: &ServerState,
media_file_hydration_map: Option<&HashMap<MediaFileToken, Url>>,
kinovi_character_ids: Option<Vec<String>>,
) -> Result<GenerationResult, AdvancedCommonWebError> {
let session = Seedance2ProSession::from_cookies_string(
server_state.seedance2pro.cookies.clone()
@@ -86,7 +115,7 @@ pub(super) async fn execute_generation_kinovi(
};
// Map aspect ratio / duration / batch from the request.
let resolution = map_common_aspect_ratio_to_kinovi_resolution(request.aspect_ratio);
let aspect_ratio = map_common_aspect_ratio_to_kinovi_resolution(request.aspect_ratio);
let duration_seconds = request.duration_seconds.unwrap_or(5).clamp(4, 15) as u8;
@@ -103,11 +132,15 @@ pub(super) async fn execute_generation_kinovi(
_ => KinoviModelType::Seedance2Pro,
};
let video_gen_args = GenerateVideoArgs {
session: &session,
let output_resolution = match model_type {
KinoviModelType::Seedance2Pro => request.resolution.map(|res| map_common_resolution_to_kinovi_pro(res)),
KinoviModelType::Seedance2Fast => request.resolution.map(|res| map_common_resolution_to_kinovi_fast(res)),
};
let request = GenerateVideoRequest {
model_type,
prompt,
resolution,
aspect_ratio,
duration_seconds,
batch_count,
start_frame_url,
@@ -115,9 +148,17 @@ pub(super) async fn execute_generation_kinovi(
reference_image_urls,
reference_video_urls,
reference_audio_urls,
character_ids: None, // TODO: resolve_kinovi_character_ids when character support is added
character_ids: kinovi_character_ids,
use_face_blur_hack: None,
output_resolution,
};
info!("\n\nKinovi Request: {:?}\n\n", request);
let video_gen_args = GenerateVideoArgs {
session: &session,
host_override: None,
request,
};
let gen_response = generate_video(video_gen_args).await
@@ -231,20 +272,39 @@ async fn upload_url_to_seedance2pro(
Ok(upload_result.public_url)
}
/// Map a CommonAspectRatio to the Seedance2Pro KinoviResolution enum.
fn map_common_aspect_ratio_to_kinovi_resolution(aspect_ratio: Option<CommonAspectRatio>) -> KinoviResolution {
/// Map a CommonAspectRatio to the Seedance2Pro KinoviAspectRatio enum.
fn map_common_aspect_ratio_to_kinovi_resolution(aspect_ratio: Option<CommonAspectRatio>) -> KinoviAspectRatio {
match aspect_ratio {
Some(CommonAspectRatio::WideSixteenByNine) | Some(CommonAspectRatio::Wide) => KinoviResolution::Landscape16x9,
Some(CommonAspectRatio::TallNineBySixteen) | Some(CommonAspectRatio::Tall) => KinoviResolution::Portrait9x16,
Some(CommonAspectRatio::Square) | Some(CommonAspectRatio::SquareHd) => KinoviResolution::Square1x1,
Some(CommonAspectRatio::WideFourByThree) => KinoviResolution::Standard4x3,
Some(CommonAspectRatio::TallThreeByFour) => KinoviResolution::Portrait3x4,
Some(CommonAspectRatio::WideSixteenByNine) | Some(CommonAspectRatio::Wide) => KinoviAspectRatio::Landscape16x9,
Some(CommonAspectRatio::TallNineBySixteen) | Some(CommonAspectRatio::Tall) => KinoviAspectRatio::Portrait9x16,
Some(CommonAspectRatio::Square) | Some(CommonAspectRatio::SquareHd) => KinoviAspectRatio::Square1x1,
Some(CommonAspectRatio::WideFourByThree) => KinoviAspectRatio::Standard4x3,
Some(CommonAspectRatio::TallThreeByFour) => KinoviAspectRatio::Portrait3x4,
// For unsupported aspect ratios, pick the nearest match.
Some(CommonAspectRatio::WideFiveByFour) | Some(CommonAspectRatio::WideThreeByTwo) => KinoviResolution::Standard4x3,
Some(CommonAspectRatio::WideTwentyOneByNine) => KinoviResolution::Landscape16x9,
Some(CommonAspectRatio::TallFourByFive) | Some(CommonAspectRatio::TallTwoByThree) => KinoviResolution::Portrait3x4,
Some(CommonAspectRatio::TallNineByTwentyOne) => KinoviResolution::Portrait9x16,
Some(CommonAspectRatio::WideFiveByFour) | Some(CommonAspectRatio::WideThreeByTwo) => KinoviAspectRatio::Standard4x3,
Some(CommonAspectRatio::WideTwentyOneByNine) => KinoviAspectRatio::Landscape16x9,
Some(CommonAspectRatio::TallFourByFive) | Some(CommonAspectRatio::TallTwoByThree) => KinoviAspectRatio::Portrait3x4,
Some(CommonAspectRatio::TallNineByTwentyOne) => KinoviAspectRatio::Portrait9x16,
// Auto or None — default to landscape.
_ => KinoviResolution::Landscape16x9,
_ => KinoviAspectRatio::Landscape16x9,
}
}
// TODO TEMP HACK
fn map_common_resolution_to_kinovi_pro(resolution: CommonResolution) -> KinoviOutputResolution {
match resolution {
CommonResolution::FourEightyP => KinoviOutputResolution::FourEightyP,
CommonResolution::SevenTwentyP => KinoviOutputResolution::SevenTwentyP,
CommonResolution::TenEightyP => KinoviOutputResolution::TenEightyP,
_ => KinoviOutputResolution::SevenTwentyP,
}
}
// TODO TEMP HACK
fn map_common_resolution_to_kinovi_fast(resolution: CommonResolution) -> KinoviOutputResolution {
match resolution {
CommonResolution::FourEightyP => KinoviOutputResolution::FourEightyP,
CommonResolution::SevenTwentyP => KinoviOutputResolution::SevenTwentyP,
_ => KinoviOutputResolution::SevenTwentyP,
}
}
@@ -2,6 +2,7 @@ pub mod omni_gen_video_generate_handler;
pub mod distill_video_request;
pub(crate) mod distill_helper;
mod execute;
mod request_helper;
#[cfg(test)]
mod tests;
@@ -39,6 +39,7 @@ use crate::http_server::common_responses::advanced_common_web_error::AdvancedCom
use crate::http_server::endpoints::generate::common::payments_error_test::payments_error_test;
use crate::http_server::endpoints::omni_gen::generate::video::distill_video_request::distill_video_request;
use crate::http_server::endpoints::omni_gen::generate::video::execute::execute_generation::execute_generation;
use crate::http_server::endpoints::omni_gen::generate::video::request_helper::resolve_kinovi_character_ids::resolve_kinovi_character_ids;
use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format;
use crate::state::server_state::ServerState;
use crate::util::lookup::lookup_image_urls_as_map::lookup_image_urls_as_map;
@@ -62,6 +63,8 @@ pub async fn omni_gen_video_generate_handler(
request: Json<OmniGenVideoCostAndGenerateRequest>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<OmniGenVideoGenerateResponse>, AdvancedCommonWebError> {
info!("request: {:?}", request);
payments_error_test(&request.prompt.as_deref().unwrap_or(""))?;
@@ -154,6 +157,13 @@ pub async fn omni_gen_video_generate_handler(
}
};
// ==================== RESOLVE CHARACTER TOKENS ==================== //
let kinovi_character_ids = resolve_kinovi_character_ids(
request.reference_character_tokens.as_deref(),
&mut mysql_connection,
).await?;
// ==================== DETERMINE PROVIDER ==================== //
let execution_provider = match request.model {
@@ -166,6 +176,8 @@ pub async fn omni_gen_video_generate_handler(
let distilled = distill_video_request(&request, media_file_hydration_map.as_ref(), execution_provider)?;
info!("distilled plan: {:?}", distilled.plan);
// ==================== BILLING ==================== //
let cost = distilled.cost.cost_in_credits.unwrap_or(0);
@@ -174,14 +186,16 @@ pub async fn omni_gen_video_generate_handler(
let apriori_job_token = InferenceJobToken::generate();
if cost > 0 {
attempt_wallet_deduction_else_common_web_error(
let maybe_deduction_result = if cost > 0 {
Some(attempt_wallet_deduction_else_common_web_error(
user_token,
Some(apriori_job_token.as_str()),
cost,
&mut mysql_connection,
).await?;
}
).await?)
} else {
None
};
// ==================== EXECUTE GENERATION ==================== //
@@ -190,6 +204,9 @@ pub async fn omni_gen_video_generate_handler(
&request,
&server_state,
media_file_hydration_map.as_ref(),
kinovi_character_ids,
maybe_deduction_result.as_ref().map(|d| &d.ledger_entry_token),
&mut mysql_connection,
).await?;
// ==================== DB TRANSACTION ==================== //
@@ -284,6 +301,9 @@ pub async fn omni_gen_video_generate_handler(
// -- Inference job --
let maybe_ledger_entry_token = maybe_deduction_result.as_ref()
.map(|d| &d.ledger_entry_token);
let job_token = if gen_result.is_seedance2pro {
// Seedance2Pro path: insert one job per order_id (batch support).
let fallback_ids = vec![gen_result.external_job_id.clone()];
@@ -313,7 +333,7 @@ pub async fn omni_gen_video_generate_handler(
maybe_external_third_party_id: order_id,
maybe_inference_args: None,
maybe_prompt_token: prompt_token.as_ref(),
maybe_wallet_ledger_entry_token: None,
maybe_wallet_ledger_entry_token: maybe_ledger_entry_token,
maybe_creator_user_token: Some(user_token),
maybe_avt_token: maybe_avt_token.as_ref(),
creator_ip_address: &ip_address,
@@ -340,6 +360,7 @@ pub async fn omni_gen_video_generate_handler(
})?
} else {
// Fal / other providers path.
// TODO: Pass maybe_wallet_ledger_entry_token to fal jobs once the fal insert supports it.
let db_result = insert_generic_inference_job_for_fal_queue_with_apriori_job_token(
InsertGenericInferenceForFalWithAprioriJobTokenArgs {
apriori_job_token: &apriori_job_token,
@@ -0,0 +1 @@
pub mod resolve_kinovi_character_ids;
@@ -0,0 +1,47 @@
//! Resolve character tokens to Kinovi character IDs for prompting.
use log::warn;
use sqlx::MySql;
use mysql_queries::queries::characters::batch_lookup_characters_by_token_for_prompting::batch_lookup_characters_by_token_for_prompting;
use tokens::tokens::characters::CharacterToken;
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
/// Resolve character tokens to Kinovi character IDs for prompting.
///
/// Looks up the characters, filters to active ones with kinovi IDs, and warns about
/// any that are missing or inactive (but doesn't fail the request).
pub async fn resolve_kinovi_character_ids(
maybe_tokens: Option<&[CharacterToken]>,
connection: &mut sqlx::pool::PoolConnection<MySql>,
) -> Result<Option<Vec<String>>, AdvancedCommonWebError> {
let tokens = match maybe_tokens {
None => return Ok(None),
Some(tokens) if tokens.is_empty() => return Ok(None),
Some(tokens) => tokens,
};
let characters = batch_lookup_characters_by_token_for_prompting(tokens, connection)
.await?;
if characters.len() != tokens.len() {
warn!(
"Not all character tokens were found: requested {}, found {}",
tokens.len(), characters.len(),
);
}
for character in &characters {
if !character.is_active {
warn!("Character {} is not yet active, skipping", character.token);
}
}
let ids: Vec<String> = characters.iter()
.filter(|c| c.is_active)
.filter_map(|c| c.kinovi_character_id.clone())
.collect();
if ids.is_empty() { Ok(None) } else { Ok(Some(ids)) }
}
@@ -244,11 +244,11 @@ mod tests {
fn text_only_has_no_media_urls() {
let d = distill_text(&text_only_request(Some(5), None));
if let VideoGenerationPlan::Seedance2proSeedance2p0(plan) = d.plan() {
assert!(plan.start_frame_url.is_none());
assert!(plan.end_frame_url.is_none());
assert!(plan.reference_image_urls.is_none());
assert!(plan.reference_video_urls.is_none());
assert!(plan.reference_audio_urls.is_none());
assert!(plan.request.start_frame_url.is_none());
assert!(plan.request.end_frame_url.is_none());
assert!(plan.request.reference_image_urls.is_none());
assert!(plan.request.reference_video_urls.is_none());
assert!(plan.request.reference_audio_urls.is_none());
} else {
panic!("wrong plan variant");
}
@@ -258,7 +258,7 @@ mod tests {
fn text_only_prompt_is_passed_through() {
let d = distill_text(&text_only_request(Some(5), None));
if let VideoGenerationPlan::Seedance2proSeedance2p0(plan) = d.plan() {
assert_eq!(plan.prompt, Some("a cat dancing".to_string()));
assert_eq!(plan.request.prompt, "a cat dancing");
} else {
panic!("wrong plan variant");
}
@@ -276,7 +276,7 @@ mod tests {
let d = distill_with_map(&req, &map);
if let VideoGenerationPlan::Seedance2proSeedance2p0(plan) = d.plan() {
let url = plan.start_frame_url.as_ref().expect("start_frame_url should be set");
let url = plan.request.start_frame_url.as_ref().expect("start_frame_url should be set");
assert!(url.starts_with("https://"), "expected URL, got: {}", url);
assert!(!url.contains("m_"), "URL should not look like a media token");
} else {
@@ -296,7 +296,7 @@ mod tests {
let d = distill_with_map(&req, &map);
if let VideoGenerationPlan::Seedance2proSeedance2p0(plan) = d.plan() {
let url = plan.end_frame_url.as_ref().expect("end_frame_url should be set");
let url = plan.request.end_frame_url.as_ref().expect("end_frame_url should be set");
assert!(url.starts_with("https://"), "expected URL, got: {}", url);
} else {
panic!("wrong plan variant");
@@ -316,8 +316,8 @@ mod tests {
let d = distill_with_map(&req, &map);
if let VideoGenerationPlan::Seedance2proSeedance2p0(plan) = d.plan() {
assert!(plan.start_frame_url.is_some());
assert!(plan.end_frame_url.is_some());
assert!(plan.request.start_frame_url.is_some());
assert!(plan.request.end_frame_url.is_some());
} else {
panic!("wrong plan variant");
}
@@ -338,7 +338,7 @@ mod tests {
let d = distill_with_map(&req, &map);
if let VideoGenerationPlan::Seedance2proSeedance2p0(plan) = d.plan() {
let urls = plan.reference_image_urls.as_ref().expect("reference_image_urls should be set");
let urls = plan.request.reference_image_urls.as_ref().expect("reference_image_urls should be set");
assert_eq!(urls.len(), 2);
for url in urls {
assert!(url.starts_with("https://"), "expected URL, got: {}", url);
@@ -362,7 +362,7 @@ mod tests {
let d = distill_with_map(&req, &map);
if let VideoGenerationPlan::Seedance2proSeedance2p0(plan) = d.plan() {
let urls = plan.reference_video_urls.as_ref().expect("reference_video_urls should be set");
let urls = plan.request.reference_video_urls.as_ref().expect("reference_video_urls should be set");
assert_eq!(urls.len(), 1);
assert!(urls[0].starts_with("https://"));
} else {
@@ -383,7 +383,7 @@ mod tests {
let d = distill_with_map(&req, &map);
if let VideoGenerationPlan::Seedance2proSeedance2p0(plan) = d.plan() {
let urls = plan.reference_audio_urls.as_ref().expect("reference_audio_urls should be set");
let urls = plan.request.reference_audio_urls.as_ref().expect("reference_audio_urls should be set");
assert_eq!(urls.len(), 1);
assert!(urls[0].starts_with("https://"));
} else {
@@ -410,12 +410,12 @@ mod tests {
let d = distill_with_map(&req, &map);
if let VideoGenerationPlan::Seedance2proSeedance2p0(plan) = d.plan() {
assert!(plan.start_frame_url.is_some());
assert!(plan.end_frame_url.is_some());
assert_eq!(plan.reference_image_urls.as_ref().map(|v| v.len()), Some(1));
assert_eq!(plan.reference_video_urls.as_ref().map(|v| v.len()), Some(1));
assert_eq!(plan.reference_audio_urls.as_ref().map(|v| v.len()), Some(1));
assert!(plan.prompt.as_deref() == Some("full test"));
assert!(plan.request.start_frame_url.is_some());
assert!(plan.request.end_frame_url.is_some());
assert_eq!(plan.request.reference_image_urls.as_ref().map(|v| v.len()), Some(1));
assert_eq!(plan.request.reference_video_urls.as_ref().map(|v| v.len()), Some(1));
assert_eq!(plan.request.reference_audio_urls.as_ref().map(|v| v.len()), Some(1));
assert_eq!(plan.request.prompt, "full test");
} else {
panic!("wrong plan variant");
}
@@ -426,7 +426,7 @@ mod tests {
// Duration below 4 should clamp to 4.
let d = distill_text(&text_only_request(Some(1), None));
if let VideoGenerationPlan::Seedance2proSeedance2p0(plan) = d.plan() {
assert!(plan.duration_seconds >= 4, "expected >= 4, got {}", plan.duration_seconds);
assert!(plan.request.duration_seconds >= 4, "expected >= 4, got {}", plan.request.duration_seconds);
} else {
panic!("wrong plan variant");
}
@@ -434,7 +434,7 @@ mod tests {
// Duration above 15 should clamp to 15.
let d = distill_text(&text_only_request(Some(99), None));
if let VideoGenerationPlan::Seedance2proSeedance2p0(plan) = d.plan() {
assert!(plan.duration_seconds <= 15, "expected <= 15, got {}", plan.duration_seconds);
assert!(plan.request.duration_seconds <= 15, "expected <= 15, got {}", plan.request.duration_seconds);
} else {
panic!("wrong plan variant");
}
@@ -449,8 +449,7 @@ mod tests {
let d = distill_text(&req);
if let VideoGenerationPlan::Seedance2proSeedance2p0(plan) = d.plan() {
// Prompt should be None or empty string.
let prompt = plan.prompt.as_deref().unwrap_or("");
assert!(prompt.is_empty() || plan.prompt.is_none());
assert!(plan.request.prompt.is_empty());
} else {
panic!("wrong plan variant");
}
@@ -460,92 +459,92 @@ mod tests {
#[test]
fn aspect_ratio_wide_16x9() {
use seedance2pro_client::requests::generate_video::generate_video::KinoviResolution;
use seedance2pro_client::requests::generate_video::generate_video::KinoviAspectRatio;
let req = make_request(
Some("test"), Some(CommonAspectRatio::WideSixteenByNine), Some(5), None,
None, None, None, None, None,
);
let d = distill_text(&req);
if let VideoGenerationPlan::Seedance2proSeedance2p0(plan) = d.plan() {
assert!(matches!(plan.resolution, KinoviResolution::Landscape16x9));
assert!(matches!(plan.request.aspect_ratio, KinoviAspectRatio::Landscape16x9));
} else { panic!("wrong plan variant"); }
}
#[test]
fn aspect_ratio_tall_9x16() {
use seedance2pro_client::requests::generate_video::generate_video::KinoviResolution;
use seedance2pro_client::requests::generate_video::generate_video::KinoviAspectRatio;
let req = make_request(
Some("test"), Some(CommonAspectRatio::TallNineBySixteen), Some(5), None,
None, None, None, None, None,
);
let d = distill_text(&req);
if let VideoGenerationPlan::Seedance2proSeedance2p0(plan) = d.plan() {
assert!(matches!(plan.resolution, KinoviResolution::Portrait9x16));
assert!(matches!(plan.request.aspect_ratio, KinoviAspectRatio::Portrait9x16));
} else { panic!("wrong plan variant"); }
}
#[test]
fn aspect_ratio_square() {
use seedance2pro_client::requests::generate_video::generate_video::KinoviResolution;
use seedance2pro_client::requests::generate_video::generate_video::KinoviAspectRatio;
let req = make_request(
Some("test"), Some(CommonAspectRatio::Square), Some(5), None,
None, None, None, None, None,
);
let d = distill_text(&req);
if let VideoGenerationPlan::Seedance2proSeedance2p0(plan) = d.plan() {
assert!(matches!(plan.resolution, KinoviResolution::Square1x1));
assert!(matches!(plan.request.aspect_ratio, KinoviAspectRatio::Square1x1));
} else { panic!("wrong plan variant"); }
}
#[test]
fn aspect_ratio_4x3() {
use seedance2pro_client::requests::generate_video::generate_video::KinoviResolution;
use seedance2pro_client::requests::generate_video::generate_video::KinoviAspectRatio;
let req = make_request(
Some("test"), Some(CommonAspectRatio::WideFourByThree), Some(5), None,
None, None, None, None, None,
);
let d = distill_text(&req);
if let VideoGenerationPlan::Seedance2proSeedance2p0(plan) = d.plan() {
assert!(matches!(plan.resolution, KinoviResolution::Standard4x3));
assert!(matches!(plan.request.aspect_ratio, KinoviAspectRatio::Standard4x3));
} else { panic!("wrong plan variant"); }
}
#[test]
fn aspect_ratio_3x4() {
use seedance2pro_client::requests::generate_video::generate_video::KinoviResolution;
use seedance2pro_client::requests::generate_video::generate_video::KinoviAspectRatio;
let req = make_request(
Some("test"), Some(CommonAspectRatio::TallThreeByFour), Some(5), None,
None, None, None, None, None,
);
let d = distill_text(&req);
if let VideoGenerationPlan::Seedance2proSeedance2p0(plan) = d.plan() {
assert!(matches!(plan.resolution, KinoviResolution::Portrait3x4));
assert!(matches!(plan.request.aspect_ratio, KinoviAspectRatio::Portrait3x4));
} else { panic!("wrong plan variant"); }
}
#[test]
fn aspect_ratio_none_defaults_to_landscape() {
use seedance2pro_client::requests::generate_video::generate_video::KinoviResolution;
use seedance2pro_client::requests::generate_video::generate_video::KinoviAspectRatio;
let req = make_request(
Some("test"), None, Some(5), None,
None, None, None, None, None,
);
let d = distill_text(&req);
if let VideoGenerationPlan::Seedance2proSeedance2p0(plan) = d.plan() {
assert!(matches!(plan.resolution, KinoviResolution::Landscape16x9));
assert!(matches!(plan.request.aspect_ratio, KinoviAspectRatio::Landscape16x9));
} else { panic!("wrong plan variant"); }
}
#[test]
fn aspect_ratio_auto_defaults_to_landscape() {
use seedance2pro_client::requests::generate_video::generate_video::KinoviResolution;
use seedance2pro_client::requests::generate_video::generate_video::KinoviAspectRatio;
let req = make_request(
Some("test"), Some(CommonAspectRatio::Auto), Some(5), None,
None, None, None, None, None,
);
let d = distill_text(&req);
if let VideoGenerationPlan::Seedance2proSeedance2p0(plan) = d.plan() {
assert!(matches!(plan.resolution, KinoviResolution::Landscape16x9));
assert!(matches!(plan.request.aspect_ratio, KinoviAspectRatio::Landscape16x9));
} else { panic!("wrong plan variant"); }
}
}
@@ -473,6 +473,8 @@ function formatTitleParts(job: Job) {
kind = "Video";
} else if (taskTypeStr.includes("image")) {
kind = "Image";
} else if (taskTypeStr.includes("character")) {
kind = "Character";
}
const modelDisplay = modelTypeStr
@@ -631,6 +633,10 @@ export const TaskQueue = () => {
const [lightboxCdnUrl, setLightboxCdnUrl] = useState<string | undefined>();
const prevCompletedIdsRef = useRef<Set<string>>(new Set());
const prevFailedIdsRef = useRef<Set<string>>(new Set());
// On first load we seed the "seen" sets with whatever is already on the
// server so we don't blast the user with toasts for jobs completed during
// a previous session.
const initialLoadDoneRef = useRef(false);
const [confirmationConfig, setConfirmationConfig] = useState<{
isOpen: boolean;
@@ -752,40 +758,46 @@ export const TaskQueue = () => {
// Track newly completed IDs
const newCompletedIdSet = new Set(done.map((d) => d.id));
const newlyCompleted = done.filter(
(d) => !prevCompletedIdsRef.current.has(d.id),
);
prevCompletedIdsRef.current = newCompletedIdSet;
if (newlyCompleted.length > 0) {
// Show toasts for newly completed jobs
for (const task of newlyCompleted) {
showToast(
"success",
`${task.title} complete${task.subtitle ? ` — ${task.subtitle}` : ""}`,
);
}
if (!isPopoverOpen) {
setUnreadCompletedIds((prev) =>
Array.from(
new Set([...(prev ?? []), ...newlyCompleted.map((d) => d.id)]),
),
);
}
}
// Track newly failed IDs and show toasts
const newFailedIdSet = new Set(failedTasks.map((f) => f.id));
const newlyFailed = failedTasks.filter(
(f) => !prevFailedIdsRef.current.has(f.id),
);
prevFailedIdsRef.current = newFailedIdSet;
for (const task of newlyFailed) {
showToast(
"error",
`${task.title} failed${task.failureReason ? ` — ${task.failureReason}` : ""}`,
if (!initialLoadDoneRef.current) {
// First load: seed the "seen" sets without toasting.
prevCompletedIdsRef.current = newCompletedIdSet;
prevFailedIdsRef.current = newFailedIdSet;
initialLoadDoneRef.current = true;
} else {
const newlyCompleted = done.filter(
(d) => !prevCompletedIdsRef.current.has(d.id),
);
prevCompletedIdsRef.current = newCompletedIdSet;
if (newlyCompleted.length > 0) {
for (const task of newlyCompleted) {
showToast("success", `${task.title} creation complete`);
}
if (!isPopoverOpen) {
setUnreadCompletedIds((prev) =>
Array.from(
new Set([
...(prev ?? []),
...newlyCompleted.map((d) => d.id),
]),
),
);
}
}
const newlyFailed = failedTasks.filter(
(f) => !prevFailedIdsRef.current.has(f.id),
);
prevFailedIdsRef.current = newFailedIdSet;
for (const task of newlyFailed) {
showToast(
"error",
`${task.title} creation failed${task.failureReason ? ` — ${task.failureReason}` : ""}`,
);
}
}
} catch {
// ignore
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faCheck,
faCircleExclamation,
faExclamation,
faXmark,
} from "@fortawesome/pro-solid-svg-icons";
@@ -27,11 +27,7 @@ interface ToastDetail {
duration?: number;
}
export function showToast(
type: ToastType,
message: string,
duration?: number,
) {
export function showToast(type: ToastType, message: string, duration?: number) {
window.dispatchEvent(
new CustomEvent<ToastDetail>(TOAST_EVENT, {
detail: { type, message, duration },
@@ -57,8 +53,7 @@ export const ToastContainer = () => {
setToasts((prev) => {
if (
prev.some(
(t) =>
t.message === message && now - t.createdAt < DEDUP_WINDOW,
(t) => t.message === message && now - t.createdAt < DEDUP_WINDOW,
)
)
return prev;
@@ -86,23 +81,25 @@ export const ToastContainer = () => {
if (toasts.length === 0) return null;
return (
<div className="fixed top-16 right-6 z-[100] flex flex-col items-end gap-2">
<div className="fixed sm:top-16 top-14 left-1/2 -translate-x-1/2 sm:left-auto sm:translate-x-0 sm:right-5 z-[100] flex flex-col items-center sm:items-end gap-2 px-4 sm:px-0 max-w-[calc(100vw-1rem)]">
{toasts.map((toast) => (
<div
key={toast.id}
className={`flex items-center gap-2.5 rounded-lg border px-4 py-2.5 text-sm shadow-lg backdrop-blur-xl animate-toast-in ${
toast.type === "success"
? "border-green-500/30 bg-green-500/15 text-green-200"
: "border-red-500/30 bg-red-500/15 text-red-200"
}`}
className="glass flex items-center gap-3 rounded-xl px-4 py-2.5 text-sm text-white animate-toast-in"
>
<FontAwesomeIcon
icon={toast.type === "success" ? faCheck : faCircleExclamation}
className={
toast.type === "success" ? "text-green-400" : "text-red-400"
}
/>
<span>{toast.message}</span>
<div
className={`flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full ${
toast.type === "success" ? "bg-green-500" : "bg-red-500"
}`}
>
<FontAwesomeIcon
icon={toast.type === "success" ? faCheck : faExclamation}
className="h-3 w-3 text-white"
/>
</div>
<span className="flex-1 text-white/90 whitespace-nowrap overflow-hidden text-ellipsis">
{toast.message}
</span>
<button
onClick={() => dismiss(toast.id)}
className="ml-1 text-white/40 transition-colors hover:text-white/80"
@@ -1,15 +1,14 @@
//const WINDOWS_VERSION = "0.7.0"; // 2026-02-23 Seedance
//const WINDOWS_VERSION = "0.11.0"; // 2026-03-02 Credits accounting
//const WINDOWS_VERSION = "0.14.0"; // 2026-03-10 WorldLabs native, Angles
//const WINDOWS_VERSION = "0.20.0"; // 2026-03-24 Reprompt, performance
const WINDOWS_VERSION = "0.23.0"; // 2026-04-03 characters + prompt box
const WINDOWS_VERSION = "0.28.0"; // 2026-04-03 characters + prompt box
//const MAC_VERSION = "0.7.0"; // 2026-02-23 Seedance
//const MAC_VERSION = "0.11.0"; // 2026-03-02 Credits accounting
//const MAC_VERSION = "0.14.0"; // 2026-03-10 WorldLabs native, Angles
//const MAC_VERSION = "0.20.0"; // 2026-03-24 Reprompt, performance
const MAC_VERSION = "0.23.0"; // 2026-04-03 characters + prompt box
const MAC_VERSION = "0.28.0"; // 2026-04-03 characters + prompt box
export const DOWNLOAD_LINKS = {
WINDOWS: `https://github.com/storytold/artcraft/releases/download/artcraft-v${WINDOWS_VERSION}/ArtCraft_${WINDOWS_VERSION}_x64-setup.exe`,
+5 -1
View File
@@ -14,7 +14,11 @@ const root = ReactDOM.createRoot(
// In development, route API through the Vite dev server origin to avoid CORS
if (import.meta.env.DEV) {
try {
StorytellerApiHostStore.getInstance().setDevelopment();
StorytellerApiHostStore.getInstance().setApiSchemeAndHost(
window.location.origin,
);
// NB: This is for Brandon to test with storyteller-web locally:
//StorytellerApiHostStore.getInstance().setDevelopment();
} catch (e) {
console.warn("Failed to set dev API host override", e);
}
@@ -153,9 +153,7 @@ function resolveDurationForModel(
}
if (model.duration_seconds_options?.length) {
if (model.duration_seconds_options.includes(current)) return current;
return (
model.duration_seconds_default ?? model.duration_seconds_options[0]!
);
return model.duration_seconds_default ?? model.duration_seconds_options[0]!;
}
return model.duration_seconds_default ?? null;
}
@@ -310,9 +308,12 @@ export default function CreateVideo() {
generateAudio: hasSound ? generateWithSound : undefined,
});
// Characters are only supported for seedance_2p0
const isSeedance2p0 = selectedModel?.model === "seedance_2p0";
const activeCharacters = isSeedance2p0 ? storedCharacters : [];
// Characters are supported for Seedance 2.0 and Seedance 2.0 Fast, in
// both keyframe and reference input modes.
const supportsCharacters =
selectedModel?.model === "seedance_2p0" ||
selectedModel?.model === "seedance_2p0_fast";
const activeCharacters = supportsCharacters ? storedCharacters : [];
// Popover items
const mentionItems = useMemo((): MentionItem[] => {
@@ -475,38 +476,49 @@ export default function CreateVideo() {
(item: PopoverItem) => {
const model = item.action ? _modelLookup.get(item.action) : undefined;
if (!model) return;
const currentDuration = useCreateVideoStore.getState().ui.duration;
const nextDuration = resolveDurationForModel(model, currentDuration);
const currentState = useCreateVideoStore.getState().ui;
const nextDuration = resolveDurationForModel(
model,
currentState.duration,
);
const newSupportsKeyframe =
!!model.starting_keyframe_supported ||
!!model.starting_keyframe_required;
const newSupportsRefs =
!!model.image_references_supported ||
!!model.video_references_supported ||
!!model.audio_references_supported;
const nextInputMode =
currentState.inputMode === "reference" && newSupportsRefs
? "reference"
: "keyframe";
setUi({
selectedModelId: model.model,
selectedSize: model.aspect_ratio_default ?? "wide_sixteen_by_nine",
duration: nextDuration,
resolution: model.resolution_default ?? null,
generateWithSound: false,
inputMode: "keyframe",
inputMode: nextInputMode,
numVideos: Math.min(
model.batch_size_max ?? 4,
model.batch_size_default ?? 1,
),
});
// Only clear media that the new model doesn't support
const newSupportsKeyframe =
!!model.starting_keyframe_supported ||
!!model.starting_keyframe_required;
if (!newSupportsKeyframe) {
// Only clear media that the new model can't use in any mode.
if (!newSupportsKeyframe && !model.image_references_supported) {
setReferenceImages([]);
}
if (!model.ending_keyframe_supported) {
setEndFrameImage(undefined);
}
const newSupportsRefs =
!!model.image_references_supported ||
!!model.video_references_supported ||
!!model.audio_references_supported;
if (!newSupportsRefs) {
if (!model.video_references_supported) {
setReferenceVideos([]);
}
if (!model.audio_references_supported) {
setReferenceAudios([]);
}
},
@@ -601,10 +613,23 @@ export default function CreateVideo() {
? referenceAudios.map((a) => a.mediaToken).filter((t) => t.length > 0)
: undefined;
// Extract character tokens from @-mentions in prompt
const mentionedCharacters = activeCharacters.filter((c) =>
prompt.includes(`@${c.name}`),
);
// Extract character tokens from @-mentions in the prompt. Match longest
// names first and require a non-word boundary after so `@Bob` doesn't
// false-match inside `@Bobby`, and only pick up characters that still
// exist in the current store (stale names are ignored).
const mentionedCharacters = (() => {
if (activeCharacters.length === 0) return [];
const sorted = [...activeCharacters].sort(
(a, b) => b.name.length - a.name.length,
);
const matched = new Set<string>();
for (const c of sorted) {
const escaped = c.name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const regex = new RegExp(`@${escaped}(?![\\w])`);
if (regex.test(prompt)) matched.add(c.character_token);
}
return activeCharacters.filter((c) => matched.has(c.character_token));
})();
const referenceCharacterTokens =
mentionedCharacters.length > 0
? mentionedCharacters.map((c) => c.character_token)
@@ -615,8 +640,7 @@ export default function CreateVideo() {
model: selectedModel.model,
numVideos: 1,
aspectRatio: selectedSize,
duration:
duration ?? selectedModel.duration_seconds_default ?? undefined,
duration: duration ?? selectedModel.duration_seconds_default ?? undefined,
resolution: hasResolutionOptions
? (resolution ?? selectedModel.resolution_default ?? undefined)
: undefined,
@@ -786,7 +810,12 @@ export default function CreateVideo() {
hasEndFrame ? () => setIsEndFramePickerOpen(true) : undefined
}
modelSelector={
<Tooltip content="Model" position="top" className="z-50" closeOnClick>
<Tooltip
content="Model"
position="top"
className="z-50"
closeOnClick
>
<PopoverMenu
items={modelItems}
onSelect={handleModelChange}
@@ -954,7 +983,7 @@ export default function CreateVideo() {
/>
</Tooltip>
)}
{isSeedance2p0 && (
{supportsCharacters && (
<button
type="button"
onClick={() => setIsCharactersModalOpen(true)}
@@ -1,19 +1,21 @@
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import {
GENERATE_APPS,
EDIT_APPS,
useGenerateApps,
useEditApps,
getBadgeStyles,
goToApp,
} from "~/config/appMenu";
export const AppsQuickMenu = () => {
const generateApps = useGenerateApps();
const editApps = useEditApps();
return (
<div className="grid w-[680px] grid-cols-2 gap-3">
<div>
<h3 className="mb-2 px-2 text-xs font-semibold opacity-50">Generate</h3>
<div className="space-y-0.5">
{GENERATE_APPS.map((app) => (
{generateApps.map((app) => (
<button
key={app.id}
onClick={() => goToApp(app.action)}
@@ -61,7 +63,7 @@ export const AppsQuickMenu = () => {
<div>
<h3 className="mb-2 px-2 text-xs font-semibold opacity-50">Edit</h3>
<div className="space-y-0.5">
{EDIT_APPS.map((app) => (
{editApps.map((app) => (
<button
key={app.id}
onClick={() => goToApp(app.action)}
@@ -58,7 +58,8 @@ import { SettingsModal } from "@storyteller/ui-settings-modal";
import { Tooltip } from "@storyteller/ui-tooltip";
import { useEffect, useRef, useState } from "react";
import toast from "react-hot-toast";
import { APP_DESCRIPTORS } from "~/config/appMenu";
import { APP_DESCRIPTORS, goToApp } from "~/config/appMenu";
import { useStoryboardStore } from "~/pages/PageStoryboard";
import { useSceneStore } from "@storyteller/ui-pagedraw";
import {
is3DEditorInitialized,
@@ -765,6 +766,10 @@ export const TopBar = ({ pageName }: Props) => {
setIsSettingsModalOpen(false);
setLogoutStates();
}}
onStoryboardPageDisable={() => {
useStoryboardStore.getState().reset();
goToApp("IMAGE");
}}
initialSection={settingsSection}
/>
@@ -11,6 +11,11 @@ import {
faPenNib,
faCrosshairs,
} from "@fortawesome/pro-solid-svg-icons";
import { useMemo } from "react";
import {
useExperimentalStore,
useStoryboardPageEnabled,
} from "@storyteller/ui-settings-modal";
import { useTabStore, TabId } from "~/pages/Stores/TabState";
import { set3DPageMounted } from "~/pages/PageEnigma/Editor/editor";
@@ -26,7 +31,8 @@ export type AppId =
| "IMAGE_TO_3D_OBJECT"
| "IMAGE_TO_3D_WORLD"
| "REMOVE_BACKGROUND"
| "ANGLES";
| "ANGLES"
| "STORYBOARD";
export interface AppDescriptor {
id: AppId;
@@ -170,6 +176,16 @@ export const ALL_APPS: FullAppItem[] = [
badge: "NEW",
},
{
id: "storyboard",
label: "Storyboard",
description: "Plan your shots with a visual storyboard",
icon: faPhotoFilm,
category: "generate",
action: "STORYBOARD",
color: "bg-fuchsia-600/40",
badge: "NEW",
},
{
id: "2d-canvas",
label: "Image Editor",
@@ -195,6 +211,33 @@ export const GENERATE_APPS = ALL_APPS.filter(
);
export const EDIT_APPS = ALL_APPS.filter((app) => app.category === "edit");
export const useVisibleApps = (): FullAppItem[] => {
const storyboardEnabled = useStoryboardPageEnabled();
return useMemo(
() =>
ALL_APPS.filter((app) =>
app.action === "STORYBOARD" ? storyboardEnabled : true,
),
[storyboardEnabled],
);
};
export const useGenerateApps = (): FullAppItem[] => {
const visible = useVisibleApps();
return useMemo(
() => visible.filter((app) => app.category === "generate"),
[visible],
);
};
export const useEditApps = (): FullAppItem[] => {
const visible = useVisibleApps();
return useMemo(
() => visible.filter((app) => app.category === "edit"),
[visible],
);
};
export const getBadgeStyles = (badge?: string) => {
switch (badge) {
case "NEW":
@@ -223,8 +266,14 @@ export const goToApp = (action?: string) => {
"IMAGE_TO_3D_WORLD",
"REMOVE_BACKGROUND",
"ANGLES",
"STORYBOARD",
].includes(action)
) {
if (action === "STORYBOARD") {
const { enabled, storyboardPageEnabled } =
useExperimentalStore.getState();
if (!enabled || !storyboardPageEnabled) return;
}
if (action === "3D") {
set3DPageMounted(true);
} else {
@@ -2,16 +2,18 @@ import { faGrid2 } from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import {
GENERATE_APPS,
EDIT_APPS,
useGenerateApps,
useEditApps,
getBadgeStyles,
goToApp,
} from "~/config/appMenu";
export const AppsIndexPage = () => {
const generateApps = useGenerateApps();
const editApps = useEditApps();
const categories = [
{ title: "Generate", apps: GENERATE_APPS },
{ title: "Edit", apps: EDIT_APPS },
{ title: "Generate", apps: generateApps },
{ title: "Edit", apps: editApps },
];
return (
@@ -18,6 +18,8 @@ import { ImageTo3DObject } from "../PageImageTo3DObject";
import { ImageTo3DWorld } from "../PageImageTo3DWorld";
import { RemoveBackground } from "../PageRemoveBackground";
import { Angles } from "../PageAngles";
import { Storyboard } from "../PageStoryboard";
import { useStoryboardPageEnabled } from "@storyteller/ui-settings-modal";
import {
timelineHeight,
@@ -108,6 +110,7 @@ export const PageEditor = () => {
}, [status.value, triggerRecheck]);
const tabStore = useTabStore();
const storyboardPageEnabled = useStoryboardPageEnabled();
const handleOverlayClick = (event: React.MouseEvent<HTMLDivElement>) => {
event.stopPropagation();
};
@@ -643,6 +646,11 @@ export const PageEditor = () => {
<Angles />
</div>
)}
{tabStore.activeTabId == "STORYBOARD" && storyboardPageEnabled && (
<div>
<Storyboard />
</div>
)}
</div>
);
};
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,85 @@
import { create } from "zustand";
import { v4 as uuidv4 } from "uuid";
export interface Board {
id: string;
imageDataUrl: string | null;
title: string;
shotNumber: number;
dialogue: string;
action: string;
notes: string;
duration: number;
}
interface StoryboardState {
boards: Board[];
selectedBoardId: string | null;
addBoard: () => void;
deleteBoard: (id: string) => void;
selectBoard: (id: string) => void;
updateBoard: (id: string, patch: Partial<Omit<Board, "id" | "shotNumber">>) => void;
reorderBoards: (fromIndex: number, toIndex: number) => void;
reset: () => void;
}
const syncShotNumbers = (boards: Board[]): Board[] =>
boards.map((b, i) => ({ ...b, shotNumber: i + 1 }));
export const useStoryboardStore = create<StoryboardState>((set, get) => ({
boards: [],
selectedBoardId: null,
addBoard: () => {
const newBoard: Board = {
id: uuidv4(),
imageDataUrl: null,
title: "",
shotNumber: get().boards.length + 1,
dialogue: "",
action: "",
notes: "",
duration: 3,
};
set((state) => ({
boards: [...state.boards, newBoard],
selectedBoardId: newBoard.id,
}));
},
deleteBoard: (id) => {
set((state) => {
const idx = state.boards.findIndex((b) => b.id === id);
const remaining = syncShotNumbers(state.boards.filter((b) => b.id !== id));
const nextSelected = remaining[idx] ?? remaining[idx - 1] ?? null;
return {
boards: remaining,
selectedBoardId: nextSelected?.id ?? null,
};
});
},
selectBoard: (id) => {
set({ selectedBoardId: id });
},
updateBoard: (id, patch) => {
set((state) => ({
boards: state.boards.map((b) => (b.id === id ? { ...b, ...patch } : b)),
}));
},
reorderBoards: (fromIndex, toIndex) => {
set((state) => {
const arr = [...state.boards];
const [moved] = arr.splice(fromIndex, 1);
arr.splice(toIndex, 0, moved);
return { boards: syncShotNumbers(arr) };
});
},
reset: () => {
set({ boards: [], selectedBoardId: null });
},
}));
@@ -0,0 +1,2 @@
export { Storyboard } from "./Storyboard";
export { useStoryboardStore } from "./StoryboardStore";
@@ -14,7 +14,8 @@ export type TabId =
| "IMAGE_TO_3D_OBJECT"
| "IMAGE_TO_3D_WORLD"
| "REMOVE_BACKGROUND"
| "ANGLES";
| "ANGLES"
| "STORYBOARD";
const DEFAULT_TAB: TabId = "IMAGE";
@@ -684,7 +684,7 @@ export const MentionTextarea = forwardRef<HTMLDivElement, MentionTextareaProps>(
onBlur={onBlur}
className={twMerge(
className,
"outline-none whitespace-pre-wrap break-words overflow-y-auto",
"outline-none whitespace-pre-wrap [overflow-wrap:anywhere] overflow-y-auto",
)}
/>
@@ -14,7 +14,6 @@ import {
import {
faWaveformLines,
faClock,
faTriangleExclamation,
faChevronDown,
faChevronUp,
} from "@fortawesome/pro-solid-svg-icons";
@@ -169,15 +168,27 @@ export const PromptBoxVideo = ({
.finally(() => storeSetLoaded(true));
}, [charactersLoaded, storeSetCharacters, storeSetLoaded]);
// CSS viewport units handle resize reactivity automatically
const EXPANDED_HEIGHT = "clamp(120px, calc(100vh - 700px), 500px)";
// Reserves room for the textarea's inline action-buttons row plus the fixed
// Model / Costs / Help row at the bottom of the page.
const BOTTOM_SAFE_AREA_PX = 160;
const computeAvailableEditorHeight = (el: HTMLElement): number => {
const topFromViewport = el.getBoundingClientRect().top;
return Math.max(
88,
Math.floor(window.innerHeight - topFromViewport - BOTTOM_SAFE_AREA_PX),
);
};
const toggleExpand = () => {
setIsExpanded((prev) => {
const next = !prev;
const el = (mentionEditorRef.current ?? textareaRef.current) as HTMLElement | null;
const el = (mentionEditorRef.current ??
textareaRef.current) as HTMLElement | null;
if (el) {
el.style.height = next ? EXPANDED_HEIGHT : "auto";
el.style.height = next
? `${computeAvailableEditorHeight(el)}px`
: "auto";
}
return next;
});
@@ -232,12 +243,52 @@ export const PromptBoxVideo = ({
const textareaRef = useRef<HTMLTextAreaElement>(null);
const mentionEditorRef = useRef<HTMLDivElement>(null);
// Apply height constraints to whichever editor element is active
// Held in a ref so the window.resize listener (installed once) always
// invokes the latest closure — picks up current `isExpanded`, refs, etc.
const applyHeightsRef = useRef<() => void>(() => {});
applyHeightsRef.current = () => {
const el = (mentionEditorRef.current ??
textareaRef.current) as HTMLElement | null;
if (!el) return;
const available = computeAvailableEditorHeight(el);
const maxH = isExpanded ? available : Math.min(available, 500);
el.style.maxHeight = `${maxH}px`;
el.style.minHeight = "0";
if (!isExpanded) {
const capped = Math.min(el.scrollHeight, 88);
el.style.minHeight = `${capped}px`;
}
};
// Re-apply on viewport changes (window resize, windowed↔fullscreen,
// monitor moves). Fires immediately + after the parent's react-spring
// `top: vh/2` tween settles so getBoundingClientRect reads the final
// position, not a mid-animation one.
useEffect(() => {
const el = (mentionEditorRef.current ?? textareaRef.current) as HTMLElement | null;
let settledId: number | undefined;
const onResize = () => {
applyHeightsRef.current();
if (settledId !== undefined) window.clearTimeout(settledId);
settledId = window.setTimeout(() => applyHeightsRef.current(), 100);
};
window.addEventListener("resize", onResize);
return () => {
window.removeEventListener("resize", onResize);
if (settledId !== undefined) window.clearTimeout(settledId);
};
}, []);
// Apply height constraints to whichever editor element is active. Max
// height is derived from the editor's actual position so long prompts
// never push the box past the bottom action row — regardless of viewport
// size (4K was previously capped at 500px while the viewport had room).
useEffect(() => {
const el = (mentionEditorRef.current ??
textareaRef.current) as HTMLElement | null;
if (el) {
const maxH = isExpanded ? 500 : Math.min(window.innerHeight - 700, 500);
el.style.maxHeight = `${Math.max(maxH, 88)}px`;
const available = computeAvailableEditorHeight(el);
const maxH = isExpanded ? available : Math.min(available, 500);
el.style.maxHeight = `${maxH}px`;
el.style.minHeight = "0";
if (!isExpanded) {
const capped = Math.min(el.scrollHeight, 88);
@@ -472,32 +523,41 @@ export const PromptBoxVideo = ({
);
// All mention items (unfiltered) for the contentEditable MentionTextarea
const allMentionItems: MentionItem[] = useMemo(() => [
...(isReferenceMode
? [
...referenceImages.map((img, i) => ({
label: `@Image${i + 1}`,
type: "image" as const,
preview: img.url,
})),
...referenceVideos.map((vid, i) => ({
label: `@Video${i + 1}`,
type: "video" as const,
preview: vid.url,
})),
...referenceAudios.map((_aud, i) => ({
label: `@Audio${i + 1}`,
type: "audio" as const,
preview: undefined as string | undefined,
})),
]
: []),
...activeCharacters.map((char) => ({
label: `@${char.name}`,
type: "character" as const,
preview: char.avatar_image_url,
})),
], [isReferenceMode, referenceImages, referenceVideos, referenceAudios, activeCharacters]);
const allMentionItems: MentionItem[] = useMemo(
() => [
...(isReferenceMode
? [
...referenceImages.map((img, i) => ({
label: `@Image${i + 1}`,
type: "image" as const,
preview: img.url,
})),
...referenceVideos.map((vid, i) => ({
label: `@Video${i + 1}`,
type: "video" as const,
preview: vid.url,
})),
...referenceAudios.map((_aud, i) => ({
label: `@Audio${i + 1}`,
type: "audio" as const,
preview: undefined as string | undefined,
})),
]
: []),
...activeCharacters.map((char) => ({
label: `@${char.name}`,
type: "character" as const,
preview: char.avatar_image_url,
})),
],
[
isReferenceMode,
referenceImages,
referenceVideos,
referenceAudios,
activeCharacters,
],
);
// Build label → color map for inline mention highlighting
const mentionColorMap = useMemo(() => {
@@ -900,7 +960,7 @@ export const PromptBoxVideo = ({
)}
>
<div className="relative flex justify-center gap-2">
<div className="promptbox-resize-wrap relative flex-1">
<div className="promptbox-resize-wrap relative flex-1 min-w-0">
{hasAnyMentionables ? (
<MentionTextarea
ref={mentionEditorRef}
@@ -917,7 +977,11 @@ export const PromptBoxVideo = ({
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
if (selectedModel?.requiresImage && referenceImages.length === 0) return;
if (
selectedModel?.requiresImage &&
referenceImages.length === 0
)
return;
if (!prompt.trim()) return;
handleEnqueue();
}
@@ -1052,12 +1116,12 @@ export const PromptBoxVideo = ({
{selectedModel?.id === "seedance_2p0" && (
<button
type="button"
onClick={() => setIsCharactersModalOpen(true)}
className="flex h-9 items-center justify-center gap-1 rounded-lg border border-ui-controls-border bg-ui-controls px-3 text-sm font-medium text-base-fg transition-all duration-150 hover:bg-ui-controls/80 active:scale-95"
>
@Characters
</button>
type="button"
onClick={() => setIsCharactersModalOpen(true)}
className="flex h-9 items-center justify-center gap-1 rounded-lg border border-ui-controls-border bg-ui-controls px-3 text-sm font-medium text-base-fg transition-all duration-150 hover:bg-ui-controls/80 active:scale-95"
>
@Characters
</button>
)}
</div>
<div className="flex items-center gap-2">
@@ -1117,7 +1181,7 @@ export const PromptBoxVideo = ({
</Tooltip>
</div>
</div>
{selectedModel?.id === "seedance_2p0" && (
{/* {selectedModel?.id === "seedance_2p0" && (
<div className="flex items-start gap-2.5 rounded-lg border border-yellow-500/40 bg-yellow-500/10 px-3.5 py-2.5 text-xs text-yellow-200">
<FontAwesomeIcon
icon={faTriangleExclamation}
@@ -1129,14 +1193,15 @@ export const PromptBoxVideo = ({
Try several short generations before longer ones.
</span>
</div>
)}
)} */}
</div>
<CharactersModal
isOpen={isCharactersModalOpen}
onClose={() => setIsCharactersModalOpen(false)}
onSelectCharacter={(character) => {
const mention = `@${character.name}`;
const spaceBefore = prompt.length > 0 && !prompt.endsWith(" ") ? " " : "";
const spaceBefore =
prompt.length > 0 && !prompt.endsWith(" ") ? " " : "";
setPrompt(prompt + spaceBefore + mention + " ");
setIsCharactersModalOpen(false);
requestAnimationFrame(() => {
@@ -1,2 +1,6 @@
export * from './lib/settings-modal';
export {
useExperimentalStore,
useStoryboardPageEnabled,
} from './lib/experimental-store';
@@ -0,0 +1,44 @@
import { Modal } from "@storyteller/ui-modal";
import { Button } from "@storyteller/ui-button";
interface Props {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
title: string;
text: string;
confirmText?: string;
cancelText?: string;
confirmVariant?: "primary" | "secondary" | "destructive" | "ghost" | "action";
}
export const ExperimentalConfirmModal = ({
isOpen,
onClose,
onConfirm,
title,
text,
confirmText = "Confirm",
cancelText = "Cancel",
confirmVariant = "destructive",
}: Props) => {
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={title}
className="max-w-md"
showClose={false}
>
<div className="text-sm text-base-fg">{text}</div>
<div className="mt-6 flex justify-end gap-2">
<Button variant="secondary" onClick={onClose}>
{cancelText}
</Button>
<Button variant={confirmVariant} onClick={onConfirm}>
{confirmText}
</Button>
</div>
</Modal>
);
};
@@ -0,0 +1,56 @@
import { create } from "zustand";
const ENABLED_STORAGE_KEY = "artcraft_experimental_enabled";
const STORYBOARD_STORAGE_KEY = "artcraft_experimental_storyboard_page";
const readBoolFlag = (key: string): boolean => {
if (typeof window === "undefined") return false;
try {
return window.localStorage.getItem(key) === "true";
} catch {
return false;
}
};
const writeBoolFlag = (key: string, enabled: boolean) => {
if (typeof window === "undefined") return;
try {
if (enabled) {
window.localStorage.setItem(key, "true");
} else {
window.localStorage.removeItem(key);
}
} catch {
// ignore storage failures
}
};
interface ExperimentalState {
enabled: boolean;
storyboardPageEnabled: boolean;
enable: () => void;
disable: () => void;
setStoryboardPageEnabled: (enabled: boolean) => void;
}
export const useExperimentalStore = create<ExperimentalState>((set) => ({
enabled: readBoolFlag(ENABLED_STORAGE_KEY),
storyboardPageEnabled: readBoolFlag(STORYBOARD_STORAGE_KEY),
enable: () => {
writeBoolFlag(ENABLED_STORAGE_KEY, true);
set({ enabled: true });
},
disable: () => {
// Resetting experimental clears every gated feature flag too.
writeBoolFlag(ENABLED_STORAGE_KEY, false);
writeBoolFlag(STORYBOARD_STORAGE_KEY, false);
set({ enabled: false, storyboardPageEnabled: false });
},
setStoryboardPageEnabled: (enabled: boolean) => {
writeBoolFlag(STORYBOARD_STORAGE_KEY, enabled);
set({ storyboardPageEnabled: enabled });
},
}));
export const useStoryboardPageEnabled = () =>
useExperimentalStore((s) => s.enabled && s.storyboardPageEnabled);
@@ -1,9 +1,15 @@
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import {
GetAppInfo,
GetAppInfoPayload,
} from "@storyteller/tauri-api";
import { Label } from "@storyteller/ui-label";
import { toast } from "react-hot-toast";
import { gtagEvent } from "@storyteller/google-analytics";
import { useExperimentalStore } from "../experimental-store";
const UNLOCK_CLICK_COUNT = 7;
const UNLOCK_WINDOW_MS = 5000;
interface AboutSettingsPaneProps {}
@@ -12,6 +18,10 @@ export const AboutSettingsPane = (args: AboutSettingsPaneProps) => {
GetAppInfoPayload | undefined
>(undefined);
const enabled = useExperimentalStore((s) => s.enabled);
const enable = useExperimentalStore((s) => s.enable);
const clickTimes = useRef<number[]>([]);
useEffect(() => {
const fetchData = async () => {
const prefs = await GetAppInfo();
@@ -20,6 +30,19 @@ export const AboutSettingsPane = (args: AboutSettingsPaneProps) => {
fetchData();
}, []);
const handleVersionClick = () => {
if (enabled) return;
const now = performance.now();
const recent = clickTimes.current.filter((t) => now - t < UNLOCK_WINDOW_MS);
recent.push(now);
clickTimes.current = recent;
if (recent.length >= UNLOCK_CLICK_COUNT) {
clickTimes.current = [];
enable();
gtagEvent("unlock_experimental_menu", {});
toast.success("Experimental features unlocked");
}
};
return (
<>
@@ -28,7 +51,12 @@ export const AboutSettingsPane = (args: AboutSettingsPaneProps) => {
<Label>
Artcraft Version
</Label>
<div>{appInfo?.artcraft_version}</div>
<div
onClick={handleVersionClick}
style={{ cursor: "default", userSelect: "none" }}
>
{appInfo?.artcraft_version}
</div>
</div>
<div className="space-y-1">
@@ -0,0 +1,73 @@
import { useState } from "react";
import { Label } from "@storyteller/ui-label";
import { Switch } from "@storyteller/ui-switch";
import { gtagEvent } from "@storyteller/google-analytics";
import { useExperimentalStore } from "../experimental-store";
import { ExperimentalConfirmModal } from "../ExperimentalConfirmModal";
interface Props {
// Called immediately before the storyboard flag flips off, so the host app
// can reset page state and navigate away from the soon-to-be-hidden tab.
onStoryboardPageDisable?: () => void;
}
export const ExperimentalSettingsPane = ({ onStoryboardPageDisable }: Props) => {
const storyboardPageEnabled = useExperimentalStore(
(s) => s.storyboardPageEnabled,
);
const setStoryboardPageEnabled = useExperimentalStore(
(s) => s.setStoryboardPageEnabled,
);
const [isDisableConfirmOpen, setIsDisableConfirmOpen] = useState(false);
const fireToggleEvent = (enabled: boolean) => {
gtagEvent("toggle_experimental_feature", {
feature: "storyboard_page",
enabled: String(enabled),
});
};
const handleStoryboardToggle = (enabled: boolean) => {
if (enabled) {
setStoryboardPageEnabled(true);
fireToggleEvent(true);
} else {
setIsDisableConfirmOpen(true);
}
};
const handleConfirmDisable = () => {
onStoryboardPageDisable?.();
setStoryboardPageEnabled(false);
fireToggleEvent(false);
setIsDisableConfirmOpen(false);
};
return (
<>
<div className="space-y-4 text-base-fg">
<div className="flex items-start justify-between gap-4">
<div className="flex flex-col gap-0.5">
<Label htmlFor="experimental-storyboard-page">Storyboard page</Label>
<p className="text-xs opacity-70">
Show the Storyboard page in the apps menu. In-development; expect
rough edges.
</p>
</div>
<Switch
enabled={storyboardPageEnabled}
setEnabled={handleStoryboardToggle}
/>
</div>
</div>
<ExperimentalConfirmModal
isOpen={isDisableConfirmOpen}
onClose={() => setIsDisableConfirmOpen(false)}
onConfirm={handleConfirmDisable}
title="Disable Storyboard page?"
text="The Storyboard page will be reset and any unsaved changes will be lost."
confirmText="Disable"
/>
</>
);
};
@@ -8,6 +8,7 @@ import {
faCircleInfo,
faCreditCard,
faPalette,
faFlask,
} from "@fortawesome/pro-solid-svg-icons";
import { twMerge } from "tailwind-merge";
import { MiscSettingsPane } from "./panes/MiscSettingsPane";
@@ -15,14 +16,19 @@ import { AudioSettingsPane } from "./panes/AudioSettingsPane";
import { AccountSettingsPane } from "./panes/AccountSettings/AccountSettingsPane";
import { AboutSettingsPane } from "./panes/AboutSettingsPane";
import { ProviderPrioritySettingsPane } from "./panes/ProviderPrioritySettingsPane";
import { ExperimentalSettingsPane } from "./panes/ExperimentalSettingsPane";
import { gtagEvent } from "@storyteller/google-analytics";
import { BillingSettingsPane } from "./panes/BillingSettingsPane";
import { AppearanceSettingsPane } from "./panes/AppearanceSettingsPane";
import { Button } from "@storyteller/ui-button";
import { useExperimentalStore } from "./experimental-store";
import { ExperimentalConfirmModal } from "./ExperimentalConfirmModal";
interface SettingsModalProps {
isOpen: boolean;
onClose: () => void;
globalAccountLogoutCallback: () => void;
onStoryboardPageDisable?: () => void;
initialSection?: SettingsSection;
}
@@ -33,17 +39,23 @@ type SettingsSection =
| "alerts"
| "about"
| "provider_priority"
| "billing";
| "billing"
| "experimental";
export const SettingsModal = ({
isOpen,
onClose,
globalAccountLogoutCallback,
onStoryboardPageDisable,
initialSection = "general",
}: SettingsModalProps) => {
const [selectedSection, setSelectedSection] =
useState<SettingsSection>(initialSection);
const experimentalEnabled = useExperimentalStore((s) => s.enabled);
const disableExperimental = useExperimentalStore((s) => s.disable);
const [isResetConfirmOpen, setIsResetConfirmOpen] = useState(false);
// Sync the selected section with incoming prop when modal opens or prop changes
useEffect(() => {
if (isOpen) {
@@ -51,6 +63,13 @@ export const SettingsModal = ({
}
}, [isOpen, initialSection]);
// If experimental gets disabled while user is on that pane, fall back to General
useEffect(() => {
if (!experimentalEnabled && selectedSection === "experimental") {
setSelectedSection("general");
}
}, [experimentalEnabled, selectedSection]);
const sections = [
{ id: "general" as const, label: "General", icon: faCog },
@@ -67,6 +86,9 @@ export const SettingsModal = ({
{ id: "about" as const, label: "About", icon: faCircleInfo },
//{ id: "video" as const, label: "Video", icon: faVideo },
//{ id: "image" as const, label: "Image", icon: faImage },
...(experimentalEnabled
? [{ id: "experimental" as const, label: "Experimental", icon: faFlask }]
: []),
];
const renderContent = () => {
@@ -89,57 +111,94 @@ export const SettingsModal = ({
return <ProviderPrioritySettingsPane />;
case "billing":
return <BillingSettingsPane />;
case "experimental":
return (
<ExperimentalSettingsPane
onStoryboardPageDisable={onStoryboardPageDisable}
/>
);
}
};
const handleConfirmReset = () => {
disableExperimental();
gtagEvent("reset_experimental_menu", {});
setIsResetConfirmOpen(false);
};
return (
<Modal
isOpen={isOpen}
onClose={onClose}
className="max-w-3xl"
childPadding={false}
>
<div className="h-[560px]">
<div className="grid h-full grid-cols-12 gap-3">
<div className="relative col-span-4 p-3 pt-2 after:absolute after:right-0 after:top-0 after:h-full after:w-px after:bg-ui-panel-border">
<div className="flex items-center justify-between gap-2.5 py-0.5">
<h2 className="text-[18px] font-semibold opacity-80">Settings</h2>
<>
<Modal
isOpen={isOpen}
onClose={onClose}
className="max-w-3xl"
childPadding={false}
>
<div className="h-[560px]">
<div className="grid h-full grid-cols-12 gap-3">
<div className="relative col-span-4 p-3 pt-2 after:absolute after:right-0 after:top-0 after:h-full after:w-px after:bg-ui-panel-border">
<div className="flex items-center justify-between gap-2.5 py-0.5">
<h2 className="text-[18px] font-semibold opacity-80">Settings</h2>
</div>
<hr className="my-2 w-full border-ui-panel-border" />
<div className="space-y-1">
{sections.map((section) => (
<button
key={section.id}
className={twMerge(
"h-9 w-full rounded-lg p-2 text-left transition-colors duration-100 hover:bg-[#63636B]/30",
section.id === selectedSection ? "bg-[#63636B]/20" : ""
)}
onClick={() => {
gtagEvent("switch_settings_section", {
section: section.id,
});
setSelectedSection(section.id);
}}
>
<div className="flex items-center gap-2.5 text-sm">
<FontAwesomeIcon icon={section.icon} />
{section.label}
</div>
</button>
))}
</div>
</div>
<hr className="my-2 w-full border-ui-panel-border" />
<div className="space-y-1">
{sections.map((section) => (
<button
key={section.id}
className={twMerge(
"h-9 w-full rounded-lg p-2 text-left transition-colors duration-100 hover:bg-[#63636B]/30",
section.id === selectedSection ? "bg-[#63636B]/20" : ""
)}
onClick={() => {
gtagEvent("switch_settings_section", {
section: section.id,
});
setSelectedSection(section.id);
}}
>
<div className="flex items-center gap-2.5 text-sm">
<FontAwesomeIcon icon={section.icon} />
{section.label}
<div className="col-span-8 flex h-full flex-col overflow-y-auto relative">
<div className="w-full border-b border-ui-panel-border py-2.5 ps-0">
<h2 className="text-[18px] font-semibold">
{sections.find((s) => s.id === selectedSection)?.label}
</h2>
</div>
{experimentalEnabled && selectedSection === "experimental" && (
<div className="sticky top-0 z-10 flex items-center justify-between gap-3 border-b border-ui-panel-border bg-ui-modal/95 backdrop-blur px-3 py-2">
<div className="flex items-center gap-2 text-xs opacity-80">
<FontAwesomeIcon icon={faFlask} />
Experimental features enabled
</div>
</button>
))}
<Button
variant="destructive"
onClick={() => setIsResetConfirmOpen(true)}
className="px-2 py-1 text-xs"
>
Reset
</Button>
</div>
)}
<div className="p-3 ps-0 text-sm h-full">{renderContent()}</div>
</div>
</div>
<div className="col-span-8 flex h-full flex-col">
<div className="w-full border-b border-ui-panel-border py-2.5 ps-0">
<h2 className="text-[18px] font-semibold">
{sections.find((s) => s.id === selectedSection)?.label}
</h2>
</div>
<div className="p-3 ps-0 text-sm h-full">{renderContent()}</div>
</div>
</div>
</div>
</Modal>
</Modal>
<ExperimentalConfirmModal
isOpen={isResetConfirmOpen}
onClose={() => setIsResetConfirmOpen(false)}
onConfirm={handleConfirmReset}
title="Reset experimental settings?"
text="This will hide the Experimental section and clear any experimental settings. You can unlock it again from the About page."
confirmText="Reset"
/>
</>
);
};