mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-03 21:10:58 -05:00
Compare commits
50
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c994bfcb4c | ||
|
|
a782715cc5 | ||
|
|
d35ad4cbd8 | ||
|
|
b5bb525216 | ||
|
|
350d7d607c | ||
|
|
467e22f111 | ||
|
|
906dd3fd99 | ||
|
|
d6ed101563 | ||
|
|
4d98c7e0d6 | ||
|
|
31385ea2c7 | ||
|
|
8ee73fda19 | ||
|
|
064562c741 | ||
|
|
d934d3a573 | ||
|
|
2ae7f02679 | ||
|
|
6a4b172bc0 | ||
|
|
35a7bf5687 | ||
|
|
6c5b1a466a | ||
|
|
baf4a8f7de | ||
|
|
8310e1534d | ||
|
|
ebe3dd676f | ||
|
|
d922f0afe0 | ||
|
|
8bf47c50ae | ||
|
|
7603bf2127 | ||
|
|
5664705c7c | ||
|
|
43388a1cb9 | ||
|
|
de9e0b1152 | ||
|
|
4ea9c8b8aa | ||
|
|
4f79fb4601 | ||
|
|
a1c026212c | ||
|
|
42fca1c9b4 | ||
|
|
be32ab6794 | ||
|
|
e1d83debd6 | ||
|
|
95975b9afb | ||
|
|
f15aece011 | ||
|
|
c1a16e9564 | ||
|
|
e6e739e874 | ||
|
|
4112652c4e | ||
|
|
969d7d7609 | ||
|
|
1f53b6a4cf | ||
|
|
3947eebe7e | ||
|
|
5eb2abf112 | ||
|
|
31a9704faf | ||
|
|
02676680b1 | ||
|
|
ba19e9ae40 | ||
|
|
00499252a1 | ||
|
|
c7b9de7cf9 | ||
|
|
c2ca096099 | ||
|
|
a04ee4f76c | ||
|
|
8bc552c450 | ||
|
|
128d3e43c5 |
+2
-1
@@ -15,11 +15,12 @@ body:
|
||||
type: json
|
||||
content: |
|
||||
{
|
||||
"model": "seedream_5_lite",
|
||||
"model": "gpt_image_2",
|
||||
"provider": "artcraft",
|
||||
"generation_mode": {"type": "text_to_image" },
|
||||
"aspect_ratio": "wide_sixteen_by_nine",
|
||||
"resolution": "two_k",
|
||||
"quality": "low",
|
||||
"image_batch_count": 1
|
||||
}
|
||||
order: 2000
|
||||
|
||||
+8
-2
@@ -3,13 +3,14 @@ use utoipa::ToSchema;
|
||||
|
||||
use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
|
||||
use enums::common::generation::common_image_model::CommonImageModel;
|
||||
use enums::common::generation::common_quality::CommonQuality;
|
||||
use enums::common::generation::common_resolution::CommonResolution;
|
||||
use enums::common::generation_provider::GenerationProvider;
|
||||
|
||||
pub const ESTIMATE_IMAGE_COST_PATH: &str = "/v1/generate/cost_estimate/image";
|
||||
|
||||
/// Request body for the image cost estimate endpoint.
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
#[derive(Serialize, Deserialize, ToSchema, Debug)]
|
||||
pub struct EstimateImageCostRequest {
|
||||
/// The image model to estimate costs for.
|
||||
pub model: CommonImageModel,
|
||||
@@ -29,12 +30,15 @@ pub struct EstimateImageCostRequest {
|
||||
/// Optional resolution.
|
||||
pub resolution: Option<CommonResolution>,
|
||||
|
||||
// Optional quality.
|
||||
pub quality: Option<CommonQuality>,
|
||||
|
||||
/// Number of images to generate in parallel.
|
||||
pub image_batch_count: Option<u16>,
|
||||
}
|
||||
|
||||
/// Describes the type of image generation being requested.
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
#[derive(Serialize, Deserialize, ToSchema, Debug)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum GenerationMode {
|
||||
/// No input images — generate from prompt only.
|
||||
@@ -94,6 +98,7 @@ mod tests {
|
||||
generation_mode: GenerationMode::TextToImage,
|
||||
aspect_ratio: None,
|
||||
resolution: None,
|
||||
quality: None,
|
||||
image_batch_count: None,
|
||||
};
|
||||
let serialized = serde_json::to_string(&request).unwrap();
|
||||
@@ -108,6 +113,7 @@ mod tests {
|
||||
generation_mode: GenerationMode::ImageEdit { count: 2 },
|
||||
aspect_ratio: None,
|
||||
resolution: None,
|
||||
quality: None,
|
||||
image_batch_count: None,
|
||||
};
|
||||
let serialized = serde_json::to_string(&request).unwrap();
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ use tokens::tokens::characters::CharacterToken;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
/// Shared request body for both the video cost estimate and video generation endpoints.
|
||||
#[derive(Serialize, Deserialize, ToSchema, Debug)]
|
||||
#[derive(Clone, Serialize, Deserialize, ToSchema, Debug)]
|
||||
pub struct OmniGenVideoCostAndGenerateRequest {
|
||||
/// REQUIRED (even if marked optional)
|
||||
/// Idempotency token to prevent duplicate requests.
|
||||
|
||||
+2
@@ -52,6 +52,8 @@ pub async fn execute_seedance2pro_seedance2p0(
|
||||
Ok(GenerateVideoResponse::Seedance2Pro(Seedance2proVideoResponsePayload {
|
||||
order_id: response.order_id,
|
||||
task_id: response.task_id,
|
||||
maybe_order_ids: response.order_ids,
|
||||
maybe_task_ids: response.task_ids,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
+2
@@ -49,5 +49,7 @@ pub async fn execute_seedance2pro_seedance2p0_fast(
|
||||
Ok(GenerateVideoResponse::Seedance2Pro(Seedance2proVideoResponsePayload {
|
||||
order_id: response.order_id,
|
||||
task_id: response.task_id,
|
||||
maybe_order_ids: response.order_ids,
|
||||
maybe_task_ids: response.task_ids,
|
||||
}))
|
||||
}
|
||||
|
||||
+3
@@ -48,6 +48,7 @@ use crate::generate::generate_video::plan::seedance2pro::plan_generate_video_see
|
||||
use crate::generate::generate_video::plan::seedance2pro::plan_generate_video_seedance2pro_seedance2p0_fast::plan_generate_video_seedance2pro_seedance2p0_fast;
|
||||
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
|
||||
use crate::generate::generate_video_v2::providers::artcraft::seedance_2p0::build::build_artcraft_seedance_2p0;
|
||||
use crate::generate::generate_video_v2::providers::artcraft::seedance_2p0_fast::build::build_artcraft_seedance_2p0_fast;
|
||||
use crate::generate::generate_video_v2::providers::kinovi::seedance_2p0::build::build_kinovi_seedance_2p0;
|
||||
use crate::generate::generate_video_v2::providers::kinovi::seedance_2p0_fast::build::build_kinovi_seedance_2p0_fast;
|
||||
use crate::generate::generate_video_v2::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
@@ -142,6 +143,7 @@ impl GenerateVideoRequestBuilder {
|
||||
pub fn use_new_builder(&self) -> bool {
|
||||
match (self.provider, self.model) {
|
||||
(Provider::Artcraft, CommonVideoModel::Seedance2p0) => true,
|
||||
(Provider::Artcraft, CommonVideoModel::Seedance2p0Fast) => true,
|
||||
(Provider::Seedance2Pro, CommonVideoModel::Seedance2p0) => true,
|
||||
(Provider::Seedance2Pro, CommonVideoModel::Seedance2p0Fast) => true,
|
||||
_ => false,
|
||||
@@ -152,6 +154,7 @@ impl GenerateVideoRequestBuilder {
|
||||
pub fn build2(self) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
|
||||
match (self.provider, self.model) {
|
||||
(Provider::Artcraft, CommonVideoModel::Seedance2p0) => build_artcraft_seedance_2p0(self),
|
||||
(Provider::Artcraft, CommonVideoModel::Seedance2p0Fast) => build_artcraft_seedance_2p0_fast(self),
|
||||
(Provider::Seedance2Pro, CommonVideoModel::Seedance2p0) => build_kinovi_seedance_2p0(self),
|
||||
(Provider::Seedance2Pro, CommonVideoModel::Seedance2p0Fast) => build_kinovi_seedance_2p0_fast(self),
|
||||
_ => self.unsupported_provider_and_model(),
|
||||
|
||||
+2
@@ -16,6 +16,8 @@ pub struct MuapiVideoResponsePayload {
|
||||
pub struct Seedance2proVideoResponsePayload {
|
||||
pub order_id: String,
|
||||
pub task_id: String,
|
||||
pub maybe_order_ids: Option<Vec<String>>,
|
||||
pub maybe_task_ids: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
|
||||
+8
-3
@@ -71,10 +71,15 @@ generate_video_v2/
|
||||
├── video_generation_request.rs ← VideoGenerationRequest enum
|
||||
└── providers/
|
||||
├── artcraft/ ← Artcraft provider (uses media file tokens directly)
|
||||
│ └── seedance_2p0/ ← Seedance 2.0 Pro model (skips draft, returns Request directly)
|
||||
│ ├── seedance_2p0/ ← Seedance 2.0 Pro model (skips draft, returns Request directly)
|
||||
│ │ ├── mod.rs
|
||||
│ │ ├── build.rs ← builder → Request conversion + plan helpers
|
||||
│ │ ├── cost.rs ← standalone cost estimation (independent of seedance2pro_client)
|
||||
│ │ └── request.rs ← request state + send() via Artcraft multi-function API
|
||||
│ └── seedance_2p0_fast/ ← Seedance 2.0 Fast model (skips draft, 480p/720p only)
|
||||
│ ├── mod.rs
|
||||
│ ├── build.rs ← builder → Request conversion + plan helpers
|
||||
│ ├── cost.rs ← standalone cost estimation (independent of seedance2pro_client)
|
||||
│ ├── build.rs ← builder → Request conversion + plan helpers (no 1080p)
|
||||
│ ├── cost.rs ← standalone cost estimation with Fast-specific pricing
|
||||
│ └── request.rs ← request state + send() via Artcraft omni-gen API
|
||||
└── kinovi/ ← Kinovi/Seedance2Pro provider
|
||||
├── mod.rs
|
||||
|
||||
@@ -2,4 +2,4 @@ pub mod providers;
|
||||
pub mod video_generation_request;
|
||||
pub mod video_generation_draft;
|
||||
pub mod video_generation_draft_or_request;
|
||||
mod video_generation_draft_context;
|
||||
pub mod video_generation_draft_context;
|
||||
+2
@@ -1 +1,3 @@
|
||||
pub(crate) mod resolve;
|
||||
pub mod seedance_2p0;
|
||||
pub mod seedance_2p0_fast;
|
||||
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
//! Shared resolve helpers for the Artcraft provider.
|
||||
//!
|
||||
//! Artcraft only accepts media file tokens — raw URLs are rejected.
|
||||
|
||||
use tokens::tokens::characters::CharacterToken;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
use crate::api::audio_list_ref::AudioListRef;
|
||||
use crate::api::character_list_ref::CharacterListRef;
|
||||
use crate::api::image_list_ref::ImageListRef;
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::video_list_ref::VideoListRef;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::errors::client_error::ClientError;
|
||||
|
||||
pub(super) fn resolve_image_ref(
|
||||
image_ref: Option<ImageRef>,
|
||||
) -> Result<Option<MediaFileToken>, ArtcraftRouterError> {
|
||||
match image_ref {
|
||||
None => Ok(None),
|
||||
Some(ImageRef::MediaFileToken(t)) => Ok(Some(t)),
|
||||
Some(ImageRef::Url(_)) => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn resolve_image_list_ref(
|
||||
image_list_ref: Option<ImageListRef>,
|
||||
) -> Result<Option<Vec<MediaFileToken>>, ArtcraftRouterError> {
|
||||
match image_list_ref {
|
||||
None => Ok(None),
|
||||
Some(ImageListRef::MediaFileTokens(tokens)) => Ok(Some(tokens)),
|
||||
Some(ImageListRef::Urls(_)) => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn resolve_video_list_ref(
|
||||
video_list_ref: Option<VideoListRef>,
|
||||
) -> Result<Option<Vec<MediaFileToken>>, ArtcraftRouterError> {
|
||||
match video_list_ref {
|
||||
None => Ok(None),
|
||||
Some(VideoListRef::MediaFileTokens(tokens)) => Ok(Some(tokens)),
|
||||
Some(VideoListRef::Urls(_)) => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn resolve_audio_list_ref(
|
||||
audio_list_ref: Option<AudioListRef>,
|
||||
) -> Result<Option<Vec<MediaFileToken>>, ArtcraftRouterError> {
|
||||
match audio_list_ref {
|
||||
None => Ok(None),
|
||||
Some(AudioListRef::MediaFileTokens(tokens)) => Ok(Some(tokens)),
|
||||
Some(AudioListRef::Urls(_)) => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn resolve_character_list_ref(
|
||||
character_list_ref: Option<CharacterListRef>,
|
||||
) -> Option<Vec<CharacterToken>> {
|
||||
match character_list_ref {
|
||||
None => None,
|
||||
Some(CharacterListRef::CharacterTokens(tokens)) => Some(tokens),
|
||||
}
|
||||
}
|
||||
+127
-398
@@ -1,19 +1,18 @@
|
||||
use artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::{
|
||||
Seedance2p0AspectRatio, Seedance2p0BatchCount, Seedance2p0MultiFunctionVideoGenRequest,
|
||||
Seedance2p0OutputResolution,
|
||||
};
|
||||
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 as CommonAspectRatioEnum;
|
||||
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
|
||||
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
|
||||
|
||||
use crate::api::audio_list_ref::AudioListRef;
|
||||
use crate::api::character_list_ref::CharacterListRef;
|
||||
use crate::api::common_aspect_ratio::CommonAspectRatio;
|
||||
use crate::api::common_resolution::CommonResolution;
|
||||
use crate::api::image_list_ref::ImageListRef;
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::video_list_ref::VideoListRef;
|
||||
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::errors::client_error::ClientError;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video_v2::providers::artcraft::resolve::{
|
||||
resolve_audio_list_ref, resolve_character_list_ref, resolve_image_list_ref,
|
||||
resolve_image_ref, resolve_video_list_ref,
|
||||
};
|
||||
use crate::generate::generate_video_v2::providers::artcraft::seedance_2p0::request::ArtcraftSeedance2p0RequestState;
|
||||
use crate::generate::generate_video_v2::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
use crate::generate::generate_video_v2::video_generation_request::VideoGenerationRequest;
|
||||
@@ -22,129 +21,66 @@ pub fn build_artcraft_seedance_2p0(mut builder: GenerateVideoRequestBuilder) ->
|
||||
let strategy = builder.request_mismatch_mitigation_strategy;
|
||||
|
||||
let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio.take(), strategy)?;
|
||||
let output_resolution = plan_output_resolution(builder.resolution.take(), strategy)?;
|
||||
let resolution = plan_output_resolution(builder.resolution.take(), strategy)?;
|
||||
let batch_count = plan_batch_count(builder.video_batch_count.take(), strategy)?;
|
||||
let duration_seconds = plan_duration(builder.duration_seconds.take(), strategy)?;
|
||||
let prompt = builder.prompt.take();
|
||||
|
||||
let start_frame_media_token = resolve_image_ref(builder.start_frame.take())?;
|
||||
let end_frame_media_token = resolve_image_ref(builder.end_frame.take())?;
|
||||
let reference_image_media_tokens = resolve_image_list_ref(builder.reference_images.take())?;
|
||||
let reference_video_media_tokens = resolve_video_list_ref(builder.reference_videos.take())?;
|
||||
let reference_audio_media_tokens = resolve_audio_list_ref(builder.reference_audio.take())?;
|
||||
let reference_character_tokens = resolve_character_list_ref(builder.reference_character_tokens.take());
|
||||
let start_frame = resolve_image_ref(builder.start_frame.take())?;
|
||||
let end_frame = resolve_image_ref(builder.end_frame.take())?;
|
||||
let reference_images = resolve_image_list_ref(builder.reference_images.take())?;
|
||||
let reference_videos = resolve_video_list_ref(builder.reference_videos.take())?;
|
||||
let reference_audio = resolve_audio_list_ref(builder.reference_audio.take())?;
|
||||
let reference_characters = resolve_character_list_ref(builder.reference_character_tokens.take());
|
||||
let idempotency_token = builder.get_or_generate_idempotency_token();
|
||||
|
||||
let request = Seedance2p0MultiFunctionVideoGenRequest {
|
||||
uuid_idempotency_token: idempotency_token,
|
||||
let request = OmniGenVideoCostAndGenerateRequest {
|
||||
model: Some(CommonVideoModelEnum::Seedance2p0),
|
||||
idempotency_token: Some(idempotency_token),
|
||||
prompt,
|
||||
start_frame_media_token,
|
||||
end_frame_media_token,
|
||||
reference_image_media_tokens,
|
||||
reference_video_media_tokens,
|
||||
reference_audio_media_tokens,
|
||||
reference_character_tokens,
|
||||
start_frame_image_media_token: start_frame,
|
||||
end_frame_image_media_token: end_frame,
|
||||
reference_image_media_tokens: reference_images,
|
||||
reference_video_media_tokens: reference_videos,
|
||||
reference_audio_media_tokens: reference_audio,
|
||||
reference_character_tokens: reference_characters,
|
||||
resolution,
|
||||
aspect_ratio,
|
||||
output_resolution,
|
||||
duration_seconds,
|
||||
batch_count: Some(batch_count),
|
||||
duration_seconds: duration_seconds.map(|d| d as u16),
|
||||
video_batch_count: Some(batch_count),
|
||||
negative_prompt: None,
|
||||
generate_audio: None,
|
||||
quality: None,
|
||||
};
|
||||
|
||||
let state = ArtcraftSeedance2p0RequestState { request };
|
||||
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p0(state)))
|
||||
}
|
||||
|
||||
// ── Resolve helpers ──
|
||||
// -- Plan helpers --
|
||||
|
||||
fn resolve_image_ref(
|
||||
image_ref: Option<ImageRef>,
|
||||
) -> Result<Option<tokens::tokens::media_files::MediaFileToken>, ArtcraftRouterError> {
|
||||
match image_ref {
|
||||
None => Ok(None),
|
||||
Some(ImageRef::MediaFileToken(t)) => Ok(Some(t)),
|
||||
Some(ImageRef::Url(_)) => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_image_list_ref(
|
||||
image_list_ref: Option<ImageListRef>,
|
||||
) -> Result<Option<Vec<tokens::tokens::media_files::MediaFileToken>>, ArtcraftRouterError> {
|
||||
match image_list_ref {
|
||||
None => Ok(None),
|
||||
Some(ImageListRef::MediaFileTokens(tokens)) => Ok(Some(tokens)),
|
||||
Some(ImageListRef::Urls(_)) => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_video_list_ref(
|
||||
video_list_ref: Option<VideoListRef>,
|
||||
) -> Result<Option<Vec<tokens::tokens::media_files::MediaFileToken>>, ArtcraftRouterError> {
|
||||
match video_list_ref {
|
||||
None => Ok(None),
|
||||
Some(VideoListRef::MediaFileTokens(tokens)) => Ok(Some(tokens)),
|
||||
Some(VideoListRef::Urls(_)) => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_audio_list_ref(
|
||||
audio_list_ref: Option<AudioListRef>,
|
||||
) -> Result<Option<Vec<tokens::tokens::media_files::MediaFileToken>>, ArtcraftRouterError> {
|
||||
match audio_list_ref {
|
||||
None => Ok(None),
|
||||
Some(AudioListRef::MediaFileTokens(tokens)) => Ok(Some(tokens)),
|
||||
Some(AudioListRef::Urls(_)) => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_character_list_ref(
|
||||
character_list_ref: Option<CharacterListRef>,
|
||||
) -> Option<Vec<tokens::tokens::characters::CharacterToken>> {
|
||||
match character_list_ref {
|
||||
None => None,
|
||||
Some(CharacterListRef::CharacterTokens(tokens)) => Some(tokens),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Plan helpers ──
|
||||
|
||||
// Supported aspect ratios and their AR values (width / height):
|
||||
// Portrait9x16 = 0.5625, Portrait3x4 = 0.75, Square1x1 = 1.0, Standard4x3 = 1.33, Landscape16x9 = 1.78
|
||||
//
|
||||
// All supported ratios cost the same, so PayMoreUpgrade and PayLessDowngrade both
|
||||
// select the nearest match rather than rounding in a specific direction.
|
||||
fn plan_aspect_ratio(
|
||||
aspect_ratio: Option<CommonAspectRatio>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<Option<Seedance2p0AspectRatio>, ArtcraftRouterError> {
|
||||
) -> Result<Option<CommonAspectRatioEnum>, ArtcraftRouterError> {
|
||||
match aspect_ratio {
|
||||
// No preference or auto — let the model decide
|
||||
None
|
||||
| Some(CommonAspectRatio::Auto)
|
||||
| Some(CommonAspectRatio::Auto2k)
|
||||
| Some(CommonAspectRatio::Auto4k) => Ok(None),
|
||||
|
||||
// Direct mappings
|
||||
Some(CommonAspectRatio::WideSixteenByNine) | Some(CommonAspectRatio::Wide) => {
|
||||
Ok(Some(Seedance2p0AspectRatio::Landscape16x9))
|
||||
Ok(Some(CommonAspectRatioEnum::WideSixteenByNine))
|
||||
}
|
||||
Some(CommonAspectRatio::TallNineBySixteen) | Some(CommonAspectRatio::Tall) => {
|
||||
Ok(Some(Seedance2p0AspectRatio::Portrait9x16))
|
||||
Ok(Some(CommonAspectRatioEnum::TallNineBySixteen))
|
||||
}
|
||||
Some(CommonAspectRatio::Square) | Some(CommonAspectRatio::SquareHd) => {
|
||||
Ok(Some(Seedance2p0AspectRatio::Square1x1))
|
||||
Ok(Some(CommonAspectRatioEnum::Square))
|
||||
}
|
||||
Some(CommonAspectRatio::WideFourByThree) => Ok(Some(Seedance2p0AspectRatio::Standard4x3)),
|
||||
Some(CommonAspectRatio::TallThreeByFour) => Ok(Some(Seedance2p0AspectRatio::Portrait3x4)),
|
||||
Some(CommonAspectRatio::WideFourByThree) => Ok(Some(CommonAspectRatioEnum::WideFourByThree)),
|
||||
Some(CommonAspectRatio::TallThreeByFour) => Ok(Some(CommonAspectRatioEnum::TallThreeByFour)),
|
||||
|
||||
// Mismatches — apply strategy
|
||||
Some(unsupported) => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
@@ -160,16 +96,15 @@ fn plan_aspect_ratio(
|
||||
}
|
||||
}
|
||||
|
||||
/// Pick the nearest supported aspect ratio by AR value (width / height).
|
||||
fn nearest_aspect_ratio(aspect_ratio: CommonAspectRatio) -> Seedance2p0AspectRatio {
|
||||
fn nearest_aspect_ratio(aspect_ratio: CommonAspectRatio) -> CommonAspectRatioEnum {
|
||||
match aspect_ratio {
|
||||
CommonAspectRatio::WideFiveByFour => Seedance2p0AspectRatio::Standard4x3, // 1.25, nearest 1.33
|
||||
CommonAspectRatio::WideThreeByTwo => Seedance2p0AspectRatio::Standard4x3, // 1.50, nearest 1.33
|
||||
CommonAspectRatio::WideTwentyOneByNine => Seedance2p0AspectRatio::Landscape16x9, // 2.33, nearest 1.78
|
||||
CommonAspectRatio::TallFourByFive => Seedance2p0AspectRatio::Portrait3x4, // 0.80, nearest 0.75
|
||||
CommonAspectRatio::TallTwoByThree => Seedance2p0AspectRatio::Portrait3x4, // 0.67, nearest 0.75
|
||||
CommonAspectRatio::TallNineByTwentyOne => Seedance2p0AspectRatio::Portrait9x16, // 0.43, nearest 0.56
|
||||
_ => Seedance2p0AspectRatio::Square1x1,
|
||||
CommonAspectRatio::WideFiveByFour => CommonAspectRatioEnum::WideFourByThree,
|
||||
CommonAspectRatio::WideThreeByTwo => CommonAspectRatioEnum::WideFourByThree,
|
||||
CommonAspectRatio::WideTwentyOneByNine => CommonAspectRatioEnum::WideSixteenByNine,
|
||||
CommonAspectRatio::TallFourByFive => CommonAspectRatioEnum::TallThreeByFour,
|
||||
CommonAspectRatio::TallTwoByThree => CommonAspectRatioEnum::TallThreeByFour,
|
||||
CommonAspectRatio::TallNineByTwentyOne => CommonAspectRatioEnum::TallNineBySixteen,
|
||||
_ => CommonAspectRatioEnum::Square,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,16 +112,14 @@ fn nearest_aspect_ratio(aspect_ratio: CommonAspectRatio) -> Seedance2p0AspectRat
|
||||
fn plan_output_resolution(
|
||||
resolution: Option<CommonResolution>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<Option<Seedance2p0OutputResolution>, ArtcraftRouterError> {
|
||||
) -> Result<Option<CommonResolutionEnum>, ArtcraftRouterError> {
|
||||
match resolution {
|
||||
None => Ok(None),
|
||||
|
||||
// Direct mappings
|
||||
Some(CommonResolution::FourEightyP) => Ok(Some(Seedance2p0OutputResolution::FourEightyP)),
|
||||
Some(CommonResolution::SevenTwentyP) => Ok(Some(Seedance2p0OutputResolution::SevenTwentyP)),
|
||||
Some(CommonResolution::TenEightyP) => Ok(Some(Seedance2p0OutputResolution::TenEightyP)),
|
||||
Some(CommonResolution::FourEightyP) => Ok(Some(CommonResolutionEnum::FourEightyP)),
|
||||
Some(CommonResolution::SevenTwentyP) => Ok(Some(CommonResolutionEnum::SevenTwentyP)),
|
||||
Some(CommonResolution::TenEightyP) => Ok(Some(CommonResolutionEnum::TenEightyP)),
|
||||
|
||||
// Mismatches
|
||||
Some(unsupported) => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
@@ -196,31 +129,29 @@ fn plan_output_resolution(
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade => {
|
||||
Ok(Some(match unsupported {
|
||||
CommonResolution::HalfK => Seedance2p0OutputResolution::FourEightyP,
|
||||
_ => Seedance2p0OutputResolution::TenEightyP,
|
||||
CommonResolution::HalfK => CommonResolutionEnum::FourEightyP,
|
||||
_ => CommonResolutionEnum::TenEightyP,
|
||||
}))
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayLessDowngrade => {
|
||||
Ok(Some(match unsupported {
|
||||
CommonResolution::HalfK => Seedance2p0OutputResolution::FourEightyP,
|
||||
_ => Seedance2p0OutputResolution::TenEightyP,
|
||||
CommonResolution::HalfK => CommonResolutionEnum::FourEightyP,
|
||||
_ => CommonResolutionEnum::TenEightyP,
|
||||
}))
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Seedance2p0 supports batch counts of 1, 2, and 4 only.
|
||||
// Batch counts: 1, 2, 4.
|
||||
fn plan_batch_count(
|
||||
video_batch_count: Option<u16>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<Seedance2p0BatchCount, ArtcraftRouterError> {
|
||||
) -> Result<u16, ArtcraftRouterError> {
|
||||
let count = video_batch_count.unwrap_or(1);
|
||||
match count {
|
||||
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
|
||||
1 => Ok(Seedance2p0BatchCount::One),
|
||||
2 => Ok(Seedance2p0BatchCount::Two),
|
||||
4 => Ok(Seedance2p0BatchCount::Four),
|
||||
1 | 2 | 4 => Ok(count),
|
||||
_ => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
@@ -228,17 +159,15 @@ fn plan_batch_count(
|
||||
value: format!("{}", count),
|
||||
}))
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade => {
|
||||
Ok(if count < 4 { Seedance2p0BatchCount::Four } else { Seedance2p0BatchCount::Four })
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(4),
|
||||
RequestMismatchMitigationStrategy::PayLessDowngrade => {
|
||||
Ok(if count < 4 { Seedance2p0BatchCount::Two } else { Seedance2p0BatchCount::Four })
|
||||
Ok(if count < 4 { 2 } else { 4 })
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Seedance2p0 supports duration of 4–15 seconds.
|
||||
// Duration: 4-15 seconds.
|
||||
fn plan_duration(
|
||||
duration_seconds: Option<u16>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
@@ -262,350 +191,150 @@ fn plan_duration(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::{
|
||||
Seedance2p0AspectRatio, Seedance2p0BatchCount, Seedance2p0OutputResolution,
|
||||
};
|
||||
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
|
||||
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
|
||||
use tokens::tokens::characters::CharacterToken;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
use crate::api::character_list_ref::CharacterListRef;
|
||||
use crate::api::common_aspect_ratio::CommonAspectRatio;
|
||||
use crate::api::common_resolution::CommonResolution;
|
||||
use crate::api::image_list_ref::ImageListRef;
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::provider::Provider;
|
||||
use crate::api::video_list_ref::VideoListRef;
|
||||
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::errors::client_error::ClientError;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video_v2::providers::artcraft::seedance_2p0::request::ArtcraftSeedance2p0RequestState;
|
||||
use crate::generate::generate_video_v2::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
use crate::generate::generate_video_v2::video_generation_request::VideoGenerationRequest;
|
||||
|
||||
use super::*;
|
||||
|
||||
// ── Materialized field conversions ──
|
||||
// ── Field conversions ──
|
||||
|
||||
mod materialized_field_conversions {
|
||||
mod field_conversions {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn prompt_is_passed_through() {
|
||||
let state = unwrap_request(build_artcraft_seedance_2p0(artcraft_builder()));
|
||||
assert_eq!(state.request.prompt.as_deref(), Some("a cat dancing"));
|
||||
fn model_is_seedance_2p0() {
|
||||
let req = unwrap_request(make_builder(|_| {}));
|
||||
assert!(matches!(req.request.model, Some(CommonVideoModelEnum::Seedance2p0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_defaults_to_none() {
|
||||
let builder = GenerateVideoRequestBuilder { prompt: None, ..artcraft_builder() };
|
||||
let state = unwrap_request(build_artcraft_seedance_2p0(builder));
|
||||
assert!(state.request.prompt.is_none());
|
||||
fn prompt_passed_through() {
|
||||
let req = unwrap_request(make_builder(|b| { b.prompt = Some("test".to_string()); }));
|
||||
assert_eq!(req.request.prompt, Some("test".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_seconds_converted() {
|
||||
let builder = GenerateVideoRequestBuilder { duration_seconds: Some(10), ..artcraft_builder() };
|
||||
let state = unwrap_request(build_artcraft_seedance_2p0(builder));
|
||||
assert_eq!(state.request.duration_seconds, Some(10));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_defaults_to_none() {
|
||||
let builder = GenerateVideoRequestBuilder { duration_seconds: None, ..artcraft_builder() };
|
||||
let state = unwrap_request(build_artcraft_seedance_2p0(builder));
|
||||
assert!(state.request.duration_seconds.is_none());
|
||||
fn duration_passed_through() {
|
||||
let req = unwrap_request(make_builder(|b| { b.duration_seconds = Some(10); }));
|
||||
assert_eq!(req.request.duration_seconds, Some(10));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_clamped_to_max() {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
duration_seconds: Some(99),
|
||||
..artcraft_builder()
|
||||
};
|
||||
let state = unwrap_request(build_artcraft_seedance_2p0(builder));
|
||||
assert_eq!(state.request.duration_seconds, Some(15));
|
||||
let req = unwrap_request(make_builder(|b| { b.duration_seconds = Some(99); }));
|
||||
assert_eq!(req.request.duration_seconds, Some(15));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_count_one() {
|
||||
let builder = GenerateVideoRequestBuilder { video_batch_count: Some(1), ..artcraft_builder() };
|
||||
let state = unwrap_request(build_artcraft_seedance_2p0(builder));
|
||||
assert!(matches!(state.request.batch_count, Some(Seedance2p0BatchCount::One)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_count_two() {
|
||||
let builder = GenerateVideoRequestBuilder { video_batch_count: Some(2), ..artcraft_builder() };
|
||||
let state = unwrap_request(build_artcraft_seedance_2p0(builder));
|
||||
assert!(matches!(state.request.batch_count, Some(Seedance2p0BatchCount::Two)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_count_four() {
|
||||
let builder = GenerateVideoRequestBuilder { video_batch_count: Some(4), ..artcraft_builder() };
|
||||
let state = unwrap_request(build_artcraft_seedance_2p0(builder));
|
||||
assert!(matches!(state.request.batch_count, Some(Seedance2p0BatchCount::Four)));
|
||||
fn batch_count_passed_through() {
|
||||
let req = unwrap_request(make_builder(|b| { b.video_batch_count = Some(4); }));
|
||||
assert_eq!(req.request.video_batch_count, Some(4));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Aspect ratio conversions ──
|
||||
// ── Resolution ──
|
||||
|
||||
mod aspect_ratio_conversions {
|
||||
mod resolution_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn aspect_ratio_wide() {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
|
||||
..artcraft_builder()
|
||||
};
|
||||
let state = unwrap_request(build_artcraft_seedance_2p0(builder));
|
||||
assert!(matches!(state.request.aspect_ratio, Some(Seedance2p0AspectRatio::Landscape16x9)));
|
||||
fn res_480p() {
|
||||
let req = unwrap_request(make_builder(|b| { b.resolution = Some(CommonResolution::FourEightyP); }));
|
||||
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::FourEightyP));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aspect_ratio_tall() {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
aspect_ratio: Some(CommonAspectRatio::TallNineBySixteen),
|
||||
..artcraft_builder()
|
||||
};
|
||||
let state = unwrap_request(build_artcraft_seedance_2p0(builder));
|
||||
assert!(matches!(state.request.aspect_ratio, Some(Seedance2p0AspectRatio::Portrait9x16)));
|
||||
fn res_720p() {
|
||||
let req = unwrap_request(make_builder(|b| { b.resolution = Some(CommonResolution::SevenTwentyP); }));
|
||||
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::SevenTwentyP));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aspect_ratio_square() {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
aspect_ratio: Some(CommonAspectRatio::Square),
|
||||
..artcraft_builder()
|
||||
};
|
||||
let state = unwrap_request(build_artcraft_seedance_2p0(builder));
|
||||
assert!(matches!(state.request.aspect_ratio, Some(Seedance2p0AspectRatio::Square1x1)));
|
||||
fn res_1080p() {
|
||||
let req = unwrap_request(make_builder(|b| { b.resolution = Some(CommonResolution::TenEightyP); }));
|
||||
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::TenEightyP));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aspect_ratio_defaults_to_none() {
|
||||
let builder = GenerateVideoRequestBuilder { aspect_ratio: None, ..artcraft_builder() };
|
||||
let state = unwrap_request(build_artcraft_seedance_2p0(builder));
|
||||
assert!(state.request.aspect_ratio.is_none());
|
||||
fn none_stays_none() {
|
||||
let req = unwrap_request(make_builder(|_| {}));
|
||||
assert!(req.request.resolution.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
// ── Resolution conversions ──
|
||||
// ── Media tokens ──
|
||||
|
||||
mod resolution_conversions {
|
||||
mod media_token_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn resolution_480p() {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
resolution: Some(CommonResolution::FourEightyP),
|
||||
..artcraft_builder()
|
||||
};
|
||||
let state = unwrap_request(build_artcraft_seedance_2p0(builder));
|
||||
assert!(matches!(state.request.output_resolution, Some(Seedance2p0OutputResolution::FourEightyP)));
|
||||
fn start_frame_token_passed_through() {
|
||||
let token = MediaFileToken::new("mf_start".to_string());
|
||||
let req = unwrap_request(make_builder(|b| {
|
||||
b.start_frame = Some(ImageRef::MediaFileToken(token.clone()));
|
||||
}));
|
||||
assert_eq!(req.request.start_frame_image_media_token, Some(token));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolution_720p() {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
resolution: Some(CommonResolution::SevenTwentyP),
|
||||
..artcraft_builder()
|
||||
};
|
||||
let state = unwrap_request(build_artcraft_seedance_2p0(builder));
|
||||
assert!(matches!(state.request.output_resolution, Some(Seedance2p0OutputResolution::SevenTwentyP)));
|
||||
fn url_start_frame_rejected() {
|
||||
let result = build_artcraft_seedance_2p0(GenerateVideoRequestBuilder {
|
||||
start_frame: Some(ImageRef::Url("https://example.com".to_string())),
|
||||
..base_builder()
|
||||
});
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolution_1080p() {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
resolution: Some(CommonResolution::TenEightyP),
|
||||
..artcraft_builder()
|
||||
};
|
||||
let state = unwrap_request(build_artcraft_seedance_2p0(builder));
|
||||
assert!(matches!(state.request.output_resolution, Some(Seedance2p0OutputResolution::TenEightyP)));
|
||||
fn reference_image_tokens_passed_through() {
|
||||
let tokens = vec![MediaFileToken::new("mf_a".to_string()), MediaFileToken::new("mf_b".to_string())];
|
||||
let req = unwrap_request(make_builder(|b| {
|
||||
b.reference_images = Some(ImageListRef::MediaFileTokens(tokens.clone()));
|
||||
}));
|
||||
assert_eq!(req.request.reference_image_media_tokens, Some(tokens));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolution_none() {
|
||||
let builder = GenerateVideoRequestBuilder { resolution: None, ..artcraft_builder() };
|
||||
let state = unwrap_request(build_artcraft_seedance_2p0(builder));
|
||||
assert!(state.request.output_resolution.is_none());
|
||||
fn character_tokens_passed_through() {
|
||||
let tokens = vec![CharacterToken::new("char_a".to_string()), CharacterToken::new("char_b".to_string())];
|
||||
let req = unwrap_request(make_builder(|b| {
|
||||
b.reference_character_tokens = Some(CharacterListRef::CharacterTokens(tokens.clone()));
|
||||
}));
|
||||
assert_eq!(req.request.reference_character_tokens, Some(tokens));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_resolution_error_out() {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
resolution: Some(CommonResolution::FourK),
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
|
||||
..artcraft_builder()
|
||||
};
|
||||
assert!(build_artcraft_seedance_2p0(builder).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_resolution_rounds_up() {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
resolution: Some(CommonResolution::FourK),
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
|
||||
..artcraft_builder()
|
||||
};
|
||||
let state = unwrap_request(build_artcraft_seedance_2p0(builder));
|
||||
assert!(matches!(state.request.output_resolution, Some(Seedance2p0OutputResolution::TenEightyP)));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Media token resolution ──
|
||||
|
||||
mod media_token_resolution {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn start_frame_media_token() {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
start_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_start123".to_string()))),
|
||||
..artcraft_builder()
|
||||
};
|
||||
let state = unwrap_request(build_artcraft_seedance_2p0(builder));
|
||||
assert_eq!(state.request.start_frame_media_token.as_ref().unwrap().as_str(), "mf_start123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn end_frame_media_token() {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
end_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_end456".to_string()))),
|
||||
..artcraft_builder()
|
||||
};
|
||||
let state = unwrap_request(build_artcraft_seedance_2p0(builder));
|
||||
assert_eq!(state.request.end_frame_media_token.as_ref().unwrap().as_str(), "mf_end456");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_image_media_tokens() {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
reference_images: Some(ImageListRef::MediaFileTokens(vec![
|
||||
MediaFileToken::new("mf_ref1".to_string()),
|
||||
MediaFileToken::new("mf_ref2".to_string()),
|
||||
])),
|
||||
..artcraft_builder()
|
||||
};
|
||||
let state = unwrap_request(build_artcraft_seedance_2p0(builder));
|
||||
let tokens = state.request.reference_image_media_tokens.unwrap();
|
||||
assert_eq!(tokens.len(), 2);
|
||||
assert_eq!(tokens[0].as_str(), "mf_ref1");
|
||||
assert_eq!(tokens[1].as_str(), "mf_ref2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_video_media_tokens() {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
reference_videos: Some(VideoListRef::MediaFileTokens(vec![
|
||||
MediaFileToken::new("mf_vid1".to_string()),
|
||||
])),
|
||||
..artcraft_builder()
|
||||
};
|
||||
let state = unwrap_request(build_artcraft_seedance_2p0(builder));
|
||||
let tokens = state.request.reference_video_media_tokens.unwrap();
|
||||
assert_eq!(tokens.len(), 1);
|
||||
assert_eq!(tokens[0].as_str(), "mf_vid1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_tokens() {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
reference_character_tokens: Some(CharacterListRef::CharacterTokens(vec![
|
||||
CharacterToken::new("char_abc".to_string()),
|
||||
CharacterToken::new("char_def".to_string()),
|
||||
])),
|
||||
..artcraft_builder()
|
||||
};
|
||||
let state = unwrap_request(build_artcraft_seedance_2p0(builder));
|
||||
let tokens = state.request.reference_character_tokens.unwrap();
|
||||
assert_eq!(tokens.len(), 2);
|
||||
assert_eq!(tokens[0].as_str(), "char_abc");
|
||||
assert_eq!(tokens[1].as_str(), "char_def");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_image_ref_returns_error() {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
start_frame: Some(ImageRef::Url("https://example.com/image.jpg".to_string())),
|
||||
..artcraft_builder()
|
||||
};
|
||||
assert!(matches!(
|
||||
build_artcraft_seedance_2p0(builder),
|
||||
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_refs_are_none() {
|
||||
let state = unwrap_request(build_artcraft_seedance_2p0(artcraft_builder()));
|
||||
assert!(state.request.start_frame_media_token.is_none());
|
||||
assert!(state.request.end_frame_media_token.is_none());
|
||||
assert!(state.request.reference_image_media_tokens.is_none());
|
||||
assert!(state.request.reference_video_media_tokens.is_none());
|
||||
assert!(state.request.reference_audio_media_tokens.is_none());
|
||||
assert!(state.request.reference_character_tokens.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
// ── Returns Request, not Draft ──
|
||||
|
||||
#[test]
|
||||
fn build_returns_request_variant() {
|
||||
let result = build_artcraft_seedance_2p0(artcraft_builder()).expect("build should succeed");
|
||||
assert!(matches!(result, VideoGenerationDraftOrRequest::Request(_)));
|
||||
}
|
||||
|
||||
// ── Full combination ──
|
||||
|
||||
#[test]
|
||||
fn full_request_all_fields() {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
prompt: Some("full test".to_string()),
|
||||
aspect_ratio: Some(CommonAspectRatio::TallNineBySixteen),
|
||||
resolution: Some(CommonResolution::TenEightyP),
|
||||
duration_seconds: Some(10),
|
||||
video_batch_count: Some(4),
|
||||
start_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_start".to_string()))),
|
||||
end_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_end".to_string()))),
|
||||
reference_images: Some(ImageListRef::MediaFileTokens(vec![MediaFileToken::new("mf_img".to_string())])),
|
||||
reference_videos: Some(VideoListRef::MediaFileTokens(vec![MediaFileToken::new("mf_vid".to_string())])),
|
||||
reference_character_tokens: Some(CharacterListRef::CharacterTokens(vec![
|
||||
CharacterToken::new("char_xyz".to_string()),
|
||||
])),
|
||||
..artcraft_builder()
|
||||
};
|
||||
let state = unwrap_request(build_artcraft_seedance_2p0(builder));
|
||||
|
||||
assert_eq!(state.request.prompt.as_deref(), Some("full test"));
|
||||
assert!(matches!(state.request.aspect_ratio, Some(Seedance2p0AspectRatio::Portrait9x16)));
|
||||
assert!(matches!(state.request.output_resolution, Some(Seedance2p0OutputResolution::TenEightyP)));
|
||||
assert_eq!(state.request.duration_seconds, Some(10));
|
||||
assert!(matches!(state.request.batch_count, Some(Seedance2p0BatchCount::Four)));
|
||||
assert!(state.request.start_frame_media_token.is_some());
|
||||
assert!(state.request.end_frame_media_token.is_some());
|
||||
assert!(state.request.reference_image_media_tokens.is_some());
|
||||
assert!(state.request.reference_video_media_tokens.is_some());
|
||||
assert!(state.request.reference_character_tokens.is_some());
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
fn artcraft_builder() -> GenerateVideoRequestBuilder {
|
||||
fn base_builder() -> GenerateVideoRequestBuilder {
|
||||
GenerateVideoRequestBuilder {
|
||||
provider: Provider::Artcraft,
|
||||
prompt: Some("a cat dancing".to_string()),
|
||||
duration_seconds: Some(5),
|
||||
video_batch_count: Some(1),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn unwrap_request(result: Result<VideoGenerationDraftOrRequest, ArtcraftRouterError>) -> ArtcraftSeedance2p0RequestState {
|
||||
match result.expect("build should succeed") {
|
||||
fn make_builder(f: impl FnOnce(&mut GenerateVideoRequestBuilder)) -> GenerateVideoRequestBuilder {
|
||||
let mut builder = base_builder();
|
||||
f(&mut builder);
|
||||
builder
|
||||
}
|
||||
|
||||
fn unwrap_request(builder: GenerateVideoRequestBuilder) -> ArtcraftSeedance2p0RequestState {
|
||||
let result = build_artcraft_seedance_2p0(builder).expect("build should succeed");
|
||||
match result {
|
||||
VideoGenerationDraftOrRequest::Request(
|
||||
VideoGenerationRequest::ArtcraftSeedance2p0(state)
|
||||
) => state,
|
||||
|
||||
+118
-359
@@ -1,6 +1,4 @@
|
||||
use artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::{
|
||||
Seedance2p0BatchCount, Seedance2p0OutputResolution,
|
||||
};
|
||||
use enums::common::generation::common_resolution::CommonResolution;
|
||||
|
||||
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
|
||||
use crate::generate::generate_video_v2::providers::artcraft::seedance_2p0::request::ArtcraftSeedance2p0RequestState;
|
||||
@@ -13,7 +11,7 @@ use crate::generate::generate_video_v2::providers::artcraft::seedance_2p0::reque
|
||||
// from the upstream credit rates and their credit-package prices,
|
||||
// then set ArtCraft credits = cents.
|
||||
|
||||
/// USD cents per second by resolution, derived from Upstream rates:
|
||||
/// USD cents per second by resolution, derived from upstream rates:
|
||||
/// 480p: 15 upstream-credits/sec ÷ 193 upstream-credits/$1 × 100 ≈ 7.772 ¢/s
|
||||
/// 720p: 40 upstream-credits/sec ÷ 250 upstream-credits/$1 × 100 = 16.0 ¢/s
|
||||
/// 1080p: 90 upstream-credits/sec ÷ 193 upstream-credits/$1 × 100 ≈ 46.632 ¢/s
|
||||
@@ -25,19 +23,18 @@ const CENTS_PER_SECOND_720P: f64 = 16.0;
|
||||
const CENTS_PER_SECOND_1080P: f64 = 46.632;
|
||||
|
||||
pub struct ArtcraftSeedance2p0CostState {
|
||||
pub resolution: Seedance2p0OutputResolution,
|
||||
pub duration_seconds: u8,
|
||||
pub batch_count: Seedance2p0BatchCount,
|
||||
pub resolution: CommonResolution,
|
||||
pub duration_seconds: u16,
|
||||
pub batch_count: u16,
|
||||
pub has_video_reference: bool,
|
||||
}
|
||||
|
||||
impl ArtcraftSeedance2p0CostState {
|
||||
pub fn from_request(request: &ArtcraftSeedance2p0RequestState) -> Self {
|
||||
let resolution = request.request.output_resolution
|
||||
.unwrap_or(Seedance2p0OutputResolution::SevenTwentyP);
|
||||
let resolution = request.request.resolution
|
||||
.unwrap_or(CommonResolution::SevenTwentyP);
|
||||
let duration_seconds = request.request.duration_seconds.unwrap_or(5);
|
||||
let batch_count = request.request.batch_count
|
||||
.unwrap_or(Seedance2p0BatchCount::One);
|
||||
let batch_count = request.request.video_batch_count.unwrap_or(1);
|
||||
let has_video_reference = request.request.reference_video_media_tokens
|
||||
.as_ref()
|
||||
.is_some_and(|tokens| !tokens.is_empty());
|
||||
@@ -47,18 +44,13 @@ impl ArtcraftSeedance2p0CostState {
|
||||
|
||||
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
|
||||
let cents_per_second = match self.resolution {
|
||||
Seedance2p0OutputResolution::FourEightyP => CENTS_PER_SECOND_480P,
|
||||
Seedance2p0OutputResolution::SevenTwentyP => CENTS_PER_SECOND_720P,
|
||||
Seedance2p0OutputResolution::TenEightyP => CENTS_PER_SECOND_1080P,
|
||||
CommonResolution::FourEightyP => CENTS_PER_SECOND_480P,
|
||||
CommonResolution::TenEightyP => CENTS_PER_SECOND_1080P,
|
||||
// Everything else (including 720p) prices at 720p.
|
||||
_ => CENTS_PER_SECOND_720P,
|
||||
};
|
||||
|
||||
let batch_multiplier: f64 = match self.batch_count {
|
||||
Seedance2p0BatchCount::One => 1.0,
|
||||
Seedance2p0BatchCount::Two => 2.0,
|
||||
Seedance2p0BatchCount::Four => 4.0,
|
||||
};
|
||||
|
||||
let usd_cents = (self.duration_seconds as f64 * cents_per_second * batch_multiplier).round() as u64;
|
||||
let usd_cents = (self.duration_seconds as f64 * cents_per_second * self.batch_count as f64).round() as u64;
|
||||
|
||||
// ArtCraft credits: 100 credits = $1.00, so credits = cents.
|
||||
VideoGenerationCostEstimate {
|
||||
@@ -74,208 +66,164 @@ impl ArtcraftSeedance2p0CostState {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::{
|
||||
Seedance2p0BatchCount, Seedance2p0MultiFunctionVideoGenRequest, Seedance2p0OutputResolution,
|
||||
};
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
use crate::api::common_resolution::CommonResolution;
|
||||
use crate::api::provider::Provider;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video_v2::providers::artcraft::seedance_2p0::request::ArtcraftSeedance2p0RequestState;
|
||||
|
||||
use super::*;
|
||||
|
||||
// ── 720p pricing ──
|
||||
// -- 720p pricing --
|
||||
|
||||
mod pricing_720p {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn cost_720p_batch_1() {
|
||||
assert_eq!(usd_cents(Seedance2p0OutputResolution::SevenTwentyP, 4, Seedance2p0BatchCount::One), 64);
|
||||
assert_eq!(usd_cents(Seedance2p0OutputResolution::SevenTwentyP, 5, Seedance2p0BatchCount::One), 80);
|
||||
assert_eq!(usd_cents(Seedance2p0OutputResolution::SevenTwentyP, 6, Seedance2p0BatchCount::One), 96);
|
||||
assert_eq!(usd_cents(Seedance2p0OutputResolution::SevenTwentyP, 7, Seedance2p0BatchCount::One), 112);
|
||||
assert_eq!(usd_cents(Seedance2p0OutputResolution::SevenTwentyP, 10, Seedance2p0BatchCount::One), 160);
|
||||
assert_eq!(usd_cents(Seedance2p0OutputResolution::SevenTwentyP, 15, Seedance2p0BatchCount::One), 240);
|
||||
fn batch_1() {
|
||||
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 4, 1), 64);
|
||||
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 5, 1), 80);
|
||||
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 6, 1), 96);
|
||||
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 10, 1), 160);
|
||||
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 15, 1), 240);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_720p_batch_2() {
|
||||
assert_eq!(usd_cents(Seedance2p0OutputResolution::SevenTwentyP, 4, Seedance2p0BatchCount::Two), 128);
|
||||
assert_eq!(usd_cents(Seedance2p0OutputResolution::SevenTwentyP, 5, Seedance2p0BatchCount::Two), 160);
|
||||
assert_eq!(usd_cents(Seedance2p0OutputResolution::SevenTwentyP, 15, Seedance2p0BatchCount::Two), 480);
|
||||
fn batch_2() {
|
||||
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 4, 2), 128);
|
||||
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 5, 2), 160);
|
||||
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 15, 2), 480);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_720p_batch_4() {
|
||||
assert_eq!(usd_cents(Seedance2p0OutputResolution::SevenTwentyP, 4, Seedance2p0BatchCount::Four), 256);
|
||||
assert_eq!(usd_cents(Seedance2p0OutputResolution::SevenTwentyP, 5, Seedance2p0BatchCount::Four), 320);
|
||||
assert_eq!(usd_cents(Seedance2p0OutputResolution::SevenTwentyP, 15, Seedance2p0BatchCount::Four), 960);
|
||||
fn batch_4() {
|
||||
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 4, 4), 256);
|
||||
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 5, 4), 320);
|
||||
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 15, 4), 960);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn none_defaults_to_720p() {
|
||||
assert_eq!(cost_cents(None, 5, 1), cost_cents(Some(CommonResolution::SevenTwentyP), 5, 1));
|
||||
}
|
||||
}
|
||||
|
||||
// ── 480p pricing ──
|
||||
// -- 480p pricing --
|
||||
|
||||
mod pricing_480p {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn cost_480p_batch_1() {
|
||||
assert_eq!(usd_cents(Seedance2p0OutputResolution::FourEightyP, 4, Seedance2p0BatchCount::One), 31);
|
||||
assert_eq!(usd_cents(Seedance2p0OutputResolution::FourEightyP, 5, Seedance2p0BatchCount::One), 39);
|
||||
assert_eq!(usd_cents(Seedance2p0OutputResolution::FourEightyP, 10, Seedance2p0BatchCount::One), 78);
|
||||
assert_eq!(usd_cents(Seedance2p0OutputResolution::FourEightyP, 15, Seedance2p0BatchCount::One), 117);
|
||||
fn batch_1() {
|
||||
assert_eq!(cost_cents(Some(CommonResolution::FourEightyP), 4, 1), 31);
|
||||
assert_eq!(cost_cents(Some(CommonResolution::FourEightyP), 5, 1), 39);
|
||||
assert_eq!(cost_cents(Some(CommonResolution::FourEightyP), 10, 1), 78);
|
||||
assert_eq!(cost_cents(Some(CommonResolution::FourEightyP), 15, 1), 117);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_480p_batch_2() {
|
||||
assert_eq!(usd_cents(Seedance2p0OutputResolution::FourEightyP, 5, Seedance2p0BatchCount::Two), 78);
|
||||
fn batch_2() {
|
||||
assert_eq!(cost_cents(Some(CommonResolution::FourEightyP), 5, 2), 78);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_480p_batch_4() {
|
||||
assert_eq!(usd_cents(Seedance2p0OutputResolution::FourEightyP, 5, Seedance2p0BatchCount::Four), 155);
|
||||
fn batch_4() {
|
||||
assert_eq!(cost_cents(Some(CommonResolution::FourEightyP), 5, 4), 155);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 1080p pricing ──
|
||||
// -- 1080p pricing --
|
||||
|
||||
mod pricing_1080p {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn cost_1080p_batch_1() {
|
||||
assert_eq!(usd_cents(Seedance2p0OutputResolution::TenEightyP, 4, Seedance2p0BatchCount::One), 187);
|
||||
assert_eq!(usd_cents(Seedance2p0OutputResolution::TenEightyP, 5, Seedance2p0BatchCount::One), 233);
|
||||
assert_eq!(usd_cents(Seedance2p0OutputResolution::TenEightyP, 10, Seedance2p0BatchCount::One), 466);
|
||||
assert_eq!(usd_cents(Seedance2p0OutputResolution::TenEightyP, 15, Seedance2p0BatchCount::One), 699);
|
||||
fn batch_1() {
|
||||
assert_eq!(cost_cents(Some(CommonResolution::TenEightyP), 4, 1), 187);
|
||||
assert_eq!(cost_cents(Some(CommonResolution::TenEightyP), 5, 1), 233);
|
||||
assert_eq!(cost_cents(Some(CommonResolution::TenEightyP), 10, 1), 466);
|
||||
assert_eq!(cost_cents(Some(CommonResolution::TenEightyP), 15, 1), 699);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_1080p_batch_2() {
|
||||
assert_eq!(usd_cents(Seedance2p0OutputResolution::TenEightyP, 5, Seedance2p0BatchCount::Two), 466);
|
||||
fn batch_2() {
|
||||
assert_eq!(cost_cents(Some(CommonResolution::TenEightyP), 5, 2), 466);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_1080p_batch_4() {
|
||||
assert_eq!(usd_cents(Seedance2p0OutputResolution::TenEightyP, 5, Seedance2p0BatchCount::Four), 933);
|
||||
fn batch_4() {
|
||||
assert_eq!(cost_cents(Some(CommonResolution::TenEightyP), 5, 4), 933);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Relative pricing ──
|
||||
// -- Relative pricing --
|
||||
|
||||
mod relative_pricing_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn cost_480p_cheaper_than_720p_cheaper_than_1080p() {
|
||||
let c480 = usd_cents(Seedance2p0OutputResolution::FourEightyP, 5, Seedance2p0BatchCount::One);
|
||||
let c720 = usd_cents(Seedance2p0OutputResolution::SevenTwentyP, 5, Seedance2p0BatchCount::One);
|
||||
let c1080 = usd_cents(Seedance2p0OutputResolution::TenEightyP, 5, Seedance2p0BatchCount::One);
|
||||
assert!(c480 < c720, "480p ({}) should be cheaper than 720p ({})", c480, c720);
|
||||
assert!(c720 < c1080, "720p ({}) should be cheaper than 1080p ({})", c720, c1080);
|
||||
let c480 = cost_cents(Some(CommonResolution::FourEightyP), 5, 1);
|
||||
let c720 = cost_cents(Some(CommonResolution::SevenTwentyP), 5, 1);
|
||||
let c1080 = cost_cents(Some(CommonResolution::TenEightyP), 5, 1);
|
||||
assert!(c480 < c720);
|
||||
assert!(c720 < c1080);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_scales_with_duration() {
|
||||
let c4 = usd_cents(Seedance2p0OutputResolution::SevenTwentyP, 4, Seedance2p0BatchCount::One);
|
||||
let c10 = usd_cents(Seedance2p0OutputResolution::SevenTwentyP, 10, Seedance2p0BatchCount::One);
|
||||
let c15 = usd_cents(Seedance2p0OutputResolution::SevenTwentyP, 15, Seedance2p0BatchCount::One);
|
||||
let c4 = cost_cents(Some(CommonResolution::SevenTwentyP), 4, 1);
|
||||
let c10 = cost_cents(Some(CommonResolution::SevenTwentyP), 10, 1);
|
||||
let c15 = cost_cents(Some(CommonResolution::SevenTwentyP), 15, 1);
|
||||
assert!(c4 < c10);
|
||||
assert!(c10 < c15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_scales_with_batch() {
|
||||
let b1 = usd_cents(Seedance2p0OutputResolution::TenEightyP, 5, Seedance2p0BatchCount::One);
|
||||
let b2 = usd_cents(Seedance2p0OutputResolution::TenEightyP, 5, Seedance2p0BatchCount::Two);
|
||||
let b4 = usd_cents(Seedance2p0OutputResolution::TenEightyP, 5, Seedance2p0BatchCount::Four);
|
||||
let b1 = cost_cents(Some(CommonResolution::TenEightyP), 5, 1);
|
||||
let b2 = cost_cents(Some(CommonResolution::TenEightyP), 5, 2);
|
||||
let b4 = cost_cents(Some(CommonResolution::TenEightyP), 5, 4);
|
||||
assert!(b1 < b2);
|
||||
assert!(b2 < b4);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Video reference does NOT affect cost (yet) ──
|
||||
// -- Credits equal cents --
|
||||
|
||||
#[test]
|
||||
fn video_reference_does_not_affect_cost() {
|
||||
let base = ArtcraftSeedance2p0CostState {
|
||||
resolution: Seedance2p0OutputResolution::SevenTwentyP,
|
||||
duration_seconds: 5,
|
||||
batch_count: Seedance2p0BatchCount::One,
|
||||
has_video_reference: false,
|
||||
};
|
||||
let without = base.estimate_cost();
|
||||
let with = ArtcraftSeedance2p0CostState { has_video_reference: true, ..base }.estimate_cost();
|
||||
assert_eq!(without.cost_in_usd_cents, with.cost_in_usd_cents);
|
||||
assert_eq!(without.cost_in_credits, with.cost_in_credits);
|
||||
}
|
||||
|
||||
// ── from_request() tests ──
|
||||
|
||||
mod from_request_tests {
|
||||
mod credits_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn from_request_720p() {
|
||||
let req = make_request_state(Some(Seedance2p0OutputResolution::SevenTwentyP), 5, Seedance2p0BatchCount::One, false);
|
||||
let cost = ArtcraftSeedance2p0CostState::from_request(&req);
|
||||
assert!(matches!(cost.resolution, Seedance2p0OutputResolution::SevenTwentyP));
|
||||
assert_eq!(cost.duration_seconds, 5);
|
||||
assert!(matches!(cost.batch_count, Seedance2p0BatchCount::One));
|
||||
assert!(!cost.has_video_reference);
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(80));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_request_none_defaults_to_720p() {
|
||||
let req = make_request_state(None, 5, Seedance2p0BatchCount::One, false);
|
||||
let cost = ArtcraftSeedance2p0CostState::from_request(&req);
|
||||
assert!(matches!(cost.resolution, Seedance2p0OutputResolution::SevenTwentyP));
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(80));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_request_480p() {
|
||||
let req = make_request_state(Some(Seedance2p0OutputResolution::FourEightyP), 5, Seedance2p0BatchCount::One, false);
|
||||
let cost = ArtcraftSeedance2p0CostState::from_request(&req);
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(39));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_request_1080p_batch_2() {
|
||||
let req = make_request_state(Some(Seedance2p0OutputResolution::TenEightyP), 5, Seedance2p0BatchCount::Two, false);
|
||||
let cost = ArtcraftSeedance2p0CostState::from_request(&req);
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(466));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_request_with_video_reference() {
|
||||
let req = make_request_state(Some(Seedance2p0OutputResolution::SevenTwentyP), 5, Seedance2p0BatchCount::One, true);
|
||||
let cost = ArtcraftSeedance2p0CostState::from_request(&req);
|
||||
assert!(cost.has_video_reference);
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(80));
|
||||
fn credits_equal_usd_cents_all_combos() {
|
||||
let resolutions = [
|
||||
Some(CommonResolution::FourEightyP),
|
||||
Some(CommonResolution::SevenTwentyP),
|
||||
Some(CommonResolution::TenEightyP),
|
||||
None,
|
||||
];
|
||||
for res in resolutions {
|
||||
for dur in [4, 5, 10, 15] {
|
||||
for batch in [1, 2, 4] {
|
||||
let cost = build_cost(res, dur, batch);
|
||||
assert_eq!(
|
||||
cost.cost_in_credits, cost.cost_in_usd_cents,
|
||||
"credits should equal cents for res={:?} dur={}s batch={}",
|
||||
res, dur, batch,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Cross-check: Artcraft v2 matches Kinovi v2 via GenerateVideoRequestBuilder ──
|
||||
|
||||
mod cross_check_with_kinovi_via_builder {
|
||||
use crate::api::common_video_model::CommonVideoModel;
|
||||
// -- Cross-check with Kinovi via builder --
|
||||
|
||||
mod cross_check_with_kinovi {
|
||||
use super::*;
|
||||
|
||||
/// Combinatorial test: build the same request with Provider::Artcraft and
|
||||
/// Provider::Seedance2Pro, then compare USD cents. They must match for all
|
||||
/// resolution × duration × batch combinations.
|
||||
#[test]
|
||||
fn artcraft_matches_kinovi_all_combos() {
|
||||
let resolutions = [
|
||||
Some(CommonResolution::FourEightyP),
|
||||
Some(CommonResolution::SevenTwentyP),
|
||||
None, // defaults to 720p
|
||||
None,
|
||||
Some(CommonResolution::TenEightyP),
|
||||
];
|
||||
let durations: [u16; 4] = [4, 5, 10, 15];
|
||||
@@ -284,39 +232,24 @@ mod tests {
|
||||
for res in &resolutions {
|
||||
for dur in &durations {
|
||||
for batch in &batches {
|
||||
let artcraft = GenerateVideoRequestBuilder {
|
||||
model: CommonVideoModel::Seedance2p0,
|
||||
provider: Provider::Artcraft,
|
||||
resolution: *res,
|
||||
duration_seconds: Some(*dur),
|
||||
video_batch_count: Some(*batch),
|
||||
..Default::default()
|
||||
};
|
||||
let artcraft_cost = build_cost(*res, *dur, *batch);
|
||||
|
||||
let kinovi = GenerateVideoRequestBuilder {
|
||||
model: CommonVideoModel::Seedance2p0,
|
||||
provider: Provider::Seedance2Pro,
|
||||
resolution: *res,
|
||||
duration_seconds: Some(*dur),
|
||||
video_batch_count: Some(*batch),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let artcraft_cost = artcraft.build2()
|
||||
.expect("artcraft build2 should succeed")
|
||||
.estimate_cost()
|
||||
.expect("artcraft estimate_cost should succeed");
|
||||
|
||||
let kinovi_cost = kinovi.build2()
|
||||
.expect("kinovi build2 should succeed")
|
||||
.expect("kinovi build2")
|
||||
.estimate_cost()
|
||||
.expect("kinovi estimate_cost should succeed");
|
||||
.expect("kinovi estimate_cost");
|
||||
|
||||
assert_eq!(
|
||||
artcraft_cost.cost_in_usd_cents, kinovi_cost.cost_in_usd_cents,
|
||||
"USD cents mismatch: res={:?} dur={}s batch={} — artcraft={:?}, kinovi={:?}",
|
||||
"USD cents mismatch: res={:?} dur={}s batch={}",
|
||||
res, dur, batch,
|
||||
artcraft_cost.cost_in_usd_cents, kinovi_cost.cost_in_usd_cents,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -324,208 +257,34 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Cross-check: Artcraft v2 matches v1 estimate_video_cost_artcraft_seedance2p0 ──
|
||||
// -- Helpers --
|
||||
|
||||
mod cross_check_with_v1 {
|
||||
use artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::Seedance2p0AspectRatio;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
use crate::generate::generate_video::cost::artcraft::estimate_video_cost_artcraft_seedance2p0::estimate_video_cost_artcraft_seedance2p0;
|
||||
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_seedance2p0::PlanArtcraftSeedance2p0;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Combinatorial test: compare v2 ArtcraftSeedance2p0CostState against
|
||||
/// v1 estimate_video_cost_artcraft_seedance2p0 for all resolution ×
|
||||
/// duration × batch combinations.
|
||||
#[test]
|
||||
fn v2_matches_v1_all_combos() {
|
||||
let resolutions = [
|
||||
(Some(CommonResolution::FourEightyP), Seedance2p0OutputResolution::FourEightyP),
|
||||
(Some(CommonResolution::SevenTwentyP), Seedance2p0OutputResolution::SevenTwentyP),
|
||||
(None, Seedance2p0OutputResolution::SevenTwentyP), // None defaults to 720p
|
||||
(Some(CommonResolution::TenEightyP), Seedance2p0OutputResolution::TenEightyP),
|
||||
];
|
||||
let durations: [u8; 4] = [4, 5, 10, 15];
|
||||
let batches = [
|
||||
(Seedance2p0BatchCount::One, Seedance2p0BatchCount::One),
|
||||
(Seedance2p0BatchCount::Two, Seedance2p0BatchCount::Two),
|
||||
(Seedance2p0BatchCount::Four, Seedance2p0BatchCount::Four),
|
||||
];
|
||||
|
||||
for (v1_res, v2_res) in &resolutions {
|
||||
for dur in &durations {
|
||||
for (v1_batch, v2_batch) in &batches {
|
||||
// v1: construct a PlanArtcraftSeedance2p0 and call the v1 cost function
|
||||
let v1_plan = PlanArtcraftSeedance2p0 {
|
||||
prompt: None,
|
||||
start_frame: None,
|
||||
end_frame: None,
|
||||
reference_images: None,
|
||||
reference_videos: None,
|
||||
reference_audio: None,
|
||||
reference_characters: None,
|
||||
aspect_ratio: Some(Seedance2p0AspectRatio::Square1x1),
|
||||
resolution: *v1_res,
|
||||
duration_seconds: Some(*dur),
|
||||
batch_count: *v1_batch,
|
||||
idempotency_token: "test".to_string(),
|
||||
};
|
||||
let v1_cost = estimate_video_cost_artcraft_seedance2p0(&v1_plan);
|
||||
|
||||
// v2: construct cost state directly
|
||||
let v2_cost = ArtcraftSeedance2p0CostState {
|
||||
resolution: *v2_res,
|
||||
duration_seconds: *dur,
|
||||
batch_count: *v2_batch,
|
||||
has_video_reference: false,
|
||||
}.estimate_cost();
|
||||
|
||||
assert_eq!(
|
||||
v2_cost.cost_in_usd_cents, v1_cost.cost_in_usd_cents,
|
||||
"USD cents mismatch: res={:?} dur={}s batch={:?} — v2={:?}, v1={:?}",
|
||||
v1_res, dur, v1_batch,
|
||||
v2_cost.cost_in_usd_cents, v1_cost.cost_in_usd_cents,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
v2_cost.cost_in_credits, v1_cost.cost_in_credits,
|
||||
"Credits mismatch: res={:?} dur={}s batch={:?} — v2={:?}, v1={:?}",
|
||||
v1_res, dur, v1_batch,
|
||||
v2_cost.cost_in_credits, v1_cost.cost_in_credits,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Credits spot checks ──
|
||||
|
||||
mod credits_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn credits_equal_usd_cents() {
|
||||
// ArtCraft credits: 100 credits = $1.00, so credits always equal cents.
|
||||
for res in [
|
||||
Seedance2p0OutputResolution::FourEightyP,
|
||||
Seedance2p0OutputResolution::SevenTwentyP,
|
||||
Seedance2p0OutputResolution::TenEightyP,
|
||||
] {
|
||||
for dur in [4, 5, 10, 15] {
|
||||
for batch in [Seedance2p0BatchCount::One, Seedance2p0BatchCount::Two, Seedance2p0BatchCount::Four] {
|
||||
assert_eq!(
|
||||
credits(res, dur, batch),
|
||||
usd_cents(res, dur, batch),
|
||||
"credits should equal usd_cents for {:?} {}s {:?}", res, dur, batch,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credits_720p() {
|
||||
assert_eq!(credits(Seedance2p0OutputResolution::SevenTwentyP, 5, Seedance2p0BatchCount::One), 80);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credits_480p() {
|
||||
assert_eq!(credits(Seedance2p0OutputResolution::FourEightyP, 5, Seedance2p0BatchCount::One), 39);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credits_1080p() {
|
||||
assert_eq!(credits(Seedance2p0OutputResolution::TenEightyP, 5, Seedance2p0BatchCount::One), 233);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Builder round-trip ──
|
||||
|
||||
mod builder_round_trip {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn build2_then_estimate_cost_720p() {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
provider: Provider::Artcraft,
|
||||
resolution: Some(CommonResolution::SevenTwentyP),
|
||||
duration_seconds: Some(5),
|
||||
video_batch_count: Some(1),
|
||||
..Default::default()
|
||||
};
|
||||
let result = builder.build2().expect("build2 should succeed");
|
||||
let cost = result.estimate_cost().expect("estimate_cost should succeed");
|
||||
assert_eq!(cost.cost_in_usd_cents, Some(80));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build2_then_estimate_cost_1080p_batch_4() {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
provider: Provider::Artcraft,
|
||||
resolution: Some(CommonResolution::TenEightyP),
|
||||
duration_seconds: Some(5),
|
||||
video_batch_count: Some(4),
|
||||
..Default::default()
|
||||
};
|
||||
let result = builder.build2().expect("build2 should succeed");
|
||||
let cost = result.estimate_cost().expect("estimate_cost should succeed");
|
||||
assert_eq!(cost.cost_in_usd_cents, Some(933));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
fn usd_cents(
|
||||
resolution: Seedance2p0OutputResolution,
|
||||
duration_seconds: u8,
|
||||
batch_count: Seedance2p0BatchCount,
|
||||
) -> u64 {
|
||||
ArtcraftSeedance2p0CostState { resolution, duration_seconds, batch_count, has_video_reference: false }
|
||||
/// Build a cost estimate end-to-end: GenerateVideoRequestBuilder → build2 → estimate_cost.
|
||||
fn build_cost(
|
||||
resolution: Option<CommonResolution>,
|
||||
duration_seconds: u16,
|
||||
video_batch_count: u16,
|
||||
) -> crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
provider: Provider::Artcraft,
|
||||
resolution,
|
||||
duration_seconds: Some(duration_seconds),
|
||||
video_batch_count: Some(video_batch_count),
|
||||
..Default::default()
|
||||
};
|
||||
builder.build2()
|
||||
.expect("build2 should succeed")
|
||||
.estimate_cost()
|
||||
.expect("estimate_cost should succeed")
|
||||
}
|
||||
|
||||
fn cost_cents(
|
||||
resolution: Option<CommonResolution>,
|
||||
duration_seconds: u16,
|
||||
video_batch_count: u16,
|
||||
) -> u64 {
|
||||
build_cost(resolution, duration_seconds, video_batch_count)
|
||||
.cost_in_usd_cents
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn credits(
|
||||
resolution: Seedance2p0OutputResolution,
|
||||
duration_seconds: u8,
|
||||
batch_count: Seedance2p0BatchCount,
|
||||
) -> u64 {
|
||||
ArtcraftSeedance2p0CostState { resolution, duration_seconds, batch_count, has_video_reference: false }
|
||||
.estimate_cost()
|
||||
.cost_in_credits
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn make_request_state(
|
||||
resolution: Option<Seedance2p0OutputResolution>,
|
||||
duration_seconds: u8,
|
||||
batch_count: Seedance2p0BatchCount,
|
||||
with_video_ref: bool,
|
||||
) -> ArtcraftSeedance2p0RequestState {
|
||||
let reference_video_media_tokens = if with_video_ref {
|
||||
Some(vec![MediaFileToken::new("mf_testvid".to_string())])
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
ArtcraftSeedance2p0RequestState {
|
||||
request: Seedance2p0MultiFunctionVideoGenRequest {
|
||||
uuid_idempotency_token: "test-idem".to_string(),
|
||||
prompt: Some("test".to_string()),
|
||||
start_frame_media_token: None,
|
||||
end_frame_media_token: None,
|
||||
reference_image_media_tokens: None,
|
||||
reference_video_media_tokens,
|
||||
reference_audio_media_tokens: None,
|
||||
reference_character_tokens: None,
|
||||
aspect_ratio: None,
|
||||
output_resolution: resolution,
|
||||
duration_seconds: Some(duration_seconds),
|
||||
batch_count: Some(batch_count),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+23
-47
@@ -1,5 +1,5 @@
|
||||
use artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::Seedance2p0MultiFunctionVideoGenRequest;
|
||||
use artcraft_client::endpoints::generate::video::multi_function::seedance_2p0_multi_function_video_gen::seedance_2p0_multi_function_video_gen;
|
||||
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 crate::client::router_artcraft_client::RouterArtcraftClient;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
@@ -10,13 +10,13 @@ use crate::generate::generate_video::generate_video_response::{
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ArtcraftSeedance2p0RequestState {
|
||||
/// Final materialized request; ready to fire.
|
||||
pub request: Seedance2p0MultiFunctionVideoGenRequest,
|
||||
/// Final materialized request; ready to fire via the omni-gen video endpoint.
|
||||
pub request: OmniGenVideoCostAndGenerateRequest,
|
||||
}
|
||||
|
||||
impl ArtcraftSeedance2p0RequestState {
|
||||
pub async fn send(&self, client: &RouterArtcraftClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
|
||||
let response = seedance_2p0_multi_function_video_gen(
|
||||
let response = omni_gen_video_generate(
|
||||
&client.api_host,
|
||||
Some(&client.credentials),
|
||||
self.request.clone(),
|
||||
@@ -25,8 +25,8 @@ impl ArtcraftSeedance2p0RequestState {
|
||||
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Storyteller(err)))?;
|
||||
|
||||
Ok(GenerateVideoResponse::Artcraft(ArtcraftVideoResponsePayload {
|
||||
inference_job_token: response.inference_job_token,
|
||||
all_inference_job_tokens: response.all_inference_job_tokens,
|
||||
inference_job_token: response.inference_job_token.clone(),
|
||||
all_inference_job_tokens: vec![response.inference_job_token],
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -42,7 +42,6 @@ mod tests {
|
||||
use crate::api::image_list_ref::ImageListRef;
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::provider::Provider;
|
||||
use crate::api::video_list_ref::VideoListRef;
|
||||
use crate::client::router_artcraft_client::RouterArtcraftClient;
|
||||
use crate::client::router_client::RouterClient;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
@@ -58,14 +57,13 @@ mod tests {
|
||||
WHITE_HOUSE_SUNSET_PRODUCTION_MEDIA_TOKEN,
|
||||
};
|
||||
|
||||
|
||||
// ── Aspect ratio tests ──
|
||||
// -- Aspect ratio tests --
|
||||
|
||||
mod aspect_ratio_tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually run — fires a real API request and incurs cost
|
||||
#[ignore]
|
||||
async fn landscape() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("A corgi running through a field of wildflowers at sunset.".to_string()),
|
||||
@@ -77,7 +75,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually run — fires a real API request and incurs cost
|
||||
#[ignore]
|
||||
async fn portrait() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("A cat sitting on a windowsill watching rain.".to_string()),
|
||||
@@ -89,7 +87,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually run — fires a real API request and incurs cost
|
||||
#[ignore]
|
||||
async fn square() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("A hummingbird hovering near a flower.".to_string()),
|
||||
@@ -101,13 +99,13 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Resolution tests ──
|
||||
// -- Resolution tests --
|
||||
|
||||
mod resolution_tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually run — fires a real API request and incurs cost
|
||||
#[ignore]
|
||||
async fn res_480p() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("A shiba inu playing in autumn leaves.".to_string()),
|
||||
@@ -119,7 +117,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually run — fires a real API request and incurs cost
|
||||
#[ignore]
|
||||
async fn res_720p() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("A golden retriever catching a frisbee on the beach.".to_string()),
|
||||
@@ -131,7 +129,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually run — fires a real API request and incurs cost
|
||||
#[ignore]
|
||||
async fn res_1080p() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("A fox walking through a snowy forest.".to_string()),
|
||||
@@ -143,13 +141,14 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Modality tests ──
|
||||
// -- Modality tests --
|
||||
|
||||
mod modality_tests {
|
||||
use super::*;
|
||||
use test_data::web::image_media_tokens::TALL_MOCHI_WITH_GLASSES_PRODUCTION_MEDIA_TOKEN;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually run — fires a real API request and incurs cost
|
||||
#[ignore]
|
||||
async fn text_to_video() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("A whale breaching in the open ocean at dawn, cinematic.".to_string()),
|
||||
@@ -161,10 +160,8 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually run — fires a real API request and incurs cost
|
||||
#[ignore]
|
||||
async fn keyframe_start_and_end_frame() {
|
||||
// Artcraft uses media file tokens directly — no URL resolution needed.
|
||||
// Tokens from test_data::web::image_urls comments.
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("The dog walks from the lake to the forest.".to_string()),
|
||||
start_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new(JUNO_AT_LAKE_PRODUCTION_MEDIA_TOKEN.to_string()))),
|
||||
@@ -177,15 +174,13 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually run — fires a real API request and incurs cost
|
||||
#[ignore]
|
||||
async fn image_references() {
|
||||
// Artcraft uses media file tokens directly.
|
||||
// Tokens from test_data::web::image_urls comments.
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("The dog in @2 runs through the scenery in @1 towards the building in @3.".to_string()),
|
||||
reference_images: Some(ImageListRef::MediaFileTokens(vec![
|
||||
MediaFileToken::new(FOREST_BACKDROP_PRODUCTION_MEDIA_TOKEN.to_string()),
|
||||
MediaFileToken::new(JUNO_AT_LAKE_PRODUCTION_MEDIA_TOKEN.to_string()),
|
||||
MediaFileToken::new(TALL_MOCHI_WITH_GLASSES_PRODUCTION_MEDIA_TOKEN.to_string()),
|
||||
MediaFileToken::new(WHITE_HOUSE_SUNSET_PRODUCTION_MEDIA_TOKEN.to_string()),
|
||||
])),
|
||||
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
|
||||
@@ -196,25 +191,8 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually run — fires a real API request and incurs cost
|
||||
async fn video_references() {
|
||||
// TODO: Needs a real video media file token to test properly.
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("Change @video1 to a nighttime scene with moonlight.".to_string()),
|
||||
reference_videos: Some(VideoListRef::MediaFileTokens(vec![
|
||||
MediaFileToken::new(JUNO_AT_LAKE_PRODUCTION_MEDIA_TOKEN.to_string()), // Using an image token as placeholder
|
||||
])),
|
||||
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
|
||||
..artcraft_builder()
|
||||
}).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually run — fires a real API request and incurs cost
|
||||
#[ignore]
|
||||
async fn character_references() {
|
||||
// Character tokens are passed through directly to the Artcraft API.
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("@Jim and @Knight are sparring in a medieval arena.".to_string()),
|
||||
reference_character_tokens: Some(CharacterListRef::CharacterTokens(vec![
|
||||
@@ -227,11 +205,9 @@ mod tests {
|
||||
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
|
||||
// TODO: Add audio reference test once a media file token for a test audio file is available.
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
// -- Helpers --
|
||||
|
||||
fn artcraft_builder() -> GenerateVideoRequestBuilder {
|
||||
GenerateVideoRequestBuilder {
|
||||
|
||||
+426
@@ -0,0 +1,426 @@
|
||||
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 as CommonAspectRatioEnum;
|
||||
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
|
||||
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
|
||||
|
||||
use crate::api::common_aspect_ratio::CommonAspectRatio;
|
||||
use crate::api::common_resolution::CommonResolution;
|
||||
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::errors::client_error::ClientError;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video_v2::providers::artcraft::resolve::{
|
||||
resolve_audio_list_ref, resolve_character_list_ref, resolve_image_list_ref,
|
||||
resolve_image_ref, resolve_video_list_ref,
|
||||
};
|
||||
use crate::generate::generate_video_v2::providers::artcraft::seedance_2p0_fast::request::ArtcraftSeedance2p0FastRequestState;
|
||||
use crate::generate::generate_video_v2::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
use crate::generate::generate_video_v2::video_generation_request::VideoGenerationRequest;
|
||||
|
||||
pub fn build_artcraft_seedance_2p0_fast(mut builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
|
||||
let strategy = builder.request_mismatch_mitigation_strategy;
|
||||
|
||||
let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio.take(), strategy)?;
|
||||
let resolution = plan_output_resolution(builder.resolution.take(), strategy)?;
|
||||
let batch_count = plan_batch_count(builder.video_batch_count.take(), strategy)?;
|
||||
let duration_seconds = plan_duration(builder.duration_seconds.take(), strategy)?;
|
||||
let prompt = builder.prompt.take();
|
||||
|
||||
let start_frame = resolve_image_ref(builder.start_frame.take())?;
|
||||
let end_frame = resolve_image_ref(builder.end_frame.take())?;
|
||||
let reference_images = resolve_image_list_ref(builder.reference_images.take())?;
|
||||
let reference_videos = resolve_video_list_ref(builder.reference_videos.take())?;
|
||||
let reference_audio = resolve_audio_list_ref(builder.reference_audio.take())?;
|
||||
let reference_characters = resolve_character_list_ref(builder.reference_character_tokens.take());
|
||||
let idempotency_token = builder.get_or_generate_idempotency_token();
|
||||
|
||||
let request = OmniGenVideoCostAndGenerateRequest {
|
||||
model: Some(CommonVideoModelEnum::Seedance2p0Fast),
|
||||
idempotency_token: Some(idempotency_token),
|
||||
prompt,
|
||||
start_frame_image_media_token: start_frame,
|
||||
end_frame_image_media_token: end_frame,
|
||||
reference_image_media_tokens: reference_images,
|
||||
reference_video_media_tokens: reference_videos,
|
||||
reference_audio_media_tokens: reference_audio,
|
||||
reference_character_tokens: reference_characters,
|
||||
resolution,
|
||||
aspect_ratio,
|
||||
duration_seconds: duration_seconds.map(|d| d as u16),
|
||||
video_batch_count: Some(batch_count),
|
||||
negative_prompt: None,
|
||||
generate_audio: None,
|
||||
quality: None,
|
||||
};
|
||||
|
||||
let state = ArtcraftSeedance2p0FastRequestState { request };
|
||||
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p0Fast(state)))
|
||||
}
|
||||
|
||||
// -- Plan helpers --
|
||||
|
||||
fn plan_aspect_ratio(
|
||||
aspect_ratio: Option<CommonAspectRatio>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<Option<CommonAspectRatioEnum>, ArtcraftRouterError> {
|
||||
match aspect_ratio {
|
||||
None
|
||||
| Some(CommonAspectRatio::Auto)
|
||||
| Some(CommonAspectRatio::Auto2k)
|
||||
| Some(CommonAspectRatio::Auto4k) => Ok(None),
|
||||
|
||||
// Direct mappings
|
||||
Some(CommonAspectRatio::WideSixteenByNine) | Some(CommonAspectRatio::Wide) => {
|
||||
Ok(Some(CommonAspectRatioEnum::WideSixteenByNine))
|
||||
}
|
||||
Some(CommonAspectRatio::TallNineBySixteen) | Some(CommonAspectRatio::Tall) => {
|
||||
Ok(Some(CommonAspectRatioEnum::TallNineBySixteen))
|
||||
}
|
||||
Some(CommonAspectRatio::Square) | Some(CommonAspectRatio::SquareHd) => {
|
||||
Ok(Some(CommonAspectRatioEnum::Square))
|
||||
}
|
||||
Some(CommonAspectRatio::WideFourByThree) => Ok(Some(CommonAspectRatioEnum::WideFourByThree)),
|
||||
Some(CommonAspectRatio::TallThreeByFour) => Ok(Some(CommonAspectRatioEnum::TallThreeByFour)),
|
||||
|
||||
// Mismatches
|
||||
Some(unsupported) => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "aspect_ratio",
|
||||
value: format!("{:?}", unsupported),
|
||||
}))
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade
|
||||
| RequestMismatchMitigationStrategy::PayLessDowngrade => {
|
||||
Ok(Some(nearest_aspect_ratio(unsupported)))
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn nearest_aspect_ratio(aspect_ratio: CommonAspectRatio) -> CommonAspectRatioEnum {
|
||||
match aspect_ratio {
|
||||
CommonAspectRatio::WideFiveByFour => CommonAspectRatioEnum::WideFourByThree,
|
||||
CommonAspectRatio::WideThreeByTwo => CommonAspectRatioEnum::WideFourByThree,
|
||||
CommonAspectRatio::WideTwentyOneByNine => CommonAspectRatioEnum::WideSixteenByNine,
|
||||
CommonAspectRatio::TallFourByFive => CommonAspectRatioEnum::TallThreeByFour,
|
||||
CommonAspectRatio::TallTwoByThree => CommonAspectRatioEnum::TallThreeByFour,
|
||||
CommonAspectRatio::TallNineByTwentyOne => CommonAspectRatioEnum::TallNineBySixteen,
|
||||
_ => CommonAspectRatioEnum::Square,
|
||||
}
|
||||
}
|
||||
|
||||
// Seedance 2.0 Fast supports output resolutions: 480p and 720p only.
|
||||
fn plan_output_resolution(
|
||||
resolution: Option<CommonResolution>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<Option<CommonResolutionEnum>, ArtcraftRouterError> {
|
||||
match resolution {
|
||||
None => Ok(None),
|
||||
|
||||
Some(CommonResolution::FourEightyP) => Ok(Some(CommonResolutionEnum::FourEightyP)),
|
||||
Some(CommonResolution::SevenTwentyP) => Ok(Some(CommonResolutionEnum::SevenTwentyP)),
|
||||
|
||||
// 1080p not supported for Fast
|
||||
Some(CommonResolution::TenEightyP) => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "resolution",
|
||||
value: format!("{:?}", CommonResolution::TenEightyP),
|
||||
}))
|
||||
}
|
||||
_ => Ok(Some(CommonResolutionEnum::SevenTwentyP)),
|
||||
},
|
||||
|
||||
Some(unsupported) => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "resolution",
|
||||
value: format!("{:?}", unsupported),
|
||||
}))
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade => {
|
||||
Ok(Some(match unsupported {
|
||||
CommonResolution::HalfK => CommonResolutionEnum::FourEightyP,
|
||||
_ => CommonResolutionEnum::SevenTwentyP,
|
||||
}))
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayLessDowngrade => {
|
||||
Ok(Some(match unsupported {
|
||||
CommonResolution::HalfK => CommonResolutionEnum::FourEightyP,
|
||||
_ => CommonResolutionEnum::SevenTwentyP,
|
||||
}))
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Batch counts: 1, 2, 4.
|
||||
fn plan_batch_count(
|
||||
video_batch_count: Option<u16>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<u16, ArtcraftRouterError> {
|
||||
let count = video_batch_count.unwrap_or(1);
|
||||
match count {
|
||||
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
|
||||
1 | 2 | 4 => Ok(count),
|
||||
_ => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "video_batch_count",
|
||||
value: format!("{}", count),
|
||||
}))
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(4),
|
||||
RequestMismatchMitigationStrategy::PayLessDowngrade => {
|
||||
Ok(if count < 4 { 2 } else { 4 })
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Duration: 4-15 seconds.
|
||||
fn plan_duration(
|
||||
duration_seconds: Option<u16>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<Option<u8>, ArtcraftRouterError> {
|
||||
const MIN: u16 = 4;
|
||||
const MAX: u16 = 15;
|
||||
match duration_seconds {
|
||||
None => Ok(None),
|
||||
Some(d) if d >= MIN && d <= MAX => Ok(Some(d as u8)),
|
||||
Some(d) => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "duration_seconds",
|
||||
value: format!("{}", d),
|
||||
}))
|
||||
}
|
||||
_ => Ok(Some(d.clamp(MIN, MAX) as u8)),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use enums::common::generation::common_aspect_ratio::CommonAspectRatio as CommonAspectRatioEnum;
|
||||
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
|
||||
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
|
||||
use tokens::tokens::characters::CharacterToken;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
use crate::api::character_list_ref::CharacterListRef;
|
||||
use crate::api::common_aspect_ratio::CommonAspectRatio;
|
||||
use crate::api::common_resolution::CommonResolution;
|
||||
use crate::api::common_video_model::CommonVideoModel;
|
||||
use crate::api::image_list_ref::ImageListRef;
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::provider::Provider;
|
||||
use crate::api::video_list_ref::VideoListRef;
|
||||
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video_v2::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
use crate::generate::generate_video_v2::video_generation_request::VideoGenerationRequest;
|
||||
|
||||
use super::*;
|
||||
|
||||
// ── Field conversions ──
|
||||
|
||||
mod field_conversions {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn prompt_is_passed_through() {
|
||||
let req = unwrap_request(artcraft_fast_builder_with(|b| { b.prompt = Some("test".to_string()); }));
|
||||
assert_eq!(req.request.prompt, Some("test".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_none_stays_none() {
|
||||
let req = unwrap_request(artcraft_fast_builder_with(|_| {}));
|
||||
assert!(req.request.prompt.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_is_seedance_2p0_fast() {
|
||||
let req = unwrap_request(artcraft_fast_builder_with(|_| {}));
|
||||
assert!(matches!(req.request.model, Some(CommonVideoModelEnum::Seedance2p0Fast)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_passed_through() {
|
||||
let req = unwrap_request(artcraft_fast_builder_with(|b| { b.duration_seconds = Some(10); }));
|
||||
assert_eq!(req.request.duration_seconds, Some(10));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_clamped_to_max() {
|
||||
let req = unwrap_request(artcraft_fast_builder_with(|b| { b.duration_seconds = Some(99); }));
|
||||
assert_eq!(req.request.duration_seconds, Some(15));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_count_passed_through() {
|
||||
let req = unwrap_request(artcraft_fast_builder_with(|b| { b.video_batch_count = Some(4); }));
|
||||
assert_eq!(req.request.video_batch_count, Some(4));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Aspect ratio ──
|
||||
|
||||
mod aspect_ratio_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn wide() {
|
||||
let req = unwrap_request(artcraft_fast_builder_with(|b| {
|
||||
b.aspect_ratio = Some(CommonAspectRatio::WideSixteenByNine);
|
||||
}));
|
||||
assert_eq!(req.request.aspect_ratio, Some(CommonAspectRatioEnum::WideSixteenByNine));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tall() {
|
||||
let req = unwrap_request(artcraft_fast_builder_with(|b| {
|
||||
b.aspect_ratio = Some(CommonAspectRatio::TallNineBySixteen);
|
||||
}));
|
||||
assert_eq!(req.request.aspect_ratio, Some(CommonAspectRatioEnum::TallNineBySixteen));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn square() {
|
||||
let req = unwrap_request(artcraft_fast_builder_with(|b| {
|
||||
b.aspect_ratio = Some(CommonAspectRatio::Square);
|
||||
}));
|
||||
assert_eq!(req.request.aspect_ratio, Some(CommonAspectRatioEnum::Square));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn none_stays_none() {
|
||||
let req = unwrap_request(artcraft_fast_builder_with(|_| {}));
|
||||
assert!(req.request.aspect_ratio.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
// ── Resolution ──
|
||||
|
||||
mod resolution_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn res_480p() {
|
||||
let req = unwrap_request(artcraft_fast_builder_with(|b| {
|
||||
b.resolution = Some(CommonResolution::FourEightyP);
|
||||
}));
|
||||
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::FourEightyP));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn res_720p() {
|
||||
let req = unwrap_request(artcraft_fast_builder_with(|b| {
|
||||
b.resolution = Some(CommonResolution::SevenTwentyP);
|
||||
}));
|
||||
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::SevenTwentyP));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn res_1080p_downgrades_to_720p() {
|
||||
let req = unwrap_request(artcraft_fast_builder_with(|b| {
|
||||
b.resolution = Some(CommonResolution::TenEightyP);
|
||||
}));
|
||||
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::SevenTwentyP));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn res_1080p_error_out() {
|
||||
let result = build_artcraft_seedance_2p0_fast(GenerateVideoRequestBuilder {
|
||||
model: CommonVideoModel::Seedance2p0Fast,
|
||||
provider: Provider::Artcraft,
|
||||
resolution: Some(CommonResolution::TenEightyP),
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
|
||||
..Default::default()
|
||||
});
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn none_stays_none() {
|
||||
let req = unwrap_request(artcraft_fast_builder_with(|_| {}));
|
||||
assert!(req.request.resolution.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
// ── Media tokens ──
|
||||
|
||||
mod media_token_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn start_frame_token_passed_through() {
|
||||
let token = MediaFileToken::new("mf_start".to_string());
|
||||
let req = unwrap_request(artcraft_fast_builder_with(|b| {
|
||||
b.start_frame = Some(ImageRef::MediaFileToken(token.clone()));
|
||||
}));
|
||||
assert_eq!(req.request.start_frame_image_media_token, Some(token));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_start_frame_rejected() {
|
||||
let result = build_artcraft_seedance_2p0_fast(GenerateVideoRequestBuilder {
|
||||
model: CommonVideoModel::Seedance2p0Fast,
|
||||
provider: Provider::Artcraft,
|
||||
start_frame: Some(ImageRef::Url("https://example.com".to_string())),
|
||||
..Default::default()
|
||||
});
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_image_tokens_passed_through() {
|
||||
let tokens = vec![
|
||||
MediaFileToken::new("mf_a".to_string()),
|
||||
MediaFileToken::new("mf_b".to_string()),
|
||||
];
|
||||
let req = unwrap_request(artcraft_fast_builder_with(|b| {
|
||||
b.reference_images = Some(ImageListRef::MediaFileTokens(tokens.clone()));
|
||||
}));
|
||||
assert_eq!(req.request.reference_image_media_tokens, Some(tokens));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_tokens_passed_through() {
|
||||
let tokens = vec![
|
||||
CharacterToken::new("char_a".to_string()),
|
||||
CharacterToken::new("char_b".to_string()),
|
||||
];
|
||||
let req = unwrap_request(artcraft_fast_builder_with(|b| {
|
||||
b.reference_character_tokens = Some(CharacterListRef::CharacterTokens(tokens.clone()));
|
||||
}));
|
||||
assert_eq!(req.request.reference_character_tokens, Some(tokens));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
fn artcraft_fast_builder_with(f: impl FnOnce(&mut GenerateVideoRequestBuilder)) -> GenerateVideoRequestBuilder {
|
||||
let mut builder = GenerateVideoRequestBuilder {
|
||||
model: CommonVideoModel::Seedance2p0Fast,
|
||||
provider: Provider::Artcraft,
|
||||
duration_seconds: Some(5),
|
||||
video_batch_count: Some(1),
|
||||
..Default::default()
|
||||
};
|
||||
f(&mut builder);
|
||||
builder
|
||||
}
|
||||
|
||||
fn unwrap_request(builder: GenerateVideoRequestBuilder) -> ArtcraftSeedance2p0FastRequestState {
|
||||
let result = build_artcraft_seedance_2p0_fast(builder).expect("build should succeed");
|
||||
match result {
|
||||
VideoGenerationDraftOrRequest::Request(
|
||||
VideoGenerationRequest::ArtcraftSeedance2p0Fast(state)
|
||||
) => state,
|
||||
_ => panic!("expected ArtcraftSeedance2p0Fast request"),
|
||||
}
|
||||
}
|
||||
}
|
||||
+260
@@ -0,0 +1,260 @@
|
||||
use enums::common::generation::common_resolution::CommonResolution;
|
||||
|
||||
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
|
||||
use crate::generate::generate_video_v2::providers::artcraft::seedance_2p0_fast::request::ArtcraftSeedance2p0FastRequestState;
|
||||
|
||||
// -- Pricing constants --
|
||||
//
|
||||
// ArtCraft credits: 100 credits = $1.00. Credits always equal USD cents.
|
||||
//
|
||||
// The per-second USD cost varies by resolution. We derive cents from the
|
||||
// upstream credit rates and their credit-package prices, then set
|
||||
// ArtCraft credits = cents.
|
||||
|
||||
/// USD cents per second by resolution, derived from upstream Fast rates:
|
||||
/// 480p: 10 upstream-credits/sec / 193 upstream-credits/$1 * 100 ~= 5.181 c/s
|
||||
/// 720p: 28 upstream-credits/sec / 220 upstream-credits/$1 * 100 ~= 12.727 c/s
|
||||
///
|
||||
/// We keep these as f64 because per-second rates are fractional; rounding
|
||||
/// happens once at the end after multiplying by duration * batch.
|
||||
const CENTS_PER_SECOND_480P: f64 = 5.181;
|
||||
const CENTS_PER_SECOND_720P: f64 = 12.727;
|
||||
|
||||
pub struct ArtcraftSeedance2p0FastCostState {
|
||||
pub resolution: CommonResolution,
|
||||
pub duration_seconds: u16,
|
||||
pub batch_count: u16,
|
||||
pub has_video_reference: bool,
|
||||
}
|
||||
|
||||
impl ArtcraftSeedance2p0FastCostState {
|
||||
pub fn from_request(request: &ArtcraftSeedance2p0FastRequestState) -> Self {
|
||||
let resolution = request.request.resolution
|
||||
.unwrap_or(CommonResolution::SevenTwentyP);
|
||||
let duration_seconds = request.request.duration_seconds.unwrap_or(5);
|
||||
let batch_count = request.request.video_batch_count.unwrap_or(1);
|
||||
let has_video_reference = request.request.reference_video_media_tokens
|
||||
.as_ref()
|
||||
.is_some_and(|tokens| !tokens.is_empty());
|
||||
|
||||
Self { resolution, duration_seconds, batch_count, has_video_reference }
|
||||
}
|
||||
|
||||
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
|
||||
let cents_per_second = match self.resolution {
|
||||
CommonResolution::FourEightyP => CENTS_PER_SECOND_480P,
|
||||
// Everything else (including 720p and unsupported resolutions) prices at 720p.
|
||||
_ => CENTS_PER_SECOND_720P,
|
||||
};
|
||||
|
||||
let usd_cents = (self.duration_seconds as f64 * cents_per_second * self.batch_count as f64).round() as u64;
|
||||
|
||||
// ArtCraft credits: 100 credits = $1.00, so credits = cents.
|
||||
VideoGenerationCostEstimate {
|
||||
cost_in_credits: Some(usd_cents),
|
||||
cost_in_usd_cents: Some(usd_cents),
|
||||
is_free: false,
|
||||
is_unlimited: false,
|
||||
is_rate_limited: false,
|
||||
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::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
|
||||
// -- 720p pricing --
|
||||
|
||||
mod pricing_720p {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn batch_1() {
|
||||
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 4, 1), 51);
|
||||
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 5, 1), 64);
|
||||
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 10, 1), 127);
|
||||
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 15, 1), 191);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_2() {
|
||||
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 4, 2), 102);
|
||||
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 5, 2), 127);
|
||||
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 15, 2), 382);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_4() {
|
||||
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 4, 4), 204);
|
||||
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 5, 4), 255);
|
||||
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 15, 4), 764);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn none_defaults_to_720p() {
|
||||
assert_eq!(cost_cents(None, 5, 1), cost_cents(Some(CommonResolution::SevenTwentyP), 5, 1));
|
||||
}
|
||||
}
|
||||
|
||||
// -- 480p pricing --
|
||||
|
||||
mod pricing_480p {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn batch_1() {
|
||||
assert_eq!(cost_cents(Some(CommonResolution::FourEightyP), 4, 1), 21);
|
||||
assert_eq!(cost_cents(Some(CommonResolution::FourEightyP), 5, 1), 26);
|
||||
assert_eq!(cost_cents(Some(CommonResolution::FourEightyP), 10, 1), 52);
|
||||
assert_eq!(cost_cents(Some(CommonResolution::FourEightyP), 15, 1), 78);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_2() {
|
||||
assert_eq!(cost_cents(Some(CommonResolution::FourEightyP), 5, 2), 52);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_4() {
|
||||
assert_eq!(cost_cents(Some(CommonResolution::FourEightyP), 5, 4), 104);
|
||||
}
|
||||
}
|
||||
|
||||
// -- Relative pricing --
|
||||
|
||||
mod relative_pricing_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn cost_480p_cheaper_than_720p() {
|
||||
let c480 = cost_cents(Some(CommonResolution::FourEightyP), 5, 1);
|
||||
let c720 = cost_cents(Some(CommonResolution::SevenTwentyP), 5, 1);
|
||||
assert!(c480 < c720, "480p ({}) should be cheaper than 720p ({})", c480, c720);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_scales_with_duration() {
|
||||
let c4 = cost_cents(Some(CommonResolution::SevenTwentyP), 4, 1);
|
||||
let c10 = cost_cents(Some(CommonResolution::SevenTwentyP), 10, 1);
|
||||
let c15 = cost_cents(Some(CommonResolution::SevenTwentyP), 15, 1);
|
||||
assert!(c4 < c10);
|
||||
assert!(c10 < c15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_scales_with_batch() {
|
||||
let b1 = cost_cents(Some(CommonResolution::SevenTwentyP), 5, 1);
|
||||
let b2 = cost_cents(Some(CommonResolution::SevenTwentyP), 5, 2);
|
||||
let b4 = cost_cents(Some(CommonResolution::SevenTwentyP), 5, 4);
|
||||
assert!(b1 < b2);
|
||||
assert!(b2 < b4);
|
||||
}
|
||||
}
|
||||
|
||||
// -- Credits equal cents --
|
||||
|
||||
mod credits_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn credits_equal_usd_cents_all_combos() {
|
||||
let resolutions = [
|
||||
Some(CommonResolution::FourEightyP),
|
||||
Some(CommonResolution::SevenTwentyP),
|
||||
None,
|
||||
];
|
||||
for res in resolutions {
|
||||
for dur in [4, 5, 10, 15] {
|
||||
for batch in [1, 2, 4] {
|
||||
let cost = build_cost(res, dur, batch);
|
||||
assert_eq!(
|
||||
cost.cost_in_credits, cost.cost_in_usd_cents,
|
||||
"credits should equal cents for res={:?} dur={}s batch={}",
|
||||
res, dur, batch,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -- Cross-check with Kinovi Fast via builder --
|
||||
|
||||
mod cross_check_with_kinovi {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn artcraft_fast_matches_kinovi_fast_all_combos() {
|
||||
let resolutions = [
|
||||
Some(CommonResolution::FourEightyP),
|
||||
Some(CommonResolution::SevenTwentyP),
|
||||
None,
|
||||
];
|
||||
let durations: [u16; 4] = [4, 5, 10, 15];
|
||||
let batches: [u16; 3] = [1, 2, 4];
|
||||
|
||||
for res in &resolutions {
|
||||
for dur in &durations {
|
||||
for batch in &batches {
|
||||
let artcraft_cost = build_cost(*res, *dur, *batch);
|
||||
|
||||
let kinovi = GenerateVideoRequestBuilder {
|
||||
model: CommonVideoModel::Seedance2p0Fast,
|
||||
provider: Provider::Seedance2Pro,
|
||||
resolution: *res,
|
||||
duration_seconds: Some(*dur),
|
||||
video_batch_count: Some(*batch),
|
||||
..Default::default()
|
||||
};
|
||||
let kinovi_cost = kinovi.build2()
|
||||
.expect("kinovi build2")
|
||||
.estimate_cost()
|
||||
.expect("kinovi estimate_cost");
|
||||
|
||||
assert_eq!(
|
||||
artcraft_cost.cost_in_usd_cents, kinovi_cost.cost_in_usd_cents,
|
||||
"USD cents mismatch: res={:?} dur={}s batch={}",
|
||||
res, dur, batch,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -- Helpers --
|
||||
|
||||
fn build_cost(
|
||||
resolution: Option<CommonResolution>,
|
||||
duration_seconds: u16,
|
||||
video_batch_count: u16,
|
||||
) -> crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
model: CommonVideoModel::Seedance2p0Fast,
|
||||
provider: Provider::Artcraft,
|
||||
resolution,
|
||||
duration_seconds: Some(duration_seconds),
|
||||
video_batch_count: Some(video_batch_count),
|
||||
..Default::default()
|
||||
};
|
||||
builder.build2()
|
||||
.expect("build2 should succeed")
|
||||
.estimate_cost()
|
||||
.expect("estimate_cost should succeed")
|
||||
}
|
||||
|
||||
fn cost_cents(
|
||||
resolution: Option<CommonResolution>,
|
||||
duration_seconds: u16,
|
||||
video_batch_count: u16,
|
||||
) -> u64 {
|
||||
build_cost(resolution, duration_seconds, video_batch_count)
|
||||
.cost_in_usd_cents
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
pub mod build;
|
||||
pub mod cost;
|
||||
pub mod request;
|
||||
+242
@@ -0,0 +1,242 @@
|
||||
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 crate::client::router_artcraft_client::RouterArtcraftClient;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::errors::provider_error::ProviderError;
|
||||
use crate::generate::generate_video::generate_video_response::{
|
||||
ArtcraftVideoResponsePayload, GenerateVideoResponse,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ArtcraftSeedance2p0FastRequestState {
|
||||
/// Final materialized request; ready to fire via the omni-gen video endpoint.
|
||||
pub request: OmniGenVideoCostAndGenerateRequest,
|
||||
}
|
||||
|
||||
impl ArtcraftSeedance2p0FastRequestState {
|
||||
pub async fn send(&self, client: &RouterArtcraftClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
|
||||
let response = omni_gen_video_generate(
|
||||
&client.api_host,
|
||||
Some(&client.credentials),
|
||||
self.request.clone(),
|
||||
)
|
||||
.await
|
||||
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Storyteller(err)))?;
|
||||
|
||||
Ok(GenerateVideoResponse::Artcraft(ArtcraftVideoResponsePayload {
|
||||
inference_job_token: response.inference_job_token.clone(),
|
||||
all_inference_job_tokens: vec![response.inference_job_token],
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use tokens::tokens::characters::CharacterToken;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
use crate::api::character_list_ref::CharacterListRef;
|
||||
use crate::api::common_aspect_ratio::CommonAspectRatio;
|
||||
use crate::api::common_resolution::CommonResolution;
|
||||
use crate::api::common_video_model::CommonVideoModel;
|
||||
use crate::api::image_list_ref::ImageListRef;
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::provider::Provider;
|
||||
use crate::client::router_artcraft_client::RouterArtcraftClient;
|
||||
use crate::client::router_client::RouterClient;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
|
||||
use crate::generate::generate_video_v2::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
|
||||
use artcraft_client::utils::api_host::ApiHost;
|
||||
|
||||
use test_data::web::character_tokens::{JIM, KNIGHT};
|
||||
use test_data::web::image_media_tokens::{
|
||||
FOREST_BACKDROP_PRODUCTION_MEDIA_TOKEN,
|
||||
JUNO_AT_LAKE_PRODUCTION_MEDIA_TOKEN,
|
||||
WHITE_HOUSE_SUNSET_PRODUCTION_MEDIA_TOKEN,
|
||||
};
|
||||
|
||||
// -- Aspect ratio tests --
|
||||
|
||||
mod aspect_ratio_tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually run — fires a real API request and incurs cost
|
||||
async fn landscape() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("A corgi running through a field of wildflowers at sunset.".to_string()),
|
||||
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
|
||||
..artcraft_fast_builder()
|
||||
}).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually run — fires a real API request and incurs cost
|
||||
async fn portrait() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("A cat sitting on a windowsill watching rain.".to_string()),
|
||||
aspect_ratio: Some(CommonAspectRatio::TallNineBySixteen),
|
||||
..artcraft_fast_builder()
|
||||
}).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually run — fires a real API request and incurs cost
|
||||
async fn square() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("A hummingbird hovering near a flower.".to_string()),
|
||||
aspect_ratio: Some(CommonAspectRatio::Square),
|
||||
..artcraft_fast_builder()
|
||||
}).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
}
|
||||
|
||||
// -- Resolution tests --
|
||||
|
||||
mod resolution_tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually run — fires a real API request and incurs cost
|
||||
async fn res_480p() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("A shiba inu playing in autumn leaves.".to_string()),
|
||||
resolution: Some(CommonResolution::FourEightyP),
|
||||
..artcraft_fast_builder()
|
||||
}).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually run — fires a real API request and incurs cost
|
||||
async fn res_720p() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("A golden retriever catching a frisbee on the beach.".to_string()),
|
||||
resolution: Some(CommonResolution::SevenTwentyP),
|
||||
..artcraft_fast_builder()
|
||||
}).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
}
|
||||
|
||||
// -- Modality tests --
|
||||
|
||||
mod modality_tests {
|
||||
use super::*;
|
||||
use test_data::web::image_media_tokens::TALL_MOCHI_WITH_GLASSES_PRODUCTION_MEDIA_TOKEN;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually run — fires a real API request and incurs cost
|
||||
async fn text_to_video() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("A sky whale breaching in the open sky, pink clouds at dawn, cinematic.".to_string()),
|
||||
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
|
||||
..artcraft_fast_builder()
|
||||
}).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually run — fires a real API request and incurs cost
|
||||
async fn keyframe_start_and_end_frame() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("The dog walks from the lake to the forest.".to_string()),
|
||||
start_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new(JUNO_AT_LAKE_PRODUCTION_MEDIA_TOKEN.to_string()))),
|
||||
end_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new(FOREST_BACKDROP_PRODUCTION_MEDIA_TOKEN.to_string()))),
|
||||
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
|
||||
..artcraft_fast_builder()
|
||||
}).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually run — fires a real API request and incurs cost
|
||||
async fn image_references() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("The dog in @2 runs through the scenery in @1 towards the building in @3.".to_string()),
|
||||
reference_images: Some(ImageListRef::MediaFileTokens(vec![
|
||||
MediaFileToken::new(FOREST_BACKDROP_PRODUCTION_MEDIA_TOKEN.to_string()),
|
||||
MediaFileToken::new(TALL_MOCHI_WITH_GLASSES_PRODUCTION_MEDIA_TOKEN.to_string()),
|
||||
MediaFileToken::new(WHITE_HOUSE_SUNSET_PRODUCTION_MEDIA_TOKEN.to_string()),
|
||||
])),
|
||||
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
|
||||
..artcraft_fast_builder()
|
||||
}).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually run — fires a real API request and incurs cost
|
||||
async fn character_references() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("@Jim and @Knight are sparring in a medieval arena.".to_string()),
|
||||
reference_character_tokens: Some(CharacterListRef::CharacterTokens(vec![
|
||||
CharacterToken::new(JIM.token.to_string()),
|
||||
CharacterToken::new(KNIGHT.token.to_string()),
|
||||
])),
|
||||
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
|
||||
..artcraft_fast_builder()
|
||||
}).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
}
|
||||
|
||||
// -- Helpers --
|
||||
|
||||
fn artcraft_fast_builder() -> GenerateVideoRequestBuilder {
|
||||
GenerateVideoRequestBuilder {
|
||||
model: CommonVideoModel::Seedance2p0Fast,
|
||||
provider: Provider::Artcraft,
|
||||
duration_seconds: Some(4),
|
||||
video_batch_count: Some(1),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn get_artcraft_client() -> RouterClient {
|
||||
let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/artcraft_cookies.txt")
|
||||
.expect("Failed to read artcraft cookies");
|
||||
let cookies = cookies.trim().to_string();
|
||||
let credentials = StorytellerCredentialSet::parse_multi_cookie_header(&cookies)
|
||||
.expect("Failed to parse cookies")
|
||||
.expect("No credentials found");
|
||||
RouterClient::Artcraft(RouterArtcraftClient::new(ApiHost::Storyteller, credentials))
|
||||
}
|
||||
|
||||
async fn run_pipeline(builder: GenerateVideoRequestBuilder) -> GenerateVideoResponse {
|
||||
let client = get_artcraft_client();
|
||||
|
||||
let draft_or_request = builder.build2().expect("build2 should succeed");
|
||||
let request = match draft_or_request {
|
||||
VideoGenerationDraftOrRequest::Request(r) => r,
|
||||
_ => panic!("expected Request variant (Artcraft skips draft)"),
|
||||
};
|
||||
|
||||
let response = request.send_request(&client).await.expect("send_request should succeed");
|
||||
|
||||
match &response {
|
||||
GenerateVideoResponse::Artcraft(p) => {
|
||||
println!("inference_job_token={:?}", p.inference_job_token);
|
||||
println!("all_inference_job_tokens={:?}", p.all_inference_job_tokens);
|
||||
}
|
||||
other => println!("response: {:?}", other),
|
||||
}
|
||||
|
||||
response
|
||||
}
|
||||
}
|
||||
+2
@@ -27,6 +27,8 @@ impl KinoviSeedance2p0RequestState {
|
||||
Ok(GenerateVideoResponse::Seedance2Pro(Seedance2proVideoResponsePayload {
|
||||
order_id: response.order_id,
|
||||
task_id: response.task_id,
|
||||
maybe_order_ids: response.order_ids,
|
||||
maybe_task_ids: response.task_ids,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -27,6 +27,8 @@ impl KinoviSeedance2p0FastRequestState {
|
||||
Ok(GenerateVideoResponse::Seedance2Pro(Seedance2proVideoResponsePayload {
|
||||
order_id: response.order_id,
|
||||
task_id: response.task_id,
|
||||
maybe_order_ids: response.order_ids,
|
||||
maybe_task_ids: response.task_ids,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
+8
-1
@@ -1,4 +1,4 @@
|
||||
use crate::client::router_client::RouterClient;
|
||||
use crate::api::provider::Provider;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
|
||||
use crate::generate::generate_video_v2::providers::kinovi::seedance_2p0::cost::KinoviSeedance2p0CostState;
|
||||
@@ -19,6 +19,13 @@ pub enum VideoGenerationDraftRequest {
|
||||
|
||||
impl VideoGenerationDraftRequest {
|
||||
|
||||
pub fn get_provider(&self) -> Provider {
|
||||
match self {
|
||||
Self::KinoviSeedance2p0(_) => Provider::Seedance2Pro,
|
||||
Self::KinoviSeedance2p0Fast(_) => Provider::Seedance2Pro,
|
||||
}
|
||||
}
|
||||
|
||||
/// Return a cost estimate to fulfill the request.
|
||||
pub fn estimate_cost(&self) -> Result<VideoGenerationCostEstimate, ArtcraftRouterError> {
|
||||
match self {
|
||||
|
||||
+8
@@ -1,3 +1,4 @@
|
||||
use crate::api::provider::Provider;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
|
||||
use crate::generate::generate_video_v2::video_generation_draft::VideoGenerationDraftRequest;
|
||||
@@ -10,6 +11,13 @@ pub enum VideoGenerationDraftOrRequest {
|
||||
|
||||
impl VideoGenerationDraftOrRequest {
|
||||
|
||||
pub fn get_provider(&self) -> Provider {
|
||||
match self {
|
||||
Self::Draft(draft) => draft.get_provider(),
|
||||
Self::Request(request) => request.get_provider(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn estimate_cost(&self) -> Result<VideoGenerationCostEstimate, ArtcraftRouterError> {
|
||||
match self {
|
||||
VideoGenerationDraftOrRequest::Draft(draft) => draft.estimate_cost(),
|
||||
|
||||
+18
@@ -1,9 +1,12 @@
|
||||
use crate::api::provider::Provider;
|
||||
use crate::client::router_client::RouterClient;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
|
||||
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
|
||||
use crate::generate::generate_video_v2::providers::artcraft::seedance_2p0::cost::ArtcraftSeedance2p0CostState;
|
||||
use crate::generate::generate_video_v2::providers::artcraft::seedance_2p0::request::ArtcraftSeedance2p0RequestState;
|
||||
use crate::generate::generate_video_v2::providers::artcraft::seedance_2p0_fast::cost::ArtcraftSeedance2p0FastCostState;
|
||||
use crate::generate::generate_video_v2::providers::artcraft::seedance_2p0_fast::request::ArtcraftSeedance2p0FastRequestState;
|
||||
use crate::generate::generate_video_v2::providers::kinovi::seedance_2p0::cost::KinoviSeedance2p0CostState;
|
||||
use crate::generate::generate_video_v2::providers::kinovi::seedance_2p0::request::KinoviSeedance2p0RequestState;
|
||||
use crate::generate::generate_video_v2::providers::kinovi::seedance_2p0_fast::cost::KinoviSeedance2p0FastCostState;
|
||||
@@ -12,16 +15,27 @@ use crate::generate::generate_video_v2::providers::kinovi::seedance_2p0_fast::re
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum VideoGenerationRequest {
|
||||
ArtcraftSeedance2p0(ArtcraftSeedance2p0RequestState),
|
||||
ArtcraftSeedance2p0Fast(ArtcraftSeedance2p0FastRequestState),
|
||||
KinoviSeedance2p0(KinoviSeedance2p0RequestState),
|
||||
KinoviSeedance2p0Fast(KinoviSeedance2p0FastRequestState),
|
||||
}
|
||||
|
||||
impl VideoGenerationRequest {
|
||||
|
||||
pub fn get_provider(&self) -> Provider {
|
||||
match self {
|
||||
Self::ArtcraftSeedance2p0(_) => Provider::Artcraft,
|
||||
Self::ArtcraftSeedance2p0Fast(_) => Provider::Artcraft,
|
||||
Self::KinoviSeedance2p0(_) => Provider::Seedance2Pro,
|
||||
Self::KinoviSeedance2p0Fast(_) => Provider::Seedance2Pro,
|
||||
}
|
||||
}
|
||||
|
||||
/// Return a cost estimate to fulfill the request.
|
||||
pub fn estimate_cost(&self) -> Result<VideoGenerationCostEstimate, ArtcraftRouterError> {
|
||||
match self {
|
||||
VideoGenerationRequest::ArtcraftSeedance2p0(request) => Ok(ArtcraftSeedance2p0CostState::from_request(request).estimate_cost()),
|
||||
VideoGenerationRequest::ArtcraftSeedance2p0Fast(request) => Ok(ArtcraftSeedance2p0FastCostState::from_request(request).estimate_cost()),
|
||||
VideoGenerationRequest::KinoviSeedance2p0(request) => Ok(KinoviSeedance2p0CostState::from_request(request).estimate_cost()),
|
||||
VideoGenerationRequest::KinoviSeedance2p0Fast(request) => Ok(KinoviSeedance2p0FastCostState::from_request(request).estimate_cost()),
|
||||
}
|
||||
@@ -35,6 +49,10 @@ impl VideoGenerationRequest {
|
||||
let client_ref = client.get_artcraft_client_ref()?;
|
||||
request.send(client_ref).await
|
||||
},
|
||||
VideoGenerationRequest::ArtcraftSeedance2p0Fast(request) => {
|
||||
let client_ref = client.get_artcraft_client_ref()?;
|
||||
request.send(client_ref).await
|
||||
},
|
||||
VideoGenerationRequest::KinoviSeedance2p0(request) => {
|
||||
let client_ref = client.get_seedance2pro_client_ref()?;
|
||||
request.send(client_ref).await
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
pub mod aspect_ratio;
|
||||
pub mod resolution;
|
||||
pub mod resolution;
|
||||
pub mod quality;
|
||||
@@ -0,0 +1,21 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use artcraft_router::api::common_quality::CommonQuality as RouterCommonQuality;
|
||||
|
||||
/// Just cargo culting this. might not be necessary for standalone types anymore
|
||||
#[derive(Copy, Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CommonQuality {
|
||||
High,
|
||||
Medium,
|
||||
Low,
|
||||
}
|
||||
|
||||
impl CommonQuality {
|
||||
pub fn to_artcraft_router_type(&self) -> RouterCommonQuality {
|
||||
match self {
|
||||
CommonQuality::High => RouterCommonQuality::High,
|
||||
CommonQuality::Medium => RouterCommonQuality::Medium,
|
||||
CommonQuality::Low => RouterCommonQuality::Low,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod common_quality;
|
||||
+1
-1
@@ -17,7 +17,7 @@ pub async fn estimate_image_cost_command(
|
||||
request: EstimateImageCostRequest,
|
||||
app_env_configs: State<'_, AppEnvConfigs>,
|
||||
) -> ResponseOrError<EstimateImageCostResponse, EstimateImageCostError> {
|
||||
debug!("estimate_image_cost_command called");
|
||||
debug!("estimate_image_cost_command called: {:?}", request);
|
||||
|
||||
let result = estimate_image_cost(
|
||||
&app_env_configs.storyteller_host,
|
||||
|
||||
+2
-2
@@ -8,7 +8,7 @@ use artcraft_api_defs::generate::cost_estimate::estimate_video_cost::{
|
||||
EstimateVideoCostResponse,
|
||||
};
|
||||
use artcraft_client::endpoints::generate::cost_estimate::video::estimate_video_cost::estimate_video_cost;
|
||||
use log::debug;
|
||||
use log::{debug, info};
|
||||
use tauri::State;
|
||||
|
||||
impl SerializeMarker for EstimateVideoCostResponse {}
|
||||
@@ -18,7 +18,7 @@ pub async fn estimate_video_cost_command(
|
||||
request: EstimateVideoCostRequest,
|
||||
app_env_configs: State<'_, AppEnvConfigs>,
|
||||
) -> ResponseOrError<EstimateVideoCostResponse, EstimateVideoCostError> {
|
||||
debug!("estimate_video_cost_command called");
|
||||
debug!("estimate_video_cost_command called: {:?}", request);
|
||||
|
||||
let result = estimate_video_cost(
|
||||
&app_env_configs.storyteller_host,
|
||||
|
||||
@@ -235,6 +235,7 @@ impl From<ArtcraftRouterError> for GenerateError {
|
||||
ArtcraftRouterError::Provider(ProviderError::Muapi(_)) => Self::ArtcraftRouterNotYetSupportedProvider("muapi"),
|
||||
ArtcraftRouterError::Provider(ProviderError::Seedance2Pro(_)) => Self::ArtcraftRouterNotYetSupportedProvider("seedance2pro"),
|
||||
ArtcraftRouterError::UnsupportedModel(model) => Self::NotYetImplemented(format!("Unsupported model: {}", model)),
|
||||
ArtcraftRouterError::UnsupportedProviderAndModelForNewApi(message) => Self::ArtcraftRouterNotYetSupportedProvider("unsupported model for new router API"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+10
-5
@@ -1,4 +1,5 @@
|
||||
use crate::core::api_adapters::aspect_ratio::common_aspect_ratio::CommonAspectRatio;
|
||||
use crate::core::api_adapters::quality::common_quality::CommonQuality;
|
||||
use crate::core::commands::enqueue::common::notify_frontend_of_errors::notify_frontend_of_errors;
|
||||
use crate::core::commands::enqueue::generate_error::{BadInputReason, GenerateError};
|
||||
use crate::core::commands::enqueue::image_edit::artcraft::handle_image_edit_artcraft::handle_image_edit_artcraft;
|
||||
@@ -15,6 +16,8 @@ use crate::core::events::generation_events::generation_enqueue_failure_event::Ge
|
||||
use crate::core::events::generation_events::generation_enqueue_success_event::GenerationEnqueueSuccessEvent;
|
||||
use crate::core::events::sendable_event_trait::SendableEvent;
|
||||
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
|
||||
use crate::core::state::artcraft_usage_tracker::artcraft_usage_tracker::ArtcraftUsageTracker;
|
||||
use crate::core::state::artcraft_usage_tracker::artcraft_usage_type::{ArtcraftUsagePage, ArtcraftUsageType};
|
||||
use crate::core::state::data_dir::app_data_root::AppDataRoot;
|
||||
use crate::core::state::data_dir::trait_data_subdir::DataSubdir;
|
||||
use crate::core::state::provider_priority::ProviderPriorityStore;
|
||||
@@ -24,6 +27,9 @@ use crate::core::utils::simple_http_download::simple_http_download;
|
||||
use crate::services::sora::state::sora_credential_manager::SoraCredentialManager;
|
||||
use crate::services::sora::state::sora_task_queue::SoraTaskQueue;
|
||||
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
|
||||
use artcraft_client::endpoints::media_files::get_media_file::get_media_file;
|
||||
use artcraft_client::error::storyteller_error::StorytellerError;
|
||||
use artcraft_client::utils::api_host::ApiHost;
|
||||
use enums::common::generation_provider::GenerationProvider;
|
||||
use enums::tauri::tasks::task_type::TaskType;
|
||||
use enums::tauri::ux::tauri_command_caller::TauriCommandCaller;
|
||||
@@ -36,13 +42,8 @@ use openai_sora_client::requests::image_gen::common::{ImageSize, NumImages};
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use sqlite_tasks::queries::create_task::{create_task, CreateTaskArgs};
|
||||
use std::time::Duration;
|
||||
use artcraft_client::endpoints::media_files::get_media_file::get_media_file;
|
||||
use artcraft_client::error::storyteller_error::StorytellerError;
|
||||
use artcraft_client::utils::api_host::ApiHost;
|
||||
use tauri::{AppHandle, Manager, State};
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
use crate::core::state::artcraft_usage_tracker::artcraft_usage_tracker::ArtcraftUsageTracker;
|
||||
use crate::core::state::artcraft_usage_tracker::artcraft_usage_type::{ArtcraftUsagePage, ArtcraftUsageType};
|
||||
|
||||
/// This is used in the Tauri command bridge.
|
||||
/// Don't change the serializations without coordinating with the frontend.
|
||||
@@ -134,6 +135,10 @@ pub struct EnqueueEditImageCommand {
|
||||
/// Image resolution
|
||||
pub image_resolution: Option<EditImageResolution>,
|
||||
|
||||
/// Quality.
|
||||
/// Tends to be used by OpenAI models.
|
||||
pub quality: Option<CommonQuality>,
|
||||
|
||||
/// OPTIONAL.
|
||||
/// Only for image angle edit models.
|
||||
pub horizontal_angle: Option<f64>,
|
||||
|
||||
+10
-5
@@ -1,3 +1,4 @@
|
||||
use crate::core::api_adapters::quality::common_quality::CommonQuality;
|
||||
use crate::core::commands::enqueue::common::notify_frontend_of_errors::notify_frontend_of_errors;
|
||||
use crate::core::commands::enqueue::generate_error::{BadInputReason, GenerateError};
|
||||
use crate::core::commands::enqueue::image_edit::image_edit_models::image_edit_model_to_model_type;
|
||||
@@ -14,6 +15,8 @@ use crate::core::events::generation_events::generation_enqueue_failure_event::Ge
|
||||
use crate::core::events::generation_events::generation_enqueue_success_event::GenerationEnqueueSuccessEvent;
|
||||
use crate::core::events::sendable_event_trait::SendableEvent;
|
||||
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
|
||||
use crate::core::state::artcraft_usage_tracker::artcraft_usage_tracker::ArtcraftUsageTracker;
|
||||
use crate::core::state::artcraft_usage_tracker::artcraft_usage_type::{ArtcraftUsagePage, ArtcraftUsageType};
|
||||
use crate::core::state::data_dir::app_data_root::AppDataRoot;
|
||||
use crate::core::state::data_dir::trait_data_subdir::DataSubdir;
|
||||
use crate::core::state::provider_priority::ProviderPriorityStore;
|
||||
@@ -23,6 +26,9 @@ use crate::core::utils::simple_http_download::simple_http_download;
|
||||
use crate::services::sora::state::sora_credential_manager::SoraCredentialManager;
|
||||
use crate::services::sora::state::sora_task_queue::SoraTaskQueue;
|
||||
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
|
||||
use artcraft_client::endpoints::media_files::get_media_file::get_media_file;
|
||||
use artcraft_client::error::storyteller_error::StorytellerError;
|
||||
use artcraft_client::utils::api_host::ApiHost;
|
||||
use enums::common::generation_provider::GenerationProvider;
|
||||
use enums::tauri::ux::tauri_command_caller::TauriCommandCaller;
|
||||
use errors::AnyhowError;
|
||||
@@ -34,13 +40,8 @@ use openai_sora_client::requests::image_gen::common::{ImageSize, NumImages};
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use sqlite_tasks::queries::create_task::{create_task, CreateTaskArgs};
|
||||
use std::time::Duration;
|
||||
use artcraft_client::endpoints::media_files::get_media_file::get_media_file;
|
||||
use artcraft_client::error::storyteller_error::StorytellerError;
|
||||
use artcraft_client::utils::api_host::ApiHost;
|
||||
use tauri::{AppHandle, Manager, State};
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
use crate::core::state::artcraft_usage_tracker::artcraft_usage_tracker::ArtcraftUsageTracker;
|
||||
use crate::core::state::artcraft_usage_tracker::artcraft_usage_type::{ArtcraftUsagePage, ArtcraftUsageType};
|
||||
|
||||
#[derive(Deserialize, Debug, Copy, Clone)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
@@ -95,6 +96,10 @@ pub struct EnqueueInpaintImageCommand {
|
||||
|
||||
/// Number of images to generate.
|
||||
pub image_count: Option<u32>,
|
||||
|
||||
/// Quality.
|
||||
/// Tends to be used by OpenAI models.
|
||||
pub quality: Option<CommonQuality>,
|
||||
|
||||
/// If true, force the dimensions of the source image and mask image to match.
|
||||
pub require_matching_dimensions: Option<bool>,
|
||||
|
||||
@@ -73,6 +73,7 @@ impl TaskEnqueueSuccess{
|
||||
Some(GenerationModel::NanoBananaPro) => Some(TaskModelType::NanoBananaPro),
|
||||
Some(GenerationModel::GptImage1) => Some(TaskModelType::GptImage1),
|
||||
Some(GenerationModel::GptImage1p5) => Some(TaskModelType::GptImage1p5),
|
||||
Some(GenerationModel::GptImage2) => Some(TaskModelType::GptImage2),
|
||||
Some(GenerationModel::Seedream4) => Some(TaskModelType::Seedream4),
|
||||
Some(GenerationModel::Seedream4p5) => Some(TaskModelType::Seedream4p5),
|
||||
Some(GenerationModel::Seedream5Lite) => Some(TaskModelType::Seedream5Lite),
|
||||
|
||||
+1
@@ -37,6 +37,7 @@ pub async fn handle_text_to_image_artcraft(
|
||||
TextToImageModel::FluxPro11Ultra => handle_text_to_image_artcraft_via_router(request, app_env_configs, storyteller_creds_manager, CommonImageModel::FluxPro11Ultra, GenerationModel::FluxPro11Ultra).await,
|
||||
TextToImageModel::GptImage1 => handle_artcraft_gpt_image_1_text_to_image(request, app_env_configs, storyteller_creds_manager).await,
|
||||
TextToImageModel::GptImage1p5 => handle_text_to_image_artcraft_via_router(request, app_env_configs, storyteller_creds_manager, CommonImageModel::GptImage1p5, GenerationModel::GptImage1p5).await,
|
||||
TextToImageModel::GptImage2 => handle_text_to_image_artcraft_via_router(request, app_env_configs, storyteller_creds_manager, CommonImageModel::GptImage2, GenerationModel::GptImage2).await,
|
||||
TextToImageModel::Gemini25Flash | TextToImageModel::NanoBanana => {
|
||||
handle_text_to_image_artcraft_via_router(request, app_env_configs, storyteller_creds_manager, CommonImageModel::NanoBanana, GenerationModel::NanoBanana).await
|
||||
},
|
||||
|
||||
+7
-1
@@ -11,6 +11,7 @@ use crate::services::storyteller::state::storyteller_credential_manager::Storyte
|
||||
use artcraft_router::api::common_aspect_ratio::CommonAspectRatio as RouterCommonAspectRatio;
|
||||
use artcraft_router::api::common_image_model::CommonImageModel;
|
||||
use artcraft_router::api::common_resolution::CommonResolution as RouterCommonResolution;
|
||||
use artcraft_router::api::common_quality::CommonQuality as RouterCommonQuality;
|
||||
use artcraft_router::api::image_list_ref::ImageListRef;
|
||||
use artcraft_router::api::provider::Provider;
|
||||
use artcraft_router::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
|
||||
@@ -42,6 +43,7 @@ pub(super) async fn handle_text_to_image_artcraft_via_router(
|
||||
|
||||
let aspect_ratio = get_aspect_ratio_t2i(request);
|
||||
let resolution = get_resolution_t2i(request);
|
||||
let quality = get_quality_t2i(request);
|
||||
|
||||
let router_request = GenerateImageRequest {
|
||||
model,
|
||||
@@ -50,7 +52,7 @@ pub(super) async fn handle_text_to_image_artcraft_via_router(
|
||||
image_inputs,
|
||||
resolution,
|
||||
aspect_ratio,
|
||||
quality: None, // TODO: Add quality
|
||||
quality,
|
||||
image_batch_count: request.number_images.map(|n| n as u16),
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
|
||||
generation_mode_mismatch_strategy: None,
|
||||
@@ -117,6 +119,10 @@ fn get_resolution_t2i(request: &EnqueueTextToImageRequest) -> Option<RouterCommo
|
||||
None
|
||||
}
|
||||
|
||||
fn get_quality_t2i(request: &EnqueueTextToImageRequest) -> Option<RouterCommonQuality> {
|
||||
request.quality.map(|quality| quality.to_artcraft_router_type())
|
||||
}
|
||||
|
||||
fn convert_desktop_aspect_ratio(ar: CommonAspectRatio2) -> RouterCommonAspectRatio {
|
||||
match ar {
|
||||
CommonAspectRatio2::Auto => RouterCommonAspectRatio::Auto,
|
||||
|
||||
+7
@@ -1,4 +1,5 @@
|
||||
use crate::core::api_adapters::aspect_ratio::common_aspect_ratio::CommonAspectRatio;
|
||||
use crate::core::api_adapters::quality::common_quality::CommonQuality;
|
||||
use crate::core::api_adapters::resolution::common_resolution::CommonResolution;
|
||||
use crate::core::commands::enqueue::common::notify_frontend_of_errors::notify_frontend_of_errors;
|
||||
use crate::core::commands::enqueue::generate_error::{BadInputReason, GenerateError, MissingCredentialsReason};
|
||||
@@ -59,6 +60,8 @@ pub enum TextToImageModel {
|
||||
GptImage1,
|
||||
#[serde(rename = "gpt_image_1p5")]
|
||||
GptImage1p5,
|
||||
#[serde(rename = "gpt_image_2")]
|
||||
GptImage2,
|
||||
#[serde(rename = "gemini_25_flash")]
|
||||
Gemini25Flash,
|
||||
#[serde(rename = "nano_banana")]
|
||||
@@ -99,6 +102,10 @@ pub struct EnqueueTextToImageRequest {
|
||||
/// Not all models support each of these resolutions, but we can choose or interpolate sensibly.
|
||||
pub common_resolution: Option<CommonResolution>,
|
||||
|
||||
/// Quality.
|
||||
/// Tends to be used by OpenAI models.
|
||||
pub quality: Option<CommonQuality>,
|
||||
|
||||
/// Aspect ratio.
|
||||
#[deprecated(note="use common_aspect_ratio")]
|
||||
pub aspect_ratio: Option<TextToImageSize>,
|
||||
|
||||
+1
@@ -11,6 +11,7 @@ pub fn text_to_image_model_to_model_type(model: TextToImageModel) -> CommonModel
|
||||
TextToImageModel::Recraft3 => CommonModelType::Recraft3,
|
||||
TextToImageModel::GptImage1 => CommonModelType::GptImage1,
|
||||
TextToImageModel::GptImage1p5 => CommonModelType::GptImage1p5,
|
||||
TextToImageModel::GptImage2 => CommonModelType::GptImage2,
|
||||
TextToImageModel::Gemini25Flash => CommonModelType::NanoBanana,
|
||||
TextToImageModel::NanoBanana => CommonModelType::NanoBanana,
|
||||
TextToImageModel::NanoBanana2 => CommonModelType::NanoBanana2,
|
||||
|
||||
@@ -33,6 +33,8 @@ pub enum GenerationModel {
|
||||
GptImage1,
|
||||
#[serde(rename = "gpt_image_1p5")]
|
||||
GptImage1p5,
|
||||
#[serde(rename = "gpt_image_2")]
|
||||
GptImage2,
|
||||
#[serde(rename = "grok_image")]
|
||||
GrokImage,
|
||||
#[serde(rename = "gemini_25_flash")]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
|
||||
/// The version of the ArtCraft desktop app reported in the UI and via Analytics.
|
||||
/// This should match the CI builds.
|
||||
pub const ARTCRAFT_VERSION: &str = "0.28.0";
|
||||
pub const ARTCRAFT_VERSION: &str = "0.30.0";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2.0.0-rc",
|
||||
"productName": "ArtCraft",
|
||||
"version": "0.28.0",
|
||||
"version": "0.30.0",
|
||||
"identifier": "ai.artcraft.app",
|
||||
"build": {
|
||||
"frontendDist": "../../../frontend/apps/artcraft/dist",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2.0.0-rc",
|
||||
"productName": "ArtCraft",
|
||||
"version": "0.28.0",
|
||||
"version": "0.30.0",
|
||||
"identifier": "ai.artcraft.app",
|
||||
"build": {
|
||||
"frontendDist": "../../../frontend/apps/artcraft/dist",
|
||||
|
||||
+2
-1
@@ -111,7 +111,8 @@ pub async fn list_media_files_by_batch_token(args: ListMediaFileByBatchArgs<'_>)
|
||||
let query = query.build_query_as::<MediaFileListItemInternal>();
|
||||
let results = query.fetch_all(args.mysql_pool).await?;
|
||||
|
||||
let number_of_pages = (row_count_result / args.page_size as i64) as usize;
|
||||
let number_of_pages = 1 + (row_count_result / args.page_size as i64) as usize;
|
||||
|
||||
let results = results.into_iter()
|
||||
.map(|record| {
|
||||
MediaFileListItem {
|
||||
|
||||
+2
-1
@@ -128,7 +128,8 @@ pub async fn list_media_files_for_user(args: ListMediaFileForUserArgs<'_>) -> An
|
||||
let query = query.build_query_as::<MediaFileListItemInternal>();
|
||||
let results = query.fetch_all(args.mysql_pool).await?;
|
||||
|
||||
let number_of_pages = (row_count_result / args.page_size as i64) as usize;
|
||||
let number_of_pages = 1 + (row_count_result / args.page_size as i64) as usize;
|
||||
|
||||
let results = results.into_iter()
|
||||
.map(|record| {
|
||||
MediaFileListItem {
|
||||
|
||||
@@ -39,6 +39,8 @@ pub enum TaskModelType {
|
||||
GptImage1,
|
||||
#[serde(rename = "gpt_image_1p5")]
|
||||
GptImage1p5,
|
||||
#[serde(rename = "gpt_image_2")]
|
||||
GptImage2,
|
||||
#[serde(rename = "seedream_4")]
|
||||
Seedream4,
|
||||
#[serde(rename = "seedream_4p5")]
|
||||
@@ -134,6 +136,7 @@ impl TaskModelType {
|
||||
Self::NanoBananaPro => "nano_banana_pro",
|
||||
Self::GptImage1 => "gpt_image_1",
|
||||
Self::GptImage1p5 => "gpt_image_1p5",
|
||||
Self::GptImage2 => "gpt_image_2",
|
||||
Self::Seedream4 => "seedream_4",
|
||||
Self::Seedream4p5 => "seedream_4p5",
|
||||
Self::Seedream5Lite => "seedream_5_lite",
|
||||
@@ -187,6 +190,7 @@ impl TaskModelType {
|
||||
"nano_banana_pro" => Ok(Self::NanoBananaPro),
|
||||
"gpt_image_1" => Ok(Self::GptImage1),
|
||||
"gpt_image_1p5" => Ok(Self::GptImage1p5),
|
||||
"gpt_image_2" => Ok(Self::GptImage2),
|
||||
"seedream_4" => Ok(Self::Seedream4),
|
||||
"seedream_4p5" => Ok(Self::Seedream4p5),
|
||||
"seedream_5_lite" => Ok(Self::Seedream5Lite),
|
||||
@@ -243,6 +247,7 @@ impl TaskModelType {
|
||||
Self::NanoBananaPro,
|
||||
Self::GptImage1,
|
||||
Self::GptImage1p5,
|
||||
Self::GptImage2,
|
||||
Self::Seedream4,
|
||||
Self::Seedream4p5,
|
||||
Self::Seedream5Lite,
|
||||
@@ -306,6 +311,7 @@ mod tests {
|
||||
assert_serialization(TaskModelType::NanoBananaPro, "nano_banana_pro");
|
||||
assert_serialization(TaskModelType::GptImage1, "gpt_image_1");
|
||||
assert_serialization(TaskModelType::GptImage1p5, "gpt_image_1p5");
|
||||
assert_serialization(TaskModelType::GptImage2, "gpt_image_2");
|
||||
assert_serialization(TaskModelType::Seedream4, "seedream_4");
|
||||
assert_serialization(TaskModelType::Seedream4p5, "seedream_4p5");
|
||||
assert_serialization(TaskModelType::Seedream5Lite, "seedream_5_lite");
|
||||
@@ -358,6 +364,7 @@ mod tests {
|
||||
assert_eq!(TaskModelType::NanoBananaPro.to_str(), "nano_banana_pro");
|
||||
assert_eq!(TaskModelType::GptImage1.to_str(), "gpt_image_1");
|
||||
assert_eq!(TaskModelType::GptImage1p5.to_str(), "gpt_image_1p5");
|
||||
assert_eq!(TaskModelType::GptImage2.to_str(), "gpt_image_2");
|
||||
assert_eq!(TaskModelType::Seedream4.to_str(), "seedream_4");
|
||||
assert_eq!(TaskModelType::Seedream4p5.to_str(), "seedream_4p5");
|
||||
assert_eq!(TaskModelType::Seedream5Lite.to_str(), "seedream_5_lite");
|
||||
@@ -410,6 +417,7 @@ mod tests {
|
||||
assert_eq!(TaskModelType::from_str("nano_banana_pro").unwrap(), TaskModelType::NanoBananaPro);
|
||||
assert_eq!(TaskModelType::from_str("gpt_image_1").unwrap(), TaskModelType::GptImage1);
|
||||
assert_eq!(TaskModelType::from_str("gpt_image_1p5").unwrap(), TaskModelType::GptImage1p5);
|
||||
assert_eq!(TaskModelType::from_str("gpt_image_2").unwrap(), TaskModelType::GptImage2);
|
||||
assert_eq!(TaskModelType::from_str("seedream_4").unwrap(), TaskModelType::Seedream4);
|
||||
assert_eq!(TaskModelType::from_str("seedream_4p5").unwrap(), TaskModelType::Seedream4p5);
|
||||
assert_eq!(TaskModelType::from_str("seedream_5_lite").unwrap(), TaskModelType::Seedream5Lite);
|
||||
@@ -460,7 +468,7 @@ mod tests {
|
||||
#[test]
|
||||
fn all_variants() {
|
||||
let mut variants = TaskModelType::all_variants();
|
||||
assert_eq!(variants.len(), 45);
|
||||
assert_eq!(variants.len(), 46);
|
||||
// Image models
|
||||
assert_eq!(variants.pop_first(), Some(TaskModelType::Flux1Dev));
|
||||
assert_eq!(variants.pop_first(), Some(TaskModelType::Flux1Schnell));
|
||||
@@ -475,6 +483,7 @@ mod tests {
|
||||
assert_eq!(variants.pop_first(), Some(TaskModelType::NanoBananaPro));
|
||||
assert_eq!(variants.pop_first(), Some(TaskModelType::GptImage1));
|
||||
assert_eq!(variants.pop_first(), Some(TaskModelType::GptImage1p5));
|
||||
assert_eq!(variants.pop_first(), Some(TaskModelType::GptImage2));
|
||||
assert_eq!(variants.pop_first(), Some(TaskModelType::Seedream4));
|
||||
assert_eq!(variants.pop_first(), Some(TaskModelType::Seedream4p5));
|
||||
assert_eq!(variants.pop_first(), Some(TaskModelType::Seedream5Lite));
|
||||
|
||||
@@ -378,6 +378,10 @@ fn build_omni_gen_image_model_providers() -> Vec<OmniGenImageModelProviderDetail
|
||||
model: CommonImageModel::GptImage1p5,
|
||||
overrides: None,
|
||||
},
|
||||
OmniGenImageProviderModelDetails {
|
||||
model: CommonImageModel::GptImage2,
|
||||
overrides: None,
|
||||
},
|
||||
OmniGenImageProviderModelDetails {
|
||||
model: CommonImageModel::Seedream4,
|
||||
overrides: None,
|
||||
|
||||
+13
-2
@@ -9,6 +9,7 @@ use artcraft_api_defs::generate::cost_estimate::estimate_image_cost::{
|
||||
};
|
||||
use artcraft_router::api::common_aspect_ratio::CommonAspectRatio as RouterAspectRatio;
|
||||
use artcraft_router::api::common_image_model::CommonImageModel as RouterImageModel;
|
||||
use artcraft_router::api::common_quality::CommonQuality as RouterQuality;
|
||||
use artcraft_router::api::common_resolution::CommonResolution as RouterResolution;
|
||||
use artcraft_router::api::provider::Provider as RouterProvider;
|
||||
use artcraft_router::client::generation_mode_mismatch_strategy::GenerationModeMismatchStrategy;
|
||||
@@ -16,6 +17,7 @@ use artcraft_router::client::request_mismatch_mitigation_strategy::RequestMismat
|
||||
use artcraft_router::generate::generate_image::generate_image_request::GenerateImageRequest;
|
||||
use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
|
||||
use enums::common::generation::common_image_model::CommonImageModel;
|
||||
use enums::common::generation::common_quality::CommonQuality;
|
||||
use enums::common::generation::common_resolution::CommonResolution;
|
||||
use enums::common::generation_provider::GenerationProvider;
|
||||
|
||||
@@ -37,6 +39,7 @@ pub async fn estimate_image_cost_handler(
|
||||
let router_model = map_image_model(request.model)?;
|
||||
let router_aspect_ratio = request.aspect_ratio.map(map_aspect_ratio);
|
||||
let router_resolution = request.resolution.map(map_resolution);
|
||||
let router_quality = request.quality.map(map_quality);
|
||||
|
||||
let router_request = GenerateImageRequest {
|
||||
model: router_model,
|
||||
@@ -45,7 +48,7 @@ pub async fn estimate_image_cost_handler(
|
||||
image_inputs: None, // TODO: Only some models charge for this - we'll need to add later.
|
||||
resolution: router_resolution,
|
||||
aspect_ratio: router_aspect_ratio,
|
||||
quality: None,
|
||||
quality: router_quality,
|
||||
image_batch_count: request.image_batch_count,
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayLessDowngrade,
|
||||
generation_mode_mismatch_strategy: Some(GenerationModeMismatchStrategy::GenerateAnyway),
|
||||
@@ -132,13 +135,13 @@ fn map_image_model(model: CommonImageModel) -> Result<RouterImageModel, HandlerE
|
||||
CommonImageModel::FluxPro11Ultra => RouterImageModel::FluxPro11Ultra,
|
||||
CommonImageModel::GptImage1 => RouterImageModel::GptImage1,
|
||||
CommonImageModel::GptImage1p5 => RouterImageModel::GptImage1p5,
|
||||
CommonImageModel::GptImage2 => RouterImageModel::GptImage2,
|
||||
CommonImageModel::NanoBanana => RouterImageModel::NanoBanana,
|
||||
CommonImageModel::NanoBanana2 => RouterImageModel::NanoBanana2,
|
||||
CommonImageModel::NanoBananaPro => RouterImageModel::NanoBananaPro,
|
||||
CommonImageModel::Seedream4 => RouterImageModel::Seedream4,
|
||||
CommonImageModel::Seedream4p5 => RouterImageModel::Seedream4p5,
|
||||
CommonImageModel::Seedream5Lite => RouterImageModel::Seedream5Lite,
|
||||
CommonImageModel::GptImage2 => RouterImageModel::GptImage2,
|
||||
};
|
||||
Ok(router_model)
|
||||
}
|
||||
@@ -178,3 +181,11 @@ fn map_resolution(res: CommonResolution) -> RouterResolution {
|
||||
CommonResolution::TenEightyP => RouterResolution::TenEightyP,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_quality(res: CommonQuality) -> RouterQuality {
|
||||
match res {
|
||||
CommonQuality::Low => RouterQuality::Low,
|
||||
CommonQuality::Medium => RouterQuality::Medium,
|
||||
CommonQuality::High => RouterQuality::High,
|
||||
}
|
||||
}
|
||||
|
||||
+32
-19
@@ -6,10 +6,11 @@ use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost
|
||||
use artcraft_api_defs::omni_gen::cost_response::omni_gen_video_cost_response::OmniGenVideoCostResponse;
|
||||
use artcraft_router::api::provider::Provider;
|
||||
use artcraft_router::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use artcraft_router::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
|
||||
use log::warn;
|
||||
|
||||
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
|
||||
use crate::http_server::endpoints::omni_gen::generate::video::distill_helper::hydrate_to_router_request::hydrate_to_router_request;
|
||||
use crate::http_server::endpoints::omni_gen::generate::video::helpers::hydrate_router_request::hydrate_to_router_request;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
/// Estimate the cost of a video generation.
|
||||
@@ -29,27 +30,39 @@ pub async fn omni_gen_video_cost_handler(
|
||||
request: Json<OmniGenVideoCostAndGenerateRequest>,
|
||||
_server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<Json<OmniGenVideoCostResponse>, AdvancedCommonWebError> {
|
||||
let mut generate_request = hydrate_to_router_request(&request)?;
|
||||
generate_request.provider = Provider::Artcraft;
|
||||
let mut builder = hydrate_to_router_request(&request)?;
|
||||
builder.provider = Provider::Artcraft;
|
||||
|
||||
let plan = match generate_request.build() {
|
||||
Ok(plan) => plan,
|
||||
Err(ArtcraftRouterError::UnsupportedModel(_)) => {
|
||||
generate_request.provider = Provider::Fal;
|
||||
generate_request.build()
|
||||
.map_err(|e| {
|
||||
warn!("Failed to build Fal cost plan: {}", e);
|
||||
AdvancedCommonWebError::from_error(e)
|
||||
})?
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to build cost plan: {}", e);
|
||||
return Err(AdvancedCommonWebError::from_error(e));
|
||||
}
|
||||
let estimate = if builder.use_new_builder() {
|
||||
builder.build2()
|
||||
.map_err(|e| {
|
||||
warn!("Failed to build2 cost estimate: {}", e);
|
||||
AdvancedCommonWebError::from_error(e)
|
||||
})?
|
||||
.estimate_cost()
|
||||
.map_err(|e| {
|
||||
warn!("Failed to estimate cost (v2): {}", e);
|
||||
AdvancedCommonWebError::from_error(e)
|
||||
})?
|
||||
} else {
|
||||
let plan = match builder.build() {
|
||||
Ok(plan) => plan,
|
||||
Err(ArtcraftRouterError::UnsupportedModel(_)) => {
|
||||
builder.provider = Provider::Fal;
|
||||
builder.build()
|
||||
.map_err(|e| {
|
||||
warn!("Failed to build Fal cost plan: {}", e);
|
||||
AdvancedCommonWebError::from_error(e)
|
||||
})?
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to build cost plan: {}", e);
|
||||
return Err(AdvancedCommonWebError::from_error(e));
|
||||
}
|
||||
};
|
||||
plan.estimate_costs()
|
||||
};
|
||||
|
||||
let estimate = plan.estimate_costs();
|
||||
|
||||
Ok(Json(OmniGenVideoCostResponse {
|
||||
success: true,
|
||||
cost_in_credits: estimate.cost_in_credits,
|
||||
|
||||
-196
@@ -1,196 +0,0 @@
|
||||
//! Distillation step for the omni-gen video endpoints.
|
||||
//!
|
||||
//! Takes a raw [`OmniGenVideoCostAndGenerateRequest`] plus a (pre-computed)
|
||||
//! `MediaFileToken -> Url` map and produces a fully self-contained
|
||||
//! [`DistilledVideoRequest`] holding:
|
||||
//! - the (private) router request, kept for inspection in tests / debugging
|
||||
//! - the [`VideoGenerationCostEstimate`] (Artcraft provider, what we bill on)
|
||||
//! - the [`VideoGenerationPlan`] (execution provider, what we actually execute)
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use log::warn;
|
||||
use url::Url;
|
||||
|
||||
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
|
||||
use artcraft_router::api::audio_list_ref::AudioListRef;
|
||||
use artcraft_router::api::image_list_ref::ImageListRef;
|
||||
use artcraft_router::api::image_ref::ImageRef;
|
||||
use artcraft_router::api::provider::Provider;
|
||||
use artcraft_router::api::video_list_ref::VideoListRef;
|
||||
use artcraft_router::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use artcraft_router::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
|
||||
use artcraft_router::generate::generate_video::video_generation_plan::VideoGenerationPlan;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
|
||||
|
||||
use super::distill_helper::hydrate_to_router_request::hydrate_to_router_request;
|
||||
|
||||
/// Self-contained, owned representation of a fully-distilled omni-gen video
|
||||
/// request: the router request, the bill-on cost estimate, and the executable
|
||||
/// plan, all in one place.
|
||||
pub struct DistilledVideoRequest {
|
||||
request: GenerateVideoRequestBuilder,
|
||||
|
||||
/// Cost estimate as computed by the Artcraft provider — this is what we bill on.
|
||||
pub cost: VideoGenerationCostEstimate,
|
||||
|
||||
/// Execution plan as computed by the execution provider — what we hand to the router.
|
||||
pub plan: VideoGenerationPlan,
|
||||
|
||||
/// The provider used for execution (Fal, Seedance2Pro, etc.).
|
||||
pub execution_provider: Provider,
|
||||
}
|
||||
|
||||
impl DistilledVideoRequest {
|
||||
/// Borrow the execution plan.
|
||||
pub fn plan(&self) -> &VideoGenerationPlan {
|
||||
&self.plan
|
||||
}
|
||||
|
||||
/// Borrow the underlying router request. Useful for tests / debugging.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn request(&self) -> &GenerateVideoRequestBuilder {
|
||||
&self.request
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a [`DistilledVideoRequest`] from a raw API request and a pre-computed
|
||||
/// `MediaFileToken -> Url` hydration map.
|
||||
pub fn distill_video_request(
|
||||
request: &OmniGenVideoCostAndGenerateRequest,
|
||||
media_file_hydration_map: Option<&HashMap<MediaFileToken, Url>>,
|
||||
execution_provider: Provider,
|
||||
) -> Result<DistilledVideoRequest, AdvancedCommonWebError> {
|
||||
// 1. Convert the raw API request into a router request.
|
||||
let initial = hydrate_to_router_request(request)?;
|
||||
|
||||
// 2. Cost estimate (Artcraft provider).
|
||||
// `initial` already has media fields in token form (from hydrate_to_router_request),
|
||||
// which is exactly what the Artcraft cost builder needs. Just swap the provider.
|
||||
let cost: VideoGenerationCostEstimate = {
|
||||
let cost_request = GenerateVideoRequestBuilder {
|
||||
provider: Provider::Artcraft,
|
||||
..initial.clone()
|
||||
};
|
||||
let cost_plan = cost_request.build().map_err(|e| {
|
||||
warn!("Failed to build cost plan during video distillation: {}", e);
|
||||
AdvancedCommonWebError::from_error(e)
|
||||
})?;
|
||||
cost_plan.estimate_costs()
|
||||
};
|
||||
|
||||
// 3. Resolve media tokens to URLs for the execution request.
|
||||
let start_frame_url = resolve_single_media_token(
|
||||
request.start_frame_image_media_token.as_ref(),
|
||||
media_file_hydration_map,
|
||||
)?;
|
||||
let end_frame_url = resolve_single_media_token(
|
||||
request.end_frame_image_media_token.as_ref(),
|
||||
media_file_hydration_map,
|
||||
)?;
|
||||
let reference_image_urls = resolve_media_token_list(
|
||||
request.reference_image_media_tokens.as_ref(),
|
||||
media_file_hydration_map,
|
||||
)?;
|
||||
let reference_video_urls = resolve_media_token_list(
|
||||
request.reference_video_media_tokens.as_ref(),
|
||||
media_file_hydration_map,
|
||||
)?;
|
||||
let reference_audio_urls = resolve_media_token_list(
|
||||
request.reference_audio_media_tokens.as_ref(),
|
||||
media_file_hydration_map,
|
||||
)?;
|
||||
|
||||
// 4. Build the execution request with resolved URLs.
|
||||
let exec_request = GenerateVideoRequestBuilder {
|
||||
model: initial.model,
|
||||
provider: execution_provider,
|
||||
prompt: initial.prompt,
|
||||
negative_prompt: initial.negative_prompt,
|
||||
start_frame: start_frame_url.map(ImageRef::Url),
|
||||
end_frame: end_frame_url.map(ImageRef::Url),
|
||||
reference_images: reference_image_urls.map(ImageListRef::Urls),
|
||||
reference_videos: reference_video_urls.map(VideoListRef::Urls),
|
||||
reference_audio: reference_audio_urls.map(AudioListRef::Urls),
|
||||
reference_character_tokens: None,
|
||||
resolution: initial.resolution,
|
||||
aspect_ratio: initial.aspect_ratio,
|
||||
duration_seconds: initial.duration_seconds,
|
||||
video_batch_count: initial.video_batch_count,
|
||||
generate_audio: initial.generate_audio,
|
||||
request_mismatch_mitigation_strategy: initial.request_mismatch_mitigation_strategy,
|
||||
idempotency_token: initial.idempotency_token,
|
||||
};
|
||||
|
||||
// 5. Build the execution plan.
|
||||
let plan = exec_request.build().map_err(|e| {
|
||||
warn!("Failed to build video generation plan during distillation: {}", e);
|
||||
AdvancedCommonWebError::from_error(e)
|
||||
})?;
|
||||
|
||||
Ok(DistilledVideoRequest {
|
||||
request: exec_request,
|
||||
cost,
|
||||
plan,
|
||||
execution_provider,
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolve a single optional media token to its URL string.
|
||||
fn resolve_single_media_token(
|
||||
token: Option<&MediaFileToken>,
|
||||
hydration_map: Option<&HashMap<MediaFileToken, Url>>,
|
||||
) -> Result<Option<String>, AdvancedCommonWebError> {
|
||||
let token = match token {
|
||||
Some(t) => t,
|
||||
None => return Ok(None),
|
||||
};
|
||||
|
||||
let map = hydration_map.ok_or_else(|| {
|
||||
AdvancedCommonWebError::BadInputWithSimpleMessage(
|
||||
"media token supplied but no hydration map was provided".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
match map.get(token) {
|
||||
Some(url) => Ok(Some(url.to_string())),
|
||||
None => Err(AdvancedCommonWebError::BadInputWithSimpleMessage(format!(
|
||||
"Media token not found in hydration map: {:?}",
|
||||
token
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a list of media tokens to their URL strings.
|
||||
fn resolve_media_token_list(
|
||||
tokens: Option<&Vec<MediaFileToken>>,
|
||||
hydration_map: Option<&HashMap<MediaFileToken, Url>>,
|
||||
) -> Result<Option<Vec<String>>, AdvancedCommonWebError> {
|
||||
let tokens = match tokens {
|
||||
Some(tokens) if !tokens.is_empty() => tokens,
|
||||
_ => return Ok(None),
|
||||
};
|
||||
|
||||
let map = hydration_map.ok_or_else(|| {
|
||||
AdvancedCommonWebError::BadInputWithSimpleMessage(
|
||||
"media tokens supplied but no hydration map was provided".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
let mut urls: Vec<String> = Vec::with_capacity(tokens.len());
|
||||
for token in tokens {
|
||||
match map.get(token) {
|
||||
Some(url) => urls.push(url.to_string()),
|
||||
None => {
|
||||
return Err(AdvancedCommonWebError::BadInputWithSimpleMessage(format!(
|
||||
"Media token not found in hydration map: {:?}",
|
||||
token
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Some(urls))
|
||||
}
|
||||
-55
@@ -1,55 +0,0 @@
|
||||
//! Fal provider execution path.
|
||||
|
||||
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;
|
||||
|
||||
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
use super::super::distill_video_request::DistilledVideoRequest;
|
||||
use super::execute_generation::GenerationResult;
|
||||
|
||||
/// Execute generation via Fal (the existing path).
|
||||
pub(super) async fn execute_generation_fal(
|
||||
distilled: &DistilledVideoRequest,
|
||||
request: &OmniGenVideoCostAndGenerateRequest,
|
||||
server_state: &ServerState,
|
||||
) -> Result<GenerationResult, AdvancedCommonWebError> {
|
||||
let fal_client = RouterFalClient::new(
|
||||
server_state.fal.api_key.clone(),
|
||||
server_state.fal.webhook_url.clone(),
|
||||
);
|
||||
let router_client = RouterClient::Fal(fal_client);
|
||||
|
||||
let generation_response = distilled.plan().generate_video(&router_client)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
warn!("Video generation failed (Fal): {:?}", e);
|
||||
AdvancedCommonWebError::from_error(e)
|
||||
})?;
|
||||
|
||||
let external_job_id = match &generation_response {
|
||||
GenerateVideoResponse::Artcraft(p) => p.inference_job_token.as_str().to_string(),
|
||||
GenerateVideoResponse::Muapi(p) => p.request_id.as_str().to_string(),
|
||||
GenerateVideoResponse::Seedance2Pro(p) => p.order_id.clone(),
|
||||
GenerateVideoResponse::Fal(p) => p.request_id.clone().unwrap_or_default(),
|
||||
};
|
||||
|
||||
let generation_mode = if request.start_frame_image_media_token.is_some() {
|
||||
CommonGenerationMode::Keyframe
|
||||
} else {
|
||||
CommonGenerationMode::Text
|
||||
};
|
||||
|
||||
Ok(GenerationResult {
|
||||
external_job_id,
|
||||
is_seedance2pro: false,
|
||||
maybe_seedance_order_ids: None,
|
||||
generation_mode,
|
||||
})
|
||||
}
|
||||
-68
@@ -1,68 +0,0 @@
|
||||
//! Main entrypoint for video generation execution.
|
||||
//!
|
||||
//! Routes to the appropriate provider (Fal or Seedance2Pro/Kinovi)
|
||||
//! based on the distilled request's `execution_provider`.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use url::Url;
|
||||
|
||||
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
|
||||
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;
|
||||
|
||||
use super::super::distill_video_request::DistilledVideoRequest;
|
||||
use super::execute_fal::execute_generation_fal;
|
||||
use super::execute_kinovi::execute_generation_kinovi;
|
||||
|
||||
/// Result of a successful generation, regardless of provider.
|
||||
pub struct GenerationResult {
|
||||
/// The external job ID used to track the generation (first order_id for Seedance).
|
||||
pub external_job_id: String,
|
||||
|
||||
/// Whether this is a Seedance2Pro generation (changes DB insertion path).
|
||||
pub is_seedance2pro: bool,
|
||||
|
||||
/// For Seedance2Pro batch jobs, the list of all order IDs.
|
||||
pub maybe_seedance_order_ids: Option<Vec<String>>,
|
||||
|
||||
/// The generation mode (Text, Keyframe, Reference).
|
||||
pub generation_mode: CommonGenerationMode,
|
||||
}
|
||||
|
||||
/// Execute video generation via the appropriate provider.
|
||||
pub async fn execute_generation(
|
||||
distilled: &DistilledVideoRequest,
|
||||
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 => {
|
||||
execute_generation_kinovi(
|
||||
distilled,
|
||||
request,
|
||||
server_state,
|
||||
media_file_hydration_map,
|
||||
kinovi_character_ids,
|
||||
maybe_wallet_ledger_entry_token,
|
||||
mysql_connection,
|
||||
).await
|
||||
}
|
||||
_ => {
|
||||
execute_generation_fal(
|
||||
distilled,
|
||||
request,
|
||||
server_state,
|
||||
).await
|
||||
}
|
||||
}
|
||||
}
|
||||
-3
@@ -1,3 +0,0 @@
|
||||
mod execute_fal;
|
||||
mod execute_kinovi;
|
||||
pub mod execute_generation;
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
//! Shared wallet billing logic for both video generation pipelines.
|
||||
|
||||
use log::info;
|
||||
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
|
||||
use tokens::tokens::users::UserToken;
|
||||
use tokens::tokens::wallet_ledger_entries::WalletLedgerEntryToken;
|
||||
|
||||
use crate::billing::wallets::attempt_wallet_deduction::attempt_wallet_deduction_else_common_web_error;
|
||||
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
|
||||
|
||||
pub struct BillWalletResult {
|
||||
pub apriori_job_token: InferenceJobToken,
|
||||
pub maybe_wallet_ledger_entry_token: Option<WalletLedgerEntryToken>,
|
||||
}
|
||||
|
||||
/// Generate an apriori job token and bill the user's wallet for the given cost.
|
||||
///
|
||||
/// If cost is 0, no wallet deduction is made but the apriori token is still generated.
|
||||
pub async fn bill_wallet(
|
||||
user_token: &UserToken,
|
||||
cost: u64,
|
||||
mysql_connection: &mut sqlx::pool::PoolConnection<sqlx::MySql>,
|
||||
) -> Result<BillWalletResult, AdvancedCommonWebError> {
|
||||
let apriori_job_token = InferenceJobToken::generate();
|
||||
|
||||
info!("Charging wallet: {} credits", cost);
|
||||
|
||||
let maybe_wallet_ledger_entry_token = if cost > 0 {
|
||||
let deduction_result = attempt_wallet_deduction_else_common_web_error(
|
||||
user_token,
|
||||
Some(apriori_job_token.as_str()),
|
||||
cost,
|
||||
mysql_connection,
|
||||
).await?;
|
||||
Some(deduction_result.ledger_entry_token)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(BillWalletResult {
|
||||
apriori_job_token,
|
||||
maybe_wallet_ledger_entry_token,
|
||||
})
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
//! Build a RouterClient for the given provider from server state.
|
||||
|
||||
use artcraft_router::api::provider::Provider;
|
||||
use artcraft_router::client::router_client::RouterClient;
|
||||
use artcraft_router::client::router_fal_client::RouterFalClient;
|
||||
use artcraft_router::client::router_seedance2pro_client::RouterSeedance2ProClient;
|
||||
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
|
||||
|
||||
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
pub fn build_router_client(
|
||||
provider: Provider,
|
||||
server_state: &ServerState,
|
||||
) -> Result<RouterClient, AdvancedCommonWebError> {
|
||||
match provider {
|
||||
Provider::Seedance2Pro => {
|
||||
let session = Seedance2ProSession::from_cookies_string(
|
||||
server_state.seedance2pro.cookies.clone()
|
||||
);
|
||||
Ok(RouterClient::Seedance2Pro(RouterSeedance2ProClient::new(session)))
|
||||
}
|
||||
Provider::Fal => {
|
||||
let fal_client = RouterFalClient::new(
|
||||
server_state.fal.api_key.clone(),
|
||||
server_state.fal.webhook_url.clone(),
|
||||
);
|
||||
Ok(RouterClient::Fal(fal_client))
|
||||
}
|
||||
other => {
|
||||
Err(AdvancedCommonWebError::server_error_with_message(
|
||||
&format!("Unsupported provider for video generation: {:?}", other),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
+47
-44
@@ -14,6 +14,53 @@ use enums::common::generation::common_aspect_ratio::CommonAspectRatio as CommonA
|
||||
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
|
||||
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
|
||||
|
||||
pub fn hydrate_to_router_request(
|
||||
request: &OmniGenVideoCostAndGenerateRequest,
|
||||
) -> Result<GenerateVideoRequestBuilder, AdvancedCommonWebError> {
|
||||
let api_model = request.model
|
||||
.as_ref()
|
||||
.ok_or_else(|| AdvancedCommonWebError::BadInputWithSimpleMessage(
|
||||
"model is required".to_string(),
|
||||
))?;
|
||||
|
||||
let model = convert_model(api_model)?;
|
||||
|
||||
let aspect_ratio = request.aspect_ratio
|
||||
.as_ref()
|
||||
.map(convert_aspect_ratio)
|
||||
.transpose()?;
|
||||
|
||||
let resolution = request.resolution
|
||||
.as_ref()
|
||||
.map(convert_resolution)
|
||||
.transpose()?;
|
||||
|
||||
Ok(GenerateVideoRequestBuilder {
|
||||
model,
|
||||
provider: Provider::Artcraft,
|
||||
prompt: request.prompt.clone(),
|
||||
negative_prompt: request.negative_prompt.clone(),
|
||||
start_frame: request.start_frame_image_media_token.clone()
|
||||
.map(ImageRef::MediaFileToken),
|
||||
end_frame: request.end_frame_image_media_token.clone()
|
||||
.map(ImageRef::MediaFileToken),
|
||||
reference_images: request.reference_image_media_tokens.clone()
|
||||
.map(ImageListRef::MediaFileTokens),
|
||||
reference_videos: request.reference_video_media_tokens.clone()
|
||||
.map(VideoListRef::MediaFileTokens),
|
||||
reference_audio: request.reference_audio_media_tokens.clone()
|
||||
.map(AudioListRef::MediaFileTokens),
|
||||
reference_character_tokens: None,
|
||||
resolution,
|
||||
aspect_ratio,
|
||||
duration_seconds: request.duration_seconds,
|
||||
video_batch_count: request.video_batch_count,
|
||||
generate_audio: request.generate_audio,
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
|
||||
idempotency_token: request.idempotency_token.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
fn convert_model(
|
||||
model: &CommonVideoModelEnum,
|
||||
) -> Result<CommonVideoModelRouter, AdvancedCommonWebError> {
|
||||
@@ -46,47 +93,3 @@ fn convert_resolution(
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn hydrate_to_router_request(
|
||||
request: &OmniGenVideoCostAndGenerateRequest,
|
||||
) -> Result<GenerateVideoRequestBuilder, AdvancedCommonWebError> {
|
||||
let api_model = request.model.as_ref()
|
||||
.ok_or_else(|| AdvancedCommonWebError::BadInputWithSimpleMessage(
|
||||
"model is required".to_string(),
|
||||
))?;
|
||||
|
||||
let model = convert_model(api_model)?;
|
||||
|
||||
let aspect_ratio = request.aspect_ratio.as_ref()
|
||||
.map(convert_aspect_ratio)
|
||||
.transpose()?;
|
||||
|
||||
let resolution = request.resolution.as_ref()
|
||||
.map(convert_resolution)
|
||||
.transpose()?;
|
||||
|
||||
Ok(GenerateVideoRequestBuilder {
|
||||
model,
|
||||
provider: Provider::Artcraft,
|
||||
prompt: request.prompt.clone(),
|
||||
negative_prompt: request.negative_prompt.clone(),
|
||||
start_frame: request.start_frame_image_media_token.clone()
|
||||
.map(ImageRef::MediaFileToken),
|
||||
end_frame: request.end_frame_image_media_token.clone()
|
||||
.map(ImageRef::MediaFileToken),
|
||||
reference_images: request.reference_image_media_tokens.clone()
|
||||
.map(ImageListRef::MediaFileTokens),
|
||||
reference_videos: request.reference_video_media_tokens.clone()
|
||||
.map(VideoListRef::MediaFileTokens),
|
||||
reference_audio: request.reference_audio_media_tokens.clone()
|
||||
.map(AudioListRef::MediaFileTokens),
|
||||
reference_character_tokens: None,
|
||||
resolution,
|
||||
aspect_ratio,
|
||||
duration_seconds: request.duration_seconds,
|
||||
video_batch_count: request.video_batch_count,
|
||||
generate_audio: request.generate_audio,
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
|
||||
idempotency_token: request.idempotency_token.clone(),
|
||||
})
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
pub mod bill_wallet;
|
||||
pub mod build_router_client;
|
||||
pub(crate) mod hydrate_router_request;
|
||||
pub mod pipeline_result;
|
||||
pub mod resolve_kinovi_character_ids;
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
use artcraft_router::generate::generate_video::generate_video_response::GenerateVideoResponse;
|
||||
|
||||
use crate::http_server::endpoints::omni_gen::generate::video::helpers::bill_wallet::BillWalletResult;
|
||||
|
||||
/// Both pipelines produce this, then the shared suffix handles DB writes.
|
||||
pub struct PipelineResult {
|
||||
pub billing: BillWalletResult,
|
||||
pub response: GenerateVideoResponse,
|
||||
}
|
||||
+11
-7
@@ -1,5 +1,7 @@
|
||||
//! Resolve character tokens to Kinovi character IDs for prompting.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use log::warn;
|
||||
use sqlx::MySql;
|
||||
|
||||
@@ -8,14 +10,14 @@ 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.
|
||||
/// Resolve character tokens to a map of CharacterToken → Kinovi character ID.
|
||||
///
|
||||
/// 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> {
|
||||
) -> Result<Option<HashMap<CharacterToken, String>>, AdvancedCommonWebError> {
|
||||
let tokens = match maybe_tokens {
|
||||
None => return Ok(None),
|
||||
Some(tokens) if tokens.is_empty() => return Ok(None),
|
||||
@@ -38,10 +40,12 @@ pub async fn resolve_kinovi_character_ids(
|
||||
}
|
||||
}
|
||||
|
||||
let ids: Vec<String> = characters.iter()
|
||||
.filter(|c| c.is_active)
|
||||
.filter_map(|c| c.kinovi_character_id.clone())
|
||||
.collect();
|
||||
let map: HashMap<CharacterToken, String> = characters.iter()
|
||||
.filter(|c| c.is_active)
|
||||
.filter_map(|c| {
|
||||
c.kinovi_character_id.as_ref().map(|kid| (c.token.clone(), kid.clone()))
|
||||
})
|
||||
.collect();
|
||||
|
||||
if ids.is_empty() { Ok(None) } else { Ok(Some(ids)) }
|
||||
if map.is_empty() { Ok(None) } else { Ok(Some(map)) }
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
use log::warn;
|
||||
|
||||
use enums::common::visibility::Visibility;
|
||||
use mysql_queries::queries::generic_inference::fal::insert_generic_inference_job_for_fal_queue::FalCategory;
|
||||
use mysql_queries::queries::generic_inference::fal::insert_generic_inference_job_for_fal_queue_with_apriori_job_token::{
|
||||
insert_generic_inference_job_for_fal_queue_with_apriori_job_token,
|
||||
InsertGenericInferenceForFalWithAprioriJobTokenArgs,
|
||||
};
|
||||
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
|
||||
|
||||
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
|
||||
|
||||
use super::shared_job_args::SharedJobArgs;
|
||||
|
||||
pub struct InsertFalJobArgs<'a, 'tx> {
|
||||
pub external_job_id: &'a str,
|
||||
pub shared: SharedJobArgs<'a, 'tx>,
|
||||
}
|
||||
|
||||
pub async fn insert_fal_job(args: InsertFalJobArgs<'_, '_>) -> Result<InferenceJobToken, AdvancedCommonWebError> {
|
||||
let InsertFalJobArgs {
|
||||
external_job_id,
|
||||
shared,
|
||||
} = args;
|
||||
|
||||
let db_result = insert_generic_inference_job_for_fal_queue_with_apriori_job_token(
|
||||
InsertGenericInferenceForFalWithAprioriJobTokenArgs {
|
||||
apriori_job_token: shared.apriori_job_token,
|
||||
uuid_idempotency_token: shared.idempotency_token,
|
||||
maybe_external_third_party_id: external_job_id,
|
||||
fal_category: FalCategory::VideoGeneration,
|
||||
maybe_prompt_token: shared.maybe_prompt_token,
|
||||
maybe_creator_user_token: Some(shared.user_token),
|
||||
maybe_avt_token: shared.maybe_avt_token,
|
||||
creator_ip_address: shared.ip_address,
|
||||
creator_set_visibility: Visibility::Public,
|
||||
mysql_executor: &mut **shared.transaction,
|
||||
maybe_inference_args: None,
|
||||
starting_job_status_override: None,
|
||||
maybe_frontend_failure_category: None,
|
||||
maybe_failure_reason: None,
|
||||
phantom: Default::default(),
|
||||
}
|
||||
).await;
|
||||
|
||||
match db_result {
|
||||
Ok(token) => Ok(token),
|
||||
Err(err) => {
|
||||
warn!("Error inserting fal inference job: {:?}", err);
|
||||
Err(AdvancedCommonWebError::from_error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
use log::{error, warn};
|
||||
|
||||
use enums::common::visibility::Visibility;
|
||||
use mysql_queries::queries::generic_inference::seedance2pro::insert_generic_inference_job_for_seedance2pro_queue_with_apriori_job_token::{
|
||||
insert_generic_inference_job_for_seedance2pro_queue_with_apriori_job_token,
|
||||
InsertGenericInferenceForSeedance2ProWithAprioriJobTokenArgs,
|
||||
};
|
||||
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
|
||||
use tokens::tokens::wallet_ledger_entries::WalletLedgerEntryToken;
|
||||
|
||||
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
|
||||
|
||||
use super::shared_job_args::SharedJobArgs;
|
||||
|
||||
pub struct InsertSeedance2proJobsArgs<'a, 'tx> {
|
||||
pub primary_order_id: &'a str,
|
||||
pub maybe_additional_order_ids: Option<&'a [String]>,
|
||||
pub maybe_wallet_ledger_entry_token: Option<&'a WalletLedgerEntryToken>,
|
||||
pub shared: SharedJobArgs<'a, 'tx>,
|
||||
}
|
||||
|
||||
pub async fn insert_seedance2pro_jobs(args: InsertSeedance2proJobsArgs<'_, '_>) -> Result<InferenceJobToken, AdvancedCommonWebError> {
|
||||
let InsertSeedance2proJobsArgs {
|
||||
primary_order_id,
|
||||
maybe_additional_order_ids,
|
||||
maybe_wallet_ledger_entry_token,
|
||||
mut shared,
|
||||
} = args;
|
||||
|
||||
// Build a deduplicated list with primary_order_id first.
|
||||
let mut all_order_ids = vec![primary_order_id.to_string()];
|
||||
if let Some(additional) = maybe_additional_order_ids {
|
||||
for id in additional {
|
||||
if id != primary_order_id {
|
||||
all_order_ids.push(id.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut all_job_tokens: Vec<InferenceJobToken> = Vec::with_capacity(all_order_ids.len());
|
||||
|
||||
for (i, order_id) in all_order_ids.iter().enumerate() {
|
||||
let job_token = if i == 0 { shared.apriori_job_token.clone() } else { InferenceJobToken::generate() };
|
||||
let idempotency_str = if i == 0 { shared.idempotency_token.to_string() } else { format!("{}-batch-{}", shared.idempotency_token, i) };
|
||||
|
||||
let db_result = insert_generic_inference_job_for_seedance2pro_queue_with_apriori_job_token(
|
||||
InsertGenericInferenceForSeedance2ProWithAprioriJobTokenArgs {
|
||||
apriori_job_token: &job_token,
|
||||
uuid_idempotency_token: &idempotency_str,
|
||||
maybe_external_third_party_id: order_id,
|
||||
maybe_prompt_token: shared.maybe_prompt_token,
|
||||
maybe_wallet_ledger_entry_token,
|
||||
maybe_creator_user_token: Some(shared.user_token),
|
||||
maybe_avt_token: shared.maybe_avt_token,
|
||||
creator_ip_address: shared.ip_address,
|
||||
creator_set_visibility: Visibility::Public,
|
||||
mysql_executor: &mut **shared.transaction,
|
||||
maybe_inference_args: None,
|
||||
phantom: Default::default(),
|
||||
}
|
||||
).await;
|
||||
|
||||
match db_result {
|
||||
Ok(token) => all_job_tokens.push(token),
|
||||
Err(err) => {
|
||||
warn!("Error inserting seedance2pro inference job (order_id={}): {:?}", order_id, err);
|
||||
if i == 0 { return Err(AdvancedCommonWebError::from_error(err)); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
all_job_tokens.first().cloned().ok_or_else(|| {
|
||||
error!("No inference job token was created");
|
||||
AdvancedCommonWebError::server_error_with_message("No inference job token was created")
|
||||
})
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
pub mod insert_fal_job;
|
||||
pub mod insert_seedance2pro_jobs;
|
||||
pub mod shared_job_args;
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
use tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken;
|
||||
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
|
||||
use tokens::tokens::prompts::PromptToken;
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
pub struct SharedJobArgs<'a, 'tx> {
|
||||
pub apriori_job_token: &'a InferenceJobToken,
|
||||
pub idempotency_token: &'a str,
|
||||
pub user_token: &'a UserToken,
|
||||
pub maybe_avt_token: Option<&'a AnonymousVisitorTrackingToken>,
|
||||
pub maybe_prompt_token: Option<&'a PromptToken>,
|
||||
pub ip_address: &'a str,
|
||||
pub transaction: &'a mut sqlx::Transaction<'tx, sqlx::MySql>,
|
||||
}
|
||||
+4
-7
@@ -1,8 +1,5 @@
|
||||
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;
|
||||
pub mod helpers;
|
||||
pub mod pipeline_v1;
|
||||
pub mod pipeline_v2;
|
||||
pub mod insert_db_job;
|
||||
|
||||
+120
-167
@@ -9,37 +9,31 @@ use url::Url;
|
||||
|
||||
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
|
||||
use artcraft_api_defs::omni_gen::generate_response::omni_gen_video_generate_response::OmniGenVideoGenerateResponse;
|
||||
use artcraft_router::api::provider::Provider;
|
||||
use artcraft_router::generate::generate_video::generate_video_response::GenerateVideoResponse;
|
||||
use enums::by_table::prompt_context_items::prompt_context_semantic_type::PromptContextSemanticType;
|
||||
use enums::by_table::prompts::prompt_type::PromptType;
|
||||
use enums::common::generation::common_generation_mode::CommonGenerationMode;
|
||||
use enums::common::generation::common_model_type::CommonModelType;
|
||||
use enums::common::generation::common_video_model::CommonVideoModel;
|
||||
use enums::common::generation_provider::GenerationProvider;
|
||||
use enums::common::visibility::Visibility;
|
||||
use http_server_common::request::get_request_ip::get_request_ip;
|
||||
use mysql_queries::queries::generic_inference::fal::insert_generic_inference_job_for_fal_queue::FalCategory;
|
||||
use mysql_queries::queries::generic_inference::fal::insert_generic_inference_job_for_fal_queue_with_apriori_job_token::{
|
||||
insert_generic_inference_job_for_fal_queue_with_apriori_job_token,
|
||||
InsertGenericInferenceForFalWithAprioriJobTokenArgs,
|
||||
};
|
||||
use mysql_queries::queries::generic_inference::seedance2pro::insert_generic_inference_job_for_seedance2pro_queue_with_apriori_job_token::{
|
||||
insert_generic_inference_job_for_seedance2pro_queue_with_apriori_job_token,
|
||||
InsertGenericInferenceForSeedance2ProWithAprioriJobTokenArgs,
|
||||
};
|
||||
use mysql_queries::queries::idepotency_tokens::insert_idempotency_token::insert_idempotency_token;
|
||||
use mysql_queries::queries::prompt_context_items::insert_batch_prompt_context_items::{
|
||||
insert_batch_prompt_context_items, InsertBatchArgs, PromptContextItem,
|
||||
};
|
||||
use mysql_queries::queries::prompts::insert_prompt::{insert_prompt, InsertPromptArgs};
|
||||
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
|
||||
use tokens::tokens::characters::CharacterToken;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
use crate::billing::wallets::attempt_wallet_deduction::attempt_wallet_deduction_else_common_web_error;
|
||||
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
|
||||
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::endpoints::omni_gen::generate::video::helpers::hydrate_router_request::hydrate_to_router_request;
|
||||
use crate::http_server::endpoints::omni_gen::generate::video::insert_db_job::insert_fal_job::{insert_fal_job, InsertFalJobArgs};
|
||||
use crate::http_server::endpoints::omni_gen::generate::video::insert_db_job::insert_seedance2pro_jobs::{insert_seedance2pro_jobs, InsertSeedance2proJobsArgs};
|
||||
use crate::http_server::endpoints::omni_gen::generate::video::insert_db_job::shared_job_args::SharedJobArgs;
|
||||
use crate::http_server::endpoints::omni_gen::generate::video::pipeline_v1::run_pipeline_v1::{run_pipeline_v1, RunPipelineV1Args};
|
||||
use crate::http_server::endpoints::omni_gen::generate::video::pipeline_v2::run_pipeline_v2::{run_pipeline_v2, RunPipelineV2Args};
|
||||
use crate::http_server::endpoints::omni_gen::generate::video::helpers::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;
|
||||
@@ -63,7 +57,7 @@ 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(""))?;
|
||||
@@ -112,8 +106,6 @@ pub async fn omni_gen_video_generate_handler(
|
||||
})?;
|
||||
|
||||
// ==================== RESOLVE MEDIA TOKENS ==================== //
|
||||
// Look up media file tokens BEFORE distilling. distill_video_request takes
|
||||
// a pre-computed `MediaFileToken -> Url` map and does no I/O of its own.
|
||||
|
||||
let media_file_hydration_map: Option<HashMap<MediaFileToken, Url>> = {
|
||||
let mut all_tokens: Vec<MediaFileToken> = Vec::new();
|
||||
@@ -157,73 +149,64 @@ pub async fn omni_gen_video_generate_handler(
|
||||
}
|
||||
};
|
||||
|
||||
// ==================== RESOLVE CHARACTER TOKENS ==================== //
|
||||
let media_file_to_url_map: Option<HashMap<MediaFileToken, String>> =
|
||||
media_file_hydration_map.as_ref().map(|map| {
|
||||
map.iter().map(|(k, v)| (k.clone(), v.to_string())).collect()
|
||||
});
|
||||
|
||||
let kinovi_character_ids = resolve_kinovi_character_ids(
|
||||
request.reference_character_tokens.as_deref(),
|
||||
&mut mysql_connection,
|
||||
).await?;
|
||||
// ==================== RESOLVE CHARACTERS ==================== //
|
||||
|
||||
// ==================== DETERMINE PROVIDER ==================== //
|
||||
|
||||
let execution_provider = match request.model {
|
||||
Some(CommonVideoModel::Seedance2p0) => Provider::Seedance2Pro,
|
||||
Some(CommonVideoModel::Seedance2p0Fast) => Provider::Seedance2Pro,
|
||||
_ => Provider::Fal,
|
||||
};
|
||||
|
||||
// ==================== DISTILL ==================== //
|
||||
|
||||
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);
|
||||
|
||||
info!("Charging wallet: {} credits", cost);
|
||||
|
||||
let apriori_job_token = InferenceJobToken::generate();
|
||||
|
||||
let maybe_deduction_result = if cost > 0 {
|
||||
Some(attempt_wallet_deduction_else_common_web_error(
|
||||
user_token,
|
||||
Some(apriori_job_token.as_str()),
|
||||
cost,
|
||||
let kinovi_character_id_map: Option<HashMap<CharacterToken, String>> =
|
||||
resolve_kinovi_character_ids(
|
||||
request.reference_character_tokens.as_deref(),
|
||||
&mut mysql_connection,
|
||||
).await?)
|
||||
} else {
|
||||
None
|
||||
).await?;
|
||||
|
||||
// ==================== HYDRATE ROUTER REQUEST ==================== //
|
||||
|
||||
let router_builder = hydrate_to_router_request(&request)?;
|
||||
|
||||
// ==================== PIPELINE DISPATCH ==================== //
|
||||
|
||||
let use_v2 = match request.model {
|
||||
Some(CommonVideoModel::Seedance2p0) => true,
|
||||
Some(CommonVideoModel::Seedance2p0Fast) => true,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
// ==================== EXECUTE GENERATION ==================== //
|
||||
let pipeline_result = if use_v2 {
|
||||
run_pipeline_v2(RunPipelineV2Args {
|
||||
router_builder: &router_builder,
|
||||
server_state: &server_state,
|
||||
mysql_connection: &mut mysql_connection,
|
||||
user_token,
|
||||
media_file_to_url_map: &media_file_to_url_map,
|
||||
kinovi_character_id_map: &kinovi_character_id_map,
|
||||
}).await?
|
||||
} else {
|
||||
run_pipeline_v1(RunPipelineV1Args {
|
||||
request: &request,
|
||||
router_builder: &router_builder,
|
||||
server_state: &server_state,
|
||||
mysql_connection: &mut mysql_connection,
|
||||
user_token,
|
||||
media_url_map: &media_file_hydration_map,
|
||||
kinovi_character_id_map: &kinovi_character_id_map,
|
||||
}).await?
|
||||
};
|
||||
|
||||
let gen_result = execute_generation(
|
||||
&distilled,
|
||||
&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 ==================== //
|
||||
// ==================== WRITE RESULT ==================== //
|
||||
|
||||
let ip_address = get_request_ip(&http_request);
|
||||
|
||||
let mut transaction = mysql_connection
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|err| {
|
||||
error!("Error starting MySQL transaction: {:?}", err);
|
||||
AdvancedCommonWebError::from_error(err)
|
||||
})?;
|
||||
let mut transaction = mysql_connection.begin().await.map_err(|err| {
|
||||
error!("Error starting MySQL transaction: {:?}", err);
|
||||
AdvancedCommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
// -- Prompt --
|
||||
|
||||
let prompt_result = insert_prompt(InsertPromptArgs {
|
||||
let prompt_token = match insert_prompt(InsertPromptArgs {
|
||||
maybe_apriori_prompt_token: None,
|
||||
prompt_type: PromptType::ArtcraftApp,
|
||||
maybe_creator_user_token: Some(user_token),
|
||||
@@ -232,18 +215,16 @@ pub async fn omni_gen_video_generate_handler(
|
||||
maybe_positive_prompt: request.prompt.as_deref(),
|
||||
maybe_negative_prompt: request.negative_prompt.as_deref(),
|
||||
maybe_other_args: None,
|
||||
maybe_generation_mode: Some(gen_result.generation_mode),
|
||||
maybe_aspect_ratio: None,
|
||||
maybe_resolution: None,
|
||||
maybe_generation_mode: Some(determine_generation_mode(&request)),
|
||||
maybe_aspect_ratio: request.aspect_ratio,
|
||||
maybe_resolution: request.resolution,
|
||||
maybe_batch_count: request.video_batch_count.map(|c| c as u8),
|
||||
maybe_generate_audio: request.generate_audio,
|
||||
maybe_duration_seconds: request.duration_seconds.map(|d| d as u32),
|
||||
creator_ip_address: &ip_address,
|
||||
mysql_executor: &mut *transaction,
|
||||
phantom: Default::default(),
|
||||
}).await;
|
||||
|
||||
let prompt_token = match prompt_result {
|
||||
}).await {
|
||||
Ok(token) => Some(token),
|
||||
Err(err) => {
|
||||
warn!("Error inserting prompt: {:?}", err);
|
||||
@@ -262,14 +243,12 @@ pub async fn omni_gen_video_generate_handler(
|
||||
context_semantic_type: PromptContextSemanticType::VidStartFrame,
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(media_token) = &request.end_frame_image_media_token {
|
||||
context_items.push(PromptContextItem {
|
||||
media_token: media_token.clone(),
|
||||
context_semantic_type: PromptContextSemanticType::VidEndFrame,
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(ref_tokens) = &request.reference_image_media_tokens {
|
||||
for media_token in ref_tokens {
|
||||
context_items.push(PromptContextItem {
|
||||
@@ -278,7 +257,6 @@ pub async fn omni_gen_video_generate_handler(
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref_tokens) = &request.reference_video_media_tokens {
|
||||
for media_token in ref_tokens {
|
||||
context_items.push(PromptContextItem {
|
||||
@@ -301,92 +279,47 @@ 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()];
|
||||
let order_ids = gen_result.maybe_seedance_order_ids
|
||||
.as_deref()
|
||||
.unwrap_or(&fallback_ids);
|
||||
|
||||
let mut all_job_tokens: Vec<InferenceJobToken> = Vec::with_capacity(order_ids.len());
|
||||
|
||||
for (i, order_id) in order_ids.iter().enumerate() {
|
||||
let job_token = if i == 0 {
|
||||
apriori_job_token.clone()
|
||||
} else {
|
||||
InferenceJobToken::generate()
|
||||
};
|
||||
|
||||
let idempotency_str = if i == 0 {
|
||||
idempotency_token.clone()
|
||||
} else {
|
||||
format!("{}-batch-{}", idempotency_token, i)
|
||||
};
|
||||
|
||||
let db_result = insert_generic_inference_job_for_seedance2pro_queue_with_apriori_job_token(
|
||||
InsertGenericInferenceForSeedance2ProWithAprioriJobTokenArgs {
|
||||
apriori_job_token: &job_token,
|
||||
uuid_idempotency_token: &idempotency_str,
|
||||
maybe_external_third_party_id: order_id,
|
||||
maybe_inference_args: None,
|
||||
maybe_prompt_token: prompt_token.as_ref(),
|
||||
maybe_wallet_ledger_entry_token: maybe_ledger_entry_token,
|
||||
maybe_creator_user_token: Some(user_token),
|
||||
let job_token = match &pipeline_result.response {
|
||||
GenerateVideoResponse::Seedance2Pro(payload) => {
|
||||
insert_seedance2pro_jobs(InsertSeedance2proJobsArgs {
|
||||
primary_order_id: &payload.order_id,
|
||||
maybe_additional_order_ids: payload.maybe_order_ids.as_deref(),
|
||||
maybe_wallet_ledger_entry_token: pipeline_result.billing.maybe_wallet_ledger_entry_token.as_ref(),
|
||||
shared: SharedJobArgs {
|
||||
apriori_job_token: &pipeline_result.billing.apriori_job_token,
|
||||
idempotency_token: &idempotency_token,
|
||||
user_token,
|
||||
maybe_avt_token: maybe_avt_token.as_ref(),
|
||||
creator_ip_address: &ip_address,
|
||||
creator_set_visibility: Visibility::Public,
|
||||
mysql_executor: &mut *transaction,
|
||||
phantom: Default::default(),
|
||||
}
|
||||
).await;
|
||||
|
||||
match db_result {
|
||||
Ok(token) => all_job_tokens.push(token),
|
||||
Err(err) => {
|
||||
warn!("Error inserting seedance2pro inference job (order_id={}): {:?}", order_id, err);
|
||||
if i == 0 {
|
||||
return Err(AdvancedCommonWebError::from_error(err));
|
||||
}
|
||||
}
|
||||
}
|
||||
maybe_prompt_token: prompt_token.as_ref(),
|
||||
ip_address: &ip_address,
|
||||
transaction: &mut transaction,
|
||||
},
|
||||
}).await?
|
||||
}
|
||||
|
||||
all_job_tokens.first().cloned().ok_or_else(|| {
|
||||
error!("No inference job token was created");
|
||||
AdvancedCommonWebError::server_error_with_message("No inference job token was created")
|
||||
})?
|
||||
} 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,
|
||||
uuid_idempotency_token: &idempotency_token,
|
||||
maybe_external_third_party_id: &gen_result.external_job_id,
|
||||
fal_category: FalCategory::VideoGeneration,
|
||||
maybe_inference_args: None,
|
||||
maybe_prompt_token: prompt_token.as_ref(),
|
||||
maybe_creator_user_token: Some(user_token),
|
||||
maybe_avt_token: maybe_avt_token.as_ref(),
|
||||
creator_ip_address: &ip_address,
|
||||
creator_set_visibility: Visibility::Public,
|
||||
mysql_executor: &mut *transaction,
|
||||
starting_job_status_override: None,
|
||||
maybe_frontend_failure_category: None,
|
||||
maybe_failure_reason: None,
|
||||
phantom: Default::default(),
|
||||
}
|
||||
).await;
|
||||
|
||||
match db_result {
|
||||
Ok(token) => token,
|
||||
Err(err) => {
|
||||
warn!("Error inserting inference job: {:?}", err);
|
||||
return Err(AdvancedCommonWebError::from_error(err));
|
||||
}
|
||||
GenerateVideoResponse::Fal(payload) => {
|
||||
let external_id = payload.request_id.as_deref().ok_or_else(|| {
|
||||
error!("Fal generation response missing request_id");
|
||||
AdvancedCommonWebError::server_error_with_message("Fal generation response missing request_id")
|
||||
})?;
|
||||
insert_fal_job(InsertFalJobArgs {
|
||||
external_job_id: external_id,
|
||||
shared: SharedJobArgs {
|
||||
apriori_job_token: &pipeline_result.billing.apriori_job_token,
|
||||
idempotency_token: &idempotency_token,
|
||||
user_token,
|
||||
maybe_avt_token: maybe_avt_token.as_ref(),
|
||||
maybe_prompt_token: prompt_token.as_ref(),
|
||||
ip_address: &ip_address,
|
||||
transaction: &mut transaction,
|
||||
},
|
||||
}).await?
|
||||
}
|
||||
GenerateVideoResponse::Artcraft(payload) => {
|
||||
payload.inference_job_token.clone()
|
||||
}
|
||||
other => {
|
||||
error!("Unexpected generation response variant: {:?}", other);
|
||||
return Err(AdvancedCommonWebError::server_error_with_message("Unexpected generation response"));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -400,3 +333,23 @@ pub async fn omni_gen_video_generate_handler(
|
||||
inference_job_token: job_token,
|
||||
}))
|
||||
}
|
||||
|
||||
fn determine_generation_mode(request: &OmniGenVideoCostAndGenerateRequest) -> CommonGenerationMode {
|
||||
let has_keyframe = request.start_frame_image_media_token.is_some()
|
||||
|| request.end_frame_image_media_token.is_some();
|
||||
|
||||
if has_keyframe {
|
||||
return CommonGenerationMode::Keyframe;
|
||||
}
|
||||
|
||||
let has_reference = request.reference_image_media_tokens.as_ref().is_some_and(|t| !t.is_empty())
|
||||
|| request.reference_video_media_tokens.as_ref().is_some_and(|t| !t.is_empty())
|
||||
|| request.reference_audio_media_tokens.as_ref().is_some_and(|t| !t.is_empty())
|
||||
|| request.reference_character_tokens.as_ref().is_some_and(|t| !t.is_empty());
|
||||
|
||||
if has_reference {
|
||||
return CommonGenerationMode::Reference;
|
||||
}
|
||||
|
||||
CommonGenerationMode::Text
|
||||
}
|
||||
|
||||
-1
@@ -1,3 +1,2 @@
|
||||
pub(crate) mod hydrate_to_router_request;
|
||||
pub(crate) mod request_to_costs;
|
||||
pub(crate) mod request_to_plan;
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
//! Fal provider execution path.
|
||||
|
||||
use log::warn;
|
||||
|
||||
use artcraft_router::api::provider::Provider;
|
||||
use artcraft_router::generate::generate_video::generate_video_response::GenerateVideoResponse;
|
||||
use artcraft_router::generate::generate_video::video_generation_plan::VideoGenerationPlan;
|
||||
|
||||
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
|
||||
use crate::http_server::endpoints::omni_gen::generate::video::helpers::build_router_client::build_router_client;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
/// Execute generation via Fal (the existing path).
|
||||
pub(crate) async fn execute_generation_fal(
|
||||
plan: &VideoGenerationPlan,
|
||||
server_state: &ServerState,
|
||||
) -> Result<GenerateVideoResponse, AdvancedCommonWebError> {
|
||||
let router_client = build_router_client(Provider::Fal, server_state)?;
|
||||
|
||||
let response = plan.generate_video(&router_client)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
warn!("Video generation failed (Fal): {:?}", e);
|
||||
AdvancedCommonWebError::from_error(e)
|
||||
})?;
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
+11
-36
@@ -9,10 +9,11 @@ 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 artcraft_router::generate::generate_video::generate_video_response::{GenerateVideoResponse, Seedance2proVideoResponsePayload};
|
||||
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, KinoviGenerateVideoRequest, KinoviBatchCount, KinoviModelType, KinoviAspectRatio, KinoviOutputResolution};
|
||||
use seedance2pro_client::requests::prepare_file_upload::prepare_file_upload::{
|
||||
@@ -30,18 +31,14 @@ use crate::http_server::endpoint_helpers::refund_wallet_after_api_failure::refun
|
||||
use crate::state::server_state::ServerState;
|
||||
use crate::util::http_download_url_to_bytes::http_download_url_to_bytes;
|
||||
|
||||
use super::super::distill_video_request::DistilledVideoRequest;
|
||||
use super::execute_generation::GenerationResult;
|
||||
|
||||
pub(super) async fn execute_generation_kinovi(
|
||||
_distilled: &DistilledVideoRequest,
|
||||
pub(crate) 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> {
|
||||
) -> Result<GenerateVideoResponse, AdvancedCommonWebError> {
|
||||
let result = execute_generation_kinovi_inner(
|
||||
request,
|
||||
server_state,
|
||||
@@ -66,7 +63,7 @@ async fn execute_generation_kinovi_inner(
|
||||
server_state: &ServerState,
|
||||
media_file_hydration_map: Option<&HashMap<MediaFileToken, Url>>,
|
||||
kinovi_character_ids: Option<Vec<String>>,
|
||||
) -> Result<GenerationResult, AdvancedCommonWebError> {
|
||||
) -> Result<GenerateVideoResponse, AdvancedCommonWebError> {
|
||||
let session = Seedance2ProSession::from_cookies_string(
|
||||
server_state.seedance2pro.cookies.clone()
|
||||
);
|
||||
@@ -97,23 +94,6 @@ async fn execute_generation_kinovi_inner(
|
||||
&session, hydration_map, request.reference_audio_media_tokens.as_deref(),
|
||||
).await?;
|
||||
|
||||
// Determine generation mode.
|
||||
let is_keyframe = request.start_frame_image_media_token.is_some()
|
||||
|| request.end_frame_image_media_token.is_some();
|
||||
let is_reference = request.reference_image_media_tokens.as_ref().map_or(false, |t| !t.is_empty())
|
||||
|| request.reference_video_media_tokens.as_ref().map_or(false, |t| !t.is_empty())
|
||||
|| request.reference_audio_media_tokens.as_ref().map_or(false, |t| !t.is_empty());
|
||||
|
||||
// TODO(bt): Move this logic to `artcraft_router` to the `execute` phase.
|
||||
|
||||
let generation_mode = if is_keyframe {
|
||||
CommonGenerationMode::Keyframe
|
||||
} else if is_reference {
|
||||
CommonGenerationMode::Reference
|
||||
} else {
|
||||
CommonGenerationMode::Text
|
||||
};
|
||||
|
||||
// Map aspect ratio / duration / batch from the request.
|
||||
let aspect_ratio = map_common_aspect_ratio_to_kinovi_resolution(request.aspect_ratio);
|
||||
|
||||
@@ -172,17 +152,12 @@ async fn execute_generation_kinovi_inner(
|
||||
gen_response.task_id, gen_response.order_id
|
||||
);
|
||||
|
||||
let order_ids: Vec<String> = match gen_response.order_ids {
|
||||
Some(ids) if !ids.is_empty() => ids,
|
||||
_ => vec![gen_response.order_id.clone()],
|
||||
};
|
||||
|
||||
Ok(GenerationResult {
|
||||
external_job_id: gen_response.order_id,
|
||||
is_seedance2pro: true,
|
||||
maybe_seedance_order_ids: Some(order_ids),
|
||||
generation_mode,
|
||||
})
|
||||
Ok(GenerateVideoResponse::Seedance2Pro(Seedance2proVideoResponsePayload {
|
||||
order_id: gen_response.order_id,
|
||||
task_id: gen_response.task_id,
|
||||
maybe_order_ids: gen_response.order_ids,
|
||||
maybe_task_ids: gen_response.task_ids,
|
||||
}))
|
||||
}
|
||||
|
||||
// --- Upload helpers ---
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
pub(crate) mod execute_fal;
|
||||
pub(crate) mod execute_kinovi;
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
pub mod execute;
|
||||
pub mod run_pipeline_v1;
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use log::{info, warn};
|
||||
use url::Url;
|
||||
|
||||
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
|
||||
use artcraft_router::api::audio_list_ref::AudioListRef;
|
||||
use artcraft_router::api::image_list_ref::ImageListRef;
|
||||
use artcraft_router::api::image_ref::ImageRef;
|
||||
use artcraft_router::api::provider::Provider;
|
||||
use artcraft_router::api::video_list_ref::VideoListRef;
|
||||
use artcraft_router::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use enums::common::generation::common_video_model::CommonVideoModel;
|
||||
use tokens::tokens::characters::CharacterToken;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
|
||||
use crate::http_server::endpoints::omni_gen::generate::video::helpers::bill_wallet::bill_wallet;
|
||||
use crate::http_server::endpoints::omni_gen::generate::video::helpers::pipeline_result::PipelineResult;
|
||||
use crate::http_server::endpoints::omni_gen::generate::video::pipeline_v1::execute::execute_fal::execute_generation_fal;
|
||||
use crate::http_server::endpoints::omni_gen::generate::video::pipeline_v1::execute::execute_kinovi::execute_generation_kinovi;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
// ── Pipeline args ──
|
||||
|
||||
pub struct RunPipelineV1Args<'a> {
|
||||
pub request: &'a OmniGenVideoCostAndGenerateRequest,
|
||||
pub router_builder: &'a GenerateVideoRequestBuilder,
|
||||
pub server_state: &'a ServerState,
|
||||
pub mysql_connection: &'a mut sqlx::pool::PoolConnection<sqlx::MySql>,
|
||||
pub user_token: &'a UserToken,
|
||||
pub media_url_map: &'a Option<HashMap<MediaFileToken, Url>>,
|
||||
pub kinovi_character_id_map: &'a Option<HashMap<CharacterToken, String>>,
|
||||
}
|
||||
|
||||
// ── Pipeline entrypoint ──
|
||||
|
||||
pub async fn run_pipeline_v1(args: RunPipelineV1Args<'_>) -> Result<PipelineResult, AdvancedCommonWebError> {
|
||||
let RunPipelineV1Args {
|
||||
request,
|
||||
router_builder,
|
||||
server_state,
|
||||
mysql_connection,
|
||||
user_token,
|
||||
media_url_map,
|
||||
kinovi_character_id_map,
|
||||
} = args;
|
||||
|
||||
// v1 execute_generation expects a flat Vec<String> of kinovi IDs
|
||||
let kinovi_character_ids: Option<Vec<String>> = kinovi_character_id_map
|
||||
.as_ref()
|
||||
.map(|map| map.values().cloned().collect());
|
||||
|
||||
let execution_provider = match request.model {
|
||||
Some(CommonVideoModel::Seedance2p0) => Provider::Seedance2Pro,
|
||||
Some(CommonVideoModel::Seedance2p0Fast) => Provider::Seedance2Pro,
|
||||
_ => Provider::Fal,
|
||||
};
|
||||
|
||||
// ── Cost estimate (Artcraft provider — what we bill on) ──
|
||||
|
||||
let cost = {
|
||||
let cost_request = GenerateVideoRequestBuilder {
|
||||
provider: Provider::Artcraft,
|
||||
..router_builder.clone()
|
||||
};
|
||||
let cost_plan = cost_request.build().map_err(|e| {
|
||||
warn!("Failed to build cost plan during video distillation: {}", e);
|
||||
AdvancedCommonWebError::from_error(e)
|
||||
})?;
|
||||
cost_plan.estimate_costs()
|
||||
};
|
||||
|
||||
// ── Build the execution request (clone builder, override provider + media URLs) ──
|
||||
|
||||
let empty_map = HashMap::new();
|
||||
let url_map = media_url_map.as_ref().unwrap_or(&empty_map);
|
||||
|
||||
let mut exec_request = router_builder.clone();
|
||||
exec_request.provider = execution_provider;
|
||||
|
||||
exec_request.start_frame = lookup_url(url_map, request.start_frame_image_media_token.as_ref()).map(ImageRef::Url);
|
||||
exec_request.end_frame = lookup_url(url_map, request.end_frame_image_media_token.as_ref()).map(ImageRef::Url);
|
||||
exec_request.reference_images = lookup_urls(url_map, request.reference_image_media_tokens.as_ref()).map(ImageListRef::Urls);
|
||||
exec_request.reference_videos = lookup_urls(url_map, request.reference_video_media_tokens.as_ref()).map(VideoListRef::Urls);
|
||||
exec_request.reference_audio = lookup_urls(url_map, request.reference_audio_media_tokens.as_ref()).map(AudioListRef::Urls);
|
||||
|
||||
let plan = exec_request.build().map_err(|e| {
|
||||
warn!("Failed to build video generation plan during distillation: {}", e);
|
||||
AdvancedCommonWebError::from_error(e)
|
||||
})?;
|
||||
|
||||
info!("v1 distilled plan: {:?}", plan);
|
||||
|
||||
// ── Bill wallet ──
|
||||
|
||||
let cost_in_credits = cost.cost_in_credits.unwrap_or(0);
|
||||
let billing = bill_wallet(user_token, cost_in_credits, mysql_connection).await?;
|
||||
|
||||
// ── Execute generation via the appropriate provider ──
|
||||
|
||||
let response = match execution_provider {
|
||||
Provider::Seedance2Pro => {
|
||||
execute_generation_kinovi(
|
||||
request, server_state,
|
||||
media_url_map.as_ref(), kinovi_character_ids,
|
||||
billing.maybe_wallet_ledger_entry_token.as_ref(), mysql_connection,
|
||||
).await?
|
||||
}
|
||||
_ => {
|
||||
execute_generation_fal(&plan, server_state).await?
|
||||
}
|
||||
};
|
||||
|
||||
Ok(PipelineResult { billing, response })
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
fn lookup_url(map: &HashMap<MediaFileToken, Url>, token: Option<&MediaFileToken>) -> Option<String> {
|
||||
token.and_then(|t| map.get(t)).map(|u| u.to_string())
|
||||
}
|
||||
|
||||
fn lookup_urls(map: &HashMap<MediaFileToken, Url>, tokens: Option<&Vec<MediaFileToken>>) -> Option<Vec<String>> {
|
||||
tokens
|
||||
.filter(|v| !v.is_empty())
|
||||
.map(|tokens| tokens.iter().filter_map(|t| map.get(t).map(|u| u.to_string())).collect())
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
pub mod run_pipeline_v2;
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use log::{error, info, warn};
|
||||
use sqlx::pool::PoolConnection;
|
||||
use artcraft_router::api::common_video_model::CommonVideoModel;
|
||||
use artcraft_router::api::provider::Provider;
|
||||
use artcraft_router::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use artcraft_router::generate::generate_video::generate_video_response::GenerateVideoResponse;
|
||||
use artcraft_router::generate::generate_video_v2::video_generation_draft_context::VideoGenerationDraftContext;
|
||||
use artcraft_router::generate::generate_video_v2::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
use tokens::tokens::characters::CharacterToken;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
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::http_server::endpoints::omni_gen::generate::video::helpers::bill_wallet::bill_wallet;
|
||||
use crate::http_server::endpoints::omni_gen::generate::video::helpers::build_router_client::build_router_client;
|
||||
use crate::http_server::endpoints::omni_gen::generate::video::helpers::pipeline_result::PipelineResult;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
pub struct RunPipelineV2Args<'a> {
|
||||
pub router_builder: &'a GenerateVideoRequestBuilder,
|
||||
pub server_state: &'a ServerState,
|
||||
pub mysql_connection: &'a mut PoolConnection<sqlx::MySql>,
|
||||
pub user_token: &'a UserToken,
|
||||
pub media_file_to_url_map: &'a Option<HashMap<MediaFileToken, String>>,
|
||||
pub kinovi_character_id_map: &'a Option<HashMap<CharacterToken, String>>,
|
||||
}
|
||||
|
||||
pub async fn run_pipeline_v2(args: RunPipelineV2Args<'_>) -> Result<PipelineResult, AdvancedCommonWebError> {
|
||||
let RunPipelineV2Args {
|
||||
router_builder,
|
||||
server_state,
|
||||
mysql_connection,
|
||||
user_token,
|
||||
media_file_to_url_map,
|
||||
kinovi_character_id_map,
|
||||
} = args;
|
||||
|
||||
let provider = match router_builder.model {
|
||||
CommonVideoModel::Seedance2p0 => Provider::Seedance2Pro,
|
||||
CommonVideoModel::Seedance2p0Fast => Provider::Seedance2Pro,
|
||||
_ => Provider::Fal,
|
||||
};
|
||||
|
||||
// 1. Build execution request
|
||||
let mut exec_builder = router_builder.clone();
|
||||
exec_builder.provider = provider;
|
||||
|
||||
let draft_or_request = exec_builder.build2()
|
||||
.map_err(|e| {
|
||||
warn!("Failed to build2 for v2 pipeline: {}", e);
|
||||
AdvancedCommonWebError::from_error(e)
|
||||
})?;
|
||||
|
||||
// 2. Calculate cost (swap provider to Artcraft for billing)
|
||||
let mut cost_builder = router_builder.clone();
|
||||
cost_builder.provider = Provider::Artcraft;
|
||||
|
||||
let cost = cost_builder.build2()
|
||||
.map_err(|e| {
|
||||
warn!("Failed to build2 cost estimate for v2: {}", e);
|
||||
AdvancedCommonWebError::from_error(e)
|
||||
})?
|
||||
.estimate_cost()
|
||||
.map_err(|e| {
|
||||
warn!("Failed to estimate cost for v2: {}", e);
|
||||
AdvancedCommonWebError::from_error(e)
|
||||
})?
|
||||
.cost_in_credits
|
||||
.unwrap_or(0);
|
||||
|
||||
info!("v2 estimated cost: {} credits", cost);
|
||||
|
||||
// 3. Bill wallet
|
||||
let billing = bill_wallet(user_token, cost, mysql_connection).await?;
|
||||
|
||||
// 4. Upload media (if draft) and generate video.
|
||||
// The entire block is wrapped so Kinovi failures trigger a refund.
|
||||
let result = upload_and_generate(
|
||||
draft_or_request,
|
||||
server_state,
|
||||
media_file_to_url_map.as_ref(),
|
||||
kinovi_character_id_map.as_ref(),
|
||||
).await;
|
||||
|
||||
// 5. On failure, refund wallet for Kinovi requests.
|
||||
if let Err(ref err) = result {
|
||||
if matches!(provider, Provider::Seedance2Pro) {
|
||||
if let Some(ledger_entry_token) = billing.maybe_wallet_ledger_entry_token.as_ref() {
|
||||
warn!("Kinovi v2 generation failed, issuing refund for {}: {:?}", ledger_entry_token.as_str(), err);
|
||||
|
||||
let result = refund_wallet_after_api_failure(ledger_entry_token, mysql_connection).await;
|
||||
|
||||
if let Err(refund_err) = result {
|
||||
error!("Failed to refund wallet after Kinovi v2 failure: {:?}", refund_err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let response = result?;
|
||||
|
||||
info!("v2 generation response: {:?}", response);
|
||||
|
||||
Ok(PipelineResult { billing, response })
|
||||
}
|
||||
|
||||
/// Finalize the draft (uploading media if needed), then send the generation request.
|
||||
///
|
||||
/// This is the block that gets refunded on failure for Kinovi providers.
|
||||
async fn upload_and_generate(
|
||||
draft_or_request: VideoGenerationDraftOrRequest,
|
||||
server_state: &ServerState,
|
||||
media_file_urls_by_token: Option<&HashMap<MediaFileToken, String>>,
|
||||
kinovi_character_ids: Option<&HashMap<CharacterToken, String>>,
|
||||
) -> Result<GenerateVideoResponse, AdvancedCommonWebError> {
|
||||
|
||||
let provider = draft_or_request.get_provider();
|
||||
let client = build_router_client(provider, server_state)?;
|
||||
|
||||
let video_request = match draft_or_request {
|
||||
VideoGenerationDraftOrRequest::Request(request) => request,
|
||||
VideoGenerationDraftOrRequest::Draft(draft) => {
|
||||
let draft_context = VideoGenerationDraftContext {
|
||||
client: Some(&client),
|
||||
media_file_to_artcraft_url_map: media_file_urls_by_token,
|
||||
character_token_to_kinovi_id_map: kinovi_character_ids,
|
||||
};
|
||||
|
||||
draft.finalize(draft_context)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
warn!("Failed to finalize v2 draft: {:?}", err);
|
||||
AdvancedCommonWebError::from_error(err)
|
||||
})?
|
||||
}
|
||||
};
|
||||
|
||||
video_request.send_request(&client)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
warn!("v2 video generation failed: {:?}", err);
|
||||
AdvancedCommonWebError::from_error(err)
|
||||
})
|
||||
}
|
||||
-1
@@ -1 +0,0 @@
|
||||
pub mod resolve_kinovi_character_ids;
|
||||
-1
@@ -1 +0,0 @@
|
||||
mod test_kinovi_seedance_2p0;
|
||||
-551
@@ -1,551 +0,0 @@
|
||||
//! Tests for omni-gen video against the Seedance 2.0 model (Kinovi/Seedance2Pro provider).
|
||||
//!
|
||||
//! Seedance 2.0 is special: it uses the Seedance2Pro/Kinovi provider for execution
|
||||
//! instead of Fal. The cost estimation uses Artcraft provider with media-token-form
|
||||
//! references, while the execution plan uses URL-form references.
|
||||
//!
|
||||
//! Pricing: 16¢ per second × batch count.
|
||||
//! - 4s/batch 1 = 64¢, 5s/batch 1 = 80¢, 15s/batch 1 = 240¢
|
||||
//! - Batch 2 = 2×, Batch 4 = 4×
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashMap;
|
||||
|
||||
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
|
||||
use artcraft_router::api::provider::Provider;
|
||||
use artcraft_router::generate::generate_video::video_generation_plan::VideoGenerationPlan;
|
||||
use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
|
||||
use enums::common::generation::common_video_model::CommonVideoModel;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
use url::Url;
|
||||
|
||||
use crate::http_server::endpoints::omni_gen::generate::video::distill_video_request::{
|
||||
distill_video_request, DistilledVideoRequest,
|
||||
};
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
fn make_request(
|
||||
prompt: Option<&str>,
|
||||
aspect_ratio: Option<CommonAspectRatio>,
|
||||
duration_seconds: Option<u16>,
|
||||
video_batch_count: Option<u16>,
|
||||
start_frame_token: Option<MediaFileToken>,
|
||||
end_frame_token: Option<MediaFileToken>,
|
||||
reference_image_tokens: Option<Vec<MediaFileToken>>,
|
||||
reference_video_tokens: Option<Vec<MediaFileToken>>,
|
||||
reference_audio_tokens: Option<Vec<MediaFileToken>>,
|
||||
) -> OmniGenVideoCostAndGenerateRequest {
|
||||
OmniGenVideoCostAndGenerateRequest {
|
||||
idempotency_token: Some("11111111-1111-1111-1111-111111111111".to_string()),
|
||||
model: Some(CommonVideoModel::Seedance2p0),
|
||||
prompt: prompt.map(|s| s.to_string()),
|
||||
negative_prompt: None,
|
||||
start_frame_image_media_token: start_frame_token,
|
||||
end_frame_image_media_token: end_frame_token,
|
||||
reference_image_media_tokens: reference_image_tokens,
|
||||
reference_video_media_tokens: reference_video_tokens,
|
||||
reference_audio_media_tokens: reference_audio_tokens,
|
||||
reference_character_tokens: None,
|
||||
resolution: None,
|
||||
aspect_ratio,
|
||||
quality: None,
|
||||
duration_seconds,
|
||||
video_batch_count,
|
||||
generate_audio: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn text_only_request(
|
||||
duration_seconds: Option<u16>,
|
||||
video_batch_count: Option<u16>,
|
||||
) -> OmniGenVideoCostAndGenerateRequest {
|
||||
make_request(
|
||||
Some("a cat dancing"),
|
||||
None,
|
||||
duration_seconds,
|
||||
video_batch_count,
|
||||
None, None, None, None, None,
|
||||
)
|
||||
}
|
||||
|
||||
fn fake_token(name: &str) -> MediaFileToken {
|
||||
MediaFileToken::new_from_str(name)
|
||||
}
|
||||
|
||||
fn build_hydration_map(tokens: &[&MediaFileToken]) -> HashMap<MediaFileToken, Url> {
|
||||
tokens.iter().enumerate().map(|(i, t)| {
|
||||
let url = Url::parse(&format!("https://cdn.example.com/media/{}.png", i)).unwrap();
|
||||
((*t).clone(), url)
|
||||
}).collect()
|
||||
}
|
||||
|
||||
fn distill_text(request: &OmniGenVideoCostAndGenerateRequest) -> DistilledVideoRequest {
|
||||
let empty: HashMap<MediaFileToken, Url> = HashMap::new();
|
||||
distill_video_request(request, Some(&empty), Provider::Seedance2Pro)
|
||||
.expect("distill should succeed for Seedance 2.0 (text)")
|
||||
}
|
||||
|
||||
fn distill_with_map(
|
||||
request: &OmniGenVideoCostAndGenerateRequest,
|
||||
hydration: &HashMap<MediaFileToken, Url>,
|
||||
) -> DistilledVideoRequest {
|
||||
distill_video_request(request, Some(hydration), Provider::Seedance2Pro)
|
||||
.expect("distill should succeed for Seedance 2.0 (with media)")
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// COST TESTS
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
mod cost_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn cost_is_present() {
|
||||
let d = distill_text(&text_only_request(Some(5), None));
|
||||
assert!(d.cost.cost_in_credits.is_some());
|
||||
assert!(d.cost.cost_in_usd_cents.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_is_not_free() {
|
||||
let d = distill_text(&text_only_request(Some(5), None));
|
||||
assert!(!d.cost.is_free);
|
||||
}
|
||||
|
||||
// Pricing: 16¢/sec × batch_count
|
||||
|
||||
#[test]
|
||||
fn cost_4s_batch_1() {
|
||||
let d = distill_text(&text_only_request(Some(4), Some(1)));
|
||||
assert_eq!(d.cost.cost_in_usd_cents, Some(64));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_5s_batch_1() {
|
||||
let d = distill_text(&text_only_request(Some(5), Some(1)));
|
||||
assert_eq!(d.cost.cost_in_usd_cents, Some(80));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_6s_batch_1() {
|
||||
let d = distill_text(&text_only_request(Some(6), Some(1)));
|
||||
assert_eq!(d.cost.cost_in_usd_cents, Some(96));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_10s_batch_1() {
|
||||
let d = distill_text(&text_only_request(Some(10), Some(1)));
|
||||
assert_eq!(d.cost.cost_in_usd_cents, Some(160));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_15s_batch_1() {
|
||||
let d = distill_text(&text_only_request(Some(15), Some(1)));
|
||||
assert_eq!(d.cost.cost_in_usd_cents, Some(240));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_5s_batch_2() {
|
||||
let d = distill_text(&text_only_request(Some(5), Some(2)));
|
||||
assert_eq!(d.cost.cost_in_usd_cents, Some(160));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_5s_batch_4() {
|
||||
let d = distill_text(&text_only_request(Some(5), Some(4)));
|
||||
assert_eq!(d.cost.cost_in_usd_cents, Some(320));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_15s_batch_4() {
|
||||
let d = distill_text(&text_only_request(Some(15), Some(4)));
|
||||
assert_eq!(d.cost.cost_in_usd_cents, Some(960));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_same_with_or_without_start_frame() {
|
||||
// Cost should not change based on media presence — it's duration × batch.
|
||||
let text_d = distill_text(&text_only_request(Some(5), Some(1)));
|
||||
|
||||
let start_token = fake_token("start_frame");
|
||||
let map = build_hydration_map(&[&start_token]);
|
||||
let req = make_request(
|
||||
Some("a cat"), None, Some(5), Some(1),
|
||||
Some(start_token), None, None, None, None,
|
||||
);
|
||||
let frame_d = distill_with_map(&req, &map);
|
||||
|
||||
assert_eq!(text_d.cost.cost_in_usd_cents, frame_d.cost.cost_in_usd_cents);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_estimation_uses_media_tokens_not_urls() {
|
||||
// The Artcraft cost plan should use token-form references.
|
||||
// If it used URLs, the Artcraft plan builder would reject them.
|
||||
// This test passes if distillation succeeds at all — the Artcraft
|
||||
// builder would error out on URL-form refs.
|
||||
let start_token = fake_token("start_frame");
|
||||
let ref_img = fake_token("ref_img_1");
|
||||
let ref_vid = fake_token("ref_vid_1");
|
||||
let ref_aud = fake_token("ref_aud_1");
|
||||
|
||||
let map = build_hydration_map(&[&start_token, &ref_img, &ref_vid, &ref_aud]);
|
||||
|
||||
let req = make_request(
|
||||
Some("test"), None, Some(5), Some(1),
|
||||
Some(start_token), None,
|
||||
Some(vec![ref_img]),
|
||||
Some(vec![ref_vid]),
|
||||
Some(vec![ref_aud]),
|
||||
);
|
||||
|
||||
// If this succeeds, the Artcraft cost path handled tokens correctly.
|
||||
let d = distill_with_map(&req, &map);
|
||||
assert!(d.cost.cost_in_credits.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_duration_produces_valid_cost() {
|
||||
// When duration_seconds is None, the model should use a default.
|
||||
let d = distill_text(&text_only_request(None, None));
|
||||
assert!(d.cost.cost_in_usd_cents.unwrap_or(0) > 0);
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// GENERATION PLAN TESTS
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
mod generation_plan_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn provider_is_seedance2pro() {
|
||||
let d = distill_text(&text_only_request(Some(5), None));
|
||||
assert!(
|
||||
matches!(d.execution_provider, Provider::Seedance2Pro),
|
||||
"expected Seedance2Pro provider, got {:?}", d.execution_provider
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_is_seedance2pro_variant() {
|
||||
let d = distill_text(&text_only_request(Some(5), None));
|
||||
assert!(
|
||||
matches!(d.plan(), VideoGenerationPlan::Seedance2proSeedance2p0(_)),
|
||||
"expected Seedance2proSeedance2p0 plan variant, got {:?}", d.plan()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
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.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");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
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.request.prompt, "a cat dancing");
|
||||
} else {
|
||||
panic!("wrong plan variant");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn start_frame_uses_url_not_token() {
|
||||
let start_token = fake_token("start_frame");
|
||||
let map = build_hydration_map(&[&start_token]);
|
||||
|
||||
let req = make_request(
|
||||
Some("test"), None, Some(5), None,
|
||||
Some(start_token), None, None, None, None,
|
||||
);
|
||||
let d = distill_with_map(&req, &map);
|
||||
|
||||
if let VideoGenerationPlan::Seedance2proSeedance2p0(plan) = d.plan() {
|
||||
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 {
|
||||
panic!("wrong plan variant");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn end_frame_uses_url_not_token() {
|
||||
let end_token = fake_token("end_frame");
|
||||
let map = build_hydration_map(&[&end_token]);
|
||||
|
||||
let req = make_request(
|
||||
Some("test"), None, Some(5), None,
|
||||
None, Some(end_token), None, None, None,
|
||||
);
|
||||
let d = distill_with_map(&req, &map);
|
||||
|
||||
if let VideoGenerationPlan::Seedance2proSeedance2p0(plan) = d.plan() {
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn start_and_end_frame_both_resolved() {
|
||||
let start = fake_token("sf");
|
||||
let end = fake_token("ef");
|
||||
let map = build_hydration_map(&[&start, &end]);
|
||||
|
||||
let req = make_request(
|
||||
Some("test"), None, Some(5), None,
|
||||
Some(start), Some(end), None, None, None,
|
||||
);
|
||||
let d = distill_with_map(&req, &map);
|
||||
|
||||
if let VideoGenerationPlan::Seedance2proSeedance2p0(plan) = d.plan() {
|
||||
assert!(plan.request.start_frame_url.is_some());
|
||||
assert!(plan.request.end_frame_url.is_some());
|
||||
} else {
|
||||
panic!("wrong plan variant");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_images_use_urls() {
|
||||
let img1 = fake_token("img1");
|
||||
let img2 = fake_token("img2");
|
||||
let map = build_hydration_map(&[&img1, &img2]);
|
||||
|
||||
let req = make_request(
|
||||
Some("test"), None, Some(5), None,
|
||||
None, None,
|
||||
Some(vec![img1, img2]),
|
||||
None, None,
|
||||
);
|
||||
let d = distill_with_map(&req, &map);
|
||||
|
||||
if let VideoGenerationPlan::Seedance2proSeedance2p0(plan) = d.plan() {
|
||||
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);
|
||||
}
|
||||
} else {
|
||||
panic!("wrong plan variant");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_videos_use_urls() {
|
||||
let vid1 = fake_token("vid1");
|
||||
let map = build_hydration_map(&[&vid1]);
|
||||
|
||||
let req = make_request(
|
||||
Some("test"), None, Some(5), None,
|
||||
None, None, None,
|
||||
Some(vec![vid1]),
|
||||
None,
|
||||
);
|
||||
let d = distill_with_map(&req, &map);
|
||||
|
||||
if let VideoGenerationPlan::Seedance2proSeedance2p0(plan) = d.plan() {
|
||||
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 {
|
||||
panic!("wrong plan variant");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_audio_use_urls() {
|
||||
let aud1 = fake_token("aud1");
|
||||
let map = build_hydration_map(&[&aud1]);
|
||||
|
||||
let req = make_request(
|
||||
Some("test"), None, Some(5), None,
|
||||
None, None, None, None,
|
||||
Some(vec![aud1]),
|
||||
);
|
||||
let d = distill_with_map(&req, &map);
|
||||
|
||||
if let VideoGenerationPlan::Seedance2proSeedance2p0(plan) = d.plan() {
|
||||
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 {
|
||||
panic!("wrong plan variant");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_reference_types_together() {
|
||||
let sf = fake_token("sf");
|
||||
let ef = fake_token("ef");
|
||||
let img = fake_token("img");
|
||||
let vid = fake_token("vid");
|
||||
let aud = fake_token("aud");
|
||||
let map = build_hydration_map(&[&sf, &ef, &img, &vid, &aud]);
|
||||
|
||||
let req = make_request(
|
||||
Some("full test"), None, Some(8), Some(2),
|
||||
Some(sf), Some(ef),
|
||||
Some(vec![img]),
|
||||
Some(vec![vid]),
|
||||
Some(vec![aud]),
|
||||
);
|
||||
let d = distill_with_map(&req, &map);
|
||||
|
||||
if let VideoGenerationPlan::Seedance2proSeedance2p0(plan) = d.plan() {
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_is_clamped_to_valid_range() {
|
||||
// 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.request.duration_seconds >= 4, "expected >= 4, got {}", plan.request.duration_seconds);
|
||||
} else {
|
||||
panic!("wrong plan variant");
|
||||
}
|
||||
|
||||
// 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.request.duration_seconds <= 15, "expected <= 15, got {}", plan.request.duration_seconds);
|
||||
} else {
|
||||
panic!("wrong plan variant");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_prompt_defaults_to_empty() {
|
||||
let req = make_request(
|
||||
None, None, Some(5), None,
|
||||
None, None, None, None, None,
|
||||
);
|
||||
let d = distill_text(&req);
|
||||
if let VideoGenerationPlan::Seedance2proSeedance2p0(plan) = d.plan() {
|
||||
// Prompt should be None or empty string.
|
||||
assert!(plan.request.prompt.is_empty());
|
||||
} else {
|
||||
panic!("wrong plan variant");
|
||||
}
|
||||
}
|
||||
|
||||
// -- Aspect ratio tests --
|
||||
|
||||
#[test]
|
||||
fn aspect_ratio_wide_16x9() {
|
||||
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.request.aspect_ratio, KinoviAspectRatio::Landscape16x9));
|
||||
} else { panic!("wrong plan variant"); }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aspect_ratio_tall_9x16() {
|
||||
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.request.aspect_ratio, KinoviAspectRatio::Portrait9x16));
|
||||
} else { panic!("wrong plan variant"); }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aspect_ratio_square() {
|
||||
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.request.aspect_ratio, KinoviAspectRatio::Square1x1));
|
||||
} else { panic!("wrong plan variant"); }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aspect_ratio_4x3() {
|
||||
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.request.aspect_ratio, KinoviAspectRatio::Standard4x3));
|
||||
} else { panic!("wrong plan variant"); }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aspect_ratio_3x4() {
|
||||
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.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::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.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::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.request.aspect_ratio, KinoviAspectRatio::Landscape16x9));
|
||||
} else { panic!("wrong plan variant"); }
|
||||
}
|
||||
}
|
||||
}
|
||||
-4
@@ -1,4 +0,0 @@
|
||||
mod test_seedance_1p5_pro;
|
||||
mod test_veo_2;
|
||||
mod test_veo_3;
|
||||
mod kinovi;
|
||||
-772
@@ -1,772 +0,0 @@
|
||||
//! Tests for omni-gen video against the Seedance 1.5 Pro model.
|
||||
//!
|
||||
//! Seedance 1.5 Pro supports both text-to-video and image-to-video (via
|
||||
//! start_frame / end_frame). Pricing is based on resolution × duration ×
|
||||
//! audio, computed via a token-based formula (30 FPS, $2.40/M tokens with
|
||||
//! audio, $1.20/M without). The special case 720p/5s/audio returns a flat 26¢.
|
||||
//!
|
||||
//! The execution plan (`FalSeedance1p5Pro`) carries mode (TextToVideo vs
|
||||
//! ImageToVideo), aspect_ratio, resolution, duration, and generate_audio.
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashMap;
|
||||
|
||||
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_resolution::CommonResolution;
|
||||
use enums::common::generation::common_video_model::CommonVideoModel;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
use url::Url;
|
||||
|
||||
use artcraft_router::api::provider::Provider;
|
||||
use crate::http_server::endpoints::omni_gen::generate::video::distill_video_request::{
|
||||
distill_video_request, DistilledVideoRequest,
|
||||
};
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
fn make_request(
|
||||
prompt: Option<&str>,
|
||||
aspect_ratio: Option<CommonAspectRatio>,
|
||||
resolution: Option<CommonResolution>,
|
||||
duration_seconds: Option<u16>,
|
||||
generate_audio: Option<bool>,
|
||||
start_frame_token: Option<MediaFileToken>,
|
||||
end_frame_token: Option<MediaFileToken>,
|
||||
) -> OmniGenVideoCostAndGenerateRequest {
|
||||
OmniGenVideoCostAndGenerateRequest {
|
||||
idempotency_token: Some("11111111-1111-1111-1111-111111111111".to_string()),
|
||||
model: Some(CommonVideoModel::Seedance1p5Pro),
|
||||
prompt: prompt.map(|s| s.to_string()),
|
||||
negative_prompt: None,
|
||||
start_frame_image_media_token: start_frame_token,
|
||||
end_frame_image_media_token: end_frame_token,
|
||||
reference_image_media_tokens: None,
|
||||
reference_video_media_tokens: None,
|
||||
reference_audio_media_tokens: None,
|
||||
reference_character_tokens: None,
|
||||
resolution,
|
||||
aspect_ratio,
|
||||
quality: None,
|
||||
duration_seconds,
|
||||
video_batch_count: None,
|
||||
generate_audio,
|
||||
}
|
||||
}
|
||||
|
||||
fn fake_token(name: &str) -> (MediaFileToken, HashMap<MediaFileToken, Url>) {
|
||||
let token = MediaFileToken::new_from_str(name);
|
||||
let url = Url::parse(&format!("https://fake.example.com/{}.png", name)).unwrap();
|
||||
let mut map = HashMap::new();
|
||||
map.insert(token.clone(), url);
|
||||
(token, map)
|
||||
}
|
||||
|
||||
fn fake_two_tokens(a: &str, b: &str) -> (MediaFileToken, MediaFileToken, HashMap<MediaFileToken, Url>) {
|
||||
let t1 = MediaFileToken::new_from_str(a);
|
||||
let t2 = MediaFileToken::new_from_str(b);
|
||||
let u1 = Url::parse(&format!("https://fake.example.com/{}.png", a)).unwrap();
|
||||
let u2 = Url::parse(&format!("https://fake.example.com/{}.png", b)).unwrap();
|
||||
let mut map = HashMap::new();
|
||||
map.insert(t1.clone(), u1);
|
||||
map.insert(t2.clone(), u2);
|
||||
(t1, t2, map)
|
||||
}
|
||||
|
||||
fn distill_text(request: &OmniGenVideoCostAndGenerateRequest) -> DistilledVideoRequest {
|
||||
let empty: HashMap<MediaFileToken, Url> = HashMap::new();
|
||||
distill_video_request(request, Some(&empty), Provider::Fal)
|
||||
.expect("distill_video_request should succeed for Seedance 1.5 Pro (text)")
|
||||
}
|
||||
|
||||
fn distill_with_map(
|
||||
request: &OmniGenVideoCostAndGenerateRequest,
|
||||
hydration: &HashMap<MediaFileToken, Url>,
|
||||
) -> DistilledVideoRequest {
|
||||
distill_video_request(request, Some(hydration), Provider::Fal)
|
||||
.expect("distill_video_request should succeed for Seedance 1.5 Pro (image)")
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// COST
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Pricing matches `estimate_video_cost_artcraft_seedance1p5_pro`, which
|
||||
// delegates to the fal token-based calculator:
|
||||
// 720p/5s/audio = flat 26¢
|
||||
// other combos = ceil(H×W×30×dur / 1024 × $rate / 1M × 100)
|
||||
//
|
||||
// Default resolution = 720p, default duration = 5s, default audio = true.
|
||||
mod cost {
|
||||
use super::*;
|
||||
|
||||
mod text {
|
||||
use super::*;
|
||||
|
||||
fn cost(
|
||||
duration: Option<u16>,
|
||||
resolution: Option<CommonResolution>,
|
||||
audio: Option<bool>,
|
||||
) -> u64 {
|
||||
let request = make_request(Some("p"), None, resolution, duration, audio, None, None);
|
||||
distill_text(&request).cost.cost_in_usd_cents.unwrap()
|
||||
}
|
||||
|
||||
// ── Default (720p/5s/audio=true) = 26¢ ───────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn default_costs_26_cents() {
|
||||
assert_eq!(cost(None, None, None), 26);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_5s_720p_audio_costs_26() {
|
||||
assert_eq!(cost(Some(5), Some(CommonResolution::SevenTwentyP), Some(true)), 26);
|
||||
}
|
||||
|
||||
// ── Duration scaling at default resolution ────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn four_seconds_costs_26() {
|
||||
// 4s at 720p uses token calc: ceil(1280*720*30*4/1024 * 2.4/1M * 100) ≈ 26¢
|
||||
assert_eq!(cost(Some(4), None, None), 26);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ten_seconds_costs_65() {
|
||||
assert_eq!(cost(Some(10), None, None), 65);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn twelve_seconds_costs_78() {
|
||||
assert_eq!(cost(Some(12), None, None), 78);
|
||||
}
|
||||
|
||||
// ── Audio toggle ──────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn no_audio_5s_720p_costs_13() {
|
||||
assert_eq!(cost(Some(5), Some(CommonResolution::SevenTwentyP), Some(false)), 13);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_audio_10s_720p() {
|
||||
// ceil(1280*720*30*10/1024 * 1.2/1M * 100) ≈ 33
|
||||
assert_eq!(cost(Some(10), Some(CommonResolution::SevenTwentyP), Some(false)), 33);
|
||||
}
|
||||
|
||||
// ── Resolution changes ────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn ten_eighty_p_5s_audio() {
|
||||
// ceil(1920*1080*30*5/1024 * 2.4/1M * 100) ≈ 73
|
||||
assert_eq!(cost(Some(5), Some(CommonResolution::TenEightyP), Some(true)), 73);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn four_eighty_p_5s_audio() {
|
||||
// ceil(640*480*30*5/1024 * 2.4/1M * 100) ≈ 11
|
||||
assert_eq!(cost(Some(5), Some(CommonResolution::FourEightyP), Some(true)), 11);
|
||||
}
|
||||
|
||||
// ── Cost is independent of aspect ratio ───────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn cost_independent_of_aspect_ratio() {
|
||||
let ars = [
|
||||
None,
|
||||
Some(CommonAspectRatio::Square),
|
||||
Some(CommonAspectRatio::WideSixteenByNine),
|
||||
Some(CommonAspectRatio::TallNineBySixteen),
|
||||
Some(CommonAspectRatio::Auto),
|
||||
];
|
||||
for ar in ars {
|
||||
let request = make_request(Some("p"), ar, None, None, None, None, None);
|
||||
assert_eq!(
|
||||
distill_text(&request).cost.cost_in_usd_cents.unwrap(), 26,
|
||||
"expected 26¢ regardless of aspect ratio {:?}", ar,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Metadata flags ────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn cost_metadata_flags_are_default() {
|
||||
let d = distill_text(&make_request(Some("p"), None, None, None, None, None, None));
|
||||
assert!(!d.cost.is_free);
|
||||
assert!(!d.cost.is_unlimited);
|
||||
assert!(!d.cost.is_rate_limited);
|
||||
assert!(!d.cost.has_watermark);
|
||||
}
|
||||
}
|
||||
|
||||
mod image {
|
||||
use super::*;
|
||||
|
||||
// ── Image-to-video has the same pricing as text-to-video ──────────────
|
||||
|
||||
#[test]
|
||||
fn image_to_video_default_costs_26() {
|
||||
let (start, map) = fake_token("mf_start0000000000000000000000");
|
||||
let request = make_request(Some("p"), None, None, None, None, Some(start), None);
|
||||
assert_eq!(distill_with_map(&request, &map).cost.cost_in_usd_cents.unwrap(), 26);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_to_video_matches_text_to_video_cost() {
|
||||
let text = distill_text(&make_request(Some("p"), None, None, Some(10), Some(true), None, None))
|
||||
.cost.cost_in_usd_cents.unwrap();
|
||||
let (start, map) = fake_token("mf_start0000000000000000000000");
|
||||
let img = distill_with_map(
|
||||
&make_request(Some("p"), None, None, Some(10), Some(true), Some(start), None),
|
||||
&map,
|
||||
).cost.cost_in_usd_cents.unwrap();
|
||||
assert_eq!(text, img);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_to_video_no_audio_costs_13() {
|
||||
let (start, map) = fake_token("mf_start0000000000000000000000");
|
||||
let request = make_request(Some("p"), None, None, None, Some(false), Some(start), None);
|
||||
assert_eq!(distill_with_map(&request, &map).cost.cost_in_usd_cents.unwrap(), 13);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// PLAN
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
mod plan {
|
||||
use super::*;
|
||||
|
||||
use artcraft_router::generate::generate_video::video_generation_plan::VideoGenerationPlan;
|
||||
use artcraft_router::generate::generate_video::plan::fal::plan_generate_video_fal_seedance_1p5_pro::{
|
||||
FalSeedance1p5ProAspectRatio, FalSeedance1p5ProDuration, FalSeedance1p5ProMode,
|
||||
FalSeedance1p5ProResolution, PlanFalSeedance1p5Pro,
|
||||
};
|
||||
|
||||
fn with_plan<F: FnOnce(&PlanFalSeedance1p5Pro)>(
|
||||
request: &OmniGenVideoCostAndGenerateRequest,
|
||||
hydration: &HashMap<MediaFileToken, Url>,
|
||||
assertion: F,
|
||||
) {
|
||||
let distilled = distill_with_map(request, hydration);
|
||||
match distilled.plan() {
|
||||
VideoGenerationPlan::FalSeedance1p5Pro(plan) => assertion(plan),
|
||||
other => panic!("expected FalSeedance1p5Pro, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
fn with_text_plan<F: FnOnce(&PlanFalSeedance1p5Pro)>(
|
||||
request: &OmniGenVideoCostAndGenerateRequest,
|
||||
assertion: F,
|
||||
) {
|
||||
let empty = HashMap::new();
|
||||
with_plan(request, &empty, assertion);
|
||||
}
|
||||
|
||||
mod text {
|
||||
use super::*;
|
||||
|
||||
// ── Mode detection ────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn text_mode_is_text_to_video() {
|
||||
with_text_plan(&make_request(Some("p"), None, None, None, None, None, None), |plan| {
|
||||
assert!(matches!(plan.mode, FalSeedance1p5ProMode::TextToVideo));
|
||||
});
|
||||
}
|
||||
|
||||
// ── Prompt passthrough ────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn prompt_is_passed_through() {
|
||||
with_text_plan(&make_request(Some("a horse on mars"), None, None, None, None, None, None), |plan| {
|
||||
assert_eq!(plan.prompt, "a horse on mars");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_prompt_is_empty_string() {
|
||||
with_text_plan(&make_request(None, None, None, None, None, None, None), |plan| {
|
||||
assert_eq!(plan.prompt, "");
|
||||
});
|
||||
}
|
||||
|
||||
// ── Aspect ratio mappings ─────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn default_aspect_ratio_is_none() {
|
||||
with_text_plan(&make_request(Some("p"), None, None, None, None, None, None), |plan| {
|
||||
assert!(plan.aspect_ratio.is_none());
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_yields_auto() {
|
||||
for ar in [CommonAspectRatio::Auto, CommonAspectRatio::Auto2k, CommonAspectRatio::Auto4k] {
|
||||
with_text_plan(&make_request(Some("p"), Some(ar), None, None, None, None, None), |plan| {
|
||||
assert!(matches!(plan.aspect_ratio, Some(FalSeedance1p5ProAspectRatio::Auto)), "expected Auto for {:?}", ar);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn square_yields_square() {
|
||||
for ar in [CommonAspectRatio::Square, CommonAspectRatio::SquareHd] {
|
||||
with_text_plan(&make_request(Some("p"), Some(ar), None, None, None, None, None), |plan| {
|
||||
assert!(matches!(plan.aspect_ratio, Some(FalSeedance1p5ProAspectRatio::Square)));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wide_16x9_yields_sixteen_by_nine() {
|
||||
for ar in [CommonAspectRatio::WideSixteenByNine, CommonAspectRatio::Wide] {
|
||||
with_text_plan(&make_request(Some("p"), Some(ar), None, None, None, None, None), |plan| {
|
||||
assert!(matches!(plan.aspect_ratio, Some(FalSeedance1p5ProAspectRatio::SixteenByNine)));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wide_4x3_yields_four_by_three() {
|
||||
with_text_plan(&make_request(Some("p"), Some(CommonAspectRatio::WideFourByThree), None, None, None, None, None), |plan| {
|
||||
assert!(matches!(plan.aspect_ratio, Some(FalSeedance1p5ProAspectRatio::FourByThree)));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wide_21x9_yields_twenty_one_by_nine() {
|
||||
with_text_plan(&make_request(Some("p"), Some(CommonAspectRatio::WideTwentyOneByNine), None, None, None, None, None), |plan| {
|
||||
assert!(matches!(plan.aspect_ratio, Some(FalSeedance1p5ProAspectRatio::TwentyOneByNine)));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tall_9x16_yields_nine_by_sixteen() {
|
||||
for ar in [CommonAspectRatio::TallNineBySixteen, CommonAspectRatio::Tall] {
|
||||
with_text_plan(&make_request(Some("p"), Some(ar), None, None, None, None, None), |plan| {
|
||||
assert!(matches!(plan.aspect_ratio, Some(FalSeedance1p5ProAspectRatio::NineBySixteen)));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tall_3x4_yields_three_by_four() {
|
||||
with_text_plan(&make_request(Some("p"), Some(CommonAspectRatio::TallThreeByFour), None, None, None, None, None), |plan| {
|
||||
assert!(matches!(plan.aspect_ratio, Some(FalSeedance1p5ProAspectRatio::ThreeByFour)));
|
||||
});
|
||||
}
|
||||
|
||||
// ── Unsupported aspect ratios fall back to nearest under PayMoreUpgrade
|
||||
|
||||
#[test]
|
||||
fn wide_5x4_falls_back_to_four_by_three() {
|
||||
with_text_plan(&make_request(Some("p"), Some(CommonAspectRatio::WideFiveByFour), None, None, None, None, None), |plan| {
|
||||
assert!(matches!(plan.aspect_ratio, Some(FalSeedance1p5ProAspectRatio::FourByThree)));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wide_3x2_falls_back_to_four_by_three() {
|
||||
with_text_plan(&make_request(Some("p"), Some(CommonAspectRatio::WideThreeByTwo), None, None, None, None, None), |plan| {
|
||||
assert!(matches!(plan.aspect_ratio, Some(FalSeedance1p5ProAspectRatio::FourByThree)));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tall_4x5_falls_back_to_three_by_four() {
|
||||
with_text_plan(&make_request(Some("p"), Some(CommonAspectRatio::TallFourByFive), None, None, None, None, None), |plan| {
|
||||
assert!(matches!(plan.aspect_ratio, Some(FalSeedance1p5ProAspectRatio::ThreeByFour)));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tall_2x3_falls_back_to_three_by_four() {
|
||||
with_text_plan(&make_request(Some("p"), Some(CommonAspectRatio::TallTwoByThree), None, None, None, None, None), |plan| {
|
||||
assert!(matches!(plan.aspect_ratio, Some(FalSeedance1p5ProAspectRatio::ThreeByFour)));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tall_9x21_falls_back_to_nine_by_sixteen() {
|
||||
with_text_plan(&make_request(Some("p"), Some(CommonAspectRatio::TallNineByTwentyOne), None, None, None, None, None), |plan| {
|
||||
assert!(matches!(plan.aspect_ratio, Some(FalSeedance1p5ProAspectRatio::NineBySixteen)));
|
||||
});
|
||||
}
|
||||
|
||||
// ── Duration mapping ──────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn default_duration_is_none() {
|
||||
with_text_plan(&make_request(Some("p"), None, None, None, None, None, None), |plan| {
|
||||
assert!(plan.duration.is_none());
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_4_yields_four() {
|
||||
with_text_plan(&make_request(Some("p"), None, None, Some(4), None, None, None), |plan| {
|
||||
assert!(matches!(plan.duration, Some(FalSeedance1p5ProDuration::Four)));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_5_yields_five() {
|
||||
with_text_plan(&make_request(Some("p"), None, None, Some(5), None, None, None), |plan| {
|
||||
assert!(matches!(plan.duration, Some(FalSeedance1p5ProDuration::Five)));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_6_yields_six() {
|
||||
with_text_plan(&make_request(Some("p"), None, None, Some(6), None, None, None), |plan| {
|
||||
assert!(matches!(plan.duration, Some(FalSeedance1p5ProDuration::Six)));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_7_yields_seven() {
|
||||
with_text_plan(&make_request(Some("p"), None, None, Some(7), None, None, None), |plan| {
|
||||
assert!(matches!(plan.duration, Some(FalSeedance1p5ProDuration::Seven)));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_8_yields_eight() {
|
||||
with_text_plan(&make_request(Some("p"), None, None, Some(8), None, None, None), |plan| {
|
||||
assert!(matches!(plan.duration, Some(FalSeedance1p5ProDuration::Eight)));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_9_yields_nine() {
|
||||
with_text_plan(&make_request(Some("p"), None, None, Some(9), None, None, None), |plan| {
|
||||
assert!(matches!(plan.duration, Some(FalSeedance1p5ProDuration::Nine)));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_10_yields_ten() {
|
||||
with_text_plan(&make_request(Some("p"), None, None, Some(10), None, None, None), |plan| {
|
||||
assert!(matches!(plan.duration, Some(FalSeedance1p5ProDuration::Ten)));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_11_yields_eleven() {
|
||||
with_text_plan(&make_request(Some("p"), None, None, Some(11), None, None, None), |plan| {
|
||||
assert!(matches!(plan.duration, Some(FalSeedance1p5ProDuration::Eleven)));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_12_yields_twelve() {
|
||||
with_text_plan(&make_request(Some("p"), None, None, Some(12), None, None, None), |plan| {
|
||||
assert!(matches!(plan.duration, Some(FalSeedance1p5ProDuration::Twelve)));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_above_12_clamps_to_twelve() {
|
||||
with_text_plan(&make_request(Some("p"), None, None, Some(20), None, None, None), |plan| {
|
||||
assert!(matches!(plan.duration, Some(FalSeedance1p5ProDuration::Twelve)));
|
||||
});
|
||||
}
|
||||
|
||||
// ── Resolution mapping ────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn default_resolution_is_none() {
|
||||
with_text_plan(&make_request(Some("p"), None, None, None, None, None, None), |plan| {
|
||||
assert!(plan.resolution.is_none());
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn four_eighty_p_yields_four_eighty_p() {
|
||||
with_text_plan(&make_request(Some("p"), None, Some(CommonResolution::FourEightyP), None, None, None, None), |plan| {
|
||||
assert!(matches!(plan.resolution, Some(FalSeedance1p5ProResolution::FourEightyP)));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seven_twenty_p_yields_seven_twenty_p() {
|
||||
with_text_plan(&make_request(Some("p"), None, Some(CommonResolution::SevenTwentyP), None, None, None, None), |plan| {
|
||||
assert!(matches!(plan.resolution, Some(FalSeedance1p5ProResolution::SevenTwentyP)));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ten_eighty_p_yields_ten_eighty_p() {
|
||||
with_text_plan(&make_request(Some("p"), None, Some(CommonResolution::TenEightyP), None, None, None, None), |plan| {
|
||||
assert!(matches!(plan.resolution, Some(FalSeedance1p5ProResolution::TenEightyP)));
|
||||
});
|
||||
}
|
||||
|
||||
// ── Generate audio passthrough ────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn default_audio_is_none() {
|
||||
with_text_plan(&make_request(Some("p"), None, None, None, None, None, None), |plan| {
|
||||
assert!(plan.generate_audio.is_none());
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_true_passes_through() {
|
||||
with_text_plan(&make_request(Some("p"), None, None, None, Some(true), None, None), |plan| {
|
||||
assert_eq!(plan.generate_audio, Some(true));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_false_passes_through() {
|
||||
with_text_plan(&make_request(Some("p"), None, None, None, Some(false), None, None), |plan| {
|
||||
assert_eq!(plan.generate_audio, Some(false));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
mod image {
|
||||
use super::*;
|
||||
|
||||
// ── Mode detection ────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn start_frame_triggers_image_to_video() {
|
||||
let (start, map) = fake_token("mf_start0000000000000000000000");
|
||||
let request = make_request(Some("p"), None, None, None, None, Some(start), None);
|
||||
with_plan(&request, &map, |plan| {
|
||||
match &plan.mode {
|
||||
FalSeedance1p5ProMode::ImageToVideo { image_url, end_image_url } => {
|
||||
assert!(image_url.starts_with("https://fake.example.com/"));
|
||||
assert!(end_image_url.is_none());
|
||||
}
|
||||
other => panic!("expected ImageToVideo, got {:?}", other),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn start_and_end_frame_both_hydrated() {
|
||||
let (start, end, map) = fake_two_tokens(
|
||||
"mf_start0000000000000000000000",
|
||||
"mf_end00000000000000000000000000",
|
||||
);
|
||||
let request = make_request(Some("p"), None, None, None, None, Some(start), Some(end));
|
||||
with_plan(&request, &map, |plan| {
|
||||
match &plan.mode {
|
||||
FalSeedance1p5ProMode::ImageToVideo { image_url, end_image_url } => {
|
||||
assert!(image_url.contains("mf_start"));
|
||||
assert!(end_image_url.as_ref().unwrap().contains("mf_end"));
|
||||
}
|
||||
other => panic!("expected ImageToVideo, got {:?}", other),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Prompt passthrough in image mode ──────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn prompt_is_passed_through_in_image_mode() {
|
||||
let (start, map) = fake_token("mf_start0000000000000000000000");
|
||||
let request = make_request(Some("a horse on mars"), None, None, None, None, Some(start), None);
|
||||
with_plan(&request, &map, |plan| {
|
||||
assert_eq!(plan.prompt, "a horse on mars");
|
||||
});
|
||||
}
|
||||
|
||||
// ── Aspect ratio in image mode ────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn aspect_ratio_passes_through_in_image_mode() {
|
||||
let (start, map) = fake_token("mf_start0000000000000000000000");
|
||||
let request = make_request(Some("p"), Some(CommonAspectRatio::WideSixteenByNine), None, None, None, Some(start), None);
|
||||
with_plan(&request, &map, |plan| {
|
||||
assert!(matches!(plan.aspect_ratio, Some(FalSeedance1p5ProAspectRatio::SixteenByNine)));
|
||||
});
|
||||
}
|
||||
|
||||
// ── Duration pass-throughs in image mode ────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn duration_4_in_image_mode() {
|
||||
let (start, map) = fake_token("mf_start0000000000000000000000");
|
||||
let request = make_request(Some("p"), None, None, Some(4), None, Some(start), None);
|
||||
with_plan(&request, &map, |plan| {
|
||||
assert!(matches!(plan.duration, Some(FalSeedance1p5ProDuration::Four)));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_5_in_image_mode() {
|
||||
let (start, map) = fake_token("mf_start0000000000000000000000");
|
||||
let request = make_request(Some("p"), None, None, Some(5), None, Some(start), None);
|
||||
with_plan(&request, &map, |plan| {
|
||||
assert!(matches!(plan.duration, Some(FalSeedance1p5ProDuration::Five)));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_8_in_image_mode() {
|
||||
let (start, map) = fake_token("mf_start0000000000000000000000");
|
||||
let request = make_request(Some("p"), None, None, Some(8), None, Some(start), None);
|
||||
with_plan(&request, &map, |plan| {
|
||||
assert!(matches!(plan.duration, Some(FalSeedance1p5ProDuration::Eight)));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_12_in_image_mode() {
|
||||
let (start, map) = fake_token("mf_start0000000000000000000000");
|
||||
let request = make_request(Some("p"), None, None, Some(12), None, Some(start), None);
|
||||
with_plan(&request, &map, |plan| {
|
||||
assert!(matches!(plan.duration, Some(FalSeedance1p5ProDuration::Twelve)));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_above_12_clamps_to_twelve_in_image_mode() {
|
||||
let (start, map) = fake_token("mf_start0000000000000000000000");
|
||||
let request = make_request(Some("p"), None, None, Some(30), None, Some(start), None);
|
||||
with_plan(&request, &map, |plan| {
|
||||
assert!(matches!(plan.duration, Some(FalSeedance1p5ProDuration::Twelve)));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_duration_is_none_in_image_mode() {
|
||||
let (start, map) = fake_token("mf_start0000000000000000000000");
|
||||
let request = make_request(Some("p"), None, None, None, None, Some(start), None);
|
||||
with_plan(&request, &map, |plan| {
|
||||
assert!(plan.duration.is_none());
|
||||
});
|
||||
}
|
||||
|
||||
// ── Resolution in image mode ──────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn resolution_passes_through_in_image_mode() {
|
||||
let (start, map) = fake_token("mf_start0000000000000000000000");
|
||||
let request = make_request(Some("p"), None, Some(CommonResolution::TenEightyP), None, None, Some(start), None);
|
||||
with_plan(&request, &map, |plan| {
|
||||
assert!(matches!(plan.resolution, Some(FalSeedance1p5ProResolution::TenEightyP)));
|
||||
});
|
||||
}
|
||||
|
||||
// ── Audio in image mode ───────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn audio_passes_through_in_image_mode() {
|
||||
let (start, map) = fake_token("mf_start0000000000000000000000");
|
||||
let request = make_request(Some("p"), None, None, None, Some(false), Some(start), None);
|
||||
with_plan(&request, &map, |plan| {
|
||||
assert_eq!(plan.generate_audio, Some(false));
|
||||
});
|
||||
}
|
||||
|
||||
// ── Media file mapping: exact 1:1 token→URL verification ──────────────
|
||||
|
||||
#[test]
|
||||
fn start_frame_url_matches_hydration_map_exactly() {
|
||||
let token = MediaFileToken::new_from_str("mf_abc00000000000000000000000000");
|
||||
let expected_url = "https://cdn.example.com/images/abc123.png";
|
||||
let mut map = HashMap::new();
|
||||
map.insert(token.clone(), Url::parse(expected_url).unwrap());
|
||||
|
||||
let request = make_request(Some("p"), None, None, None, None, Some(token), None);
|
||||
with_plan(&request, &map, |plan| {
|
||||
match &plan.mode {
|
||||
FalSeedance1p5ProMode::ImageToVideo { image_url, .. } => {
|
||||
assert_eq!(image_url, expected_url);
|
||||
}
|
||||
other => panic!("expected ImageToVideo, got {:?}", other),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn start_and_end_frame_urls_match_hydration_map_exactly() {
|
||||
let start_token = MediaFileToken::new_from_str("mf_start0000000000000000000000");
|
||||
let end_token = MediaFileToken::new_from_str("mf_end00000000000000000000000000");
|
||||
let start_url = "https://cdn.example.com/images/start_frame.png";
|
||||
let end_url = "https://cdn.example.com/images/end_frame.png";
|
||||
|
||||
let mut map = HashMap::new();
|
||||
map.insert(start_token.clone(), Url::parse(start_url).unwrap());
|
||||
map.insert(end_token.clone(), Url::parse(end_url).unwrap());
|
||||
|
||||
let request = make_request(Some("p"), None, None, None, None, Some(start_token), Some(end_token));
|
||||
with_plan(&request, &map, |plan| {
|
||||
match &plan.mode {
|
||||
FalSeedance1p5ProMode::ImageToVideo { image_url, end_image_url } => {
|
||||
assert_eq!(image_url, start_url);
|
||||
assert_eq!(end_image_url.as_deref().unwrap(), end_url);
|
||||
}
|
||||
other => panic!("expected ImageToVideo, got {:?}", other),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hydration_map_with_extra_tokens_still_resolves_correctly() {
|
||||
// Map has 4 tokens but only start_frame + end_frame are used by
|
||||
// the request. The extras are ignored.
|
||||
let start_token = MediaFileToken::new_from_str("mf_start0000000000000000000000");
|
||||
let end_token = MediaFileToken::new_from_str("mf_end00000000000000000000000000");
|
||||
let extra1 = MediaFileToken::new_from_str("mf_extra1000000000000000000000000");
|
||||
let extra2 = MediaFileToken::new_from_str("mf_extra2000000000000000000000000");
|
||||
|
||||
let start_url = "https://cdn.example.com/start.png";
|
||||
let end_url = "https://cdn.example.com/end.png";
|
||||
|
||||
let mut map = HashMap::new();
|
||||
map.insert(start_token.clone(), Url::parse(start_url).unwrap());
|
||||
map.insert(end_token.clone(), Url::parse(end_url).unwrap());
|
||||
map.insert(extra1, Url::parse("https://cdn.example.com/extra1.png").unwrap());
|
||||
map.insert(extra2, Url::parse("https://cdn.example.com/extra2.png").unwrap());
|
||||
|
||||
let request = make_request(Some("p"), None, None, None, None, Some(start_token), Some(end_token));
|
||||
with_plan(&request, &map, |plan| {
|
||||
match &plan.mode {
|
||||
FalSeedance1p5ProMode::ImageToVideo { image_url, end_image_url } => {
|
||||
assert_eq!(image_url, start_url);
|
||||
assert_eq!(end_image_url.as_deref().unwrap(), end_url);
|
||||
}
|
||||
other => panic!("expected ImageToVideo, got {:?}", other),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn start_frame_only_with_larger_hydration_map() {
|
||||
// Map has 3 tokens, request only uses start_frame.
|
||||
let start_token = MediaFileToken::new_from_str("mf_start0000000000000000000000");
|
||||
let other1 = MediaFileToken::new_from_str("mf_other1000000000000000000000000");
|
||||
let other2 = MediaFileToken::new_from_str("mf_other2000000000000000000000000");
|
||||
|
||||
let start_url = "https://cdn.example.com/keyframe.jpg";
|
||||
|
||||
let mut map = HashMap::new();
|
||||
map.insert(start_token.clone(), Url::parse(start_url).unwrap());
|
||||
map.insert(other1, Url::parse("https://cdn.example.com/other1.jpg").unwrap());
|
||||
map.insert(other2, Url::parse("https://cdn.example.com/other2.jpg").unwrap());
|
||||
|
||||
let request = make_request(Some("p"), None, None, None, None, Some(start_token), None);
|
||||
with_plan(&request, &map, |plan| {
|
||||
match &plan.mode {
|
||||
FalSeedance1p5ProMode::ImageToVideo { image_url, end_image_url } => {
|
||||
assert_eq!(image_url, start_url);
|
||||
assert!(end_image_url.is_none());
|
||||
}
|
||||
other => panic!("expected ImageToVideo, got {:?}", other),
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-443
@@ -1,443 +0,0 @@
|
||||
//! Tests for omni-gen video against the Veo 2 model.
|
||||
//!
|
||||
//! Veo 2 supports both text-to-video and image-to-video (via start_frame).
|
||||
//!
|
||||
//! Pricing: $2.50 for the first 5 seconds, +$0.50 per additional second.
|
||||
//! 5s → 250¢, 6s → 300¢, 7s → 350¢, 8s → 400¢
|
||||
//!
|
||||
//! Supported aspect ratios: Auto, WideSixteenNine, TallNineSixteen.
|
||||
//! Unsupported ratios fall back to Auto under PayMoreUpgrade.
|
||||
//!
|
||||
//! Supported durations: 5–8 seconds. Over-max clamps to 8s (PayMoreUpgrade).
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashMap;
|
||||
|
||||
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_video_model::CommonVideoModel;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
use url::Url;
|
||||
|
||||
use artcraft_router::api::provider::Provider;
|
||||
use crate::http_server::endpoints::omni_gen::generate::video::distill_video_request::{
|
||||
distill_video_request, DistilledVideoRequest,
|
||||
};
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
fn make_request(
|
||||
prompt: Option<&str>,
|
||||
aspect_ratio: Option<CommonAspectRatio>,
|
||||
duration_seconds: Option<u16>,
|
||||
start_frame_token: Option<MediaFileToken>,
|
||||
) -> OmniGenVideoCostAndGenerateRequest {
|
||||
OmniGenVideoCostAndGenerateRequest {
|
||||
idempotency_token: Some("11111111-1111-1111-1111-111111111111".to_string()),
|
||||
model: Some(CommonVideoModel::Veo2),
|
||||
prompt: prompt.map(|s| s.to_string()),
|
||||
negative_prompt: None,
|
||||
start_frame_image_media_token: start_frame_token,
|
||||
end_frame_image_media_token: None,
|
||||
reference_image_media_tokens: None,
|
||||
reference_video_media_tokens: None,
|
||||
reference_audio_media_tokens: None,
|
||||
reference_character_tokens: None,
|
||||
resolution: None,
|
||||
aspect_ratio,
|
||||
quality: None,
|
||||
duration_seconds,
|
||||
video_batch_count: None,
|
||||
generate_audio: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn fake_token(name: &str) -> (MediaFileToken, HashMap<MediaFileToken, Url>) {
|
||||
let token = MediaFileToken::new_from_str(name);
|
||||
let url = Url::parse(&format!("https://fake.example.com/{}.png", name)).unwrap();
|
||||
let mut map = HashMap::new();
|
||||
map.insert(token.clone(), url);
|
||||
(token, map)
|
||||
}
|
||||
|
||||
fn distill_text(request: &OmniGenVideoCostAndGenerateRequest) -> DistilledVideoRequest {
|
||||
let empty: HashMap<MediaFileToken, Url> = HashMap::new();
|
||||
distill_video_request(request, Some(&empty), Provider::Fal)
|
||||
.expect("distill_video_request should succeed for Veo 2 (text)")
|
||||
}
|
||||
|
||||
fn distill_image(
|
||||
request: &OmniGenVideoCostAndGenerateRequest,
|
||||
hydration: &HashMap<MediaFileToken, Url>,
|
||||
) -> DistilledVideoRequest {
|
||||
distill_video_request(request, Some(hydration), Provider::Fal)
|
||||
.expect("distill_video_request should succeed for Veo 2 (image)")
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// COST
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
mod cost {
|
||||
use super::*;
|
||||
|
||||
mod text {
|
||||
use super::*;
|
||||
|
||||
fn cost(duration: Option<u16>) -> u64 {
|
||||
let request = make_request(Some("p"), None, duration, None);
|
||||
distill_text(&request).cost.cost_in_usd_cents.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_duration_costs_250() { assert_eq!(cost(None), 250); }
|
||||
|
||||
#[test]
|
||||
fn five_seconds_costs_250() { assert_eq!(cost(Some(5)), 250); }
|
||||
|
||||
#[test]
|
||||
fn six_seconds_costs_300() { assert_eq!(cost(Some(6)), 300); }
|
||||
|
||||
#[test]
|
||||
fn seven_seconds_costs_350() { assert_eq!(cost(Some(7)), 350); }
|
||||
|
||||
#[test]
|
||||
fn eight_seconds_costs_400() { assert_eq!(cost(Some(8)), 400); }
|
||||
|
||||
#[test]
|
||||
fn duration_above_8_clamps_to_400() { assert_eq!(cost(Some(15)), 400); }
|
||||
|
||||
#[test]
|
||||
fn cost_independent_of_aspect_ratio() {
|
||||
let ars = [
|
||||
None,
|
||||
Some(CommonAspectRatio::Auto),
|
||||
Some(CommonAspectRatio::WideSixteenByNine),
|
||||
Some(CommonAspectRatio::TallNineBySixteen),
|
||||
Some(CommonAspectRatio::Square),
|
||||
];
|
||||
for ar in ars {
|
||||
let request = make_request(Some("p"), ar, None, None);
|
||||
assert_eq!(distill_text(&request).cost.cost_in_usd_cents.unwrap(), 250);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_metadata_flags_are_default() {
|
||||
let d = distill_text(&make_request(Some("p"), None, None, None));
|
||||
assert!(!d.cost.is_free);
|
||||
assert!(!d.cost.is_unlimited);
|
||||
assert!(!d.cost.is_rate_limited);
|
||||
assert!(!d.cost.has_watermark);
|
||||
}
|
||||
}
|
||||
|
||||
mod image {
|
||||
use super::*;
|
||||
|
||||
fn cost(duration: Option<u16>) -> u64 {
|
||||
let (start, map) = fake_token("mf_start0000000000000000000000");
|
||||
let request = make_request(Some("p"), None, duration, Some(start));
|
||||
distill_image(&request, &map).cost.cost_in_usd_cents.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_duration_costs_250() { assert_eq!(cost(None), 250); }
|
||||
|
||||
#[test]
|
||||
fn five_seconds_costs_250() { assert_eq!(cost(Some(5)), 250); }
|
||||
|
||||
#[test]
|
||||
fn six_seconds_costs_300() { assert_eq!(cost(Some(6)), 300); }
|
||||
|
||||
#[test]
|
||||
fn seven_seconds_costs_350() { assert_eq!(cost(Some(7)), 350); }
|
||||
|
||||
#[test]
|
||||
fn eight_seconds_costs_400() { assert_eq!(cost(Some(8)), 400); }
|
||||
|
||||
#[test]
|
||||
fn duration_above_8_clamps_to_400() { assert_eq!(cost(Some(15)), 400); }
|
||||
|
||||
#[test]
|
||||
fn image_and_text_cost_match() {
|
||||
for dur in [5u16, 6, 7, 8] {
|
||||
let text = distill_text(&make_request(Some("p"), None, Some(dur), None))
|
||||
.cost.cost_in_usd_cents.unwrap();
|
||||
let (start, map) = fake_token("mf_start0000000000000000000000");
|
||||
let img = distill_image(
|
||||
&make_request(Some("p"), None, Some(dur), Some(start)),
|
||||
&map,
|
||||
).cost.cost_in_usd_cents.unwrap();
|
||||
assert_eq!(text, img, "text/image cost diverged at dur={}", dur);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// PLAN
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
mod plan {
|
||||
use super::*;
|
||||
|
||||
use artcraft_router::generate::generate_video::video_generation_plan::VideoGenerationPlan;
|
||||
use artcraft_router::generate::generate_video::plan::fal::plan_generate_video_fal_veo_2::{
|
||||
FalVeo2Mode, PlanFalVeo2,
|
||||
};
|
||||
use fal_client::requests::webhook::video::image::enqueue_veo_2_image_to_video_webhook::{
|
||||
Veo2AspectRatio, Veo2Duration,
|
||||
};
|
||||
|
||||
fn with_text_plan<F: FnOnce(&PlanFalVeo2)>(
|
||||
request: &OmniGenVideoCostAndGenerateRequest,
|
||||
assertion: F,
|
||||
) {
|
||||
let distilled = distill_text(request);
|
||||
match distilled.plan() {
|
||||
VideoGenerationPlan::FalVeo2(plan) => assertion(plan),
|
||||
other => panic!("expected FalVeo2, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
fn with_image_plan<F: FnOnce(&PlanFalVeo2)>(
|
||||
request: &OmniGenVideoCostAndGenerateRequest,
|
||||
hydration: &HashMap<MediaFileToken, Url>,
|
||||
assertion: F,
|
||||
) {
|
||||
let distilled = distill_image(request, hydration);
|
||||
match distilled.plan() {
|
||||
VideoGenerationPlan::FalVeo2(plan) => assertion(plan),
|
||||
other => panic!("expected FalVeo2, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
mod text {
|
||||
use super::*;
|
||||
|
||||
// ── Mode detection ────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn text_mode_is_text_to_video() {
|
||||
with_text_plan(&make_request(Some("p"), None, None, None), |plan| {
|
||||
assert!(matches!(plan.mode, FalVeo2Mode::TextToVideo));
|
||||
});
|
||||
}
|
||||
|
||||
// ── Prompt passthrough ────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn prompt_is_passed_through() {
|
||||
with_text_plan(&make_request(Some("a horse on mars"), None, None, None), |plan| {
|
||||
assert_eq!(plan.prompt, "a horse on mars");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_prompt_is_empty_string() {
|
||||
with_text_plan(&make_request(None, None, None, None), |plan| {
|
||||
assert_eq!(plan.prompt, "");
|
||||
});
|
||||
}
|
||||
|
||||
// ── Aspect ratio mappings ─────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn default_aspect_ratio_is_auto() {
|
||||
with_text_plan(&make_request(Some("p"), None, None, None), |plan| {
|
||||
assert!(matches!(plan.aspect_ratio, Some(Veo2AspectRatio::Auto)));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_yields_auto() {
|
||||
for ar in [CommonAspectRatio::Auto, CommonAspectRatio::Auto2k, CommonAspectRatio::Auto4k] {
|
||||
with_text_plan(&make_request(Some("p"), Some(ar), None, None), |plan| {
|
||||
assert!(matches!(plan.aspect_ratio, Some(Veo2AspectRatio::Auto)));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wide_16x9_yields_wide() {
|
||||
for ar in [CommonAspectRatio::WideSixteenByNine, CommonAspectRatio::Wide] {
|
||||
with_text_plan(&make_request(Some("p"), Some(ar), None, None), |plan| {
|
||||
assert!(matches!(plan.aspect_ratio, Some(Veo2AspectRatio::WideSixteenNine)));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tall_9x16_yields_tall() {
|
||||
for ar in [CommonAspectRatio::TallNineBySixteen, CommonAspectRatio::Tall] {
|
||||
with_text_plan(&make_request(Some("p"), Some(ar), None, None), |plan| {
|
||||
assert!(matches!(plan.aspect_ratio, Some(Veo2AspectRatio::TallNineSixteen)));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_aspect_ratio_falls_back_to_auto() {
|
||||
for ar in [
|
||||
CommonAspectRatio::Square, CommonAspectRatio::SquareHd,
|
||||
CommonAspectRatio::WideFourByThree, CommonAspectRatio::TallThreeByFour,
|
||||
] {
|
||||
with_text_plan(&make_request(Some("p"), Some(ar), None, None), |plan| {
|
||||
assert!(matches!(plan.aspect_ratio, Some(Veo2AspectRatio::Auto)));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Duration mappings ─────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn default_duration_is_default() {
|
||||
with_text_plan(&make_request(Some("p"), None, None, None), |plan| {
|
||||
assert!(matches!(plan.duration, Veo2Duration::Default));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_5_yields_five() {
|
||||
with_text_plan(&make_request(Some("p"), None, Some(5), None), |plan| {
|
||||
assert!(matches!(plan.duration, Veo2Duration::FiveSeconds));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_6_yields_six() {
|
||||
with_text_plan(&make_request(Some("p"), None, Some(6), None), |plan| {
|
||||
assert!(matches!(plan.duration, Veo2Duration::SixSeconds));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_7_yields_seven() {
|
||||
with_text_plan(&make_request(Some("p"), None, Some(7), None), |plan| {
|
||||
assert!(matches!(plan.duration, Veo2Duration::SevenSeconds));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_8_yields_eight() {
|
||||
with_text_plan(&make_request(Some("p"), None, Some(8), None), |plan| {
|
||||
assert!(matches!(plan.duration, Veo2Duration::EightSeconds));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_above_8_clamps_to_eight() {
|
||||
with_text_plan(&make_request(Some("p"), None, Some(20), None), |plan| {
|
||||
assert!(matches!(plan.duration, Veo2Duration::EightSeconds));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
mod image {
|
||||
use super::*;
|
||||
|
||||
// ── Mode detection / start frame hydration ────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn start_frame_triggers_image_to_video() {
|
||||
let (start, map) = fake_token("mf_start0000000000000000000000");
|
||||
let request = make_request(Some("p"), None, None, Some(start));
|
||||
with_image_plan(&request, &map, |plan| {
|
||||
match &plan.mode {
|
||||
FalVeo2Mode::ImageToVideo { image_url } => {
|
||||
assert!(image_url.starts_with("https://fake.example.com/"));
|
||||
}
|
||||
other => panic!("expected ImageToVideo, got {:?}", other),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn start_frame_url_matches_hydration_map_exactly() {
|
||||
let token = MediaFileToken::new_from_str("mf_myframe0000000000000000000000");
|
||||
let expected_url = "https://cdn.example.com/frames/my_frame.png";
|
||||
let mut map = HashMap::new();
|
||||
map.insert(token.clone(), Url::parse(expected_url).unwrap());
|
||||
|
||||
let request = make_request(Some("p"), None, None, Some(token));
|
||||
with_image_plan(&request, &map, |plan| {
|
||||
match &plan.mode {
|
||||
FalVeo2Mode::ImageToVideo { image_url } => {
|
||||
assert_eq!(image_url, expected_url);
|
||||
}
|
||||
other => panic!("expected ImageToVideo, got {:?}", other),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Prompt passthrough in image mode ──────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn prompt_is_passed_through_in_image_mode() {
|
||||
let (start, map) = fake_token("mf_start0000000000000000000000");
|
||||
let request = make_request(Some("a horse on mars"), None, None, Some(start));
|
||||
with_image_plan(&request, &map, |plan| {
|
||||
assert_eq!(plan.prompt, "a horse on mars");
|
||||
});
|
||||
}
|
||||
|
||||
// ── Aspect ratio is None in image mode (i2v inherits source AR) ────────
|
||||
|
||||
#[test]
|
||||
fn aspect_ratio_is_none_in_image_mode() {
|
||||
let (start, map) = fake_token("mf_start0000000000000000000000");
|
||||
let request = make_request(Some("p"), Some(CommonAspectRatio::WideSixteenByNine), None, Some(start));
|
||||
with_image_plan(&request, &map, |plan| {
|
||||
assert!(plan.aspect_ratio.is_none(), "image-to-video should not have aspect_ratio");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aspect_ratio_is_none_in_image_mode_even_with_tall() {
|
||||
let (start, map) = fake_token("mf_start0000000000000000000000");
|
||||
let request = make_request(Some("p"), Some(CommonAspectRatio::TallNineBySixteen), None, Some(start));
|
||||
with_image_plan(&request, &map, |plan| {
|
||||
assert!(plan.aspect_ratio.is_none());
|
||||
});
|
||||
}
|
||||
|
||||
// ── Duration pass-throughs in image mode ──────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn duration_5_in_image_mode() {
|
||||
let (start, map) = fake_token("mf_start0000000000000000000000");
|
||||
let request = make_request(Some("p"), None, Some(5), Some(start));
|
||||
with_image_plan(&request, &map, |plan| {
|
||||
assert!(matches!(plan.duration, Veo2Duration::FiveSeconds));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_8_in_image_mode() {
|
||||
let (start, map) = fake_token("mf_start0000000000000000000000");
|
||||
let request = make_request(Some("p"), None, Some(8), Some(start));
|
||||
with_image_plan(&request, &map, |plan| {
|
||||
assert!(matches!(plan.duration, Veo2Duration::EightSeconds));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_above_8_clamps_in_image_mode() {
|
||||
let (start, map) = fake_token("mf_start0000000000000000000000");
|
||||
let request = make_request(Some("p"), None, Some(20), Some(start));
|
||||
with_image_plan(&request, &map, |plan| {
|
||||
assert!(matches!(plan.duration, Veo2Duration::EightSeconds));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_duration_in_image_mode() {
|
||||
let (start, map) = fake_token("mf_start0000000000000000000000");
|
||||
let request = make_request(Some("p"), None, None, Some(start));
|
||||
with_image_plan(&request, &map, |plan| {
|
||||
assert!(matches!(plan.duration, Veo2Duration::Default));
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-590
@@ -1,590 +0,0 @@
|
||||
//! Tests for omni-gen video against the Veo 3 model.
|
||||
//!
|
||||
//! Veo 3 supports both text-to-video and image-to-video (via start_frame).
|
||||
//!
|
||||
//! Pricing: $0.20/sec (audio off) or $0.40/sec (audio on).
|
||||
//! Default: 8s, audio off (artcraft legacy) → 160¢.
|
||||
//! 4s no-audio → 80¢, 6s no-audio → 120¢, 8s no-audio → 160¢
|
||||
//! 4s audio → 160¢, 6s audio → 240¢, 8s audio → 320¢
|
||||
//!
|
||||
//! Supported aspect ratios (text mode only): Default, 16:9, 9:16, Square.
|
||||
//! Image-to-video inherits the source frame's aspect ratio.
|
||||
//!
|
||||
//! Supported resolutions: 720p, 1080p.
|
||||
//! Supported durations: 4s, 6s, 8s. Over-max clamps to 8s (PayMoreUpgrade).
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashMap;
|
||||
|
||||
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_resolution::CommonResolution;
|
||||
use enums::common::generation::common_video_model::CommonVideoModel;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
use url::Url;
|
||||
|
||||
use artcraft_router::api::provider::Provider;
|
||||
use crate::http_server::endpoints::omni_gen::generate::video::distill_video_request::{
|
||||
distill_video_request, DistilledVideoRequest,
|
||||
};
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
fn make_request(
|
||||
prompt: Option<&str>,
|
||||
aspect_ratio: Option<CommonAspectRatio>,
|
||||
resolution: Option<CommonResolution>,
|
||||
duration_seconds: Option<u16>,
|
||||
generate_audio: Option<bool>,
|
||||
start_frame_token: Option<MediaFileToken>,
|
||||
) -> OmniGenVideoCostAndGenerateRequest {
|
||||
OmniGenVideoCostAndGenerateRequest {
|
||||
idempotency_token: Some("11111111-1111-1111-1111-111111111111".to_string()),
|
||||
model: Some(CommonVideoModel::Veo3),
|
||||
prompt: prompt.map(|s| s.to_string()),
|
||||
negative_prompt: None,
|
||||
start_frame_image_media_token: start_frame_token,
|
||||
end_frame_image_media_token: None,
|
||||
reference_image_media_tokens: None,
|
||||
reference_video_media_tokens: None,
|
||||
reference_audio_media_tokens: None,
|
||||
reference_character_tokens: None,
|
||||
resolution,
|
||||
aspect_ratio,
|
||||
quality: None,
|
||||
duration_seconds,
|
||||
video_batch_count: None,
|
||||
generate_audio,
|
||||
}
|
||||
}
|
||||
|
||||
fn fake_token(name: &str) -> (MediaFileToken, HashMap<MediaFileToken, Url>) {
|
||||
let token = MediaFileToken::new_from_str(name);
|
||||
let url = Url::parse(&format!("https://fake.example.com/{}.png", name)).unwrap();
|
||||
let mut map = HashMap::new();
|
||||
map.insert(token.clone(), url);
|
||||
(token, map)
|
||||
}
|
||||
|
||||
fn distill_text(request: &OmniGenVideoCostAndGenerateRequest) -> DistilledVideoRequest {
|
||||
let empty: HashMap<MediaFileToken, Url> = HashMap::new();
|
||||
distill_video_request(request, Some(&empty), Provider::Fal)
|
||||
.expect("distill should succeed for Veo 3 (text)")
|
||||
}
|
||||
|
||||
fn distill_image(
|
||||
request: &OmniGenVideoCostAndGenerateRequest,
|
||||
hydration: &HashMap<MediaFileToken, Url>,
|
||||
) -> DistilledVideoRequest {
|
||||
distill_video_request(request, Some(hydration), Provider::Fal)
|
||||
.expect("distill should succeed for Veo 3 (image)")
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// COST
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Artcraft cost path: always 8s (legacy). $0.20/sec no-audio, $0.40/sec audio.
|
||||
// Default audio = false (artcraft legacy).
|
||||
mod cost {
|
||||
use super::*;
|
||||
|
||||
mod text {
|
||||
use super::*;
|
||||
|
||||
fn cost(duration: Option<u16>, audio: Option<bool>) -> u64 {
|
||||
let request = make_request(Some("p"), None, None, duration, audio, None);
|
||||
distill_text(&request).cost.cost_in_usd_cents.unwrap()
|
||||
}
|
||||
|
||||
// ── No audio: $0.20/sec ──────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn default_no_audio_costs_160() { assert_eq!(cost(None, None), 160); } // 8s × 20¢
|
||||
|
||||
#[test]
|
||||
fn four_seconds_no_audio_costs_80() { assert_eq!(cost(Some(4), Some(false)), 80); }
|
||||
|
||||
#[test]
|
||||
fn six_seconds_no_audio_costs_120() { assert_eq!(cost(Some(6), Some(false)), 120); }
|
||||
|
||||
#[test]
|
||||
fn eight_seconds_no_audio_costs_160() { assert_eq!(cost(Some(8), Some(false)), 160); }
|
||||
|
||||
// ── Audio on: $0.40/sec ───────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn four_seconds_audio_on_costs_160() { assert_eq!(cost(Some(4), Some(true)), 160); }
|
||||
|
||||
#[test]
|
||||
fn six_seconds_audio_on_costs_240() { assert_eq!(cost(Some(6), Some(true)), 240); }
|
||||
|
||||
#[test]
|
||||
fn eight_seconds_audio_on_costs_320() { assert_eq!(cost(Some(8), Some(true)), 320); }
|
||||
|
||||
#[test]
|
||||
fn default_duration_audio_on_costs_320() { assert_eq!(cost(None, Some(true)), 320); }
|
||||
|
||||
// ── Over-max clamps to 8s ─────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn duration_above_8_clamps_to_160_no_audio() { assert_eq!(cost(Some(20), Some(false)), 160); }
|
||||
|
||||
#[test]
|
||||
fn duration_above_8_clamps_to_320_audio() { assert_eq!(cost(Some(20), Some(true)), 320); }
|
||||
|
||||
// ── Cost is independent of aspect ratio ───────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn cost_independent_of_aspect_ratio() {
|
||||
for ar in [None, Some(CommonAspectRatio::Auto), Some(CommonAspectRatio::WideSixteenByNine), Some(CommonAspectRatio::TallNineBySixteen)] {
|
||||
let request = make_request(Some("p"), ar, None, None, None, None);
|
||||
assert_eq!(distill_text(&request).cost.cost_in_usd_cents.unwrap(), 160);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Cost is independent of resolution ─────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn cost_independent_of_resolution() {
|
||||
for res in [None, Some(CommonResolution::SevenTwentyP), Some(CommonResolution::TenEightyP)] {
|
||||
let request = make_request(Some("p"), None, res, None, None, None);
|
||||
assert_eq!(distill_text(&request).cost.cost_in_usd_cents.unwrap(), 160);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Metadata flags ────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn cost_metadata_flags_are_default() {
|
||||
let d = distill_text(&make_request(Some("p"), None, None, None, None, None));
|
||||
assert!(!d.cost.is_free);
|
||||
assert!(!d.cost.is_unlimited);
|
||||
assert!(!d.cost.is_rate_limited);
|
||||
assert!(!d.cost.has_watermark);
|
||||
}
|
||||
}
|
||||
|
||||
mod image {
|
||||
use super::*;
|
||||
|
||||
fn cost_img(duration: Option<u16>, audio: Option<bool>) -> u64 {
|
||||
let (start, map) = fake_token("mf_start0000000000000000000000");
|
||||
let request = make_request(Some("p"), None, None, duration, audio, Some(start));
|
||||
distill_image(&request, &map).cost.cost_in_usd_cents.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_costs_160() { assert_eq!(cost_img(None, None), 160); }
|
||||
|
||||
#[test]
|
||||
fn four_seconds_no_audio_costs_80() { assert_eq!(cost_img(Some(4), Some(false)), 80); }
|
||||
|
||||
#[test]
|
||||
fn six_seconds_audio_on_costs_240() { assert_eq!(cost_img(Some(6), Some(true)), 240); }
|
||||
|
||||
#[test]
|
||||
fn eight_seconds_audio_on_costs_320() { assert_eq!(cost_img(Some(8), Some(true)), 320); }
|
||||
|
||||
#[test]
|
||||
fn image_and_text_cost_match() {
|
||||
let text = distill_text(&make_request(Some("p"), None, None, None, None, None))
|
||||
.cost.cost_in_usd_cents.unwrap();
|
||||
let (start, map) = fake_token("mf_start0000000000000000000000");
|
||||
let img = distill_image(
|
||||
&make_request(Some("p"), None, None, None, None, Some(start)),
|
||||
&map,
|
||||
).cost.cost_in_usd_cents.unwrap();
|
||||
assert_eq!(text, img);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// PLAN
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
mod plan {
|
||||
use super::*;
|
||||
|
||||
use artcraft_router::generate::generate_video::video_generation_plan::VideoGenerationPlan;
|
||||
use artcraft_router::generate::generate_video::plan::fal::plan_generate_video_fal_veo_3::{
|
||||
FalVeo3Duration, FalVeo3I2vAspectRatio, FalVeo3Mode, FalVeo3Resolution,
|
||||
FalVeo3T2vAspectRatio, PlanFalVeo3,
|
||||
};
|
||||
|
||||
fn with_text_plan<F: FnOnce(&PlanFalVeo3)>(
|
||||
request: &OmniGenVideoCostAndGenerateRequest,
|
||||
assertion: F,
|
||||
) {
|
||||
let distilled = distill_text(request);
|
||||
match distilled.plan() {
|
||||
VideoGenerationPlan::FalVeo3(plan) => assertion(plan),
|
||||
other => panic!("expected FalVeo3, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
fn with_image_plan<F: FnOnce(&PlanFalVeo3)>(
|
||||
request: &OmniGenVideoCostAndGenerateRequest,
|
||||
hydration: &HashMap<MediaFileToken, Url>,
|
||||
assertion: F,
|
||||
) {
|
||||
let distilled = distill_image(request, hydration);
|
||||
match distilled.plan() {
|
||||
VideoGenerationPlan::FalVeo3(plan) => assertion(plan),
|
||||
other => panic!("expected FalVeo3, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
mod text {
|
||||
use super::*;
|
||||
|
||||
// ── Mode detection ────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn text_mode_is_text_to_video() {
|
||||
with_text_plan(&make_request(Some("p"), None, None, None, None, None), |plan| {
|
||||
assert!(matches!(plan.mode, FalVeo3Mode::TextToVideo));
|
||||
});
|
||||
}
|
||||
|
||||
// ── Prompt passthrough ────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn prompt_is_passed_through() {
|
||||
with_text_plan(&make_request(Some("a horse on mars"), None, None, None, None, None), |plan| {
|
||||
assert_eq!(plan.prompt, "a horse on mars");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_prompt_is_empty_string() {
|
||||
with_text_plan(&make_request(None, None, None, None, None, None), |plan| {
|
||||
assert_eq!(plan.prompt, "");
|
||||
});
|
||||
}
|
||||
|
||||
// ── Aspect ratio mappings (text mode only) ────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn default_aspect_ratio_is_default() {
|
||||
with_text_plan(&make_request(Some("p"), None, None, None, None, None), |plan| {
|
||||
assert!(matches!(plan.t2v_aspect_ratio, Some(FalVeo3T2vAspectRatio::Default)));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wide_16x9_yields_wide() {
|
||||
for ar in [CommonAspectRatio::WideSixteenByNine, CommonAspectRatio::Wide] {
|
||||
with_text_plan(&make_request(Some("p"), Some(ar), None, None, None, None), |plan| {
|
||||
assert!(matches!(plan.t2v_aspect_ratio, Some(FalVeo3T2vAspectRatio::WideSixteenNine)));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tall_9x16_yields_tall() {
|
||||
for ar in [CommonAspectRatio::TallNineBySixteen, CommonAspectRatio::Tall] {
|
||||
with_text_plan(&make_request(Some("p"), Some(ar), None, None, None, None), |plan| {
|
||||
assert!(matches!(plan.t2v_aspect_ratio, Some(FalVeo3T2vAspectRatio::TallNineSixteen)));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn square_falls_back_to_default() {
|
||||
// Veo 3 t2v has no Square — falls back to Default (16:9).
|
||||
for ar in [CommonAspectRatio::Square, CommonAspectRatio::SquareHd] {
|
||||
with_text_plan(&make_request(Some("p"), Some(ar), None, None, None, None), |plan| {
|
||||
assert!(matches!(plan.t2v_aspect_ratio, Some(FalVeo3T2vAspectRatio::Default)));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_falls_back_to_default() {
|
||||
// Veo 3 t2v has no Auto — falls back to Default (16:9).
|
||||
for ar in [CommonAspectRatio::Auto, CommonAspectRatio::Auto2k, CommonAspectRatio::Auto4k] {
|
||||
with_text_plan(&make_request(Some("p"), Some(ar), None, None, None, None), |plan| {
|
||||
assert!(matches!(plan.t2v_aspect_ratio, Some(FalVeo3T2vAspectRatio::Default)));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_aspect_ratio_falls_back_to_default() {
|
||||
for ar in [CommonAspectRatio::WideFourByThree, CommonAspectRatio::TallThreeByFour, CommonAspectRatio::WideFiveByFour] {
|
||||
with_text_plan(&make_request(Some("p"), Some(ar), None, None, None, None), |plan| {
|
||||
assert!(matches!(plan.t2v_aspect_ratio, Some(FalVeo3T2vAspectRatio::Default)));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Duration mappings ─────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn default_duration_is_default() {
|
||||
with_text_plan(&make_request(Some("p"), None, None, None, None, None), |plan| {
|
||||
assert!(matches!(plan.duration, FalVeo3Duration::Default));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_4_yields_four() {
|
||||
with_text_plan(&make_request(Some("p"), None, None, Some(4), None, None), |plan| {
|
||||
assert!(matches!(plan.duration, FalVeo3Duration::FourSeconds));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_6_yields_six() {
|
||||
with_text_plan(&make_request(Some("p"), None, None, Some(6), None, None), |plan| {
|
||||
assert!(matches!(plan.duration, FalVeo3Duration::SixSeconds));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_8_yields_eight() {
|
||||
with_text_plan(&make_request(Some("p"), None, None, Some(8), None, None), |plan| {
|
||||
assert!(matches!(plan.duration, FalVeo3Duration::EightSeconds));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_above_8_clamps_to_eight() {
|
||||
with_text_plan(&make_request(Some("p"), None, None, Some(20), None, None), |plan| {
|
||||
assert!(matches!(plan.duration, FalVeo3Duration::EightSeconds));
|
||||
});
|
||||
}
|
||||
|
||||
// ── Resolution mappings ───────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn default_resolution_is_default() {
|
||||
with_text_plan(&make_request(Some("p"), None, None, None, None, None), |plan| {
|
||||
assert!(matches!(plan.resolution, FalVeo3Resolution::Default));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seven_twenty_p_yields_seven_twenty_p() {
|
||||
with_text_plan(&make_request(Some("p"), None, Some(CommonResolution::SevenTwentyP), None, None, None), |plan| {
|
||||
assert!(matches!(plan.resolution, FalVeo3Resolution::SevenTwentyP));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ten_eighty_p_yields_ten_eighty_p() {
|
||||
with_text_plan(&make_request(Some("p"), None, Some(CommonResolution::TenEightyP), None, None, None), |plan| {
|
||||
assert!(matches!(plan.resolution, FalVeo3Resolution::TenEightyP));
|
||||
});
|
||||
}
|
||||
|
||||
// ── Generate audio passthrough ────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn default_audio_is_true() {
|
||||
// Fal plan defaults generate_audio to true.
|
||||
with_text_plan(&make_request(Some("p"), None, None, None, None, None), |plan| {
|
||||
assert!(plan.generate_audio);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_true_passes_through() {
|
||||
with_text_plan(&make_request(Some("p"), None, None, None, Some(true), None), |plan| {
|
||||
assert!(plan.generate_audio);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_false_passes_through() {
|
||||
with_text_plan(&make_request(Some("p"), None, None, None, Some(false), None), |plan| {
|
||||
assert!(!plan.generate_audio);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
mod image {
|
||||
use super::*;
|
||||
|
||||
// ── Mode detection / start frame hydration ────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn start_frame_triggers_image_to_video() {
|
||||
let (start, map) = fake_token("mf_start0000000000000000000000");
|
||||
let request = make_request(Some("p"), None, None, None, None, Some(start));
|
||||
with_image_plan(&request, &map, |plan| {
|
||||
match &plan.mode {
|
||||
FalVeo3Mode::ImageToVideo { image_url } => {
|
||||
assert!(image_url.starts_with("https://fake.example.com/"));
|
||||
}
|
||||
other => panic!("expected ImageToVideo, got {:?}", other),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn start_frame_url_matches_hydration_map_exactly() {
|
||||
let token = MediaFileToken::new_from_str("mf_myframe0000000000000000000000");
|
||||
let expected_url = "https://cdn.example.com/frames/my_frame.png";
|
||||
let mut map = HashMap::new();
|
||||
map.insert(token.clone(), Url::parse(expected_url).unwrap());
|
||||
|
||||
let request = make_request(Some("p"), None, None, None, None, Some(token));
|
||||
with_image_plan(&request, &map, |plan| {
|
||||
match &plan.mode {
|
||||
FalVeo3Mode::ImageToVideo { image_url } => {
|
||||
assert_eq!(image_url, expected_url);
|
||||
}
|
||||
other => panic!("expected ImageToVideo, got {:?}", other),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Aspect ratio is None in image mode ────────────────────────────────
|
||||
|
||||
// ── Aspect ratio in image mode: uses i2v (has Auto, no Square) ─────────
|
||||
|
||||
#[test]
|
||||
fn t2v_aspect_ratio_is_none_in_image_mode() {
|
||||
let (start, map) = fake_token("mf_start0000000000000000000000");
|
||||
let request = make_request(Some("p"), Some(CommonAspectRatio::WideSixteenByNine), None, None, None, Some(start));
|
||||
with_image_plan(&request, &map, |plan| {
|
||||
assert!(plan.t2v_aspect_ratio.is_none());
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn i2v_default_aspect_ratio_is_auto() {
|
||||
let (start, map) = fake_token("mf_start0000000000000000000000");
|
||||
let request = make_request(Some("p"), None, None, None, None, Some(start));
|
||||
with_image_plan(&request, &map, |plan| {
|
||||
assert!(matches!(plan.i2v_aspect_ratio, Some(FalVeo3I2vAspectRatio::Auto)));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn i2v_wide_16x9_yields_wide() {
|
||||
let (start, map) = fake_token("mf_start0000000000000000000000");
|
||||
let request = make_request(Some("p"), Some(CommonAspectRatio::WideSixteenByNine), None, None, None, Some(start));
|
||||
with_image_plan(&request, &map, |plan| {
|
||||
assert!(matches!(plan.i2v_aspect_ratio, Some(FalVeo3I2vAspectRatio::WideSixteenNine)));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn i2v_tall_9x16_yields_tall() {
|
||||
let (start, map) = fake_token("mf_start0000000000000000000000");
|
||||
let request = make_request(Some("p"), Some(CommonAspectRatio::TallNineBySixteen), None, None, None, Some(start));
|
||||
with_image_plan(&request, &map, |plan| {
|
||||
assert!(matches!(plan.i2v_aspect_ratio, Some(FalVeo3I2vAspectRatio::TallNineSixteen)));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn i2v_auto_yields_auto() {
|
||||
for ar in [CommonAspectRatio::Auto, CommonAspectRatio::Auto2k, CommonAspectRatio::Auto4k] {
|
||||
let (start, map) = fake_token("mf_start0000000000000000000000");
|
||||
let request = make_request(Some("p"), Some(ar), None, None, None, Some(start));
|
||||
with_image_plan(&request, &map, |plan| {
|
||||
assert!(matches!(plan.i2v_aspect_ratio, Some(FalVeo3I2vAspectRatio::Auto)));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn i2v_square_falls_back_to_auto() {
|
||||
for ar in [CommonAspectRatio::Square, CommonAspectRatio::SquareHd] {
|
||||
let (start, map) = fake_token("mf_start0000000000000000000000");
|
||||
let request = make_request(Some("p"), Some(ar), None, None, None, Some(start));
|
||||
with_image_plan(&request, &map, |plan| {
|
||||
assert!(matches!(plan.i2v_aspect_ratio, Some(FalVeo3I2vAspectRatio::Auto)));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn i2v_unsupported_falls_back_to_auto() {
|
||||
for ar in [CommonAspectRatio::WideFourByThree, CommonAspectRatio::TallThreeByFour] {
|
||||
let (start, map) = fake_token("mf_start0000000000000000000000");
|
||||
let request = make_request(Some("p"), Some(ar), None, None, None, Some(start));
|
||||
with_image_plan(&request, &map, |plan| {
|
||||
assert!(matches!(plan.i2v_aspect_ratio, Some(FalVeo3I2vAspectRatio::Auto)));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Prompt passthrough in image mode ──────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn prompt_is_passed_through_in_image_mode() {
|
||||
let (start, map) = fake_token("mf_start0000000000000000000000");
|
||||
let request = make_request(Some("a horse on mars"), None, None, None, None, Some(start));
|
||||
with_image_plan(&request, &map, |plan| {
|
||||
assert_eq!(plan.prompt, "a horse on mars");
|
||||
});
|
||||
}
|
||||
|
||||
// ── Duration pass-throughs in image mode ──────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn duration_4_in_image_mode() {
|
||||
let (start, map) = fake_token("mf_start0000000000000000000000");
|
||||
let request = make_request(Some("p"), None, None, Some(4), None, Some(start));
|
||||
with_image_plan(&request, &map, |plan| {
|
||||
assert!(matches!(plan.duration, FalVeo3Duration::FourSeconds));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_8_in_image_mode() {
|
||||
let (start, map) = fake_token("mf_start0000000000000000000000");
|
||||
let request = make_request(Some("p"), None, None, Some(8), None, Some(start));
|
||||
with_image_plan(&request, &map, |plan| {
|
||||
assert!(matches!(plan.duration, FalVeo3Duration::EightSeconds));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_duration_in_image_mode() {
|
||||
let (start, map) = fake_token("mf_start0000000000000000000000");
|
||||
let request = make_request(Some("p"), None, None, None, None, Some(start));
|
||||
with_image_plan(&request, &map, |plan| {
|
||||
assert!(matches!(plan.duration, FalVeo3Duration::Default));
|
||||
});
|
||||
}
|
||||
|
||||
// ── Resolution in image mode ──────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn resolution_passes_through_in_image_mode() {
|
||||
let (start, map) = fake_token("mf_start0000000000000000000000");
|
||||
let request = make_request(Some("p"), None, Some(CommonResolution::TenEightyP), None, None, Some(start));
|
||||
with_image_plan(&request, &map, |plan| {
|
||||
assert!(matches!(plan.resolution, FalVeo3Resolution::TenEightyP));
|
||||
});
|
||||
}
|
||||
|
||||
// ── Audio in image mode ───────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn audio_passes_through_in_image_mode() {
|
||||
let (start, map) = fake_token("mf_start0000000000000000000000");
|
||||
let request = make_request(Some("p"), None, None, None, Some(false), Some(start));
|
||||
with_image_plan(&request, &map, |plan| {
|
||||
assert!(!plan.generate_audio);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_audio_is_true_in_image_mode() {
|
||||
let (start, map) = fake_token("mf_start0000000000000000000000");
|
||||
let request = make_request(Some("p"), None, None, None, None, Some(start));
|
||||
with_image_plan(&request, &map, |plan| {
|
||||
assert!(plan.generate_audio);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,3 +3,4 @@
|
||||
pub const JUNO_AT_LAKE_PRODUCTION_MEDIA_TOKEN: &str = "m_m1bz02z1kkzanxy6rb4vk1kvq9de9g";
|
||||
pub const FOREST_BACKDROP_PRODUCTION_MEDIA_TOKEN: &str = "m_f5yy0fdmkwrf2hxaq4yw4sd95tvvrf";
|
||||
pub const WHITE_HOUSE_SUNSET_PRODUCTION_MEDIA_TOKEN: &str = "m_xmzdeqhjpxs5418kvepcrj5a3vkfb1";
|
||||
pub const TALL_MOCHI_WITH_GLASSES_PRODUCTION_MEDIA_TOKEN: &str = "m_5y81rcnpnrbey55da6cc2s9z4d2f45";
|
||||
@@ -40,8 +40,9 @@ const getLabel = (item: any) => {
|
||||
export function useGalleryData(options: {
|
||||
username: string | null;
|
||||
filterMediaClasses: FilterMediaClasses[];
|
||||
excludeUploads?: boolean;
|
||||
}) {
|
||||
const { username, filterMediaClasses } = options;
|
||||
const { username, filterMediaClasses, excludeUploads } = options;
|
||||
|
||||
const [items, setItems] = useState<GalleryItem[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -83,7 +84,7 @@ export function useGalleryData(options: {
|
||||
const response = await api.listUserMediaFiles({
|
||||
username,
|
||||
filter_media_classes: filterMediaClasses,
|
||||
include_user_uploads: true,
|
||||
include_user_uploads: !excludeUploads,
|
||||
page_index: reset ? 0 : pageIndex,
|
||||
page_size: PAGE_SIZE,
|
||||
});
|
||||
@@ -91,7 +92,9 @@ export function useGalleryData(options: {
|
||||
if (response.success && response.data) {
|
||||
const newItems = response.data
|
||||
.filter(
|
||||
(item: any) => item.media_type !== FilterMediaType.SCENE_JSON,
|
||||
(item: any) =>
|
||||
item.media_type !== FilterMediaType.SCENE_JSON &&
|
||||
!(excludeUploads && item.origin_category === "upload"),
|
||||
)
|
||||
.map(mapApiItem);
|
||||
|
||||
@@ -114,7 +117,7 @@ export function useGalleryData(options: {
|
||||
setIsInitialLoading(false);
|
||||
isLoadingRef.current = false;
|
||||
},
|
||||
[username, filterMediaClasses, pageIndex, api, mapApiItem],
|
||||
[username, filterMediaClasses, pageIndex, api, mapApiItem, excludeUploads],
|
||||
);
|
||||
|
||||
// Initial load + filter change
|
||||
|
||||
@@ -1,241 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faCheck, faSpinnerThird } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { Modal } from "@storyteller/ui-modal";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import {
|
||||
UsersApi,
|
||||
GalleryModalApi,
|
||||
FilterMediaClasses,
|
||||
} from "@storyteller/api";
|
||||
import { getThumbnailUrl, THUMBNAIL_SIZES } from "@storyteller/common";
|
||||
|
||||
interface LibraryImage {
|
||||
token: string;
|
||||
url: string;
|
||||
thumbnailUrl: string;
|
||||
}
|
||||
|
||||
interface ImagePickerModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSelect: (images: LibraryImage[]) => void;
|
||||
maxSelect: number;
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 60;
|
||||
|
||||
export const ImagePickerModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSelect,
|
||||
maxSelect,
|
||||
}: ImagePickerModalProps) => {
|
||||
const [username, setUsername] = useState<string | null>(null);
|
||||
const [images, setImages] = useState<LibraryImage[]>([]);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [pageIndex, setPageIndex] = useState(0);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const isLoadingRef = useRef(false);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const api = useMemo(() => new GalleryModalApi(), []);
|
||||
|
||||
// Auth check
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const checkSession = async () => {
|
||||
const usersApi = new UsersApi();
|
||||
const response = await usersApi.GetSession();
|
||||
if (response.success && response.data?.loggedIn && response.data.user) {
|
||||
setUsername(response.data.user.username);
|
||||
}
|
||||
};
|
||||
checkSession();
|
||||
}, [isOpen]);
|
||||
|
||||
// Load images
|
||||
const loadImages = useCallback(
|
||||
async (reset = false) => {
|
||||
if (!username || isLoadingRef.current) return;
|
||||
isLoadingRef.current = true;
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const response = await api.listUserMediaFiles({
|
||||
username,
|
||||
filter_media_classes: [FilterMediaClasses.IMAGE],
|
||||
include_user_uploads: true,
|
||||
page_index: reset ? 0 : pageIndex,
|
||||
page_size: PAGE_SIZE,
|
||||
});
|
||||
|
||||
if (response.success && response.data) {
|
||||
const newImages: LibraryImage[] = response.data
|
||||
.filter((item: any) => item.media_class === "image")
|
||||
.map((item: any) => ({
|
||||
token: item.token,
|
||||
url: item.media_links?.cdn_url ?? "",
|
||||
thumbnailUrl:
|
||||
getThumbnailUrl(item.media_links?.maybe_thumbnail_template, {
|
||||
width: THUMBNAIL_SIZES.MEDIUM,
|
||||
}) ??
|
||||
item.media_links?.cdn_url ??
|
||||
"",
|
||||
}))
|
||||
.filter((img: LibraryImage) => img.url);
|
||||
|
||||
if (reset) {
|
||||
setImages(newImages);
|
||||
} else {
|
||||
setImages((prev) => [...prev, ...newImages]);
|
||||
}
|
||||
|
||||
const current = response.pagination?.current ?? 0;
|
||||
const total = response.pagination?.total_page_count ?? 1;
|
||||
setPageIndex(current + 1);
|
||||
setHasMore(current + 1 < total);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
isLoadingRef.current = false;
|
||||
},
|
||||
[username, pageIndex, api],
|
||||
);
|
||||
|
||||
// Initial load when opened
|
||||
useEffect(() => {
|
||||
if (!username || !isOpen) return;
|
||||
setImages([]);
|
||||
setSelected(new Set());
|
||||
setPageIndex(0);
|
||||
setHasMore(true);
|
||||
isLoadingRef.current = false;
|
||||
loadImages(true);
|
||||
}, [username, isOpen]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Infinite scroll
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
const handleScroll = () => {
|
||||
const scrollBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||
if (scrollBottom < 300 && hasMore && !isLoadingRef.current) {
|
||||
loadImages();
|
||||
}
|
||||
};
|
||||
el.addEventListener("scroll", handleScroll, { passive: true });
|
||||
return () => el.removeEventListener("scroll", handleScroll);
|
||||
}, [hasMore, loadImages]);
|
||||
|
||||
const toggleSelect = (token: string) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(token)) {
|
||||
next.delete(token);
|
||||
} else if (next.size < maxSelect) {
|
||||
next.add(token);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
const selectedImages = images.filter((img) => selected.has(img.token));
|
||||
onSelect(selectedImages);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="Pick from Library"
|
||||
showClose
|
||||
className="max-w-7xl"
|
||||
>
|
||||
<div className="flex flex-col" style={{ height: "min(80vh, 800px)" }}>
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto">
|
||||
{images.length === 0 && loading ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<FontAwesomeIcon
|
||||
icon={faSpinnerThird}
|
||||
className="animate-spin text-2xl text-white/40"
|
||||
/>
|
||||
</div>
|
||||
) : images.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center text-white/40 text-sm">
|
||||
No images found
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 pt-1">
|
||||
{images.map((img) => {
|
||||
const isSelected = selected.has(img.token);
|
||||
return (
|
||||
<button
|
||||
key={img.token}
|
||||
onClick={() => toggleSelect(img.token)}
|
||||
className={`group relative aspect-square overflow-hidden rounded-lg transition-all ${
|
||||
isSelected
|
||||
? "ring-2 ring-primary-400 ring-offset-2 ring-offset-[#1a1a20]"
|
||||
: "hover:ring-2 hover:ring-white/30"
|
||||
}`}
|
||||
>
|
||||
<img
|
||||
src={img.thumbnailUrl}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
{isSelected && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-primary-400/20">
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-primary-400 text-white">
|
||||
<FontAwesomeIcon icon={faCheck} className="h-3 w-3" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{loading && images.length > 0 && (
|
||||
<div className="flex justify-center py-4">
|
||||
<FontAwesomeIcon
|
||||
icon={faSpinnerThird}
|
||||
className="animate-spin text-lg text-white/40"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between pt-3">
|
||||
<span className="text-sm text-white/50">
|
||||
{selected.size} of {maxSelect} selected
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={onClose}
|
||||
className="px-4 py-1.5 text-sm"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleConfirm}
|
||||
disabled={selected.size === 0}
|
||||
className="px-4 py-1.5 text-sm"
|
||||
>
|
||||
Use selected
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -304,7 +304,7 @@ export const ImagePromptRow = ({
|
||||
interactive
|
||||
position="top"
|
||||
delay={100}
|
||||
className="bg-ui-controls text-base-fg border border-ui-panel-border p-2 -mb-0.5"
|
||||
className="bg-ui-controls text-base-fg border border-ui-controls-border p-2 -mb-0.5"
|
||||
closeOnClick
|
||||
content={
|
||||
<div className="flex flex-col gap-1.5">
|
||||
@@ -397,7 +397,7 @@ export const ImagePromptRow = ({
|
||||
interactive
|
||||
position="top"
|
||||
delay={100}
|
||||
className="bg-ui-controls text-base-fg border border-ui-panel-border p-2 -mb-0.5"
|
||||
className="bg-ui-controls text-base-fg border border-ui-controls-border p-2 -mb-0.5"
|
||||
closeOnClick
|
||||
content={
|
||||
<div className="flex flex-col gap-1.5">
|
||||
|
||||
@@ -384,7 +384,7 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
|
||||
<div
|
||||
className={twMerge(
|
||||
"glass rounded-xl p-3 sm:p-4 !transition-all duration-200",
|
||||
hasAnyRowAbove && "rounded-t-none",
|
||||
hasAnyRowAbove && "rounded-t-none border-t-0",
|
||||
isFocused && "ring-1 ring-primary",
|
||||
)}
|
||||
>
|
||||
@@ -430,7 +430,7 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
|
||||
mentionItems={mentionItems}
|
||||
placeholder={placeholder}
|
||||
className={twMerge(
|
||||
"promptbox-scrollbar min-h-[2.5em] w-full text-white",
|
||||
"promptbox-scrollbar min-h-[2.5em] w-full text-base-fg placeholder-base-fg/60",
|
||||
isExpanded ? "max-h-[500px]" : "max-h-[5.5em]",
|
||||
)}
|
||||
colorMap={mentionColorMap}
|
||||
@@ -450,7 +450,7 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
|
||||
ref={highlightRef}
|
||||
aria-hidden
|
||||
className={twMerge(
|
||||
"pointer-events-none absolute inset-0 overflow-y-auto whitespace-pre-wrap break-words text-sm text-white",
|
||||
"pointer-events-none absolute inset-0 overflow-y-auto whitespace-pre-wrap break-words text-sm text-base-fg",
|
||||
isExpanded ? "max-h-[500px]" : "max-h-[5.5em]",
|
||||
)}
|
||||
>
|
||||
@@ -464,7 +464,7 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
|
||||
autoFocus
|
||||
placeholder={placeholder}
|
||||
className={twMerge(
|
||||
"promptbox-scrollbar min-h-[2.5em] w-full flex-1 resize-y overflow-y-auto bg-transparent text-md text-white placeholder-white/50 focus:outline-none",
|
||||
"promptbox-scrollbar min-h-[2.5em] w-full flex-1 resize-y overflow-y-auto bg-transparent text-md text-base-fg placeholder-base-fg/60 focus:outline-none",
|
||||
isExpanded ? "max-h-[500px]" : "max-h-[5.5em]",
|
||||
hasMentionItems && "text-transparent caret-white",
|
||||
)}
|
||||
@@ -477,15 +477,15 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
|
||||
/>
|
||||
|
||||
{mentionOpen && filteredMentionItems.length > 0 && (
|
||||
<div className="absolute bottom-full left-0 z-50 mb-1 w-64 max-w-[calc(100vw-3rem)] overflow-hidden rounded-lg border border-white/10 bg-[#2a2a2e] shadow-lg backdrop-blur-xl">
|
||||
<div className="px-3 py-1.5 text-[11px] font-semibold uppercase tracking-wider text-white/50">
|
||||
<div className="absolute bottom-full left-0 z-50 mb-1 w-64 max-w-[calc(100vw-3rem)] overflow-hidden rounded-lg border border-ui-panel-border bg-ui-controls shadow-lg backdrop-blur-xl">
|
||||
<div className="px-3 py-1.5 text-[11px] font-semibold uppercase tracking-wider text-base-fg/50">
|
||||
Mentions
|
||||
</div>
|
||||
{filteredMentionItems.map((item, i) => (
|
||||
<button
|
||||
key={item.label}
|
||||
className={twMerge(
|
||||
"flex w-full cursor-pointer items-center gap-2.5 px-3 py-2 text-sm text-white transition-colors",
|
||||
"flex w-full cursor-pointer items-center gap-2.5 px-3 py-2 text-sm text-base-fg transition-colors",
|
||||
i === mentionIndex
|
||||
? "bg-white/10"
|
||||
: "hover:bg-white/5",
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
export { PromptBox } from "./PromptBox";
|
||||
export { ImagePromptRow } from "./ImagePromptRow";
|
||||
export { ImagePickerModal } from "./ImagePickerModal";
|
||||
export { MediaReferenceRow } from "./MediaReferenceRow";
|
||||
export { CharactersModal } from "./CharactersModal";
|
||||
export { useCharactersStore } from "./characters-store";
|
||||
|
||||
@@ -18,7 +18,7 @@ if (import.meta.env.DEV) {
|
||||
window.location.origin,
|
||||
);
|
||||
// NB: This is for Brandon to test with storyteller-web locally:
|
||||
StorytellerApiHostStore.getInstance().setDevelopment();
|
||||
//StorytellerApiHostStore.getInstance().setDevelopment();
|
||||
} catch (e) {
|
||||
console.warn("Failed to set dev API host override", e);
|
||||
}
|
||||
|
||||
@@ -4,9 +4,9 @@ import { FilterMediaClasses } from "@storyteller/api";
|
||||
import type { OmniGenImageModelInfo } from "@storyteller/api";
|
||||
import { PopoverMenu, type PopoverItem } from "@storyteller/ui-popover";
|
||||
import { Tooltip } from "@storyteller/ui-tooltip";
|
||||
import { GalleryModal, type GalleryItem } from "@storyteller/ui-gallery-modal";
|
||||
import {
|
||||
PromptBox,
|
||||
ImagePickerModal,
|
||||
type RefImage,
|
||||
} from "../../components/prompt-box";
|
||||
import {
|
||||
@@ -111,6 +111,28 @@ export default function CreateImage() {
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [referenceImages, setReferenceImages] = useState<RefImage[]>([]);
|
||||
const [isImagePickerOpen, setIsImagePickerOpen] = useState(false);
|
||||
const [pickerSelectedIds, setPickerSelectedIds] = useState<string[]>([]);
|
||||
const imagePickerMax = Math.max(
|
||||
1,
|
||||
(selectedModel?.image_refs_max ?? 1) - referenceImages.length,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isImagePickerOpen) setPickerSelectedIds([]);
|
||||
}, [isImagePickerOpen]);
|
||||
|
||||
const handlePickerSelect = useCallback(
|
||||
(id: string) => {
|
||||
setPickerSelectedIds((prev) => {
|
||||
if (prev.includes(id)) return prev.filter((x) => x !== id);
|
||||
if (prev.length >= imagePickerMax) {
|
||||
return imagePickerMax === 1 ? [id] : prev;
|
||||
}
|
||||
return [...prev, id];
|
||||
});
|
||||
},
|
||||
[imagePickerMax],
|
||||
);
|
||||
|
||||
// Batch store (enqueue flow only)
|
||||
const startBatch = useCreateImageStore((s) => s.startBatch);
|
||||
@@ -124,6 +146,7 @@ export default function CreateImage() {
|
||||
const gallery = useGalleryData({
|
||||
username: user?.username ?? null,
|
||||
filterMediaClasses: IMAGE_FILTER,
|
||||
excludeUploads: true,
|
||||
});
|
||||
|
||||
const newlyCompletedTokens = useMemo(
|
||||
@@ -239,18 +262,19 @@ export default function CreateImage() {
|
||||
);
|
||||
|
||||
const handleLibraryImageSelect = useCallback(
|
||||
(images: { token: string; url: string; thumbnailUrl: string }[]) => {
|
||||
(items: GalleryItem[]) => {
|
||||
const maxImages = selectedModel?.image_refs_max ?? 1;
|
||||
const availableSlots = Math.max(0, maxImages - referenceImages.length);
|
||||
const newImages: RefImage[] = images
|
||||
const newImages: RefImage[] = items
|
||||
.slice(0, availableSlots)
|
||||
.map((img) => ({
|
||||
.map((item) => ({
|
||||
id: Math.random().toString(36).substring(7),
|
||||
url: img.thumbnailUrl || img.url,
|
||||
url: item.thumbnail || item.fullImage || "",
|
||||
file: new File([], "library-image"),
|
||||
mediaToken: img.token,
|
||||
mediaToken: item.id,
|
||||
}));
|
||||
setReferenceImages([...referenceImages, ...newImages]);
|
||||
setIsImagePickerOpen(false);
|
||||
},
|
||||
[referenceImages, selectedModel],
|
||||
);
|
||||
@@ -447,14 +471,16 @@ export default function CreateImage() {
|
||||
}
|
||||
modals={
|
||||
<>
|
||||
<ImagePickerModal
|
||||
<GalleryModal
|
||||
mode="select"
|
||||
isOpen={isImagePickerOpen}
|
||||
onClose={() => setIsImagePickerOpen(false)}
|
||||
onSelect={handleLibraryImageSelect}
|
||||
maxSelect={Math.max(
|
||||
1,
|
||||
(selectedModel?.image_refs_max ?? 1) - referenceImages.length,
|
||||
)}
|
||||
selectedItemIds={pickerSelectedIds}
|
||||
onSelectItem={handlePickerSelect}
|
||||
maxSelections={imagePickerMax}
|
||||
onUseSelected={handleLibraryImageSelect}
|
||||
forceFilter="image"
|
||||
hideFilter
|
||||
/>
|
||||
<Lightbox
|
||||
isOpen={lightbox.lightboxOpen}
|
||||
|
||||
@@ -12,9 +12,9 @@ import { ToggleButton } from "@storyteller/ui-button";
|
||||
import { PopoverMenu, type PopoverItem } from "@storyteller/ui-popover";
|
||||
import { SliderV2 } from "@storyteller/ui-sliderv2";
|
||||
import { Tooltip } from "@storyteller/ui-tooltip";
|
||||
import { GalleryModal, type GalleryItem } from "@storyteller/ui-gallery-modal";
|
||||
import {
|
||||
PromptBox,
|
||||
ImagePickerModal,
|
||||
MediaReferenceRow,
|
||||
CharactersModal,
|
||||
useCharactersStore,
|
||||
@@ -217,6 +217,18 @@ export default function CreateVideo() {
|
||||
const [isImagePickerOpen, setIsImagePickerOpen] = useState(false);
|
||||
const [isEndFramePickerOpen, setIsEndFramePickerOpen] = useState(false);
|
||||
const [isCharactersModalOpen, setIsCharactersModalOpen] = useState(false);
|
||||
const [pickerSelectedIds, setPickerSelectedIds] = useState<string[]>([]);
|
||||
const [endFramePickerSelectedIds, setEndFramePickerSelectedIds] = useState<
|
||||
string[]
|
||||
>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isImagePickerOpen) setPickerSelectedIds([]);
|
||||
}, [isImagePickerOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEndFramePickerOpen) setEndFramePickerSelectedIds([]);
|
||||
}, [isEndFramePickerOpen]);
|
||||
|
||||
// Characters store for @-mentions
|
||||
const storedCharacters = useCharactersStore((s) => s.characters);
|
||||
@@ -278,6 +290,7 @@ export default function CreateVideo() {
|
||||
const gallery = useGalleryData({
|
||||
username: user?.username ?? null,
|
||||
filterMediaClasses: VIDEO_FILTER,
|
||||
excludeUploads: true,
|
||||
});
|
||||
|
||||
const newlyCompletedTokens = useMemo(
|
||||
@@ -592,38 +605,65 @@ export default function CreateVideo() {
|
||||
[inputMode, setUi],
|
||||
);
|
||||
|
||||
const imagePickerMax = Math.max(
|
||||
1,
|
||||
(isReferenceMode
|
||||
? (selectedModel?.image_references_max ?? 3)
|
||||
: 1) - referenceImages.length,
|
||||
);
|
||||
|
||||
const handlePickerSelect = useCallback(
|
||||
(id: string) => {
|
||||
setPickerSelectedIds((prev) => {
|
||||
if (prev.includes(id)) return prev.filter((x) => x !== id);
|
||||
if (prev.length >= imagePickerMax) {
|
||||
return imagePickerMax === 1 ? [id] : prev;
|
||||
}
|
||||
return [...prev, id];
|
||||
});
|
||||
},
|
||||
[imagePickerMax],
|
||||
);
|
||||
|
||||
const handleEndFramePickerSelect = useCallback((id: string) => {
|
||||
setEndFramePickerSelectedIds((prev) => {
|
||||
if (prev.includes(id)) return prev.filter((x) => x !== id);
|
||||
// Single-select: auto-swap
|
||||
return [id];
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleLibraryImageSelect = useCallback(
|
||||
(images: { token: string; url: string; thumbnailUrl: string }[]) => {
|
||||
(items: GalleryItem[]) => {
|
||||
const maxImages = isReferenceMode
|
||||
? (selectedModel?.image_references_max ?? 3)
|
||||
: 1;
|
||||
const availableSlots = Math.max(0, maxImages - referenceImages.length);
|
||||
const newImages: RefImage[] = images
|
||||
const newImages: RefImage[] = items
|
||||
.slice(0, availableSlots)
|
||||
.map((img) => ({
|
||||
.map((item) => ({
|
||||
id: Math.random().toString(36).substring(7),
|
||||
url: img.thumbnailUrl || img.url,
|
||||
url: item.thumbnail || item.fullImage || "",
|
||||
file: new File([], "library-image"),
|
||||
mediaToken: img.token,
|
||||
mediaToken: item.id,
|
||||
}));
|
||||
setReferenceImages([...referenceImages, ...newImages]);
|
||||
setIsImagePickerOpen(false);
|
||||
},
|
||||
[referenceImages, isReferenceMode, selectedModel],
|
||||
);
|
||||
|
||||
const handleEndFrameLibrarySelect = useCallback(
|
||||
(images: { token: string; url: string; thumbnailUrl: string }[]) => {
|
||||
const img = images[0];
|
||||
if (!img) return;
|
||||
setEndFrameImage({
|
||||
id: Math.random().toString(36).substring(7),
|
||||
url: img.thumbnailUrl || img.url,
|
||||
file: new File([], "library-image"),
|
||||
mediaToken: img.token,
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
const handleEndFrameLibrarySelect = useCallback((items: GalleryItem[]) => {
|
||||
const item = items[0];
|
||||
if (!item) return;
|
||||
setEndFrameImage({
|
||||
id: Math.random().toString(36).substring(7),
|
||||
url: item.thumbnail || item.fullImage || "",
|
||||
file: new File([], "library-image"),
|
||||
mediaToken: item.id,
|
||||
});
|
||||
setIsEndFramePickerOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleGenerate = useCallback(async () => {
|
||||
if (!prompt.trim() || isGenerating || needsImage || !selectedModel) return;
|
||||
@@ -1039,22 +1079,27 @@ export default function CreateVideo() {
|
||||
}
|
||||
modals={
|
||||
<>
|
||||
<ImagePickerModal
|
||||
<GalleryModal
|
||||
mode="select"
|
||||
isOpen={isImagePickerOpen}
|
||||
onClose={() => setIsImagePickerOpen(false)}
|
||||
onSelect={handleLibraryImageSelect}
|
||||
maxSelect={Math.max(
|
||||
1,
|
||||
(isReferenceMode
|
||||
? (selectedModel?.image_references_max ?? 3)
|
||||
: 1) - referenceImages.length,
|
||||
)}
|
||||
selectedItemIds={pickerSelectedIds}
|
||||
onSelectItem={handlePickerSelect}
|
||||
maxSelections={imagePickerMax}
|
||||
onUseSelected={handleLibraryImageSelect}
|
||||
forceFilter="image"
|
||||
hideFilter
|
||||
/>
|
||||
<ImagePickerModal
|
||||
<GalleryModal
|
||||
mode="select"
|
||||
isOpen={isEndFramePickerOpen}
|
||||
onClose={() => setIsEndFramePickerOpen(false)}
|
||||
onSelect={handleEndFrameLibrarySelect}
|
||||
maxSelect={1}
|
||||
selectedItemIds={endFramePickerSelectedIds}
|
||||
onSelectItem={handleEndFramePickerSelect}
|
||||
maxSelections={1}
|
||||
onUseSelected={handleEndFrameLibrarySelect}
|
||||
forceFilter="image"
|
||||
hideFilter
|
||||
/>
|
||||
<CharactersModal
|
||||
isOpen={isCharactersModalOpen}
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
Arial,
|
||||
sans-serif;
|
||||
color: #ffffff;
|
||||
background-color: #1b1b1f;
|
||||
background-color: #242424;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
|
||||
.glass {
|
||||
@apply relative border border-white/10 shadow-lg backdrop-blur-xl transition-colors duration-300;
|
||||
background-color: rgba(46, 46, 49, 0.85) !important;
|
||||
@apply bg-ui-controls/85 !important;
|
||||
}
|
||||
|
||||
/* Minimal thin scrollbar for prompt textareas */
|
||||
@@ -132,6 +132,7 @@
|
||||
0% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
@@ -62,15 +62,16 @@ module.exports = {
|
||||
},
|
||||
"base-fg": "rgb(255 255 255 / <alpha-value>)",
|
||||
ui: {
|
||||
// Aligned with artcraft app's theme-gray (frontend/apps/artcraft/app/src/styles/base.css)
|
||||
background: "#242424",
|
||||
panel: "#1F1F1F",
|
||||
"panel-border": "#3F3F3F",
|
||||
border: "#3F3F3F",
|
||||
"panel-border": "#272727",
|
||||
border: "#272727",
|
||||
controls: "#3E3E41",
|
||||
"controls-button": "#3E3E41",
|
||||
"controls-border": "#3f3f3f",
|
||||
divider: "#515168",
|
||||
modal: "#1f1f1f",
|
||||
"controls-border": "#464646",
|
||||
divider: "#272727",
|
||||
modal: "#1F1F1F",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { MediaFileDelete } from "@storyteller/tauri-api";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
|
||||
export const galleryModalDeleteMedia = (id: string) => MediaFileDelete(id);
|
||||
|
||||
export const galleryModalSubscribeToMediaEvents = (handlers: {
|
||||
onGenerationComplete: () => void;
|
||||
onMediaDeleted: (mediaId: string) => void;
|
||||
}) => {
|
||||
const unlistenGen = listen("generation-complete-event", () => {
|
||||
handlers.onGenerationComplete();
|
||||
});
|
||||
const unlistenDel = listen<any>("media_file_deleted_event", (event) => {
|
||||
const token: string | undefined =
|
||||
event?.payload?.data?.media_file_token ??
|
||||
event?.payload?.media_file_token;
|
||||
if (token) handlers.onMediaDeleted(token);
|
||||
});
|
||||
return () => {
|
||||
unlistenGen.then((fn) => fn());
|
||||
unlistenDel.then((fn) => fn());
|
||||
};
|
||||
};
|
||||
@@ -2,6 +2,7 @@ import { Fragment, useCallback } from "react";
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
import { DoNotShow } from "~/constants";
|
||||
import { Button } from "@storyteller/ui-button"
|
||||
import { Checkbox } from "@storyteller/ui-checkbox";
|
||||
|
||||
interface Props {
|
||||
text: string;
|
||||
@@ -74,10 +75,7 @@ export const ConfirmationModal = ({
|
||||
<div className="mt-2">{text}</div>
|
||||
{canHide && (
|
||||
<div className="mt-2">
|
||||
<label>
|
||||
<input id="hide-dialog" type="checkbox" />
|
||||
Do not show this again
|
||||
</label>
|
||||
<Checkbox id="hide-dialog" label="Do not show this again" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Checkbox } from "@storyteller/ui-checkbox";
|
||||
import { LoadingDots } from "@storyteller/ui-loading";
|
||||
import { Modal } from "@storyteller/ui-modal";
|
||||
import { UploadAssetError, UploadSuccess } from "@storyteller/ui-upload-modal";
|
||||
@@ -89,15 +90,12 @@ export function UploadModal3D(props: Props) {
|
||||
case UploaderStates.ready:
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
id="upload-as-character"
|
||||
type="checkbox"
|
||||
checked={isCharacter}
|
||||
onChange={(e) => setIsCharacter(e.target.checked)}
|
||||
/>
|
||||
<label htmlFor="upload-as-character">Upload as Character</label>
|
||||
</div>
|
||||
<Checkbox
|
||||
id="upload-as-character"
|
||||
checked={isCharacter}
|
||||
onChange={(e) => setIsCharacter(e.target.checked)}
|
||||
label="Upload as Character"
|
||||
/>
|
||||
<UploadFiles3D
|
||||
title={title}
|
||||
engineCategory={selectedCategory}
|
||||
|
||||
@@ -73,6 +73,10 @@ import { TabId, useTabStore } from "~/pages/Stores/TabState";
|
||||
import { authentication } from "~/signals";
|
||||
import { setLogoutStates } from "~/signals/authentication/utilities";
|
||||
import type { BaseSelectorImage } from "@storyteller/ui-pagedraw";
|
||||
import {
|
||||
galleryModalDeleteMedia,
|
||||
galleryModalSubscribeToMediaEvents,
|
||||
} from "~/Helpers/galleryModalTauriBindings";
|
||||
import { AppsQuickMenu } from "./AppsQuickMenu";
|
||||
import { SceneTitleInput } from "./SceneTitleInput";
|
||||
import { TaskQueue } from "./TaskQueue";
|
||||
@@ -782,6 +786,8 @@ export const TopBar = ({ pageName }: Props) => {
|
||||
onMake3DObjectClicked={handleMake3DObjectFromGallery}
|
||||
onMake3DWorldClicked={handleMake3DWorldFromGallery}
|
||||
onRecreateClicked={handleRecreateFromGallery}
|
||||
onDeleteMedia={galleryModalDeleteMedia}
|
||||
subscribeToMediaEvents={galleryModalSubscribeToMediaEvents}
|
||||
/>
|
||||
|
||||
<ProviderSetupModal />
|
||||
|
||||
@@ -375,25 +375,53 @@ export class MouseControls {
|
||||
|
||||
if ((event.ctrlKey || event.metaKey) && !this.isProcessing) {
|
||||
const keyLower = event.key.toLowerCase();
|
||||
if (keyLower === "z") {
|
||||
// redo
|
||||
if (keyLower === "z" && !event.shiftKey) {
|
||||
// undo
|
||||
event.preventDefault();
|
||||
this.isProcessing = true;
|
||||
await this.sceneManager?.redo();
|
||||
this.isProcessing = false;
|
||||
try {
|
||||
await this.sceneManager?.undo();
|
||||
} finally {
|
||||
this.isProcessing = false;
|
||||
}
|
||||
} else if (keyLower === "z" && event.shiftKey) {
|
||||
// redo (Ctrl+Shift+Z / Cmd+Shift+Z)
|
||||
event.preventDefault();
|
||||
this.isProcessing = true;
|
||||
try {
|
||||
await this.sceneManager?.redo();
|
||||
} finally {
|
||||
this.isProcessing = false;
|
||||
}
|
||||
} else if (keyLower === "y") {
|
||||
// redo (Ctrl+Y on Windows/Linux, Cmd+Y on macOS)
|
||||
event.preventDefault();
|
||||
this.isProcessing = true;
|
||||
try {
|
||||
await this.sceneManager?.redo();
|
||||
} finally {
|
||||
this.isProcessing = false;
|
||||
}
|
||||
} else if (keyLower === "c") {
|
||||
// Copy
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.isProcessing = true;
|
||||
await this.sceneManager?.copy();
|
||||
this.isProcessing = false;
|
||||
try {
|
||||
await this.sceneManager?.copy();
|
||||
} finally {
|
||||
this.isProcessing = false;
|
||||
}
|
||||
} else if (keyLower === "v") {
|
||||
// Paste
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.isProcessing = true;
|
||||
await this.sceneManager?.paste();
|
||||
this.isProcessing = false;
|
||||
try {
|
||||
await this.sceneManager?.paste();
|
||||
} finally {
|
||||
this.isProcessing = false;
|
||||
}
|
||||
} else if (event.key === "0") {
|
||||
// Stats Menu
|
||||
this.enable_stats();
|
||||
|
||||
@@ -118,10 +118,10 @@ export class SceneManager implements SceneManagerAPI {
|
||||
}
|
||||
|
||||
public async undo() {
|
||||
this.undoIndex += 1;
|
||||
if (this.undoIndex >= this.undoStack.length) {
|
||||
this.undoIndex = this.undoStack.length;
|
||||
return;
|
||||
}
|
||||
this.undoIndex += 1;
|
||||
const undoCommand = this.undoStack.at(
|
||||
this.undoStack.length - this.undoIndex,
|
||||
);
|
||||
@@ -131,18 +131,30 @@ export class SceneManager implements SceneManagerAPI {
|
||||
}
|
||||
|
||||
public async redo() {
|
||||
if (this.undoIndex <= 0) {
|
||||
return;
|
||||
}
|
||||
const undoCommand = this.undoStack.at(
|
||||
this.undoStack.length - this.undoIndex,
|
||||
);
|
||||
await undoCommand?.redo();
|
||||
this.undoIndex -= 1;
|
||||
if (this.undoIndex <= -1) {
|
||||
this.undoIndex = 0;
|
||||
}
|
||||
this.updateOutliner(); // In the future we will address this because of its relational issues with editor and the current class.
|
||||
this.lastSceneState = this.getSceneState();
|
||||
}
|
||||
|
||||
// Drop any redo-future before pushing a fresh command so the new action
|
||||
// replaces the branch of history the user had undone past.
|
||||
private dropRedoHistory() {
|
||||
if (this.undoIndex > 0) {
|
||||
this.undoStack = this.undoStack.slice(
|
||||
0,
|
||||
this.undoStack.length - this.undoIndex,
|
||||
);
|
||||
this.undoIndex = 0;
|
||||
}
|
||||
}
|
||||
|
||||
public async create(
|
||||
media_token: string,
|
||||
name: string,
|
||||
@@ -467,6 +479,7 @@ export class SceneManager implements SceneManagerAPI {
|
||||
}
|
||||
|
||||
public async add_creation_undostack(object: THREE.Object3D) {
|
||||
this.dropRedoHistory();
|
||||
this.undoStack.push(
|
||||
new CreationSceneItem(this, {
|
||||
object_uuid: object.uuid,
|
||||
@@ -488,6 +501,7 @@ export class SceneManager implements SceneManagerAPI {
|
||||
const is_transform = this.is_transformation(sceneState);
|
||||
const is_userdata = this.is_userdata(sceneState);
|
||||
|
||||
this.dropRedoHistory();
|
||||
this.undoStack = this.undoStack.slice(-16);
|
||||
|
||||
if (is_deleted != "") {
|
||||
@@ -558,7 +572,6 @@ export class SceneManager implements SceneManagerAPI {
|
||||
}
|
||||
}
|
||||
}
|
||||
this.undoIndex = 0;
|
||||
this.lastSceneState = sceneState;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Checkbox } from "@storyteller/ui-checkbox";
|
||||
import { Modal } from "@storyteller/ui-modal";
|
||||
import { UploadModal3D } from "~/components/reusable/UploadModal3D";
|
||||
import {
|
||||
@@ -590,34 +591,12 @@ export const AssetModal = () => {
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-auto flex items-center gap-2 pt-3">
|
||||
<div
|
||||
className="flex cursor-pointer items-center"
|
||||
onClick={(e) => {
|
||||
// Prevent double-firing when clicking the checkbox itself
|
||||
if (e.target instanceof HTMLInputElement) return;
|
||||
const checkbox = document.getElementById(
|
||||
"reopen-after-add",
|
||||
) as HTMLInputElement;
|
||||
if (checkbox) {
|
||||
checkbox.checked = !checkbox.checked;
|
||||
reopenAfterDragSignal.value = checkbox.checked;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="reopen-after-add"
|
||||
checked={reopenAfterDragSignal.value}
|
||||
onChange={handleReopenChange}
|
||||
className="h-4 w-4 cursor-pointer rounded-lg border-gray-300 bg-gray-700 text-primary focus:ring-primary"
|
||||
/>
|
||||
<label
|
||||
htmlFor="reopen-after-add"
|
||||
className="ml-2 cursor-pointer select-none text-sm text-white/70"
|
||||
>
|
||||
Reopen after adding
|
||||
</label>
|
||||
</div>
|
||||
<Checkbox
|
||||
id="reopen-after-add"
|
||||
checked={reopenAfterDragSignal.value}
|
||||
onChange={handleReopenChange}
|
||||
label="Reopen after adding"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-9 p-3 pb-0 ps-0 pt-2">
|
||||
|
||||
@@ -23,7 +23,7 @@ export const isHotkeyDisabled = ()=>{
|
||||
export const disableHotkeyInput = (level: number)=>{
|
||||
if(hotkeysStatus.value.disabled === true){
|
||||
if (level > hotkeysStatus.value.disabledBy){
|
||||
hotkeysStatus.value.disabledBy === level;
|
||||
hotkeysStatus.value = { ...hotkeysStatus.value, disabledBy: level };
|
||||
}
|
||||
}else{
|
||||
hotkeysStatus.value = {
|
||||
|
||||
@@ -51,10 +51,12 @@ body {
|
||||
|
||||
/* ===================== THEME VARIABLES ===================== */
|
||||
:root {
|
||||
--st-bg: #242424; /* gray theme default */
|
||||
--st-bg: #242424;
|
||||
/* gray theme default */
|
||||
--st-panel: #1f1f1f;
|
||||
--st-panel-border: #3f3f3f;
|
||||
--st-modal: #1f1f1f; /* default modal surface (can be gradient per theme) */
|
||||
--st-modal: #1f1f1f;
|
||||
/* default modal surface (can be gradient per theme) */
|
||||
--st-controls: #3e3e41;
|
||||
--st-controls-rgb: 62 62 65;
|
||||
--st-controls-border: #3f3f3f;
|
||||
@@ -76,9 +78,11 @@ body {
|
||||
--st-panel: #f8f8f8;
|
||||
--st-panel-border: #e5e7eb;
|
||||
--st-modal: #f8f8f8;
|
||||
--st-controls: #eeeeef; /* requested light button surface */
|
||||
--st-controls: #eeeeef;
|
||||
/* requested light button surface */
|
||||
--st-controls-rgb: 238 238 239;
|
||||
--st-controls-border: #dcdcdf; /* requested light button border */
|
||||
--st-controls-border: #dcdcdf;
|
||||
/* requested light button border */
|
||||
--st-fg: #111827;
|
||||
--st-fg-rgb: 17 24 39;
|
||||
--st-divider: #d1d5db;
|
||||
@@ -94,14 +98,14 @@ body {
|
||||
.theme-gray {
|
||||
--st-bg: #242424;
|
||||
--st-panel: #1f1f1f;
|
||||
--st-panel-border: #303030;
|
||||
--st-panel-border: #272727;
|
||||
--st-modal: #1f1f1f;
|
||||
--st-controls: #3e3e41;
|
||||
--st-controls-rgb: 62 62 65;
|
||||
--st-controls-border: #464646;
|
||||
--st-fg: #ffffff;
|
||||
--st-fg-rgb: 255 255 255;
|
||||
--st-divider: #424242;
|
||||
--st-divider: #272727;
|
||||
--st-color-scheme: dark;
|
||||
--st-gallery-vignette-alpha: 0.3;
|
||||
--st-photo-tint-rgb: 0 0 0;
|
||||
@@ -147,7 +151,8 @@ body {
|
||||
--st-fg-rgb: 248 250 252;
|
||||
--st-divider: #2a3152;
|
||||
--st-color-scheme: dark;
|
||||
--st-photo-tint-rgb: 22 32 56; /* slightly darker bluish tint */
|
||||
--st-photo-tint-rgb: 22 32 56;
|
||||
/* slightly darker bluish tint */
|
||||
--st-photo-tint-alpha: 0.34;
|
||||
--st-photo-grayscale: 0;
|
||||
--st-badge-alpha: 0.4;
|
||||
@@ -170,7 +175,8 @@ body {
|
||||
--st-fg-rgb: 248 250 252;
|
||||
--st-divider: #4a2e3a;
|
||||
--st-color-scheme: dark;
|
||||
--st-photo-tint-rgb: 96 32 48; /* warmer tint for images */
|
||||
--st-photo-tint-rgb: 96 32 48;
|
||||
/* warmer tint for images */
|
||||
--st-photo-tint-alpha: 0.3;
|
||||
--st-photo-grayscale: 0;
|
||||
--st-badge-alpha: 0.4;
|
||||
@@ -180,37 +186,44 @@ body {
|
||||
.bg-ui-background {
|
||||
background: var(--st-bg);
|
||||
}
|
||||
|
||||
.bg-ui-panel {
|
||||
background: var(--st-panel);
|
||||
}
|
||||
|
||||
.bg-ui-panel-gradient {
|
||||
background:
|
||||
radial-gradient(
|
||||
60.68% 60.68% at 50% 0%,
|
||||
radial-gradient(60.68% 60.68% at 50% 0%,
|
||||
rgba(255, 255, 255, 0.08) 0%,
|
||||
rgba(0, 0, 0, 0) 70%
|
||||
),
|
||||
rgba(0, 0, 0, 0) 70%),
|
||||
var(--st-panel);
|
||||
background-color: var(--st-panel);
|
||||
}
|
||||
|
||||
.bg-ui-modal {
|
||||
background: var(--st-modal);
|
||||
}
|
||||
|
||||
.border-ui-panel-border {
|
||||
border-color: var(--st-panel-border);
|
||||
}
|
||||
|
||||
.border-ui-controls-border {
|
||||
border-color: var(--st-controls-border);
|
||||
}
|
||||
|
||||
.bg-ui-controls {
|
||||
background: var(--st-controls);
|
||||
}
|
||||
|
||||
.bg-ui-badge {
|
||||
background-color: rgba(0, 0, 0, var(--st-badge-alpha));
|
||||
}
|
||||
|
||||
.text-base-fg {
|
||||
color: rgb(var(--st-fg-rgb) / 1);
|
||||
}
|
||||
|
||||
.border-ui-divider {
|
||||
border-color: var(--st-divider);
|
||||
}
|
||||
@@ -239,6 +252,7 @@ img {
|
||||
.icon-auto-contrast {
|
||||
filter: invert(1);
|
||||
}
|
||||
|
||||
.theme-light .icon-auto-contrast {
|
||||
filter: none;
|
||||
}
|
||||
@@ -249,35 +263,45 @@ img {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(255, 255, 255, 0.2) transparent;
|
||||
}
|
||||
|
||||
.promptbox-scrollbar::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.promptbox-scrollbar::-webkit-scrollbar:vertical {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.promptbox-scrollbar::-webkit-scrollbar:horizontal {
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
.promptbox-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.promptbox-scrollbar::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.promptbox-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
.promptbox-scrollbar::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.promptbox-scrollbar::-webkit-resizer {
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.promptbox-resize-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.promptbox-resize-wrap::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
@@ -288,17 +312,21 @@ img {
|
||||
pointer-events: none;
|
||||
background:
|
||||
linear-gradient(135deg,
|
||||
transparent 40%, rgba(255, 255, 255, 0.5) 40%, rgba(255, 255, 255, 0.5) 45%, transparent 45%,
|
||||
transparent 55%, rgba(255, 255, 255, 0.5) 55%, rgba(255, 255, 255, 0.5) 60%, transparent 60%,
|
||||
transparent 70%, rgba(255, 255, 255, 0.5) 70%, rgba(255, 255, 255, 0.5) 75%, transparent 75%);
|
||||
transparent 40%, rgba(255, 255, 255, 0.5) 40%, rgba(255, 255, 255, 0.5) 45%, transparent 45%,
|
||||
transparent 55%, rgba(255, 255, 255, 0.5) 55%, rgba(255, 255, 255, 0.5) 60%, transparent 60%,
|
||||
transparent 70%, rgba(255, 255, 255, 0.5) 70%, rgba(255, 255, 255, 0.5) 75%, transparent 75%);
|
||||
}
|
||||
|
||||
.scrollbar-hidden {
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
-ms-overflow-style: none;
|
||||
/* IE and Edge */
|
||||
scrollbar-width: none;
|
||||
/* Firefox */
|
||||
}
|
||||
|
||||
.scrollbar-hidden::-webkit-scrollbar {
|
||||
display: none; /* Chrome, Safari, Opera */
|
||||
display: none;
|
||||
/* Chrome, Safari, Opera */
|
||||
}
|
||||
|
||||
.topbar-spacer {
|
||||
@@ -307,9 +335,12 @@ img {
|
||||
}
|
||||
|
||||
.prevent-select {
|
||||
-webkit-user-select: none; /* Safari */
|
||||
-ms-user-select: none; /* IE 10 and IE 11 */
|
||||
user-select: none; /* Standard syntax */
|
||||
-webkit-user-select: none;
|
||||
/* Safari */
|
||||
-ms-user-select: none;
|
||||
/* IE 10 and IE 11 */
|
||||
user-select: none;
|
||||
/* Standard syntax */
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -357,36 +388,42 @@ img {
|
||||
9999px 0 0 0 #ffffff30,
|
||||
10014px 0 0 0 #ffffff30;
|
||||
}
|
||||
|
||||
16.667% {
|
||||
box-shadow:
|
||||
9984px -10px 0 0 #ffffff30,
|
||||
9999px 0 0 0 #ffffff30,
|
||||
10014px 0 0 0 #ffffff30;
|
||||
}
|
||||
|
||||
33.333% {
|
||||
box-shadow:
|
||||
9984px 0 0 0 #ffffff30,
|
||||
9999px 0 0 0 #ffffff30,
|
||||
10014px 0 0 0 #ffffff30;
|
||||
}
|
||||
|
||||
50% {
|
||||
box-shadow:
|
||||
9984px 0 0 0 #ffffff30,
|
||||
9999px -10px 0 0 #ffffff30,
|
||||
10014px 0 0 0 #ffffff30;
|
||||
}
|
||||
|
||||
66.667% {
|
||||
box-shadow:
|
||||
9984px 0 0 0 #ffffff30,
|
||||
9999px 0 0 0 #ffffff30,
|
||||
10014px 0 0 0 #ffffff30;
|
||||
}
|
||||
|
||||
83.333% {
|
||||
box-shadow:
|
||||
9984px 0 0 0 #ffffff30,
|
||||
9999px 0 0 0 #ffffff30,
|
||||
10014px -10px 0 0 #ffffff30;
|
||||
}
|
||||
|
||||
100% {
|
||||
box-shadow:
|
||||
9984px 0 0 0 #ffffff30,
|
||||
@@ -423,72 +460,84 @@ img {
|
||||
9991px 0 0 0 #ffffff30,
|
||||
10007px 0 0 0 #ffffff30;
|
||||
}
|
||||
|
||||
8.333% {
|
||||
box-shadow:
|
||||
10007px -16px 0 0 #ffffff30,
|
||||
9991px 0 0 0 #ffffff30,
|
||||
10007px 0 0 0 #ffffff30;
|
||||
}
|
||||
|
||||
16.667% {
|
||||
box-shadow:
|
||||
10007px -16px 0 0 #ffffff30,
|
||||
9991px -16px 0 0 #ffffff30,
|
||||
10007px 0 0 0 #ffffff30;
|
||||
}
|
||||
|
||||
25% {
|
||||
box-shadow:
|
||||
10007px -16px 0 0 #ffffff30,
|
||||
9991px -16px 0 0 #ffffff30,
|
||||
9991px 0 0 0 #ffffff30;
|
||||
}
|
||||
|
||||
33.333% {
|
||||
box-shadow:
|
||||
10007px 0 0 0 #ffffff30,
|
||||
9991px -16px 0 0 #ffffff30,
|
||||
9991px 0 0 0 #ffffff30;
|
||||
}
|
||||
|
||||
41.667% {
|
||||
box-shadow:
|
||||
10007px 0 0 0 #ffffff30,
|
||||
10007px -16px 0 0 #ffffff30,
|
||||
9991px 0 0 0 #ffffff30;
|
||||
}
|
||||
|
||||
50% {
|
||||
box-shadow:
|
||||
10007px 0 0 0 #ffffff30,
|
||||
10007px -16px 0 0 #ffffff30,
|
||||
9991px -16px 0 0 #ffffff30;
|
||||
}
|
||||
|
||||
58.333% {
|
||||
box-shadow:
|
||||
9991px 0 0 0 #ffffff30,
|
||||
10007px -16px 0 0 #ffffff30,
|
||||
9991px -16px 0 0 #ffffff30;
|
||||
}
|
||||
|
||||
66.666% {
|
||||
box-shadow:
|
||||
9991px 0 0 0 #ffffff30,
|
||||
10007px 0 0 0 #ffffff30,
|
||||
9991px -16px 0 0 #ffffff30;
|
||||
}
|
||||
|
||||
75% {
|
||||
box-shadow:
|
||||
9991px 0 0 0 #ffffff30,
|
||||
10007px 0 0 0 #ffffff30,
|
||||
10007px -16px 0 0 #ffffff30;
|
||||
}
|
||||
|
||||
83.333% {
|
||||
box-shadow:
|
||||
9991px -16px 0 0 #ffffff30,
|
||||
10007px 0 0 0 #ffffff30,
|
||||
10007px -16px 0 0 #ffffff30;
|
||||
}
|
||||
|
||||
91.667% {
|
||||
box-shadow:
|
||||
9991px -16px 0 0 #ffffff30,
|
||||
9991px 0 0 0 #ffffff30,
|
||||
10007px -16px 0 0 #ffffff30;
|
||||
}
|
||||
|
||||
100% {
|
||||
box-shadow:
|
||||
9991px -16px 0 0 #ffffff30,
|
||||
@@ -505,6 +554,7 @@ img {
|
||||
.st-loading-tile {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.st-border-svg {
|
||||
position: absolute;
|
||||
inset: -2px;
|
||||
@@ -525,17 +575,18 @@ img {
|
||||
/* Single runner: a solid line chasing its tail */
|
||||
/* Continuous runner with wrapping to avoid flicker at corners */
|
||||
.st-border-solid {
|
||||
stroke-dasharray: 90 110; /* shorter visible segment + larger gap = clearer motion */
|
||||
stroke-dasharray: 90 110;
|
||||
/* shorter visible segment + larger gap = clearer motion */
|
||||
stroke-dashoffset: 0;
|
||||
animation: st-solid-wrap 1.6s linear infinite;
|
||||
filter: drop-shadow(0 0 10px rgba(59, 130, 246, 0.8))
|
||||
drop-shadow(0 0 18px rgba(59, 130, 246, 0.5));
|
||||
filter: drop-shadow(0 0 10px rgba(59, 130, 246, 0.8)) drop-shadow(0 0 18px rgba(59, 130, 246, 0.5));
|
||||
}
|
||||
|
||||
@keyframes st-solid-wrap {
|
||||
0% {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
stroke-dashoffset: -200;
|
||||
}
|
||||
@@ -545,7 +596,8 @@ img {
|
||||
0% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user