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