Compare commits

..
Author SHA1 Message Date
Brandon Thomas 1d68c1db25 remove comments 2026-05-20 10:46:57 -04:00
Brandon Thomas 38d3e1d129 module rename 2026-05-20 10:45:45 -04:00
Brandon Thomas b1d506807d rename "global" enums 2026-05-20 10:25:33 -04:00
Brandon Thomas 00b759c94e rename modules 2026-05-20 09:43:22 -04:00
Brandon Thomas a90b03c31d model rename 2026-05-20 09:34:46 -04:00
Brandon Thomas c8e72f01f1 artcraft router cleanup (#1541) 2026-05-20 09:13:20 -04:00
bflatastic b94787e10c webapp: settings ui fixes 2026-05-20 13:20:13 +07:00
Brandon Thomas ee0a51b399 seedance plus (#1540) 2026-05-20 01:14:22 -04:00
Brandon Thomas 09af5d8f9c rename model 2026-05-19 22:28:59 -04:00
bflatastic 01db05c169 website/webapp: flow fixes 2026-05-20 00:41:45 +07:00
bflatastic 6a69c26fa2 website: readd Image/Video navbar links 2026-05-19 23:14:28 +07:00
BFlat a50ce98803 webapp: coming soon model (#1539) 2026-05-19 22:59:34 +07:00
BFlat 8d7b623a96 webapp: demo scene to support image AND video (#1538)
PageScene's top-right preview card now first-classes video alongside
images.

- **New query params**: `?image=<media_token>` and
`?video=<media_token>`. `?image=` is an exact alias for the existing
`?output=` (and `?demo=`), which keep working for legacy/shared links.
- **Toggle when both are set**: a small sliding pill in the card header
switches between the video and image side. Video shows first. The whole
pill is one click target - tap anywhere on it to flip.
- **Video gets native controls**: `<video autoplay muted loop
playsInline controls>`. Wrapper click-to-expand suppresses clicks that
land on the control bar so play/pause doesn't fullscreen the card.
- **Prompt caption follows the active side**: the line under the preview
shows whichever asset (video or image) is currently displayed.
- **Promptbox 3D auto-prefill still uses the image's prompt** only.
Video-only links skip prefill.
- Internal URL builders (splash modal `pickExample`, "See other demo
scenes" overlay) now produce `?image=` URLs. External links with
`?output=` continue to resolve via the parser fallback.
2026-05-19 22:39:22 +07:00
Brandon Thomas 1cd2900b98 webapp launch script 2026-05-18 23:19:33 -04:00
Brandon Thomas 5bb6531009 research preview model (#1537) 2026-05-18 23:01:16 -04:00
bflatastic 87153de16d webapp: edit-3d page stack "See other demo scenes" and onboarding hint inside the promptbox
Add an `aboveStackSlot` prop to PromptBox3D rendered as the first
child of its `bottom-4 flex-col` stack, and thread a
`promptboxAboveStackSlot` prop through Stage3D / Stage3DBody. The lib
now owns the vertical math, so the webapp's button never overlaps the
prompt input regardless of textarea height — replaces the host-side
ResizeObserver/MutationObserver positioning that was unreliable in
production builds.
2026-05-19 01:27:06 +07:00
bflatastic 4982d3162d webapp: fix "See other demo scenes" promptbox tracking in production builds 2026-05-19 01:07:25 +07:00
BFlat 83cf580a9a webapp: add "See other demo scenes" overlay; fix FOV stickiness acros… (#1536)
…s scenes

Adds an inline overlay above the promptbox in /edit-3d (visible only
when `?output=` is in the URL) for swapping to another curated example
scene. The grid reuses SceneSplashCard + EXAMPLE_SCENES and filters out
the current scene. Position tracks the promptbox stack via Resize- and
MutationObserver so the pill sits 12px above it at any height.

Also resets the lib's camera Zustand state (cameras, selectedCameraId,
focalLengthDragging) on every sceneToken change. save_manager.load-
FromJson only emits CamerasReplacedEvent when scene_json carries a
`cameras` field, so navigating from a scene whose JSON had cameras (e.g.
60mm) into one that omits them left the prior cameras array in the store
and the per-frame tick kept applying its FOV to the new viewport.

OnboardingHelper: nudge the WASD/Look chip up to bottom-72 and drop its
background tint so it stacks cleanly above the new pill.
2026-05-19 00:56:06 +07:00
BFlat 246b189a2a Feature/website to webapp linking (#1535) 2026-05-19 00:15:23 +07:00
Brandon Thomas b1187543c9 analytics 2026-05-18 07:03:27 -04:00
Brandon Thomas a9e26d1321 analytics queries (#1534) 2026-05-17 23:47:52 -04:00
Brandon Thomas f7500a3026 gmi + cli tooling improvements (#1533) 2026-05-17 19:32:45 -04:00
BFlat fa4776bb21 webapp: image ref in promptbox click to preview (#1532)
feature: add high-res image preview modal to prompt box

- Added click-to-preview functionality for reference and end frame
images in `ImagePromptRow`.
- Updated `RefImage` interface to include `fullUrl` for fetching
high-resolution library images.
- Populated `fullUrl` for library selections in `create-image` and
`create-video` pages.
- Configured the preview modal to tightly wrap the image so the close
button anchors correctly to the corner.
- Updated the image uploading indicator to use the standardized
`spinner-third` icon.
2026-05-17 12:39:53 +07:00
125 changed files with 4572 additions and 2375 deletions
@@ -0,0 +1,13 @@
-- Daily ArtCraft generations since January 1st, 2026.
SELECT
DATE(created_at) AS day,
COUNT(*) AS generations
FROM generic_inference_jobs
WHERE created_at >= '2026-01-01'
AND job_type NOT IN (
'gpt_sovits', 'tacotron2', 'rvc_v2', 'f5_tts',
'so_vits_svc', 'styletts2', 'face_fusion', 'seed_vc', 'comfy_ui'
)
GROUP BY day
ORDER BY day DESC;
@@ -0,0 +1,28 @@
-- Daily ArtCraft generations by power users since January 1st, 2026.
-- A "power user" is a user who created more than 10 ArtCraft jobs in the calendar month.
SELECT
DATE(j.created_at) AS day,
COUNT(*) AS generations
FROM generic_inference_jobs j
INNER JOIN (
SELECT maybe_creator_user_token, DATE_FORMAT(created_at, '%Y-%m') AS month
FROM generic_inference_jobs
WHERE created_at >= '2026-01-01'
AND maybe_creator_user_token IS NOT NULL
AND job_type NOT IN (
'gpt_sovits', 'tacotron2', 'rvc_v2', 'f5_tts',
'so_vits_svc', 'styletts2', 'face_fusion', 'seed_vc', 'comfy_ui'
)
GROUP BY maybe_creator_user_token, month
HAVING COUNT(*) > 10
) power ON power.maybe_creator_user_token = j.maybe_creator_user_token
AND power.month = DATE_FORMAT(j.created_at, '%Y-%m')
WHERE j.created_at >= '2026-01-01'
AND j.maybe_creator_user_token IS NOT NULL
AND j.job_type NOT IN (
'gpt_sovits', 'tacotron2', 'rvc_v2', 'f5_tts',
'so_vits_svc', 'styletts2', 'face_fusion', 'seed_vc', 'comfy_ui'
)
GROUP BY day
ORDER BY day DESC;
@@ -0,0 +1,13 @@
-- Monthly ArtCraft generations since January 1st, 2026.
SELECT
DATE_FORMAT(created_at, '%Y-%m') AS month,
COUNT(*) AS generations
FROM generic_inference_jobs
WHERE created_at >= '2026-01-01'
AND job_type NOT IN (
'gpt_sovits', 'tacotron2', 'rvc_v2', 'f5_tts',
'so_vits_svc', 'styletts2', 'face_fusion', 'seed_vc', 'comfy_ui'
)
GROUP BY month
ORDER BY month DESC;
@@ -0,0 +1,23 @@
-- Monthly ArtCraft generations by power users since January 1st, 2026.
-- A "power user" is a user who created more than 10 ArtCraft jobs in the calendar month.
SELECT
month,
SUM(job_count) AS generations
FROM (
SELECT
maybe_creator_user_token,
DATE_FORMAT(created_at, '%Y-%m') AS month,
COUNT(*) AS job_count
FROM generic_inference_jobs
WHERE created_at >= '2026-01-01'
AND maybe_creator_user_token IS NOT NULL
AND job_type NOT IN (
'gpt_sovits', 'tacotron2', 'rvc_v2', 'f5_tts',
'so_vits_svc', 'styletts2', 'face_fusion', 'seed_vc', 'comfy_ui'
)
GROUP BY maybe_creator_user_token, month
HAVING COUNT(*) > 10
) power
GROUP BY month
ORDER BY month DESC;
@@ -0,0 +1,13 @@
-- Weekly ArtCraft generations since January 1st, 2026.
SELECT
DATE(DATE_SUB(created_at, INTERVAL WEEKDAY(created_at) DAY)) AS week_start,
COUNT(*) AS generations
FROM generic_inference_jobs
WHERE created_at >= '2026-01-01'
AND job_type NOT IN (
'gpt_sovits', 'tacotron2', 'rvc_v2', 'f5_tts',
'so_vits_svc', 'styletts2', 'face_fusion', 'seed_vc', 'comfy_ui'
)
GROUP BY week_start
ORDER BY week_start DESC;
@@ -0,0 +1,28 @@
-- Weekly ArtCraft generations by power users since January 1st, 2026.
-- A "power user" is a user who created more than 10 ArtCraft jobs in the calendar month.
SELECT
DATE(DATE_SUB(j.created_at, INTERVAL WEEKDAY(j.created_at) DAY)) AS week_start,
COUNT(*) AS generations
FROM generic_inference_jobs j
INNER JOIN (
SELECT maybe_creator_user_token, DATE_FORMAT(created_at, '%Y-%m') AS month
FROM generic_inference_jobs
WHERE created_at >= '2026-01-01'
AND maybe_creator_user_token IS NOT NULL
AND job_type NOT IN (
'gpt_sovits', 'tacotron2', 'rvc_v2', 'f5_tts',
'so_vits_svc', 'styletts2', 'face_fusion', 'seed_vc', 'comfy_ui'
)
GROUP BY maybe_creator_user_token, month
HAVING COUNT(*) > 10
) power ON power.maybe_creator_user_token = j.maybe_creator_user_token
AND power.month = DATE_FORMAT(j.created_at, '%Y-%m')
WHERE j.created_at >= '2026-01-01'
AND j.maybe_creator_user_token IS NOT NULL
AND j.job_type NOT IN (
'gpt_sovits', 'tacotron2', 'rvc_v2', 'f5_tts',
'so_vits_svc', 'styletts2', 'face_fusion', 'seed_vc', 'comfy_ui'
)
GROUP BY week_start
ORDER BY week_start DESC;
@@ -41,11 +41,17 @@ pub enum CommonVideoModel {
#[serde(rename = "seedance_2p0_fast")]
Seedance2p0Fast,
#[serde(rename = "seedance_2p0_g")]
Seedance2p0Global,
#[serde(rename = "seedance_2p0_bp")]
Seedance2p0BytePlus,
#[serde(rename = "seedance_2p0_fast_g")]
Seedance2p0FastGlobal,
#[serde(rename = "seedance_2p0_bp_fast")]
Seedance2p0BytePlusFast,
#[serde(rename = "seedance_2p0_u")]
Seedance2p0Ultra,
#[serde(rename = "seedance_2p0_u_fast")]
Seedance2p0UltraFast,
#[serde(rename = "happy_horse_1p0")]
HappyHorse1p0,
@@ -70,4 +76,10 @@ pub enum CommonVideoModel {
#[serde(rename = "veo_3p1_fast")]
Veo3p1Fast,
#[serde(rename = "preview_model")]
PreviewModel,
#[serde(rename = "preview_model_fast")]
PreviewModelFast,
}
@@ -48,14 +48,18 @@ 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::happy_horse_1p0::build::build_artcraft_happy_horse_1p0;
use crate::generate::generate_video_v2::providers::artcraft::preview_model::build::build_artcraft_preview_model;
use crate::generate::generate_video_v2::providers::artcraft::preview_model_fast::build::build_artcraft_preview_model_fast;
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::artcraft::seedance_2p0_g::build::build_artcraft_seedance_2p0_g;
use crate::generate::generate_video_v2::providers::artcraft::seedance_2p0_fast_g::build::build_artcraft_seedance_2p0_fast_g;
use crate::generate::generate_video_v2::providers::artcraft::seedance_2p0_u::build::build_artcraft_seedance_2p0_u;
use crate::generate::generate_video_v2::providers::artcraft::seedance_2p0_u_fast::build::build_artcraft_seedance_2p0_u_fast;
use crate::generate::generate_video_v2::providers::artcraft::seedance_2p0_bp::build::build_artcraft_seedance_2p0_bp;
use crate::generate::generate_video_v2::providers::artcraft::seedance_2p0_bp_fast::build::build_artcraft_seedance_2p0_bp_fast;
use crate::generate::generate_video_v2::providers::kinovi::happy_horse_1p0::build::build_kinovi_happy_horse_1p0;
use crate::generate::generate_video_v2::providers::kinovi::seedance_2p0::build::build_kinovi_seedance_2p0;
use crate::generate::generate_video_v2::providers::gmicloud::seedance_2p0_g::build::build_gmicloud_seedance_2p0_g;
use crate::generate::generate_video_v2::providers::gmicloud::seedance_2p0_fast_g::build::build_gmicloud_seedance_2p0_fast_g;
use crate::generate::generate_video_v2::providers::gmicloud::seedance_2p0_g::build::build_gmicloud_seedance_2p0_u;
use crate::generate::generate_video_v2::providers::gmicloud::seedance_2p0_fast_g::build::build_gmicloud_seedance_2p0_u_fast;
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;
@@ -148,13 +152,20 @@ impl GenerateVideoRequestBuilder {
pub fn use_new_builder(&self) -> bool {
match (self.provider, self.model) {
// Artcraft
(Provider::Artcraft, CommonVideoModel::HappyHorse1p0) => true,
(Provider::Artcraft, CommonVideoModel::Seedance2p0) => true,
(Provider::Artcraft, CommonVideoModel::Seedance2p0Fast) => true,
(Provider::Artcraft, CommonVideoModel::Seedance2p0Global) => true,
(Provider::Artcraft, CommonVideoModel::Seedance2p0FastGlobal) => true,
(Provider::GmiCloud, CommonVideoModel::Seedance2p0Global) => true,
(Provider::GmiCloud, CommonVideoModel::Seedance2p0FastGlobal) => true,
(Provider::Artcraft, CommonVideoModel::Seedance2p0Ultra) => true,
(Provider::Artcraft, CommonVideoModel::Seedance2p0UltraFast) => true,
(Provider::Artcraft, CommonVideoModel::Seedance2p0BytePlus) => true,
(Provider::Artcraft, CommonVideoModel::Seedance2p0BytePlusFast) => true,
(Provider::Artcraft, CommonVideoModel::PreviewModel) => true,
(Provider::Artcraft, CommonVideoModel::PreviewModelFast) => true,
// GmiCloud
(Provider::GmiCloud, CommonVideoModel::Seedance2p0Ultra) => true,
(Provider::GmiCloud, CommonVideoModel::Seedance2p0UltraFast) => true,
// Kinovi
(Provider::Seedance2Pro, CommonVideoModel::HappyHorse1p0) => true,
(Provider::Seedance2Pro, CommonVideoModel::Seedance2p0) => true,
(Provider::Seedance2Pro, CommonVideoModel::Seedance2p0Fast) => true,
@@ -165,13 +176,20 @@ impl GenerateVideoRequestBuilder {
// New builder (transitional state)
pub fn build2(self) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
match (self.provider, self.model) {
// Artcraft
(Provider::Artcraft, CommonVideoModel::HappyHorse1p0) => build_artcraft_happy_horse_1p0(self),
(Provider::Artcraft, CommonVideoModel::Seedance2p0) => build_artcraft_seedance_2p0(self),
(Provider::Artcraft, CommonVideoModel::Seedance2p0Fast) => build_artcraft_seedance_2p0_fast(self),
(Provider::Artcraft, CommonVideoModel::Seedance2p0Global) => build_artcraft_seedance_2p0_g(self),
(Provider::Artcraft, CommonVideoModel::Seedance2p0FastGlobal) => build_artcraft_seedance_2p0_fast_g(self),
(Provider::GmiCloud, CommonVideoModel::Seedance2p0Global) => build_gmicloud_seedance_2p0_g(self),
(Provider::GmiCloud, CommonVideoModel::Seedance2p0FastGlobal) => build_gmicloud_seedance_2p0_fast_g(self),
(Provider::Artcraft, CommonVideoModel::Seedance2p0Ultra) => build_artcraft_seedance_2p0_u(self),
(Provider::Artcraft, CommonVideoModel::Seedance2p0UltraFast) => build_artcraft_seedance_2p0_u_fast(self),
(Provider::Artcraft, CommonVideoModel::Seedance2p0BytePlus) => build_artcraft_seedance_2p0_bp(self),
(Provider::Artcraft, CommonVideoModel::Seedance2p0BytePlusFast) => build_artcraft_seedance_2p0_bp_fast(self),
(Provider::Artcraft, CommonVideoModel::PreviewModel) => build_artcraft_preview_model(self),
(Provider::Artcraft, CommonVideoModel::PreviewModelFast) => build_artcraft_preview_model_fast(self),
// GmiCloud
(Provider::GmiCloud, CommonVideoModel::Seedance2p0Ultra) => build_gmicloud_seedance_2p0_u(self),
(Provider::GmiCloud, CommonVideoModel::Seedance2p0UltraFast) => build_gmicloud_seedance_2p0_u_fast(self),
// Kinovi
(Provider::Seedance2Pro, CommonVideoModel::HappyHorse1p0) => build_kinovi_happy_horse_1p0(self),
(Provider::Seedance2Pro, CommonVideoModel::Seedance2p0) => build_kinovi_seedance_2p0(self),
(Provider::Seedance2Pro, CommonVideoModel::Seedance2p0Fast) => build_kinovi_seedance_2p0_fast(self),
@@ -13,15 +13,40 @@ 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_g::request::ArtcraftSeedance2p0GRequestState;
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_g(mut builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
/// Which output resolutions a model supports.
#[derive(Copy, Clone)]
pub enum SupportedResolutions {
/// 480p, 720p, 1080p
Full,
/// 480p, 720p only (1080p downgrades to 720p)
Fast,
/// 720p, 1080p only (480p upgrades)
NoFourEightyP,
}
/// Which aspect ratios a model supports for the 21:9 ultra-wide slot.
#[derive(Copy, Clone)]
pub enum UltraWideSupport {
/// 21:9 maps directly to WideTwentyOneByNine
Supported,
/// 21:9 is not a direct option; falls through to nearest_aspect_ratio
Unsupported,
}
/// Build an `OmniGenVideoCostAndGenerateRequest` from the builder, using
/// model-specific configuration for the model enum variant, resolution
/// strategy, and ultra-wide aspect ratio support.
pub fn build_artcraft_omni_request(
mut builder: GenerateVideoRequestBuilder,
model: CommonVideoModelEnum,
resolutions: SupportedResolutions,
ultra_wide: UltraWideSupport,
) -> Result<OmniGenVideoCostAndGenerateRequest, 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 aspect_ratio = plan_aspect_ratio(builder.aspect_ratio.take(), strategy, ultra_wide)?;
let resolution = plan_output_resolution(builder.resolution.take(), strategy, resolutions)?;
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();
@@ -34,8 +59,8 @@ pub fn build_artcraft_seedance_2p0_g(mut builder: GenerateVideoRequestBuilder) -
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::Seedance2p0Global),
Ok(OmniGenVideoCostAndGenerateRequest {
model: Some(model),
idempotency_token: Some(idempotency_token),
prompt,
start_frame_image_media_token: start_frame,
@@ -51,17 +76,15 @@ pub fn build_artcraft_seedance_2p0_g(mut builder: GenerateVideoRequestBuilder) -
negative_prompt: None,
generate_audio: None,
quality: None,
};
let state = ArtcraftSeedance2p0GRequestState { request };
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p0G(state)))
})
}
// -- Plan helpers --
// ── Plan helpers ──
fn plan_aspect_ratio(
aspect_ratio: Option<CommonAspectRatio>,
strategy: RequestMismatchMitigationStrategy,
ultra_wide: UltraWideSupport,
) -> Result<Option<CommonAspectRatioEnum>, ArtcraftRouterError> {
match aspect_ratio {
None
@@ -80,7 +103,19 @@ fn plan_aspect_ratio(
}
Some(CommonAspectRatio::WideFourByThree) => Ok(Some(CommonAspectRatioEnum::WideFourByThree)),
Some(CommonAspectRatio::TallThreeByFour) => Ok(Some(CommonAspectRatioEnum::TallThreeByFour)),
Some(CommonAspectRatio::WideTwentyOneByNine) => Ok(Some(CommonAspectRatioEnum::WideTwentyOneByNine)),
Some(CommonAspectRatio::WideTwentyOneByNine) => match ultra_wide {
UltraWideSupport::Supported => Ok(Some(CommonAspectRatioEnum::WideTwentyOneByNine)),
UltraWideSupport::Unsupported => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "aspect_ratio",
value: format!("{:?}", CommonAspectRatio::WideTwentyOneByNine),
}))
}
_ => Ok(Some(nearest_aspect_ratio(CommonAspectRatio::WideTwentyOneByNine))),
},
},
Some(unsupported) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
@@ -101,6 +136,7 @@ fn nearest_aspect_ratio(aspect_ratio: CommonAspectRatio) -> CommonAspectRatioEnu
match aspect_ratio {
CommonAspectRatio::WideFiveByFour => CommonAspectRatioEnum::WideFourByThree,
CommonAspectRatio::WideThreeByTwo => CommonAspectRatioEnum::WideFourByThree,
CommonAspectRatio::WideTwentyOneByNine => CommonAspectRatioEnum::WideSixteenByNine,
CommonAspectRatio::TallFourByFive => CommonAspectRatioEnum::TallThreeByFour,
CommonAspectRatio::TallTwoByThree => CommonAspectRatioEnum::TallThreeByFour,
CommonAspectRatio::TallNineByTwentyOne => CommonAspectRatioEnum::TallNineBySixteen,
@@ -108,17 +144,46 @@ fn nearest_aspect_ratio(aspect_ratio: CommonAspectRatio) -> CommonAspectRatioEnu
}
}
// Seedance 2.0 Pro supports output resolutions: 480p, 720p, 1080p.
fn plan_output_resolution(
resolution: Option<CommonResolution>,
strategy: RequestMismatchMitigationStrategy,
supported: SupportedResolutions,
) -> Result<Option<CommonResolutionEnum>, ArtcraftRouterError> {
match resolution {
None => Ok(None),
Some(CommonResolution::FourEightyP) => Ok(Some(CommonResolutionEnum::FourEightyP)),
Some(CommonResolution::FourEightyP) => match supported {
SupportedResolutions::Full | SupportedResolutions::Fast => {
Ok(Some(CommonResolutionEnum::FourEightyP))
}
SupportedResolutions::NoFourEightyP => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "resolution",
value: format!("{:?}", CommonResolution::FourEightyP),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(Some(CommonResolutionEnum::TenEightyP)),
RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Some(CommonResolutionEnum::SevenTwentyP)),
},
},
Some(CommonResolution::SevenTwentyP) => Ok(Some(CommonResolutionEnum::SevenTwentyP)),
Some(CommonResolution::TenEightyP) => Ok(Some(CommonResolutionEnum::TenEightyP)),
Some(CommonResolution::TenEightyP) => match supported {
SupportedResolutions::Full | SupportedResolutions::NoFourEightyP => {
Ok(Some(CommonResolutionEnum::TenEightyP))
}
SupportedResolutions::Fast => 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 => {
@@ -128,14 +193,16 @@ fn plan_output_resolution(
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade => {
Ok(Some(match unsupported {
CommonResolution::HalfK => CommonResolutionEnum::FourEightyP,
Ok(Some(match (unsupported, supported) {
(CommonResolution::HalfK, _) => CommonResolutionEnum::FourEightyP,
(_, SupportedResolutions::Fast) => CommonResolutionEnum::SevenTwentyP,
_ => CommonResolutionEnum::TenEightyP,
}))
}
RequestMismatchMitigationStrategy::PayLessDowngrade => {
Ok(Some(match unsupported {
CommonResolution::HalfK => CommonResolutionEnum::FourEightyP,
Ok(Some(match (unsupported, supported) {
(CommonResolution::HalfK, _) => CommonResolutionEnum::FourEightyP,
(_, SupportedResolutions::Fast) => CommonResolutionEnum::SevenTwentyP,
_ => CommonResolutionEnum::TenEightyP,
}))
}
@@ -143,8 +210,8 @@ fn plan_output_resolution(
}
}
// Batch counts: 1, 2, 4.
fn plan_batch_count(
/// Batch counts: 1, 2, 4.
pub fn plan_batch_count(
video_batch_count: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<u16, ArtcraftRouterError> {
@@ -167,8 +234,8 @@ fn plan_batch_count(
}
}
// Duration: 4-15 seconds.
fn plan_duration(
/// Duration: 4-15 seconds.
pub fn plan_duration(
duration_seconds: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<u8>, ArtcraftRouterError> {
@@ -188,157 +255,3 @@ fn plan_duration(
},
}
}
#[cfg(test)]
mod tests {
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_resolution::CommonResolution;
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::provider::Provider;
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 model_is_seedance_2p0() {
let req = unwrap_request(make_builder(|_| {}));
assert!(matches!(req.request.model, Some(CommonVideoModelEnum::Seedance2p0Global)));
}
#[test]
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_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 req = unwrap_request(make_builder(|b| { b.duration_seconds = Some(99); }));
assert_eq!(req.request.duration_seconds, Some(15));
}
#[test]
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));
}
}
// ── Resolution ──
mod resolution_tests {
use super::*;
#[test]
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 res_720p() {
let req = unwrap_request(make_builder(|b| { b.resolution = Some(CommonResolution::SevenTwentyP); }));
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::SevenTwentyP));
}
#[test]
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 none_stays_none() {
let req = unwrap_request(make_builder(|_| {}));
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(make_builder(|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_g(GenerateVideoRequestBuilder {
start_frame: Some(ImageRef::Url("https://example.com".to_string())),
..base_builder()
});
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(make_builder(|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(make_builder(|b| {
b.reference_character_tokens = Some(CharacterListRef::CharacterTokens(tokens.clone()));
}));
assert_eq!(req.request.reference_character_tokens, Some(tokens));
}
}
// ── Helpers ──
fn base_builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
provider: Provider::Artcraft,
duration_seconds: Some(5),
video_batch_count: Some(1),
..Default::default()
}
}
fn make_builder(f: impl FnOnce(&mut GenerateVideoRequestBuilder)) -> GenerateVideoRequestBuilder {
let mut builder = base_builder();
f(&mut builder);
builder
}
fn unwrap_request(builder: GenerateVideoRequestBuilder) -> ArtcraftSeedance2p0GRequestState {
let result = build_artcraft_seedance_2p0_g(builder).expect("build should succeed");
match result {
VideoGenerationDraftOrRequest::Request(
VideoGenerationRequest::ArtcraftSeedance2p0G(state)
) => state,
_ => panic!("expected ArtcraftSeedance2p0 request"),
}
}
}
@@ -1,185 +1,25 @@
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::build_common::{
build_artcraft_omni_request, SupportedResolutions, UltraWideSupport,
};
use crate::generate::generate_video_v2::providers::artcraft::happy_horse_1p0::request::ArtcraftHappyHorse1p0RequestState;
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_happy_horse_1p0(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::HappyHorse1p0),
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,
};
pub fn build_artcraft_happy_horse_1p0(builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
let request = build_artcraft_omni_request(
builder,
CommonVideoModelEnum::HappyHorse1p0,
SupportedResolutions::NoFourEightyP,
UltraWideSupport::Unsupported,
)?;
let state = ArtcraftHappyHorse1p0RequestState { request };
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftHappyHorse1p0(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),
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)),
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,
}
}
// Happy Horse supports 720p and 1080p only (no 480p).
fn plan_output_resolution(
resolution: Option<CommonResolution>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<CommonResolutionEnum>, ArtcraftRouterError> {
match resolution {
None => Ok(None),
Some(CommonResolution::SevenTwentyP) => Ok(Some(CommonResolutionEnum::SevenTwentyP)),
Some(CommonResolution::TenEightyP) => Ok(Some(CommonResolutionEnum::TenEightyP)),
Some(unsupported) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "resolution",
value: format!("{:?}", unsupported),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade => {
Ok(Some(CommonResolutionEnum::TenEightyP))
}
RequestMismatchMitigationStrategy::PayLessDowngrade => {
Ok(Some(CommonResolutionEnum::SevenTwentyP))
}
},
}
}
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 })
}
},
}
}
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_resolution::CommonResolution as CommonResolutionEnum;
@@ -1,12 +1,9 @@
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,
};
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
use crate::generate::generate_video_v2::providers::artcraft::request_common::send_artcraft_omni_request;
#[derive(Clone, Debug)]
pub struct ArtcraftHappyHorse1p0RequestState {
@@ -15,18 +12,7 @@ pub struct ArtcraftHappyHorse1p0RequestState {
impl ArtcraftHappyHorse1p0RequestState {
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],
}))
send_artcraft_omni_request(&self.request, client).await
}
}
@@ -1,6 +1,12 @@
pub(crate) mod build_common;
pub mod happy_horse_1p0;
pub mod preview_model;
pub mod preview_model_fast;
pub(crate) mod request_common;
pub(crate) mod resolve;
pub mod seedance_2p0;
pub mod seedance_2p0_fast;
pub mod seedance_2p0_fast_g;
pub mod seedance_2p0_g;
pub mod seedance_2p0_u_fast;
pub mod seedance_2p0_u;
pub mod seedance_2p0_bp;
pub mod seedance_2p0_bp_fast;
@@ -0,0 +1,175 @@
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video_v2::providers::artcraft::build_common::{
build_artcraft_omni_request, SupportedResolutions, UltraWideSupport,
};
use crate::generate::generate_video_v2::providers::artcraft::preview_model::request::ArtcraftPreviewModelRequestState;
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_preview_model(builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
let request = build_artcraft_omni_request(
builder,
CommonVideoModelEnum::PreviewModel,
SupportedResolutions::Full,
UltraWideSupport::Supported,
)?;
let state = ArtcraftPreviewModelRequestState { request };
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftPreviewModel(state)))
}
#[cfg(test)]
mod tests {
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_resolution::CommonResolution;
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::provider::Provider;
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 model_is_preview_model() {
let req = unwrap_request(make_builder(|_| {}));
assert!(matches!(req.request.model, Some(CommonVideoModelEnum::PreviewModel)));
}
#[test]
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_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 req = unwrap_request(make_builder(|b| { b.duration_seconds = Some(99); }));
assert_eq!(req.request.duration_seconds, Some(15));
}
#[test]
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));
}
}
// ── Resolution ──
mod resolution_tests {
use super::*;
#[test]
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 res_720p() {
let req = unwrap_request(make_builder(|b| { b.resolution = Some(CommonResolution::SevenTwentyP); }));
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::SevenTwentyP));
}
#[test]
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 none_stays_none() {
let req = unwrap_request(make_builder(|_| {}));
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(make_builder(|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_preview_model(GenerateVideoRequestBuilder {
start_frame: Some(ImageRef::Url("https://example.com".to_string())),
..base_builder()
});
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(make_builder(|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(make_builder(|b| {
b.reference_character_tokens = Some(CharacterListRef::CharacterTokens(tokens.clone()));
}));
assert_eq!(req.request.reference_character_tokens, Some(tokens));
}
}
// ── Helpers ──
fn base_builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
provider: Provider::Artcraft,
duration_seconds: Some(5),
video_batch_count: Some(1),
..Default::default()
}
}
fn make_builder(f: impl FnOnce(&mut GenerateVideoRequestBuilder)) -> GenerateVideoRequestBuilder {
let mut builder = base_builder();
f(&mut builder);
builder
}
fn unwrap_request(builder: GenerateVideoRequestBuilder) -> ArtcraftPreviewModelRequestState {
let result = build_artcraft_preview_model(builder).expect("build should succeed");
match result {
VideoGenerationDraftOrRequest::Request(
VideoGenerationRequest::ArtcraftPreviewModel(state)
) => state,
_ => panic!("expected ArtcraftPreviewModel request"),
}
}
}
@@ -0,0 +1,233 @@
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::preview_model::request::ArtcraftPreviewModelRequestState;
// ── Pricing constants ──
//
// ArtCraft credits: 100 credits = $1.00. Credits always equal USD cents.
/// USD cents per second by resolution:
/// 480p: $0.10/s = 10.0 ¢/s
/// 720p: $0.25/s = 25.0 ¢/s
/// 1080p: $0.50/s = 50.0 ¢/s
const CENTS_PER_SECOND_480P: f64 = 10.0;
const CENTS_PER_SECOND_720P: f64 = 25.0;
const CENTS_PER_SECOND_1080P: f64 = 50.0;
pub struct ArtcraftPreviewModelCostState {
pub resolution: CommonResolution,
pub duration_seconds: u16,
pub batch_count: u16,
}
impl ArtcraftPreviewModelCostState {
pub fn from_request(request: &ArtcraftPreviewModelRequestState) -> 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);
Self { resolution, duration_seconds, batch_count }
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
let cents_per_second = match self.resolution {
CommonResolution::FourEightyP => CENTS_PER_SECOND_480P,
CommonResolution::TenEightyP => CENTS_PER_SECOND_1080P,
// Everything else (including 720p) 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;
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,
failures_are_refunded: None,
}
}
}
#[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), 100);
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 5, 1), 125);
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 10, 1), 250);
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 15, 1), 375);
}
#[test]
fn batch_2() {
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 5, 2), 250);
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 15, 2), 750);
}
#[test]
fn batch_4() {
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 5, 4), 500);
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 15, 4), 1500);
}
#[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), 40);
assert_eq!(cost_cents(Some(CommonResolution::FourEightyP), 5, 1), 50);
assert_eq!(cost_cents(Some(CommonResolution::FourEightyP), 10, 1), 100);
assert_eq!(cost_cents(Some(CommonResolution::FourEightyP), 15, 1), 150);
}
#[test]
fn batch_2() {
assert_eq!(cost_cents(Some(CommonResolution::FourEightyP), 5, 2), 100);
}
#[test]
fn batch_4() {
assert_eq!(cost_cents(Some(CommonResolution::FourEightyP), 5, 4), 200);
}
}
// -- 1080p pricing --
mod pricing_1080p {
use super::*;
#[test]
fn batch_1() {
assert_eq!(cost_cents(Some(CommonResolution::TenEightyP), 4, 1), 200);
assert_eq!(cost_cents(Some(CommonResolution::TenEightyP), 5, 1), 250);
assert_eq!(cost_cents(Some(CommonResolution::TenEightyP), 10, 1), 500);
assert_eq!(cost_cents(Some(CommonResolution::TenEightyP), 15, 1), 750);
}
#[test]
fn batch_2() {
assert_eq!(cost_cents(Some(CommonResolution::TenEightyP), 5, 2), 500);
}
#[test]
fn batch_4() {
assert_eq!(cost_cents(Some(CommonResolution::TenEightyP), 5, 4), 1000);
}
}
// -- Relative pricing --
mod relative_pricing_tests {
use super::*;
#[test]
fn cost_480p_cheaper_than_720p_cheaper_than_1080p() {
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 = 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::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);
}
}
// -- Credits equal cents --
mod credits_tests {
use super::*;
#[test]
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,
);
}
}
}
}
}
// -- 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::PreviewModel,
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,17 @@
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
use crate::generate::generate_video_v2::providers::artcraft::request_common::send_artcraft_omni_request;
#[derive(Clone, Debug)]
pub struct ArtcraftPreviewModelRequestState {
pub request: OmniGenVideoCostAndGenerateRequest,
}
impl ArtcraftPreviewModelRequestState {
pub async fn send(&self, client: &RouterArtcraftClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
send_artcraft_omni_request(&self.request, client).await
}
}
@@ -0,0 +1,188 @@
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video_v2::providers::artcraft::build_common::{
build_artcraft_omni_request, SupportedResolutions, UltraWideSupport,
};
use crate::generate::generate_video_v2::providers::artcraft::preview_model_fast::request::ArtcraftPreviewModelFastRequestState;
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_preview_model_fast(builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
let request = build_artcraft_omni_request(
builder,
CommonVideoModelEnum::PreviewModelFast,
SupportedResolutions::Fast,
UltraWideSupport::Supported,
)?;
let state = ArtcraftPreviewModelFastRequestState { request };
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftPreviewModelFast(state)))
}
#[cfg(test)]
mod tests {
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_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::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 model_is_preview_model_fast() {
let req = unwrap_request(make_builder(|_| {}));
assert!(matches!(req.request.model, Some(CommonVideoModelEnum::PreviewModelFast)));
}
#[test]
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_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 req = unwrap_request(make_builder(|b| { b.duration_seconds = Some(99); }));
assert_eq!(req.request.duration_seconds, Some(15));
}
#[test]
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));
}
}
// ── Resolution ──
mod resolution_tests {
use super::*;
#[test]
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 res_720p() {
let req = unwrap_request(make_builder(|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(make_builder(|b| { b.resolution = Some(CommonResolution::TenEightyP); }));
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::SevenTwentyP));
}
#[test]
fn res_1080p_error_out() {
let result = build_artcraft_preview_model_fast(GenerateVideoRequestBuilder {
model: CommonVideoModel::PreviewModelFast,
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(make_builder(|_| {}));
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(make_builder(|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_preview_model_fast(GenerateVideoRequestBuilder {
model: CommonVideoModel::PreviewModelFast,
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(make_builder(|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(make_builder(|b| {
b.reference_character_tokens = Some(CharacterListRef::CharacterTokens(tokens.clone()));
}));
assert_eq!(req.request.reference_character_tokens, Some(tokens));
}
}
// ── Helpers ──
fn make_builder(f: impl FnOnce(&mut GenerateVideoRequestBuilder)) -> GenerateVideoRequestBuilder {
let mut builder = GenerateVideoRequestBuilder {
model: CommonVideoModel::PreviewModelFast,
provider: Provider::Artcraft,
duration_seconds: Some(5),
video_batch_count: Some(1),
..Default::default()
};
f(&mut builder);
builder
}
fn unwrap_request(builder: GenerateVideoRequestBuilder) -> ArtcraftPreviewModelFastRequestState {
let result = build_artcraft_preview_model_fast(builder).expect("build should succeed");
match result {
VideoGenerationDraftOrRequest::Request(
VideoGenerationRequest::ArtcraftPreviewModelFast(state)
) => state,
_ => panic!("expected ArtcraftPreviewModelFast request"),
}
}
}
@@ -0,0 +1,203 @@
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::preview_model_fast::request::ArtcraftPreviewModelFastRequestState;
// ── Pricing constants ──
//
// ArtCraft credits: 100 credits = $1.00. Credits always equal USD cents.
/// USD cents per second by resolution:
/// 480p: $0.09/s = 9.0 ¢/s
/// 720p: $0.20/s = 20.0 ¢/s
const CENTS_PER_SECOND_480P: f64 = 9.0;
const CENTS_PER_SECOND_720P: f64 = 20.0;
pub struct ArtcraftPreviewModelFastCostState {
pub resolution: CommonResolution,
pub duration_seconds: u16,
pub batch_count: u16,
}
impl ArtcraftPreviewModelFastCostState {
pub fn from_request(request: &ArtcraftPreviewModelFastRequestState) -> 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);
Self { resolution, duration_seconds, batch_count }
}
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;
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,
failures_are_refunded: None,
}
}
}
#[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), 80);
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 5, 1), 100);
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 10, 1), 200);
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 15, 1), 300);
}
#[test]
fn batch_2() {
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 5, 2), 200);
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 15, 2), 600);
}
#[test]
fn batch_4() {
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 5, 4), 400);
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 15, 4), 1200);
}
#[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), 36);
assert_eq!(cost_cents(Some(CommonResolution::FourEightyP), 5, 1), 45);
assert_eq!(cost_cents(Some(CommonResolution::FourEightyP), 10, 1), 90);
assert_eq!(cost_cents(Some(CommonResolution::FourEightyP), 15, 1), 135);
}
#[test]
fn batch_2() {
assert_eq!(cost_cents(Some(CommonResolution::FourEightyP), 5, 2), 90);
}
#[test]
fn batch_4() {
assert_eq!(cost_cents(Some(CommonResolution::FourEightyP), 5, 4), 180);
}
}
// -- 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);
}
#[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,
);
}
}
}
}
}
// -- 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::PreviewModelFast,
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,17 @@
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
use crate::generate::generate_video_v2::providers::artcraft::request_common::send_artcraft_omni_request;
#[derive(Clone, Debug)]
pub struct ArtcraftPreviewModelFastRequestState {
pub request: OmniGenVideoCostAndGenerateRequest,
}
impl ArtcraftPreviewModelFastRequestState {
pub async fn send(&self, client: &RouterArtcraftClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
send_artcraft_omni_request(&self.request, client).await
}
}
@@ -0,0 +1,29 @@
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,
};
/// Send a video generation request to the Artcraft omni-gen endpoint.
/// All Artcraft model request states delegate to this function.
pub async fn send_artcraft_omni_request(
request: &OmniGenVideoCostAndGenerateRequest,
client: &RouterArtcraftClient,
) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
let response = omni_gen_video_generate(
&client.api_host,
Some(&client.credentials),
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],
}))
}
@@ -1,194 +1,25 @@
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::build_common::{
build_artcraft_omni_request, SupportedResolutions, UltraWideSupport,
};
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;
pub fn build_artcraft_seedance_2p0(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::Seedance2p0),
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,
};
pub fn build_artcraft_seedance_2p0(builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
let request = build_artcraft_omni_request(
builder,
CommonVideoModelEnum::Seedance2p0,
SupportedResolutions::Full,
UltraWideSupport::Supported,
)?;
let state = ArtcraftSeedance2p0RequestState { request };
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p0(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),
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)),
Some(CommonAspectRatio::WideTwentyOneByNine) => Ok(Some(CommonAspectRatioEnum::WideTwentyOneByNine)),
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::TallFourByFive => CommonAspectRatioEnum::TallThreeByFour,
CommonAspectRatio::TallTwoByThree => CommonAspectRatioEnum::TallThreeByFour,
CommonAspectRatio::TallNineByTwentyOne => CommonAspectRatioEnum::TallNineBySixteen,
_ => CommonAspectRatioEnum::Square,
}
}
// Seedance 2.0 Pro supports output resolutions: 480p, 720p, 1080p.
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)),
Some(CommonResolution::TenEightyP) => Ok(Some(CommonResolutionEnum::TenEightyP)),
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::TenEightyP,
}))
}
RequestMismatchMitigationStrategy::PayLessDowngrade => {
Ok(Some(match unsupported {
CommonResolution::HalfK => CommonResolutionEnum::FourEightyP,
_ => CommonResolutionEnum::TenEightyP,
}))
}
},
}
}
// 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_resolution::CommonResolution as CommonResolutionEnum;
@@ -1,33 +1,18 @@
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,
};
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
use crate::generate::generate_video_v2::providers::artcraft::request_common::send_artcraft_omni_request;
#[derive(Clone, Debug)]
pub struct ArtcraftSeedance2p0RequestState {
/// 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 = 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],
}))
send_artcraft_omni_request(&self.request, client).await
}
}
@@ -0,0 +1,167 @@
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video_v2::providers::artcraft::build_common::{
build_artcraft_omni_request, SupportedResolutions, UltraWideSupport,
};
use crate::generate::generate_video_v2::providers::artcraft::seedance_2p0_bp::request::ArtcraftSeedance2p0BytePlusRequestState;
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_bp(builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
let request = build_artcraft_omni_request(
builder,
CommonVideoModelEnum::Seedance2p0BytePlus,
SupportedResolutions::Full,
UltraWideSupport::Supported,
)?;
let state = ArtcraftSeedance2p0BytePlusRequestState { request };
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p0BytePlus(state)))
}
#[cfg(test)]
mod tests {
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_resolution::CommonResolution;
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::provider::Provider;
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::*;
mod field_conversions {
use super::*;
#[test]
fn model_is_seedance_2p0_plus() {
let req = unwrap_request(make_builder(|_| {}));
assert!(matches!(req.request.model, Some(CommonVideoModelEnum::Seedance2p0BytePlus)));
}
#[test]
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_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 req = unwrap_request(make_builder(|b| { b.duration_seconds = Some(99); }));
assert_eq!(req.request.duration_seconds, Some(15));
}
#[test]
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));
}
}
mod resolution_tests {
use super::*;
#[test]
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 res_720p() {
let req = unwrap_request(make_builder(|b| { b.resolution = Some(CommonResolution::SevenTwentyP); }));
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::SevenTwentyP));
}
#[test]
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 none_stays_none() {
let req = unwrap_request(make_builder(|_| {}));
assert!(req.request.resolution.is_none());
}
}
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(make_builder(|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_bp(GenerateVideoRequestBuilder {
start_frame: Some(ImageRef::Url("https://example.com".to_string())),
..base_builder()
});
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(make_builder(|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(make_builder(|b| {
b.reference_character_tokens = Some(CharacterListRef::CharacterTokens(tokens.clone()));
}));
assert_eq!(req.request.reference_character_tokens, Some(tokens));
}
}
fn base_builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
provider: Provider::Artcraft,
duration_seconds: Some(5),
video_batch_count: Some(1),
..Default::default()
}
}
fn make_builder(f: impl FnOnce(&mut GenerateVideoRequestBuilder)) -> GenerateVideoRequestBuilder {
let mut builder = base_builder();
f(&mut builder);
builder
}
fn unwrap_request(builder: GenerateVideoRequestBuilder) -> ArtcraftSeedance2p0BytePlusRequestState {
let result = build_artcraft_seedance_2p0_bp(builder).expect("build should succeed");
match result {
VideoGenerationDraftOrRequest::Request(
VideoGenerationRequest::ArtcraftSeedance2p0BytePlus(state)
) => state,
_ => panic!("expected ArtcraftSeedance2p0BytePlus request"),
}
}
}
@@ -0,0 +1,175 @@
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_bp::request::ArtcraftSeedance2p0BytePlusRequestState;
/// USD cents per second by resolution:
/// 480p: $0.10/s = 10.0 ¢/s
/// 720p: $0.25/s = 25.0 ¢/s
/// 1080p: $0.50/s = 50.0 ¢/s
const CENTS_PER_SECOND_480P: f64 = 10.0;
const CENTS_PER_SECOND_720P: f64 = 25.0;
const CENTS_PER_SECOND_1080P: f64 = 50.0;
pub struct ArtcraftSeedance2p0BytePlusCostState {
pub resolution: CommonResolution,
pub duration_seconds: u16,
pub batch_count: u16,
}
impl ArtcraftSeedance2p0BytePlusCostState {
pub fn from_request(request: &ArtcraftSeedance2p0BytePlusRequestState) -> 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);
Self { resolution, duration_seconds, batch_count }
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
let cents_per_second = match self.resolution {
CommonResolution::FourEightyP => CENTS_PER_SECOND_480P,
CommonResolution::TenEightyP => CENTS_PER_SECOND_1080P,
_ => CENTS_PER_SECOND_720P,
};
let usd_cents = (self.duration_seconds as f64 * cents_per_second * self.batch_count as f64).round() as u64;
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,
failures_are_refunded: None,
}
}
}
#[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;
mod pricing_720p {
use super::*;
#[test]
fn batch_1() {
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 4, 1), 100);
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 5, 1), 125);
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 10, 1), 250);
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 15, 1), 375);
}
#[test]
fn batch_2() {
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 5, 2), 250);
}
#[test]
fn batch_4() {
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 5, 4), 500);
}
#[test]
fn none_defaults_to_720p() {
assert_eq!(cost_cents(None, 5, 1), cost_cents(Some(CommonResolution::SevenTwentyP), 5, 1));
}
}
mod pricing_480p {
use super::*;
#[test]
fn batch_1() {
assert_eq!(cost_cents(Some(CommonResolution::FourEightyP), 4, 1), 40);
assert_eq!(cost_cents(Some(CommonResolution::FourEightyP), 5, 1), 50);
assert_eq!(cost_cents(Some(CommonResolution::FourEightyP), 10, 1), 100);
assert_eq!(cost_cents(Some(CommonResolution::FourEightyP), 15, 1), 150);
}
#[test]
fn batch_2() {
assert_eq!(cost_cents(Some(CommonResolution::FourEightyP), 5, 2), 100);
}
#[test]
fn batch_4() {
assert_eq!(cost_cents(Some(CommonResolution::FourEightyP), 5, 4), 200);
}
}
mod pricing_1080p {
use super::*;
#[test]
fn batch_1() {
assert_eq!(cost_cents(Some(CommonResolution::TenEightyP), 4, 1), 200);
assert_eq!(cost_cents(Some(CommonResolution::TenEightyP), 5, 1), 250);
assert_eq!(cost_cents(Some(CommonResolution::TenEightyP), 10, 1), 500);
assert_eq!(cost_cents(Some(CommonResolution::TenEightyP), 15, 1), 750);
}
#[test]
fn batch_2() {
assert_eq!(cost_cents(Some(CommonResolution::TenEightyP), 5, 2), 500);
}
#[test]
fn batch_4() {
assert_eq!(cost_cents(Some(CommonResolution::TenEightyP), 5, 4), 1000);
}
}
mod relative_pricing_tests {
use super::*;
#[test]
fn cost_480p_cheaper_than_720p_cheaper_than_1080p() {
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);
}
}
mod credits_tests {
use super::*;
#[test]
fn credits_equal_usd_cents() {
for res in [Some(CommonResolution::FourEightyP), Some(CommonResolution::SevenTwentyP), Some(CommonResolution::TenEightyP), None] {
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);
}
}
}
}
}
fn build_cost(
resolution: Option<CommonResolution>,
duration_seconds: u16,
video_batch_count: u16,
) -> crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate {
GenerateVideoRequestBuilder {
model: CommonVideoModel::Seedance2p0BytePlus,
provider: Provider::Artcraft,
resolution,
duration_seconds: Some(duration_seconds),
video_batch_count: Some(video_batch_count),
..Default::default()
}.build2().expect("build2").estimate_cost().expect("estimate_cost")
}
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,17 @@
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
use crate::generate::generate_video_v2::providers::artcraft::request_common::send_artcraft_omni_request;
#[derive(Clone, Debug)]
pub struct ArtcraftSeedance2p0BytePlusRequestState {
pub request: OmniGenVideoCostAndGenerateRequest,
}
impl ArtcraftSeedance2p0BytePlusRequestState {
pub async fn send(&self, client: &RouterArtcraftClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
send_artcraft_omni_request(&self.request, client).await
}
}
@@ -0,0 +1,180 @@
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video_v2::providers::artcraft::build_common::{
build_artcraft_omni_request, SupportedResolutions, UltraWideSupport,
};
use crate::generate::generate_video_v2::providers::artcraft::seedance_2p0_bp_fast::request::ArtcraftSeedance2p0BytePlusFastRequestState;
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_bp_fast(builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
let request = build_artcraft_omni_request(
builder,
CommonVideoModelEnum::Seedance2p0BytePlusFast,
SupportedResolutions::Fast,
UltraWideSupport::Supported,
)?;
let state = ArtcraftSeedance2p0BytePlusFastRequestState { request };
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p0BytePlusFast(state)))
}
#[cfg(test)]
mod tests {
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_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::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::*;
mod field_conversions {
use super::*;
#[test]
fn model_is_seedance_2p0_plus_fast() {
let req = unwrap_request(make_builder(|_| {}));
assert!(matches!(req.request.model, Some(CommonVideoModelEnum::Seedance2p0BytePlusFast)));
}
#[test]
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_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 req = unwrap_request(make_builder(|b| { b.duration_seconds = Some(99); }));
assert_eq!(req.request.duration_seconds, Some(15));
}
#[test]
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));
}
}
mod resolution_tests {
use super::*;
#[test]
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 res_720p() {
let req = unwrap_request(make_builder(|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(make_builder(|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_bp_fast(GenerateVideoRequestBuilder {
model: CommonVideoModel::Seedance2p0BytePlusFast,
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(make_builder(|_| {}));
assert!(req.request.resolution.is_none());
}
}
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(make_builder(|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_bp_fast(GenerateVideoRequestBuilder {
model: CommonVideoModel::Seedance2p0BytePlusFast,
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(make_builder(|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(make_builder(|b| {
b.reference_character_tokens = Some(CharacterListRef::CharacterTokens(tokens.clone()));
}));
assert_eq!(req.request.reference_character_tokens, Some(tokens));
}
}
fn make_builder(f: impl FnOnce(&mut GenerateVideoRequestBuilder)) -> GenerateVideoRequestBuilder {
let mut builder = GenerateVideoRequestBuilder {
model: CommonVideoModel::Seedance2p0BytePlusFast,
provider: Provider::Artcraft,
duration_seconds: Some(5),
video_batch_count: Some(1),
..Default::default()
};
f(&mut builder);
builder
}
fn unwrap_request(builder: GenerateVideoRequestBuilder) -> ArtcraftSeedance2p0BytePlusFastRequestState {
let result = build_artcraft_seedance_2p0_bp_fast(builder).expect("build should succeed");
match result {
VideoGenerationDraftOrRequest::Request(
VideoGenerationRequest::ArtcraftSeedance2p0BytePlusFast(state)
) => state,
_ => panic!("expected ArtcraftSeedance2p0BytePlusFast request"),
}
}
}
@@ -0,0 +1,146 @@
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_bp_fast::request::ArtcraftSeedance2p0BytePlusFastRequestState;
/// USD cents per second by resolution:
/// 480p: $0.09/s = 9.0 ¢/s
/// 720p: $0.20/s = 20.0 ¢/s
const CENTS_PER_SECOND_480P: f64 = 9.0;
const CENTS_PER_SECOND_720P: f64 = 20.0;
pub struct ArtcraftSeedance2p0BytePlusFastCostState {
pub resolution: CommonResolution,
pub duration_seconds: u16,
pub batch_count: u16,
}
impl ArtcraftSeedance2p0BytePlusFastCostState {
pub fn from_request(request: &ArtcraftSeedance2p0BytePlusFastRequestState) -> 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);
Self { resolution, duration_seconds, batch_count }
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
let cents_per_second = match self.resolution {
CommonResolution::FourEightyP => CENTS_PER_SECOND_480P,
_ => CENTS_PER_SECOND_720P,
};
let usd_cents = (self.duration_seconds as f64 * cents_per_second * self.batch_count as f64).round() as u64;
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,
failures_are_refunded: None,
}
}
}
#[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;
mod pricing_720p {
use super::*;
#[test]
fn batch_1() {
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 4, 1), 80);
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 5, 1), 100);
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 10, 1), 200);
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 15, 1), 300);
}
#[test]
fn batch_2() {
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 5, 2), 200);
}
#[test]
fn batch_4() {
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 5, 4), 400);
}
#[test]
fn none_defaults_to_720p() {
assert_eq!(cost_cents(None, 5, 1), cost_cents(Some(CommonResolution::SevenTwentyP), 5, 1));
}
}
mod pricing_480p {
use super::*;
#[test]
fn batch_1() {
assert_eq!(cost_cents(Some(CommonResolution::FourEightyP), 4, 1), 36);
assert_eq!(cost_cents(Some(CommonResolution::FourEightyP), 5, 1), 45);
assert_eq!(cost_cents(Some(CommonResolution::FourEightyP), 10, 1), 90);
assert_eq!(cost_cents(Some(CommonResolution::FourEightyP), 15, 1), 135);
}
#[test]
fn batch_2() {
assert_eq!(cost_cents(Some(CommonResolution::FourEightyP), 5, 2), 90);
}
#[test]
fn batch_4() {
assert_eq!(cost_cents(Some(CommonResolution::FourEightyP), 5, 4), 180);
}
}
mod relative_pricing_tests {
use super::*;
#[test]
fn cost_480p_cheaper_than_720p() {
assert!(cost_cents(Some(CommonResolution::FourEightyP), 5, 1) < cost_cents(Some(CommonResolution::SevenTwentyP), 5, 1));
}
}
mod credits_tests {
use super::*;
#[test]
fn credits_equal_usd_cents() {
for res in [Some(CommonResolution::FourEightyP), Some(CommonResolution::SevenTwentyP), None] {
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);
}
}
}
}
}
fn build_cost(
resolution: Option<CommonResolution>,
duration_seconds: u16,
video_batch_count: u16,
) -> crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate {
GenerateVideoRequestBuilder {
model: CommonVideoModel::Seedance2p0BytePlusFast,
provider: Provider::Artcraft,
resolution,
duration_seconds: Some(duration_seconds),
video_batch_count: Some(video_batch_count),
..Default::default()
}.build2().expect("build2").estimate_cost().expect("estimate_cost")
}
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,17 @@
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
use crate::generate::generate_video_v2::providers::artcraft::request_common::send_artcraft_omni_request;
#[derive(Clone, Debug)]
pub struct ArtcraftSeedance2p0BytePlusFastRequestState {
pub request: OmniGenVideoCostAndGenerateRequest,
}
impl ArtcraftSeedance2p0BytePlusFastRequestState {
pub async fn send(&self, client: &RouterArtcraftClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
send_artcraft_omni_request(&self.request, client).await
}
}
@@ -1,206 +1,25 @@
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::build_common::{
build_artcraft_omni_request, SupportedResolutions, UltraWideSupport,
};
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,
};
pub fn build_artcraft_seedance_2p0_fast(builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
let request = build_artcraft_omni_request(
builder,
CommonVideoModelEnum::Seedance2p0Fast,
SupportedResolutions::Fast,
UltraWideSupport::Supported,
)?;
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)),
Some(CommonAspectRatio::WideTwentyOneByNine) => Ok(Some(CommonAspectRatioEnum::WideTwentyOneByNine)),
// 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::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;
@@ -1,33 +1,18 @@
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,
};
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
use crate::generate::generate_video_v2::providers::artcraft::request_common::send_artcraft_omni_request;
#[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],
}))
send_artcraft_omni_request(&self.request, client).await
}
}
@@ -1,426 +0,0 @@
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_g::request::ArtcraftSeedance2p0FastGRequestState;
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_g(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::Seedance2p0FastGlobal),
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 = ArtcraftSeedance2p0FastGRequestState { request };
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p0FastG(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)),
Some(CommonAspectRatio::WideTwentyOneByNine) => Ok(Some(CommonAspectRatioEnum::WideTwentyOneByNine)),
// 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::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::Seedance2p0FastGlobal)));
}
#[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_g(GenerateVideoRequestBuilder {
model: CommonVideoModel::Seedance2p0FastGlobal,
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_g(GenerateVideoRequestBuilder {
model: CommonVideoModel::Seedance2p0FastGlobal,
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::Seedance2p0FastGlobal,
provider: Provider::Artcraft,
duration_seconds: Some(5),
video_batch_count: Some(1),
..Default::default()
};
f(&mut builder);
builder
}
fn unwrap_request(builder: GenerateVideoRequestBuilder) -> ArtcraftSeedance2p0FastGRequestState {
let result = build_artcraft_seedance_2p0_fast_g(builder).expect("build should succeed");
match result {
VideoGenerationDraftOrRequest::Request(
VideoGenerationRequest::ArtcraftSeedance2p0FastG(state)
) => state,
_ => panic!("expected ArtcraftSeedance2p0Fast request"),
}
}
}
@@ -0,0 +1,175 @@
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video_v2::providers::artcraft::build_common::{
build_artcraft_omni_request, SupportedResolutions, UltraWideSupport,
};
use crate::generate::generate_video_v2::providers::artcraft::seedance_2p0_u::request::ArtcraftSeedance2p0UltraRequestState;
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_u(builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
let request = build_artcraft_omni_request(
builder,
CommonVideoModelEnum::Seedance2p0Ultra,
SupportedResolutions::Full,
UltraWideSupport::Supported,
)?;
let state = ArtcraftSeedance2p0UltraRequestState { request };
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p0Ultra(state)))
}
#[cfg(test)]
mod tests {
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_resolution::CommonResolution;
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::provider::Provider;
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 model_is_seedance_2p0() {
let req = unwrap_request(make_builder(|_| {}));
assert!(matches!(req.request.model, Some(CommonVideoModelEnum::Seedance2p0Ultra)));
}
#[test]
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_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 req = unwrap_request(make_builder(|b| { b.duration_seconds = Some(99); }));
assert_eq!(req.request.duration_seconds, Some(15));
}
#[test]
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));
}
}
// ── Resolution ──
mod resolution_tests {
use super::*;
#[test]
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 res_720p() {
let req = unwrap_request(make_builder(|b| { b.resolution = Some(CommonResolution::SevenTwentyP); }));
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::SevenTwentyP));
}
#[test]
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 none_stays_none() {
let req = unwrap_request(make_builder(|_| {}));
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(make_builder(|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_u(GenerateVideoRequestBuilder {
start_frame: Some(ImageRef::Url("https://example.com".to_string())),
..base_builder()
});
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(make_builder(|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(make_builder(|b| {
b.reference_character_tokens = Some(CharacterListRef::CharacterTokens(tokens.clone()));
}));
assert_eq!(req.request.reference_character_tokens, Some(tokens));
}
}
// ── Helpers ──
fn base_builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
provider: Provider::Artcraft,
duration_seconds: Some(5),
video_batch_count: Some(1),
..Default::default()
}
}
fn make_builder(f: impl FnOnce(&mut GenerateVideoRequestBuilder)) -> GenerateVideoRequestBuilder {
let mut builder = base_builder();
f(&mut builder);
builder
}
fn unwrap_request(builder: GenerateVideoRequestBuilder) -> ArtcraftSeedance2p0UltraRequestState {
let result = build_artcraft_seedance_2p0_u(builder).expect("build should succeed");
match result {
VideoGenerationDraftOrRequest::Request(
VideoGenerationRequest::ArtcraftSeedance2p0Ultra(state)
) => state,
_ => panic!("expected ArtcraftSeedance2p0 request"),
}
}
}
@@ -1,7 +1,7 @@
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_g::request::ArtcraftSeedance2p0GRequestState;
use crate::generate::generate_video_v2::providers::artcraft::seedance_2p0_u::request::ArtcraftSeedance2p0UltraRequestState;
// ── Pricing constants ──
@@ -9,14 +9,14 @@ const CENTS_PER_SECOND_480P: f64 = 5.4404;
const CENTS_PER_SECOND_720P: f64 = 11.2;
const CENTS_PER_SECOND_1080P: f64 = 32.6424;
pub struct ArtcraftSeedance2p0GCostState {
pub struct ArtcraftSeedance2p0UltraCostState {
pub resolution: CommonResolution,
pub duration_seconds: u16,
pub batch_count: u16,
}
impl ArtcraftSeedance2p0GCostState {
pub fn from_request(request: &ArtcraftSeedance2p0GRequestState) -> Self {
impl ArtcraftSeedance2p0UltraCostState {
pub fn from_request(request: &ArtcraftSeedance2p0UltraRequestState) -> Self {
let resolution = request.request.resolution
.unwrap_or(CommonResolution::SevenTwentyP);
let duration_seconds = request.request.duration_seconds.unwrap_or(5);
@@ -165,7 +165,7 @@ mod tests {
video_batch_count: u16,
) -> crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate {
let builder = GenerateVideoRequestBuilder {
model: CommonVideoModel::Seedance2p0Global,
model: CommonVideoModel::Seedance2p0Ultra,
provider: Provider::Artcraft,
resolution,
duration_seconds: Some(duration_seconds),
@@ -0,0 +1,3 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -1,33 +1,18 @@
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,
};
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
use crate::generate::generate_video_v2::providers::artcraft::request_common::send_artcraft_omni_request;
#[derive(Clone, Debug)]
pub struct ArtcraftSeedance2p0GRequestState {
/// Final materialized request; ready to fire via the omni-gen video endpoint.
pub struct ArtcraftSeedance2p0UltraRequestState {
pub request: OmniGenVideoCostAndGenerateRequest,
}
impl ArtcraftSeedance2p0GRequestState {
impl ArtcraftSeedance2p0UltraRequestState {
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],
}))
send_artcraft_omni_request(&self.request, client).await
}
}
@@ -0,0 +1,245 @@
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video_v2::providers::artcraft::build_common::{
build_artcraft_omni_request, SupportedResolutions, UltraWideSupport,
};
use crate::generate::generate_video_v2::providers::artcraft::seedance_2p0_u_fast::request::ArtcraftSeedance2p0UltraFastRequestState;
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_u_fast(builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
let request = build_artcraft_omni_request(
builder,
CommonVideoModelEnum::Seedance2p0UltraFast,
SupportedResolutions::Fast,
UltraWideSupport::Supported,
)?;
let state = ArtcraftSeedance2p0UltraFastRequestState { request };
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p0UltraFast(state)))
}
#[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::Seedance2p0UltraFast)));
}
#[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_u_fast(GenerateVideoRequestBuilder {
model: CommonVideoModel::Seedance2p0UltraFast,
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_u_fast(GenerateVideoRequestBuilder {
model: CommonVideoModel::Seedance2p0UltraFast,
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::Seedance2p0UltraFast,
provider: Provider::Artcraft,
duration_seconds: Some(5),
video_batch_count: Some(1),
..Default::default()
};
f(&mut builder);
builder
}
fn unwrap_request(builder: GenerateVideoRequestBuilder) -> ArtcraftSeedance2p0UltraFastRequestState {
let result = build_artcraft_seedance_2p0_u_fast(builder).expect("build should succeed");
match result {
VideoGenerationDraftOrRequest::Request(
VideoGenerationRequest::ArtcraftSeedance2p0UltraFast(state)
) => state,
_ => panic!("expected ArtcraftSeedance2p0Fast request"),
}
}
}
@@ -1,21 +1,21 @@
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_g::request::ArtcraftSeedance2p0FastGRequestState;
use crate::generate::generate_video_v2::providers::artcraft::seedance_2p0_u_fast::request::ArtcraftSeedance2p0UltraFastRequestState;
// ── Pricing constants ──
const CENTS_PER_SECOND_480P: f64 = 3.6267;
const CENTS_PER_SECOND_720P: f64 = 8.9089;
pub struct ArtcraftSeedance2p0FastGCostState {
pub struct ArtcraftSeedance2p0UltraFastCostState {
pub resolution: CommonResolution,
pub duration_seconds: u16,
pub batch_count: u16,
}
impl ArtcraftSeedance2p0FastGCostState {
pub fn from_request(request: &ArtcraftSeedance2p0FastGRequestState) -> Self {
impl ArtcraftSeedance2p0UltraFastCostState {
pub fn from_request(request: &ArtcraftSeedance2p0UltraFastRequestState) -> Self {
let resolution = request.request.resolution
.unwrap_or(CommonResolution::SevenTwentyP);
let duration_seconds = request.request.duration_seconds.unwrap_or(5);
@@ -125,7 +125,7 @@ mod tests {
let fast = cost_cents(Some(CommonResolution::SevenTwentyP), 10, 1);
let standard = {
let builder = GenerateVideoRequestBuilder {
model: CommonVideoModel::Seedance2p0Global,
model: CommonVideoModel::Seedance2p0Ultra,
provider: Provider::Artcraft,
resolution: Some(CommonResolution::SevenTwentyP),
duration_seconds: Some(10),
@@ -150,7 +150,7 @@ mod tests {
video_batch_count: u16,
) -> crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate {
let builder = GenerateVideoRequestBuilder {
model: CommonVideoModel::Seedance2p0FastGlobal,
model: CommonVideoModel::Seedance2p0UltraFast,
provider: Provider::Artcraft,
resolution,
duration_seconds: Some(duration_seconds),
@@ -0,0 +1,3 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -1,33 +1,18 @@
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,
};
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
use crate::generate::generate_video_v2::providers::artcraft::request_common::send_artcraft_omni_request;
#[derive(Clone, Debug)]
pub struct ArtcraftSeedance2p0FastGRequestState {
/// Final materialized request; ready to fire via the omni-gen video endpoint.
pub struct ArtcraftSeedance2p0UltraFastRequestState {
pub request: OmniGenVideoCostAndGenerateRequest,
}
impl ArtcraftSeedance2p0FastGRequestState {
impl ArtcraftSeedance2p0UltraFastRequestState {
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],
}))
send_artcraft_omni_request(&self.request, client).await
}
}
@@ -200,7 +185,7 @@ mod tests {
fn artcraft_fast_builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
model: CommonVideoModel::Seedance2p0FastGlobal,
model: CommonVideoModel::Seedance2p0UltraFast,
provider: Provider::Artcraft,
duration_seconds: Some(4),
video_batch_count: Some(1),
@@ -12,11 +12,11 @@ use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigati
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::gmicloud::seedance_2p0_fast_g::request::GmiCloudSeedance2p0FastGRequestState;
use crate::generate::generate_video_v2::providers::gmicloud::seedance_2p0_fast_g::request::GmiCloudSeedance2p0UltraFastRequestState;
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_gmicloud_seedance_2p0_fast_g(
pub fn build_gmicloud_seedance_2p0_u_fast(
mut builder: GenerateVideoRequestBuilder,
) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
let strategy = builder.request_mismatch_mitigation_strategy;
@@ -49,8 +49,8 @@ pub fn build_gmicloud_seedance_2p0_fast_g(
reference_asset_ids: None,
};
let state = GmiCloudSeedance2p0FastGRequestState { request };
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::GmiCloudSeedance2p0FastG(state)))
let state = GmiCloudSeedance2p0UltraFastRequestState { request };
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::GmiCloudSeedance2p0UltraFast(state)))
}
fn plan_ratio(
@@ -247,7 +247,7 @@ mod tests {
#[test]
fn media_file_token_rejected() {
let result = build_gmicloud_seedance_2p0_fast_g(GenerateVideoRequestBuilder {
let result = build_gmicloud_seedance_2p0_u_fast(GenerateVideoRequestBuilder {
start_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_x".to_string()))),
..base_builder()
});
@@ -284,7 +284,7 @@ mod tests {
fn base_builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
model: CommonVideoModel::Seedance2p0FastGlobal,
model: CommonVideoModel::Seedance2p0UltraFast,
provider: Provider::GmiCloud,
duration_seconds: Some(5),
video_batch_count: Some(1),
@@ -298,10 +298,10 @@ mod tests {
builder
}
fn unwrap_request(builder: GenerateVideoRequestBuilder) -> GmiCloudSeedance2p0FastGRequestState {
let result = build_gmicloud_seedance_2p0_fast_g(builder).expect("build should succeed");
fn unwrap_request(builder: GenerateVideoRequestBuilder) -> GmiCloudSeedance2p0UltraFastRequestState {
let result = build_gmicloud_seedance_2p0_u_fast(builder).expect("build should succeed");
match result {
VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::GmiCloudSeedance2p0FastG(state)) => state,
VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::GmiCloudSeedance2p0UltraFast(state)) => state,
_ => panic!("expected GmiCloudSeedance2p0FastG request"),
}
}
@@ -1,14 +1,14 @@
use gmicloud_client::traits::gmicloud_request_cost_calculator_trait::GmiCloudRequestCostCalculator;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
use crate::generate::generate_video_v2::providers::gmicloud::seedance_2p0_fast_g::request::GmiCloudSeedance2p0FastGRequestState;
use crate::generate::generate_video_v2::providers::gmicloud::seedance_2p0_fast_g::request::GmiCloudSeedance2p0UltraFastRequestState;
pub struct GmiCloudSeedance2p0FastGCostState {
request: GmiCloudSeedance2p0FastGRequestState,
pub struct GmiCloudSeedance2p0UltraFastCostState {
request: GmiCloudSeedance2p0UltraFastRequestState,
}
impl GmiCloudSeedance2p0FastGCostState {
pub fn from_request(request: &GmiCloudSeedance2p0FastGRequestState) -> Self {
impl GmiCloudSeedance2p0UltraFastCostState {
pub fn from_request(request: &GmiCloudSeedance2p0UltraFastRequestState) -> Self {
Self { request: request.clone() }
}
@@ -93,7 +93,7 @@ mod tests {
let fast = cost_cents(Some(CommonResolution::SevenTwentyP), 10);
let standard = {
let builder = GenerateVideoRequestBuilder {
model: CommonVideoModel::Seedance2p0Global,
model: CommonVideoModel::Seedance2p0Ultra,
provider: Provider::GmiCloud,
resolution: Some(CommonResolution::SevenTwentyP),
duration_seconds: Some(10),
@@ -108,7 +108,7 @@ mod tests {
fn cost_cents(resolution: Option<CommonResolution>, duration_seconds: u16) -> u64 {
let builder = GenerateVideoRequestBuilder {
model: CommonVideoModel::Seedance2p0FastGlobal,
model: CommonVideoModel::Seedance2p0UltraFast,
provider: Provider::GmiCloud,
resolution,
duration_seconds: Some(duration_seconds),
@@ -8,11 +8,11 @@ use crate::generate::generate_video::generate_video_response::{
};
#[derive(Clone, Debug)]
pub struct GmiCloudSeedance2p0FastGRequestState {
pub struct GmiCloudSeedance2p0UltraFastRequestState {
pub request: Seedance20FastRequest,
}
impl GmiCloudSeedance2p0FastGRequestState {
impl GmiCloudSeedance2p0UltraFastRequestState {
pub async fn send(&self, client: &RouterGmiCloudClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
let response = self.request.send_request(&client.api_key)
.await
@@ -69,7 +69,7 @@ mod tests {
fn gmicloud_fast_builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
model: CommonVideoModel::Seedance2p0FastGlobal,
model: CommonVideoModel::Seedance2p0UltraFast,
provider: Provider::GmiCloud,
video_batch_count: Some(1),
..Default::default()
@@ -12,11 +12,11 @@ use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigati
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::gmicloud::seedance_2p0_g::request::GmiCloudSeedance2p0GRequestState;
use crate::generate::generate_video_v2::providers::gmicloud::seedance_2p0_g::request::GmiCloudSeedance2p0UltraRequestState;
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_gmicloud_seedance_2p0_g(
pub fn build_gmicloud_seedance_2p0_u(
mut builder: GenerateVideoRequestBuilder,
) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
let strategy = builder.request_mismatch_mitigation_strategy;
@@ -49,8 +49,8 @@ pub fn build_gmicloud_seedance_2p0_g(
reference_asset_ids: None,
};
let state = GmiCloudSeedance2p0GRequestState { request };
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::GmiCloudSeedance2p0G(state)))
let state = GmiCloudSeedance2p0UltraRequestState { request };
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::GmiCloudSeedance2p0Ultra(state)))
}
fn plan_ratio(
@@ -270,7 +270,7 @@ mod tests {
#[test]
fn media_file_token_rejected_for_start_frame() {
let result = build_gmicloud_seedance_2p0_g(GenerateVideoRequestBuilder {
let result = build_gmicloud_seedance_2p0_u(GenerateVideoRequestBuilder {
start_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_test".to_string()))),
..base_builder()
});
@@ -288,7 +288,7 @@ mod tests {
#[test]
fn reference_image_tokens_rejected() {
let result = build_gmicloud_seedance_2p0_g(GenerateVideoRequestBuilder {
let result = build_gmicloud_seedance_2p0_u(GenerateVideoRequestBuilder {
reference_images: Some(ImageListRef::MediaFileTokens(vec![MediaFileToken::new("mf_a".to_string())])),
..base_builder()
});
@@ -316,7 +316,7 @@ mod tests {
fn base_builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
model: CommonVideoModel::Seedance2p0Global,
model: CommonVideoModel::Seedance2p0Ultra,
provider: Provider::GmiCloud,
duration_seconds: Some(5),
video_batch_count: Some(1),
@@ -330,10 +330,10 @@ mod tests {
builder
}
fn unwrap_request(builder: GenerateVideoRequestBuilder) -> GmiCloudSeedance2p0GRequestState {
let result = build_gmicloud_seedance_2p0_g(builder).expect("build should succeed");
fn unwrap_request(builder: GenerateVideoRequestBuilder) -> GmiCloudSeedance2p0UltraRequestState {
let result = build_gmicloud_seedance_2p0_u(builder).expect("build should succeed");
match result {
VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::GmiCloudSeedance2p0G(state)) => state,
VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::GmiCloudSeedance2p0Ultra(state)) => state,
_ => panic!("expected GmiCloudSeedance2p0G request"),
}
}
@@ -1,14 +1,14 @@
use gmicloud_client::traits::gmicloud_request_cost_calculator_trait::GmiCloudRequestCostCalculator;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
use crate::generate::generate_video_v2::providers::gmicloud::seedance_2p0_g::request::GmiCloudSeedance2p0GRequestState;
use crate::generate::generate_video_v2::providers::gmicloud::seedance_2p0_g::request::GmiCloudSeedance2p0UltraRequestState;
pub struct GmiCloudSeedance2p0GCostState {
request: GmiCloudSeedance2p0GRequestState,
pub struct GmiCloudSeedance2p0UltraCostState {
request: GmiCloudSeedance2p0UltraRequestState,
}
impl GmiCloudSeedance2p0GCostState {
pub fn from_request(request: &GmiCloudSeedance2p0GRequestState) -> Self {
impl GmiCloudSeedance2p0UltraCostState {
pub fn from_request(request: &GmiCloudSeedance2p0UltraRequestState) -> Self {
Self { request: request.clone() }
}
@@ -116,7 +116,7 @@ mod tests {
fn cost_cents(resolution: Option<CommonResolution>, duration_seconds: u16) -> u64 {
let builder = GenerateVideoRequestBuilder {
model: CommonVideoModel::Seedance2p0Global,
model: CommonVideoModel::Seedance2p0Ultra,
provider: Provider::GmiCloud,
resolution,
duration_seconds: Some(duration_seconds),
@@ -8,11 +8,11 @@ use crate::generate::generate_video::generate_video_response::{
};
#[derive(Clone, Debug)]
pub struct GmiCloudSeedance2p0GRequestState {
pub struct GmiCloudSeedance2p0UltraRequestState {
pub request: Seedance20Request,
}
impl GmiCloudSeedance2p0GRequestState {
impl GmiCloudSeedance2p0UltraRequestState {
pub async fn send(&self, client: &RouterGmiCloudClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
let response = self.request.send_request(&client.api_key)
.await
@@ -67,7 +67,7 @@ mod tests {
fn gmicloud_builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
model: CommonVideoModel::Seedance2p0Global,
model: CommonVideoModel::Seedance2p0Ultra,
provider: Provider::GmiCloud,
video_batch_count: Some(1),
..Default::default()
@@ -5,18 +5,26 @@ use crate::generate::generate_video::generate_video_response::GenerateVideoRespo
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
use crate::generate::generate_video_v2::providers::artcraft::happy_horse_1p0::cost::ArtcraftHappyHorse1p0CostState;
use crate::generate::generate_video_v2::providers::artcraft::happy_horse_1p0::request::ArtcraftHappyHorse1p0RequestState;
use crate::generate::generate_video_v2::providers::artcraft::preview_model::cost::ArtcraftPreviewModelCostState;
use crate::generate::generate_video_v2::providers::artcraft::preview_model::request::ArtcraftPreviewModelRequestState;
use crate::generate::generate_video_v2::providers::artcraft::preview_model_fast::cost::ArtcraftPreviewModelFastCostState;
use crate::generate::generate_video_v2::providers::artcraft::preview_model_fast::request::ArtcraftPreviewModelFastRequestState;
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::artcraft::seedance_2p0_g::cost::ArtcraftSeedance2p0GCostState;
use crate::generate::generate_video_v2::providers::artcraft::seedance_2p0_g::request::ArtcraftSeedance2p0GRequestState;
use crate::generate::generate_video_v2::providers::artcraft::seedance_2p0_fast_g::cost::ArtcraftSeedance2p0FastGCostState;
use crate::generate::generate_video_v2::providers::artcraft::seedance_2p0_fast_g::request::ArtcraftSeedance2p0FastGRequestState;
use crate::generate::generate_video_v2::providers::gmicloud::seedance_2p0_g::cost::GmiCloudSeedance2p0GCostState;
use crate::generate::generate_video_v2::providers::gmicloud::seedance_2p0_g::request::GmiCloudSeedance2p0GRequestState;
use crate::generate::generate_video_v2::providers::gmicloud::seedance_2p0_fast_g::cost::GmiCloudSeedance2p0FastGCostState;
use crate::generate::generate_video_v2::providers::gmicloud::seedance_2p0_fast_g::request::GmiCloudSeedance2p0FastGRequestState;
use crate::generate::generate_video_v2::providers::artcraft::seedance_2p0_u::cost::ArtcraftSeedance2p0UltraCostState;
use crate::generate::generate_video_v2::providers::artcraft::seedance_2p0_u::request::ArtcraftSeedance2p0UltraRequestState;
use crate::generate::generate_video_v2::providers::artcraft::seedance_2p0_u_fast::cost::ArtcraftSeedance2p0UltraFastCostState;
use crate::generate::generate_video_v2::providers::artcraft::seedance_2p0_u_fast::request::ArtcraftSeedance2p0UltraFastRequestState;
use crate::generate::generate_video_v2::providers::artcraft::seedance_2p0_bp::cost::ArtcraftSeedance2p0BytePlusCostState;
use crate::generate::generate_video_v2::providers::artcraft::seedance_2p0_bp::request::ArtcraftSeedance2p0BytePlusRequestState;
use crate::generate::generate_video_v2::providers::artcraft::seedance_2p0_bp_fast::cost::ArtcraftSeedance2p0BytePlusFastCostState;
use crate::generate::generate_video_v2::providers::artcraft::seedance_2p0_bp_fast::request::ArtcraftSeedance2p0BytePlusFastRequestState;
use crate::generate::generate_video_v2::providers::gmicloud::seedance_2p0_g::cost::GmiCloudSeedance2p0UltraCostState;
use crate::generate::generate_video_v2::providers::gmicloud::seedance_2p0_g::request::GmiCloudSeedance2p0UltraRequestState;
use crate::generate::generate_video_v2::providers::gmicloud::seedance_2p0_fast_g::cost::GmiCloudSeedance2p0UltraFastCostState;
use crate::generate::generate_video_v2::providers::gmicloud::seedance_2p0_fast_g::request::GmiCloudSeedance2p0UltraFastRequestState;
use crate::generate::generate_video_v2::providers::kinovi::happy_horse_1p0::cost::KinoviHappyHorse1p0CostState;
use crate::generate::generate_video_v2::providers::kinovi::happy_horse_1p0::request::KinoviHappyHorse1p0RequestState;
use crate::generate::generate_video_v2::providers::kinovi::seedance_2p0::cost::KinoviSeedance2p0CostState;
@@ -27,12 +35,16 @@ use crate::generate::generate_video_v2::providers::kinovi::seedance_2p0_fast::re
#[derive(Clone, Debug)]
pub enum VideoGenerationRequest {
ArtcraftHappyHorse1p0(ArtcraftHappyHorse1p0RequestState),
ArtcraftPreviewModel(ArtcraftPreviewModelRequestState),
ArtcraftPreviewModelFast(ArtcraftPreviewModelFastRequestState),
ArtcraftSeedance2p0(ArtcraftSeedance2p0RequestState),
ArtcraftSeedance2p0Fast(ArtcraftSeedance2p0FastRequestState),
ArtcraftSeedance2p0G(ArtcraftSeedance2p0GRequestState),
ArtcraftSeedance2p0FastG(ArtcraftSeedance2p0FastGRequestState),
GmiCloudSeedance2p0G(GmiCloudSeedance2p0GRequestState),
GmiCloudSeedance2p0FastG(GmiCloudSeedance2p0FastGRequestState),
ArtcraftSeedance2p0Ultra(ArtcraftSeedance2p0UltraRequestState),
ArtcraftSeedance2p0UltraFast(ArtcraftSeedance2p0UltraFastRequestState),
ArtcraftSeedance2p0BytePlus(ArtcraftSeedance2p0BytePlusRequestState),
ArtcraftSeedance2p0BytePlusFast(ArtcraftSeedance2p0BytePlusFastRequestState),
GmiCloudSeedance2p0Ultra(GmiCloudSeedance2p0UltraRequestState),
GmiCloudSeedance2p0UltraFast(GmiCloudSeedance2p0UltraFastRequestState),
KinoviHappyHorse1p0(KinoviHappyHorse1p0RequestState),
KinoviSeedance2p0(KinoviSeedance2p0RequestState),
KinoviSeedance2p0Fast(KinoviSeedance2p0FastRequestState),
@@ -43,12 +55,16 @@ impl VideoGenerationRequest {
pub fn get_provider(&self) -> Provider {
match self {
Self::ArtcraftHappyHorse1p0(_) => Provider::Artcraft,
Self::ArtcraftPreviewModel(_) => Provider::Artcraft,
Self::ArtcraftPreviewModelFast(_) => Provider::Artcraft,
Self::ArtcraftSeedance2p0(_) => Provider::Artcraft,
Self::ArtcraftSeedance2p0Fast(_) => Provider::Artcraft,
Self::ArtcraftSeedance2p0G(_) => Provider::Artcraft,
Self::ArtcraftSeedance2p0FastG(_) => Provider::Artcraft,
Self::GmiCloudSeedance2p0G(_) => Provider::GmiCloud,
Self::GmiCloudSeedance2p0FastG(_) => Provider::GmiCloud,
Self::ArtcraftSeedance2p0Ultra(_) => Provider::Artcraft,
Self::ArtcraftSeedance2p0UltraFast(_) => Provider::Artcraft,
Self::ArtcraftSeedance2p0BytePlus(_) => Provider::Artcraft,
Self::ArtcraftSeedance2p0BytePlusFast(_) => Provider::Artcraft,
Self::GmiCloudSeedance2p0Ultra(_) => Provider::GmiCloud,
Self::GmiCloudSeedance2p0UltraFast(_) => Provider::GmiCloud,
Self::KinoviHappyHorse1p0(_) => Provider::Seedance2Pro,
Self::KinoviSeedance2p0(_) => Provider::Seedance2Pro,
Self::KinoviSeedance2p0Fast(_) => Provider::Seedance2Pro,
@@ -59,12 +75,16 @@ impl VideoGenerationRequest {
pub fn estimate_cost(&self) -> Result<VideoGenerationCostEstimate, ArtcraftRouterError> {
match self {
VideoGenerationRequest::ArtcraftHappyHorse1p0(request) => Ok(ArtcraftHappyHorse1p0CostState::from_request(request).estimate_cost()),
VideoGenerationRequest::ArtcraftPreviewModel(request) => Ok(ArtcraftPreviewModelCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::ArtcraftPreviewModelFast(request) => Ok(ArtcraftPreviewModelFastCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::ArtcraftSeedance2p0(request) => Ok(ArtcraftSeedance2p0CostState::from_request(request).estimate_cost()),
VideoGenerationRequest::ArtcraftSeedance2p0Fast(request) => Ok(ArtcraftSeedance2p0FastCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::ArtcraftSeedance2p0G(request) => Ok(ArtcraftSeedance2p0GCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::ArtcraftSeedance2p0FastG(request) => Ok(ArtcraftSeedance2p0FastGCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::GmiCloudSeedance2p0G(request) => Ok(GmiCloudSeedance2p0GCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::GmiCloudSeedance2p0FastG(request) => Ok(GmiCloudSeedance2p0FastGCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::ArtcraftSeedance2p0Ultra(request) => Ok(ArtcraftSeedance2p0UltraCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::ArtcraftSeedance2p0UltraFast(request) => Ok(ArtcraftSeedance2p0UltraFastCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::ArtcraftSeedance2p0BytePlus(request) => Ok(ArtcraftSeedance2p0BytePlusCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::ArtcraftSeedance2p0BytePlusFast(request) => Ok(ArtcraftSeedance2p0BytePlusFastCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::GmiCloudSeedance2p0Ultra(request) => Ok(GmiCloudSeedance2p0UltraCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::GmiCloudSeedance2p0UltraFast(request) => Ok(GmiCloudSeedance2p0UltraFastCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::KinoviHappyHorse1p0(request) => Ok(KinoviHappyHorse1p0CostState::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()),
@@ -79,6 +99,14 @@ impl VideoGenerationRequest {
let client_ref = client.get_artcraft_client_ref()?;
request.send(client_ref).await
},
VideoGenerationRequest::ArtcraftPreviewModel(request) => {
let client_ref = client.get_artcraft_client_ref()?;
request.send(client_ref).await
},
VideoGenerationRequest::ArtcraftPreviewModelFast(request) => {
let client_ref = client.get_artcraft_client_ref()?;
request.send(client_ref).await
},
VideoGenerationRequest::ArtcraftSeedance2p0(request) => {
let client_ref = client.get_artcraft_client_ref()?;
request.send(client_ref).await
@@ -87,19 +115,27 @@ impl VideoGenerationRequest {
let client_ref = client.get_artcraft_client_ref()?;
request.send(client_ref).await
},
VideoGenerationRequest::ArtcraftSeedance2p0G(request) => {
VideoGenerationRequest::ArtcraftSeedance2p0Ultra(request) => {
let client_ref = client.get_artcraft_client_ref()?;
request.send(client_ref).await
},
VideoGenerationRequest::ArtcraftSeedance2p0FastG(request) => {
VideoGenerationRequest::ArtcraftSeedance2p0UltraFast(request) => {
let client_ref = client.get_artcraft_client_ref()?;
request.send(client_ref).await
},
VideoGenerationRequest::GmiCloudSeedance2p0G(request) => {
VideoGenerationRequest::ArtcraftSeedance2p0BytePlus(request) => {
let client_ref = client.get_artcraft_client_ref()?;
request.send(client_ref).await
},
VideoGenerationRequest::ArtcraftSeedance2p0BytePlusFast(request) => {
let client_ref = client.get_artcraft_client_ref()?;
request.send(client_ref).await
},
VideoGenerationRequest::GmiCloudSeedance2p0Ultra(request) => {
let client_ref = client.get_gmicloud_client_ref()?;
request.send(client_ref).await
},
VideoGenerationRequest::GmiCloudSeedance2p0FastG(request) => {
VideoGenerationRequest::GmiCloudSeedance2p0UltraFast(request) => {
let client_ref = client.get_gmicloud_client_ref()?;
request.send(client_ref).await
},
@@ -9,6 +9,9 @@ pub enum GmiCloudSpecificApiError {
/// The request was rejected due to content policy.
ContentPolicyViolation(String),
/// The input image was rejected because it may contain a real person.
ContentContainsRealPerson(String),
/// The account has insufficient credits or a billing issue.
BillingError { status_code: u16, body: String },
}
@@ -2,8 +2,10 @@ use crate::creds::gmicloud_api_key::GmiCloudApiKey;
use crate::error::gmicloud_error::GmiCloudError;
use crate::requests::api::video::seedance_2_0_260128::raw_request::Seedance20Payload;
use crate::requests::common::create_request::{
create_gmicloud_request, GmiCloudCreateRequest, GmiCloudCreateResponse,
create_gmicloud_request, create_gmicloud_request_with_context,
GmiCloudCreateRequest, GmiCloudCreateResponse,
};
use crate::requests::context::request_context::RequestContext;
const MODEL_ID: &str = "seedance-2-0-260128";
@@ -113,12 +115,23 @@ impl Seedance20Request {
pub async fn send_request(
&self,
api_key: &GmiCloudApiKey,
) -> Result<GmiCloudCreateResponse, GmiCloudError> {
let context = RequestContext {
api_key,
maybe_timeout: None,
};
self.send_request_with_context(&context).await
}
pub async fn send_request_with_context(
&self,
context: &RequestContext<'_>,
) -> Result<GmiCloudCreateResponse, GmiCloudError> {
let body = GmiCloudCreateRequest {
model: MODEL_ID.to_string(),
payload: self.to_raw_payload(),
};
create_gmicloud_request(api_key, &body).await
create_gmicloud_request_with_context(context, &body).await
}
pub fn model_id() -> &'static str {
@@ -2,8 +2,10 @@ use crate::creds::gmicloud_api_key::GmiCloudApiKey;
use crate::error::gmicloud_error::GmiCloudError;
use crate::requests::api::video::seedance_2_0_fast_260128::raw_request::Seedance20FastPayload;
use crate::requests::common::create_request::{
create_gmicloud_request, GmiCloudCreateRequest, GmiCloudCreateResponse,
create_gmicloud_request, create_gmicloud_request_with_context,
GmiCloudCreateRequest, GmiCloudCreateResponse,
};
use crate::requests::context::request_context::RequestContext;
const MODEL_ID: &str = "seedance-2-0-fast-260128";
@@ -111,12 +113,23 @@ impl Seedance20FastRequest {
pub async fn send_request(
&self,
api_key: &GmiCloudApiKey,
) -> Result<GmiCloudCreateResponse, GmiCloudError> {
let context = RequestContext {
api_key,
maybe_timeout: None,
};
self.send_request_with_context(&context).await
}
pub async fn send_request_with_context(
&self,
context: &RequestContext<'_>,
) -> Result<GmiCloudCreateResponse, GmiCloudError> {
let body = GmiCloudCreateRequest {
model: MODEL_ID.to_string(),
payload: self.to_raw_payload(),
};
create_gmicloud_request(api_key, &body).await
create_gmicloud_request_with_context(context, &body).await
}
pub fn model_id() -> &'static str {
@@ -1,3 +1,5 @@
use std::time::Duration;
use log::warn;
use serde::{Deserialize, Serialize};
@@ -5,9 +7,14 @@ use crate::creds::gmicloud_api_key::GmiCloudApiKey;
use crate::error::gmicloud_error::GmiCloudError;
use crate::error::gmicloud_generic_api_error::GmiCloudGenericApiError;
use crate::error::gmicloud_specific_api_error::GmiCloudSpecificApiError;
use crate::requests::context::request_context::RequestContext;
const BASE_URL: &str = "https://console.gmicloud.ai/api/v1/ie/requestqueue/apikey";
/// Default request timeout. Video requests return quickly (async queue),
/// but image requests block until completion and can take 60+ seconds.
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(180);
/// The top-level request body sent to `POST /requests`.
#[derive(Debug, Serialize)]
pub struct GmiCloudCreateRequest<P: Serialize> {
@@ -23,17 +30,34 @@ pub struct GmiCloudCreateResponse {
pub status: String,
}
/// Submit a video generation request to GmiCloud.
/// Submit a request to GmiCloud using just an API key (default timeout).
pub async fn create_gmicloud_request<P: Serialize>(
api_key: &GmiCloudApiKey,
body: &GmiCloudCreateRequest<P>,
) -> Result<GmiCloudCreateResponse, GmiCloudError> {
let context = RequestContext {
api_key,
maybe_timeout: None,
};
create_gmicloud_request_with_context(&context, body).await
}
/// Submit a request to GmiCloud with explicit context (custom timeout, etc.).
pub async fn create_gmicloud_request_with_context<P: Serialize>(
context: &RequestContext<'_>,
body: &GmiCloudCreateRequest<P>,
) -> Result<GmiCloudCreateResponse, GmiCloudError> {
let url = format!("{}/requests", BASE_URL);
let timeout = context.maybe_timeout.unwrap_or(DEFAULT_TIMEOUT);
let client = reqwest::Client::builder()
.timeout(timeout)
.build()
.map_err(GmiCloudGenericApiError::from)?;
let client = reqwest::Client::new();
let response = client
.post(&url)
.header("Authorization", format!("Bearer {}", api_key.as_str()))
.header("Authorization", format!("Bearer {}", context.api_key.as_str()))
.json(body)
.send()
.await
@@ -49,6 +73,12 @@ pub async fn create_gmicloud_request<P: Serialize>(
if !status.is_success() {
warn!("GmiCloud API error: status={}, body={}", status, body_text);
// Detect specific error codes embedded in the response body.
if let Some(specific) = classify_error_body(&body_text) {
return Err(specific.into());
}
return Err(GmiCloudGenericApiError::UncategorizedBadResponseWithStatusAndBody {
status_code: status.as_u16(),
body: body_text,
@@ -60,3 +90,28 @@ pub async fn create_gmicloud_request<P: Serialize>(
Ok(parsed)
}
/// Attempt to classify an error response body into a specific error type.
/// GmiCloud sometimes wraps inner API errors as a JSON string within `{"error": "..."}`.
fn classify_error_body(body: &str) -> Option<GmiCloudSpecificApiError> {
if body.contains("InputImageSensitiveContentDetected") {
let message = extract_inner_message(body)
.unwrap_or_else(|| "Input image may contain a real person".to_string());
return Some(GmiCloudSpecificApiError::ContentContainsRealPerson(message));
}
None
}
/// Extract the inner `message` field from a doubly-encoded GmiCloud error response.
/// Expected shape: `{"error":"API request failed with status 400: {\"error\":{\"message\":\"...\"}}"}`
fn extract_inner_message(body: &str) -> Option<String> {
let outer: serde_json::Value = serde_json::from_str(body).ok()?;
let error_str = outer.get("error")?.as_str()?;
// The inner JSON is embedded after "status NNN: "
let inner_json_start = error_str.find('{')?;
let inner_str = &error_str[inner_json_start..];
let inner: serde_json::Value = serde_json::from_str(inner_str).ok()?;
inner.get("error")?.get("message")?.as_str().map(|s| s.to_string())
}
@@ -0,0 +1 @@
pub mod request_context;
@@ -0,0 +1,8 @@
use std::time::Duration;
use crate::creds::gmicloud_api_key::GmiCloudApiKey;
pub struct RequestContext<'a> {
pub api_key: &'a GmiCloudApiKey,
pub maybe_timeout: Option<Duration>,
}
@@ -1,3 +1,4 @@
pub mod api;
pub mod common;
pub mod context;
pub mod poll_request_queue;
@@ -1,7 +1,12 @@
use std::fs;
use std::path::Path;
use anyhow::anyhow;
use clap::Subcommand;
use clap::{Args, Subcommand};
use log::{info, warn};
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
use artcraft_client::utils::api_host::ApiHost;
use super::state::ArtcraftState;
use super::subcommands;
@@ -10,6 +15,24 @@ use super::subcommands;
/// Used by the underscore-insensitive arg normalizer.
pub const SUBCOMMAND_NAMES: &[&str] = &["generate_video"];
const DEFAULT_COOKIE_FILE: &str = "artcraft_cookies.txt";
#[derive(Args)]
pub struct ArtcraftArgs {
/// Path to a file containing the session cookies.
/// If not specified, reads from `artcraft_cookies.txt` in the current directory.
#[arg(long, global = true)]
pub cookie_file: Option<String>,
/// Target environment: "dev" / "development" (localhost:12345) or "prod" / "production" (api.storyteller.ai).
/// [default: dev]
#[arg(long, global = true, default_value = "dev")]
pub environment: String,
#[command(subcommand)]
pub command: ArtcraftCommand,
}
#[derive(Subcommand)]
#[command(rename_all = "snake_case")]
pub enum ArtcraftCommand {
@@ -17,17 +40,52 @@ pub enum ArtcraftCommand {
GenerateVideo(subcommands::generate_video::GenerateVideoArgs),
}
pub async fn run(command: ArtcraftCommand) -> anyhow::Result<()> {
let cookies_str = easyenv::get_env_string_required("ARTCRAFT_COOKIES")
.map_err(|err| anyhow!("Missing ARTCRAFT_COOKIES env var: {:?}", err))?;
pub async fn dispatch(args: ArtcraftArgs) -> anyhow::Result<()> {
let cookie_path = args.cookie_file.as_deref().unwrap_or(DEFAULT_COOKIE_FILE);
let cookies_str = load_cookies(cookie_path)?;
let creds = StorytellerCredentialSet::parse_multi_cookie_header(&cookies_str)
.map_err(|err| anyhow!("Failed to parse ARTCRAFT_COOKIES: {:?}", err))?
.ok_or_else(|| anyhow!("ARTCRAFT_COOKIES contained no recognized cookies (expected session=... and/or visitor=...)"))?;
.map_err(|err| anyhow!("Failed to parse cookies: {:?}", err))?
.ok_or_else(|| anyhow!("Cookie file contained no recognized cookies (expected session=... and/or visitor=...)"))?;
let state = ArtcraftState { creds };
let api_host = parse_api_host(&args.environment)?;
let state = ArtcraftState { creds, api_host };
match command {
ArtcraftCommand::GenerateVideo(args) => subcommands::generate_video::run(&state, args).await,
match args.command {
ArtcraftCommand::GenerateVideo(cmd_args) => subcommands::generate_video::run(&state, cmd_args).await,
}
}
fn load_cookies(cookie_path: &str) -> anyhow::Result<String> {
info!("Reading cookies from: {}", cookie_path);
if Path::new(cookie_path).exists() {
let content = fs::read_to_string(cookie_path)
.map_err(|err| anyhow!("Failed to read cookie file {:?}: {}", cookie_path, err))?;
Ok(content.trim().to_string())
} else {
warn!("Cookie file {:?} does not exist.", cookie_path);
Err(anyhow!(
"Cookie file {:?} not found. Use --cookie-file to specify a path, \
or create '{}' in the current directory.",
cookie_path, DEFAULT_COOKIE_FILE
))
}
}
fn parse_api_host(environment: &str) -> anyhow::Result<ApiHost> {
match environment {
"dev" | "development" => {
info!("Environment: development (localhost:12345)");
Ok(ApiHost::Localhost { port: 12345 })
}
"prod" | "production" => {
info!("Environment: production (api.storyteller.ai)");
Ok(ApiHost::Storyteller)
}
other => Err(anyhow!(
"Unknown environment '{}'. Use 'dev', 'development', 'prod', or 'production'.",
other
)),
}
}
@@ -2,5 +2,6 @@ pub mod dispatch;
mod state;
mod subcommands;
pub use dispatch::ArtcraftArgs;
pub use dispatch::ArtcraftCommand;
pub use dispatch::run;
pub use dispatch::dispatch;
@@ -1,5 +1,7 @@
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
use artcraft_client::utils::api_host::ApiHost;
pub struct ArtcraftState {
pub creds: StorytellerCredentialSet,
pub api_host: ApiHost,
}
@@ -7,7 +7,6 @@ use log::info;
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 artcraft_client::utils::api_host::ApiHost;
use tokens::tokens::media_files::MediaFileToken;
use crate::utils::parse_video_model::parse_video_model;
@@ -51,9 +50,7 @@ pub struct GenerateVideoArgs {
#[arg(long)]
pub audio_reference_tokens: Option<String>,
/// Use localhost:12345 instead of production API.
#[arg(long)]
pub localhost: bool,
/// Video model to use. Accepts canonical names (eg. "seedance_2p0") or aliases
/// (eg. "seedance2", "happyhorse", "veo3fast"). [default: seedance2p0]
@@ -77,7 +74,7 @@ pub async fn run(state: &ArtcraftState, args: GenerateVideoArgs) -> anyhow::Resu
.ok_or_else(|| anyhow!("Unknown model '{}'. Try --help for supported names.", model_str))?;
let prompt = resolve_prompt(&args.prompt)?;
let api_host = if args.localhost { ApiHost::Localhost { port: 12345 } } else { ApiHost::Storyteller };
let api_host = &state.api_host;
let duration = args.duration.unwrap_or(5);
info!("Model: {:?}", model);
@@ -131,7 +128,7 @@ pub async fn run(state: &ArtcraftState, args: GenerateVideoArgs) -> anyhow::Resu
info!("Sending request to omni endpoint (idempotency_token={})...", idempotency_token);
let response = omni_gen_video_generate(
&api_host,
api_host,
Some(&state.creds),
request,
).await.map_err(|err| anyhow!("Omni video generation failed: {}", err))?;
@@ -29,10 +29,7 @@ pub enum TopLevelCommand {
},
/// ArtCraft support commands (omni API)
Artcraft {
#[command(subcommand)]
command: artcraft::ArtcraftCommand,
},
Artcraft(artcraft::dispatch::ArtcraftArgs),
}
pub async fn run(cli: Cli) -> anyhow::Result<()> {
@@ -40,8 +37,8 @@ pub async fn run(cli: Cli) -> anyhow::Result<()> {
TopLevelCommand::Seedance2pro { command } => {
seedance2pro::run(command).await
}
TopLevelCommand::Artcraft { command } => {
artcraft::run(command).await
TopLevelCommand::Artcraft(args) => {
artcraft::dispatch(args).await
}
}
}
@@ -22,6 +22,9 @@ use crate::payloads::generic_inference_args::generic_inference_args::GenericInfe
pub struct InsertGenericInferenceForSeedance2ProWithAprioriJobTokenArgs<'e, 'c, E>
where E: 'e + Executor<'c, Database = MySql>
{
/// Which kinovi queue to use: standard or alternate
pub use_alternate_kinovi: bool,
pub uuid_idempotency_token: &'e str,
// NOTE: We'll generate this ahead of time so we can save it with billing info!
@@ -56,12 +59,23 @@ pub async fn insert_generic_inference_job_for_seedance2pro_queue_with_apriori_jo
let serialized_args_payload = serde_json::ser::to_string(&args.maybe_inference_args)
.map_err(|_e| anyhow!("could not encode inference args"))?;
const JOB_TYPE: InferenceJobType = InferenceJobType::Seedance2ProQueue;
const INFERENCE_CATEGORY: InferenceCategory = InferenceCategory::VideoGeneration;
const PRODUCT_CATEGORY: InferenceJobProductCategory = InferenceJobProductCategory::Seedance2ProVideo;
const EXTERNAL_THIRD_PARTY: InferenceJobExternalThirdParty = InferenceJobExternalThirdParty::Seedance2Pro;
const STATUS: JobStatusPlus = JobStatusPlus::Pending;
let job_type;
let external_third_party;
let product_category;
if args.use_alternate_kinovi {
job_type = InferenceJobType::Seedance2ProAltQueue;
external_third_party = InferenceJobExternalThirdParty::Seedance2ProAlt;
product_category = InferenceJobProductCategory::Seedance2ProVideoAlt;
} else {
job_type = InferenceJobType::Seedance2ProQueue;
external_third_party = InferenceJobExternalThirdParty::Seedance2Pro;
product_category = InferenceJobProductCategory::Seedance2ProVideo;
}
let query = sqlx::query!(
r#"
INSERT INTO generic_inference_jobs
@@ -113,12 +127,12 @@ SET
args.apriori_job_token.as_str(),
args.uuid_idempotency_token,
JOB_TYPE.to_str(),
job_type.to_str(),
EXTERNAL_THIRD_PARTY.to_str(),
external_third_party.to_str(),
args.maybe_external_third_party_id,
PRODUCT_CATEGORY.to_str(),
product_category.to_str(),
INFERENCE_CATEGORY.to_str(),
args.maybe_prompt_token.map(|t| t.to_string()),
@@ -41,7 +41,11 @@ struct RawRecord {
}
/// Returns all non-terminal Seedance2Pro video jobs that have an associated order_id.
pub async fn list_pending_seedance2pro_video_jobs(pool: &MySqlPool) -> Result<Vec<PendingSeedance2ProJob>, sqlx::Error> {
pub async fn list_pending_seedance2pro_video_jobs(
pool: &MySqlPool,
external_third_party: InferenceJobExternalThirdParty,
job_type: InferenceJobType,
) -> Result<Vec<PendingSeedance2ProJob>, sqlx::Error> {
let records = sqlx::query_as!(
RawRecord,
r#"
@@ -64,8 +68,8 @@ WHERE jobs.maybe_external_third_party = ?
LIMIT 25000
"#,
InferenceJobExternalThirdParty::Seedance2Pro.to_str(),
InferenceJobType::Seedance2ProQueue.to_str(),
external_third_party.to_str(),
job_type.to_str(),
)
.fetch_all(pool)
.await?;
@@ -29,6 +29,10 @@ pub enum InferenceJobExternalThirdParty {
#[serde(rename = "seedance2pro")]
Seedance2Pro,
/// Seedance 2 Pro Alt jobs
#[serde(rename = "seedance2pro_alt")]
Seedance2ProAlt,
/// World Labs jobs
#[serde(rename = "worldlabs")]
Worldlabs,
@@ -46,6 +50,7 @@ impl InferenceJobExternalThirdParty {
Self::Fal => "fal",
Self::GmiCloud => "gmicloud",
Self::Seedance2Pro => "seedance2pro",
Self::Seedance2ProAlt => "seedance2pro_alt",
Self::Worldlabs => "worldlabs",
}
}
@@ -56,6 +61,7 @@ impl InferenceJobExternalThirdParty {
"fal" => Ok(Self::Fal),
"gmicloud" => Ok(Self::GmiCloud),
"seedance2pro" => Ok(Self::Seedance2Pro),
"seedance2pro_alt" => Ok(Self::Seedance2ProAlt),
"worldlabs" => Ok(Self::Worldlabs),
_ => Err(format!("invalid value: {:?}", value)),
}
@@ -69,6 +75,7 @@ impl InferenceJobExternalThirdParty {
Self::Fal,
Self::GmiCloud,
Self::Seedance2Pro,
Self::Seedance2ProAlt,
Self::Worldlabs,
])
}
@@ -88,6 +95,7 @@ mod tests {
assert_serialization(InferenceJobExternalThirdParty::Fal, "fal");
assert_serialization(InferenceJobExternalThirdParty::GmiCloud, "gmicloud");
assert_serialization(InferenceJobExternalThirdParty::Seedance2Pro, "seedance2pro");
assert_serialization(InferenceJobExternalThirdParty::Seedance2ProAlt, "seedance2pro_alt");
assert_serialization(InferenceJobExternalThirdParty::Worldlabs, "worldlabs");
}
@@ -97,6 +105,7 @@ mod tests {
assert_eq!(InferenceJobExternalThirdParty::Fal.to_str(), "fal");
assert_eq!(InferenceJobExternalThirdParty::GmiCloud.to_str(), "gmicloud");
assert_eq!(InferenceJobExternalThirdParty::Seedance2Pro.to_str(), "seedance2pro");
assert_eq!(InferenceJobExternalThirdParty::Seedance2ProAlt.to_str(), "seedance2pro_alt");
assert_eq!(InferenceJobExternalThirdParty::Worldlabs.to_str(), "worldlabs");
}
@@ -106,13 +115,14 @@ mod tests {
assert_eq!(InferenceJobExternalThirdParty::from_str("fal").unwrap(), InferenceJobExternalThirdParty::Fal);
assert_eq!(InferenceJobExternalThirdParty::from_str("gmicloud").unwrap(), InferenceJobExternalThirdParty::GmiCloud);
assert_eq!(InferenceJobExternalThirdParty::from_str("seedance2pro").unwrap(), InferenceJobExternalThirdParty::Seedance2Pro);
assert_eq!(InferenceJobExternalThirdParty::from_str("seedance2pro_alt").unwrap(), InferenceJobExternalThirdParty::Seedance2ProAlt);
assert_eq!(InferenceJobExternalThirdParty::from_str("worldlabs").unwrap(), InferenceJobExternalThirdParty::Worldlabs);
}
#[test]
fn all_variants() {
// Static check
const EXPECTED_COUNT : usize = 5;
const EXPECTED_COUNT : usize = 6;
assert_eq!(InferenceJobExternalThirdParty::all_variants().len(), EXPECTED_COUNT);
assert_eq!(InferenceJobExternalThirdParty::iter().len(), EXPECTED_COUNT);
@@ -48,6 +48,11 @@ pub enum InferenceJobProductCategory {
#[serde(rename = "seedance2pro_character")]
Seedance2ProCharacter,
// =============== SEEDANCE 2 PRO ALT ===============
#[serde(rename = "seedance2pro_video_alt")]
Seedance2ProVideoAlt,
// =============== WORLD LABS ===============
/// World Labs: Gaussian Splat Generation
@@ -153,6 +158,7 @@ impl InferenceJobProductCategory {
Self::BeebleVideo => "beeble_video",
Self::Seedance2ProVideo => "seedance2pro_video",
Self::Seedance2ProCharacter => "seedance2pro_character",
Self::Seedance2ProVideoAlt => "seedance2pro_video_alt",
Self::WorldlabsSplat => "worldlabs_splat",
Self::TtsGptSoVits => "tts_gpt_so_vits",
Self::TtsStyleTts2 => "tts_style_tts2",
@@ -190,6 +196,7 @@ impl InferenceJobProductCategory {
"beeble_video" => Ok(Self::BeebleVideo),
"seedance2pro_video" => Ok(Self::Seedance2ProVideo),
"seedance2pro_character" => Ok(Self::Seedance2ProCharacter),
"seedance2pro_video_alt" => Ok(Self::Seedance2ProVideoAlt),
"worldlabs_splat" => Ok(Self::WorldlabsSplat),
"tts_gpt_so_vits" => Ok(Self::TtsGptSoVits),
"tts_style_tts2" => Ok(Self::TtsStyleTts2),
@@ -230,6 +237,7 @@ impl InferenceJobProductCategory {
Self::BeebleVideo,
Self::Seedance2ProVideo,
Self::Seedance2ProCharacter,
Self::Seedance2ProVideoAlt,
Self::WorldlabsSplat,
Self::TtsGptSoVits,
Self::TtsStyleTts2,
@@ -276,6 +284,7 @@ mod tests {
assert_serialization(InferenceJobProductCategory::BeebleVideo, "beeble_video");
assert_serialization(InferenceJobProductCategory::Seedance2ProVideo, "seedance2pro_video");
assert_serialization(InferenceJobProductCategory::Seedance2ProCharacter, "seedance2pro_character");
assert_serialization(InferenceJobProductCategory::Seedance2ProVideoAlt, "seedance2pro_video_alt");
assert_serialization(InferenceJobProductCategory::WorldlabsSplat, "worldlabs_splat");
assert_serialization(InferenceJobProductCategory::TtsGptSoVits, "tts_gpt_so_vits");
assert_serialization(InferenceJobProductCategory::TtsStyleTts2, "tts_style_tts2");
@@ -312,6 +321,7 @@ mod tests {
assert_eq!(InferenceJobProductCategory::BeebleVideo.to_str(), "beeble_video");
assert_eq!(InferenceJobProductCategory::Seedance2ProVideo.to_str(), "seedance2pro_video");
assert_eq!(InferenceJobProductCategory::Seedance2ProCharacter.to_str(), "seedance2pro_character");
assert_eq!(InferenceJobProductCategory::Seedance2ProVideoAlt.to_str(), "seedance2pro_video_alt");
assert_eq!(InferenceJobProductCategory::WorldlabsSplat.to_str(), "worldlabs_splat");
assert_eq!(InferenceJobProductCategory::TtsGptSoVits.to_str(), "tts_gpt_so_vits");
assert_eq!(InferenceJobProductCategory::TtsStyleTts2.to_str(), "tts_style_tts2");
@@ -347,6 +357,7 @@ mod tests {
assert_eq!(InferenceJobProductCategory::from_str("beeble_video").unwrap(), InferenceJobProductCategory::BeebleVideo);
assert_eq!(InferenceJobProductCategory::from_str("seedance2pro_video").unwrap(), InferenceJobProductCategory::Seedance2ProVideo);
assert_eq!(InferenceJobProductCategory::from_str("seedance2pro_character").unwrap(), InferenceJobProductCategory::Seedance2ProCharacter);
assert_eq!(InferenceJobProductCategory::from_str("seedance2pro_video_alt").unwrap(), InferenceJobProductCategory::Seedance2ProVideoAlt);
assert_eq!(InferenceJobProductCategory::from_str("worldlabs_splat").unwrap(), InferenceJobProductCategory::WorldlabsSplat);
assert_eq!(InferenceJobProductCategory::from_str("tts_gpt_so_vits").unwrap(), InferenceJobProductCategory::TtsGptSoVits);
assert_eq!(InferenceJobProductCategory::from_str("tts_style_tts2").unwrap(), InferenceJobProductCategory::TtsStyleTts2);
@@ -374,7 +385,7 @@ mod tests {
#[test]
fn all_variants() {
// Static check
const EXPECTED_COUNT : usize = 32;
const EXPECTED_COUNT : usize = 33;
assert_eq!(InferenceJobProductCategory::all_variants().len(), EXPECTED_COUNT);
assert_eq!(InferenceJobProductCategory::iter().len(), EXPECTED_COUNT);
@@ -31,6 +31,10 @@ pub enum InferenceJobType {
#[serde(rename = "seedance2pro_character")]
Seedance2ProCharacter,
/// Seedance 2 Pro Alt jobs. We poll for results.
#[serde(rename = "seedance2pro_alt_queue")]
Seedance2ProAltQueue,
/// Beeble jobs. We poll for results.
#[serde(rename = "beeble_queue")]
BeebleQueue,
@@ -133,6 +137,7 @@ impl InferenceJobType {
Self::GmiCloudQueue => "gmicloud_queue",
Self::BeebleQueue => "beeble_queue",
Self::Seedance2ProQueue => "seedance2pro_queue",
Self::Seedance2ProAltQueue => "seedance2pro_alt_queue",
Self::Seedance2ProCharacter => "seedance2pro_character",
Self::WorldlabsQueue => "worldlabs_queue",
Self::VideoRender => "video_render",
@@ -165,6 +170,7 @@ impl InferenceJobType {
"beeble_queue" => Ok(Self::BeebleQueue),
"seedance2pro_queue" => Ok(Self::Seedance2ProQueue),
"seedance2pro_character" => Ok(Self::Seedance2ProCharacter),
"seedance2pro_alt_queue" => Ok(Self::Seedance2ProAltQueue),
"worldlabs_queue" => Ok(Self::WorldlabsQueue),
"video_render" => Ok(Self::VideoRender),
"live_portrait" => Ok(Self::LivePortrait),
@@ -199,6 +205,7 @@ impl InferenceJobType {
Self::BeebleQueue,
Self::Seedance2ProQueue,
Self::Seedance2ProCharacter,
Self::Seedance2ProAltQueue,
Self::WorldlabsQueue,
Self::VideoRender,
Self::LivePortrait,
@@ -244,6 +251,7 @@ mod tests {
assert_serialization(InferenceJobType::BeebleQueue, "beeble_queue");
assert_serialization(InferenceJobType::Seedance2ProQueue, "seedance2pro_queue");
assert_serialization(InferenceJobType::Seedance2ProCharacter, "seedance2pro_character");
assert_serialization(InferenceJobType::Seedance2ProAltQueue, "seedance2pro_alt_queue");
assert_serialization(InferenceJobType::WorldlabsQueue, "worldlabs_queue");
assert_serialization(InferenceJobType::VideoRender, "video_render");
assert_serialization(InferenceJobType::LivePortrait, "live_portrait");
@@ -274,6 +282,7 @@ mod tests {
assert_eq!(InferenceJobType::BeebleQueue.to_str(), "beeble_queue");
assert_eq!(InferenceJobType::Seedance2ProQueue.to_str(), "seedance2pro_queue");
assert_eq!(InferenceJobType::Seedance2ProCharacter.to_str(), "seedance2pro_character");
assert_eq!(InferenceJobType::Seedance2ProAltQueue.to_str(), "seedance2pro_alt_queue");
assert_eq!(InferenceJobType::WorldlabsQueue.to_str(), "worldlabs_queue");
assert_eq!(InferenceJobType::VideoRender.to_str(), "video_render");
assert_eq!(InferenceJobType::LivePortrait.to_str(), "live_portrait");
@@ -304,6 +313,7 @@ mod tests {
assert_eq!(InferenceJobType::from_str("beeble_queue").unwrap(), InferenceJobType::BeebleQueue);
assert_eq!(InferenceJobType::from_str("seedance2pro_queue").unwrap(), InferenceJobType::Seedance2ProQueue);
assert_eq!(InferenceJobType::from_str("seedance2pro_character").unwrap(), InferenceJobType::Seedance2ProCharacter);
assert_eq!(InferenceJobType::from_str("seedance2pro_alt_queue").unwrap(), InferenceJobType::Seedance2ProAltQueue);
assert_eq!(InferenceJobType::from_str("worldlabs_queue").unwrap(), InferenceJobType::WorldlabsQueue);
assert_eq!(InferenceJobType::from_str("video_render").unwrap(), InferenceJobType::VideoRender);
assert_eq!(InferenceJobType::from_str("live_portrait").unwrap(), InferenceJobType::LivePortrait);
@@ -330,7 +340,7 @@ mod tests {
#[test]
fn all_variants() {
// Static check
const EXPECTED_COUNT : usize = 26;
const EXPECTED_COUNT : usize = 27;
assert_eq!(InferenceJobType::all_variants().len(), EXPECTED_COUNT);
assert_eq!(InferenceJobType::iter().len(), EXPECTED_COUNT);
@@ -130,10 +130,14 @@ pub enum CommonModelType {
Seedance2p0,
#[serde(rename = "seedance_2p0_fast")]
Seedance2p0Fast,
#[serde(rename = "seedance_2p0_g")]
Seedance2p0Global,
#[serde(rename = "seedance_2p0_fast_g")]
Seedance2p0FastGlobal,
#[serde(rename = "seedance_2p0_bp")]
Seedance2p0BytePlus,
#[serde(rename = "seedance_2p0_bp_fast")]
Seedance2p0BytePlusFast,
#[serde(rename = "seedance_2p0_u")]
Seedance2p0Ultra,
#[serde(rename = "seedance_2p0_u_fast")]
Seedance2p0UltraFast,
#[serde(rename = "sora_2")]
Sora2,
#[serde(rename = "sora_2_pro")]
@@ -148,6 +152,10 @@ pub enum CommonModelType {
Veo3p1,
#[serde(rename = "veo_3p1_fast")]
Veo3p1Fast,
#[serde(rename = "preview_model")]
PreviewModel,
#[serde(rename = "preview_model_fast")]
PreviewModelFast,
#[serde(rename = "switch_x")]
SwitchX,
@@ -223,8 +231,10 @@ impl CommonModelType {
Self::Seedance1p5Pro => "seedance_1p5_pro",
Self::Seedance2p0 => "seedance_2p0",
Self::Seedance2p0Fast => "seedance_2p0_fast",
Self::Seedance2p0Global => "seedance_2p0_g",
Self::Seedance2p0FastGlobal => "seedance_2p0_fast_g",
Self::Seedance2p0BytePlus => "seedance_2p0_bp",
Self::Seedance2p0BytePlusFast => "seedance_2p0_bp_fast",
Self::Seedance2p0Ultra => "seedance_2p0_u",
Self::Seedance2p0UltraFast => "seedance_2p0_u_fast",
Self::Sora2 => "sora_2",
Self::Sora2Pro => "sora_2_pro",
Self::Veo2 => "veo_2",
@@ -232,6 +242,8 @@ impl CommonModelType {
Self::Veo3Fast => "veo_3_fast",
Self::Veo3p1 => "veo_3p1",
Self::Veo3p1Fast => "veo_3p1_fast",
Self::PreviewModel => "preview_model",
Self::PreviewModelFast => "preview_model_fast",
Self::SwitchX => "switch_x",
// 3D Object generation models
@@ -295,8 +307,10 @@ impl CommonModelType {
"seedance_1p5_pro" => Ok(Self::Seedance1p5Pro),
"seedance_2p0" => Ok(Self::Seedance2p0),
"seedance_2p0_fast" => Ok(Self::Seedance2p0Fast),
"seedance_2p0_g" => Ok(Self::Seedance2p0Global),
"seedance_2p0_fast_g" => Ok(Self::Seedance2p0FastGlobal),
"seedance_2p0_bp" => Ok(Self::Seedance2p0BytePlus),
"seedance_2p0_bp_fast" => Ok(Self::Seedance2p0BytePlusFast),
"seedance_2p0_u" => Ok(Self::Seedance2p0Ultra),
"seedance_2p0_u_fast" => Ok(Self::Seedance2p0UltraFast),
"sora_2" => Ok(Self::Sora2),
"sora_2_pro" => Ok(Self::Sora2Pro),
"veo_2" => Ok(Self::Veo2),
@@ -304,6 +318,8 @@ impl CommonModelType {
"veo_3_fast" => Ok(Self::Veo3Fast),
"veo_3p1" => Ok(Self::Veo3p1),
"veo_3p1_fast" => Ok(Self::Veo3p1Fast),
"preview_model" => Ok(Self::PreviewModel),
"preview_model_fast" => Ok(Self::PreviewModelFast),
"switch_x" => Ok(Self::SwitchX),
// 3D Object generation models
@@ -371,8 +387,10 @@ impl CommonModelType {
Self::Seedance1p5Pro,
Self::Seedance2p0,
Self::Seedance2p0Fast,
Self::Seedance2p0Global,
Self::Seedance2p0FastGlobal,
Self::Seedance2p0BytePlus,
Self::Seedance2p0BytePlusFast,
Self::Seedance2p0Ultra,
Self::Seedance2p0UltraFast,
Self::Sora2,
Self::Sora2Pro,
Self::Veo2,
@@ -380,6 +398,8 @@ impl CommonModelType {
Self::Veo3Fast,
Self::Veo3p1,
Self::Veo3p1Fast,
Self::PreviewModel,
Self::PreviewModelFast,
Self::SwitchX,
// 3D Object generation models
@@ -444,8 +464,10 @@ impl CommonModelType {
Self::Seedance1p5Pro => CommonModelClass::Video,
Self::Seedance2p0 => CommonModelClass::Video,
Self::Seedance2p0Fast => CommonModelClass::Video,
Self::Seedance2p0Global => CommonModelClass::Video,
Self::Seedance2p0FastGlobal => CommonModelClass::Video,
Self::Seedance2p0BytePlus => CommonModelClass::Video,
Self::Seedance2p0BytePlusFast => CommonModelClass::Video,
Self::Seedance2p0Ultra => CommonModelClass::Video,
Self::Seedance2p0UltraFast => CommonModelClass::Video,
Self::Sora2 => CommonModelClass::Video,
Self::Sora2Pro => CommonModelClass::Video,
Self::Veo2 => CommonModelClass::Video,
@@ -453,6 +475,8 @@ impl CommonModelType {
Self::Veo3Fast => CommonModelClass::Video,
Self::Veo3p1 => CommonModelClass::Video,
Self::Veo3p1Fast => CommonModelClass::Video,
Self::PreviewModel => CommonModelClass::Video,
Self::PreviewModelFast => CommonModelClass::Video,
Self::SwitchX => CommonModelClass::Video,
// 3D Object generation models (mesh)
@@ -526,8 +550,10 @@ mod tests {
assert_serialization(CommonModelType::Seedance1p5Pro, "seedance_1p5_pro");
assert_serialization(CommonModelType::Seedance2p0, "seedance_2p0");
assert_serialization(CommonModelType::Seedance2p0Fast, "seedance_2p0_fast");
assert_serialization(CommonModelType::Seedance2p0Global, "seedance_2p0_g");
assert_serialization(CommonModelType::Seedance2p0FastGlobal, "seedance_2p0_fast_g");
assert_serialization(CommonModelType::Seedance2p0BytePlus, "seedance_2p0_bp");
assert_serialization(CommonModelType::Seedance2p0BytePlusFast, "seedance_2p0_bp_fast");
assert_serialization(CommonModelType::Seedance2p0Ultra, "seedance_2p0_u");
assert_serialization(CommonModelType::Seedance2p0UltraFast, "seedance_2p0_u_fast");
assert_serialization(CommonModelType::Sora2, "sora_2");
assert_serialization(CommonModelType::Sora2Pro, "sora_2_pro");
assert_serialization(CommonModelType::Veo2, "veo_2");
@@ -535,6 +561,8 @@ mod tests {
assert_serialization(CommonModelType::Veo3Fast, "veo_3_fast");
assert_serialization(CommonModelType::Veo3p1, "veo_3p1");
assert_serialization(CommonModelType::Veo3p1Fast, "veo_3p1_fast");
assert_serialization(CommonModelType::PreviewModel, "preview_model");
assert_serialization(CommonModelType::PreviewModelFast, "preview_model_fast");
assert_serialization(CommonModelType::SwitchX, "switch_x");
// 3D Object generation models
assert_serialization(CommonModelType::Hunyuan3d2_0, "hunyuan_3d_2p0");
@@ -595,8 +623,10 @@ mod tests {
assert_eq!(CommonModelType::Seedance1p5Pro.to_str(), "seedance_1p5_pro");
assert_eq!(CommonModelType::Seedance2p0.to_str(), "seedance_2p0");
assert_eq!(CommonModelType::Seedance2p0Fast.to_str(), "seedance_2p0_fast");
assert_eq!(CommonModelType::Seedance2p0Global.to_str(), "seedance_2p0_g");
assert_eq!(CommonModelType::Seedance2p0FastGlobal.to_str(), "seedance_2p0_fast_g");
assert_eq!(CommonModelType::Seedance2p0BytePlus.to_str(), "seedance_2p0_bp");
assert_eq!(CommonModelType::Seedance2p0BytePlusFast.to_str(), "seedance_2p0_bp_fast");
assert_eq!(CommonModelType::Seedance2p0Ultra.to_str(), "seedance_2p0_u");
assert_eq!(CommonModelType::Seedance2p0UltraFast.to_str(), "seedance_2p0_u_fast");
assert_eq!(CommonModelType::Sora2.to_str(), "sora_2");
assert_eq!(CommonModelType::Sora2Pro.to_str(), "sora_2_pro");
assert_eq!(CommonModelType::Veo2.to_str(), "veo_2");
@@ -604,6 +634,8 @@ mod tests {
assert_eq!(CommonModelType::Veo3Fast.to_str(), "veo_3_fast");
assert_eq!(CommonModelType::Veo3p1.to_str(), "veo_3p1");
assert_eq!(CommonModelType::Veo3p1Fast.to_str(), "veo_3p1_fast");
assert_eq!(CommonModelType::PreviewModel.to_str(), "preview_model");
assert_eq!(CommonModelType::PreviewModelFast.to_str(), "preview_model_fast");
assert_eq!(CommonModelType::SwitchX.to_str(), "switch_x");
// 3D Object generation models
@@ -664,8 +696,10 @@ mod tests {
assert_eq!(CommonModelType::from_str("seedance_1p5_pro").unwrap(), CommonModelType::Seedance1p5Pro);
assert_eq!(CommonModelType::from_str("seedance_2p0").unwrap(), CommonModelType::Seedance2p0);
assert_eq!(CommonModelType::from_str("seedance_2p0_fast").unwrap(), CommonModelType::Seedance2p0Fast);
assert_eq!(CommonModelType::from_str("seedance_2p0_g").unwrap(), CommonModelType::Seedance2p0Global);
assert_eq!(CommonModelType::from_str("seedance_2p0_fast_g").unwrap(), CommonModelType::Seedance2p0FastGlobal);
assert_eq!(CommonModelType::from_str("seedance_2p0_bp").unwrap(), CommonModelType::Seedance2p0BytePlus);
assert_eq!(CommonModelType::from_str("seedance_2p0_bp_fast").unwrap(), CommonModelType::Seedance2p0BytePlusFast);
assert_eq!(CommonModelType::from_str("seedance_2p0_u").unwrap(), CommonModelType::Seedance2p0Ultra);
assert_eq!(CommonModelType::from_str("seedance_2p0_u_fast").unwrap(), CommonModelType::Seedance2p0UltraFast);
assert_eq!(CommonModelType::from_str("sora_2").unwrap(), CommonModelType::Sora2);
assert_eq!(CommonModelType::from_str("sora_2_pro").unwrap(), CommonModelType::Sora2Pro);
assert_eq!(CommonModelType::from_str("veo_2").unwrap(), CommonModelType::Veo2);
@@ -673,6 +707,8 @@ mod tests {
assert_eq!(CommonModelType::from_str("veo_3_fast").unwrap(), CommonModelType::Veo3Fast);
assert_eq!(CommonModelType::from_str("veo_3p1").unwrap(), CommonModelType::Veo3p1);
assert_eq!(CommonModelType::from_str("veo_3p1_fast").unwrap(), CommonModelType::Veo3p1Fast);
assert_eq!(CommonModelType::from_str("preview_model").unwrap(), CommonModelType::PreviewModel);
assert_eq!(CommonModelType::from_str("preview_model_fast").unwrap(), CommonModelType::PreviewModelFast);
assert_eq!(CommonModelType::from_str("switch_x").unwrap(), CommonModelType::SwitchX);
// 3D Object generation models
@@ -687,7 +723,7 @@ mod tests {
#[test]
fn all_variants() {
let mut variants = CommonModelType::all_variants();
assert_eq!(variants.len(), 60);
assert_eq!(variants.len(), 64);
// Image models
assert_eq!(variants.pop_first(), Some(CommonModelType::Flux1Dev));
assert_eq!(variants.pop_first(), Some(CommonModelType::Flux1Schnell));
@@ -735,8 +771,10 @@ mod tests {
assert_eq!(variants.pop_first(), Some(CommonModelType::Seedance1p5Pro));
assert_eq!(variants.pop_first(), Some(CommonModelType::Seedance2p0));
assert_eq!(variants.pop_first(), Some(CommonModelType::Seedance2p0Fast));
assert_eq!(variants.pop_first(), Some(CommonModelType::Seedance2p0Global));
assert_eq!(variants.pop_first(), Some(CommonModelType::Seedance2p0FastGlobal));
assert_eq!(variants.pop_first(), Some(CommonModelType::Seedance2p0BytePlus));
assert_eq!(variants.pop_first(), Some(CommonModelType::Seedance2p0BytePlusFast));
assert_eq!(variants.pop_first(), Some(CommonModelType::Seedance2p0Ultra));
assert_eq!(variants.pop_first(), Some(CommonModelType::Seedance2p0UltraFast));
assert_eq!(variants.pop_first(), Some(CommonModelType::Sora2));
assert_eq!(variants.pop_first(), Some(CommonModelType::Sora2Pro));
assert_eq!(variants.pop_first(), Some(CommonModelType::Veo2));
@@ -744,6 +782,8 @@ mod tests {
assert_eq!(variants.pop_first(), Some(CommonModelType::Veo3Fast));
assert_eq!(variants.pop_first(), Some(CommonModelType::Veo3p1));
assert_eq!(variants.pop_first(), Some(CommonModelType::Veo3p1Fast));
assert_eq!(variants.pop_first(), Some(CommonModelType::PreviewModel));
assert_eq!(variants.pop_first(), Some(CommonModelType::PreviewModelFast));
assert_eq!(variants.pop_first(), Some(CommonModelType::SwitchX));
// 3D Object generation models
assert_eq!(variants.pop_first(), Some(CommonModelType::Hunyuan3d2_0));
@@ -44,11 +44,17 @@ pub enum CommonVideoModel {
#[serde(rename = "seedance_2p0_fast")]
Seedance2p0Fast,
#[serde(rename = "seedance_2p0_g")]
Seedance2p0Global,
#[serde(rename = "seedance_2p0_bp")]
Seedance2p0BytePlus,
#[serde(rename = "seedance_2p0_fast_g")]
Seedance2p0FastGlobal,
#[serde(rename = "seedance_2p0_bp_fast")]
Seedance2p0BytePlusFast,
#[serde(rename = "seedance_2p0_u")]
Seedance2p0Ultra,
#[serde(rename = "seedance_2p0_u_fast")]
Seedance2p0UltraFast,
#[serde(rename = "sora_2")]
Sora2,
@@ -70,6 +76,16 @@ pub enum CommonVideoModel {
#[serde(rename = "veo_3p1_fast")]
Veo3p1Fast,
// NB: Temporary model rollout. Previously used for "Seedance 2.0 BytePlus".
// This can be reused in the future.
#[serde(rename = "preview_model")]
PreviewModel,
// NB: Temporary model rollout. Previously used for "Seedance 2.0 BytePlus Fast".
// This can be reused in the future.
#[serde(rename = "preview_model_fast")]
PreviewModelFast,
}
impl CommonVideoModel {
@@ -89,8 +105,10 @@ impl CommonVideoModel {
Self::Seedance1p5Pro => CommonModelType::Seedance1p5Pro,
Self::Seedance2p0 => CommonModelType::Seedance2p0,
Self::Seedance2p0Fast => CommonModelType::Seedance2p0Fast,
Self::Seedance2p0Global => CommonModelType::Seedance2p0Global,
Self::Seedance2p0FastGlobal => CommonModelType::Seedance2p0FastGlobal,
Self::Seedance2p0BytePlus => CommonModelType::Seedance2p0BytePlus,
Self::Seedance2p0BytePlusFast => CommonModelType::Seedance2p0BytePlusFast,
Self::Seedance2p0Ultra => CommonModelType::Seedance2p0Ultra,
Self::Seedance2p0UltraFast => CommonModelType::Seedance2p0UltraFast,
Self::Sora2 => CommonModelType::Sora2,
Self::Sora2Pro => CommonModelType::Sora2Pro,
Self::Veo2 => CommonModelType::Veo2,
@@ -98,6 +116,8 @@ impl CommonVideoModel {
Self::Veo3Fast => CommonModelType::Veo3Fast,
Self::Veo3p1 => CommonModelType::Veo3p1,
Self::Veo3p1Fast => CommonModelType::Veo3p1Fast,
Self::PreviewModel => CommonModelType::PreviewModel,
Self::PreviewModelFast => CommonModelType::PreviewModelFast,
}
}
}
@@ -123,8 +143,10 @@ mod tests {
assert_serialization(CommonVideoModel::Seedance1p5Pro, "seedance_1p5_pro");
assert_serialization(CommonVideoModel::Seedance2p0, "seedance_2p0");
assert_serialization(CommonVideoModel::Seedance2p0Fast, "seedance_2p0_fast");
assert_serialization(CommonVideoModel::Seedance2p0Global, "seedance_2p0_g");
assert_serialization(CommonVideoModel::Seedance2p0FastGlobal, "seedance_2p0_fast_g");
assert_serialization(CommonVideoModel::Seedance2p0BytePlus, "seedance_2p0_bp");
assert_serialization(CommonVideoModel::Seedance2p0BytePlusFast, "seedance_2p0_bp_fast");
assert_serialization(CommonVideoModel::Seedance2p0Ultra, "seedance_2p0_u");
assert_serialization(CommonVideoModel::Seedance2p0UltraFast, "seedance_2p0_u_fast");
assert_serialization(CommonVideoModel::Sora2, "sora_2");
assert_serialization(CommonVideoModel::Sora2Pro, "sora_2_pro");
assert_serialization(CommonVideoModel::Veo2, "veo_2");
@@ -132,6 +154,8 @@ mod tests {
assert_serialization(CommonVideoModel::Veo3Fast, "veo_3_fast");
assert_serialization(CommonVideoModel::Veo3p1, "veo_3p1");
assert_serialization(CommonVideoModel::Veo3p1Fast, "veo_3p1_fast");
assert_serialization(CommonVideoModel::PreviewModel, "preview_model");
assert_serialization(CommonVideoModel::PreviewModelFast, "preview_model_fast");
}
#[test]
@@ -150,8 +174,10 @@ mod tests {
("seedance_1p5_pro", CommonVideoModel::Seedance1p5Pro),
("seedance_2p0", CommonVideoModel::Seedance2p0),
("seedance_2p0_fast", CommonVideoModel::Seedance2p0Fast),
("seedance_2p0_g", CommonVideoModel::Seedance2p0Global),
("seedance_2p0_fast_g", CommonVideoModel::Seedance2p0FastGlobal),
("seedance_2p0_bp", CommonVideoModel::Seedance2p0BytePlus),
("seedance_2p0_bp_fast", CommonVideoModel::Seedance2p0BytePlusFast),
("seedance_2p0_u", CommonVideoModel::Seedance2p0Ultra),
("seedance_2p0_u_fast", CommonVideoModel::Seedance2p0UltraFast),
("sora_2", CommonVideoModel::Sora2),
("sora_2_pro", CommonVideoModel::Sora2Pro),
("veo_2", CommonVideoModel::Veo2),
@@ -159,6 +185,8 @@ mod tests {
("veo_3_fast", CommonVideoModel::Veo3Fast),
("veo_3p1", CommonVideoModel::Veo3p1),
("veo_3p1_fast", CommonVideoModel::Veo3p1Fast),
("preview_model", CommonVideoModel::PreviewModel),
("preview_model_fast", CommonVideoModel::PreviewModelFast),
];
for (json_str, expected) in cases {
let json = format!("\"{}\"", json_str);
@@ -184,8 +212,10 @@ mod tests {
CommonVideoModel::Seedance1p5Pro,
CommonVideoModel::Seedance2p0,
CommonVideoModel::Seedance2p0Fast,
CommonVideoModel::Seedance2p0Global,
CommonVideoModel::Seedance2p0FastGlobal,
CommonVideoModel::Seedance2p0BytePlus,
CommonVideoModel::Seedance2p0BytePlusFast,
CommonVideoModel::Seedance2p0Ultra,
CommonVideoModel::Seedance2p0UltraFast,
CommonVideoModel::Sora2,
CommonVideoModel::Sora2Pro,
CommonVideoModel::Veo2,
@@ -193,6 +223,8 @@ mod tests {
CommonVideoModel::Veo3Fast,
CommonVideoModel::Veo3p1,
CommonVideoModel::Veo3p1Fast,
CommonVideoModel::PreviewModel,
CommonVideoModel::PreviewModelFast,
];
for variant in all {
let json = serde_json::to_string(&variant).unwrap();
@@ -217,8 +249,10 @@ mod tests {
(CommonVideoModel::Seedance1p5Pro, CommonModelType::Seedance1p5Pro),
(CommonVideoModel::Seedance2p0, CommonModelType::Seedance2p0),
(CommonVideoModel::Seedance2p0Fast, CommonModelType::Seedance2p0Fast),
(CommonVideoModel::Seedance2p0Global, CommonModelType::Seedance2p0Global),
(CommonVideoModel::Seedance2p0FastGlobal, CommonModelType::Seedance2p0FastGlobal),
(CommonVideoModel::Seedance2p0BytePlus, CommonModelType::Seedance2p0BytePlus),
(CommonVideoModel::Seedance2p0BytePlusFast, CommonModelType::Seedance2p0BytePlusFast),
(CommonVideoModel::Seedance2p0Ultra, CommonModelType::Seedance2p0Ultra),
(CommonVideoModel::Seedance2p0UltraFast, CommonModelType::Seedance2p0UltraFast),
(CommonVideoModel::Sora2, CommonModelType::Sora2),
(CommonVideoModel::Sora2Pro, CommonModelType::Sora2Pro),
(CommonVideoModel::Veo2, CommonModelType::Veo2),
@@ -226,6 +260,8 @@ mod tests {
(CommonVideoModel::Veo3Fast, CommonModelType::Veo3Fast),
(CommonVideoModel::Veo3p1, CommonModelType::Veo3p1),
(CommonVideoModel::Veo3p1Fast, CommonModelType::Veo3p1Fast),
(CommonVideoModel::PreviewModel, CommonModelType::PreviewModel),
(CommonVideoModel::PreviewModelFast, CommonModelType::PreviewModelFast),
];
for (video_model, expected) in models {
assert_eq!(video_model.to_common_model_type(), expected);
@@ -13,7 +13,7 @@ use pager::client::pager::Pager;
use crate::http_server::endpoints::health_check_handler::get_health_check_handler;
use crate::http_server::http_server_shared_state::HttpServerSharedState;
const DEFAULT_BIND_ADDRESS: &str = "0.0.0.0:12345";
const DEFAULT_BIND_ADDRESS: &str = "0.0.0.0:11223";
const DEFAULT_NUM_WORKERS: usize = 2;
pub struct CreateServerArgs {
@@ -20,6 +20,10 @@ pub struct JobDependencies {
/// Session credentials for polling seedance2-pro.com.
pub seedance2pro_session: Seedance2ProSession,
/// When true, this instance polls for alternate-account jobs
/// (Seedance2ProAltQueue / Seedance2ProAlt) instead of the primary ones.
pub is_alternate_mode: bool,
pub server_environment: ServerEnvironment,
pub job_stats: JobStats,
@@ -2,6 +2,8 @@ use std::collections::HashMap;
use std::time::{Duration, Instant};
use chrono::Utc;
use enums::by_table::generic_inference_jobs::inference_job_external_third_party::InferenceJobExternalThirdParty;
use enums::by_table::generic_inference_jobs::inference_job_type::InferenceJobType;
use log::{error, info, warn};
use pager::notification::notification_details_builder::NotificationDetailsBuilder;
use pager::notification::notification_urgency::NotificationUrgency;
@@ -61,7 +63,13 @@ pub async fn video_polling_main_loop(job_dependencies: JobDependencies) {
async fn run_poll_iteration(deps: &JobDependencies) -> anyhow::Result<()> {
// 1. Query all (limit 25,000) non-terminal Seedance2Pro jobs from DB.
// This is all non-(complete_success, complete_failure) jobs.
let pending_jobs = match list_pending_seedance2pro_video_jobs(&deps.mysql_pool).await {
let (third_party, job_type) = if deps.is_alternate_mode {
(InferenceJobExternalThirdParty::Seedance2ProAlt, InferenceJobType::Seedance2ProAltQueue)
} else {
(InferenceJobExternalThirdParty::Seedance2Pro, InferenceJobType::Seedance2ProQueue)
};
let pending_jobs = match list_pending_seedance2pro_video_jobs(&deps.mysql_pool, third_party, job_type).await {
Ok(jobs) => jobs,
Err(err) => {
error!("Failed to list pending database jobs: {:?}", err);
@@ -45,7 +45,11 @@ const ENV_SECRET_KEY: &str = "SECRET_KEY";
const ENV_REGION_NAME: &str = "REGION_NAME";
const ENV_PUBLIC_BUCKET_NAME: &str = "PUBLIC_BUCKET_NAME";
const ENV_S3_ENDPOINT: &str = "S3_COMPATIBLE_ENDPOINT_URL";
const ENV_SEEDANCE2PRO_COOKIES : &str = "SEEDANCE2PRO_COOKIES";
const ENV_SEEDANCE2PRO_ALTERNATE: &str = "SEEDANCE2PRO_ALTERNATE";
const ENV_SEEDANCE2PRO_COOKIES: &str = "SEEDANCE2PRO_COOKIES";
const ENV_SEEDANCE2PRO_ALT_COOKIES: &str = "SEEDANCE2PRO_ALT_COOKIES";
const ENV_MAX_JOB_AGE_THRESHOLD_HOURS: &str = "MAX_JOB_AGE_THRESHOLD_HOURS";
#[tokio::main]
@@ -101,8 +105,20 @@ async fn main() -> AnyhowResult<()> {
Some(bucket_timeout),
)?;
// Alternate mode: uses a different Kinovi account, different job types, skips character polling.
let is_alternate_mode = easyenv::get_env_string_optional(ENV_SEEDANCE2PRO_ALTERNATE)
.map(|v| v.trim().to_lowercase())
.map(|v| v == "true" || v == "1")
.unwrap_or(false);
if is_alternate_mode {
info!("Running in ALTERNATE mode (SEEDANCE2PRO_ALTERNATE=true).");
}
// Seedance2Pro session from cookie string
let seedance2pro_cookies = easyenv::get_env_string_required(ENV_SEEDANCE2PRO_COOKIES)?;
let cookie_env_var = if is_alternate_mode { ENV_SEEDANCE2PRO_ALT_COOKIES } else { ENV_SEEDANCE2PRO_COOKIES };
info!("Reading Seedance2Pro cookies from environment variable: {}", cookie_env_var);
let seedance2pro_cookies = easyenv::get_env_string_required(cookie_env_var)?;
let seedance2pro_session = Seedance2ProSession::from_cookies_string(seedance2pro_cookies);
// How often to poll for results (default: 15 seconds)
@@ -171,6 +187,7 @@ async fn main() -> AnyhowResult<()> {
mysql_pool,
public_bucket_client,
seedance2pro_session,
is_alternate_mode,
server_environment,
job_stats,
poll_interval_millis,
@@ -214,23 +231,27 @@ async fn main() -> AnyhowResult<()> {
// Spawn all polling loops as concurrent tasks.
let video_deps = job_dependencies.clone();
let character_deps = job_dependencies.clone();
let credits_deps = job_dependencies;
let credits_deps = job_dependencies.clone();
let video_handle = tokio::spawn(async move {
video_polling_main_loop(video_deps).await;
});
let character_handle = tokio::spawn(async move {
character_polling_main_loop(character_deps).await;
});
let credits_handle = tokio::spawn(async move {
credits_checking_main_loop(credits_deps).await;
});
// Wait for all to finish (they exit when application_shutdown is set).
let _ = tokio::join!(video_handle, character_handle, credits_handle);
if is_alternate_mode {
// Alternate mode: skip character polling entirely.
info!("Alternate mode: character polling is disabled.");
let _ = tokio::join!(video_handle, credits_handle);
} else {
let character_deps = job_dependencies;
let character_handle = tokio::spawn(async move {
character_polling_main_loop(character_deps).await;
});
let _ = tokio::join!(video_handle, character_handle, credits_handle);
}
info!("Shutting down pager worker...");
pager_for_shutdown.shutdown_worker();
@@ -4,4 +4,4 @@
SERVER_ENVIRONMENT='development'
# Don't collide with storyteller-web in development
HTTP_BIND_ADDRESS=0.0.0.0:13333
HTTP_BIND_ADDRESS=0.0.0.0:13339
@@ -13,7 +13,7 @@ use pager::client::pager::Pager;
use crate::http_server::endpoints::health_check_handler::get_health_check_handler;
use crate::http_server::http_server_shared_state::HttpServerSharedState;
const DEFAULT_BIND_ADDRESS: &str = "0.0.0.0:12345";
const DEFAULT_BIND_ADDRESS: &str = "0.0.0.0:13339";
const DEFAULT_NUM_WORKERS: usize = 2;
pub struct CreateServerArgs {
@@ -155,10 +155,89 @@ fn build_omni_gen_video_models() -> Vec<OmniGenVideoModelDetails> {
..Default::default()
});
// models.push(OmniGenVideoModelDetails {
// model: CommonVideoModel::Seedance2p0Ultra,
// model_creator: Some(ModelCreator::Bytedance),
// full_name: Some("Seedance 2.0 (Global)".to_string()),
// text_prompt_supported: Some(true),
// starting_keyframe_supported: Some(true),
// ending_keyframe_supported: Some(true),
// image_references_supported: Some(true),
// image_references_max: Some(9),
// audio_references_supported: Some(true),
// audio_references_max: Some(3),
// audio_references_max_total_duration_seconds: Some(15),
// video_references_supported: Some(true),
// video_references_max: Some(3),
// video_references_max_total_duration_seconds: Some(15),
// character_references_supported: Some(true),
// character_references_max: Some(9),
// aspect_ratio_options: Some(vec![
// CommonAspectRatio::WideTwentyOneByNine,
// CommonAspectRatio::WideSixteenByNine,
// CommonAspectRatio::WideFourByThree,
// CommonAspectRatio::Square,
// CommonAspectRatio::TallThreeByFour,
// CommonAspectRatio::TallNineBySixteen,
// ]),
// aspect_ratio_default: Some(CommonAspectRatio::WideSixteenByNine),
// resolution_options: Some(vec![
// CommonResolution::FourEightyP,
// CommonResolution::SevenTwentyP,
// CommonResolution::TenEightyP,
// ]),
// resolution_default: Some(CommonResolution::SevenTwentyP),
// batch_size_options: Some(vec![1, 2, 4]),
// batch_size_default: Some(1),
// duration_seconds_min: Some(4),
// duration_seconds_max: Some(15),
// duration_seconds_default: Some(5),
// ..Default::default()
// });
//
// models.push(OmniGenVideoModelDetails {
// model: CommonVideoModel::Seedance2p0UltraFast,
// model_creator: Some(ModelCreator::Bytedance),
// full_name: Some("Seedance 2.0 Fast (Global)".to_string()),
// text_prompt_supported: Some(true),
// starting_keyframe_supported: Some(true),
// ending_keyframe_supported: Some(true),
// image_references_supported: Some(true),
// image_references_max: Some(9),
// audio_references_supported: Some(true),
// audio_references_max: Some(3),
// audio_references_max_total_duration_seconds: Some(15),
// video_references_supported: Some(true),
// video_references_max: Some(3),
// video_references_max_total_duration_seconds: Some(15),
// character_references_supported: Some(true),
// character_references_max: Some(9),
// aspect_ratio_options: Some(vec![
// CommonAspectRatio::WideTwentyOneByNine,
// CommonAspectRatio::WideSixteenByNine,
// CommonAspectRatio::WideFourByThree,
// CommonAspectRatio::Square,
// CommonAspectRatio::TallThreeByFour,
// CommonAspectRatio::TallNineBySixteen,
// ]),
// aspect_ratio_default: Some(CommonAspectRatio::WideSixteenByNine),
// resolution_options: Some(vec![
// CommonResolution::FourEightyP,
// CommonResolution::SevenTwentyP,
// ]),
// resolution_default: Some(CommonResolution::SevenTwentyP),
// batch_size_options: Some(vec![1, 2, 4]),
// batch_size_default: Some(1),
// duration_seconds_min: Some(4),
// duration_seconds_max: Some(15),
// duration_seconds_default: Some(5),
// ..Default::default()
// });
models.push(OmniGenVideoModelDetails {
model: CommonVideoModel::Seedance2p0Global,
model: CommonVideoModel::Seedance2p0BytePlus,
model_creator: Some(ModelCreator::Bytedance),
full_name: Some("Seedance 2.0 (Global)".to_string()),
full_name: Some("Seedance 2.0 Plus".to_string()),
text_prompt_supported: Some(true),
starting_keyframe_supported: Some(true),
ending_keyframe_supported: Some(true),
@@ -196,9 +275,9 @@ fn build_omni_gen_video_models() -> Vec<OmniGenVideoModelDetails> {
});
models.push(OmniGenVideoModelDetails {
model: CommonVideoModel::Seedance2p0FastGlobal,
model: CommonVideoModel::Seedance2p0BytePlusFast,
model_creator: Some(ModelCreator::Bytedance),
full_name: Some("Seedance 2.0 Fast (Global)".to_string()),
full_name: Some("Seedance 2.0 Plus Fast".to_string()),
text_prompt_supported: Some(true),
starting_keyframe_supported: Some(true),
ending_keyframe_supported: Some(true),
@@ -141,8 +141,10 @@ fn map_video_model(model: CommonVideoModel) -> Result<RouterVideoModel, HandlerE
CommonVideoModel::Seedance1p5Pro => RouterVideoModel::Seedance1p5Pro,
CommonVideoModel::Seedance2p0 => RouterVideoModel::Seedance2p0,
CommonVideoModel::Seedance2p0Fast => RouterVideoModel::Seedance2p0Fast,
CommonVideoModel::Seedance2p0Global => RouterVideoModel::Seedance2p0Global,
CommonVideoModel::Seedance2p0FastGlobal => RouterVideoModel::Seedance2p0FastGlobal,
CommonVideoModel::Seedance2p0Ultra => RouterVideoModel::Seedance2p0Ultra,
CommonVideoModel::Seedance2p0UltraFast => RouterVideoModel::Seedance2p0UltraFast,
CommonVideoModel::Seedance2p0BytePlus=> RouterVideoModel::Seedance2p0BytePlus,
CommonVideoModel::Seedance2p0BytePlusFast=> RouterVideoModel::Seedance2p0BytePlusFast,
CommonVideoModel::HappyHorse1p0 => RouterVideoModel::HappyHorse1p0,
CommonVideoModel::Sora2 => RouterVideoModel::Sora2,
CommonVideoModel::Sora2Pro => RouterVideoModel::Sora2Pro,
@@ -151,6 +153,8 @@ fn map_video_model(model: CommonVideoModel) -> Result<RouterVideoModel, HandlerE
CommonVideoModel::Veo3Fast => RouterVideoModel::Veo3Fast,
CommonVideoModel::Veo3p1 => RouterVideoModel::Veo3p1,
CommonVideoModel::Veo3p1Fast => RouterVideoModel::Veo3p1Fast,
CommonVideoModel::PreviewModel => RouterVideoModel::PreviewModel,
CommonVideoModel::PreviewModelFast => RouterVideoModel::PreviewModelFast,
};
Ok(router_model)
}
@@ -192,7 +192,7 @@ pub async fn seedance_2p0_multi_function_video_gen_handler(
info!("User {:?} is seedance-whitelisted, trying whitelist session first", user_token);
let whitelist_session = Seedance2ProSession::from_cookies_string(
server_state.seedance2pro.cookies_whitelist.clone()
server_state.seedance2pro.cookies_byteplus.clone()
);
let result = upload_and_generate(
@@ -412,6 +412,7 @@ pub async fn seedance_2p0_multi_function_video_gen_handler(
let db_result = insert_generic_inference_job_for_seedance2pro_queue_with_apriori_job_token(
InsertGenericInferenceForSeedance2ProWithAprioriJobTokenArgs {
use_alternate_kinovi: false,
apriori_job_token: &job_token,
uuid_idempotency_token: &idempotency_str,
maybe_external_third_party_id: order_id,
@@ -13,13 +13,11 @@ use crate::state::server_state::ServerState;
pub fn build_router_client(
provider: Provider,
server_state: &ServerState,
use_alternate_kinovi: bool,
) -> Result<RouterClient, AdvancedCommonWebError> {
match provider {
Provider::Seedance2Pro => {
let session = Seedance2ProSession::from_cookies_string(
server_state.seedance2pro.cookies.clone()
);
Ok(RouterClient::Seedance2Pro(RouterSeedance2ProClient::new(session)))
kinovi_provider(server_state, use_alternate_kinovi)
}
Provider::Fal => {
let fal_client = RouterFalClient::new(
@@ -40,3 +38,19 @@ pub fn build_router_client(
}
}
}
fn kinovi_provider(server_state: &ServerState, use_alternate_kinovi: bool) -> Result<RouterClient, AdvancedCommonWebError> {
let session = if use_alternate_kinovi {
// Alternate Kinovi
Seedance2ProSession::from_cookies_string(
server_state.seedance2pro.cookies_byteplus.clone()
)
} else {
// Standard Kinovi
Seedance2ProSession::from_cookies_string(
server_state.seedance2pro.cookies.clone()
)
};
Ok(RouterClient::Seedance2Pro(RouterSeedance2ProClient::new(session)))
}
@@ -16,6 +16,7 @@ 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 use_alternate_kinovi: bool,
pub shared: SharedJobArgs<'a, 'tx>,
}
@@ -29,6 +30,7 @@ pub async fn insert_seedance2pro_jobs(args: InsertSeedance2proJobsArgs<'_, '_>)
primary_order_id,
maybe_additional_order_ids,
maybe_wallet_ledger_entry_token,
use_alternate_kinovi,
mut shared,
} = args;
@@ -55,6 +57,7 @@ pub async fn insert_seedance2pro_jobs(args: InsertSeedance2proJobsArgs<'_, '_>)
let db_result = insert_generic_inference_job_for_seedance2pro_queue_with_apriori_job_token(
InsertGenericInferenceForSeedance2ProWithAprioriJobTokenArgs {
use_alternate_kinovi,
apriori_job_token: &job_token,
uuid_idempotency_token: &idempotency_str,
maybe_external_third_party_id: order_id,
@@ -187,8 +187,20 @@ pub async fn omni_gen_video_generate_handler(
Some(CommonVideoModel::HappyHorse1p0) => true,
Some(CommonVideoModel::Seedance2p0) => true,
Some(CommonVideoModel::Seedance2p0Fast) => true,
Some(CommonVideoModel::Seedance2p0Global) => true,
Some(CommonVideoModel::Seedance2p0FastGlobal) => true,
Some(CommonVideoModel::Seedance2p0Ultra) => true,
Some(CommonVideoModel::Seedance2p0UltraFast) => true,
Some(CommonVideoModel::Seedance2p0BytePlus) => true,
Some(CommonVideoModel::Seedance2p0BytePlusFast) => true,
Some(CommonVideoModel::PreviewModel) => true,
Some(CommonVideoModel::PreviewModelFast) => true,
_ => false,
};
let use_alternate_kinovi = match request.model {
Some(CommonVideoModel::Seedance2p0BytePlus) => true,
Some(CommonVideoModel::Seedance2p0BytePlusFast) => true,
Some(CommonVideoModel::PreviewModel) => true,
Some(CommonVideoModel::PreviewModelFast) => true,
_ => false,
};
@@ -201,6 +213,7 @@ pub async fn omni_gen_video_generate_handler(
user_token,
media_file_to_url_map: &media_file_to_url_map,
kinovi_character_id_map: &kinovi_character_id_map,
use_alternate_kinovi,
}).await?
} else {
info!("Using pipeline v1");
@@ -336,6 +349,7 @@ pub async fn omni_gen_video_generate_handler(
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(),
use_alternate_kinovi,
shared: SharedJobArgs {
apriori_job_token: &pipeline_result.billing.apriori_job_token,
idempotency_token: &idempotency_token,
@@ -15,7 +15,9 @@ 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)?;
const USE_ALTERNATE_KINOVI: bool = false;
let router_client = build_router_client(Provider::Fal, server_state, USE_ALTERNATE_KINOVI)?;
let response = plan.generate_video(&router_client)
.await
@@ -27,6 +27,7 @@ pub struct RunPipelineV2Args<'a> {
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 use_alternate_kinovi: bool,
}
pub async fn run_pipeline_v2(args: RunPipelineV2Args<'_>) -> Result<PipelineResult, AdvancedCommonWebError> {
@@ -37,14 +38,29 @@ pub async fn run_pipeline_v2(args: RunPipelineV2Args<'_>) -> Result<PipelineResu
user_token,
media_file_to_url_map,
kinovi_character_id_map,
use_alternate_kinovi,
} = args;
let mut router_builder = router_builder.clone();
match router_builder.model {
CommonVideoModel::PreviewModel |
CommonVideoModel::Seedance2p0BytePlus => {
router_builder.model = CommonVideoModel::Seedance2p0;
},
CommonVideoModel::PreviewModelFast |
CommonVideoModel::Seedance2p0BytePlusFast => {
router_builder.model = CommonVideoModel::Seedance2p0Fast;
},
_ => {}, // Fall-through
}
let provider = match router_builder.model {
CommonVideoModel::HappyHorse1p0 => Provider::Seedance2Pro,
CommonVideoModel::Seedance2p0 => Provider::Seedance2Pro,
CommonVideoModel::Seedance2p0Fast => Provider::Seedance2Pro,
CommonVideoModel::Seedance2p0Global => Provider::GmiCloud,
CommonVideoModel::Seedance2p0FastGlobal => Provider::GmiCloud,
CommonVideoModel::Seedance2p0Ultra => Provider::GmiCloud,
CommonVideoModel::Seedance2p0UltraFast => Provider::GmiCloud,
_ => Provider::Fal,
};
@@ -97,6 +113,7 @@ pub async fn run_pipeline_v2(args: RunPipelineV2Args<'_>) -> Result<PipelineResu
server_state,
media_file_to_url_map.as_ref(),
kinovi_character_id_map.as_ref(),
use_alternate_kinovi,
).await;
// 5. On failure, refund wallet for Kinovi requests.
@@ -129,10 +146,11 @@ async fn upload_and_generate(
server_state: &ServerState,
media_file_urls_by_token: Option<&HashMap<MediaFileToken, String>>,
kinovi_character_ids: Option<&HashMap<CharacterToken, String>>,
use_alternate_kinovi: bool,
) -> Result<GenerateVideoResponse, AdvancedCommonWebError> {
let provider = draft_or_request.get_provider();
let client = build_router_client(provider, server_state)?;
let client = build_router_client(provider, server_state, use_alternate_kinovi)?;
let video_request = match draft_or_request {
VideoGenerationDraftOrRequest::Request(request) => request,
@@ -492,7 +492,7 @@ async fn main() -> AnyhowResult<()> {
},
seedance2pro: Seedance2ProData {
cookies: easyenv::get_env_string_required("SEEDANCE2PRO_COOKIES")?,
cookies_whitelist: easyenv::get_env_string_required("SEEDANCE2PRO_WHITELIST_COOKIES")?,
cookies_byteplus: easyenv::get_env_string_required("SEEDANCE2PRO_WHITELIST_COOKIES")?,
},
openai: OpenAiData {
api_key: openai_api_key,
@@ -346,7 +346,7 @@ pub struct Seedance2ProData {
pub cookies: String,
/// Cookie string for seedance2-pro.com (Whitelisted account)
pub cookies_whitelist: String,
pub cookies_byteplus: String,
}
/// OpenAI integration
@@ -126,6 +126,11 @@ export function App() {
<Route path="/edit-3d/:sceneToken" element={<PageScene />} />
<Route path="/support" element={<Support />} />
<Route path="/pricing" element={<Pricing />} />
{/* Welcome is public so it stays reachable right after signup
without losing the race against the session refresh that
RequireAuth reads. The page itself drives the pricing/Stripe
handoff via PricingTable. */}
<Route path="/welcome" element={<Welcome />} />
{/* Protected — sign-in required (user-owned content / billing flows) */}
<Route element={<RequireAuth />}>
@@ -134,7 +139,6 @@ export function App() {
<Route path="/library" element={<Library />} />
<Route path="/library/:filter" element={<Library />} />
<Route path="/referrals" element={<Referrals />} />
<Route path="/welcome" element={<Welcome />} />
<Route path="/onboarding" element={<Onboarding />} />
<Route path="/checkout/success" element={<CheckoutSuccess />} />
<Route path="/checkout/cancel" element={<CheckoutCancel />} />
@@ -267,7 +267,7 @@ export function LightboxDetails({
return (
<a
key={contextImage.media_token}
href={`/media/${contextImage.media_token}`}
href={`${SHARE_URL_BASE}${contextImage.media_token}`}
target="_blank"
rel="noopener noreferrer"
className="relative aspect-square overflow-hidden rounded-lg border border-white/10 hover:border-white/40 transition-colors block"
@@ -9,13 +9,13 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faImages,
faPlus,
faSpinner,
faSpinnerThird,
faXmark,
} from "@fortawesome/pro-solid-svg-icons";
import { faImage } from "@fortawesome/pro-regular-svg-icons";
import { Button } from "@storyteller/ui-button";
import { Tooltip } from "@storyteller/ui-tooltip";
import { Modal } from "@storyteller/ui-modal";
import { twMerge } from "tailwind-merge";
import { UploaderStates } from "@storyteller/common";
import {
@@ -75,6 +75,7 @@ export const ImagePromptRow = ({
const [uploadingImages, setUploadingImages] = useState<
{ id: string; file: File }[]
>([]);
const [previewImage, setPreviewImage] = useState<RefImage | null>(null);
const referenceImagesRef = useRef(referenceImages);
referenceImagesRef.current = referenceImages;
@@ -273,6 +274,7 @@ export const ImagePromptRow = ({
image={image}
allowReorder={allowReorder}
onRemove={handleRemoveReference}
onPreview={(img) => setPreviewImage(img)}
/>
))}
</SortableContext>
@@ -285,6 +287,7 @@ export const ImagePromptRow = ({
key={image.id}
image={image}
onRemove={handleRemoveReference}
onPreview={(img) => setPreviewImage(img)}
/>
))
)}
@@ -371,14 +374,20 @@ export const ImagePromptRow = ({
</div>
<div className="flex items-center gap-2">
{endFrameImage ? (
<div className="group relative aspect-square w-10 sm:w-14 overflow-hidden rounded-lg border-2 border-white/30 transition-all hover:border-white/80">
<div
className="group relative aspect-square w-10 sm:w-14 overflow-hidden rounded-lg border-2 border-white/30 transition-all cursor-pointer hover:border-white/80"
onClick={() => setPreviewImage(endFrameImage)}
>
<img
src={endFrameImage.url}
alt="End frame"
className="h-full w-full object-cover"
/>
<button
onClick={() => setEndFrameImage?.(undefined)}
onClick={(e) => {
e.stopPropagation();
setEndFrameImage?.(undefined);
}}
className="absolute right-[2px] top-[2px] flex h-5 w-5 cursor-pointer items-center justify-center rounded-full bg-black/50 text-white sm:opacity-0 backdrop-blur-md transition-colors hover:bg-black sm:group-hover:opacity-100"
>
<FontAwesomeIcon icon={faXmark} className="h-2.5 w-2.5" />
@@ -389,7 +398,7 @@ export const ImagePromptRow = ({
<FontAwesomeIcon
icon={faSpinnerThird}
spin
className="h-6 w-6 text-white"
className="h-5 w-5 text-white"
/>
</div>
) : onPickEndFrameFromLibrary ? (
@@ -445,6 +454,25 @@ export const ImagePromptRow = ({
</div>
)}
</div>
<Modal
isOpen={!!previewImage}
onClose={() => setPreviewImage(null)}
className="w-fit h-fit max-w-none bg-transparent border-0 p-0 shadow-none overflow-visible"
backdropClassName="!bg-black/80"
showClose={true}
closeOnOutsideClick={true}
>
{previewImage && (
<div className="relative flex items-center justify-center">
<img
src={previewImage.fullUrl || previewImage.url}
alt="Preview"
className="max-w-[90vw] max-h-[90vh] object-contain drop-shadow-2xl rounded-lg"
/>
</div>
)}
</Modal>
</>
);
};
@@ -454,11 +482,16 @@ export const ImagePromptRow = ({
const ImageThumbnail = ({
image,
onRemove,
onPreview,
}: {
image: RefImage;
onRemove: (id: string) => void;
onPreview?: (image: RefImage) => void;
}) => (
<div className="group glass relative aspect-square w-10 sm:w-14 overflow-hidden rounded-lg border-2 border-white/30 transition-all hover:border-white/80">
<div
className="group glass relative aspect-square w-10 sm:w-14 overflow-hidden rounded-lg border-2 border-white/30 transition-all cursor-pointer hover:border-white/80"
onClick={() => onPreview?.(image)}
>
<img
src={image.url}
alt="Reference"
@@ -480,10 +513,12 @@ const SortableImage = ({
image,
allowReorder,
onRemove,
onPreview,
}: {
image: RefImage;
allowReorder: boolean;
onRemove: (id: string) => void;
onPreview?: (image: RefImage) => void;
}) => {
const {
attributes,
@@ -505,6 +540,7 @@ const SortableImage = ({
ref={setNodeRef}
style={style}
{...(allowReorder ? { ...attributes, ...listeners } : {})}
onClick={() => onPreview?.(image)}
className={twMerge(
"group glass relative aspect-square w-10 sm:w-14 overflow-hidden rounded-lg border-2 border-white/30 transition-opacity",
allowReorder
@@ -546,7 +582,7 @@ const UploadingThumbnail = ({ file }: { file: File }) => {
/>
</div>
<div className="absolute inset-0 flex items-center justify-center bg-black/20">
<FontAwesomeIcon icon={faSpinner} spin className="h-6 w-6 text-white" />
<FontAwesomeIcon icon={faSpinnerThird} spin className="h-6 w-6 text-white" />
</div>
</div>
);
@@ -1,8 +1,10 @@
export interface RefImage {
id: string;
url: string;
file: File;
mediaToken: string;
fullUrl?: string;
file?: File;
mediaToken?: string;
isCustom?: boolean;
}
export interface RefVideo {
@@ -358,8 +358,6 @@ function PasswordForm({ user, isEditing, onOpen, onClose }: SectionFormProps) {
title="Password"
collapsedDescription="Used to sign in to your account."
editingDescription={`Choose a new password of at least ${PASSWORD_MIN} characters.`}
currentLabel="Current"
currentValue={"••••••••"}
isEditing={isEditing}
onOpen={handleOpen}
>
@@ -417,8 +415,8 @@ function SectionShell({
title: string;
collapsedDescription: string;
editingDescription: string;
currentLabel: string;
currentValue: string;
currentLabel?: string;
currentValue?: string;
isEditing: boolean;
onOpen: () => void;
children: React.ReactNode;
@@ -444,7 +442,9 @@ function SectionShell({
)}
</div>
{!isEditing ? (
<CurrentValue label={currentLabel} value={currentValue} />
currentLabel && currentValue ? (
<CurrentValue label={currentLabel} value={currentValue} />
) : null
) : (
children
)}
@@ -342,6 +342,13 @@ export function TopBar() {
</>
) : (
<>
<Link
to="/pricing"
className="hidden lg:flex h-8 items-center gap-1.5 px-3 rounded-lg text-sm font-medium text-white/85 hover:bg-white/[0.04] transition-colors"
>
<FontAwesomeIcon icon={faGem} className="text-[11px]" />
Pricing
</Link>
<Link
to="/login"
className="h-8 flex items-center px-3 rounded-lg text-sm font-medium text-white/80 hover:text-white hover:bg-white/[0.04] transition-all"
+1 -1
View File
@@ -19,7 +19,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);
}
@@ -2,64 +2,11 @@ import { faXmarkCircle } from "@fortawesome/pro-solid-svg-icons";
import { faDiscord } from "@fortawesome/free-brands-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Button } from "@storyteller/ui-button";
import { Link, useSearchParams } from "react-router-dom";
import { Link } from "react-router-dom";
import { SOCIAL_LINKS } from "../../config/links";
import Seo from "../../components/seo";
import { useEffect, useState } from "react";
import { UsersApi } from "@storyteller/api";
const CheckoutCancel = () => {
const [searchParams] = useSearchParams();
const skipOnboarding = searchParams.get("skip_onboarding") === "true";
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const checkOnboarding = async () => {
// Skip onboarding check if we already went through it
if (skipOnboarding) {
setIsLoading(false);
return;
}
try {
const usersApi = new UsersApi();
const sessionResponse = await usersApi.GetSession();
if (sessionResponse.success && sessionResponse.data?.loggedIn) {
const onboarding = sessionResponse.data.onboarding;
// If onboarding is undefined or has any required fields not set, redirect to onboarding
if (
!onboarding ||
onboarding.password_not_set ||
onboarding.email_not_set ||
onboarding.email_not_confirmed
) {
// Redirect to onboarding - it will add skip_onboarding=true after username step
window.location.href =
"/onboarding?redirect_to=" +
encodeURIComponent("/checkout/cancel");
return;
}
}
} catch (error) {
console.error("Error checking onboarding status:", error);
} finally {
setIsLoading(false);
}
};
checkOnboarding();
}, [skipOnboarding]);
if (isLoading) {
return (
<div className="relative min-h-screen bg-[#101014] text-white flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-4 border-primary border-t-transparent"></div>
</div>
);
}
return (
<div className="relative min-h-screen overflow-hidden bg-[#101014] text-white">
<Seo
@@ -72,7 +19,7 @@ const CheckoutCancel = () => {
<div className="w-[900px] h-[900px] rounded-full bg-gradient-to-br from-gray-500/20 via-gray-600/10 to-gray-700/5 opacity-40 blur-[120px]"></div>
</div>
<main className="relative z-10 pt-28 pb-20 px-4 sm:px-6 lg:px-8 flex flex-col items-center justify-center min-h-[calc(100vh-200px)]">
<main className="relative z-10 pt-20 pb-20 px-4 sm:px-6 lg:px-8 flex flex-col items-center justify-center min-h-[calc(100vh-200px)]">
{/* Cancel Card */}
<div className="max-w-lg w-full">
<div className="bg-[#1A1A1E] border border-white/10 rounded-3xl p-8 md:p-12 text-center">
@@ -3,6 +3,11 @@ import {
faDownload,
faDesktop,
faRocket,
faImage,
faVideo,
faWandMagicSparkles,
faCube,
faArrowRight,
} from "@fortawesome/pro-solid-svg-icons";
import { faDiscord } from "@fortawesome/free-brands-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
@@ -72,20 +77,21 @@ const CheckoutSuccess = () => {
}
return (
<div className="relative min-h-screen overflow-hidden bg-[#101014] text-white">
<div className="relative min-h-full bg-[#101014] text-white">
<Seo
title="Payment Successful - ArtCraft"
description="Your ArtCraft subscription is now active. Download and start creating!"
/>
{/* Background gradient - celebratory green/purple */}
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-0">
{/* Background gradient — clipped to its own wrapper so the outer
page can grow naturally and let SidebarInset handle scrolling. */}
<div className="absolute inset-0 overflow-hidden flex items-center justify-center pointer-events-none z-0">
<div className="w-[900px] h-[900px] rounded-full bg-gradient-to-br from-green-500/40 via-primary/30 to-purple-600/20 opacity-40 blur-[120px]"></div>
</div>
<main className="relative z-10 pt-28 pb-20 px-4 sm:px-6 lg:px-8 flex flex-col items-center justify-center min-h-[calc(100vh-200px)]">
<main className="relative z-10 pt-12 pb-20 px-4 sm:px-6 lg:px-8 flex flex-col items-center justify-center min-h-[calc(100vh-200px)]">
{/* Success Card */}
<div className="max-w-2xl w-full">
<div className="max-w-4xl w-full">
<div className="bg-[#1A1A1E] border border-white/10 rounded-3xl p-8 md:p-12 text-center">
{/* Success Icon */}
<div className="mb-6">
@@ -107,6 +113,54 @@ const CheckoutSuccess = () => {
create amazing AI-generated art!
</p>
{/* Start creating in the browser */}
<div className="bg-[#252529] rounded-2xl p-6 mb-8 text-left">
<div className="flex items-center gap-3 mb-2">
<div className="w-8 h-8 rounded-full bg-primary/20 flex items-center justify-center">
<FontAwesomeIcon
icon={faWandMagicSparkles}
className="text-primary text-sm"
/>
</div>
<h2 className="text-lg font-medium text-white">
Start creating right here
</h2>
</div>
<p className="text-white/60 text-sm mb-4">
No download needed - jump straight in from your browser.
</p>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{[
{ to: "/create-image", icon: faImage, label: "Image" },
{ to: "/create-video", icon: faVideo, label: "Video" },
{
to: "/background-change",
icon: faWandMagicSparkles,
label: "BG Change",
},
{ to: "/edit-3d", icon: faCube, label: "Edit 3D" },
].map((item) => (
<Link
key={item.to}
to={item.to}
className="group flex items-center gap-3 bg-[#1A1A1E] hover:bg-[#222227] border border-white/[0.06] hover:border-white/[0.12] rounded-xl px-4 py-3 transition-all"
>
<FontAwesomeIcon
icon={item.icon}
className="text-primary text-base shrink-0"
/>
<span className="text-white/90 font-medium flex-1">
{item.label}
</span>
<FontAwesomeIcon
icon={faArrowRight}
className="text-white/30 text-[11px] transition-transform group-hover:translate-x-0.5"
/>
</Link>
))}
</div>
</div>
{/* Next Steps */}
<div className="bg-[#252529] rounded-2xl p-6 mb-8 text-left">
<div className="flex items-center gap-3 mb-4">
@@ -268,6 +268,7 @@ export default function CreateImage() {
.map((item) => ({
id: Math.random().toString(36).substring(7),
url: item.thumbnail || item.fullImage || "",
fullUrl: item.fullImage || undefined,
file: new File([], "library-image"),
mediaToken: item.id,
}));
@@ -295,7 +296,7 @@ export default function CreateImage() {
const imageMediaTokens = selectedModel.image_refs_supported
? referenceImages
.map((img) => img.mediaToken)
.filter((t) => t.length > 0)
.filter((t): t is string => typeof t === "string" && t.length > 0)
: undefined;
const result = await enqueueImageGeneration({
@@ -415,6 +416,7 @@ export default function CreateImage() {
onSelect={handleModelChange}
mode="toggle"
panelTitle="Select Model"
panelClassName="w-max"
showIconsInList
triggerIcon={
<img
@@ -1,10 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { flushSync } from "react-dom";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faClock,
faWaveformLines,
} from "@fortawesome/pro-solid-svg-icons";
import { faClock, faWaveformLines } from "@fortawesome/pro-solid-svg-icons";
import { CharactersApi, FilterMediaClasses } from "@storyteller/api";
import type { OmniGenVideoModelInfo } from "@storyteller/api";
import { ToggleButton } from "@storyteller/ui-button";
@@ -97,6 +94,13 @@ const LABEL_TO_RES: Record<string, string> = Object.fromEntries(
// ── Model lookup ─────────────────────────────────────────────────────────
// Placeholder entries shown in the picker as disabled "SOON" items. These are
// UI-only — they are not in `_modelLookup`, so the click handler is a no-op
// (the `disabled` flag also prevents the row from firing).
const COMING_SOON_MODELS: ReadonlyArray<{ id: string; label: string }> = [
{ id: "seedance_2p1", label: "Seedance 2.1" },
];
let _modelLookup = new Map<string, OmniGenVideoModelInfo>();
function buildModelPopoverItems(
@@ -104,7 +108,7 @@ function buildModelPopoverItems(
selectedId: string,
): PopoverItem[] {
_modelLookup = new Map(models.map((m) => [m.model, m]));
return models.map((model) => ({
const apiItems: PopoverItem[] = models.map((model) => ({
label: model.full_name || model.model,
selected: model.model === selectedId,
icon: (
@@ -116,6 +120,27 @@ function buildModelPopoverItems(
),
action: model.model,
}));
const comingSoonItems: PopoverItem[] = COMING_SOON_MODELS.map(
({ id, label }) => ({
label,
selected: false,
disabled: true,
className: "!ml-0",
icon: (
<img
src={getModelCreatorIconPath(id)}
alt={`${id} logo`}
className="h-4 w-4 icon-auto-contrast"
/>
),
trailing: (
<span className="-ml-3.5 shrink-0 rounded-full bg-white/20 px-2 py-0 text-[10px] font-bold uppercase tracking-wider text-white">
Soon
</span>
),
}),
);
return [...apiItems, ...comingSoonItems];
}
function buildSizePopoverItems(
@@ -683,6 +708,7 @@ export default function CreateVideo() {
.map((item) => ({
id: Math.random().toString(36).substring(7),
url: item.thumbnail || item.fullImage || "",
fullUrl: item.fullImage || undefined,
file: new File([], "library-image"),
mediaToken: item.id,
}));
@@ -698,6 +724,7 @@ export default function CreateVideo() {
setEndFrameImage({
id: Math.random().toString(36).substring(7),
url: item.thumbnail || item.fullImage || "",
fullUrl: item.fullImage || undefined,
file: new File([], "library-image"),
mediaToken: item.id,
});
@@ -744,15 +771,19 @@ export default function CreateVideo() {
isReferenceMode && referenceImages.length > 0
? referenceImages
.map((img) => img.mediaToken)
.filter((t) => t.length > 0)
.filter((t): t is string => typeof t === "string" && t.length > 0)
: undefined;
const referenceVideoTokens =
isReferenceMode && referenceVideos.length > 0
? referenceVideos.map((v) => v.mediaToken).filter((t) => t.length > 0)
? referenceVideos
.map((v) => v.mediaToken)
.filter((t): t is string => typeof t === "string" && t.length > 0)
: undefined;
const referenceAudioTokens =
isReferenceMode && referenceAudios.length > 0
? referenceAudios.map((a) => a.mediaToken).filter((t) => t.length > 0)
? referenceAudios
.map((a) => a.mediaToken)
.filter((t): t is string => typeof t === "string" && t.length > 0)
: undefined;
// Extract character tokens from @-mentions in the prompt. Match longest
@@ -807,7 +838,11 @@ export default function CreateVideo() {
console.log("[generate-video] params", baseParams);
const modelLabel = selectedModel.full_name ?? selectedModel.model;
const batchId = startBatch(prompt, modelLabel, numVideos > 1 ? numVideos : undefined);
const batchId = startBatch(
prompt,
modelLabel,
numVideos > 1 ? numVideos : undefined,
);
try {
console.log("[generate-video] enqueueing job...");
@@ -973,6 +1008,7 @@ export default function CreateVideo() {
onSelect={handleModelChange}
mode="toggle"
panelTitle="Select Model"
panelClassName="w-max"
showIconsInList
triggerIcon={
<img
@@ -307,7 +307,7 @@ const Onboarding = () => {
<div className="w-[900px] h-[900px] rounded-full bg-gradient-to-br from-green-500/40 via-primary/30 to-purple-600/20 opacity-40 blur-[120px]"></div>
</div>
<main className="relative z-10 pt-28 pb-20 px-4 sm:px-6 lg:px-8 flex flex-col items-center justify-center min-h-[calc(100vh-200px)]">
<main className="relative z-10 pt-20 pb-20 px-4 sm:px-6 lg:px-8 flex flex-col items-center justify-center min-h-[calc(100vh-200px)]">
<div className="max-w-md w-full text-center">
<div className="mb-6">
<div className="w-20 h-20 mx-auto rounded-full bg-green-500/20 flex items-center justify-center">
@@ -338,7 +338,7 @@ const Onboarding = () => {
<div className="w-[900px] h-[900px] rounded-full bg-gradient-to-br from-blue-700 via-blue-500 to-[#00AABA] opacity-20 blur-[120px]"></div>
</div>
<main className="relative z-10 pt-28 pb-20 px-4 sm:px-6 lg:px-8 flex flex-col items-center justify-center min-h-[calc(100vh-200px)]">
<main className="relative z-10 pt-20 pb-20 px-4 sm:px-6 lg:px-8 flex flex-col items-center justify-center min-h-[calc(100vh-200px)]">
<div className="max-w-md w-full">
{isNewAccount && (
<div className="bg-blue-500/10 border border-blue-500/30 rounded-xl p-4 mb-6 flex items-start gap-3 animate-fade-in shadow-lg shadow-blue-900/10">
@@ -1,9 +1,10 @@
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faCompress,
faExpand,
faImage,
faVideo,
faWandMagicSparkles,
faXmark,
} from "@fortawesome/pro-solid-svg-icons";
@@ -11,96 +12,86 @@ import { MediaFilesApi, PromptsApi } from "@storyteller/api";
import { addCorsParam, PLACEHOLDER_IMAGES } from "@storyteller/common";
import { LoadingSpinner } from "@storyteller/ui-loading-spinner";
import { Viewer3D } from "@storyteller/ui-viewer-3d";
import { is3DModelUrl, isVideoUrl } from "../../components/lightbox/shared";
import { is3DModelUrl } from "../../components/lightbox/shared";
// Demo overlay rendered on top-right of the 3D editor when the URL carries
// `?output=<media_token>` (alias: `?demo=<media_token>`). It resolves the
// token to a media URL and renders the asset (image / video / 3D model)
// in a 16:9 picture-in-picture card so the scene and its rendered output
// can be shown side by side.
// `?image=<media_token>` (or the legacy `?output=` / `?demo=` aliases) and
// optionally `?video=<media_token>`. Each token is resolved independently
// to a CDN URL and rendered in a 16:9 picture-in-picture card so the scene
// and its rendered output can be shown side by side.
//
// UX details aimed at making the relationship obvious at a glance:
// - "Rendered Output" header with a wand-sparkles icon and a
// "Generated from this scene" subtitle so first-time viewers
// immediately understand the card is the AI render of the scene
// they're looking at.
// - Slide-in entrance animation draws the eye to the corner on load
// so the card isn't missed.
// - The title-bar expand button promotes the card to a centered
// larger view over the editor; clicking again (or the backdrop /
// Esc) collapses it back to the corner.
// UX details:
// - "Rendered Output" header with a wand-sparkles icon and a "Generated
// from this scene" subtitle so first-time viewers immediately understand
// the card is the AI render of the scene they're looking at.
// - Slide-in entrance animation draws the eye to the corner on load.
// - The title-bar expand button promotes the card to a centered larger
// view over the editor; clicking again (or backdrop / Esc) collapses it.
// - When both an image and a video token are present, a small segmented
// toggle appears in the header. Video shows first (the toggle defaults
// to the video side); flipping the toggle swaps the rendered media
// *and* the prompt caption shown below it.
//
// The component is fully self-contained: it fetches its own media and
// renders nothing while the token is unresolved or invalid.
// The component is fully self-contained: it fetches both assets and renders
// nothing while everything is unresolved or invalid.
interface DemoOutputOverlayProps {
outputToken: string;
imageToken: string | null;
videoToken: string | null;
}
interface OverlayMedia {
interface ResolvedAsset {
url: string;
isVideo: boolean;
is3D: boolean;
promptText: string | null;
}
export function DemoOutputOverlay({ outputToken }: DemoOutputOverlayProps) {
const [media, setMedia] = useState<OverlayMedia | null>(null);
const [promptText, setPromptText] = useState<string | null>(null);
type View = "video" | "image";
export function DemoOutputOverlay({
imageToken,
videoToken,
}: DemoOutputOverlayProps) {
const [image, setImage] = useState<ResolvedAsset | null>(null);
const [video, setVideo] = useState<ResolvedAsset | null>(null);
const [loading, setLoading] = useState(true);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [isExpanded, setIsExpanded] = useState(false);
const [isHidden, setIsHidden] = useState(false);
// Video shows first when both tokens are provided. When only one token
// is present, the effective view derivation below pins to that side
// regardless of this value, so the initial pick only matters for the
// both-present case.
const [view, setView] = useState<View>(videoToken ? "video" : "image");
useEffect(() => {
let cancelled = false;
setLoading(true);
setErrorMessage(null);
setMedia(null);
setPromptText(null);
setImage(null);
setVideo(null);
setView(videoToken ? "video" : "image");
(async () => {
try {
const mediaResponse = await new MediaFilesApi().GetMediaFileByToken({
mediaFileToken: outputToken,
});
if (cancelled) return;
const file = mediaResponse?.data;
const url = file?.media_links?.cdn_url || null;
if (!mediaResponse?.success || !file || !url) {
setErrorMessage("Rendered output not found");
return;
}
setMedia({
url,
isVideo: isVideoUrl(url),
is3D: is3DModelUrl(url),
});
// The prompt fetch is best-effort and decorative; failures or a
// missing prompt_token should leave the card without a caption,
// not surface an error to the user.
if (file.maybe_prompt_token) {
const promptResponse = await new PromptsApi().GetPromptsByToken({
token: file.maybe_prompt_token,
});
if (cancelled) return;
const text = promptResponse?.success
? promptResponse.data?.maybe_positive_prompt || null
: null;
setPromptText(text);
}
} catch {
if (!cancelled) setErrorMessage("Failed to load rendered output");
} finally {
if (!cancelled) setLoading(false);
const [imageResult, videoResult] = await Promise.all([
imageToken ? resolveAsset(imageToken) : Promise.resolve(null),
videoToken ? resolveAsset(videoToken) : Promise.resolve(null),
]);
if (cancelled) return;
setImage(imageResult);
setVideo(videoResult);
const anyRequested = !!(imageToken || videoToken);
const anyResolved = !!(imageResult || videoResult);
if (anyRequested && !anyResolved) {
setErrorMessage("Rendered output not found");
}
setLoading(false);
})();
return () => {
cancelled = true;
};
}, [outputToken]);
}, [imageToken, videoToken]);
useEffect(() => {
if (!isExpanded) return;
@@ -111,6 +102,22 @@ export function DemoOutputOverlay({ outputToken }: DemoOutputOverlayProps) {
return () => window.removeEventListener("keydown", onKeyDown);
}, [isExpanded]);
const hasImage = !!image;
const hasVideo = !!video;
// The selected `view` reflects user intent, but render against whichever
// side actually resolved — if a fetch fails, we silently fall back to
// the other side rather than showing an empty card with a dead toggle.
const effectiveView: View | null = useMemo(() => {
if (view === "video" && hasVideo) return "video";
if (view === "image" && hasImage) return "image";
if (hasVideo) return "video";
if (hasImage) return "image";
return null;
}, [view, hasImage, hasVideo]);
const activeAsset = effectiveView === "video" ? video : image;
const captionText = activeAsset?.promptText ?? null;
const showToggle = hasImage && hasVideo;
if (isHidden) {
return <ShowOutputPill onClick={() => setIsHidden(false)} />;
}
@@ -130,7 +137,13 @@ export function DemoOutputOverlay({ outputToken }: DemoOutputOverlayProps) {
isExpanded={isExpanded}
onToggleExpanded={() => setIsExpanded((v) => !v)}
onHide={handleStepDown}
promptText={promptText}
captionText={captionText}
toggle={
showToggle && effectiveView ? (
<ViewToggle view={effectiveView} onChange={setView} />
) : null
}
activeIsVideo={effectiveView === "video"}
>
{loading ? (
<div className="absolute inset-0 flex items-center justify-center">
@@ -140,8 +153,8 @@ export function DemoOutputOverlay({ outputToken }: DemoOutputOverlayProps) {
<div className="absolute inset-0 flex items-center justify-center px-3 text-center text-xs text-white/60">
{errorMessage}
</div>
) : media ? (
<OverlayMediaView media={media} />
) : activeAsset && effectiveView ? (
<OverlayMediaView asset={activeAsset} view={effectiveView} />
) : null}
</Card>
);
@@ -167,6 +180,36 @@ export function DemoOutputOverlay({ outputToken }: DemoOutputOverlayProps) {
);
}
async function resolveAsset(token: string): Promise<ResolvedAsset | null> {
try {
const mediaResponse = await new MediaFilesApi().GetMediaFileByToken({
mediaFileToken: token,
});
const file = mediaResponse?.data;
const url = file?.media_links?.cdn_url;
if (!mediaResponse?.success || !file || !url) return null;
// Prompt fetch is best-effort and decorative; a missing or failed
// prompt token leaves the asset without a caption rather than
// failing the whole resolve.
let promptText: string | null = null;
if (file.maybe_prompt_token) {
try {
const promptResponse = await new PromptsApi().GetPromptsByToken({
token: file.maybe_prompt_token,
});
promptText = promptResponse?.success
? promptResponse.data?.maybe_positive_prompt || null
: null;
} catch {
// leave promptText null
}
}
return { url, is3D: is3DModelUrl(url), promptText };
} catch {
return null;
}
}
function ShowOutputPill({ onClick }: { onClick: () => void }) {
return (
<button
@@ -181,11 +224,62 @@ function ShowOutputPill({ onClick }: { onClick: () => void }) {
);
}
interface ViewToggleProps {
view: View;
onChange: (next: View) => void;
}
// Single-button sliding toggle. The whole pill is the click target —
// clicking anywhere on it flips between video and image. A primary-
// coloured indicator translates between the two icons to show which
// side is currently active.
function ViewToggle({ view, onChange }: ViewToggleProps) {
const next: View = view === "video" ? "image" : "video";
return (
<button
type="button"
aria-label={view === "video" ? "Switch to image" : "Switch to video"}
onClick={() => onChange(next)}
className="relative flex h-7 w-14 shrink-0 items-center rounded-full border border-ui-controls-border bg-black/30 p-0.5 transition-colors hover:bg-black/40"
>
<span
aria-hidden="true"
className="pointer-events-none absolute top-0.5 bottom-0.5 left-0.5 w-[calc(50%-0.125rem)] rounded-full bg-primary shadow-sm transition-transform duration-200 ease-out"
style={{
transform: view === "image" ? "translateX(100%)" : "translateX(0%)",
}}
/>
<ToggleIcon icon={faVideo} active={view === "video"} />
<ToggleIcon icon={faImage} active={view === "image"} />
</button>
);
}
interface ToggleIconProps {
icon: typeof faVideo;
active: boolean;
}
function ToggleIcon({ icon, active }: ToggleIconProps) {
return (
<span
className={
"relative z-10 flex h-full flex-1 items-center justify-center transition-colors " +
(active ? "text-white" : "text-base-fg/60")
}
>
<FontAwesomeIcon icon={icon} className="h-3 w-3" />
</span>
);
}
interface CardProps {
isExpanded: boolean;
onToggleExpanded: () => void;
onHide: () => void;
promptText: string | null;
captionText: string | null;
toggle: React.ReactNode;
activeIsVideo: boolean;
children: React.ReactNode;
}
@@ -193,9 +287,22 @@ function Card({
isExpanded,
onToggleExpanded,
onHide,
promptText,
captionText,
toggle,
activeIsVideo,
children,
}: CardProps) {
// When the video element is active, clicks on the native control bar
// must not bubble to the wrapper's expand handler — otherwise pressing
// play would also fullscreen the card. Suppress the wrapper handler
// for any click whose target lives inside a <video> element.
const handleMediaClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (activeIsVideo) {
const target = e.target as HTMLElement;
if (target.closest("video")) return;
}
onToggleExpanded();
};
return (
<div className="glass pointer-events-auto overflow-hidden rounded-xl shadow-xl border-2 border-primary">
<div className="flex items-center justify-between gap-3 border-b border-ui-controls-border/60 px-3 py-2">
@@ -213,22 +320,27 @@ function Card({
</div>
</div>
</div>
<button
type="button"
onClick={onHide}
aria-label={
isExpanded ? "Collapse to picture-in-picture" : "Hide rendered output"
}
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-base-fg/60 transition-colors hover:bg-ui-controls hover:text-base-fg"
>
<FontAwesomeIcon icon={faXmark} className="h-4 w-4" />
</button>
<div className="flex items-center gap-2">
{toggle}
<button
type="button"
onClick={onHide}
aria-label={
isExpanded
? "Collapse to picture-in-picture"
: "Hide rendered output"
}
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-base-fg/60 transition-colors hover:bg-ui-controls hover:text-base-fg"
>
<FontAwesomeIcon icon={faXmark} className="h-4 w-4" />
</button>
</div>
</div>
<div
role="button"
tabIndex={0}
aria-label={isExpanded ? "Collapse output view" : "Expand output view"}
onClick={onToggleExpanded}
onClick={handleMediaClick}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
@@ -254,10 +366,10 @@ function Card({
/>
</button>
</div>
{promptText && (
{captionText && (
<div
className="border-t border-ui-controls-border/60 px-3 py-2 text-[11px] leading-[15px] text-base-fg/70"
title={promptText}
title={captionText}
style={{
display: "-webkit-box",
WebkitLineClamp: 2,
@@ -266,19 +378,24 @@ function Card({
maxHeight: "47px",
}}
>
{promptText}
{captionText}
</div>
)}
</div>
);
}
function OverlayMediaView({ media }: { media: OverlayMedia }) {
const src = addCorsParam(media.url) || media.url;
if (media.is3D) {
interface OverlayMediaViewProps {
asset: ResolvedAsset;
view: View;
}
function OverlayMediaView({ asset, view }: OverlayMediaViewProps) {
const src = addCorsParam(asset.url) || asset.url;
if (asset.is3D) {
return <Viewer3D modelUrl={src} isActive className="h-full w-full" />;
}
if (media.isVideo) {
if (view === "video") {
return (
<video
src={src}
@@ -287,6 +404,7 @@ function OverlayMediaView({ media }: { media: OverlayMedia }) {
loop
muted
playsInline
controls
/>
);
}
@@ -0,0 +1,122 @@
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faCubes,
faLayerGroup,
faXmark,
} from "@fortawesome/pro-solid-svg-icons";
import { SceneSplashCard } from "./splash/SceneSplashCard";
import { EXAMPLE_SCENES } from "./splash/example-scenes";
// Demo-mode affordance fed into Stage3D's `promptboxAboveStackSlot`
// (lib slot rendered just above the promptbox stack). Collapsed: a
// pill that fades in below the model. Expanded: an inline panel that
// grows upward with the same horizontal lane as the promptbox, listing
// the curated example scenes minus the one the user is on. Clicking a
// card navigates to `/edit-3d/${sceneToken}?image=${outputToken}` —
// same shape as the splash modal's pickExample.
//
// Rendering through the lib's slot (instead of as a separately
// absolutely-positioned overlay) means the lib's `bottom-4` anchor
// owns the vertical math: the slot is part of the same `flex flex-col
// gap-3` stack as the prompt card and toolbar, so it never overlaps
// the prompt input regardless of how tall the textarea has grown. The
// host doesn't need to measure anything.
interface OtherScenesOverlayProps {
currentSceneToken: string | undefined;
demoOutputToken: string | null;
}
export function OtherScenesOverlay({
currentSceneToken,
demoOutputToken,
}: OtherScenesOverlayProps) {
const [isOpen, setIsOpen] = useState(false);
const navigate = useNavigate();
const otherScenes = useMemo(
() => EXAMPLE_SCENES.filter((s) => s.sceneToken !== currentSceneToken),
[currentSceneToken],
);
if (!demoOutputToken || otherScenes.length === 0) return null;
const handlePick = (sceneToken: string, outputToken: string) => {
setIsOpen(false);
navigate(`/edit-3d/${sceneToken}?image=${outputToken}`);
};
return (
<div className="flex justify-center">
{isOpen ? (
<ExpandedPanel
scenes={otherScenes}
onClose={() => setIsOpen(false)}
onPick={handlePick}
/>
) : (
<CollapsedPill onClick={() => setIsOpen(true)} />
)}
</div>
);
}
function CollapsedPill({ onClick }: { onClick: () => void }) {
return (
<button
type="button"
onClick={onClick}
className="flex items-center gap-2.5 rounded-full border border-primary/40 bg-gradient-to-r from-primary/90 to-primary px-5 py-2.5 text-sm font-semibold text-white transition-colors hover:from-primary hover:to-primary animate-in fade-in slide-in-from-bottom-2 duration-300"
>
<FontAwesomeIcon icon={faCubes} className="h-3.5 w-3.5" />
<span>See other demo scenes</span>
</button>
);
}
interface ExpandedPanelProps {
scenes: readonly (typeof EXAMPLE_SCENES)[number][];
onClose: () => void;
onPick: (sceneToken: string, outputToken: string) => void;
}
function ExpandedPanel({ scenes, onClose, onPick }: ExpandedPanelProps) {
return (
<div className="glass w-[860px] max-w-[90vw] overflow-hidden rounded-xl border border-ui-controls-border shadow-2xl animate-in fade-in slide-in-from-bottom-2 duration-200">
<div className="flex items-center justify-between border-b border-ui-controls-border/60 px-4 py-2.5">
<div className="flex items-center gap-2">
<FontAwesomeIcon
icon={faLayerGroup}
className="h-3 w-3 text-primary"
/>
<div className="text-xs font-semibold uppercase tracking-wider text-base-fg">
Other demo scenes
</div>
</div>
<button
type="button"
onClick={onClose}
aria-label="Close other scenes"
className="flex h-6 w-6 items-center justify-center rounded-full text-base-fg/60 transition-colors hover:bg-ui-controls hover:text-base-fg"
>
<FontAwesomeIcon icon={faXmark} className="h-4 w-4" />
</button>
</div>
<div className="grid grid-cols-2 gap-3 p-3 sm:grid-cols-3">
{scenes.map((scene) => (
<SceneSplashCard
key={scene.id}
variant="example"
title={scene.title}
description={scene.description}
accentClass={scene.accentClass}
outputToken={scene.outputToken}
onClick={() => onPick(scene.sceneToken, scene.outputToken)}
/>
))}
</div>
</div>
);
}
@@ -20,6 +20,7 @@ import { useSidebar } from "../../components/ui/sidebar";
import { useSignupCta } from "../../components/signup-cta-modal";
import Seo from "../../components/seo";
import { DemoOutputOverlay } from "./demo-output-overlay";
import { OtherScenesOverlay } from "./other-scenes-overlay";
import { usePromptPrefillFromOutput } from "./use-prompt-prefill-from-output";
import { useSceneCacheStore } from "./scene-cache-store";
import { useWebAppPageSceneAdapter } from "./web-adapter";
@@ -30,6 +31,30 @@ import {
useSceneSplashStore,
} from "./splash";
// Mirror of the lib's private `DEFAULT_CAMERAS` in PageSceneStore.ts.
// Re-applied to the store on every scene navigation so a stale
// `cameras` array (from the previous scene) can't drive the new
// viewport's FOV when the loaded scene's JSON omits camera data — see
// the camera-reset effect below for the full mechanism.
const DEFAULT_CAMERAS = [
{
id: "main",
label: "Main View",
focalLength: 17,
position: { x: -2.5, y: 2.5, z: 2.5 },
rotation: { x: 0, y: 0, z: 0 },
lookAt: { x: 0, y: 0, z: 0 },
},
{
id: "cam2",
label: "Camera 2",
focalLength: 10,
position: { x: 0, y: 0.6, z: 1.5 },
rotation: { x: 0, y: 0, z: 0 },
lookAt: { x: 0, y: 0, z: 0 },
},
];
// Stage3D fills its parent box; this wrapper is that box. It clamps
// the editor to the SidebarInset area and feeds the lib its rect:
//
@@ -69,11 +94,20 @@ export default function PageScene() {
function PageSceneEditor() {
const { sceneToken } = useParams<{ sceneToken?: string }>();
const [searchParams] = useSearchParams();
// `?output=<media_token>` (alias `?demo=`) flips the editor into demo
// mode: the named output media renders in a top-right picture-in-picture
// card alongside the live scene. Empty string is normalized to null.
const demoOutputToken =
searchParams.get("output") || searchParams.get("demo") || null;
// `?image=`, `?video=`, and the legacy `?output=` / `?demo=` aliases
// flip the editor into demo mode: the named media renders in a
// top-right picture-in-picture card alongside the live scene. `image`
// and `output`/`demo` are equivalent — they name the image side of
// the preview (the legacy aliases keep existing shared links working).
// `video` names a video token shown on the video side. When both sides
// are present, the card adds a toggle and starts on video.
const imageToken =
searchParams.get("image") ||
searchParams.get("output") ||
searchParams.get("demo") ||
null;
const videoToken = searchParams.get("video") || null;
const demoToken = imageToken || videoToken;
const { user } = useSession();
const navigate = useNavigate();
const { setOpen } = useSidebar();
@@ -117,6 +151,26 @@ function PageSceneEditor() {
}
}, [sceneToken]);
// Reset the lib's camera Zustand state on every scene navigation.
// The lib's camera tickPerFrame reads `cameras` + `selectedCameraId`
// from this module-global store every frame and applies the matched
// entry's `focalLength` to the viewport's THREE.PerspectiveCamera
// (CameraController.ts). save_manager only emits CamerasReplacedEvent
// when the loaded scene_json includes a `cameras` field — older
// saves and some demo scenes don't, so without this reset an A→B
// handoff leaves scene A's `cameras` array (and its 60mm focal
// length) in the store and the new viewport renders at A's FOV
// forever. Re-seeding with the same defaults the store ships with
// mirrors the "fresh editor" state; if B's JSON has cameras, the
// load path replaces these immediately.
useEffect(() => {
usePageSceneStore.setState({
cameras: DEFAULT_CAMERAS,
selectedCameraId: "main",
focalLengthDragging: { isDragging: false, focalLength: 35 },
});
}, [sceneToken]);
// Mirror the URL-token scene's metadata into the lib store. Cache-hit
// loads short-circuit the lib's loadScene path (Editor.initialize
// prefers cacheJsonString over the sceneToken), so loadSceneViaApi —
@@ -165,7 +219,7 @@ function PageSceneEditor() {
// `?output=`, Stage3DBody's cold-sync handles the default letterbox by
// reading the selected model's defaultAspectRatio (now 16:9 for the
// InstructiveEdit-tagged models that populate STAGE_3D_PAGE_MODEL_LIST).
usePromptPrefillFromOutput(demoOutputToken);
usePromptPrefillFromOutput(imageToken);
const navigateToImageTo3D = useCallback(() => {
navigate("/create-image");
@@ -244,6 +298,12 @@ function PageSceneEditor() {
showImageTo3DButton={false}
showHelpMenu={false}
modelSelectorPlacement="prompt-box"
promptboxAboveStackSlot={
<OtherScenesOverlay
currentSceneToken={sceneToken}
demoOutputToken={demoToken}
/>
}
/>
{/* Controls3D's "My Library" popup item flips the
galleryModalVisibleViewMode signal — the modal below subscribes
@@ -252,7 +312,9 @@ function PageSceneEditor() {
adds. Both components portal themselves out of this wrapper. */}
<GalleryModal mode="view" />
<GalleryDragComponent />
{demoOutputToken && <DemoOutputOverlay outputToken={demoOutputToken} />}
{demoToken && (
<DemoOutputOverlay imageToken={imageToken} videoToken={videoToken} />
)}
<SceneSplashModal currentSceneToken={sceneToken} />
</div>
);
@@ -1,5 +1,5 @@
// Example-scene data for the edit-3D splash. Each card opens
// `/edit-3d/${sceneToken}?output=${outputToken}` so the editor mounts
// `/edit-3d/${sceneToken}?image=${outputToken}` so the editor mounts
// the scene and the demo-output overlay renders the AI-generated still
// next to it. Real per-scene tokens land later; for now every entry
// points at the same demo pair so the feature is exercised end-to-end.

Some files were not shown because too many files have changed in this diff Show More