Compare commits

...
Author SHA1 Message Date
Brandon Thomas c994bfcb4c pipeline 2 cost estimates 2026-04-23 17:03:29 -04:00
Brandon Thomas a782715cc5 fix job insert issues 2026-04-23 16:43:05 -04:00
Brandon Thomas d35ad4cbd8 cleanup 2026-04-23 16:30:29 -04:00
Brandon Thomas b5bb525216 refacto 2026-04-23 16:24:30 -04:00
Brandon Thomas 350d7d607c task ids 2026-04-23 16:18:29 -04:00
Brandon Thomas 467e22f111 error handling 2026-04-23 15:39:43 -04:00
Brandon Thomas 906dd3fd99 pass prompt tokens to jobs 2026-04-23 15:35:37 -04:00
Brandon Thomas d6ed101563 generation mode 2026-04-23 15:18:23 -04:00
Brandon Thomas 4d98c7e0d6 cleanup 2026-04-23 14:51:29 -04:00
Brandon Thomas 31385ea2c7 cleanup 2026-04-23 14:49:27 -04:00
Brandon Thomas 8ee73fda19 cleanup 2026-04-23 14:38:26 -04:00
Brandon Thomas 064562c741 cleanup, unfortunately this kills the tests (for now) 2026-04-23 14:31:55 -04:00
Brandon Thomas d934d3a573 cleanup 2026-04-23 14:28:27 -04:00
Brandon Thomas 2ae7f02679 cleanup 2026-04-23 14:26:51 -04:00
Brandon Thomas 6a4b172bc0 cleanup 2026-04-23 14:20:24 -04:00
Brandon Thomas 35a7bf5687 wip 2026-04-23 12:41:08 -04:00
Brandon Thomas 6c5b1a466a cleanup 2026-04-23 06:28:24 -04:00
Brandon Thomas baf4a8f7de cleanup 2026-04-23 06:19:51 -04:00
Brandon Thomas 8310e1534d cleanup 2026-04-23 06:13:15 -04:00
Brandon Thomas ebe3dd676f cleanup/refactor 2026-04-23 06:04:13 -04:00
Brandon Thomas d922f0afe0 cleanup 2026-04-23 05:52:43 -04:00
Brandon Thomas 8bf47c50ae cleanup 2026-04-23 05:48:51 -04:00
Brandon Thomas 7603bf2127 cleanup 2026-04-23 05:40:07 -04:00
Brandon Thomas 5664705c7c cleanup 2026-04-23 05:31:31 -04:00
Brandon Thomas 43388a1cb9 cleanup 2026-04-23 05:24:09 -04:00
Brandon Thomas de9e0b1152 cleanup 2026-04-23 05:03:06 -04:00
Brandon Thomas 4ea9c8b8aa cleanup 2026-04-23 04:31:48 -04:00
Brandon Thomas 4f79fb4601 get provider 2026-04-23 04:20:02 -04:00
Brandon Thomas a1c026212c refactor 2026-04-23 04:15:23 -04:00
Brandon Thomas 42fca1c9b4 cleanup 2026-04-23 00:42:00 -04:00
Brandon Thomas be32ab6794 restore prompt saving 2026-04-23 00:30:33 -04:00
Brandon Thomas e1d83debd6 refactor 2026-04-23 00:30:33 -04:00
Brandon Thomas 95975b9afb wip 2026-04-23 00:30:33 -04:00
Brandon Thomas f15aece011 update handler 2026-04-23 00:30:33 -04:00
Brandon Thomas c1a16e9564 wip 2026-04-23 00:30:33 -04:00
Brandon Thomas e6e739e874 move old artcraft_router pipeline (#1430) 2026-04-23 00:29:22 -04:00
Brandon Thomas 4112652c4e artcraft 0.29 -> 0.30 (#1429) 2026-04-22 22:27:13 -04:00
Brandon Thomas 969d7d7609 add seedance fast @ artcraft to artcraft router, update seedance (non-fast): both call omni (#1428) 2026-04-22 21:34:52 -04:00
Brandon Thomas 1f53b6a4cf tauri: seedance 2.0 fast, seedance 2.0 1080p (#1427) 2026-04-22 19:45:58 -04:00
Brandon Thomas 3947eebe7e bump artcraft 0.28 -> 0.29 (#1426) 2026-04-22 19:18:19 -04:00
Brandon Thomas 5eb2abf112 artcraft desktop gpt-image cost estimates + quality arg (#1425) 2026-04-22 19:17:07 -04:00
Brandon Thomas 31a9704faf fix media file list pagination (#1423) 2026-04-22 17:12:06 -04:00
bflatastic 02676680b1 fixed quality field name 2026-04-23 01:56:45 +07:00
BFlat ba19e9ae40 gpt image 2 quality selector (#1422) 2026-04-23 01:37:50 +07:00
Brandon Thomas 00499252a1 gpt-image-2 desktop support for quality (#1421) 2026-04-22 14:18:55 -04:00
Tanish Manku c7b9de7cf9 3D: Fix editor undo/redo keybinds and hotkey signal bugs (#1252)
- Ctrl+Z/Cmd+Z was calling redo() instead of undo(); fixed and added
missing Ctrl+Shift+Z/Cmd+Shift+Z and Ctrl+Y redo bindings
- Wrap all async keybind handlers in try/finally so isProcessing is
always cleared even if undo/redo/copy/paste throws
- Fix === typo in disableHotkeyInput (comparison instead of assignment),
and use full value replacement so the Preact signal update is reactive
2026-04-22 08:24:42 -07:00
Brandon Thomas c2ca096099 desktop frontend support for gpt-image-2 (#1418) 2026-04-22 10:40:47 -04:00
Brandon Thomas a04ee4f76c fix compile error 2026-04-22 10:15:52 -04:00
bflatastic 8bc552c450 add gpt image 2 to name mapping 2026-04-22 18:44:24 +07:00
BFlat 128d3e43c5 reuse gallery modal for the artcraft website and app styling aligned (#1415)
- website to reuse shared app lib's gallery modal for image selection
- styling alignments with the desktop app
- new checkbox shared component
2026-04-22 18:38:02 +07:00
139 changed files with 3766 additions and 5260 deletions
@@ -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
@@ -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();
@@ -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.
@@ -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,
}))
}
@@ -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,
}))
}
@@ -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(),
@@ -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)]
@@ -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;
@@ -1 +1,3 @@
pub(crate) mod resolve;
pub mod seedance_2p0;
pub mod seedance_2p0_fast;
@@ -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),
}
}
@@ -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,
@@ -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),
},
}
}
}
@@ -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 {
@@ -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"),
}
}
}
@@ -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()
}
}
@@ -0,0 +1,3 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -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
}
}
@@ -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,
}))
}
}
@@ -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,
}))
}
}
@@ -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 {
@@ -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(),
@@ -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;
@@ -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,
@@ -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"),
}
}
}
@@ -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>,
@@ -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),
@@ -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
},
@@ -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,
@@ -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>,
@@ -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 -1
View File
@@ -1,4 +1,4 @@
/// The version of the ArtCraft desktop app reported in the UI and via Analytics.
/// This should match the CI builds.
pub const ARTCRAFT_VERSION: &str = "0.28.0";
pub const ARTCRAFT_VERSION: &str = "0.30.0";
+1 -1
View File
@@ -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 -1
View File
@@ -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",
@@ -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 {
@@ -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,
@@ -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,
}
}
@@ -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,
@@ -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))
}
@@ -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,
})
}
@@ -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
}
}
}
@@ -1,3 +0,0 @@
mod execute_fal;
mod execute_kinovi;
pub mod execute_generation;
@@ -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,
})
}
@@ -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),
))
}
}
}
@@ -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(),
})
}
@@ -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;
@@ -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,
}
@@ -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)) }
}
@@ -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))
}
}
}
@@ -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")
})
}
@@ -0,0 +1,3 @@
pub mod insert_fal_job;
pub mod insert_seedance2pro_jobs;
pub mod shared_job_args;
@@ -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>,
}
@@ -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;
@@ -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
}
@@ -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)
}
@@ -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 ---
@@ -0,0 +1,2 @@
pub(crate) mod execute_fal;
pub(crate) mod execute_kinovi;
@@ -0,0 +1,2 @@
pub mod execute;
pub mod run_pipeline_v1;
@@ -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())
}
@@ -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 +0,0 @@
pub mod resolve_kinovi_character_ids;
@@ -1 +0,0 @@
mod test_kinovi_seedance_2p0;
@@ -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"); }
}
}
}
@@ -1,4 +0,0 @@
mod test_seedance_1p5_pro;
mod test_veo_2;
mod test_veo_3;
mod kinovi;
@@ -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),
}
});
}
}
}
}
@@ -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));
});
}
}
}
}
@@ -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";
+1 -1
View File
@@ -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" />
&nbsp;&nbsp;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 = {
+77 -25
View File
@@ -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