Compare commits

...
Author SHA1 Message Date
Tanish Manku b8db60365b artcraft: surface credits-fetch state on top bar coin + 60s poll
Customers occasionally saw "0 Credits" with no signal that the fetch
had failed silently. Add a status badge on the coin (slow/failed/
recovered, hidden in the happy path), a 60s background refresh, and
gate the initial fetch on AUTH_STATUS.LOGGED_IN to remove the 1s
setTimeout band-aid that was racing intermediate auth states.
2026-04-30 16:22:50 -07:00
BFlat 8f9607cc5d promptbox: default both Enter and Shift+Enter to newline (#1454)
Only the submit button triggers generation by default. The "Enter to
generate" toggle (available in both app and website settings top right
nav) still allows opting into Enter-to-submit.
2026-04-30 10:02:10 +07:00
bflatastic e1b8864a8a website: persist pending video batches to localStorage 2026-04-30 08:33:12 +07:00
bflatastic 8dcebec25c for loop removed and using video batch in endpoint 2026-04-30 08:09:22 +07:00
bflatastic f7c9e4ecc7 website: full time stamp in lightbox 2026-04-30 06:59:04 +07:00
Brandon Thomas 633e6bdd6c bump tauri 0.30.0 -> 0.31.0 (#1453) 2026-04-29 18:51:37 -04:00
BFlat 13f1fb4f63 bug fixes for character and video ref uploads on website, show error … (#1451)
…toasts
2026-04-30 01:16:41 +07:00
Brandon Thomas 967828ec2f tauri changes to support artcraft_router v2 (happy horse, seedance 2, etc.) (#1448) 2026-04-29 13:53:30 -04:00
Brandon Thomas 90baabca4b website seedance 21:9 (#1445) 2026-04-29 00:15:51 -04:00
bflatastic 3ede4ef8d1 website: download on site instead of opening new tab 2026-04-29 11:04:42 +07:00
Brandon Thomas 96194ea0d8 new aspect ratio for seedance (#1444) 2026-04-28 22:55:16 -04:00
65 changed files with 1747 additions and 1407 deletions
@@ -80,6 +80,7 @@ fn plan_aspect_ratio(
}
Some(CommonAspectRatio::WideFourByThree) => Ok(Some(CommonAspectRatioEnum::WideFourByThree)),
Some(CommonAspectRatio::TallThreeByFour) => Ok(Some(CommonAspectRatioEnum::TallThreeByFour)),
Some(CommonAspectRatio::WideTwentyOneByNine) => Ok(Some(CommonAspectRatioEnum::WideTwentyOneByNine)),
Some(unsupported) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
@@ -100,7 +101,6 @@ fn nearest_aspect_ratio(aspect_ratio: CommonAspectRatio) -> CommonAspectRatioEnu
match aspect_ratio {
CommonAspectRatio::WideFiveByFour => CommonAspectRatioEnum::WideFourByThree,
CommonAspectRatio::WideThreeByTwo => CommonAspectRatioEnum::WideFourByThree,
CommonAspectRatio::WideTwentyOneByNine => CommonAspectRatioEnum::WideSixteenByNine,
CommonAspectRatio::TallFourByFive => CommonAspectRatioEnum::TallThreeByFour,
CommonAspectRatio::TallTwoByThree => CommonAspectRatioEnum::TallThreeByFour,
CommonAspectRatio::TallNineByTwentyOne => CommonAspectRatioEnum::TallNineBySixteen,
@@ -222,9 +222,10 @@ mod tests {
fn artcraft_matches_kinovi_all_combos() {
let resolutions = [
Some(CommonResolution::FourEightyP),
Some(CommonResolution::SevenTwentyP),
None,
Some(CommonResolution::TenEightyP),
// NB: These are underpriced (for now)
//Some(CommonResolution::SevenTwentyP),
//None,
];
let durations: [u16; 4] = [4, 5, 10, 15];
let batches: [u16; 3] = [1, 2, 4];
@@ -81,6 +81,7 @@ fn plan_aspect_ratio(
}
Some(CommonAspectRatio::WideFourByThree) => Ok(Some(CommonAspectRatioEnum::WideFourByThree)),
Some(CommonAspectRatio::TallThreeByFour) => Ok(Some(CommonAspectRatioEnum::TallThreeByFour)),
Some(CommonAspectRatio::WideTwentyOneByNine) => Ok(Some(CommonAspectRatioEnum::WideTwentyOneByNine)),
// Mismatches
Some(unsupported) => match strategy {
@@ -102,7 +103,6 @@ fn nearest_aspect_ratio(aspect_ratio: CommonAspectRatio) -> CommonAspectRatioEnu
match aspect_ratio {
CommonAspectRatio::WideFiveByFour => CommonAspectRatioEnum::WideFourByThree,
CommonAspectRatio::WideThreeByTwo => CommonAspectRatioEnum::WideFourByThree,
CommonAspectRatio::WideTwentyOneByNine => CommonAspectRatioEnum::WideSixteenByNine,
CommonAspectRatio::TallFourByFive => CommonAspectRatioEnum::TallThreeByFour,
CommonAspectRatio::TallTwoByThree => CommonAspectRatioEnum::TallThreeByFour,
CommonAspectRatio::TallNineByTwentyOne => CommonAspectRatioEnum::TallNineBySixteen,
@@ -185,47 +185,47 @@ mod tests {
// -- Cross-check with Kinovi Fast via builder --
mod cross_check_with_kinovi {
use super::*;
#[test]
fn artcraft_fast_matches_kinovi_fast_all_combos() {
let resolutions = [
Some(CommonResolution::FourEightyP),
Some(CommonResolution::SevenTwentyP),
None,
];
let durations: [u16; 4] = [4, 5, 10, 15];
let batches: [u16; 3] = [1, 2, 4];
for res in &resolutions {
for dur in &durations {
for batch in &batches {
let artcraft_cost = build_cost(*res, *dur, *batch);
let kinovi = GenerateVideoRequestBuilder {
model: CommonVideoModel::Seedance2p0Fast,
provider: Provider::Seedance2Pro,
resolution: *res,
duration_seconds: Some(*dur),
video_batch_count: Some(*batch),
..Default::default()
};
let kinovi_cost = kinovi.build2()
.expect("kinovi build2")
.estimate_cost()
.expect("kinovi estimate_cost");
assert_eq!(
artcraft_cost.cost_in_usd_cents, kinovi_cost.cost_in_usd_cents,
"USD cents mismatch: res={:?} dur={}s batch={}",
res, dur, batch,
);
}
}
}
}
}
//mod cross_check_with_kinovi {
// use super::*;
//
// #[test]
// fn artcraft_fast_matches_kinovi_fast_all_combos() {
// let resolutions = [
// Some(CommonResolution::FourEightyP),
// Some(CommonResolution::SevenTwentyP),
// None,
// ];
// let durations: [u16; 4] = [4, 5, 10, 15];
// let batches: [u16; 3] = [1, 2, 4];
//
// for res in &resolutions {
// for dur in &durations {
// for batch in &batches {
// let artcraft_cost = build_cost(*res, *dur, *batch);
//
// let kinovi = GenerateVideoRequestBuilder {
// model: CommonVideoModel::Seedance2p0Fast,
// provider: Provider::Seedance2Pro,
// resolution: *res,
// duration_seconds: Some(*dur),
// video_batch_count: Some(*batch),
// ..Default::default()
// };
// let kinovi_cost = kinovi.build2()
// .expect("kinovi build2")
// .estimate_cost()
// .expect("kinovi estimate_cost");
//
// assert_eq!(
// artcraft_cost.cost_in_usd_cents, kinovi_cost.cost_in_usd_cents,
// "USD cents mismatch: res={:?} dur={}s batch={}",
// res, dur, batch,
// );
// }
// }
// }
// }
//}
// -- Helpers --
@@ -1,4 +1,8 @@
use seedance2pro_client::requests::generate_video::generate_video::{KinoviAspectRatio, KinoviBatchCount, KinoviOutputResolution};
use seedance2pro_client::generate::video::generate_seedance_2p0::{
KinoviSeedance2p0AspectRatio as KinoviAspectRatio,
KinoviSeedance2p0BatchCount as KinoviBatchCount,
KinoviSeedance2p0OutputResolution as KinoviOutputResolution,
};
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::common_resolution::CommonResolution;
@@ -73,6 +77,7 @@ fn plan_aspect_ratio(
}
Some(CommonAspectRatio::WideFourByThree) => Ok(KinoviAspectRatio::Standard4x3),
Some(CommonAspectRatio::TallThreeByFour) => Ok(KinoviAspectRatio::Portrait3x4),
Some(CommonAspectRatio::WideTwentyOneByNine) => Ok(KinoviAspectRatio::UltraWide21x9),
// Mismatches — apply strategy
Some(unsupported) => match strategy {
@@ -95,7 +100,6 @@ fn nearest_aspect_ratio(aspect_ratio: CommonAspectRatio) -> KinoviAspectRatio {
match aspect_ratio {
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
@@ -193,7 +197,11 @@ fn plan_duration(
#[cfg(test)]
mod tests {
use seedance2pro_client::requests::generate_video::generate_video::{KinoviAspectRatio, KinoviBatchCount, KinoviOutputResolution};
use seedance2pro_client::generate::video::generate_seedance_2p0::{
KinoviSeedance2p0AspectRatio as KinoviAspectRatio,
KinoviSeedance2p0BatchCount as KinoviBatchCount,
KinoviSeedance2p0OutputResolution as KinoviOutputResolution,
};
use tokens::tokens::characters::CharacterToken;
use tokens::tokens::media_files::MediaFileToken;
@@ -1,22 +1,23 @@
use seedance2pro_client::requests::generate_video::generate_video::{KinoviAspectRatio, KinoviBatchCount, KinoviGenerateVideoRequest, KinoviModelType, KinoviOutputResolution};
use seedance2pro_client::generate::video::generate_seedance_2p0::{
GenerateSeedance2p0Request, KinoviSeedance2p0BatchCount,
KinoviSeedance2p0OutputResolution,
};
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
use crate::generate::generate_video_v2::providers::kinovi::seedance_2p0::draft::KinoviSeedance2p0DraftState;
use crate::generate::generate_video_v2::providers::kinovi::seedance_2p0::request::KinoviSeedance2p0RequestState;
pub struct KinoviSeedance2p0CostState {
pub resolution: KinoviOutputResolution,
pub resolution: Option<KinoviSeedance2p0OutputResolution>,
pub duration_seconds: u8,
pub batch_count: KinoviBatchCount,
/// Forward-looking: Kinovi charges for this. We need to take it into
/// account in the future.
pub batch_count: Option<KinoviSeedance2p0BatchCount>,
pub has_video_reference: bool,
}
impl KinoviSeedance2p0CostState {
pub fn from_request(request: &KinoviSeedance2p0RequestState) -> Self {
Self {
resolution: request.request.output_resolution.unwrap_or(KinoviOutputResolution::SevenTwentyP),
resolution: request.request.output_resolution,
duration_seconds: request.request.duration_seconds,
batch_count: request.request.batch_count,
has_video_reference: request.request.reference_video_urls
@@ -32,27 +33,22 @@ impl KinoviSeedance2p0CostState {
.is_some();
Self {
resolution: draft.resolution.unwrap_or(KinoviOutputResolution::SevenTwentyP),
resolution: draft.resolution,
duration_seconds: draft.duration_seconds,
batch_count: draft.batch_count,
batch_count: Some(draft.batch_count),
has_video_reference,
}
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
let request = KinoviGenerateVideoRequest {
// Pricing factors
model_type: KinoviModelType::Seedance2Pro,
output_resolution: Some(self.resolution),
let pricing_request = GenerateSeedance2p0Request {
output_resolution: self.resolution,
duration_seconds: self.duration_seconds,
batch_count: self.batch_count,
// NB: has_video_reference is tracked but does not affect price yet.
// When Kinovi starts charging for video references, add cost here.
// No impact on price
prompt: String::new(),
aspect_ratio: KinoviAspectRatio::Portrait9x16,
aspect_ratio: None,
start_frame_url: None,
end_frame_url: None,
reference_image_urls: None,
@@ -62,8 +58,8 @@ impl KinoviSeedance2p0CostState {
use_face_blur_hack: None,
};
let cost_in_credits = request.estimate_credits();
let cost_in_usd_cents = request.estimate_cost_in_usd_cents();
let cost_in_credits = pricing_request.estimate_credits();
let cost_in_usd_cents = pricing_request.estimate_cost_in_usd_cents();
VideoGenerationCostEstimate {
cost_in_credits: Some(cost_in_credits as u64),
@@ -79,6 +75,11 @@ impl KinoviSeedance2p0CostState {
#[cfg(test)]
mod tests {
use super::*;
use seedance2pro_client::generate::video::generate_seedance_2p0::{
KinoviSeedance2p0OutputResolution as KinoviOutputResolution,
KinoviSeedance2p0BatchCount as KinoviBatchCount,
};
use crate::api::common_resolution::CommonResolution;
use crate::api::provider::Provider;
use crate::api::video_list_ref::VideoListRef;
@@ -93,26 +94,26 @@ mod tests {
#[test]
fn cost_720p_batch_1() {
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::One), 64);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::One), 80);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 6, KinoviBatchCount::One), 96);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 7, KinoviBatchCount::One), 112);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 10, KinoviBatchCount::One), 160);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::One), 240);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::One), 83);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::One), 104);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 6, KinoviBatchCount::One), 124);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 7, KinoviBatchCount::One), 145);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 10, KinoviBatchCount::One), 207);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::One), 311);
}
#[test]
fn cost_720p_batch_2() {
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::Two), 128);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Two), 160);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::Two), 480);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::Two), 166);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Two), 207);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::Two), 622);
}
#[test]
fn cost_720p_batch_4() {
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::Four), 256);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Four), 320);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::Four), 960);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::Four), 332);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Four), 415);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::Four), 1244);
}
}
@@ -202,9 +203,9 @@ mod tests {
#[test]
fn video_reference_does_not_affect_cost() {
let base = KinoviSeedance2p0CostState {
resolution: KinoviOutputResolution::SevenTwentyP,
resolution: Some(KinoviOutputResolution::SevenTwentyP),
duration_seconds: 5,
batch_count: KinoviBatchCount::One,
batch_count: Some(KinoviBatchCount::One),
has_video_reference: false,
};
let without = base.estimate_cost();
@@ -222,19 +223,19 @@ mod tests {
fn from_request_720p() {
let req = make_request_state(Some(KinoviOutputResolution::SevenTwentyP), 5, KinoviBatchCount::One, false);
let cost = KinoviSeedance2p0CostState::from_request(&req);
assert!(matches!(cost.resolution, KinoviOutputResolution::SevenTwentyP));
assert!(matches!(cost.resolution, Some(KinoviOutputResolution::SevenTwentyP)));
assert_eq!(cost.duration_seconds, 5);
assert!(matches!(cost.batch_count, KinoviBatchCount::One));
assert!(matches!(cost.batch_count, Some(KinoviBatchCount::One)));
assert!(!cost.has_video_reference);
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(80));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(104));
}
#[test]
fn from_request_none_defaults_to_720p() {
let req = make_request_state(None, 5, KinoviBatchCount::One, false);
let cost = KinoviSeedance2p0CostState::from_request(&req);
assert!(matches!(cost.resolution, KinoviOutputResolution::SevenTwentyP));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(80));
assert!(cost.resolution.is_none()); // None defaults to 720p pricing
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(104));
}
#[test]
@@ -257,7 +258,7 @@ mod tests {
let cost = KinoviSeedance2p0CostState::from_request(&req);
assert!(cost.has_video_reference);
// Video refs don't affect cost yet
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(80));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(104));
}
#[test]
@@ -277,18 +278,18 @@ mod tests {
fn from_draft_720p_default() {
let draft = make_draft(5, 1, None, false);
let cost = KinoviSeedance2p0CostState::from_draft(&draft);
assert!(matches!(cost.resolution, KinoviOutputResolution::SevenTwentyP));
assert!(cost.resolution.is_none()); // None defaults to 720p pricing
assert_eq!(cost.duration_seconds, 5);
assert!(matches!(cost.batch_count, KinoviBatchCount::One));
assert!(matches!(cost.batch_count, Some(KinoviBatchCount::One)));
assert!(!cost.has_video_reference);
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(80));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(104));
}
#[test]
fn from_draft_480p() {
let draft = make_draft(5, 1, Some(CommonResolution::FourEightyP), false);
let cost = KinoviSeedance2p0CostState::from_draft(&draft);
assert!(matches!(cost.resolution, KinoviOutputResolution::FourEightyP));
assert!(matches!(cost.resolution, Some(KinoviOutputResolution::FourEightyP)));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(39));
}
@@ -296,8 +297,8 @@ mod tests {
fn from_draft_1080p_batch_4() {
let draft = make_draft(5, 4, Some(CommonResolution::TenEightyP), false);
let cost = KinoviSeedance2p0CostState::from_draft(&draft);
assert!(matches!(cost.resolution, KinoviOutputResolution::TenEightyP));
assert!(matches!(cost.batch_count, KinoviBatchCount::Four));
assert!(matches!(cost.resolution, Some(KinoviOutputResolution::TenEightyP)));
assert!(matches!(cost.batch_count, Some(KinoviBatchCount::Four)));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(933));
}
@@ -307,7 +308,7 @@ mod tests {
let cost = KinoviSeedance2p0CostState::from_draft(&draft);
assert!(cost.has_video_reference);
// Video refs don't affect cost yet
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(80));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(104));
}
#[test]
@@ -322,8 +323,8 @@ mod tests {
let draft = make_draft(15, 2, Some(CommonResolution::SevenTwentyP), false);
let cost = KinoviSeedance2p0CostState::from_draft(&draft);
assert_eq!(cost.duration_seconds, 15);
assert!(matches!(cost.batch_count, KinoviBatchCount::Two));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(480));
assert!(matches!(cost.batch_count, Some(KinoviBatchCount::Two)));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(622));
}
}
@@ -382,7 +383,10 @@ mod tests {
duration_seconds: u8,
batch_count: KinoviBatchCount,
) -> u64 {
KinoviSeedance2p0CostState { resolution, duration_seconds, batch_count, has_video_reference: false }
KinoviSeedance2p0CostState {
resolution: Some(resolution), duration_seconds, batch_count: Some(batch_count),
has_video_reference: false,
}
.estimate_cost()
.cost_in_usd_cents
.unwrap()
@@ -393,7 +397,10 @@ mod tests {
duration_seconds: u8,
batch_count: KinoviBatchCount,
) -> u64 {
KinoviSeedance2p0CostState { resolution, duration_seconds, batch_count, has_video_reference: false }
KinoviSeedance2p0CostState {
resolution: Some(resolution), duration_seconds, batch_count: Some(batch_count),
has_video_reference: false,
}
.estimate_cost()
.cost_in_credits
.unwrap()
@@ -443,13 +450,12 @@ mod tests {
};
KinoviSeedance2p0RequestState {
request: KinoviGenerateVideoRequest {
model_type: KinoviModelType::Seedance2Pro,
request: GenerateSeedance2p0Request {
prompt: "test".to_string(),
aspect_ratio: KinoviAspectRatio::Landscape16x9,
aspect_ratio: None,
output_resolution: resolution,
duration_seconds,
batch_count,
batch_count: Some(batch_count),
start_frame_url: None,
end_frame_url: None,
reference_image_urls: None,
@@ -1,4 +1,7 @@
use seedance2pro_client::requests::generate_video::generate_video::{KinoviAspectRatio, KinoviBatchCount, KinoviGenerateVideoRequest, KinoviModelType, KinoviOutputResolution};
use seedance2pro_client::generate::video::generate_seedance_2p0::{
GenerateSeedance2p0Request, KinoviSeedance2p0AspectRatio,
KinoviSeedance2p0BatchCount, KinoviSeedance2p0OutputResolution,
};
use crate::api::audio_list_ref::AudioListRef;
use crate::api::character_list_ref::CharacterListRef;
@@ -19,10 +22,10 @@ pub struct KinoviSeedance2p0DraftState {
// Materialized / finalized types
pub prompt: String,
pub aspect_ratio: KinoviAspectRatio,
pub resolution: Option<KinoviOutputResolution>,
pub aspect_ratio: KinoviSeedance2p0AspectRatio,
pub resolution: Option<KinoviSeedance2p0OutputResolution>,
pub duration_seconds: u8,
pub batch_count: KinoviBatchCount,
pub batch_count: KinoviSeedance2p0BatchCount,
// Pending types that need to be queried.
pub unhandled_request_state: Option<KinoviSeedance2p0RemainingItems>,
@@ -77,13 +80,12 @@ impl KinoviSeedance2p0DraftState {
)?;
}
let request = KinoviGenerateVideoRequest {
model_type: KinoviModelType::Seedance2Pro,
let request = GenerateSeedance2p0Request {
prompt: self.prompt.clone(),
aspect_ratio: self.aspect_ratio,
aspect_ratio: Some(self.aspect_ratio),
output_resolution: self.resolution,
duration_seconds: self.duration_seconds,
batch_count: self.batch_count,
batch_count: Some(self.batch_count),
start_frame_url,
end_frame_url,
reference_image_urls,
@@ -1,28 +1,31 @@
use seedance2pro_client::generate::video::generate_seedance_2p0::{
generate_seedance_2p0, GenerateSeedance2p0Args, GenerateSeedance2p0Request,
};
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::generate_video_response::{GenerateVideoResponse, Seedance2proVideoResponsePayload};
use seedance2pro_client::requests::generate_video::generate_video::{generate_video, GenerateVideoArgs, KinoviGenerateVideoRequest};
#[derive(Debug, Clone)]
pub struct KinoviSeedance2p0RequestState {
/// Final materialized request; ready to fire.
pub request: KinoviGenerateVideoRequest,
pub request: GenerateSeedance2p0Request,
}
impl KinoviSeedance2p0RequestState {
pub async fn send(&self, client: &RouterSeedance2ProClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
let session = &client.session;
let args = GenerateVideoArgs {
let args = GenerateSeedance2p0Args {
session,
host_override: None,
request: self.request.clone(), // TODO: Yuck.
request: self.request.clone(),
};
let response = generate_video(args)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Seedance2Pro(err)))?;
let response = generate_seedance_2p0(args)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Seedance2Pro(err)))?;
Ok(GenerateVideoResponse::Seedance2Pro(Seedance2proVideoResponsePayload {
order_id: response.order_id,
@@ -32,320 +35,3 @@ impl KinoviSeedance2p0RequestState {
}))
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use tokens::tokens::characters::CharacterToken;
use tokens::tokens::media_files::MediaFileToken;
use crate::api::character_list_ref::CharacterListRef;
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::provider::Provider;
use crate::api::video_list_ref::VideoListRef;
use crate::client::router_client::RouterClient;
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
use crate::generate::generate_video_v2::video_generation_draft_context::VideoGenerationDraftContext;
use crate::generate::generate_video_v2::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
use test_data::web::image_urls::{FOREST_BACKDROP_IMAGE_URL, JUNO_AT_LAKE_IMAGE_URL, WHITE_HOUSE_SUNSET_IMAGE_URL};
const STEAMPUNK_CLOWN_KINOVI_ID: &str = "char_1775176566518_sik0te";
const MOCHI_KINOVI_ID: &str = "char_1775177718294_g2pitx";
mod aspect_ratio_tests {
use super::*;
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn landscape() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A corgi running through a field of wildflowers at sunset.".to_string()),
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
..seedance2pro_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn portrait() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A cat sitting on a windowsill watching rain.".to_string()),
aspect_ratio: Some(CommonAspectRatio::TallNineBySixteen),
..seedance2pro_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn square() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A hummingbird hovering near a flower.".to_string()),
aspect_ratio: Some(CommonAspectRatio::Square),
..seedance2pro_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
assert_eq!(1, 2, "Inspect output above");
}
}
mod resolution_tests {
use super::*;
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn res_480p() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A shiba inu playing in autumn leaves.".to_string()),
resolution: Some(CommonResolution::FourEightyP),
..seedance2pro_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn res_720p() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A golden retriever catching a frisbee on the beach.".to_string()),
resolution: Some(CommonResolution::SevenTwentyP),
..seedance2pro_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn res_1080p() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A fox walking through a snowy forest.".to_string()),
resolution: Some(CommonResolution::TenEightyP),
..seedance2pro_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
assert_eq!(1, 2, "Inspect output above");
}
}
mod modality_tests {
use super::*;
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn text_to_video() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A whale breaching in the open ocean at dawn, cinematic.".to_string()),
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
..seedance2pro_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn keyframe_start_and_end_frame() {
let start_token = MediaFileToken::new("mf_start".to_string());
let end_token = MediaFileToken::new("mf_end".to_string());
let mut media_map = HashMap::new();
media_map.insert(start_token.clone(), JUNO_AT_LAKE_IMAGE_URL.to_string());
media_map.insert(end_token.clone(), FOREST_BACKDROP_IMAGE_URL.to_string());
let response = run_pipeline_with_media_map(GenerateVideoRequestBuilder {
prompt: Some("The dog walks from the lake to the forest.".to_string()),
start_frame: Some(ImageRef::MediaFileToken(start_token)),
end_frame: Some(ImageRef::MediaFileToken(end_token)),
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
..seedance2pro_builder()
}, media_map).await;
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn image_references() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("The dog in @2 runs through the scenery in @1 towards the building in @3.".to_string()),
reference_images: Some(ImageListRef::Urls(vec![
FOREST_BACKDROP_IMAGE_URL.to_string(),
JUNO_AT_LAKE_IMAGE_URL.to_string(),
WHITE_HOUSE_SUNSET_IMAGE_URL.to_string(),
])),
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
..seedance2pro_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn video_reference() {
// NB: This URL is a pre-uploaded video on the Seedance2Pro CDN from prior tests.
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("Change @video1 to a nighttime scene with moonlight.".to_string()),
reference_videos: Some(VideoListRef::Urls(vec![
"https://static.seedance2-pro.com/materials/20260315/1773594284659-3a46d231.mp4".to_string(),
])),
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
..seedance2pro_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn character_references() {
let char_clown = CharacterToken::new("char_clown".to_string());
let char_mochi = CharacterToken::new("char_mochi".to_string());
let mut character_map = HashMap::new();
character_map.insert(char_clown.clone(), STEAMPUNK_CLOWN_KINOVI_ID.to_string());
character_map.insert(char_mochi.clone(), MOCHI_KINOVI_ID.to_string());
let response = run_pipeline_with_maps(
GenerateVideoRequestBuilder {
prompt: Some("@Steampunk Clown and @Mochi are playing fetch in a sunny park.".to_string()),
reference_character_tokens: Some(CharacterListRef::CharacterTokens(vec![
char_clown,
char_mochi,
])),
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
..seedance2pro_builder()
},
HashMap::new(),
character_map,
).await;
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
assert_eq!(1, 2, "Inspect output above");
}
// TODO: Add audio reference test once a web-accessible audio URL is available in test_data.
}
// ── Helpers ──
fn seedance2pro_builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
provider: Provider::Seedance2Pro,
duration_seconds: Some(4),
video_batch_count: Some(1),
..Default::default()
}
}
fn get_seedance2pro_client() -> RouterClient {
let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/seedance2pro_cookies.txt")
.expect("Failed to read seedance2pro cookies");
let session = Seedance2ProSession::from_cookies_string(cookies.trim().to_string());
RouterClient::Seedance2Pro(RouterSeedance2ProClient::new(session))
}
/// Run the full pipeline: builder → build2 → finalize → send.
async fn run_pipeline(builder: GenerateVideoRequestBuilder) -> GenerateVideoResponse {
let client = get_seedance2pro_client();
let draft_or_request = builder.build2().expect("build2 should succeed");
let draft = match draft_or_request {
VideoGenerationDraftOrRequest::Draft(d) => d,
_ => panic!("expected Draft variant"),
};
let draft_context = VideoGenerationDraftContext {
client: Some(&client),
..Default::default()
};
let request = draft.finalize(draft_context).await.expect("finalize should succeed");
let response = request.send_request(&client).await.expect("send_request should succeed");
match &response {
GenerateVideoResponse::Seedance2Pro(p) => {
println!("task_id={}, order_id={}", p.task_id, p.order_id);
}
other => println!("response: {:?}", other),
}
response
}
/// Run the full pipeline with a media file token map for resolving references.
async fn run_pipeline_with_media_map(
builder: GenerateVideoRequestBuilder,
media_map: HashMap<MediaFileToken, String>,
) -> GenerateVideoResponse {
let client = get_seedance2pro_client();
let draft_or_request = builder.build2().expect("build2 should succeed");
let draft = match draft_or_request {
VideoGenerationDraftOrRequest::Draft(d) => d,
_ => panic!("expected Draft variant"),
};
let draft_context = VideoGenerationDraftContext {
client: Some(&client),
media_file_to_artcraft_url_map: Some(&media_map),
..Default::default()
};
let request = draft.finalize(draft_context).await.expect("finalize should succeed");
let response = request.send_request(&client).await.expect("send_request should succeed");
match &response {
GenerateVideoResponse::Seedance2Pro(p) => {
println!("task_id={}, order_id={}", p.task_id, p.order_id);
}
other => println!("response: {:?}", other),
}
response
}
/// Run the full pipeline with both a media map and character map.
async fn run_pipeline_with_maps(
builder: GenerateVideoRequestBuilder,
media_map: HashMap<MediaFileToken, String>,
character_map: HashMap<CharacterToken, String>,
) -> GenerateVideoResponse {
let client = get_seedance2pro_client();
let draft_or_request = builder.build2().expect("build2 should succeed");
let draft = match draft_or_request {
VideoGenerationDraftOrRequest::Draft(d) => d,
_ => panic!("expected Draft variant"),
};
let draft_context = VideoGenerationDraftContext {
client: Some(&client),
media_file_to_artcraft_url_map: Some(&media_map),
character_token_to_kinovi_id_map: Some(&character_map),
};
let request = draft.finalize(draft_context).await.expect("finalize should succeed");
let response = request.send_request(&client).await.expect("send_request should succeed");
match &response {
GenerateVideoResponse::Seedance2Pro(p) => {
println!("task_id={}, order_id={}", p.task_id, p.order_id);
}
other => println!("response: {:?}", other),
}
response
}
}
@@ -1,4 +1,8 @@
use seedance2pro_client::requests::generate_video::generate_video::{KinoviAspectRatio, KinoviBatchCount, KinoviOutputResolution};
use seedance2pro_client::generate::video::generate_seedance_2p0_fast::{
KinoviSeedance2p0FastAspectRatio as KinoviAspectRatio,
KinoviSeedance2p0FastBatchCount as KinoviBatchCount,
KinoviSeedance2p0FastOutputResolution as KinoviOutputResolution,
};
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::common_resolution::CommonResolution;
@@ -73,6 +77,7 @@ fn plan_aspect_ratio(
}
Some(CommonAspectRatio::WideFourByThree) => Ok(KinoviAspectRatio::Standard4x3),
Some(CommonAspectRatio::TallThreeByFour) => Ok(KinoviAspectRatio::Portrait3x4),
Some(CommonAspectRatio::WideTwentyOneByNine) => Ok(KinoviAspectRatio::UltraWide21x9),
// Mismatches — apply strategy
Some(unsupported) => match strategy {
@@ -95,7 +100,6 @@ fn nearest_aspect_ratio(aspect_ratio: CommonAspectRatio) -> KinoviAspectRatio {
match aspect_ratio {
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
@@ -208,7 +212,11 @@ fn plan_duration(
#[cfg(test)]
mod tests {
use seedance2pro_client::requests::generate_video::generate_video::{KinoviAspectRatio, KinoviBatchCount, KinoviOutputResolution};
use seedance2pro_client::generate::video::generate_seedance_2p0_fast::{
KinoviSeedance2p0FastAspectRatio as KinoviAspectRatio,
KinoviSeedance2p0FastBatchCount as KinoviBatchCount,
KinoviSeedance2p0FastOutputResolution as KinoviOutputResolution,
};
use tokens::tokens::characters::CharacterToken;
use tokens::tokens::media_files::MediaFileToken;
@@ -1,22 +1,23 @@
use seedance2pro_client::requests::generate_video::generate_video::{KinoviAspectRatio, KinoviBatchCount, KinoviGenerateVideoRequest, KinoviModelType, KinoviOutputResolution};
use seedance2pro_client::generate::video::generate_seedance_2p0_fast::{
GenerateSeedance2p0FastRequest, KinoviSeedance2p0FastBatchCount,
KinoviSeedance2p0FastOutputResolution,
};
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
use crate::generate::generate_video_v2::providers::kinovi::seedance_2p0_fast::draft::KinoviSeedance2p0FastDraftState;
use crate::generate::generate_video_v2::providers::kinovi::seedance_2p0_fast::request::KinoviSeedance2p0FastRequestState;
pub struct KinoviSeedance2p0FastCostState {
pub resolution: KinoviOutputResolution,
pub resolution: Option<KinoviSeedance2p0FastOutputResolution>,
pub duration_seconds: u8,
pub batch_count: KinoviBatchCount,
/// Forward-looking: Kinovi charges for this. We need to take it into
/// account in the future.
pub batch_count: Option<KinoviSeedance2p0FastBatchCount>,
pub has_video_reference: bool,
}
impl KinoviSeedance2p0FastCostState {
pub fn from_request(request: &KinoviSeedance2p0FastRequestState) -> Self {
Self {
resolution: request.request.output_resolution.unwrap_or(KinoviOutputResolution::SevenTwentyP),
resolution: request.request.output_resolution,
duration_seconds: request.request.duration_seconds,
batch_count: request.request.batch_count,
has_video_reference: request.request.reference_video_urls
@@ -32,27 +33,22 @@ impl KinoviSeedance2p0FastCostState {
.is_some();
Self {
resolution: draft.resolution.unwrap_or(KinoviOutputResolution::SevenTwentyP),
resolution: draft.resolution,
duration_seconds: draft.duration_seconds,
batch_count: draft.batch_count,
batch_count: Some(draft.batch_count),
has_video_reference,
}
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
let request = KinoviGenerateVideoRequest {
// Pricing factors
model_type: KinoviModelType::Seedance2Fast,
output_resolution: Some(self.resolution),
let pricing_request = GenerateSeedance2p0FastRequest {
output_resolution: self.resolution,
duration_seconds: self.duration_seconds,
batch_count: self.batch_count,
// NB: has_video_reference is tracked but does not affect price yet.
// When Kinovi starts charging for video references, add cost here.
// No impact on price
prompt: String::new(),
aspect_ratio: KinoviAspectRatio::Portrait9x16,
aspect_ratio: None,
start_frame_url: None,
end_frame_url: None,
reference_image_urls: None,
@@ -62,8 +58,8 @@ impl KinoviSeedance2p0FastCostState {
use_face_blur_hack: None,
};
let cost_in_credits = request.estimate_credits();
let cost_in_usd_cents = request.estimate_cost_in_usd_cents();
let cost_in_credits = pricing_request.estimate_credits();
let cost_in_usd_cents = pricing_request.estimate_cost_in_usd_cents();
VideoGenerationCostEstimate {
cost_in_credits: Some(cost_in_credits as u64),
@@ -80,6 +76,11 @@ impl KinoviSeedance2p0FastCostState {
mod tests {
use super::*;
use crate::api::common_resolution::CommonResolution;
use seedance2pro_client::generate::video::generate_seedance_2p0_fast::{
KinoviSeedance2p0FastOutputResolution as KinoviOutputResolution,
KinoviSeedance2p0FastBatchCount as KinoviBatchCount,
};
use crate::api::common_video_model::CommonVideoModel;
use crate::api::provider::Provider;
use crate::api::video_list_ref::VideoListRef;
@@ -90,51 +91,51 @@ mod tests {
// ── Direct estimate_cost() tests ──
// These must match the reference implementation values exactly.
// -- 720p (legacy pricing: 220 credits/$1, 28 credits/sec) --
// -- 720p (28 credits/sec, 193 credits/$1) --
mod pricing_720p {
use super::*;
#[test]
fn cost_720p_batch_1() {
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::One), 51);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::One), 64);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 10, KinoviBatchCount::One), 127);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::One), 191);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::One), 58);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::One), 73);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 10, KinoviBatchCount::One), 145);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::One), 218);
}
#[test]
fn cost_720p_batch_2() {
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Two), 127);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Two), 145);
}
#[test]
fn cost_720p_batch_4() {
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Four), 255);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Four), 290);
}
}
// -- 480p (new pricing: 193 credits/$1, 10 credits/sec) --
// -- 480p (14 credits/sec, 193 credits/$1) --
mod pricing_480p {
use super::*;
#[test]
fn cost_480p_batch_1() {
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 4, KinoviBatchCount::One), 21);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::One), 26);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 10, KinoviBatchCount::One), 52);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 15, KinoviBatchCount::One), 78);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 4, KinoviBatchCount::One), 29);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::One), 36);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 10, KinoviBatchCount::One), 73);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 15, KinoviBatchCount::One), 109);
}
#[test]
fn cost_480p_batch_2() {
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::Two), 52);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::Two), 73);
}
#[test]
fn cost_480p_batch_4() {
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::Four), 104);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::Four), 145);
}
}
@@ -174,9 +175,9 @@ mod tests {
#[test]
fn video_reference_does_not_affect_cost() {
let base = KinoviSeedance2p0FastCostState {
resolution: KinoviOutputResolution::SevenTwentyP,
resolution: Some(KinoviOutputResolution::SevenTwentyP),
duration_seconds: 5,
batch_count: KinoviBatchCount::One,
batch_count: Some(KinoviBatchCount::One),
has_video_reference: false,
};
let without = base.estimate_cost();
@@ -194,26 +195,26 @@ mod tests {
fn from_request_720p() {
let req = make_request_state(Some(KinoviOutputResolution::SevenTwentyP), 5, KinoviBatchCount::One, false);
let cost = KinoviSeedance2p0FastCostState::from_request(&req);
assert!(matches!(cost.resolution, KinoviOutputResolution::SevenTwentyP));
assert!(matches!(cost.resolution, Some(KinoviOutputResolution::SevenTwentyP)));
assert_eq!(cost.duration_seconds, 5);
assert!(matches!(cost.batch_count, KinoviBatchCount::One));
assert!(matches!(cost.batch_count, Some(KinoviBatchCount::One)));
assert!(!cost.has_video_reference);
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(64));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(73));
}
#[test]
fn from_request_none_defaults_to_720p() {
let req = make_request_state(None, 5, KinoviBatchCount::One, false);
let cost = KinoviSeedance2p0FastCostState::from_request(&req);
assert!(matches!(cost.resolution, KinoviOutputResolution::SevenTwentyP));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(64));
assert!(cost.resolution.is_none());
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(73));
}
#[test]
fn from_request_480p() {
let req = make_request_state(Some(KinoviOutputResolution::FourEightyP), 5, KinoviBatchCount::One, false);
let cost = KinoviSeedance2p0FastCostState::from_request(&req);
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(26));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(36));
}
#[test]
@@ -222,7 +223,7 @@ mod tests {
let cost = KinoviSeedance2p0FastCostState::from_request(&req);
assert!(cost.has_video_reference);
// Video refs don't affect cost yet
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(64));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(73));
}
#[test]
@@ -242,19 +243,19 @@ mod tests {
fn from_draft_720p_default() {
let draft = make_draft(5, 1, None, false);
let cost = KinoviSeedance2p0FastCostState::from_draft(&draft);
assert!(matches!(cost.resolution, KinoviOutputResolution::SevenTwentyP));
assert!(cost.resolution.is_none());
assert_eq!(cost.duration_seconds, 5);
assert!(matches!(cost.batch_count, KinoviBatchCount::One));
assert!(matches!(cost.batch_count, Some(KinoviBatchCount::One)));
assert!(!cost.has_video_reference);
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(64));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(73));
}
#[test]
fn from_draft_480p() {
let draft = make_draft(5, 1, Some(CommonResolution::FourEightyP), false);
let cost = KinoviSeedance2p0FastCostState::from_draft(&draft);
assert!(matches!(cost.resolution, KinoviOutputResolution::FourEightyP));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(26));
assert!(matches!(cost.resolution, Some(KinoviOutputResolution::FourEightyP)));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(36));
}
#[test]
@@ -263,7 +264,7 @@ mod tests {
let cost = KinoviSeedance2p0FastCostState::from_draft(&draft);
assert!(cost.has_video_reference);
// Video refs don't affect cost yet
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(64));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(73));
}
#[test]
@@ -278,8 +279,8 @@ mod tests {
let draft = make_draft(15, 2, Some(CommonResolution::SevenTwentyP), false);
let cost = KinoviSeedance2p0FastCostState::from_draft(&draft);
assert_eq!(cost.duration_seconds, 15);
assert!(matches!(cost.batch_count, KinoviBatchCount::Two));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(382));
assert!(matches!(cost.batch_count, Some(KinoviBatchCount::Two)));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(435));
}
}
@@ -322,7 +323,7 @@ mod tests {
#[test]
fn credits_480p() {
assert_eq!(credits(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::One), 50);
assert_eq!(credits(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::One), 70);
}
}
@@ -333,7 +334,10 @@ mod tests {
duration_seconds: u8,
batch_count: KinoviBatchCount,
) -> u64 {
KinoviSeedance2p0FastCostState { resolution, duration_seconds, batch_count, has_video_reference: false }
KinoviSeedance2p0FastCostState {
resolution: Some(resolution), duration_seconds, batch_count: Some(batch_count),
has_video_reference: false,
}
.estimate_cost()
.cost_in_usd_cents
.unwrap()
@@ -344,7 +348,10 @@ mod tests {
duration_seconds: u8,
batch_count: KinoviBatchCount,
) -> u64 {
KinoviSeedance2p0FastCostState { resolution, duration_seconds, batch_count, has_video_reference: false }
KinoviSeedance2p0FastCostState {
resolution: Some(resolution), duration_seconds, batch_count: Some(batch_count),
has_video_reference: false,
}
.estimate_cost()
.cost_in_credits
.unwrap()
@@ -395,13 +402,12 @@ mod tests {
};
KinoviSeedance2p0FastRequestState {
request: KinoviGenerateVideoRequest {
model_type: KinoviModelType::Seedance2Fast,
request: GenerateSeedance2p0FastRequest {
prompt: "test".to_string(),
aspect_ratio: KinoviAspectRatio::Landscape16x9,
aspect_ratio: None,
output_resolution: resolution,
duration_seconds,
batch_count,
batch_count: Some(batch_count),
start_frame_url: None,
end_frame_url: None,
reference_image_urls: None,
@@ -1,4 +1,7 @@
use seedance2pro_client::requests::generate_video::generate_video::{KinoviAspectRatio, KinoviBatchCount, KinoviGenerateVideoRequest, KinoviModelType, KinoviOutputResolution};
use seedance2pro_client::generate::video::generate_seedance_2p0_fast::{
GenerateSeedance2p0FastRequest, KinoviSeedance2p0FastAspectRatio,
KinoviSeedance2p0FastBatchCount, KinoviSeedance2p0FastOutputResolution,
};
use crate::api::audio_list_ref::AudioListRef;
use crate::api::character_list_ref::CharacterListRef;
@@ -16,15 +19,12 @@ use crate::generate::generate_video_v2::video_generation_draft_context::VideoGen
#[derive(Debug, Clone)]
pub struct KinoviSeedance2p0FastDraftState {
// Materialized / finalized types
pub prompt: String,
pub aspect_ratio: KinoviAspectRatio,
pub resolution: Option<KinoviOutputResolution>,
pub aspect_ratio: KinoviSeedance2p0FastAspectRatio,
pub resolution: Option<KinoviSeedance2p0FastOutputResolution>,
pub duration_seconds: u8,
pub batch_count: KinoviBatchCount,
pub batch_count: KinoviSeedance2p0FastBatchCount,
// Pending types that need to be queried.
pub unhandled_request_state: Option<KinoviSeedance2p0FastRemainingItems>,
}
@@ -77,13 +77,12 @@ impl KinoviSeedance2p0FastDraftState {
)?;
}
let request = KinoviGenerateVideoRequest {
model_type: KinoviModelType::Seedance2Fast,
let request = GenerateSeedance2p0FastRequest {
prompt: self.prompt.clone(),
aspect_ratio: self.aspect_ratio,
aspect_ratio: Some(self.aspect_ratio),
output_resolution: self.resolution,
duration_seconds: self.duration_seconds,
batch_count: self.batch_count,
batch_count: Some(self.batch_count),
start_frame_url,
end_frame_url,
reference_image_urls,
@@ -1,28 +1,31 @@
use seedance2pro_client::generate::video::generate_seedance_2p0_fast::{
generate_seedance_2p0_fast, GenerateSeedance2p0FastArgs, GenerateSeedance2p0FastRequest,
};
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::generate_video_response::{GenerateVideoResponse, Seedance2proVideoResponsePayload};
use seedance2pro_client::requests::generate_video::generate_video::{generate_video, GenerateVideoArgs, KinoviGenerateVideoRequest};
#[derive(Debug, Clone)]
pub struct KinoviSeedance2p0FastRequestState {
/// Final materialized request; ready to fire.
pub request: KinoviGenerateVideoRequest,
pub request: GenerateSeedance2p0FastRequest,
}
impl KinoviSeedance2p0FastRequestState {
pub async fn send(&self, client: &RouterSeedance2ProClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
let session = &client.session;
let args = GenerateVideoArgs {
let args = GenerateSeedance2p0FastArgs {
session,
host_override: None,
request: self.request.clone(), // TODO: Yuck.
request: self.request.clone(),
};
let response = generate_video(args)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Seedance2Pro(err)))?;
let response = generate_seedance_2p0_fast(args)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Seedance2Pro(err)))?;
Ok(GenerateVideoResponse::Seedance2Pro(Seedance2proVideoResponsePayload {
order_id: response.order_id,
@@ -32,229 +35,3 @@ impl KinoviSeedance2p0FastRequestState {
}))
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use tokens::tokens::characters::CharacterToken;
use tokens::tokens::media_files::MediaFileToken;
use crate::api::character_list_ref::CharacterListRef;
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::common_resolution::CommonResolution;
use crate::api::common_video_model::CommonVideoModel;
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::provider::Provider;
use crate::api::video_list_ref::VideoListRef;
use crate::client::router_client::RouterClient;
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
use crate::generate::generate_video_v2::video_generation_draft_context::VideoGenerationDraftContext;
use crate::generate::generate_video_v2::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
use test_data::web::image_urls::{FOREST_BACKDROP_IMAGE_URL, JUNO_AT_LAKE_IMAGE_URL, WHITE_HOUSE_SUNSET_IMAGE_URL};
mod aspect_ratio_tests {
use super::*;
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn landscape() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A corgi running through a field of wildflowers at sunset.".to_string()),
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
..seedance2pro_fast_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn portrait() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A cat sitting on a windowsill watching rain.".to_string()),
aspect_ratio: Some(CommonAspectRatio::TallNineBySixteen),
..seedance2pro_fast_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn square() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A hummingbird hovering near a flower.".to_string()),
aspect_ratio: Some(CommonAspectRatio::Square),
..seedance2pro_fast_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
assert_eq!(1, 2, "Inspect output above");
}
}
mod resolution_tests {
use super::*;
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn res_480p() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A shiba inu playing in autumn leaves.".to_string()),
resolution: Some(CommonResolution::FourEightyP),
..seedance2pro_fast_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn res_720p() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A golden retriever catching a frisbee on the beach.".to_string()),
resolution: Some(CommonResolution::SevenTwentyP),
..seedance2pro_fast_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
assert_eq!(1, 2, "Inspect output above");
}
}
mod modality_tests {
use super::*;
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn text_to_video() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A whale breaching in the open ocean at dawn, cinematic.".to_string()),
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
..seedance2pro_fast_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn keyframe_start_and_end_frame() {
let start_token = MediaFileToken::new("mf_start".to_string());
let end_token = MediaFileToken::new("mf_end".to_string());
let mut media_map = HashMap::new();
media_map.insert(start_token.clone(), JUNO_AT_LAKE_IMAGE_URL.to_string());
media_map.insert(end_token.clone(), FOREST_BACKDROP_IMAGE_URL.to_string());
let response = run_pipeline_with_media_map(GenerateVideoRequestBuilder {
prompt: Some("The dog walks from the lake to the forest.".to_string()),
start_frame: Some(ImageRef::MediaFileToken(start_token)),
end_frame: Some(ImageRef::MediaFileToken(end_token)),
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
..seedance2pro_fast_builder()
}, media_map).await;
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn image_references() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("The dog in @2 runs through the scenery in @1 towards the building in @3.".to_string()),
reference_images: Some(ImageListRef::Urls(vec![
FOREST_BACKDROP_IMAGE_URL.to_string(),
JUNO_AT_LAKE_IMAGE_URL.to_string(),
WHITE_HOUSE_SUNSET_IMAGE_URL.to_string(),
])),
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
..seedance2pro_fast_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
assert_eq!(1, 2, "Inspect output above");
}
}
// ── Helpers ──
fn seedance2pro_fast_builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
model: CommonVideoModel::Seedance2p0Fast,
provider: Provider::Seedance2Pro,
duration_seconds: Some(4),
video_batch_count: Some(1),
..Default::default()
}
}
fn get_seedance2pro_client() -> RouterClient {
let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/seedance2pro_cookies.txt")
.expect("Failed to read seedance2pro cookies");
let session = Seedance2ProSession::from_cookies_string(cookies.trim().to_string());
RouterClient::Seedance2Pro(RouterSeedance2ProClient::new(session))
}
/// Run the full pipeline: builder -> build2 -> finalize -> send.
async fn run_pipeline(builder: GenerateVideoRequestBuilder) -> GenerateVideoResponse {
let client = get_seedance2pro_client();
let draft_or_request = builder.build2().expect("build2 should succeed");
let draft = match draft_or_request {
VideoGenerationDraftOrRequest::Draft(d) => d,
_ => panic!("expected Draft variant"),
};
let draft_context = VideoGenerationDraftContext {
client: Some(&client),
..Default::default()
};
let request = draft.finalize(draft_context).await.expect("finalize should succeed");
let response = request.send_request(&client).await.expect("send_request should succeed");
match &response {
GenerateVideoResponse::Seedance2Pro(p) => {
println!("task_id={}, order_id={}", p.task_id, p.order_id);
}
other => println!("response: {:?}", other),
}
response
}
/// Run the full pipeline with a media file token map for resolving references.
async fn run_pipeline_with_media_map(
builder: GenerateVideoRequestBuilder,
media_map: HashMap<MediaFileToken, String>,
) -> GenerateVideoResponse {
let client = get_seedance2pro_client();
let draft_or_request = builder.build2().expect("build2 should succeed");
let draft = match draft_or_request {
VideoGenerationDraftOrRequest::Draft(d) => d,
_ => panic!("expected Draft variant"),
};
let draft_context = VideoGenerationDraftContext {
client: Some(&client),
media_file_to_artcraft_url_map: Some(&media_map),
..Default::default()
};
let request = draft.finalize(draft_context).await.expect("finalize should succeed");
let response = request.send_request(&client).await.expect("send_request should succeed");
match &response {
GenerateVideoResponse::Seedance2Pro(p) => {
println!("task_id={}, order_id={}", p.task_id, p.order_id);
}
other => println!("response: {:?}", other),
}
response
}
}
@@ -38,6 +38,7 @@ pub struct GenerateSeedance2p0Request {
#[derive(Debug, Clone, Copy)]
pub enum KinoviSeedance2p0AspectRatio {
Landscape16x9,
UltraWide21x9,
Portrait9x16,
Square1x1,
Standard4x3,
@@ -155,6 +156,7 @@ pub async fn generate_seedance_2p0(
fn map_aspect_ratio(ar: Option<KinoviSeedance2p0AspectRatio>) -> KinoviAspectRatioRaw {
match ar {
Some(KinoviSeedance2p0AspectRatio::Landscape16x9) => KinoviAspectRatioRaw::Landscape16x9,
Some(KinoviSeedance2p0AspectRatio::UltraWide21x9) => KinoviAspectRatioRaw::UltraWide21x9,
Some(KinoviSeedance2p0AspectRatio::Portrait9x16) => KinoviAspectRatioRaw::Portrait9x16,
Some(KinoviSeedance2p0AspectRatio::Square1x1) => KinoviAspectRatioRaw::Square1x1,
Some(KinoviSeedance2p0AspectRatio::Standard4x3) => KinoviAspectRatioRaw::Landscape4x3,
@@ -430,6 +432,7 @@ mod tests {
let ratios = [
KinoviSeedance2p0AspectRatio::Landscape16x9,
KinoviSeedance2p0AspectRatio::UltraWide21x9,
KinoviSeedance2p0AspectRatio::Portrait9x16,
KinoviSeedance2p0AspectRatio::Square1x1,
KinoviSeedance2p0AspectRatio::Standard4x3,
@@ -528,6 +531,70 @@ mod tests {
}
}
mod ultra_wide {
use super::*;
#[tokio::test]
#[ignore]
async fn test_text_to_video_21x9() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
let result = generate_seedance_2p0(GenerateSeedance2p0Args {
session: &session,
host_override: None,
request: GenerateSeedance2p0Request {
prompt: "A corgi is riding on the back of a sauropod dinosaur".to_string(),
aspect_ratio: Some(KinoviSeedance2p0AspectRatio::UltraWide21x9),
output_resolution: None,
batch_count: None,
duration_seconds: 5,
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,
},
}).await?;
println!("t2v 21:9 — task_id={}, order_id={}", result.task_id, result.order_id);
assert!(!result.task_id.is_empty());
assert_eq!(1, 2);
Ok(())
}
#[tokio::test]
#[ignore]
async fn test_keyframe_21x9() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
let start_frame_url = upload_test_image(&session, test_data::web::image_urls::WIDE_CORGI_SHIBA_TREASURE_OCEAN_URL).await?;
let result = generate_seedance_2p0(GenerateSeedance2p0Args {
session: &session,
host_override: None,
request: GenerateSeedance2p0Request {
prompt: "The dogs sail across the ocean on a treasure ship.".to_string(),
aspect_ratio: Some(KinoviSeedance2p0AspectRatio::UltraWide21x9),
output_resolution: None,
batch_count: None,
duration_seconds: 5,
start_frame_url: Some(start_frame_url),
end_frame_url: None,
reference_image_urls: None,
reference_video_urls: None,
reference_audio_urls: None,
character_ids: None,
use_face_blur_hack: None,
},
}).await?;
println!("keyframe 21:9 — task_id={}, order_id={}", result.task_id, result.order_id);
assert!(!result.task_id.is_empty());
assert_eq!(1, 2);
Ok(())
}
}
mod keyframe {
use super::*;
@@ -38,6 +38,7 @@ pub struct GenerateSeedance2p0FastRequest {
#[derive(Debug, Clone, Copy)]
pub enum KinoviSeedance2p0FastAspectRatio {
Landscape16x9,
UltraWide21x9,
Portrait9x16,
Square1x1,
Standard4x3,
@@ -152,6 +153,7 @@ pub async fn generate_seedance_2p0_fast(
fn map_aspect_ratio(ar: Option<KinoviSeedance2p0FastAspectRatio>) -> KinoviAspectRatioRaw {
match ar {
Some(KinoviSeedance2p0FastAspectRatio::Landscape16x9) => KinoviAspectRatioRaw::Landscape16x9,
Some(KinoviSeedance2p0FastAspectRatio::UltraWide21x9) => KinoviAspectRatioRaw::UltraWide21x9,
Some(KinoviSeedance2p0FastAspectRatio::Portrait9x16) => KinoviAspectRatioRaw::Portrait9x16,
Some(KinoviSeedance2p0FastAspectRatio::Square1x1) => KinoviAspectRatioRaw::Square1x1,
Some(KinoviSeedance2p0FastAspectRatio::Standard4x3) => KinoviAspectRatioRaw::Landscape4x3,
@@ -382,6 +384,7 @@ mod tests {
let ratios = [
KinoviSeedance2p0FastAspectRatio::Landscape16x9,
KinoviSeedance2p0FastAspectRatio::UltraWide21x9,
KinoviSeedance2p0FastAspectRatio::Portrait9x16,
KinoviSeedance2p0FastAspectRatio::Square1x1,
KinoviSeedance2p0FastAspectRatio::Standard4x3,
@@ -480,6 +483,70 @@ mod tests {
}
}
mod ultra_wide {
use super::*;
#[tokio::test]
#[ignore]
async fn test_text_to_video_21x9() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
let result = generate_seedance_2p0_fast(GenerateSeedance2p0FastArgs {
session: &session,
host_override: None,
request: GenerateSeedance2p0FastRequest {
prompt: "A shiba is riding on the back of a sauropod dinosaur".to_string(),
aspect_ratio: Some(KinoviSeedance2p0FastAspectRatio::UltraWide21x9),
output_resolution: Some(KinoviSeedance2p0FastOutputResolution::FourEightyP),
batch_count: None,
duration_seconds: 5,
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,
},
}).await?;
println!("fast t2v 21:9 480p — task_id={}, order_id={}", result.task_id, result.order_id);
assert!(!result.task_id.is_empty());
assert_eq!(1, 2);
Ok(())
}
#[tokio::test]
#[ignore]
async fn test_keyframe_21x9() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
let start_frame_url = upload_test_image(&session, test_data::web::image_urls::WIDE_CORGI_SHIBA_TREASURE_OCEAN_URL).await?;
let result = generate_seedance_2p0_fast(GenerateSeedance2p0FastArgs {
session: &session,
host_override: None,
request: GenerateSeedance2p0FastRequest {
prompt: "The dogs in @1 set sail across the ocean on a treasure ship.".to_string(),
aspect_ratio: Some(KinoviSeedance2p0FastAspectRatio::UltraWide21x9),
output_resolution: None,
batch_count: None,
duration_seconds: 5,
start_frame_url: None,
end_frame_url: None,
reference_image_urls: None,
reference_video_urls: Some(vec![start_frame_url]),
reference_audio_urls: None,
character_ids: None,
use_face_blur_hack: None,
},
}).await?;
println!("fast keyframe 21:9 — task_id={}, order_id={}", result.task_id, result.order_id);
assert!(!result.task_id.is_empty());
assert_eq!(1, 2);
Ok(())
}
}
mod keyframe {
use super::*;
@@ -172,6 +172,8 @@ impl KinoviGenerateVideoRequest {
pub enum KinoviAspectRatio {
/// 16:9 landscape (1280x720)
Landscape16x9,
/// 21:9 ultra-wide (1280x540)
UltraWide21x9,
/// 9:16 portrait (720x1280)
Portrait9x16,
/// 1:1 square (720x720)
@@ -186,6 +188,7 @@ impl KinoviAspectRatio {
fn as_str(&self) -> &'static str {
match self {
Self::Landscape16x9 => "1280x720",
Self::UltraWide21x9 => "1280x540",
Self::Portrait9x16 => "720x1280",
Self::Square1x1 => "720x720",
Self::Standard4x3 => "960x720",
@@ -181,6 +181,8 @@ impl WorkflowRunTaskRequest {
pub enum KinoviAspectRatioRaw {
/// 16:9 landscape (1280x720)
Landscape16x9,
/// 21:9 ultra-wide (1280x540)
UltraWide21x9,
/// 9:16 portrait (720x1280)
Portrait9x16,
/// 1:1 square (720x720)
@@ -195,6 +197,7 @@ impl KinoviAspectRatioRaw {
fn as_str(&self) -> &'static str {
match self {
Self::Landscape16x9 => "1280x720",
Self::UltraWide21x9 => "1280x540",
Self::Portrait9x16 => "720x1280",
Self::Square1x1 => "720x720",
Self::Landscape4x3 => "960x720",
@@ -37,16 +37,20 @@ pub async fn handle_video_artcraft(
None => {
Err(GenerateError::no_model_specified())
},
// Handled via router
Some(VideoModel::Kling3p0Pro) => handle_artcraft_video_via_router(request, app_env_configs, &creds, CommonVideoModel::Kling3p0Pro, GenerationModel::Kling3p0Pro).await,
Some(VideoModel::Kling3p0Standard) => handle_artcraft_video_via_router(request, app_env_configs, &creds, CommonVideoModel::Kling3p0Standard, GenerationModel::Kling3p0Standard).await,
Some(VideoModel::Seedance1p5Pro) => handle_artcraft_video_via_router(request, app_env_configs, &creds, CommonVideoModel::Seedance1p5Pro, GenerationModel::Seedance1p5Pro).await,
Some(VideoModel::Seedance2p0) => handle_artcraft_video_via_router(request, app_env_configs, &creds, CommonVideoModel::Seedance2p0, GenerationModel::Seedance2p0).await,
Some(VideoModel::Seedance2p0Fast) => handle_artcraft_video_via_router(request, app_env_configs, &creds, CommonVideoModel::Seedance2p0Fast, GenerationModel::Seedance2p0Fast).await,
Some(VideoModel::HappyHorse1p0) => handle_artcraft_video_via_router(request, app_env_configs, &creds, CommonVideoModel::HappyHorse1p0, GenerationModel::HappyHorse1p0).await,
// Legacy endpoints
Some(VideoModel::Kling16Pro) => handle_artcraft_kling_1p6_pro(request, app_env_configs, &creds).await,
Some(VideoModel::Kling21Pro) => handle_artcraft_kling_2p1_pro(request, app_env_configs, &creds).await,
Some(VideoModel::Kling21Master) => handle_artcraft_kling_2p1_master(request, app_env_configs, &creds).await,
Some(VideoModel::Kling2p5TurboPro) => handle_artcraft_kling_2p5_turbo_pro(request, app_env_configs, &creds).await,
Some(VideoModel::Kling2p6Pro) => handle_artcraft_kling_2p6_pro(request, app_env_configs, &creds).await,
Some(VideoModel::Kling3p0Standard) => handle_artcraft_video_via_router(request, app_env_configs, &creds, CommonVideoModel::Kling3p0Standard, GenerationModel::Kling3p0Standard).await,
Some(VideoModel::Kling3p0Pro) => handle_artcraft_video_via_router(request, app_env_configs, &creds, CommonVideoModel::Kling3p0Pro, GenerationModel::Kling3p0Pro).await,
Some(VideoModel::Seedance10Lite) => handle_artcraft_seedance_1_lite(request, app_env_configs, &creds).await,
Some(VideoModel::Seedance1p5Pro) => handle_artcraft_video_via_router(request, app_env_configs, &creds, CommonVideoModel::Seedance1p5Pro, GenerationModel::Seedance1p5Pro).await,
Some(VideoModel::Seedance2p0) => handle_artcraft_seedance_2p0(request, app_env_configs, &creds).await,
Some(VideoModel::Sora2) => handle_artcraft_sora2(request, app_env_configs, &creds).await,
Some(VideoModel::Sora2Pro) => handle_artcraft_sora2_pro(request, app_env_configs, &creds).await,
Some(VideoModel::Veo2) => handle_artcraft_veo2(request, app_env_configs, &creds).await,
@@ -3,18 +3,23 @@ use crate::core::commands::enqueue::image_to_video::enqueue_image_to_video_comma
use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess;
use crate::core::events::generation_events::common::GenerationModel;
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
use artcraft_router::api::audio_list_ref::AudioListRef;
use artcraft_router::api::character_list_ref::CharacterListRef;
use artcraft_router::api::common_video_model::CommonVideoModel;
use artcraft_router::api::image_list_ref::ImageListRef;
use artcraft_router::api::image_ref::ImageRef;
use artcraft_router::api::provider::Provider;
use artcraft_router::api::video_list_ref::VideoListRef;
use artcraft_router::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use artcraft_router::client::router_artcraft_client::RouterArtcraftClient;
use artcraft_router::client::router_client::RouterClient;
use artcraft_router::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use artcraft_router::generate::generate_video::generate_video_response::GenerateVideoResponse;
use artcraft_router::generate::generate_video_v2::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use enums::common::generation_provider::GenerationProvider;
use enums::tauri::tasks::task_type::TaskType;
use log::{error, info};
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
pub(super) async fn handle_artcraft_video_via_router(
request: &EnqueueImageToVideoRequest,
@@ -31,44 +36,43 @@ pub(super) async fn handle_artcraft_video_via_router(
let start_frame = request.image_media_token.clone().map(ImageRef::MediaFileToken);
let end_frame = request.end_frame_image_media_token.clone().map(ImageRef::MediaFileToken);
let reference_images = request.reference_image_media_tokens.clone().map(ImageListRef::MediaFileTokens);
let reference_videos = request.reference_video_media_tokens.clone().map(VideoListRef::MediaFileTokens);
let reference_audio = request.reference_audio_media_tokens.clone().map(AudioListRef::MediaFileTokens);
let reference_character_tokens = request.reference_character_tokens.clone().map(CharacterListRef::CharacterTokens);
let router_request = GenerateVideoRequestBuilder {
model,
provider: Provider::Artcraft,
prompt: request.prompt.clone(),
negative_prompt: None,
start_frame,
end_frame,
reference_images: None,
reference_videos: None,
reference_audio: None,
reference_character_tokens: request.reference_character_tokens.clone().map(CharacterListRef::CharacterTokens),
resolution: None,
reference_images,
reference_videos,
reference_audio,
reference_character_tokens,
resolution: request.resolution,
aspect_ratio: request.aspect_ratio,
duration_seconds: request.duration_seconds,
video_batch_count: request.video_batch_count,
generate_audio: request.generate_audio,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
idempotency_token: None,
negative_prompt: None,
};
let plan = router_request.build()?;
info!("Video Generation Plan: {:?}", plan);
let response = match plan.generate_video(&client).await {
Ok(resp) => {
info!("Successfully enqueued.");
resp
}
Err(err) => {
error!("Failed to enqueue: {:?}", err);
return Err(GenerateError::from(err));
}
let response = if router_request.use_new_builder() {
info!("Building request for artcraft_router (v2 pipeline)...");
generate_via_v2(router_request, &client).await?
} else {
info!("Building request for artcraft_router (v1 pipeline)...");
generate_via_v1(router_request, &client).await?
};
let job_id = response.get_artcraft_payload()
.map(|p| p.inference_job_token.to_string())
.ok_or(GenerateError::ResponseHadNoJobTokens)?;
.map(|p| p.inference_job_token.to_string())
.ok_or(GenerateError::ResponseHadNoJobTokens)?;
Ok(TaskEnqueueSuccess {
task_type: TaskType::VideoGeneration,
@@ -77,3 +81,43 @@ pub(super) async fn handle_artcraft_video_via_router(
provider_job_id: Some(job_id),
})
}
/// V1 pipeline: build → plan → generate_video.
async fn generate_via_v1(
router_request: GenerateVideoRequestBuilder,
client: &RouterClient,
) -> Result<GenerateVideoResponse, GenerateError> {
let plan = router_request.build()?;
let response = plan.generate_video(client).await.map_err(|err| {
error!("V1 failed to enqueue: {:?}", err);
GenerateError::from(err)
})?;
info!("V1 successfully enqueued.");
Ok(response)
}
/// V2 pipeline: build2 → send_request (Artcraft skips draft phase).
async fn generate_via_v2(
router_request: GenerateVideoRequestBuilder,
client: &RouterClient,
) -> Result<GenerateVideoResponse, GenerateError> {
let draft_or_request = router_request.build2()?;
let request = match draft_or_request {
VideoGenerationDraftOrRequest::Request(r) => r,
VideoGenerationDraftOrRequest::Draft(_) => {
error!("Unexpected Draft variant for Artcraft provider");
return Err(GenerateError::NotYetImplemented("Artcraft provider should not produce a draft request".to_string()));
}
};
let response = request.send_request(client).await.map_err(|err| {
error!("V2 failed to enqueue: {:?}", err);
GenerateError::from(err)
})?;
info!("V2 successfully enqueued.");
Ok(response)
}
@@ -1,17 +1,14 @@
use crate::core::commands::enqueue::common::notify_frontend_of_errors::notify_frontend_of_errors;
use crate::core::commands::enqueue::generate_error::{BadInputReason, GenerateError, MissingCredentialsReason, ProviderFailureReason};
use crate::core::commands::enqueue::generate_error::{BadInputReason, GenerateError, MissingCredentialsReason};
use crate::core::commands::enqueue::image_to_video::artcraft::handle_artcraft_video::handle_video_artcraft;
use crate::core::commands::enqueue::image_to_video::grok::handle_grok_video::handle_grok_video;
use crate::core::commands::enqueue::image_to_video::sora2::handle_sora_sora2::handle_sora_sora2;
use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess;
use crate::core::commands::enqueue::text_to_image::enqueue_text_to_image_command::TextToImageModel;
use crate::core::commands::response::failure_response_wrapper::{CommandErrorResponseWrapper, CommandErrorStatus};
use crate::core::commands::response::shorthand::Response;
use crate::core::commands::response::success_response_wrapper::SerializeMarker;
use crate::core::events::basic_sendable_event_trait::BasicSendableEvent;
use crate::core::events::functional_events::credits_balance_changed_event::CreditsBalanceChangedEvent;
use crate::core::events::generation_events::common::GenerationAction;
use crate::core::events::generation_events::generation_enqueue_failure_event::GenerationEnqueueFailureEvent;
use crate::core::events::generation_events::generation_enqueue_success_event::GenerationEnqueueSuccessEvent;
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
use crate::core::state::artcraft_usage_tracker::artcraft_usage_tracker::ArtcraftUsageTracker;
@@ -24,6 +21,7 @@ use crate::services::sora::state::sora_credential_manager::SoraCredentialManager
use crate::services::sora::state::sora_task_queue::SoraTaskQueue;
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
use artcraft_router::api::common_aspect_ratio::CommonAspectRatio;
use artcraft_router::api::common_resolution::CommonResolution;
use enums::common::generation_provider::GenerationProvider;
use enums::tauri::ux::tauri_command_caller::TauriCommandCaller;
use log::{error, info, warn};
@@ -61,6 +59,9 @@ pub enum VideoModel {
#[serde(rename = "kling_3p0_pro")]
Kling3p0Pro,
#[serde(rename = "happy_horse_1p0")]
HappyHorse1p0,
#[serde(rename = "seedance_1.0_lite")]
Seedance10Lite,
@@ -69,6 +70,9 @@ pub enum VideoModel {
#[serde(rename = "seedance_2p0")]
Seedance2p0,
#[serde(rename = "seedance_2p0_fast")]
Seedance2p0Fast,
#[serde(rename = "sora_2")]
Sora2,
@@ -141,6 +145,10 @@ pub struct EnqueueImageToVideoRequest {
/// Aspect ratio for the generated video.
pub aspect_ratio: Option<CommonAspectRatio>,
/// OPTIONAL.
/// Resolution for the generated video.
pub resolution: Option<CommonResolution>,
/// OPTIONAL.
/// Duration of the generated video in seconds.
pub duration_seconds: Option<u16>,
@@ -88,9 +88,11 @@ impl TaskEnqueueSuccess{
Some(GenerationModel::Kling2p6Pro) => Some(TaskModelType::Kling2p6Pro),
Some(GenerationModel::Kling3p0Standard) => Some(TaskModelType::Kling3p0Standard),
Some(GenerationModel::Kling3p0Pro) => Some(TaskModelType::Kling3p0Pro),
Some(GenerationModel::HappyHorse1p0) => Some(TaskModelType::HappyHorse1p0),
Some(GenerationModel::Seedance10Lite) => Some(TaskModelType::Seedance10Lite),
Some(GenerationModel::Seedance1p5Pro) => Some(TaskModelType::Seedance1p5Pro),
Some(GenerationModel::Seedance2p0) => Some(TaskModelType::Seedance2p0),
Some(GenerationModel::Seedance2p0Fast) => Some(TaskModelType::Seedance2p0Fast),
Some(GenerationModel::Sora2) => Some(TaskModelType::Sora2),
Some(GenerationModel::Sora2Pro) => Some(TaskModelType::Sora2Pro),
Some(GenerationModel::Veo2) => Some(TaskModelType::Veo2),
@@ -95,6 +95,8 @@ pub enum GenerationModel {
Kling3p0Standard,
#[serde(rename = "kling_3p0_pro")]
Kling3p0Pro,
#[serde(rename = "happy_horse_1p0")]
HappyHorse1p0,
#[serde(rename = "recraft_3")]
Recraft3,
#[serde(rename = "sora")]
@@ -109,6 +111,8 @@ pub enum GenerationModel {
Seedance1p5Pro,
#[serde(rename = "seedance_2p0")]
Seedance2p0,
#[serde(rename = "seedance_2p0_fast")]
Seedance2p0Fast,
#[serde(rename = "veo_2")]
Veo2,
#[serde(rename = "veo_3")]
+1 -1
View File
@@ -1,4 +1,4 @@
/// The version of the ArtCraft desktop app reported in the UI and via Analytics.
/// This should match the CI builds.
pub const ARTCRAFT_VERSION: &str = "0.30.0";
pub const ARTCRAFT_VERSION: &str = "0.31.0";
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2.0.0-rc",
"productName": "ArtCraft",
"version": "0.30.0",
"version": "0.31.0",
"identifier": "ai.artcraft.app",
"build": {
"frontendDist": "../../../frontend/apps/artcraft/dist",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2.0.0-rc",
"productName": "ArtCraft",
"version": "0.30.0",
"version": "0.31.0",
"identifier": "ai.artcraft.app",
"build": {
"frontendDist": "../../../frontend/apps/artcraft/dist",
@@ -77,12 +77,16 @@ pub enum TaskModelType {
Kling3p0Standard,
#[serde(rename = "kling_3p0_pro")]
Kling3p0Pro,
#[serde(rename = "happy_horse_1p0")]
HappyHorse1p0,
#[serde(rename = "seedance_1.0_lite")]
Seedance10Lite,
#[serde(rename = "seedance_1p5_pro")]
Seedance1p5Pro,
#[serde(rename = "seedance_2p0")]
Seedance2p0,
#[serde(rename = "seedance_2p0_fast")]
Seedance2p0Fast,
#[serde(rename = "sora_2")]
Sora2,
#[serde(rename = "sora_2_pro")]
@@ -154,9 +158,11 @@ impl TaskModelType {
Self::Kling2p6Pro => "kling_2p6_pro",
Self::Kling3p0Standard => "kling_3p0_standard",
Self::Kling3p0Pro => "kling_3p0_pro",
Self::HappyHorse1p0 => "happy_horse_1p0",
Self::Seedance10Lite => "seedance_1.0_lite",
Self::Seedance1p5Pro => "seedance_1p5_pro",
Self::Seedance2p0 => "seedance_2p0",
Self::Seedance2p0Fast => "seedance_2p0_fast",
Self::Sora2 => "sora_2",
Self::Sora2Pro => "sora_2_pro",
Self::Veo2 => "veo_2",
@@ -208,9 +214,11 @@ impl TaskModelType {
"kling_2p6_pro" => Ok(Self::Kling2p6Pro),
"kling_3p0_standard" => Ok(Self::Kling3p0Standard),
"kling_3p0_pro" => Ok(Self::Kling3p0Pro),
"happy_horse_1p0" => Ok(Self::HappyHorse1p0),
"seedance_1.0_lite" => Ok(Self::Seedance10Lite),
"seedance_1p5_pro" => Ok(Self::Seedance1p5Pro),
"seedance_2p0" => Ok(Self::Seedance2p0),
"seedance_2p0_fast" => Ok(Self::Seedance2p0Fast),
"sora_2" => Ok(Self::Sora2),
"sora_2_pro" => Ok(Self::Sora2Pro),
"veo_2" => Ok(Self::Veo2),
@@ -265,9 +273,11 @@ impl TaskModelType {
Self::Kling2p6Pro,
Self::Kling3p0Standard,
Self::Kling3p0Pro,
Self::HappyHorse1p0,
Self::Seedance10Lite,
Self::Seedance1p5Pro,
Self::Seedance2p0,
Self::Seedance2p0Fast,
Self::Sora2,
Self::Sora2Pro,
Self::Veo2,
@@ -329,9 +339,11 @@ mod tests {
assert_serialization(TaskModelType::Kling2p6Pro, "kling_2p6_pro");
assert_serialization(TaskModelType::Kling3p0Standard, "kling_3p0_standard");
assert_serialization(TaskModelType::Kling3p0Pro, "kling_3p0_pro");
assert_serialization(TaskModelType::HappyHorse1p0, "happy_horse_1p0");
assert_serialization(TaskModelType::Seedance10Lite, "seedance_1.0_lite");
assert_serialization(TaskModelType::Seedance1p5Pro, "seedance_1p5_pro");
assert_serialization(TaskModelType::Seedance2p0, "seedance_2p0");
assert_serialization(TaskModelType::Seedance2p0Fast, "seedance_2p0_fast");
assert_serialization(TaskModelType::Sora2, "sora_2");
assert_serialization(TaskModelType::Sora2Pro, "sora_2_pro");
assert_serialization(TaskModelType::Veo2, "veo_2");
@@ -382,9 +394,11 @@ mod tests {
assert_eq!(TaskModelType::Kling2p6Pro.to_str(), "kling_2p6_pro");
assert_eq!(TaskModelType::Kling3p0Standard.to_str(), "kling_3p0_standard");
assert_eq!(TaskModelType::Kling3p0Pro.to_str(), "kling_3p0_pro");
assert_eq!(TaskModelType::HappyHorse1p0.to_str(), "happy_horse_1p0");
assert_eq!(TaskModelType::Seedance10Lite.to_str(), "seedance_1.0_lite");
assert_eq!(TaskModelType::Seedance1p5Pro.to_str(), "seedance_1p5_pro");
assert_eq!(TaskModelType::Seedance2p0.to_str(), "seedance_2p0");
assert_eq!(TaskModelType::Seedance2p0Fast.to_str(), "seedance_2p0_fast");
assert_eq!(TaskModelType::Sora2.to_str(), "sora_2");
assert_eq!(TaskModelType::Sora2Pro.to_str(), "sora_2_pro");
assert_eq!(TaskModelType::Veo2.to_str(), "veo_2");
@@ -435,9 +449,11 @@ mod tests {
assert_eq!(TaskModelType::from_str("kling_2p6_pro").unwrap(), TaskModelType::Kling2p6Pro);
assert_eq!(TaskModelType::from_str("kling_3p0_standard").unwrap(), TaskModelType::Kling3p0Standard);
assert_eq!(TaskModelType::from_str("kling_3p0_pro").unwrap(), TaskModelType::Kling3p0Pro);
assert_eq!(TaskModelType::from_str("happy_horse_1p0").unwrap(), TaskModelType::HappyHorse1p0);
assert_eq!(TaskModelType::from_str("seedance_1.0_lite").unwrap(), TaskModelType::Seedance10Lite);
assert_eq!(TaskModelType::from_str("seedance_1p5_pro").unwrap(), TaskModelType::Seedance1p5Pro);
assert_eq!(TaskModelType::from_str("seedance_2p0").unwrap(), TaskModelType::Seedance2p0);
assert_eq!(TaskModelType::from_str("seedance_2p0_fast").unwrap(), TaskModelType::Seedance2p0Fast);
assert_eq!(TaskModelType::from_str("sora_2").unwrap(), TaskModelType::Sora2);
assert_eq!(TaskModelType::from_str("sora_2_pro").unwrap(), TaskModelType::Sora2Pro);
assert_eq!(TaskModelType::from_str("veo_2").unwrap(), TaskModelType::Veo2);
@@ -468,7 +484,7 @@ mod tests {
#[test]
fn all_variants() {
let mut variants = TaskModelType::all_variants();
assert_eq!(variants.len(), 46);
assert_eq!(variants.len(), 48);
// Image models
assert_eq!(variants.pop_first(), Some(TaskModelType::Flux1Dev));
assert_eq!(variants.pop_first(), Some(TaskModelType::Flux1Schnell));
@@ -501,9 +517,11 @@ mod tests {
assert_eq!(variants.pop_first(), Some(TaskModelType::Kling2p6Pro));
assert_eq!(variants.pop_first(), Some(TaskModelType::Kling3p0Standard));
assert_eq!(variants.pop_first(), Some(TaskModelType::Kling3p0Pro));
assert_eq!(variants.pop_first(), Some(TaskModelType::HappyHorse1p0));
assert_eq!(variants.pop_first(), Some(TaskModelType::Seedance10Lite));
assert_eq!(variants.pop_first(), Some(TaskModelType::Seedance1p5Pro));
assert_eq!(variants.pop_first(), Some(TaskModelType::Seedance2p0));
assert_eq!(variants.pop_first(), Some(TaskModelType::Seedance2p0Fast));
assert_eq!(variants.pop_first(), Some(TaskModelType::Sora2));
assert_eq!(variants.pop_first(), Some(TaskModelType::Sora2Pro));
assert_eq!(variants.pop_first(), Some(TaskModelType::Veo2));
@@ -94,6 +94,7 @@ fn build_omni_gen_video_models() -> Vec<OmniGenVideoModelDetails> {
character_references_supported: Some(true),
character_references_max: Some(9),
aspect_ratio_options: Some(vec![
CommonAspectRatio::WideTwentyOneByNine,
CommonAspectRatio::WideSixteenByNine,
CommonAspectRatio::WideFourByThree,
CommonAspectRatio::Square,
@@ -133,6 +134,7 @@ fn build_omni_gen_video_models() -> Vec<OmniGenVideoModelDetails> {
character_references_supported: Some(true),
character_references_max: Some(9),
aspect_ratio_options: Some(vec![
CommonAspectRatio::WideTwentyOneByNine,
CommonAspectRatio::WideSixteenByNine,
CommonAspectRatio::WideFourByThree,
CommonAspectRatio::Square,
@@ -4,7 +4,7 @@ import { Button } from "@storyteller/ui-button";
import { faCoins } from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { BillingApi } from "@storyteller/api";
import { toast } from "@storyteller/ui-toaster";
import { toast } from "./toast/toast";
interface CreditPack {
id: string;
@@ -11,9 +11,10 @@ import {
faSpinnerThird,
faVideo,
} from "@fortawesome/pro-solid-svg-icons";
import { addCorsParam, PLACEHOLDER_IMAGES } from "@storyteller/common";
import { PLACEHOLDER_IMAGES } from "@storyteller/common";
import { Tooltip } from "@storyteller/ui-tooltip";
import { toast } from "@storyteller/ui-toaster";
import { toast } from "../toast/toast";
import { downloadMediaFile } from "../../lib/download-media";
import {
getModelCreatorIconPath,
getModelDisplayName,
@@ -119,6 +120,7 @@ export const GalleryCard = memo(function GalleryCard({
const [ratio, setRatio] = useState<number | undefined>(cached);
const [shareCopied, setShareCopied] = useState(false);
const [isRecreating, setIsRecreating] = useState(false);
const [isDownloading, setIsDownloading] = useState(false);
const isVideo = item.mediaClass === "video";
const is3D = item.mediaClass === "dimensional";
const recreateMediaClass: RecreateMediaClass | null = isVideo
@@ -277,9 +279,23 @@ export const GalleryCard = memo(function GalleryCard({
[item, onClick],
);
const downloadHref = item.fullImage
? addCorsParam(item.fullImage) || item.fullImage
: undefined;
const handleDownload = useCallback(
async (e: React.MouseEvent) => {
e.stopPropagation();
if (!item.fullImage || isDownloading) return;
setIsDownloading(true);
try {
await downloadMediaFile({
url: item.fullImage,
filename: `artcraft-${item.id}`,
mediaClass: item.mediaClass,
});
} finally {
setIsDownloading(false);
}
},
[item.fullImage, item.id, item.mediaClass, isDownloading],
);
return (
<div
@@ -385,19 +401,20 @@ export const GalleryCard = memo(function GalleryCard({
/>
</button>
</Tooltip>
{downloadHref && (
{item.fullImage && (
<Tooltip content="Download" position="top">
<a
href={downloadHref}
download={`artcraft-${item.id}`}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="flex h-7 w-7 items-center justify-center rounded-md text-white/85 transition-colors hover:bg-white/15 hover:text-white"
<button
type="button"
onClick={handleDownload}
disabled={isDownloading}
className="flex h-7 w-7 items-center justify-center rounded-md text-white/85 transition-colors hover:bg-white/15 hover:text-white disabled:opacity-60"
aria-label="Download"
>
<FontAwesomeIcon icon={faArrowDownToLine} className="text-sm" />
</a>
<FontAwesomeIcon
icon={isDownloading ? faSpinnerThird : faArrowDownToLine}
className={`text-sm ${isDownloading ? "animate-spin" : ""}`}
/>
</button>
</Tooltip>
)}
</div>
@@ -144,6 +144,7 @@ export function GenerationGalleryGrid({
modelLabel={entry.job.modelLabel}
progress={entry.job.progress}
estimatedTimeLeftMs={entry.job.estimatedTimeLeftMs}
batchCount={entry.job.batchCount}
/>
)}
{entry.kind === "failed" && (
@@ -10,6 +10,7 @@ export interface PendingCardProps {
prompt: string;
progress?: number;
estimatedTimeLeftMs?: number;
batchCount?: number;
}
const formatTimeLeft = (ms: number): string => {
@@ -29,6 +30,7 @@ export const PendingCard = memo(function PendingCard({
prompt,
progress,
estimatedTimeLeftMs,
batchCount,
}: PendingCardProps) {
const progressPercent =
progress != null ? Math.max(0, Math.min(100, Math.round(progress))) : null;
@@ -44,6 +46,11 @@ export const PendingCard = memo(function PendingCard({
return (
<div className="relative aspect-square w-full overflow-hidden rounded-lg bg-white/[0.03]">
<div className="animate-shimmer h-full w-full" />
{batchCount != null && batchCount > 1 && (
<div className="absolute left-2 right-2 top-2 z-10 rounded-md bg-black/60 px-2.5 py-1.5 text-center text-[10px] leading-snug text-white/70 backdrop-blur-sm">
Generating {batchCount} videos · Results may appear one at a time
</div>
)}
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2">
<FontAwesomeIcon
icon={faSpinnerThird}
@@ -19,6 +19,7 @@ export interface InProgressJob {
progress: number;
estimatedTimeLeftMs?: number;
createdAt: string;
batchCount?: number;
}
export interface FailedJob {
@@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from "react";
import dayjs from "dayjs";
import { Button } from "@storyteller/ui-button";
import { toast } from "@storyteller/ui-toaster";
import { toast } from "../toast/toast";
import { Gravatar } from "@storyteller/ui-gravatar";
import type { UserInfo } from "@storyteller/api";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
@@ -14,12 +14,12 @@ import {
faImage,
faLink,
faPencil,
faSpinnerThird,
faTrashCan,
faUser,
faXmark,
} from "@fortawesome/pro-solid-svg-icons";
import {
addCorsParam,
getContextImageThumbnail,
THUMBNAIL_SIZES,
} from "@storyteller/common";
@@ -29,6 +29,7 @@ import {
getProviderDisplayName,
getProviderIconByName,
} from "@storyteller/model-list";
import { downloadMediaFile } from "../../lib/download-media";
import {
formatAspectRatio,
formatDuration,
@@ -88,6 +89,7 @@ export function LightboxDetails({
}: LightboxDetailsProps) {
const promptCopy = useCopyFeedback();
const shareCopy = useCopyFeedback();
const [isDownloading, setIsDownloading] = useState(false);
const [isPromptExpanded, setIsPromptExpanded] = useState(false);
const [isPromptClamped, setIsPromptClamped] = useState(false);
@@ -95,6 +97,7 @@ export function LightboxDetails({
useEffect(() => {
setIsPromptExpanded(false);
setIsDownloading(false);
}, [mediaToken]);
useEffect(() => {
@@ -348,7 +351,7 @@ export function LightboxDetails({
{createdAt && (
<InfoRow
label="Created"
value={dayjs(createdAt).format("MMMM D, YYYY")}
value={dayjs(createdAt).format("MMMM D, YYYY h:mm:ss A")}
/>
)}
</div>
@@ -368,23 +371,33 @@ export function LightboxDetails({
>
{shareCopy.copied ? "Copied" : "Share"}
</Button>
<a
<button
type="button"
disabled={!mediaUrl || isDownloading}
onClick={async () => {
if (!mediaUrl || isDownloading) return;
setIsDownloading(true);
try {
await downloadMediaFile({
url: mediaUrl,
filename: `artcraft-${mediaToken || "media"}`,
});
} finally {
setIsDownloading(false);
}
}}
className={`w-full inline-flex items-center justify-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors border border-ui-panel-border ${
mediaUrl
? "bg-ui-controls/40 hover:bg-ui-controls/60 text-white"
: "bg-ui-controls/20 text-white/60 cursor-not-allowed pointer-events-none"
? "bg-ui-controls/40 hover:bg-ui-controls/60 text-white disabled:opacity-60"
: "bg-ui-controls/20 text-white/60 cursor-not-allowed"
}`}
href={mediaUrl ? addCorsParam(mediaUrl) || mediaUrl : undefined}
download={
mediaUrl ? `artcraft-${mediaToken || "media"}` : undefined
}
aria-disabled={!mediaUrl}
target="_blank"
rel="noopener noreferrer"
>
<FontAwesomeIcon icon={faArrowDownToLine} />
Download
</a>
<FontAwesomeIcon
icon={isDownloading ? faSpinnerThird : faArrowDownToLine}
className={isDownloading ? "animate-spin" : ""}
/>
{isDownloading ? "Downloading…" : "Download"}
</button>
</div>
{onRecreate && promptData.hasToken && (
<Button
@@ -1,6 +1,6 @@
import { Modal } from "@storyteller/ui-modal";
import { LoadingSpinner } from "@storyteller/ui-loading-spinner";
import { toast } from "@storyteller/ui-toaster";
import { toast } from "../toast/toast";
import { useEffect, useState, useMemo, useCallback } from "react";
import { useNavigate } from "react-router-dom";
import { MediaFilesApi, PromptsApi, type UserInfo } from "@storyteller/api";
@@ -21,9 +21,11 @@ import {
faBars,
faXmark,
faGem,
faCog,
} from "@fortawesome/pro-solid-svg-icons";
import { TaskQueue } from "./task-queue";
import { CreditsModal } from "../credits-modal";
import { SettingsModal } from "../settings-modal/SettingsModal";
const NAV_ITEMS = [
{ name: "Home", href: "/" },
@@ -80,6 +82,7 @@ export default function Navbar() {
// null = unknown (still loading) so we don't flash the upgrade button
const [hasPaidPlan, setHasPaidPlan] = useState<boolean | null>(null);
const [creditsModalOpen, setCreditsModalOpen] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
// Check session on mount and when auth changes or location changes
useEffect(() => {
@@ -300,6 +303,14 @@ export default function Navbar() {
<TaskQueue />
<Button
variant="secondary"
onClick={() => setSettingsOpen(true)}
className="h-[38px] w-[38px]"
>
<FontAwesomeIcon icon={faCog} className="text-base" />
</Button>
<Menu as="div" className="relative ml-3">
<div>
<MenuButton className="flex rounded-full bg-gray-800 text-sm focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-gray-800">
@@ -355,6 +366,7 @@ export default function Navbar() {
>
Pricing
</Link>
<div className="flex gap-2">
<Link to="/login">
<Button
@@ -431,6 +443,16 @@ export default function Navbar() {
<div className="my-3 border-t border-white/[0.06]" />
<button
onClick={() => setSettingsOpen(true)}
className="flex w-full items-center gap-2 rounded-md px-3 py-[7px] text-[13px] font-medium text-white/60 active:bg-white/5 transition-colors"
>
<FontAwesomeIcon icon={faCog} className="text-xs" />
Settings
</button>
<div className="my-3 border-t border-white/[0.06]" />
{!isLoading && user ? (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-1.5">
@@ -506,6 +528,11 @@ export default function Navbar() {
onClose={() => setCreditsModalOpen(false)}
/>
<SettingsModal
isOpen={settingsOpen}
onClose={() => setSettingsOpen(false)}
/>
<style>{`
.link-underline {
transform-origin: left center;
@@ -17,7 +17,7 @@ import {
Character,
MediaUploadApi,
} from "@storyteller/api";
import { toast } from "@storyteller/ui-toaster";
import { toast } from "../toast/toast";
import { v4 as uuidv4 } from "uuid";
import { Button } from "@storyteller/ui-button";
import { useCharactersStore } from "./characters-store";
@@ -39,9 +39,11 @@ interface UploadedImage {
interface PendingCharacter {
name: string;
previewUrl?: string;
createdAt: number;
}
const POLL_INTERVAL_MS = 5000;
const PENDING_STALE_MS = 3 * 60 * 1000;
export const CharactersModal = ({
isOpen,
@@ -74,8 +76,11 @@ export const CharactersModal = ({
setRefreshKey((k) => k + 1);
};
const handleCreated = (pending: PendingCharacter) => {
setPendingCharacters((prev) => [pending, ...prev]);
const handleCreated = (pending: { name: string; previewUrl?: string }) => {
setPendingCharacters((prev) => [
{ ...pending, createdAt: Date.now() },
...prev,
]);
setView("list");
setRefreshKey((k) => k + 1);
};
@@ -84,6 +89,58 @@ export const CharactersModal = ({
setPendingCharacters((prev) => prev.filter((p) => p.name !== name));
}, []);
// Poll the server while the modal is open and any creation is pending, so
// a creation that finishes while the user is on the create/edit view still
// gets cleaned up — preventing the duplicate (real + pending) card.
useEffect(() => {
if (!isOpen || pendingCharacters.length === 0) return;
const interval = setInterval(async () => {
try {
const api = new CharactersApi();
const res = await api.ListAllCharacters();
if (!res.success || !res.data) return;
const serverNames = new Set(res.data.map((c) => c.name));
let resolved = false;
setPendingCharacters((prev) =>
prev.filter((p) => {
if (serverNames.has(p.name)) {
resolved = true;
return false;
}
return true;
}),
);
if (resolved) setRefreshKey((k) => k + 1);
} catch {
// retry next tick
}
}, POLL_INTERVAL_MS);
return () => clearInterval(interval);
}, [isOpen, pendingCharacters.length]);
// Time out failed creations so the "Creating..." card never sticks forever.
useEffect(() => {
if (pendingCharacters.length === 0) return;
const timers = pendingCharacters.map((pending) =>
setTimeout(
() => {
setPendingCharacters((prev) => {
if (!prev.some((p) => p.name === pending.name)) return prev;
toast.error(`Character "${pending.name}" failed to create`);
return prev.filter((p) => p.name !== pending.name);
});
},
Math.max(0, PENDING_STALE_MS - (Date.now() - pending.createdAt)),
),
);
return () => timers.forEach(clearTimeout);
}, [pendingCharacters]);
return (
<Modal
isOpen={isOpen}
@@ -148,13 +205,9 @@ const CharacterListView = ({
}) => {
const [characters, setCharacters] = useState<Character[]>([]);
const [loading, setLoading] = useState(true);
const [hasMore, setHasMore] = useState(false);
const [cursor, setCursor] = useState<number | undefined>(undefined);
const [confirmDelete, setConfirmDelete] = useState<Character | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const sentinelRef = useRef<HTMLDivElement>(null);
const loadingMoreRef = useRef(false);
const storeSetCharacters = useCharactersStore((s) => s.setCharacters);
const storeSetLoaded = useCharactersStore((s) => s.setLoaded);
const storeRemoveCharacter = useCharactersStore((s) => s.removeCharacter);
@@ -173,79 +226,33 @@ const CharacterListView = ({
[storeSetCharacters, storeSetLoaded],
);
const fetchCharacters = useCallback(
async (nextCursor?: number) => {
if (loadingMoreRef.current) return;
loadingMoreRef.current = true;
try {
const api = new CharactersApi();
const res = await api.ListCharacters({ cursor: nextCursor });
if (res.success && res.data) {
setCharacters((prev) => {
const updated = nextCursor ? [...prev, ...res.data!] : res.data!;
syncToStore(updated);
return updated;
});
const nextPage = res.pagination?.next_cursor;
setCursor(nextPage ?? undefined);
setHasMore(!!nextPage);
}
} catch {
storeSetLoaded(true);
} finally {
setLoading(false);
loadingMoreRef.current = false;
const fetchCharacters = useCallback(async () => {
try {
const api = new CharactersApi();
const res = await api.ListAllCharacters();
if (res.success && res.data) {
setCharacters(res.data);
syncToStore(res.data);
}
},
[syncToStore, storeSetLoaded],
);
} catch {
storeSetLoaded(true);
} finally {
setLoading(false);
}
}, [syncToStore, storeSetLoaded]);
useEffect(() => {
fetchCharacters();
}, [fetchCharacters]);
// Poll for pending characters becoming active
// Drop pendings already represented in the fetched list (no duplicate cards)
useEffect(() => {
if (pendingCharacters.length === 0) return;
const interval = setInterval(async () => {
try {
const api = new CharactersApi();
const res = await api.ListCharacters({});
if (res.success && res.data) {
const serverNames = new Set(res.data.map((c) => c.name));
for (const pending of pendingCharacters) {
if (serverNames.has(pending.name)) {
onPendingResolved(pending.name);
}
}
setCharacters(res.data);
syncToStore(res.data);
}
} catch {
// Silently retry on next interval
}
}, POLL_INTERVAL_MS);
return () => clearInterval(interval);
}, [pendingCharacters, onPendingResolved, syncToStore]);
// Infinite scroll via IntersectionObserver
useEffect(() => {
if (!sentinelRef.current || !hasMore) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting && hasMore && cursor) {
fetchCharacters(cursor);
}
},
{ threshold: 0.1 },
);
observer.observe(sentinelRef.current);
return () => observer.disconnect();
}, [hasMore, cursor, fetchCharacters]);
const names = new Set(characters.map((c) => c.name));
for (const pending of pendingCharacters) {
if (names.has(pending.name)) onPendingResolved(pending.name);
}
}, [characters, pendingCharacters, onPendingResolved]);
const handleDelete = async (character: Character) => {
setIsDeleting(true);
@@ -423,16 +430,6 @@ const CharacterListView = ({
</div>
)}
{/* Sentinel for infinite scroll */}
{hasMore && (
<div ref={sentinelRef} className="flex justify-center py-4">
<FontAwesomeIcon
icon={faSpinnerThird}
className="text-white/30 animate-spin"
/>
</div>
)}
{/* Delete confirmation overlay */}
{confirmDelete && (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm">
@@ -611,7 +608,7 @@ const NewCharacterView = ({
onCreated,
}: {
onBack: () => void;
onCreated: (pending: PendingCharacter) => void;
onCreated: (pending: { name: string; previewUrl?: string }) => void;
}) => {
const addCharacterToStore = useCharactersStore((s) => s.addCharacter);
const [name, setName] = useState("");
@@ -11,6 +11,7 @@ import {
} from "@fortawesome/pro-solid-svg-icons";
import { twMerge } from "tailwind-merge";
import { UploaderStates } from "@storyteller/common";
import { toast } from "../toast/toast";
import type { RefVideo, RefAudio } from "./types";
import {
uploadVideo,
@@ -47,11 +48,6 @@ export const MediaReferenceRow = ({
const [uploadingVideo, setUploadingVideo] = useState(false);
const [uploadingAudio, setUploadingAudio] = useState(false);
const referenceVideosRef = useRef(referenceVideos);
referenceVideosRef.current = referenceVideos;
const referenceAudiosRef = useRef(referenceAudios);
referenceAudiosRef.current = referenceAudios;
const totalVideoDuration = referenceVideos.reduce(
(sum, v) => sum + v.duration,
0,
@@ -61,24 +57,42 @@ export const MediaReferenceRow = ({
0,
);
const canAddVideo = referenceVideos.length < maxVideoCount && !uploadingVideo;
const canAddAudio = referenceAudios.length < maxAudioCount && !uploadingAudio;
const canAddVideo =
referenceVideos.length < maxVideoCount &&
totalVideoDuration < maxVideoRefDuration &&
!uploadingVideo;
const canAddAudio =
referenceAudios.length < maxAudioCount &&
totalAudioDuration < maxAudioRefDuration &&
!uploadingAudio;
const handleVideoUpload = useCallback(
async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
if (files.length === 0) return;
const handleVideoUpload = async (
e: React.ChangeEvent<HTMLInputElement>,
) => {
const files = Array.from(e.target.files || []);
if (videoInputRef.current) videoInputRef.current.value = "";
if (files.length === 0) return;
const file = files[0];
const duration = await getVideoDuration(file);
// Snapshot at call time so a stale read inside the async callback can't overwrite a remove.
const baseVideos = [...referenceVideos];
if (totalVideoDuration + duration > maxVideoRefDuration) {
if (videoInputRef.current) videoInputRef.current.value = "";
return;
}
const file = files[0];
const duration = await getVideoDuration(file);
setUploadingVideo(true);
if (duration <= 0) {
toast.error("Could not read video file");
return;
}
const currentTotal = baseVideos.reduce((sum, v) => sum + v.duration, 0);
if (currentTotal + duration > maxVideoRefDuration) {
toast.error(
`Video too long — max ${maxVideoRefDuration}s total (${maxVideoRefDuration - currentTotal}s remaining)`,
);
return;
}
setUploadingVideo(true);
try {
await uploadVideo({
title: `reference-video-${Math.random().toString(36).substring(2, 15)}`,
assetFile: file,
@@ -91,37 +105,50 @@ export const MediaReferenceRow = ({
mediaToken: newState.data,
duration,
};
onReferenceVideosChange([...referenceVideosRef.current, refVideo]);
setUploadingVideo(false);
onReferenceVideosChange([...baseVideos, refVideo]);
} else if (
newState.status === UploaderStates.assetError ||
newState.status === UploaderStates.imageCreateError
) {
setUploadingVideo(false);
toast.error(newState.errorMessage || "Could not upload video");
}
},
});
} catch (err) {
toast.error(
err instanceof Error ? err.message : "Could not upload video",
);
} finally {
setUploadingVideo(false);
}
};
if (videoInputRef.current) videoInputRef.current.value = "";
},
[totalVideoDuration, maxVideoRefDuration, onReferenceVideosChange],
);
const handleAudioUpload = async (
e: React.ChangeEvent<HTMLInputElement>,
) => {
const files = Array.from(e.target.files || []);
if (audioInputRef.current) audioInputRef.current.value = "";
if (files.length === 0) return;
const handleAudioUpload = useCallback(
async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
if (files.length === 0) return;
const baseAudios = [...referenceAudios];
const file = files[0];
const duration = await getAudioDuration(file);
const file = files[0];
const duration = await getAudioDuration(file);
if (totalAudioDuration + duration > maxAudioRefDuration) {
if (audioInputRef.current) audioInputRef.current.value = "";
return;
}
setUploadingAudio(true);
if (duration <= 0) {
toast.error("Could not read audio file");
return;
}
const currentTotal = baseAudios.reduce((sum, a) => sum + a.duration, 0);
if (currentTotal + duration > maxAudioRefDuration) {
toast.error(
`Audio too long — max ${maxAudioRefDuration}s total (${maxAudioRefDuration - currentTotal}s remaining)`,
);
return;
}
setUploadingAudio(true);
try {
await uploadAudio({
title: `reference-audio-${Math.random().toString(36).substring(2, 15)}`,
assetFile: file,
@@ -134,21 +161,23 @@ export const MediaReferenceRow = ({
mediaToken: newState.data,
duration,
};
onReferenceAudiosChange([...referenceAudiosRef.current, refAudio]);
setUploadingAudio(false);
onReferenceAudiosChange([...baseAudios, refAudio]);
} else if (
newState.status === UploaderStates.assetError ||
newState.status === UploaderStates.imageCreateError
) {
setUploadingAudio(false);
toast.error(newState.errorMessage || "Could not upload audio");
}
},
});
if (audioInputRef.current) audioInputRef.current.value = "";
},
[totalAudioDuration, maxAudioRefDuration, onReferenceAudiosChange],
);
} catch (err) {
toast.error(
err instanceof Error ? err.message : "Could not upload audio",
);
} finally {
setUploadingAudio(false);
}
};
const removeVideo = (id: string) => {
const video = referenceVideos.find((v) => v.id === id);
@@ -529,9 +529,13 @@ export const MentionTextarea = forwardRef<HTMLDivElement, MentionTextareaProps>(
const el = editorRef.current;
if (!el) return;
const caretPos = getCaretOffset(el);
// Use the known mention bounds — `getCaretOffset` can return 0 when the
// dropdown click collapses the selection out of the editor, which would
// leave the typed query in the prompt alongside the inserted label.
const queryEnd =
mentionState.triggerIndex + mentionState.query.length;
const before = value.slice(0, mentionState.triggerIndex);
const after = value.slice(caretPos);
const after = value.slice(queryEnd);
const mention = `${item.label} `;
const newValue = before + mention + after;
@@ -550,7 +554,7 @@ export const MentionTextarea = forwardRef<HTMLDivElement, MentionTextareaProps>(
el.focus();
});
},
[value, mentionState.triggerIndex, onChange],
[value, mentionState.triggerIndex, mentionState.query, onChange],
);
const handleKeyDown = useCallback(
@@ -587,10 +591,7 @@ export const MentionTextarea = forwardRef<HTMLDivElement, MentionTextareaProps>(
}
}
// Default: plain Enter inserts a newline (instead of letting the
// contentEditable create a <div>). Shift/Cmd+Enter falls through to
// externalOnKeyDown so the host can submit.
if (e.key === "Enter" && !e.shiftKey && !e.metaKey) {
if (e.key === "Enter") {
e.preventDefault();
document.execCommand("insertLineBreak");
handleInput();
@@ -21,6 +21,7 @@ import { Tooltip } from "@storyteller/ui-tooltip";
import { ImagePromptRow } from "./ImagePromptRow";
import { MentionTextarea } from "./MentionTextarea";
import type { RefImage, MentionItem } from "./types";
import { useEnterToGenerateStore } from "../../lib/enter-to-generate-store";
// ── @-mention color palette ─────────────────────────────────────────────
@@ -144,6 +145,7 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
const textareaRef = useRef<HTMLTextAreaElement>(null);
const highlightRef = useRef<HTMLDivElement>(null);
const mentionEditorRef = useRef<HTMLDivElement>(null);
const enterToGenerate = useEnterToGenerateStore((s) => s.enabled);
const [isFocused, setIsFocused] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
const [showImagePrompts, setShowImagePrompts] = useState(false);
@@ -291,8 +293,7 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
}
}
// Default: Enter inserts a newline; Shift+Enter submits.
if (e.key === "Enter" && e.shiftKey) {
if (e.key === "Enter" && enterToGenerate && !e.shiftKey) {
e.preventDefault();
onSubmit();
}
@@ -436,8 +437,7 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
)}
colorMap={mentionColorMap}
onKeyDown={(e) => {
// Default: Enter inserts a newline; Shift+Enter (or Cmd+Enter) submits.
if (e.key === "Enter" && (e.shiftKey || e.metaKey)) {
if (e.key === "Enter" && enterToGenerate && !e.shiftKey && !e.metaKey) {
e.preventDefault();
onSubmit();
}
@@ -15,22 +15,29 @@ export const uploadVideo: UploadMediaFn = async ({
const api = new MediaUploadApi();
progressCallback({ status: UploaderStates.uploadingImage });
const response = await api.UploadNewVideo({
uuid: crypto.randomUUID(),
blob: assetFile,
fileName: assetFile.name || `reference-video-${Date.now()}`,
maybe_title: `ref_video_${title}`,
});
try {
const response = await api.UploadNewVideo({
uuid: crypto.randomUUID(),
blob: assetFile,
fileName: assetFile.name || `reference-video-${Date.now()}`,
maybe_title: `ref_video_${title}`,
});
if (!response?.success || !response.data) {
if (!response?.success || !response.data) {
progressCallback({
status: UploaderStates.imageCreateError,
errorMessage: response?.errorMessage ?? "Could not upload video",
});
return;
}
progressCallback({ status: UploaderStates.success, data: response.data });
} catch (err) {
progressCallback({
status: UploaderStates.imageCreateError,
errorMessage: response?.errorMessage ?? "Could not upload video",
errorMessage: err instanceof Error ? err.message : "Could not upload video",
});
return;
}
progressCallback({ status: UploaderStates.success, data: response.data });
};
export const uploadAudio: UploadMediaFn = async ({
@@ -41,22 +48,29 @@ export const uploadAudio: UploadMediaFn = async ({
const api = new MediaUploadApi();
progressCallback({ status: UploaderStates.uploadingImage });
const response = await api.UploadAudio({
uuid: crypto.randomUUID(),
blob: assetFile,
fileName: assetFile.name || `reference-audio-${Date.now()}`,
maybe_title: `ref_audio_${title}`,
});
try {
const response = await api.UploadAudio({
uuid: crypto.randomUUID(),
blob: assetFile,
fileName: assetFile.name || `reference-audio-${Date.now()}`,
maybe_title: `ref_audio_${title}`,
});
if (!response?.success || !response.data) {
if (!response?.success || !response.data) {
progressCallback({
status: UploaderStates.imageCreateError,
errorMessage: response?.errorMessage ?? "Could not upload audio",
});
return;
}
progressCallback({ status: UploaderStates.success, data: response.data });
} catch (err) {
progressCallback({
status: UploaderStates.imageCreateError,
errorMessage: response?.errorMessage ?? "Could not upload audio",
errorMessage: err instanceof Error ? err.message : "Could not upload audio",
});
return;
}
progressCallback({ status: UploaderStates.success, data: response.data });
};
export const getVideoDuration = (file: File): Promise<number> =>
@@ -0,0 +1,59 @@
import { Modal } from "@storyteller/ui-modal";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faCog } from "@fortawesome/pro-solid-svg-icons";
import { Switch } from "@storyteller/ui-switch";
import { useEnterToGenerateStore } from "../../lib/enter-to-generate-store";
interface SettingsModalProps {
isOpen: boolean;
onClose: () => void;
}
export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
const enterToGenerate = useEnterToGenerateStore((s) => s.enabled);
const setEnterToGenerate = useEnterToGenerateStore((s) => s.setEnabled);
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 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">
<button className="h-9 w-full rounded-lg p-2 text-left bg-[#63636B]/20">
<div className="flex items-center gap-2.5 text-sm">
<FontAwesomeIcon icon={faCog} />
General
</div>
</button>
</div>
</div>
<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">General</h2>
</div>
<div className="p-3 ps-0 text-sm h-full">
<div className="space-y-4 text-base-fg">
<div className="flex flex-col gap-2 pt-3">
<div className="flex flex-col gap-0.5">
<p className="text-sm font-medium">Enter to generate</p>
<p className="text-xs opacity-70">
When on, pressing Enter submits the prompt and Shift+Enter
adds a new line. When off (default), both Enter and
Shift+Enter add a new line — use the button to submit.
</p>
</div>
<Switch enabled={enterToGenerate} setEnabled={setEnterToGenerate} offClassName="bg-white/20" />
</div>
</div>
</div>
</div>
</div>
</div>
</Modal>
);
}
@@ -35,6 +35,13 @@ export function showToast(type: ToastType, message: string, duration?: number) {
);
}
export const toast = {
success: (message: string, opts?: { duration?: number }) =>
showToast("success", message, opts?.duration),
error: (message: string, opts?: { duration?: number }) =>
showToast("error", message, opts?.duration),
};
// ── Component ──────────────────────────────────────────────────────────────
const DEFAULT_DURATION = 6000;
@@ -0,0 +1,54 @@
import { addCorsParam } from "@storyteller/common";
import { toast } from "../components/toast/toast";
const EXT_BY_MEDIA_CLASS: Record<string, string> = {
image: "png",
video: "mp4",
dimensional: "glb",
};
function extensionForUrl(url: string, mediaClass?: string | null): string {
try {
const pathname = new URL(url, window.location.href).pathname;
const match = pathname.match(/\.([a-z0-9]{2,5})$/i);
if (match) return match[1].toLowerCase();
} catch {
// ignore — fall through to mediaClass default
}
if (mediaClass && EXT_BY_MEDIA_CLASS[mediaClass]) {
return EXT_BY_MEDIA_CLASS[mediaClass];
}
return "bin";
}
export async function downloadMediaFile({
url,
filename,
mediaClass,
}: {
url: string;
filename: string;
mediaClass?: string | null;
}): Promise<void> {
const corsUrl = addCorsParam(url) || url;
let blobUrl: string | null = null;
try {
const response = await fetch(corsUrl, { credentials: "omit" });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const blob = await response.blob();
blobUrl = window.URL.createObjectURL(blob);
const ext = extensionForUrl(url, mediaClass);
const a = document.createElement("a");
a.style.display = "none";
a.href = blobUrl;
a.download = `${filename}.${ext}`;
document.body.appendChild(a);
a.click();
a.remove();
} catch {
toast.error("Could not download file.");
} finally {
if (blobUrl) window.URL.revokeObjectURL(blobUrl);
}
}
@@ -0,0 +1,17 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
interface EnterToGenerateStore {
enabled: boolean;
setEnabled: (enabled: boolean) => void;
}
export const useEnterToGenerateStore = create<EnterToGenerateStore>()(
persist(
(set) => ({
enabled: false,
setEnabled: (enabled) => set({ enabled }),
}),
{ name: "artcraft-website-enter-to-generate" },
),
);
@@ -4,7 +4,7 @@ import {
PromptsApi,
type Prompts,
} from "@storyteller/api";
import { toast } from "@storyteller/ui-toaster";
import { toast } from "../components/toast/toast";
import type {
RefAudio,
RefImage,
@@ -1,4 +1,5 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import type { RecreatePayload } from "../../lib/recreate";
import type {
RefImage,
@@ -21,6 +22,7 @@ export type VideoBatch = {
modelLabel: string;
jobToken?: string;
failureReason?: string;
batchCount?: number;
};
export type VideoInputMode = "keyframe" | "reference";
@@ -52,7 +54,7 @@ type CreateVideoState = {
setRefs: (patch: Partial<VideoRefsState>) => void;
setPendingRecreate: (payload: RecreatePayload | null) => void;
consumePendingRecreate: () => RecreatePayload | null;
startBatch: (prompt: string, modelLabel: string) => string;
startBatch: (prompt: string, modelLabel: string, batchCount?: number) => string;
setBatchJobToken: (batchId: string, jobToken: string) => void;
completeBatch: (batchId: string, video: GeneratedVideo) => void;
failBatch: (batchId: string, reason?: string) => void;
@@ -79,76 +81,87 @@ const DEFAULT_REFS: VideoRefsState = {
referenceAudios: [],
};
export const useCreateVideoStore = create<CreateVideoState>((set, get) => ({
batches: [],
ui: { ...DEFAULT_UI },
refs: { ...DEFAULT_REFS },
pendingRecreate: null,
export const useCreateVideoStore = create<CreateVideoState>()(
persist(
(set, get) => ({
batches: [],
ui: { ...DEFAULT_UI },
refs: { ...DEFAULT_REFS },
pendingRecreate: null,
setUi: (patch) =>
set((s) => ({ ui: { ...s.ui, ...patch } })),
setUi: (patch) =>
set((s) => ({ ui: { ...s.ui, ...patch } })),
setRefs: (patch) =>
set((s) => ({ refs: { ...s.refs, ...patch } })),
setRefs: (patch) =>
set((s) => ({ refs: { ...s.refs, ...patch } })),
setPendingRecreate: (payload) => set({ pendingRecreate: payload }),
setPendingRecreate: (payload) => set({ pendingRecreate: payload }),
consumePendingRecreate: () => {
const payload = get().pendingRecreate;
if (payload) set({ pendingRecreate: null });
return payload;
},
consumePendingRecreate: () => {
const payload = get().pendingRecreate;
if (payload) set({ pendingRecreate: null });
return payload;
},
startBatch: (prompt, modelLabel) => {
const id = crypto.randomUUID();
const batch: VideoBatch = {
id,
prompt,
status: "pending",
createdAt: Date.now(),
modelLabel,
};
set((s) => ({ batches: [...s.batches, batch] }));
return id;
},
startBatch: (prompt, modelLabel, batchCount) => {
const id = crypto.randomUUID();
const batch: VideoBatch = {
id,
prompt,
status: "pending",
createdAt: Date.now(),
modelLabel,
batchCount,
};
set((s) => ({ batches: [...s.batches, batch] }));
return id;
},
setBatchJobToken: (batchId, jobToken) => {
set((s) => ({
batches: s.batches.map((b) =>
b.id === batchId ? { ...b, jobToken } : b,
),
}));
},
setBatchJobToken: (batchId, jobToken) => {
set((s) => ({
batches: s.batches.map((b) =>
b.id === batchId ? { ...b, jobToken } : b,
),
}));
},
completeBatch: (batchId, video) => {
set((s) => ({
batches: s.batches.map((b) =>
b.id === batchId
? { ...b, status: "complete" as const, video }
: b,
),
}));
},
completeBatch: (batchId, video) => {
set((s) => ({
batches: s.batches.map((b) =>
b.id === batchId
? { ...b, status: "complete" as const, video }
: b,
),
}));
},
failBatch: (batchId, reason) => {
set((s) => ({
batches: s.batches.map((b) =>
b.id === batchId
? { ...b, status: "failed" as const, failureReason: reason }
: b,
),
}));
},
failBatch: (batchId, reason) => {
set((s) => ({
batches: s.batches.map((b) =>
b.id === batchId
? { ...b, status: "failed" as const, failureReason: reason }
: b,
),
}));
},
dismissBatch: (id) => {
set((s) => ({ batches: s.batches.filter((b) => b.id !== id) }));
},
dismissBatch: (id) => {
set((s) => ({ batches: s.batches.filter((b) => b.id !== id) }));
},
clearCompleted: () => {
set((s) => ({
batches: s.batches.filter((b) => b.status !== "complete"),
}));
},
clearCompleted: () => {
set((s) => ({
batches: s.batches.filter((b) => b.status !== "complete"),
}));
},
reset: () => set({ batches: [] }),
}));
reset: () => set({ batches: [] }),
}),
{
name: "artcraft-video-batches",
partialize: (state) => ({
batches: state.batches.filter((b) => b.status === "pending"),
}),
},
),
);
@@ -258,7 +258,7 @@ export default function CreateVideo() {
if (charactersLoaded) return;
const api = new CharactersApi();
api
.ListCharacters()
.ListAllCharacters()
.then((res) => {
if (res.success && res.data) {
storeSetCharacters(
@@ -275,6 +275,7 @@ export default function CreateVideo() {
}, [charactersLoaded, storeSetCharacters, storeSetLoaded]);
// Batch store (enqueue flow only)
const batches = useCreateVideoStore((s) => s.batches);
const startBatch = useCreateVideoStore((s) => s.startBatch);
const setBatchJobToken = useCreateVideoStore((s) => s.setBatchJobToken);
const completeBatch = useCreateVideoStore((s) => s.completeBatch);
@@ -310,6 +311,26 @@ export default function CreateVideo() {
excludeUploads: true,
});
// Map job token → batch count so PendingCard can show "N videos generating"
const jobTokenToBatchCount = useMemo(() => {
const map = new Map<string, number>();
for (const batch of batches) {
if (batch.jobToken && batch.batchCount && batch.batchCount > 1) {
map.set(batch.jobToken, batch.batchCount);
}
}
return map;
}, [batches]);
const enrichedInProgress = useMemo(
() =>
jobs.inProgress.map((job) => {
const batchCount = jobTokenToBatchCount.get(job.id);
return batchCount ? { ...job, batchCount } : job;
}),
[jobs.inProgress, jobTokenToBatchCount],
);
const newlyCompletedTokens = useMemo(
() => new Set(jobs.newlyCompleted.map((i) => i.id)),
[jobs.newlyCompleted],
@@ -690,8 +711,20 @@ export default function CreateVideo() {
needsImage ||
!selectedModel
) {
console.log("[generate-video] blocked", {
hasPrompt: !!prompt.trim(),
isGenerating: isGeneratingRef.current,
needsImage,
hasModel: !!selectedModel,
});
return;
}
console.log("[generate-video] starting", {
model: selectedModel.model,
numVideos,
inputMode,
isReferenceMode,
});
isGeneratingRef.current = true;
setIsGenerating(true);
@@ -743,7 +776,7 @@ export default function CreateVideo() {
const baseParams = {
prompt: prompt.trim(),
model: selectedModel.model,
numVideos: 1,
numVideos,
aspectRatio: selectedSize,
duration: duration ?? selectedModel.duration_seconds_default ?? undefined,
resolution: hasResolutionOptions
@@ -767,45 +800,53 @@ export default function CreateVideo() {
: undefined,
referenceCharacterTokens,
};
console.log("[generate-video] params", baseParams);
// Enqueue each video as a separate job so they complete independently
const modelLabel = selectedModel.full_name ?? selectedModel.model;
const count = Math.max(1, numVideos);
const batchId = startBatch(prompt, modelLabel, numVideos > 1 ? numVideos : undefined);
for (let i = 0; i < count; i++) {
const batchId = startBatch(prompt, modelLabel);
try {
const result = await enqueueVideoGeneration(baseParams);
if (!result.success || !result.jobToken) {
failBatch(batchId, result.error ?? "Failed to start generation");
continue;
}
try {
console.log("[generate-video] enqueueing job...");
const result = await enqueueVideoGeneration(baseParams);
console.log("[generate-video] enqueue result", result);
if (!result.success || !result.jobToken) {
console.warn("[generate-video] enqueue failed", result.error);
failBatch(batchId, result.error ?? "Failed to start generation");
} else {
setBatchJobToken(batchId, result.jobToken);
console.log("[generate-video] polling started", {
jobToken: result.jobToken,
});
const stopPolling = startVideoPolling(
result.jobToken,
(video) => {
console.log("[generate-video] complete", {
batchId,
media_token: video.media_token,
});
completeBatch(batchId, video);
pollingCleanupsRef.current.delete(batchId);
window.dispatchEvent(new Event("task-queue-update"));
},
(reason) => {
console.warn("[generate-video] poll failed", { batchId, reason });
failBatch(batchId, reason);
pollingCleanupsRef.current.delete(batchId);
window.dispatchEvent(new Event("task-queue-update"));
},
);
pollingCleanupsRef.current.set(batchId, stopPolling);
} catch {
failBatch(batchId, "Network error - please try again");
}
} catch (err) {
console.error("[generate-video] unexpected error", err);
failBatch(batchId, "Network error - please try again");
}
window.dispatchEvent(new Event("credits-change"));
window.dispatchEvent(new Event("task-queue-update"));
console.log("[generate-video] done enqueuing");
setIsGenerating(false);
isGeneratingRef.current = false;
}, [
@@ -861,7 +902,7 @@ export default function CreateVideo() {
glowOrbs={videoGlowOrbs}
gridContent={
<GenerationGalleryGrid
inProgressJobs={jobs.inProgress}
inProgressJobs={enrichedInProgress}
failedJobs={jobs.failed}
onDismissFailed={jobs.dismissFailed}
newlyCompletedItems={jobs.newlyCompleted}
@@ -50,6 +50,8 @@ export async function enqueueVideoGeneration(
: null,
};
console.log("[generate-video] raw request body", body);
try {
const api = new OmniGenApi();
const response = await api.generateVideo(body);
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useState } from "react";
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import { LoadingSpinner } from "@storyteller/ui-loading-spinner";
import { toast } from "@storyteller/ui-toaster";
import { toast } from "../../components/toast/toast";
import { MediaFilesApi, PromptsApi, type UserInfo } from "@storyteller/api";
import { addCorsParam, PLACEHOLDER_IMAGES } from "@storyteller/common";
import { Viewer3D } from "@storyteller/ui-viewer-3d";
@@ -11,14 +11,26 @@ import {
faGrid2,
faImages,
faCalculator,
faExclamation,
faCheck,
} from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { signal } from "@preact/signals-react";
import { useSignals } from "@preact/signals-react/runtime";
import { FilterMediaClasses } from "@storyteller/api";
import { getCreatorIcon, ModelCreator, IMAGE_MODELS_BY_ID, VIDEO_MODELS_BY_ID, CommonAspectRatio, CommonResolution } from "@storyteller/model-list";
import { useClassyModelSelectorStore, ModelPage } from "@storyteller/ui-model-selector";
import { useCreditsState } from "@storyteller/credits";
import {
getCreatorIcon,
ModelCreator,
IMAGE_MODELS_BY_ID,
VIDEO_MODELS_BY_ID,
CommonAspectRatio,
CommonResolution,
} from "@storyteller/model-list";
import {
useClassyModelSelectorStore,
ModelPage,
} from "@storyteller/ui-model-selector";
import { useCreditsState, type CreditsIconStatus } from "@storyteller/credits";
import { gtagEvent } from "@storyteller/google-analytics";
import { ProviderBillingModal } from "@storyteller/provider-billing-modal";
import { ProviderSetupModal } from "@storyteller/provider-setup-modal";
@@ -51,9 +63,14 @@ import {
useCostBreakdownModalStore,
CreditsModal,
} from "@storyteller/ui-pricing-modal";
import { RefImage, RefVideo, RefAudio, usePromptImageStore, usePromptVideoStore } from "@storyteller/ui-promptbox";
import {
RefImage,
RefVideo,
RefAudio,
usePromptImageStore,
usePromptVideoStore,
} from "@storyteller/ui-promptbox";
import type { Prompts } from "@storyteller/api";
import { LoadingSpinner } from "@storyteller/ui-loading-spinner";
import { SettingsModal } from "@storyteller/ui-settings-modal";
import { Tooltip } from "@storyteller/ui-tooltip";
import { useEffect, useRef, useState } from "react";
@@ -70,6 +87,7 @@ import { useImageTo3DStore } from "~/pages/PageImageTo3DObject/ImageTo3DStore";
import { useImageTo3DWorldStore } from "~/pages/PageImageTo3DWorld/ImageTo3DWorldStore";
import { useRemoveBackgroundStore } from "~/pages/PageRemoveBackground/RemoveBackgroundStore";
import { TabId, useTabStore } from "~/pages/Stores/TabState";
import { AUTH_STATUS } from "~/enums";
import { authentication } from "~/signals";
import { setLogoutStates } from "~/signals/authentication/utilities";
import type { BaseSelectorImage } from "@storyteller/ui-pagedraw";
@@ -97,6 +115,7 @@ type SettingsSection =
| "billing";
const SWITCHER_THROTTLE_TIME = 500; // milliseconds
const CREDITS_POLL_INTERVAL = 60_000; // milliseconds
// NB: See `TabState` for the default tab.
const appMenuTabs: MenuIconItem[] = [
@@ -123,6 +142,74 @@ const appMenuTabs: MenuIconItem[] = [
export const topNavMediaId = signal<string>("");
export const topNavMediaUrl = signal<string>("");
const CreditsCoinWithStatus = ({
iconStatus,
}: {
iconStatus: CreditsIconStatus;
}) => {
const showBadge = iconStatus !== "hidden";
const badgeColorClass =
iconStatus === "failed"
? "bg-red text-white"
: iconStatus === "recovered"
? "bg-emerald-500 text-white"
: "bg-amber-400 text-black"; // 'slow'
const badgeIconDef = iconStatus === "recovered" ? faCheck : faExclamation;
const tooltipMessage =
iconStatus === "failed"
? "Couldn't refresh your balance."
: iconStatus === "recovered"
? "Balance up to date."
: "Refreshing your balance — current amount may not be up to date.";
const showRetry = iconStatus === "slow" || iconStatus === "failed";
const handleRetry = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
console.log("TopBar: Retrying credits fetch");
void useCreditsState.getState().fetchFromServer();
};
return (
<Tooltip
position="bottom"
interactive
disabled={!showBadge}
content={
<div className="flex max-w-[220px] flex-col gap-2 text-xs text-base-fg">
<span>{tooltipMessage}</span>
{showRetry && (
<Button
variant="secondary"
className="h-7 self-start px-2 text-xs"
onClick={handleRetry}
>
Retry
</Button>
)}
</div>
}
>
<span className="relative inline-flex">
<FontAwesomeIcon icon={faCoins} className="text-primary" />
{showBadge && (
<span
className={`absolute -right-1.5 -top-1.5 flex h-3 w-3 items-center justify-center rounded-full ring-1 ring-ui-background ${badgeColorClass}`}
>
<FontAwesomeIcon icon={badgeIconDef} className="text-[7px]" />
</span>
)}
</span>
</Tooltip>
);
};
export const TopBar = ({ pageName }: Props) => {
useSignals();
@@ -157,39 +244,33 @@ export const TopBar = ({ pageName }: Props) => {
const [disableSwitcher, setDisableSwitcher] = useState(false);
const switcherThrottle = useRef(false);
const creditsStore = useCreditsState();
const sumTotalCredits = creditsStore.totalCredits;
const sumTotalCredits = useCreditsState((s) => s.totalCredits);
const creditsIconStatus = useCreditsState((s) => s.iconStatus);
// Just calling this function kills the app:
const subscriptionStore = useSubscriptionState();
const hasPaidPlan = subscriptionStore.hasPaidPlan();
const [isCreditsLoading, setIsCreditsLoading] = useState(true);
// Fetch credits + subscription on entering LOGGED_IN, then poll credits every
// 60s. Reading via getState() inside the effect keeps the dep array honest
// (the only real dep is the auth status). Earlier versions had a 1s setTimeout
// band-aid to outrun intermediate auth states; gating on LOGGED_IN replaces it.
const authStatus = authentication.status.value;
useEffect(() => {
const t = setTimeout(() => {
Promise.all([
creditsStore.fetchFromServer(),
subscriptionStore.fetchFromServer(),
])
}, 1000);
return () => clearTimeout(t);
}, []);
if (authStatus !== AUTH_STATUS.LOGGED_IN) return;
// Re-fetch credits & subscription whenever the user logs in and out
useEffect(() => {
setIsCreditsLoading(true);
const t = setTimeout(() => {
Promise.all([
creditsStore.fetchFromServer(),
subscriptionStore.fetchFromServer(),
]).finally(() => setIsCreditsLoading(false));
}, 1000); // 1 second delay before fetching to make sure credits and subscription info loads
return () => clearTimeout(t);
}, [authentication.status.value]);
void useCreditsState.getState().fetchFromServer();
void useSubscriptionState.getState().fetchFromServer();
const interval = setInterval(() => {
void useCreditsState.getState().fetchFromServer();
console.log("TopBar: Polled credits");
}, CREDITS_POLL_INTERVAL);
return () => clearInterval(interval);
}, [authStatus]);
useCreditsBalanceChangedEvent(async () => {
creditsStore.fetchFromServer();
useCreditsState.getState().fetchFromServer();
});
useSubscriptionPlanChangedEvent(async () => {
@@ -296,10 +377,18 @@ export const TopBar = ({ pageName }: Props) => {
endFrameImage = { ...base, file: new File([], "recreate-ref") };
break;
case "vid_ref":
vidRefs.push({ ...base, file: new File([], "recreate-ref"), duration: 0 });
vidRefs.push({
...base,
file: new File([], "recreate-ref"),
duration: 0,
});
break;
case "audioref":
audioRefs.push({ ...base, file: new File([], "recreate-ref"), duration: 0 });
audioRefs.push({
...base,
file: new File([], "recreate-ref"),
duration: 0,
});
break;
default:
// imgref, imgref_character, imgref_style, imgref_bg, imgsrc, vid_start_frame, imgmask
@@ -310,7 +399,8 @@ export const TopBar = ({ pageName }: Props) => {
// Determine input mode from generation_mode or context image semantics
const hasKeyframeSemantics = contextImages.some(
(ci) => ci.semantic === "vid_start_frame" || ci.semantic === "vid_end_frame",
(ci) =>
ci.semantic === "vid_start_frame" || ci.semantic === "vid_end_frame",
);
const inputMode: "keyframe" | "reference" =
promptData.maybe_generation_mode === "keyframe" || hasKeyframeSemantics
@@ -383,10 +473,14 @@ export const TopBar = ({ pageName }: Props) => {
}
if (imgRefs.length > 0) imageStore.setReferenceImages(imgRefs);
if (promptData.maybe_aspect_ratio) {
imageStore.setCommonAspectRatio(promptData.maybe_aspect_ratio as CommonAspectRatio);
imageStore.setCommonAspectRatio(
promptData.maybe_aspect_ratio as CommonAspectRatio,
);
}
if (promptData.maybe_resolution) {
imageStore.setCommonResolution(promptData.maybe_resolution as CommonResolution);
imageStore.setCommonResolution(
promptData.maybe_resolution as CommonResolution,
);
}
if (promptData.maybe_model_type) {
@@ -604,97 +698,91 @@ export const TopBar = ({ pageName }: Props) => {
<div className="flex justify-end gap-2" data-tauri-drag-region>
<div className="no-drag flex items-center gap-1.5">
{isCreditsLoading ? (
<LoadingSpinner className="mx-2 text-base-fg/50" />
) : (
<>
<PopoverMenu
position="bottom"
align="center"
triggerIcon={
<FontAwesomeIcon icon={faCoins} className="text-primary" />
}
triggerLabel={
<span className="whitespace-nowrap text-sm font-medium">
{sumTotalCredits} Credits
<PopoverMenu
position="bottom"
align="center"
triggerIcon={
<CreditsCoinWithStatus iconStatus={creditsIconStatus} />
}
triggerLabel={
<span className="whitespace-nowrap text-sm font-medium">
{sumTotalCredits} Credits
</span>
}
buttonClassName="h-[30px] px-2 ps-1.5 bg-transparent hover:bg-ui-controls/30 border-0 shadow-none"
panelClassName="mt-3 bg-ui-panel border border-ui-panel-border text-base-fg"
>
{(close) => (
<div className="w-72 p-2.5 text-base-fg">
<div className="mb-2 flex items-center justify-between">
<span className="flex items-center gap-1.5 text-sm font-medium text-base-fg/80">
Your credit balance
</span>
}
buttonClassName="h-[30px] px-2 ps-1.5 bg-transparent hover:bg-ui-controls/30 border-0 shadow-none"
panelClassName="mt-3 bg-ui-panel border border-ui-panel-border text-base-fg"
>
{(close) => (
<div className="w-72 p-2.5 text-base-fg">
<div className="mb-2 flex items-center justify-between">
<span className="flex items-center gap-1.5 text-sm font-medium text-base-fg/80">
Your credit balance
</span>
<button
className="text-sm font-medium text-primary-400 transition-all hover:text-primary-300"
onClick={() => {
close();
toggleCreditsModal();
}}
>
Buy credits
</button>
</div>
<div className="flex items-center gap-2 text-4xl font-bold text-base-fg">
<FontAwesomeIcon
icon={faCoins}
className="text-2xl text-primary"
/>
{sumTotalCredits}
</div>
<button
className="text-sm font-medium text-primary-400 transition-all hover:text-primary-300"
onClick={() => {
close();
toggleCreditsModal();
}}
>
Buy credits
</button>
</div>
<div className="flex items-center gap-2 text-4xl font-bold text-base-fg">
<FontAwesomeIcon
icon={faCoins}
className="text-2xl text-primary"
/>
{sumTotalCredits}
</div>
<button
className="mt-2 flex items-center gap-1.5 text-xs text-base-fg/50 transition-colors hover:text-primary"
onClick={() => {
close();
useCostBreakdownModalStore.getState().openModal();
}}
>
<FontAwesomeIcon icon={faCalculator} />
Cost calculator
</button>
<div className="mt-3 flex gap-2">
<Button
variant="action"
className="h-9 grow"
onClick={() => {
close();
handleOpenBillingSettings();
}}
>
See details
</Button>
<Button
variant="primary"
className="h-9 grow"
onClick={() => {
close();
toggleSubscriptionModal();
}}
icon={faGem}
>
Support
</Button>
</div>
</div>
)}
</PopoverMenu>
{!hasPaidPlan && (
<Button
variant="primary"
icon={faGem}
onClick={toggleSubscriptionModal}
className="h-[38px] shadow-md shadow-primary-500/50 transition-all duration-300 hover:shadow-md hover:shadow-primary-500/75"
<button
className="mt-2 flex items-center gap-1.5 text-xs text-base-fg/50 transition-colors hover:text-primary"
onClick={() => {
close();
useCostBreakdownModalStore.getState().openModal();
}}
>
Support
</Button>
)}
</>
<FontAwesomeIcon icon={faCalculator} />
Cost calculator
</button>
<div className="mt-3 flex gap-2">
<Button
variant="action"
className="h-9 grow"
onClick={() => {
close();
handleOpenBillingSettings();
}}
>
See details
</Button>
<Button
variant="primary"
className="h-9 grow"
onClick={() => {
close();
toggleSubscriptionModal();
}}
icon={faGem}
>
Support
</Button>
</div>
</div>
)}
</PopoverMenu>
{!hasPaidPlan && (
<Button
variant="primary"
icon={faGem}
onClick={toggleSubscriptionModal}
className="h-[38px] shadow-md shadow-primary-500/50 transition-all duration-300 hover:shadow-md hover:shadow-primary-500/75"
>
Support
</Button>
)}
<UploadImagesButton className="h-[38px] w-[38px]" />
@@ -125,4 +125,25 @@ export class CharactersApi extends ApiManager {
errorMessage: err.message,
}));
}
public async ListAllCharacters(): Promise<ApiResponse<Character[]>> {
const all: Character[] = [];
let cursor: number | undefined = undefined;
while (true) {
const res: ApiResponse<Character[], { next_cursor?: number | null }> =
await this.ListCharacters({ cursor });
if (!res.success || !res.data) {
return all.length > 0
? { success: true, data: all }
: { success: false, errorMessage: res.errorMessage };
}
all.push(...res.data);
const next = res.pagination?.next_cursor;
if (!next) break;
cursor = next;
}
return { success: true, data: all };
}
}
@@ -49,9 +49,11 @@ interface UploadedImage {
interface PendingCharacter {
name: string;
previewUrl?: string;
createdAt: number;
}
const POLL_INTERVAL_MS = 5000;
const PENDING_STALE_MS = 3 * 60 * 1000;
export const CharactersModal = ({
isOpen,
@@ -84,8 +86,11 @@ export const CharactersModal = ({
setRefreshKey((k) => k + 1);
};
const handleCreated = (pending: PendingCharacter) => {
setPendingCharacters((prev) => [pending, ...prev]);
const handleCreated = (pending: { name: string; previewUrl?: string }) => {
setPendingCharacters((prev) => [
{ ...pending, createdAt: Date.now() },
...prev,
]);
setView("list");
setRefreshKey((k) => k + 1);
};
@@ -94,6 +99,58 @@ export const CharactersModal = ({
setPendingCharacters((prev) => prev.filter((p) => p.name !== name));
}, []);
// Poll the server while the modal is open and any creation is pending, so
// a creation that finishes while the user is on the create/edit view still
// gets cleaned up — preventing the duplicate (real + pending) card.
useEffect(() => {
if (!isOpen || pendingCharacters.length === 0) return;
const interval = setInterval(async () => {
try {
const api = new CharactersApi();
const res = await api.ListAllCharacters();
if (!res.success || !res.data) return;
const serverNames = new Set(res.data.map((c) => c.name));
let resolved = false;
setPendingCharacters((prev) =>
prev.filter((p) => {
if (serverNames.has(p.name)) {
resolved = true;
return false;
}
return true;
}),
);
if (resolved) setRefreshKey((k) => k + 1);
} catch {
// retry next tick
}
}, POLL_INTERVAL_MS);
return () => clearInterval(interval);
}, [isOpen, pendingCharacters.length]);
// Time out failed creations so the "Creating..." card never sticks forever.
useEffect(() => {
if (pendingCharacters.length === 0) return;
const timers = pendingCharacters.map((pending) =>
setTimeout(
() => {
setPendingCharacters((prev) => {
if (!prev.some((p) => p.name === pending.name)) return prev;
toast.error(`Character "${pending.name}" failed to create`);
return prev.filter((p) => p.name !== pending.name);
});
},
Math.max(0, PENDING_STALE_MS - (Date.now() - pending.createdAt)),
),
);
return () => timers.forEach(clearTimeout);
}, [pendingCharacters]);
return (
<Modal
isOpen={isOpen}
@@ -158,11 +215,7 @@ const CharacterListView = ({
}) => {
const [characters, setCharacters] = useState<Character[]>([]);
const [loading, setLoading] = useState(true);
const [hasMore, setHasMore] = useState(false);
const [cursor, setCursor] = useState<number | undefined>(undefined);
const sentinelRef = useRef<HTMLDivElement>(null);
const loadingMoreRef = useRef(false);
const storeSetCharacters = useCharactersStore((s) => s.setCharacters);
const storeSetLoaded = useCharactersStore((s) => s.setLoaded);
const storeRemoveCharacter = useCharactersStore((s) => s.removeCharacter);
@@ -181,83 +234,33 @@ const CharacterListView = ({
[storeSetCharacters, storeSetLoaded],
);
const fetchCharacters = useCallback(
async (nextCursor?: number) => {
if (loadingMoreRef.current) return;
loadingMoreRef.current = true;
try {
const api = new CharactersApi();
const res = await api.ListCharacters({
cursor: nextCursor,
});
if (res.success && res.data) {
setCharacters((prev) => {
const updated = nextCursor ? [...prev, ...res.data!] : res.data!;
syncToStore(updated);
return updated;
});
const nextPage = res.pagination?.next_cursor;
setCursor(nextPage ?? undefined);
setHasMore(!!nextPage);
}
} catch {
storeSetLoaded(true);
} finally {
setLoading(false);
loadingMoreRef.current = false;
const fetchCharacters = useCallback(async () => {
try {
const api = new CharactersApi();
const res = await api.ListAllCharacters();
if (res.success && res.data) {
setCharacters(res.data);
syncToStore(res.data);
}
},
[syncToStore, storeSetLoaded],
);
} catch {
storeSetLoaded(true);
} finally {
setLoading(false);
}
}, [syncToStore, storeSetLoaded]);
useEffect(() => {
fetchCharacters();
}, [fetchCharacters]);
// Poll for pending characters becoming active
// Drop pendings already represented in the fetched list (no duplicate cards)
useEffect(() => {
if (pendingCharacters.length === 0) return;
const interval = setInterval(async () => {
try {
const api = new CharactersApi();
const res = await api.ListCharacters({});
if (res.success && res.data) {
const serverNames = new Set(res.data.map((c) => c.name));
// Resolve any pending characters that now appear in the server list
for (const pending of pendingCharacters) {
if (serverNames.has(pending.name)) {
onPendingResolved(pending.name);
}
}
// Update the list with fresh server data
setCharacters(res.data);
syncToStore(res.data);
}
} catch {
// Silently retry on next interval
}
}, POLL_INTERVAL_MS);
return () => clearInterval(interval);
}, [pendingCharacters, onPendingResolved, syncToStore]);
// Infinite scroll via IntersectionObserver
useEffect(() => {
if (!sentinelRef.current || !hasMore) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting && hasMore && cursor) {
fetchCharacters(cursor);
}
},
{ threshold: 0.1 },
);
observer.observe(sentinelRef.current);
return () => observer.disconnect();
}, [hasMore, cursor, fetchCharacters]);
const names = new Set(characters.map((c) => c.name));
for (const pending of pendingCharacters) {
if (names.has(pending.name)) onPendingResolved(pending.name);
}
}, [characters, pendingCharacters, onPendingResolved]);
const handleDelete = (character: Character) => {
showActionReminder({
@@ -455,15 +458,6 @@ const CharacterListView = ({
</div>
)}
{/* Sentinel for infinite scroll */}
{hasMore && (
<div ref={sentinelRef} className="flex justify-center py-4">
<FontAwesomeIcon
icon={faSpinnerThird}
className="text-base-fg/30 animate-spin"
/>
</div>
)}
</div>
);
};
@@ -608,7 +602,7 @@ const NewCharacterView = ({
onCreated,
}: {
onBack: () => void;
onCreated: (pending: PendingCharacter) => void;
onCreated: (pending: { name: string; previewUrl?: string }) => void;
}) => {
const addCharacterToStore = useCharactersStore((s) => s.addCharacter);
const [name, setName] = useState("");
@@ -562,9 +562,13 @@ export const MentionTextarea = forwardRef<HTMLDivElement, MentionTextareaProps>(
const el = editorRef.current;
if (!el) return;
const caretPos = getCaretOffset(el);
// Use the known mention bounds — `getCaretOffset` can return 0 when the
// dropdown click collapses the selection out of the editor, which would
// leave the typed query in the prompt alongside the inserted label.
const queryEnd =
mentionState.triggerIndex + mentionState.query.length;
const before = value.slice(0, mentionState.triggerIndex);
const after = value.slice(caretPos);
const after = value.slice(queryEnd);
const mention = `${item.label} `;
const newValue = before + mention + after;
@@ -583,7 +587,7 @@ export const MentionTextarea = forwardRef<HTMLDivElement, MentionTextareaProps>(
el.focus();
});
},
[value, mentionState.triggerIndex, onChange],
[value, mentionState.triggerIndex, mentionState.query, onChange],
);
const handleKeyDown = useCallback(
@@ -621,13 +625,11 @@ export const MentionTextarea = forwardRef<HTMLDivElement, MentionTextareaProps>(
}
// Insert a newline instead of letting the contentEditable create a <div>.
// When `enterToGenerate` is off (default), plain Enter inserts a newline
// and Shift/Cmd+Enter submits via externalOnKeyDown. When the user opts
// into Enter-to-generate, the modifier combo is what inserts the newline.
// When `enterToGenerate` is off (default), all Enter combos insert a newline
// (only the button submits). When the user opts into Enter-to-generate,
// Shift/Cmd+Enter inserts a newline and plain Enter submits.
if (e.key === "Enter") {
const newlineCombo = enterToGenerate
? e.shiftKey || e.metaKey
: !e.shiftKey && !e.metaKey;
const newlineCombo = !enterToGenerate || e.shiftKey || e.metaKey;
if (newlineCombo) {
e.preventDefault();
document.execCommand("insertLineBreak");
@@ -332,7 +332,7 @@ export const PromptBox2D = ({
e.stopPropagation();
if (e.key !== "Enter") return;
const isSubmitCombo = enterToGenerate ? !e.shiftKey : e.shiftKey;
const isSubmitCombo = enterToGenerate && !e.shiftKey;
if (isSubmitCombo) {
e.preventDefault();
handleGenerate();
@@ -461,7 +461,7 @@ export const PromptBox3D = ({
e.stopPropagation();
if (e.key !== "Enter") return;
const isSubmitCombo = enterToGenerate ? !e.shiftKey : e.shiftKey;
const isSubmitCombo = enterToGenerate && !e.shiftKey;
if (isSubmitCombo) {
e.preventDefault();
handleEnqueue();
@@ -239,7 +239,7 @@ export const PromptBoxEdit = ({
e.stopPropagation();
if (e.key !== "Enter") return;
const isSubmitCombo = enterToGenerate ? !e.shiftKey : e.shiftKey;
const isSubmitCombo = enterToGenerate && !e.shiftKey;
if (isSubmitCombo) {
e.preventDefault();
const busy = Boolean(isEnqueueing ?? internalEnqueueing);
@@ -397,7 +397,7 @@ export const PromptBoxImage = ({
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key !== "Enter") return;
const isSubmitCombo = enterToGenerate ? !e.shiftKey : e.shiftKey;
const isSubmitCombo = enterToGenerate && !e.shiftKey;
if (isSubmitCombo) {
e.preventDefault();
handleEnqueue();
@@ -154,7 +154,7 @@ export const PromptBoxVideo = ({
if (charactersLoaded) return;
const api = new CharactersApi();
api
.ListCharacters()
.ListAllCharacters()
.then((res) => {
if (res.success && res.data) {
storeSetCharacters(
@@ -865,7 +865,7 @@ export const PromptBoxVideo = ({
}
if (e.key !== "Enter") return;
const isSubmitCombo = enterToGenerate ? !e.shiftKey : e.shiftKey;
const isSubmitCombo = enterToGenerate && !e.shiftKey;
if (isSubmitCombo) {
e.preventDefault();
@@ -991,9 +991,7 @@ export const PromptBoxVideo = ({
className="promptbox-scrollbar text-md relative mb-2 min-h-[2.5em] w-full resize-y overflow-y-auto rounded bg-transparent pb-2 pr-2 pt-1 text-base-fg"
onKeyDown={(e) => {
if (e.key !== "Enter") return;
const isSubmitCombo = enterToGenerate
? !e.shiftKey
: e.shiftKey;
const isSubmitCombo = enterToGenerate && !e.shiftKey;
if (isSubmitCombo) {
e.preventDefault();
if (
@@ -209,7 +209,7 @@ export const usePromptEditStore = create<PromptEditStore>()((set) => ({
// ----- Enter-to-Generate Preference Store -----
// Controls how the Enter key behaves inside prompt boxes.
// false (default): Enter inserts a newline; Shift+Enter submits.
// false (default): Both Enter and Shift+Enter insert a newline; only the button submits.
// true: Enter submits; Shift+Enter inserts a newline.
// Persisted to localStorage so the choice survives reloads.
const ENTER_TO_GENERATE_STORAGE_KEY = "artcraft_enter_to_generate";
@@ -12,10 +12,14 @@ import { Label } from "@storyteller/ui-label";
import { Switch } from "@storyteller/ui-switch";
import { DownloadDirectoryReveal } from "@storyteller/tauri-api";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faFolder, faMagnifyingGlass, faRotateLeft } from "@fortawesome/pro-solid-svg-icons";
import {
faFolder,
faMagnifyingGlass,
faRotateLeft,
} from "@fortawesome/pro-solid-svg-icons";
import { useEnterToGenerateStore } from "@storyteller/ui-promptbox";
interface MiscSettingsPaneProps { }
interface MiscSettingsPaneProps {}
export const MiscSettingsPane = (args: MiscSettingsPaneProps) => {
const [preferences, setPreferences] = useState<
@@ -114,8 +118,8 @@ export const MiscSettingsPane = (args: MiscSettingsPaneProps) => {
<Label htmlFor="enter-to-generate">Enter to generate</Label>
<p className="text-xs opacity-70">
When on, pressing Enter submits the prompt and Shift+Enter adds a
new line. When off (default), Enter adds a new line and Shift+Enter
submits.
new line. When off (default), both Enter and Shift+Enter add a new
line - use only the button to submit.
</p>
</div>
<Switch enabled={enterToGenerate} setEnabled={setEnterToGenerate} />
@@ -2,18 +2,15 @@
"files": [],
"include": [],
"references": [
{
"path": "../../analytics/google-analytics"
},
{
"path": "../modal"
},
{
"path": "../../tauri-utils"
},
{
"path": "../../model-list"
},
{
"path": "../promptbox"
},
{
"path": "../../state/subscription"
},
@@ -33,10 +30,10 @@
"path": "../../soundboard"
},
{
"path": "../../tauri-events"
"path": "../input"
},
{
"path": "../input"
"path": "../../tauri-events"
},
{
"path": "../login-modal"
@@ -45,7 +42,7 @@
"path": "../../api"
},
{
"path": "../button"
"path": "../../analytics/google-analytics"
},
{
"path": "../label"
@@ -53,6 +50,12 @@
{
"path": "../../tauri-api"
},
{
"path": "../button"
},
{
"path": "../modal"
},
{
"path": "./tsconfig.lib.json"
},
@@ -43,18 +43,15 @@
"src/**/*.tsx"
],
"references": [
{
"path": "../../analytics/google-analytics/tsconfig.lib.json"
},
{
"path": "../modal/tsconfig.lib.json"
},
{
"path": "../../tauri-utils/tsconfig.lib.json"
},
{
"path": "../../model-list/tsconfig.lib.json"
},
{
"path": "../promptbox/tsconfig.lib.json"
},
{
"path": "../../state/subscription/tsconfig.lib.json"
},
@@ -74,10 +71,10 @@
"path": "../../soundboard/tsconfig.lib.json"
},
{
"path": "../../tauri-events/tsconfig.lib.json"
"path": "../input/tsconfig.lib.json"
},
{
"path": "../input/tsconfig.lib.json"
"path": "../../tauri-events/tsconfig.lib.json"
},
{
"path": "../login-modal/tsconfig.lib.json"
@@ -86,13 +83,19 @@
"path": "../../api/tsconfig.lib.json"
},
{
"path": "../button/tsconfig.lib.json"
"path": "../../analytics/google-analytics/tsconfig.lib.json"
},
{
"path": "../label/tsconfig.lib.json"
},
{
"path": "../../tauri-api/tsconfig.lib.json"
},
{
"path": "../button/tsconfig.lib.json"
},
{
"path": "../modal/tsconfig.lib.json"
}
]
}
@@ -6,25 +6,26 @@ interface SwitchProps {
enabled: boolean;
setEnabled: (enabled: boolean) => void;
className?: string;
offClassName?: string;
}
export function Switch({ enabled, setEnabled, className }: SwitchProps) {
export function Switch({ enabled, setEnabled, className, offClassName }: SwitchProps) {
return (
<HeadlessSwitch checked={enabled} onChange={setEnabled} as={Fragment}>
{({ checked, disabled }) => (
<button
className={clsx(
"group inline-flex h-6 w-11 items-center rounded-full",
checked ? "bg-primary" : "bg-action",
checked ? "bg-primary" : (offClassName ?? "bg-action"),
disabled && "cursor-not-allowed opacity-50",
className
className,
)}
>
<span className="sr-only">Enable notifications</span>
<span
className={clsx(
"size-4 rounded-full bg-white transition",
checked ? "translate-x-6" : "translate-x-1"
checked ? "translate-x-6" : "translate-x-1",
)}
/>
</button>
@@ -189,6 +189,53 @@ export const VIDEO_MODELS: VideoModel[] = [
maxPromptLength: 2500,
generateWithSound: true,
}),
new VideoModel({
id: "happy_horse_1p0",
tauriId: "happy_horse_1p0",
fullName: "Happy Horse 1.0",
category: "video",
creator: ModelCreator.Alibaba,
selectorName: "Happy Horse 1.0",
selectorDescription: "High quality video model",
selectorBadges: ["2 min."],
startFrame: true,
endFrame: false,
requiresImage: false,
resolutionOptions: ["720p", "1080p"],
sizeOptions: [
{
tauriValue: "wide_sixteen_by_nine",
textLabel: "16:9",
icon: SizeIconOption.Landscape16x9,
},
{
tauriValue: "wide_four_by_three",
textLabel: "4:3",
icon: SizeIconOption.Standard4x3,
},
{
tauriValue: "square",
textLabel: "1:1",
icon: SizeIconOption.Square,
},
{
tauriValue: "tall_three_by_four",
textLabel: "3:4",
icon: SizeIconOption.Portrait3x4,
},
{
tauriValue: "tall_nine_by_sixteen",
textLabel: "9:16",
icon: SizeIconOption.Portrait9x16,
},
],
supportsCommonAspectRatio: true,
durationOptions: [3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15],
defaultDuration: 5,
progressBarTime: 300000,
maxPromptLength: 2500,
generateWithSound: true,
}),
new VideoModel({
id: "seedance_1_0_lite",
tauriId: "seedance_1.0_lite",
@@ -270,6 +317,11 @@ export const VIDEO_MODELS: VideoModel[] = [
endFrame: true,
requiresImage: false,
sizeOptions: [
{
tauriValue: "wide_twenty_one_by_nine",
textLabel: "21:9",
icon: SizeIconOption.Landscape16x9,
},
{
tauriValue: "wide_sixteen_by_nine",
textLabel: "16:9",
@@ -324,6 +376,11 @@ export const VIDEO_MODELS: VideoModel[] = [
endFrame: true,
requiresImage: false,
sizeOptions: [
{
tauriValue: "wide_twenty_one_by_nine",
textLabel: "21:9",
icon: SizeIconOption.Landscape16x9,
},
{
tauriValue: "wide_sixteen_by_nine",
textLabel: "16:9",
@@ -1,6 +1,11 @@
import { create } from 'zustand'
import { ArtcraftGetCredits } from "@storyteller/tauri-api";
const SLOW_FETCH_MS = 3000;
const RECOVERED_HOLD_MS = 1000;
export type CreditsIconStatus = 'hidden' | 'slow' | 'failed' | 'recovered';
export interface CreditsState {
// Daily free credits (if/when we offer them)
freeCredits: number,
@@ -13,6 +18,9 @@ export interface CreditsState {
// Total credits available
totalCredits: number,
// Drives the status badge on the credits coin in TopBar.
iconStatus: CreditsIconStatus,
}
export type CreditsActions = {
@@ -22,31 +30,94 @@ export type CreditsActions = {
reset: () => void
}
export const useCreditsState = create<CreditsState & CreditsActions>((set) => ({
freeCredits: 0,
monthlyCredits: 0,
bankedCredits: 0,
totalCredits: 0,
export const useCreditsState = create<CreditsState & CreditsActions>((set, get) => {
let token = 0;
let slowTimer: ReturnType<typeof setTimeout> | null = null;
let recoveredTimer: ReturnType<typeof setTimeout> | null = null;
reset: () => set({ freeCredits: 0, monthlyCredits: 0, bankedCredits: 0, totalCredits: 0 }),
const clearSlowTimer = () => {
if (slowTimer) { clearTimeout(slowTimer); slowTimer = null; }
};
const clearRecoveredTimer = () => {
if (recoveredTimer) { clearTimeout(recoveredTimer); recoveredTimer = null; }
};
// Call to fetch credits from the server
fetchFromServer: async () => {
let data;
try {
data = await ArtcraftGetCredits();
} catch (error) {
console.error("Error fetching credits", error);
return;
}
console.log("Fetched credits from server: ", data);
if (!!data.payload) {
set((state) => ({
return {
freeCredits: 0,
monthlyCredits: 0,
bankedCredits: 0,
totalCredits: 0,
iconStatus: 'hidden',
reset: () => {
token += 1;
clearSlowTimer();
clearRecoveredTimer();
set({
freeCredits: 0,
monthlyCredits: 0,
bankedCredits: 0,
totalCredits: 0,
iconStatus: 'hidden',
});
},
fetchFromServer: async () => {
const myToken = ++token;
clearSlowTimer();
clearRecoveredTimer();
// The recovered window has served its purpose; start the new cycle clean.
// Preserve visible 'slow' / 'failed' so the icon doesn't flicker while the
// new fetch is in flight.
if (get().iconStatus === 'recovered') {
set({ iconStatus: 'hidden' });
}
slowTimer = setTimeout(() => {
if (myToken === token) {
set({ iconStatus: 'slow' });
}
}, SLOW_FETCH_MS);
let data;
try {
data = await ArtcraftGetCredits();
} catch (error) {
console.error("Error fetching credits", error);
if (myToken !== token) return;
clearSlowTimer();
set({ iconStatus: 'failed' });
return;
}
if (myToken !== token) return;
clearSlowTimer();
console.log("Fetched credits from server: ", data);
if (!data?.payload) {
set({ iconStatus: 'failed' });
return;
}
const prior = get().iconStatus;
const wasProblematic = prior === 'slow' || prior === 'failed';
set({
freeCredits: data.payload.free_credits,
monthlyCredits: data.payload.monthly_credits,
bankedCredits: data.payload.banked_credits,
totalCredits: data.payload.sum_total_credits,
}));
}
}
}))
iconStatus: wasProblematic ? 'recovered' : 'hidden',
});
if (wasProblematic) {
recoveredTimer = setTimeout(() => {
if (get().iconStatus === 'recovered') {
set({ iconStatus: 'hidden' });
}
recoveredTimer = null;
}, RECOVERED_HOLD_MS);
}
},
};
});
+168 -85
View File
File diff suppressed because it is too large Load Diff