mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-09 23:46:45 -05:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b8db60365b | ||
|
|
8f9607cc5d | ||
|
|
e1b8864a8a | ||
|
|
8dcebec25c | ||
|
|
f7c9e4ecc7 | ||
|
|
633e6bdd6c | ||
|
|
13f1fb4f63 | ||
|
|
967828ec2f | ||
|
|
90baabca4b | ||
|
|
3ede4ef8d1 | ||
|
|
96194ea0d8 |
+1
-1
@@ -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,
|
||||
|
||||
+3
-2
@@ -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];
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+41
-41
@@ -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 --
|
||||
|
||||
|
||||
+11
-3
@@ -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;
|
||||
|
||||
|
||||
+60
-54
@@ -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,
|
||||
|
||||
+10
-8
@@ -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,
|
||||
|
||||
+10
-324
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+11
-3
@@ -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;
|
||||
|
||||
|
||||
+63
-57
@@ -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,
|
||||
|
||||
+10
-11
@@ -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,
|
||||
|
||||
+10
-233
@@ -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::*;
|
||||
|
||||
|
||||
+67
@@ -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",
|
||||
|
||||
+3
@@ -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",
|
||||
|
||||
+8
-4
@@ -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,
|
||||
|
||||
+66
-22
@@ -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)
|
||||
}
|
||||
|
||||
+12
-4
@@ -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,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,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,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>
|
||||
|
||||
+1
@@ -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);
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
Generated
+168
-85
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user