Compare commits

..
Author SHA1 Message Date
Hanashi 7d3955efe7 artcraft router 2026-08-04 05:15:49 -04:00
Hanashi 85d263cff8 add minimax to fal 2026-08-04 04:42:27 -04:00
186 changed files with 273 additions and 9196 deletions
@@ -1,12 +0,0 @@
{
"db_name": "MySQL",
"query": "\nUPDATE generic_inference_jobs\nSET\n status = ?,\n on_success_result_entity_type = ?,\n on_success_result_entity_token = ?,\n failure_reason = NULL,\n internal_debugging_failure_reason = NULL,\n success_execution_millis = ?,\n success_inference_execution_millis = ?,\n retry_at = NULL,\n successfully_completed_at = NOW()\nWHERE token = ?\nAND status NOT IN ('complete_success', 'complete_failure')\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 6
},
"nullable": []
},
"hash": "03919ba8286d18980ecaa0038df76f43f5388db0d3838181fa8f15f4cbb932af"
}
@@ -1,12 +0,0 @@
{
"db_name": "MySQL",
"query": "\nUPDATE generic_inference_jobs\nSET\n status = ?,\n assigned_worker = ?,\n last_assigned_worker = ?,\n assigned_cluster = ?,\n first_started_at = NOW()\nWHERE token = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 5
},
"nullable": []
},
"hash": "258ac40d3fab39f110e1e82d50e73542423bfe9a21f8127e6cd83214c1726e25"
}
@@ -1,36 +0,0 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n token as `job_token: tokens::tokens::generic_inference_jobs::InferenceJobToken`,\n maybe_prompt_token as `maybe_prompt_token: tokens::tokens::prompts::PromptToken`\nFROM generic_inference_jobs\nWHERE job_type = ?\nAND status = 'pending'\nORDER BY id ASC\nLIMIT 1\nFOR UPDATE SKIP LOCKED\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "job_token: tokens::tokens::generic_inference_jobs::InferenceJobToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 1,
"name": "maybe_prompt_token: tokens::tokens::prompts::PromptToken",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
true
]
},
"hash": "6ca0fcdf495624cf347f612586868f6dea93f52d9f942a92dcd2b3e6aba7c63c"
}
@@ -1,12 +0,0 @@
{
"db_name": "MySQL",
"query": "\nUPDATE generic_inference_jobs\nSET\n status = ?,\n frontend_failure_category = ?,\n failure_reason = ?,\n internal_debugging_failure_reason = ?,\n success_execution_millis = ?,\n success_inference_execution_millis = ?\nWHERE token = ?\nAND status NOT IN ('complete_success', 'complete_failure')\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 7
},
"nullable": []
},
"hash": "96599b4854248d84188b092db84edb50dc5402bb7771fd8ea96a96ad43e732c0"
}
@@ -1,179 +0,0 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n m.token as `token: tokens::tokens::media_files::MediaFileToken`,\n\n m.media_class as `media_class: enums::by_table::media_files::media_file_class::MediaFileClass`,\n m.media_type as `media_type: enums::by_table::media_files::media_file_type::MediaFileType`,\n\n m.maybe_batch_token as `maybe_batch_token: tokens::tokens::batch_generations::BatchGenerationToken`,\n m.maybe_prompt_token as `maybe_prompt_token: tokens::tokens::prompts::PromptToken`,\n\n m.public_bucket_directory_hash,\n m.maybe_public_bucket_prefix,\n m.maybe_public_bucket_extension,\n\n media_file_cover_image.public_bucket_directory_hash as maybe_file_cover_image_public_bucket_hash,\n media_file_cover_image.maybe_public_bucket_prefix as maybe_file_cover_image_public_bucket_prefix,\n media_file_cover_image.maybe_public_bucket_extension as maybe_file_cover_image_public_bucket_extension,\n\n m.maybe_origin_filename,\n m.maybe_duration_millis as `maybe_duration_millis: u64`,\n\n m.created_at,\n m.updated_at\n\nFROM media_files AS m\nLEFT OUTER JOIN media_files as media_file_cover_image\n ON media_file_cover_image.token = m.maybe_cover_image_media_file_token\nWHERE\n m.maybe_source_job_token = ?\n AND m.maybe_creator_user_token = ?\n AND m.user_deleted_at IS NULL\n AND m.mod_deleted_at IS NULL\n AND NOT m.is_intermediate_system_file\nORDER BY m.id ASC\nLIMIT ?\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "token: tokens::tokens::media_files::MediaFileToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 1,
"name": "media_class: enums::by_table::media_files::media_file_class::MediaFileClass",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 2,
"name": "media_type: enums::by_table::media_files::media_file_type::MediaFileType",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 3,
"name": "maybe_batch_token: tokens::tokens::batch_generations::BatchGenerationToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 4,
"name": "maybe_prompt_token: tokens::tokens::prompts::PromptToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 5,
"name": "public_bucket_directory_hash",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 6,
"name": "maybe_public_bucket_prefix",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 7,
"name": "maybe_public_bucket_extension",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 8,
"name": "maybe_file_cover_image_public_bucket_hash",
"type_info": {
"type": "VarString",
"flags": "BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 9,
"name": "maybe_file_cover_image_public_bucket_prefix",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 10,
"name": "maybe_file_cover_image_public_bucket_extension",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 11,
"name": "maybe_origin_filename",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 12,
"name": "maybe_duration_millis: u64",
"type_info": {
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 10
}
},
{
"ordinal": 13,
"name": "created_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 14,
"name": "updated_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP | ON_UPDATE_NOW",
"char_set": 63,
"max_size": 19
}
}
],
"parameters": {
"Right": 3
},
"nullable": [
false,
false,
false,
true,
true,
false,
true,
true,
true,
true,
true,
true,
true,
false,
false
]
},
"hash": "cd04f4f7b48ae9da53b6a210111f8e3edff94dee30263e22b45d5e29dbe9245a"
}
@@ -1,12 +0,0 @@
{
"db_name": "MySQL",
"query": "\n INSERT INTO media_files\n SET\n token = ?,\n\n media_class = ?,\n media_type = ?,\n\n is_user_upload = ?,\n is_intermediate_system_file = ?,\n\n origin_category = ?,\n origin_product_category = ?,\n maybe_origin_model_type = ?,\n maybe_origin_model_token = ?,\n maybe_origin_filename = ?,\n\n maybe_batch_token = ?,\n maybe_source_job_token = ?,\n\n maybe_mime_type = ?,\n file_size_bytes = ?,\n maybe_duration_millis = ?,\n maybe_audio_encoding = ?,\n maybe_video_encoding = ?,\n maybe_frame_width = ?,\n maybe_frame_height = ?,\n maybe_prompt_token = ?,\n checksum_sha2 = ?,\n\n maybe_engine_category = ?,\n\n maybe_title = ?,\n maybe_text_transcript = ?,\n\n maybe_scene_source_media_file_token = ?,\n\n public_bucket_directory_hash = ?,\n maybe_public_bucket_prefix = ?,\n maybe_public_bucket_extension = ?,\n\n maybe_creator_user_token = ?,\n maybe_creator_anonymous_visitor_token = ?,\n\n creator_ip_address = ?,\n creator_set_visibility = ?,\n\n maybe_creator_file_synthetic_id = ?,\n maybe_creator_category_synthetic_id = ?,\n\n extra_file_modification_info = ?,\n\n maybe_generation_provider = ?,\n\n platform_type = ?,\n\n maybe_cover_image_media_file_token = ?,\n\n maybe_mod_user_token = ?,\n is_generated_on_prem = ?,\n generated_by_worker = ?,\n generated_by_cluster = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 42
},
"nullable": []
},
"hash": "d88098343686ded52a71fc45a1b96d28ea3003975d6e7d20eee2022aa31d8189"
}
@@ -1,124 +0,0 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n token as `job_token: tokens::tokens::generic_inference_jobs::InferenceJobToken`,\n status as `status: enums::common::job_status_plus::JobStatusPlus`,\n job_type as `job_type: enums::by_table::generic_inference_jobs::inference_job_type::InferenceJobType`,\n maybe_creator_user_token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,\n maybe_creator_anonymous_visitor_token as `maybe_creator_anonymous_visitor_token: tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken`,\n creator_ip_address,\n maybe_prompt_token as `maybe_prompt_token: tokens::tokens::prompts::PromptToken`,\n maybe_debug_log_event_token as `maybe_debug_log_event_token: tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken`,\n platform_type as `maybe_platform_type: enums::common::platform_type::PlatformType`,\n on_success_result_entity_token as `maybe_result_media_file_token: tokens::tokens::media_files::MediaFileToken`\nFROM generic_inference_jobs\nWHERE token = ?\nAND job_type IN ('artcraft_minimax_h3_turbo', 'artcraft_minimax_h3_ultra')\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "job_token: tokens::tokens::generic_inference_jobs::InferenceJobToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 1,
"name": "status: enums::common::job_status_plus::JobStatusPlus",
"type_info": {
"type": "String",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | ENUM",
"char_set": 224,
"max_size": 76
}
},
{
"ordinal": 2,
"name": "job_type: enums::by_table::generic_inference_jobs::inference_job_type::InferenceJobType",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 3,
"name": "maybe_creator_user_token: tokens::tokens::users::UserToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 4,
"name": "maybe_creator_anonymous_visitor_token: tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 5,
"name": "creator_ip_address",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 160
}
},
{
"ordinal": 6,
"name": "maybe_prompt_token: tokens::tokens::prompts::PromptToken",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 7,
"name": "maybe_debug_log_event_token: tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 8,
"name": "maybe_platform_type: enums::common::platform_type::PlatformType",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 9,
"name": "maybe_result_media_file_token: tokens::tokens::media_files::MediaFileToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
true,
true,
true,
false,
true,
true,
true,
true
]
},
"hash": "f1d41ad17cb01f5aef0ce2eea042ccb3e5e649365a0befaf0f46492b75523442"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\n INSERT INTO media_files\n SET\n token = ?,\n\n media_class = ?,\n media_type = ?,\n\n is_user_upload = ?,\n is_intermediate_system_file = ?,\n\n origin_category = ?,\n origin_product_category = ?,\n maybe_origin_model_type = ?,\n maybe_origin_model_token = ?,\n maybe_origin_filename = ?,\n\n maybe_batch_token = ?,\n\n maybe_mime_type = ?,\n file_size_bytes = ?,\n maybe_duration_millis = ?,\n maybe_audio_encoding = ?,\n maybe_video_encoding = ?,\n maybe_frame_width = ?,\n maybe_frame_height = ?,\n maybe_prompt_token = ?,\n checksum_sha2 = ?,\n\n maybe_engine_category = ?,\n\n maybe_title = ?,\n maybe_text_transcript = ?,\n\n maybe_scene_source_media_file_token = ?,\n\n public_bucket_directory_hash = ?,\n maybe_public_bucket_prefix = ?,\n maybe_public_bucket_extension = ?,\n\n maybe_creator_user_token = ?,\n maybe_creator_anonymous_visitor_token = ?,\n\n creator_ip_address = ?,\n creator_set_visibility = ?,\n\n maybe_creator_file_synthetic_id = ?,\n maybe_creator_category_synthetic_id = ?,\n\n extra_file_modification_info = ?,\n\n maybe_generation_provider = ?,\n\n platform_type = ?,\n\n maybe_cover_image_media_file_token = ?,\n\n maybe_mod_user_token = ?,\n is_generated_on_prem = ?,\n generated_by_worker = ?,\n generated_by_cluster = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 41
},
"nullable": []
},
"hash": "f5bc4a956a20f6c0998585092a0922301d53c95ae8ea9cb808ae2dbcf8d0bb32"
}
@@ -1,7 +0,0 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE media_files
DROP INDEX index_maybe_source_job_token,
DROP COLUMN maybe_source_job_token;
@@ -1,13 +0,0 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
-- The inference job ('jinf_...') whose completion produced this media file.
-- Only set for files written as job outputs; uploads and other origins stay NULL.
ALTER TABLE media_files
ADD COLUMN maybe_source_job_token VARCHAR(32) DEFAULT NULL;
-- For "outputs of this job"-style lookups.
-- The column is all-NULL at creation time, so the index build is cheap.
ALTER TABLE media_files
ADD INDEX index_maybe_source_job_token (maybe_source_job_token);
@@ -102,10 +102,6 @@ CREATE TABLE media_files (
-- If the media file is generated as part of a batch, this designates the batch.
maybe_batch_token VARCHAR(32) DEFAULT NULL,
-- The inference job ('jinf_...') whose completion produced this media file.
-- Only set for files written as job outputs; uploads and other origins stay NULL.
maybe_source_job_token VARCHAR(32) DEFAULT NULL,
-- ========== METADATA ==========
-- If the file is a user upload (not including studio renders).
@@ -331,7 +327,6 @@ CREATE TABLE media_files (
KEY fk_maybe_origin_model_token (maybe_origin_model_token),
KEY fk_maybe_origin_model_type_and_token (maybe_origin_model_type, maybe_origin_model_token),
KEY index_maybe_batch_token (maybe_batch_token),
KEY index_maybe_source_job_token (maybe_source_job_token),
KEY index_is_user_upload (is_user_upload),
KEY index_is_intermediate_system_file (is_intermediate_system_file),
KEY index_maybe_scene_source_media_file_token (maybe_scene_source_media_file_token),
@@ -1,29 +0,0 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
use enums::by_table::generic_inference_jobs::frontend_failure_category::FrontendFailureCategory;
/// Request body for `POST /v1/internal/minimax_jobs/job/{job_token}/failure`.
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct MarkMinimaxJobFailureRequest {
/// Optional failure class the frontend can localize and present.
pub maybe_frontend_failure_category: Option<FrontendFailureCategory>,
/// Optional failure reason we can show the user (512 characters max).
pub maybe_user_failure_reason: Option<String>,
/// Optional internal-only stack trace or error (512 characters max).
pub maybe_internal_debugging_failure_reason: Option<String>,
/// Optional total wall-clock runtime of the failed attempt, in milliseconds.
pub execution_duration_millis: Option<u64>,
/// Optional inference-only runtime of the failed attempt, in milliseconds.
pub inference_duration_millis: Option<u64>,
}
/// Response body for `POST /v1/internal/minimax_jobs/job/{job_token}/failure`.
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct MarkMinimaxJobFailureResponse {
pub success: bool,
}
@@ -1,29 +0,0 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
use tokens::tokens::media_files::MediaFileToken;
// NB: The success request is form-multipart (video bytes + optional
// `duration_millis` / `width` / `height` metadata fields), so its form struct
// lives with the actix handler; only the response shape is declared here.
/// Response body for `POST /v1/internal/minimax_jobs/job/{job_token}/success`.
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct MarkMinimaxJobSuccessResponse {
pub success: bool,
/// The media file created from the uploaded video.
pub media_file_token: MediaFileToken,
/// Video duration stored on the media file record, in milliseconds
/// (worker-supplied, or probed from the file when not supplied).
pub maybe_duration_millis: Option<u64>,
/// Frame width stored on the media file record, in pixels
/// (worker-supplied, or probed from the file when not supplied).
pub maybe_width: Option<u32>,
/// Frame height stored on the media file record, in pixels
/// (worker-supplied, or probed from the file when not supplied).
pub maybe_height: Option<u32>,
}
@@ -1,33 +0,0 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
/// The first-party Minimax models a worker can request jobs for.
///
/// This is deliberately its own enum (not `CommonVideoModel`) so the internal
/// worker API surface stays small and stable.
#[derive(Copy, Clone, Debug, Eq, PartialEq, Serialize, Deserialize, ToSchema)]
pub enum MinimaxWorkerModel {
#[serde(rename = "minimax_h3_turbo")]
MinimaxH3Turbo,
#[serde(rename = "minimax_h3_ultra")]
MinimaxH3Ultra,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn serde_round_trip() {
let cases = [
(MinimaxWorkerModel::MinimaxH3Turbo, "\"minimax_h3_turbo\""),
(MinimaxWorkerModel::MinimaxH3Ultra, "\"minimax_h3_ultra\""),
];
for (variant, json) in cases {
assert_eq!(serde_json::to_string(&variant).unwrap(), json);
let parsed: MinimaxWorkerModel = serde_json::from_str(json).unwrap();
assert_eq!(parsed, variant);
}
}
}
@@ -1,4 +0,0 @@
pub mod mark_minimax_job_failure;
pub mod mark_minimax_job_success;
pub mod minimax_worker_model;
pub mod obtain_minimax_job;
@@ -1,85 +0,0 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
use enums::by_table::prompt_context_items::prompt_context_semantic_type::PromptContextSemanticType;
use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
use enums::common::generation::common_bitrate::CommonBitrate;
use enums::common::generation::common_generation_mode::CommonGenerationMode;
use enums::common::generation::common_resolution::CommonResolution;
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use tokens::tokens::media_files::MediaFileToken;
use tokens::tokens::prompts::PromptToken;
use crate::common::responses::media_links::MediaLinks;
use crate::internal::minimax_jobs::minimax_worker_model::MinimaxWorkerModel;
/// Request body for `POST /v1/internal/minimax_jobs/obtain_job`.
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct ObtainMinimaxJobRequest {
/// Which model the worker wants a job for.
pub model: MinimaxWorkerModel,
/// The worker's hostname (linux hostname, k8s pod name), if reported.
pub worker_hostname: Option<String>,
/// The cluster the worker runs in (e.g. "runpod", "lambdalabs"), if reported.
pub cluster_name: Option<String>,
}
/// Response body for `POST /v1/internal/minimax_jobs/obtain_job`.
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct ObtainMinimaxJobResponse {
pub success: bool,
/// The obtained job, or `None` when no pending job is available.
pub maybe_job: Option<ObtainedMinimaxJob>,
}
/// A job the worker now holds a lock on (status was moved to `started`).
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct ObtainedMinimaxJob {
pub job_token: InferenceJobToken,
/// Echo of the requested model.
pub model: MinimaxWorkerModel,
/// The prompt and inputs the worker needs to run inference. Only `None`
/// when the job was somehow enqueued without a prompt record (or the
/// prompt lookup failed) — workers should fail such jobs via the failure
/// endpoint.
pub maybe_prompt: Option<MinimaxJobPromptDetails>,
}
/// The prompt (text + generation settings) and its attached media references.
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct MinimaxJobPromptDetails {
pub prompt_token: PromptToken,
pub maybe_positive_prompt: Option<String>,
pub maybe_negative_prompt: Option<String>,
pub maybe_generation_mode: Option<CommonGenerationMode>,
pub maybe_aspect_ratio: Option<CommonAspectRatio>,
pub maybe_resolution: Option<CommonResolution>,
pub maybe_bitrate: Option<CommonBitrate>,
pub maybe_generate_audio: Option<bool>,
pub maybe_duration_seconds: Option<u32>,
/// Image / video / audio references attached to the prompt, with full CDN
/// links, in their original order.
pub media_references: Vec<MinimaxJobMediaReference>,
}
/// One media input attached to the prompt (start frame, end frame, or an
/// image / video / audio reference), with full CDN links.
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct MinimaxJobMediaReference {
pub media_file_token: MediaFileToken,
/// The role the media plays (start frame, end frame, image / video / audio
/// reference, etc.).
pub semantic_type: PromptContextSemanticType,
/// Full CDN links to the media file.
pub media_links: MediaLinks,
}
@@ -1,4 +0,0 @@
//! Internal-facing API surfaces (worker fleets, etc.). These are
//! authenticated with internal API keys, never consumer credentials.
pub mod minimax_jobs;
@@ -5,7 +5,6 @@ pub mod common;
pub mod credits;
pub mod folders;
pub mod generate;
pub mod internal;
pub mod jobs;
pub mod media_file;
pub mod moderation;
@@ -1,61 +0,0 @@
//! Types for `GET /v1/media_files/by_job/{job_token}` — list the media files
//! produced by an inference job.
use chrono::{DateTime, Utc};
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
use enums::by_table::media_files::media_file_class::MediaFileClass;
use enums::by_table::media_files::media_file_type::MediaFileType;
use tokens::tokens::batch_generations::BatchGenerationToken;
use tokens::tokens::media_files::MediaFileToken;
use tokens::tokens::prompts::PromptToken;
use crate::common::responses::media_file_cover_image_details::MediaFileCoverImageDetails;
use crate::common::responses::media_links::MediaLinks;
#[derive(Serialize, Deserialize, ToSchema)]
pub struct ListMediaFilesByJobSuccessResponse {
pub success: bool,
/// The job's output files, oldest first.
pub media_files: Vec<JobMediaFileInfo>,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct JobMediaFileInfo {
pub token: MediaFileToken,
/// The coarse-grained class of media file: image, video, etc.
pub media_class: MediaFileClass,
/// Type of media will dictate which fields are populated and what
/// the frontend should display (eg. video player vs audio player).
/// This is closer in meaning to a "mime type".
pub media_type: MediaFileType,
/// If the file was generated as part of a batch, this is the token for the batch.
pub maybe_batch_token: Option<BatchGenerationToken>,
/// The foreign key to the prompt used to generate the media, if applicable.
pub maybe_prompt_token: Option<PromptToken>,
/// Rich CDN links to the media, including thumbnails, previews, and more.
pub media_links: MediaLinks,
/// Information about the cover image. Many media files do not require a cover image,
/// e.g. image files, video files with thumbnails, audio files, etc.
/// 3D files require them.
pub cover_image: MediaFileCoverImageDetails,
/// The original filename for uploaded files, if they were provided.
/// In the future we'll provide our own internal optional filenames.
pub maybe_original_filename: Option<String>,
/// Duration for audio and video files, if available.
/// Measured in milliseconds.
pub maybe_duration_millis: Option<u64>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
@@ -1 +0,0 @@
pub mod list_media_files_by_job;
@@ -1,5 +1,4 @@
pub mod delete_media_file;
pub mod job;
pub mod list;
pub mod list_batch_generated_media_files;
pub mod list_session_project_media_files;
@@ -14,12 +14,6 @@ pub enum RouterVideoModel {
#[serde(rename = "grok_imagine_video_1p5")]
GrokImagineVideo1p5,
#[serde(rename = "flux_3")]
Flux3,
#[serde(rename = "flux_3_draft")]
Flux3Draft,
#[serde(rename = "kling_1p6_pro")]
Kling16Pro,
@@ -44,12 +38,6 @@ pub enum RouterVideoModel {
#[serde(rename = "minimax_h3")]
MinimaxH3,
#[serde(rename = "minimax_h3_turbo")]
MinimaxH3Turbo,
#[serde(rename = "minimax_h3_ultra")]
MinimaxH3Ultra,
#[serde(rename = "seedance_1p0_lite")]
Seedance10Lite,
@@ -165,26 +153,10 @@ mod tests {
assert_serde_round_trip(RouterVideoModel::ViduQ3Turbo, "vidu_q3_turbo");
}
#[test]
fn flux_3_variants_serialize() {
// NB: These strings must match `CommonVideoModel` — the two enums convert
// via serde string round-trip in the omni handlers.
assert_serde_round_trip(RouterVideoModel::Flux3, "flux_3");
assert_serde_round_trip(RouterVideoModel::Flux3Draft, "flux_3_draft");
}
#[test]
fn minimax_h3_serializes() {
// NB: This string must match `CommonVideoModel` — the two enums convert
// via serde string round-trip in the omni handlers.
assert_serde_round_trip(RouterVideoModel::MinimaxH3, "minimax_h3");
}
#[test]
fn minimax_h3_turbo_and_ultra_serialize() {
// NB: These strings must match `CommonVideoModel` — the two enums convert
// via serde string round-trip in the omni handlers.
assert_serde_round_trip(RouterVideoModel::MinimaxH3Turbo, "minimax_h3_turbo");
assert_serde_round_trip(RouterVideoModel::MinimaxH3Ultra, "minimax_h3_ultra");
}
}
@@ -43,8 +43,6 @@ use crate::generate::generate_video::providers::artcraft::veo_3_fast::build::bui
use crate::generate::generate_video::providers::artcraft::veo_3p1::build::build_artcraft_veo_3p1;
use crate::generate::generate_video::providers::artcraft::veo_3p1_fast::build::build_artcraft_veo_3p1_fast;
use crate::generate::generate_video::providers::artcraft::veo_3p1_lite::build::build_artcraft_veo_3p1_lite;
use crate::generate::generate_video::providers::artcraft::flux_3::build::build_artcraft_flux_3;
use crate::generate::generate_video::providers::artcraft::flux_3_draft::build::build_artcraft_flux_3_draft;
use crate::generate::generate_video::providers::artcraft::minimax_h3::build::build_artcraft_minimax_h3;
use crate::generate::generate_video::providers::artcraft::vidu_q3::build::build_artcraft_vidu_q3;
use crate::generate::generate_video::providers::artcraft::vidu_q3_turbo::build::build_artcraft_vidu_q3_turbo;
@@ -71,8 +69,6 @@ use crate::generate::generate_video::providers::fal::veo_3_fast::build::build_fa
use crate::generate::generate_video::providers::fal::veo_3p1::build::build_fal_veo_3p1;
use crate::generate::generate_video::providers::fal::veo_3p1_fast::build::build_fal_veo_3p1_fast;
use crate::generate::generate_video::providers::fal::veo_3p1_lite::build::build_fal_veo_3p1_lite;
use crate::generate::generate_video::providers::fal::flux_3::build::build_fal_flux_3;
use crate::generate::generate_video::providers::fal::flux_3_draft::build::build_fal_flux_3_draft;
use crate::generate::generate_video::providers::fal::minimax_h3::build::build_fal_minimax_h3;
use crate::generate::generate_video::providers::fal::vidu_q3::build::build_fal_vidu_q3;
use crate::generate::generate_video::providers::fal::vidu_q3_turbo::build::build_fal_vidu_q3_turbo;
@@ -178,8 +174,6 @@ impl GenerateVideoRequestBuilder {
// Artcraft
(RouterProvider::Artcraft, RouterVideoModel::GrokImagineVideo) => build_artcraft_grok_imagine_video(self),
(RouterProvider::Artcraft, RouterVideoModel::GrokImagineVideo1p5) => build_artcraft_grok_imagine_video_1p5(self),
(RouterProvider::Artcraft, RouterVideoModel::Flux3) => build_artcraft_flux_3(self),
(RouterProvider::Artcraft, RouterVideoModel::Flux3Draft) => build_artcraft_flux_3_draft(self),
(RouterProvider::Artcraft, RouterVideoModel::HappyHorse1p0) => build_artcraft_happy_horse_1p0(self),
(RouterProvider::Artcraft, RouterVideoModel::Kling16Pro) => build_artcraft_kling_1_6_pro(self),
(RouterProvider::Artcraft, RouterVideoModel::Kling21Master) => build_artcraft_kling_2_1_master(self),
@@ -216,8 +210,6 @@ impl GenerateVideoRequestBuilder {
(RouterProvider::Artcraft, RouterVideoModel::ViduQ3) => build_artcraft_vidu_q3(self),
(RouterProvider::Artcraft, RouterVideoModel::ViduQ3Turbo) => build_artcraft_vidu_q3_turbo(self),
// Fal
(RouterProvider::Fal, RouterVideoModel::Flux3) => build_fal_flux_3(self),
(RouterProvider::Fal, RouterVideoModel::Flux3Draft) => build_fal_flux_3_draft(self),
(RouterProvider::Fal, RouterVideoModel::Kling16Pro) => build_fal_kling_1_6_pro(self),
(RouterProvider::Fal, RouterVideoModel::Kling21Master) => build_fal_kling_2_1_master(self),
(RouterProvider::Fal, RouterVideoModel::Kling21Pro) => build_fal_kling_2_1_pro(self),
@@ -1,64 +0,0 @@
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
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::providers::artcraft::build_common::{
build_artcraft_omni_video_request, SupportedResolutions, UltraWideSupport,
};
use crate::generate::generate_video::providers::artcraft::flux_3::request::ArtcraftFlux3RequestState;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
pub fn build_artcraft_flux_3(builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
let state = build_artcraft_flux_3_state(builder)?;
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftFlux3(state)))
}
pub(crate) fn build_artcraft_flux_3_state(mut builder: GenerateVideoRequestBuilder) -> Result<ArtcraftFlux3RequestState, ArtcraftRouterError> {
// Flux 3 accepts 5–20 seconds — wider than the generic helper's window —
// so pre-plan the duration and reapply it after (the helper would clamp
// 16–20s down). Also preserve generate_audio (the helper hardcodes None on
// its output).
let strategy = builder.request_mismatch_mitigation_strategy;
let final_duration = plan_flux_3_duration(builder.duration_seconds, strategy)?;
builder.duration_seconds = final_duration;
let generate_audio = builder.generate_audio;
let mut request = build_artcraft_omni_video_request(
builder,
CommonVideoModelEnum::Flux3,
SupportedResolutions::Full,
UltraWideSupport::Supported,
)?;
request.generate_audio = generate_audio;
request.duration_seconds = final_duration;
Ok(ArtcraftFlux3RequestState { request })
}
/// Flux 3 durations: 5–20 seconds (unset lets the model pick).
pub(crate) fn plan_flux_3_duration(
duration_seconds: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<u16>, ArtcraftRouterError> {
const MIN: u16 = 5;
const MAX: u16 = 20;
match duration_seconds {
None => Ok(None),
Some(d) if (MIN..=MAX).contains(&d) => Ok(Some(d)),
Some(other) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "duration_seconds",
value: format!("{}", other),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade
| RequestMismatchMitigationStrategy::PayLessDowngrade => {
Ok(Some(other.clamp(MIN, MAX)))
}
},
}
}
@@ -1,123 +0,0 @@
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
use crate::generate::generate_video::providers::artcraft::flux_3::request::ArtcraftFlux3RequestState;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
/// Per-second rates in hundredths of a US cent.
const LOW_RES_RATE_HUNDREDTH_CENTS_PER_SEC: u64 = 1_955;
const HIGH_RES_RATE_HUNDREDTH_CENTS_PER_SEC: u64 = 3_335;
#[derive(Clone, Debug)]
pub struct ArtcraftFlux3CostState {
pub duration_seconds: u64,
pub is_1080p: bool,
}
impl ArtcraftFlux3CostState {
pub fn from_request(request: &ArtcraftFlux3RequestState) -> Self {
Self {
// Flux 3 defaults None → 5s (the shortest selectable duration).
duration_seconds: request.request.duration_seconds.map(u64::from).unwrap_or(5),
is_1080p: is_1080p(request.request.resolution),
}
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
let rate = if self.is_1080p {
HIGH_RES_RATE_HUNDREDTH_CENTS_PER_SEC
} else {
LOW_RES_RATE_HUNDREDTH_CENTS_PER_SEC
};
// Round up to the next whole cent.
let cost_in_usd_cents = (rate * self.duration_seconds).div_ceil(100);
VideoGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
cost_in_usd_cents: Some(cost_in_usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
}
/// Flux 3 renders 720p or 1080p; 1080p and above land on the higher tier,
/// while 720p and below render at 720p. The default (unset) resolution is
/// 720p.
fn is_1080p(resolution: Option<CommonResolutionEnum>) -> bool {
match resolution {
None => false, // defaults to 720p
Some(CommonResolutionEnum::TenEightyP)
| Some(CommonResolutionEnum::OneK)
| Some(CommonResolutionEnum::TwoK)
| Some(CommonResolutionEnum::ThreeK)
| Some(CommonResolutionEnum::FourK) => true,
Some(CommonResolutionEnum::HalfK)
| Some(CommonResolutionEnum::FourEightyP)
| Some(CommonResolutionEnum::SevenTwentyP) => false,
}
}
#[cfg(test)]
mod tests {
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
use crate::api::router_provider::RouterProvider;
use crate::api::router_resolution::RouterResolution;
use crate::api::router_video_model::RouterVideoModel;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::providers::artcraft::flux_3::build::build_artcraft_flux_3_state;
use crate::generate::generate_video::providers::artcraft::flux_3::cost::ArtcraftFlux3CostState;
#[test]
fn default_settings_5s_is_98() { assert_eq!(cost_cents(Some(5), None), 98); }
#[test]
fn default_duration_is_5s() { assert_eq!(cost_cents(None, None), 98); }
#[test]
fn low_res_20s_is_391() { assert_eq!(cost_cents(Some(20), Some(RouterResolution::SevenTwentyP)), 391); }
#[test]
fn four_eighty_p_lands_on_low_tier() {
assert_eq!(cost_cents(Some(5), Some(RouterResolution::FourEightyP)), 98);
}
#[test]
fn high_res_5s_is_167() { assert_eq!(cost_cents(Some(5), Some(RouterResolution::TenEightyP)), 167); }
#[test]
fn high_res_10s_is_334() { assert_eq!(cost_cents(Some(10), Some(RouterResolution::TenEightyP)), 334); }
#[test]
fn odd_duration_rounds_up_to_whole_cents() {
// 7s low res: 1955 × 7 = 13685 hundredth-cents → 137 cents.
assert_eq!(cost_cents(Some(7), None), 137);
}
#[test]
fn resolution_classifier_defaults_720p() {
assert!(!super::is_1080p(None));
assert!(super::is_1080p(Some(CommonResolutionEnum::TenEightyP)));
assert!(!super::is_1080p(Some(CommonResolutionEnum::SevenTwentyP)));
assert!(!super::is_1080p(Some(CommonResolutionEnum::FourEightyP)));
}
fn cost_cents(
duration_seconds: Option<u16>,
resolution: Option<RouterResolution>,
) -> u64 {
let b = GenerateVideoRequestBuilder {
model: RouterVideoModel::Flux3,
provider: RouterProvider::Artcraft,
prompt: Some("test".to_string()),
duration_seconds,
resolution,
..Default::default()
};
let state = build_artcraft_flux_3_state(b).unwrap();
ArtcraftFlux3CostState::from_request(&state).estimate_cost().cost_in_usd_cents.unwrap()
}
}
@@ -1,3 +0,0 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -1,104 +0,0 @@
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::providers::artcraft::request_common::send_artcraft_omni_video_request;
#[derive(Clone, Debug)]
pub struct ArtcraftFlux3RequestState {
pub request: OmniGenVideoCostAndGenerateRequest,
}
impl ArtcraftFlux3RequestState {
pub async fn send(&self, client: &RouterArtcraftClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
send_artcraft_omni_video_request(&self.request, client).await
}
}
#[cfg(test)]
mod tests {
use tokens::tokens::media_files::MediaFileToken;
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
use artcraft_client::utils::api_host::ApiHost;
use test_data::web::image_media_tokens::JUNO_AT_LAKE_PRODUCTION_MEDIA_TOKEN;
use crate::api::image_ref::ImageRef;
use crate::api::router_aspect_ratio::RouterAspectRatio;
use crate::api::router_provider::RouterProvider;
use crate::api::router_video_model::RouterVideoModel;
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::client::router_client::RouterClient;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
#[tokio::test]
#[ignore] // sends a real generation to the Artcraft backend, incurs cost
async fn text_to_video() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A red kite soaring over coastal cliffs, cinematic.".to_string()),
aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine),
..artcraft_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore] // sends a real generation to the Artcraft backend, incurs cost
async fn keyframe_start_frame() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("The dog stands up and walks toward the lake.".to_string()),
start_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new(JUNO_AT_LAKE_PRODUCTION_MEDIA_TOKEN.to_string()))),
..artcraft_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
// ── Helpers ──
fn artcraft_builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
model: RouterVideoModel::Flux3,
provider: RouterProvider::Artcraft,
duration_seconds: Some(5),
video_batch_count: Some(1),
..Default::default()
}
}
fn get_artcraft_client() -> RouterClient {
let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/artcraft_cookies.txt")
.expect("Failed to read artcraft cookies");
let cookies = cookies.trim().to_string();
let credentials = StorytellerCredentialSet::parse_multi_cookie_header(&cookies)
.expect("Failed to parse cookies")
.expect("No credentials found");
RouterClient::Artcraft(RouterArtcraftClient::new(ApiHost::Storyteller, credentials))
}
async fn run_pipeline(builder: GenerateVideoRequestBuilder) -> GenerateVideoResponse {
let client = get_artcraft_client();
let draft_or_request = builder.build2().expect("build2 should succeed");
let request = match draft_or_request {
VideoGenerationDraftOrRequest::Request(r) => r,
_ => panic!("expected Request variant (Artcraft skips draft)"),
};
let response = request.send_request(&client).await.expect("send_request should succeed");
match &response {
GenerateVideoResponse::Artcraft(p) => {
println!("inference_job_token={:?}", p.inference_job_token);
println!("all_inference_job_tokens={:?}", p.all_inference_job_tokens);
}
other => println!("response: {:?}", other),
}
response
}
}
@@ -1,37 +0,0 @@
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::providers::artcraft::build_common::{
build_artcraft_omni_video_request, SupportedResolutions, UltraWideSupport,
};
use crate::generate::generate_video::providers::artcraft::flux_3::build::plan_flux_3_duration;
use crate::generate::generate_video::providers::artcraft::flux_3_draft::request::ArtcraftFlux3DraftRequestState;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
pub fn build_artcraft_flux_3_draft(builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
let state = build_artcraft_flux_3_draft_state(builder)?;
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftFlux3Draft(state)))
}
pub(crate) fn build_artcraft_flux_3_draft_state(mut builder: GenerateVideoRequestBuilder) -> Result<ArtcraftFlux3DraftRequestState, ArtcraftRouterError> {
// Same duration handling as full-quality Flux 3: 5–20 seconds is wider than
// the generic helper's window, so pre-plan and reapply after. Also preserve
// generate_audio (the helper hardcodes None on its output).
let strategy = builder.request_mismatch_mitigation_strategy;
let final_duration = plan_flux_3_duration(builder.duration_seconds, strategy)?;
builder.duration_seconds = final_duration;
let generate_audio = builder.generate_audio;
let mut request = build_artcraft_omni_video_request(
builder,
CommonVideoModelEnum::Flux3Draft,
SupportedResolutions::Full,
UltraWideSupport::Supported,
)?;
request.generate_audio = generate_audio;
request.duration_seconds = final_duration;
Ok(ArtcraftFlux3DraftRequestState { request })
}
@@ -1,86 +0,0 @@
use crate::generate::generate_video::providers::artcraft::flux_3_draft::request::ArtcraftFlux3DraftRequestState;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
/// Per-second rate in hundredths of a US cent. Drafts always render 720p, so
/// resolution does not affect the bill.
const GENERATION_RATE_HUNDREDTH_CENTS_PER_SEC: u64 = 690;
#[derive(Clone, Debug)]
pub struct ArtcraftFlux3DraftCostState {
pub duration_seconds: u64,
}
impl ArtcraftFlux3DraftCostState {
pub fn from_request(request: &ArtcraftFlux3DraftRequestState) -> Self {
Self {
// Flux 3 Draft defaults None → 5s (the shortest selectable duration).
duration_seconds: request.request.duration_seconds.map(u64::from).unwrap_or(5),
}
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
// Round up to the next whole cent.
let cost_in_usd_cents =
(GENERATION_RATE_HUNDREDTH_CENTS_PER_SEC * self.duration_seconds).div_ceil(100);
VideoGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
cost_in_usd_cents: Some(cost_in_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::router_provider::RouterProvider;
use crate::api::router_resolution::RouterResolution;
use crate::api::router_video_model::RouterVideoModel;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::providers::artcraft::flux_3_draft::build::build_artcraft_flux_3_draft_state;
use crate::generate::generate_video::providers::artcraft::flux_3_draft::cost::ArtcraftFlux3DraftCostState;
#[test]
fn default_settings_5s_is_35() { assert_eq!(cost_cents(Some(5), None), 35); }
#[test]
fn default_duration_is_5s() { assert_eq!(cost_cents(None, None), 35); }
#[test]
fn twenty_seconds_is_138() { assert_eq!(cost_cents(Some(20), None), 138); }
/// Drafts always render 720p; a requested resolution does not change the
/// bill.
#[test]
fn cost_ignores_resolution() {
for resolution in [None, Some(RouterResolution::SevenTwentyP), Some(RouterResolution::TenEightyP)] {
assert_eq!(cost_cents(Some(5), resolution), 35, "resolution={resolution:?}");
}
}
#[test]
fn odd_duration_rounds_up_to_whole_cents() {
// 7s generation: 690 × 7 = 4830 hundredth-cents → 49 cents.
assert_eq!(cost_cents(Some(7), None), 49);
}
fn cost_cents(
duration_seconds: Option<u16>,
resolution: Option<RouterResolution>,
) -> u64 {
let b = GenerateVideoRequestBuilder {
model: RouterVideoModel::Flux3Draft,
provider: RouterProvider::Artcraft,
prompt: Some("test".to_string()),
duration_seconds,
resolution,
..Default::default()
};
let state = build_artcraft_flux_3_draft_state(b).unwrap();
ArtcraftFlux3DraftCostState::from_request(&state).estimate_cost().cost_in_usd_cents.unwrap()
}
}
@@ -1,3 +0,0 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -1,88 +0,0 @@
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::providers::artcraft::request_common::send_artcraft_omni_video_request;
#[derive(Clone, Debug)]
pub struct ArtcraftFlux3DraftRequestState {
pub request: OmniGenVideoCostAndGenerateRequest,
}
impl ArtcraftFlux3DraftRequestState {
pub async fn send(&self, client: &RouterArtcraftClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
send_artcraft_omni_video_request(&self.request, client).await
}
}
#[cfg(test)]
mod tests {
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
use artcraft_client::utils::api_host::ApiHost;
use crate::api::router_aspect_ratio::RouterAspectRatio;
use crate::api::router_provider::RouterProvider;
use crate::api::router_video_model::RouterVideoModel;
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::client::router_client::RouterClient;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
#[tokio::test]
#[ignore] // sends a real generation to the Artcraft backend, incurs cost
async fn text_to_video() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A red kite soaring over coastal cliffs, cinematic.".to_string()),
aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine),
..artcraft_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
// ── Helpers ──
fn artcraft_builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
model: RouterVideoModel::Flux3Draft,
provider: RouterProvider::Artcraft,
duration_seconds: Some(5),
video_batch_count: Some(1),
..Default::default()
}
}
fn get_artcraft_client() -> RouterClient {
let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/artcraft_cookies.txt")
.expect("Failed to read artcraft cookies");
let cookies = cookies.trim().to_string();
let credentials = StorytellerCredentialSet::parse_multi_cookie_header(&cookies)
.expect("Failed to parse cookies")
.expect("No credentials found");
RouterClient::Artcraft(RouterArtcraftClient::new(ApiHost::Storyteller, credentials))
}
async fn run_pipeline(builder: GenerateVideoRequestBuilder) -> GenerateVideoResponse {
let client = get_artcraft_client();
let draft_or_request = builder.build2().expect("build2 should succeed");
let request = match draft_or_request {
VideoGenerationDraftOrRequest::Request(r) => r,
_ => panic!("expected Request variant (Artcraft skips draft)"),
};
let response = request.send_request(&client).await.expect("send_request should succeed");
match &response {
GenerateVideoResponse::Artcraft(p) => {
println!("inference_job_token={:?}", p.inference_job_token);
println!("all_inference_job_tokens={:?}", p.all_inference_job_tokens);
}
other => println!("response: {:?}", other),
}
response
}
}
@@ -4,8 +4,6 @@ pub(crate) mod seedance_common;
mod parity_tests;
pub mod grok_imagine_video;
pub mod grok_imagine_video_1p5;
pub mod flux_3;
pub mod flux_3_draft;
pub mod happy_horse_1p0;
pub mod kling_1_6_pro;
pub mod kling_2_1_master;
@@ -1,416 +0,0 @@
use fal_client::requests::api::video::image::flux_3::api::Flux3ImageToVideoRequest;
use fal_client::requests::api::video::images::flux_3::api::Flux3FirstLastFrameToVideoRequest;
use fal_client::requests::api::video::text::flux_3::api::{
Flux3AspectRatio, Flux3Duration, Flux3Resolution, Flux3TextToVideoRequest,
};
use crate::api::audio_list_ref::AudioListRef;
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::router_aspect_ratio::RouterAspectRatio;
use crate::api::router_resolution::RouterResolution;
use crate::api::video_list_ref::VideoListRef;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::providers::fal::flux_3::request::{
FalFlux3Mode, FalFlux3RequestState,
};
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
pub(crate) const MIN_DURATION_SECONDS: u16 = 5;
pub(crate) const MAX_DURATION_SECONDS: u16 = 20;
#[derive(Debug, Clone, Copy)]
pub(crate) enum PlanAspectRatio {
Auto,
TwentyOneByNine,
SixteenByNine,
FourByThree,
Square,
ThreeByFour,
NineBySixteen,
}
pub fn build_fal_flux_3(
builder: GenerateVideoRequestBuilder,
) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
let state = build_fal_flux_3_state(builder)?;
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalFlux3(state)))
}
pub(crate) fn build_fal_flux_3_state(
builder: GenerateVideoRequestBuilder,
) -> Result<FalFlux3RequestState, ArtcraftRouterError> {
let strategy = builder.request_mismatch_mitigation_strategy;
// Flux 3 exposes no reference modality here — only text, starting keyframe,
// and start/end keyframes.
reject_references(&builder)?;
let start = optional_url(builder.start_frame.clone())?;
let end = optional_url(builder.end_frame.clone())?;
let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio, strategy)?;
let resolution = plan_resolution(builder.resolution, strategy)?;
let flexible_duration = plan_flexible_duration(builder.duration_seconds, strategy)?;
let fixed_duration = plan_fixed_duration(builder.duration_seconds, strategy)?;
let prompt = builder.prompt.clone().unwrap_or_default();
let generate_audio = builder.generate_audio;
// Modality dispatch:
// start + end frames → first-last-frame-to-video
// start frame only → image-to-video
// no media → text-to-video
let mode = match (start, end) {
(None, None) => FalFlux3Mode::TextToVideo(Flux3TextToVideoRequest {
prompt,
duration: flexible_duration,
resolution,
aspect_ratio: aspect_ratio.map(to_flux_3_aspect_ratio),
generate_audio,
safety_tolerance: Some(4), // NB: 4 is the most permissive.
}),
(Some(image_url), None) => FalFlux3Mode::ImageToVideo(Flux3ImageToVideoRequest {
prompt,
image_url,
duration: flexible_duration,
resolution,
aspect_ratio: aspect_ratio.map(to_flux_3_aspect_ratio),
generate_audio,
safety_tolerance: Some(4), // NB: 4 is the most permissive.
}),
(Some(start_image_url), Some(end_image_url)) => FalFlux3Mode::FirstLastFrameToVideo(
Flux3FirstLastFrameToVideoRequest {
prompt,
start_image_url,
end_image_url,
duration: fixed_duration,
resolution,
aspect_ratio: aspect_ratio.map(to_flux_3_aspect_ratio),
generate_audio,
safety_tolerance: Some(4), // NB: 4 is the most permissive.
},
),
(None, Some(_)) => {
return Err(unsupported(
"end_frame",
"Flux 3 requires a start_frame when end_frame is provided",
));
}
};
Ok(FalFlux3RequestState { mode })
}
// ── Input helpers (shared with the draft variant) ──
pub(crate) fn unsupported(field: &'static str, value: &str) -> ArtcraftRouterError {
ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field,
value: value.to_string(),
})
}
/// Flux 3 supports no reference inputs — no image, video, or audio references.
pub(crate) fn reject_references(builder: &GenerateVideoRequestBuilder) -> Result<(), ArtcraftRouterError> {
let has_reference_images = match &builder.reference_images {
None => false,
Some(ImageListRef::Urls(urls)) => !urls.is_empty(),
Some(ImageListRef::MediaFileTokens(tokens)) => !tokens.is_empty(),
};
if has_reference_images {
return Err(unsupported("reference_images", "Flux 3 does not support image references"));
}
let has_reference_videos = match &builder.reference_videos {
None => false,
Some(VideoListRef::Urls(urls)) => !urls.is_empty(),
Some(VideoListRef::MediaFileTokens(tokens)) => !tokens.is_empty(),
};
if has_reference_videos {
return Err(unsupported("reference_videos", "Flux 3 does not support video references"));
}
let has_reference_audio = match &builder.reference_audio {
None => false,
Some(AudioListRef::Urls(urls)) => !urls.is_empty(),
Some(AudioListRef::MediaFileTokens(tokens)) => !tokens.is_empty(),
};
if has_reference_audio {
return Err(unsupported("reference_audio", "Flux 3 does not support audio references"));
}
Ok(())
}
pub(crate) fn optional_url(image_ref: Option<ImageRef>) -> Result<Option<String>, ArtcraftRouterError> {
match image_ref {
None => Ok(None),
Some(ImageRef::Url(url)) => Ok(Some(url)),
Some(ImageRef::MediaFileToken(_)) => {
Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls))
}
}
}
// ── Plan helpers (shared with the draft variant) ──
pub(crate) fn plan_aspect_ratio(
aspect_ratio: Option<RouterAspectRatio>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<PlanAspectRatio>, ArtcraftRouterError> {
use PlanAspectRatio as Ar;
match aspect_ratio {
None => Ok(None),
Some(RouterAspectRatio::Auto)
| Some(RouterAspectRatio::Auto2k)
| Some(RouterAspectRatio::Auto3k)
| Some(RouterAspectRatio::Auto4k) => Ok(Some(Ar::Auto)),
Some(RouterAspectRatio::WideTwentyOneByNine) => Ok(Some(Ar::TwentyOneByNine)),
Some(RouterAspectRatio::WideSixteenByNine) | Some(RouterAspectRatio::Wide) => Ok(Some(Ar::SixteenByNine)),
Some(RouterAspectRatio::TallNineBySixteen) | Some(RouterAspectRatio::Tall) => Ok(Some(Ar::NineBySixteen)),
Some(RouterAspectRatio::WideFourByThree) => Ok(Some(Ar::FourByThree)),
Some(RouterAspectRatio::TallThreeByFour) => Ok(Some(Ar::ThreeByFour)),
Some(RouterAspectRatio::Square) | Some(RouterAspectRatio::SquareHd) => Ok(Some(Ar::Square)),
Some(other) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(unsupported("aspect_ratio", &format!("{:?}", other)))
}
_ => Ok(Some(Ar::Auto)),
},
}
}
/// Flux 3 renders 720p or 1080p; 480p rounds up to 720p (same billing tier).
pub(crate) fn plan_resolution(
resolution: Option<RouterResolution>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<Flux3Resolution>, ArtcraftRouterError> {
match resolution {
None => Ok(None),
Some(RouterResolution::FourEightyP)
| Some(RouterResolution::SevenTwentyP) => Ok(Some(Flux3Resolution::SevenTwentyP)),
Some(RouterResolution::TenEightyP) => Ok(Some(Flux3Resolution::TenEightyP)),
Some(other) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(unsupported("resolution", &format!("{:?}", other)))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(Some(Flux3Resolution::TenEightyP)),
RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Some(Flux3Resolution::SevenTwentyP)),
},
}
}
/// Duration for the modalities that accept `auto` (text, image). `None` stays
/// unset so fal's `auto` default applies.
pub(crate) fn plan_flexible_duration(
duration_seconds: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<Flux3Duration>, ArtcraftRouterError> {
Ok(plan_fixed_duration(duration_seconds, strategy)?.map(Flux3Duration::Seconds))
}
/// Duration for the fixed-duration first-last-frame modality. `None` stays
/// unset so fal's 5-second default applies.
pub(crate) fn plan_fixed_duration(
duration_seconds: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<u8>, ArtcraftRouterError> {
match duration_seconds {
None => Ok(None),
Some(seconds) if (MIN_DURATION_SECONDS..=MAX_DURATION_SECONDS).contains(&seconds) => {
Ok(Some(seconds as u8))
}
Some(other) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(unsupported("duration_seconds", &format!("{}", other)))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade
| RequestMismatchMitigationStrategy::PayLessDowngrade => {
Ok(Some(other.clamp(MIN_DURATION_SECONDS, MAX_DURATION_SECONDS) as u8))
}
},
}
}
// ── Leaf converters ──
pub(crate) fn to_flux_3_aspect_ratio(a: PlanAspectRatio) -> Flux3AspectRatio {
match a {
PlanAspectRatio::Auto => Flux3AspectRatio::Auto,
PlanAspectRatio::TwentyOneByNine => Flux3AspectRatio::TwentyOneByNine,
PlanAspectRatio::SixteenByNine => Flux3AspectRatio::SixteenByNine,
PlanAspectRatio::FourByThree => Flux3AspectRatio::FourByThree,
PlanAspectRatio::Square => Flux3AspectRatio::Square,
PlanAspectRatio::ThreeByFour => Flux3AspectRatio::ThreeByFour,
PlanAspectRatio::NineBySixteen => Flux3AspectRatio::NineBySixteen,
}
}
#[cfg(test)]
mod tests {
use crate::api::router_provider::RouterProvider;
use crate::api::router_video_model::RouterVideoModel;
use super::*;
const START_URL: &str = "https://example.com/start.png";
const END_URL: &str = "https://example.com/end.png";
mod dispatch_tests {
use super::*;
#[test]
fn no_inputs_picks_t2v() {
let state = build_fal_flux_3_state(base_builder()).expect("build");
assert!(matches!(state.mode, FalFlux3Mode::TextToVideo(_)));
}
#[test]
fn start_frame_picks_i2v() {
let mut b = base_builder();
b.start_frame = Some(ImageRef::Url(START_URL.to_string()));
let state = build_fal_flux_3_state(b).expect("build");
assert!(matches!(state.mode, FalFlux3Mode::ImageToVideo(_)));
}
#[test]
fn start_and_end_frames_pick_first_last_frame() {
let mut b = base_builder();
b.start_frame = Some(ImageRef::Url(START_URL.to_string()));
b.end_frame = Some(ImageRef::Url(END_URL.to_string()));
let state = build_fal_flux_3_state(b).expect("build");
let FalFlux3Mode::FirstLastFrameToVideo(req) = state.mode else {
panic!("expected FirstLastFrameToVideo");
};
assert_eq!(req.start_image_url, START_URL);
assert_eq!(req.end_image_url, END_URL);
}
#[test]
fn end_frame_without_start_frame_errors() {
let mut b = base_builder();
b.end_frame = Some(ImageRef::Url(END_URL.to_string()));
assert!(build_fal_flux_3_state(b).is_err());
}
#[test]
fn reference_images_error() {
let mut b = base_builder();
b.reference_images = Some(ImageListRef::Urls(vec!["https://example.com/ref.png".to_string()]));
assert!(build_fal_flux_3_state(b).is_err());
}
#[test]
fn reference_videos_error() {
let mut b = base_builder();
b.reference_videos = Some(VideoListRef::Urls(vec!["https://example.com/v.mp4".to_string()]));
assert!(build_fal_flux_3_state(b).is_err());
}
#[test]
fn reference_audio_errors() {
let mut b = base_builder();
b.reference_audio = Some(AudioListRef::Urls(vec!["https://example.com/a.mp3".to_string()]));
assert!(build_fal_flux_3_state(b).is_err());
}
#[test]
fn empty_reference_lists_are_ignored() {
let mut b = base_builder();
b.reference_images = Some(ImageListRef::Urls(vec![]));
b.reference_videos = Some(VideoListRef::Urls(vec![]));
b.reference_audio = Some(AudioListRef::Urls(vec![]));
let state = build_fal_flux_3_state(b).expect("build");
assert!(matches!(state.mode, FalFlux3Mode::TextToVideo(_)));
}
}
mod plan_tests {
use super::*;
#[test]
fn duration_25_errors_under_error_out() {
let mut b = base_builder();
b.duration_seconds = Some(25);
b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::ErrorOut;
assert!(build_fal_flux_3_state(b).is_err());
}
#[test]
fn duration_25_clamps_to_20_under_pay_less() {
let mut b = base_builder();
b.duration_seconds = Some(25);
b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::PayLessDowngrade;
let state = build_fal_flux_3_state(b).expect("build");
let FalFlux3Mode::TextToVideo(req) = state.mode else {
panic!("expected TextToVideo");
};
assert_eq!(req.duration, Some(Flux3Duration::Seconds(20)));
}
#[test]
fn resolution_480p_and_720p_map_to_720p() {
for res in [RouterResolution::FourEightyP, RouterResolution::SevenTwentyP] {
let mut b = base_builder();
b.resolution = Some(res);
let state = build_fal_flux_3_state(b).expect("build");
let FalFlux3Mode::TextToVideo(req) = state.mode else {
panic!("expected TextToVideo");
};
assert_eq!(req.resolution, Some(Flux3Resolution::SevenTwentyP), "res={res:?}");
}
}
#[test]
fn auto_aspect_ratio_maps_to_flux_auto() {
let mut b = base_builder();
b.aspect_ratio = Some(RouterAspectRatio::Auto);
let state = build_fal_flux_3_state(b).expect("build");
let FalFlux3Mode::TextToVideo(req) = state.mode else {
panic!("expected TextToVideo");
};
assert_eq!(req.aspect_ratio, Some(Flux3AspectRatio::Auto));
}
#[test]
fn twenty_one_by_nine_is_supported() {
let mut b = base_builder();
b.aspect_ratio = Some(RouterAspectRatio::WideTwentyOneByNine);
b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::ErrorOut;
let state = build_fal_flux_3_state(b).expect("build");
let FalFlux3Mode::TextToVideo(req) = state.mode else {
panic!("expected TextToVideo");
};
assert_eq!(req.aspect_ratio, Some(Flux3AspectRatio::TwentyOneByNine));
}
#[test]
fn generate_audio_propagates() {
let mut b = base_builder();
b.generate_audio = Some(false);
let state = build_fal_flux_3_state(b).expect("build");
let FalFlux3Mode::TextToVideo(req) = state.mode else {
panic!("expected TextToVideo");
};
assert_eq!(req.generate_audio, Some(false));
}
}
fn base_builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
model: RouterVideoModel::Flux3,
provider: RouterProvider::Fal,
prompt: Some("test".to_string()),
..Default::default()
}
}
}
@@ -1,106 +0,0 @@
use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator;
use crate::generate::generate_video::providers::fal::flux_3::request::{
FalFlux3Mode, FalFlux3RequestState,
};
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
#[derive(Clone, Debug)]
pub struct FalFlux3CostState {
pub cost_in_usd_cents: u64,
}
impl FalFlux3CostState {
pub fn from_request(request: &FalFlux3RequestState) -> Self {
// Cost math is owned by fal_client's per-endpoint
// `FalRequestCostCalculator` implementations. The router state just
// forwards the result so router cost ≡ fal_client cost by construction.
let cost_in_usd_cents = match &request.mode {
FalFlux3Mode::TextToVideo(req) => req.calculate_cost_in_cents(),
FalFlux3Mode::ImageToVideo(req) => req.calculate_cost_in_cents(),
FalFlux3Mode::FirstLastFrameToVideo(req) => req.calculate_cost_in_cents(),
};
Self { cost_in_usd_cents }
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
VideoGenerationCostEstimate {
cost_in_credits: Some(self.cost_in_usd_cents),
cost_in_usd_cents: Some(self.cost_in_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::image_ref::ImageRef;
use crate::api::router_provider::RouterProvider;
use crate::api::router_resolution::RouterResolution;
use crate::api::router_video_model::RouterVideoModel;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::providers::fal::flux_3::build::build_fal_flux_3_state;
use super::*;
// Pricing (from fal_client's flux_3 cost modules):
// 720p: $0.17/s, 1080p: $0.29/s (all modalities)
// fal defaults when unset: duration = 5s (auto estimates at the 5s floor),
// resolution = 720p.
#[test]
fn t2v_default_settings_is_85() {
assert_eq!(cost_cents(base_builder(None, None)), 85);
}
#[test]
fn t2v_10s_1080p_is_290() {
assert_eq!(cost_cents(base_builder(Some(10), Some(RouterResolution::TenEightyP))), 290);
}
#[test]
fn i2v_5s_720p_is_85() {
let mut b = base_builder(Some(5), Some(RouterResolution::SevenTwentyP));
b.start_frame = Some(ImageRef::Url("https://example.com/start.png".to_string()));
assert_eq!(cost_cents(b), 85);
}
#[test]
fn first_last_frame_20s_1080p_is_580() {
let mut b = base_builder(Some(20), Some(RouterResolution::TenEightyP));
b.start_frame = Some(ImageRef::Url("https://example.com/start.png".to_string()));
b.end_frame = Some(ImageRef::Url("https://example.com/end.png".to_string()));
assert_eq!(cost_cents(b), 580);
}
#[test]
fn estimate_cost_forwards_cents() {
let estimate = FalFlux3CostState { cost_in_usd_cents: 85 }.estimate_cost();
assert_eq!(estimate.cost_in_usd_cents, Some(85));
assert_eq!(estimate.cost_in_credits, Some(85));
assert!(!estimate.is_free);
}
fn base_builder(
duration_seconds: Option<u16>,
resolution: Option<RouterResolution>,
) -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
model: RouterVideoModel::Flux3,
provider: RouterProvider::Fal,
prompt: Some("test".to_string()),
duration_seconds,
resolution,
..Default::default()
}
}
fn cost_cents(builder: GenerateVideoRequestBuilder) -> u64 {
let state = build_fal_flux_3_state(builder).expect("build");
FalFlux3CostState::from_request(&state).cost_in_usd_cents
}
}
@@ -1,3 +0,0 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -1,144 +0,0 @@
use std::fmt::Debug;
use std::sync::Arc;
use fal_client::requests::api::video::image::flux_3::api::Flux3ImageToVideoRequest;
use fal_client::requests::api::video::images::flux_3::api::Flux3FirstLastFrameToVideoRequest;
use fal_client::requests::api::video::text::flux_3::api::Flux3TextToVideoRequest;
use fal_client::requests::traits::fal_endpoint_trait::FalEndpoint;
use crate::client::router_fal_client::RouterFalClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video::generate_video_response::{
FalVideoResponsePayload, GenerateVideoResponse,
};
#[derive(Clone, Debug)]
pub enum FalFlux3Mode {
TextToVideo(Flux3TextToVideoRequest),
ImageToVideo(Flux3ImageToVideoRequest),
FirstLastFrameToVideo(Flux3FirstLastFrameToVideoRequest),
}
#[derive(Clone, Debug)]
pub struct FalFlux3RequestState {
pub mode: FalFlux3Mode,
}
impl FalFlux3RequestState {
pub async fn send(&self, client: &RouterFalClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
match &self.mode {
FalFlux3Mode::TextToVideo(request) => send_request(request, client).await,
FalFlux3Mode::ImageToVideo(request) => send_request(request, client).await,
FalFlux3Mode::FirstLastFrameToVideo(request) => send_request(request, client).await,
}
}
}
// ── Helpers ──
async fn send_request<T>(request: &T, client: &RouterFalClient) -> Result<GenerateVideoResponse, ArtcraftRouterError>
where
T: FalEndpoint + Clone + Debug + Send + Sync + 'static,
{
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
let payload = send_fal_request(request, client).await?;
Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload {
request_id: payload.request_id,
gateway_request_id: payload.gateway_request_id,
maybe_status_url: payload.status_url,
maybe_response_url: payload.response_url,
maybe_outbound_request: Some(outbound),
}))
}
struct FalResponseIds {
request_id: Option<String>,
gateway_request_id: Option<String>,
status_url: Option<String>,
response_url: Option<String>,
}
async fn send_fal_request<T: FalEndpoint>(
request: &T,
client: &RouterFalClient,
) -> Result<FalResponseIds, ArtcraftRouterError> {
if let Some(webhook_url) = &client.webhook_url {
let response = request.send_webhook_request(&client.api_key, webhook_url).await?;
Ok(FalResponseIds {
request_id: response.request_id,
gateway_request_id: response.gateway_request_id,
status_url: None,
response_url: None,
})
} else {
let response = request.send_queue_request(&client.api_key).await?;
Ok(FalResponseIds {
request_id: Some(response.request_id),
gateway_request_id: None,
status_url: Some(response.status_url),
response_url: Some(response.response_url),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use fal_client::creds::fal_api_key::FalApiKey;
use fal_client::requests::api::video::text::flux_3::api::{
Flux3AspectRatio, Flux3Duration, Flux3Resolution,
};
use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL;
#[tokio::test]
#[ignore] // requires real API key, incurs cost
async fn send_text_to_video_webhook() {
let state = FalFlux3RequestState {
mode: FalFlux3Mode::TextToVideo(Flux3TextToVideoRequest {
prompt: "a calm lake at sunrise".to_string(),
duration: Some(Flux3Duration::Seconds(5)),
resolution: Some(Flux3Resolution::SevenTwentyP),
aspect_ratio: Some(Flux3AspectRatio::SixteenByNine),
generate_audio: Some(false),
safety_tolerance: Some(4), // NB: 4 is the most permissive.
}),
};
let response = state.send(&client_with_webhook()).await.expect("send should succeed");
let payload = response.get_fal_payload().expect("expected Fal payload");
assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some());
}
#[tokio::test]
#[ignore] // requires real API key, incurs cost
async fn send_image_to_video_webhook() {
let state = FalFlux3RequestState {
mode: FalFlux3Mode::ImageToVideo(Flux3ImageToVideoRequest {
prompt: "the lake comes alive".to_string(),
image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(),
duration: Some(Flux3Duration::Seconds(5)),
resolution: Some(Flux3Resolution::SevenTwentyP),
aspect_ratio: None,
generate_audio: Some(false),
safety_tolerance: None,
}),
};
let response = state.send(&client_with_webhook()).await.expect("send should succeed");
let payload = response.get_fal_payload().expect("expected Fal payload");
assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some());
}
// ── Helpers ──
fn read_fal_api_key() -> FalApiKey {
let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")
.expect("Failed to read fal_api_key.txt");
FalApiKey::from_str(secret.trim())
}
fn client_with_webhook() -> RouterFalClient {
RouterFalClient::new_with_webhook(
read_fal_api_key(),
"https://example.com/fal-webhook-test".to_string(),
)
}
}
@@ -1,151 +0,0 @@
use fal_client::requests::api::video::image::flux_3_draft::api::Flux3DraftImageToVideoRequest;
use fal_client::requests::api::video::images::flux_3_draft::api::Flux3DraftFirstLastFrameToVideoRequest;
use fal_client::requests::api::video::text::flux_3_draft::api::Flux3DraftTextToVideoRequest;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::providers::fal::flux_3::build::{
optional_url, plan_aspect_ratio, plan_fixed_duration, plan_flexible_duration,
reject_references, to_flux_3_aspect_ratio, unsupported,
};
use crate::generate::generate_video::providers::fal::flux_3_draft::request::{
FalFlux3DraftMode, FalFlux3DraftRequestState,
};
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
pub fn build_fal_flux_3_draft(
builder: GenerateVideoRequestBuilder,
) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
let state = build_fal_flux_3_draft_state(builder)?;
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalFlux3Draft(state)))
}
/// Same modality dispatch as full-quality Flux 3 (see that build module) —
/// text, starting keyframe, and start/end keyframes; no references — but
/// against the draft endpoints, which always render 720p and take no
/// resolution input, so any requested resolution is silently dropped.
pub(crate) fn build_fal_flux_3_draft_state(
builder: GenerateVideoRequestBuilder,
) -> Result<FalFlux3DraftRequestState, ArtcraftRouterError> {
let strategy = builder.request_mismatch_mitigation_strategy;
reject_references(&builder)?;
let start = optional_url(builder.start_frame.clone())?;
let end = optional_url(builder.end_frame.clone())?;
let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio, strategy)?;
let flexible_duration = plan_flexible_duration(builder.duration_seconds, strategy)?;
let fixed_duration = plan_fixed_duration(builder.duration_seconds, strategy)?;
let prompt = builder.prompt.clone().unwrap_or_default();
let generate_audio = builder.generate_audio;
let mode = match (start, end) {
(None, None) => FalFlux3DraftMode::TextToVideo(Flux3DraftTextToVideoRequest {
prompt,
duration: flexible_duration,
aspect_ratio: aspect_ratio.map(to_flux_3_aspect_ratio),
generate_audio,
safety_tolerance: Some(4), // NB: 4 is the most permissive.
}),
(Some(image_url), None) => FalFlux3DraftMode::ImageToVideo(Flux3DraftImageToVideoRequest {
prompt,
image_url,
duration: flexible_duration,
aspect_ratio: aspect_ratio.map(to_flux_3_aspect_ratio),
generate_audio,
safety_tolerance: Some(4), // NB: 4 is the most permissive.
}),
(Some(start_image_url), Some(end_image_url)) => FalFlux3DraftMode::FirstLastFrameToVideo(
Flux3DraftFirstLastFrameToVideoRequest {
prompt,
start_image_url,
end_image_url,
duration: fixed_duration,
aspect_ratio: aspect_ratio.map(to_flux_3_aspect_ratio),
generate_audio,
safety_tolerance: Some(4), // NB: 4 is the most permissive.
},
),
(None, Some(_)) => {
return Err(unsupported(
"end_frame",
"Flux 3 Draft requires a start_frame when end_frame is provided",
));
}
};
Ok(FalFlux3DraftRequestState { mode })
}
#[cfg(test)]
mod tests {
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::router_provider::RouterProvider;
use crate::api::router_video_model::RouterVideoModel;
use crate::api::video_list_ref::VideoListRef;
use super::*;
const START_URL: &str = "https://example.com/start.png";
const END_URL: &str = "https://example.com/end.png";
mod dispatch_tests {
use super::*;
#[test]
fn no_inputs_picks_t2v() {
let state = build_fal_flux_3_draft_state(base_builder()).expect("build");
assert!(matches!(state.mode, FalFlux3DraftMode::TextToVideo(_)));
}
#[test]
fn start_frame_picks_i2v() {
let mut b = base_builder();
b.start_frame = Some(ImageRef::Url(START_URL.to_string()));
let state = build_fal_flux_3_draft_state(b).expect("build");
assert!(matches!(state.mode, FalFlux3DraftMode::ImageToVideo(_)));
}
#[test]
fn start_and_end_frames_pick_first_last_frame() {
let mut b = base_builder();
b.start_frame = Some(ImageRef::Url(START_URL.to_string()));
b.end_frame = Some(ImageRef::Url(END_URL.to_string()));
let state = build_fal_flux_3_draft_state(b).expect("build");
assert!(matches!(state.mode, FalFlux3DraftMode::FirstLastFrameToVideo(_)));
}
#[test]
fn end_frame_without_start_frame_errors() {
let mut b = base_builder();
b.end_frame = Some(ImageRef::Url(END_URL.to_string()));
assert!(build_fal_flux_3_draft_state(b).is_err());
}
#[test]
fn reference_images_error() {
let mut b = base_builder();
b.reference_images = Some(ImageListRef::Urls(vec!["https://example.com/ref.png".to_string()]));
assert!(build_fal_flux_3_draft_state(b).is_err());
}
#[test]
fn reference_videos_error() {
let mut b = base_builder();
b.reference_videos = Some(VideoListRef::Urls(vec!["https://example.com/v.mp4".to_string()]));
assert!(build_fal_flux_3_draft_state(b).is_err());
}
}
fn base_builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
model: RouterVideoModel::Flux3Draft,
provider: RouterProvider::Fal,
prompt: Some("test".to_string()),
..Default::default()
}
}
}
@@ -1,76 +0,0 @@
use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator;
use crate::generate::generate_video::providers::fal::flux_3_draft::request::{
FalFlux3DraftMode, FalFlux3DraftRequestState,
};
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
#[derive(Clone, Debug)]
pub struct FalFlux3DraftCostState {
pub cost_in_usd_cents: u64,
}
impl FalFlux3DraftCostState {
pub fn from_request(request: &FalFlux3DraftRequestState) -> Self {
// Cost math is owned by fal_client's per-endpoint
// `FalRequestCostCalculator` implementations. The router state just
// forwards the result so router cost ≡ fal_client cost by construction.
let cost_in_usd_cents = match &request.mode {
FalFlux3DraftMode::TextToVideo(req) => req.calculate_cost_in_cents(),
FalFlux3DraftMode::ImageToVideo(req) => req.calculate_cost_in_cents(),
FalFlux3DraftMode::FirstLastFrameToVideo(req) => req.calculate_cost_in_cents(),
};
Self { cost_in_usd_cents }
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
VideoGenerationCostEstimate {
cost_in_credits: Some(self.cost_in_usd_cents),
cost_in_usd_cents: Some(self.cost_in_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::router_provider::RouterProvider;
use crate::api::router_video_model::RouterVideoModel;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::providers::fal::flux_3_draft::build::build_fal_flux_3_draft_state;
use super::*;
// Pricing (from fal_client's flux_3_draft cost modules):
// $0.06/s (always 720p, all modalities)
// fal defaults when unset: duration = 5s (auto estimates at the 5s floor).
#[test]
fn t2v_default_settings_is_30() {
assert_eq!(cost_cents(base_builder(None)), 30);
}
#[test]
fn t2v_20s_is_120() {
assert_eq!(cost_cents(base_builder(Some(20))), 120);
}
fn base_builder(duration_seconds: Option<u16>) -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
model: RouterVideoModel::Flux3Draft,
provider: RouterProvider::Fal,
prompt: Some("test".to_string()),
duration_seconds,
..Default::default()
}
}
fn cost_cents(builder: GenerateVideoRequestBuilder) -> u64 {
let state = build_fal_flux_3_draft_state(builder).expect("build");
FalFlux3DraftCostState::from_request(&state).cost_in_usd_cents
}
}
@@ -1,3 +0,0 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -1,121 +0,0 @@
use std::fmt::Debug;
use std::sync::Arc;
use fal_client::requests::api::video::image::flux_3_draft::api::Flux3DraftImageToVideoRequest;
use fal_client::requests::api::video::images::flux_3_draft::api::Flux3DraftFirstLastFrameToVideoRequest;
use fal_client::requests::api::video::text::flux_3_draft::api::Flux3DraftTextToVideoRequest;
use fal_client::requests::traits::fal_endpoint_trait::FalEndpoint;
use crate::client::router_fal_client::RouterFalClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video::generate_video_response::{
FalVideoResponsePayload, GenerateVideoResponse,
};
#[derive(Clone, Debug)]
pub enum FalFlux3DraftMode {
TextToVideo(Flux3DraftTextToVideoRequest),
ImageToVideo(Flux3DraftImageToVideoRequest),
FirstLastFrameToVideo(Flux3DraftFirstLastFrameToVideoRequest),
}
#[derive(Clone, Debug)]
pub struct FalFlux3DraftRequestState {
pub mode: FalFlux3DraftMode,
}
impl FalFlux3DraftRequestState {
pub async fn send(&self, client: &RouterFalClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
match &self.mode {
FalFlux3DraftMode::TextToVideo(request) => send_request(request, client).await,
FalFlux3DraftMode::ImageToVideo(request) => send_request(request, client).await,
FalFlux3DraftMode::FirstLastFrameToVideo(request) => send_request(request, client).await,
}
}
}
// ── Helpers ──
async fn send_request<T>(request: &T, client: &RouterFalClient) -> Result<GenerateVideoResponse, ArtcraftRouterError>
where
T: FalEndpoint + Clone + Debug + Send + Sync + 'static,
{
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
let payload = send_fal_request(request, client).await?;
Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload {
request_id: payload.request_id,
gateway_request_id: payload.gateway_request_id,
maybe_status_url: payload.status_url,
maybe_response_url: payload.response_url,
maybe_outbound_request: Some(outbound),
}))
}
struct FalResponseIds {
request_id: Option<String>,
gateway_request_id: Option<String>,
status_url: Option<String>,
response_url: Option<String>,
}
async fn send_fal_request<T: FalEndpoint>(
request: &T,
client: &RouterFalClient,
) -> Result<FalResponseIds, ArtcraftRouterError> {
if let Some(webhook_url) = &client.webhook_url {
let response = request.send_webhook_request(&client.api_key, webhook_url).await?;
Ok(FalResponseIds {
request_id: response.request_id,
gateway_request_id: response.gateway_request_id,
status_url: None,
response_url: None,
})
} else {
let response = request.send_queue_request(&client.api_key).await?;
Ok(FalResponseIds {
request_id: Some(response.request_id),
gateway_request_id: None,
status_url: Some(response.status_url),
response_url: Some(response.response_url),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use fal_client::creds::fal_api_key::FalApiKey;
use fal_client::requests::api::video::text::flux_3::api::{Flux3AspectRatio, Flux3Duration};
#[tokio::test]
#[ignore] // requires real API key, incurs cost
async fn send_text_to_video_webhook() {
let state = FalFlux3DraftRequestState {
mode: FalFlux3DraftMode::TextToVideo(Flux3DraftTextToVideoRequest {
prompt: "a calm lake at sunrise".to_string(),
duration: Some(Flux3Duration::Seconds(5)),
aspect_ratio: Some(Flux3AspectRatio::SixteenByNine),
generate_audio: Some(false),
safety_tolerance: None,
}),
};
let response = state.send(&client_with_webhook()).await.expect("send should succeed");
let payload = response.get_fal_payload().expect("expected Fal payload");
assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some());
}
// ── Helpers ──
fn read_fal_api_key() -> FalApiKey {
let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")
.expect("Failed to read fal_api_key.txt");
FalApiKey::from_str(secret.trim())
}
fn client_with_webhook() -> RouterFalClient {
RouterFalClient::new_with_webhook(
read_fal_api_key(),
"https://example.com/fal-webhook-test".to_string(),
)
}
}
@@ -1,5 +1,3 @@
pub mod flux_3;
pub mod flux_3_draft;
pub mod kling_1_6_pro;
pub mod kling_2_1_master;
pub mod kling_2_1_pro;
@@ -21,10 +21,6 @@ use crate::generate::generate_video::providers::artcraft::kling_2_6_pro::cost::A
use crate::generate::generate_video::providers::artcraft::kling_2_6_pro::request::ArtcraftKling2p6ProRequestState;
use crate::generate::generate_video::providers::artcraft::kling_3p0_pro::cost::ArtcraftKling3p0ProCostState;
use crate::generate::generate_video::providers::artcraft::kling_3p0_pro::request::ArtcraftKling3p0ProRequestState;
use crate::generate::generate_video::providers::artcraft::flux_3::cost::ArtcraftFlux3CostState;
use crate::generate::generate_video::providers::artcraft::flux_3::request::ArtcraftFlux3RequestState;
use crate::generate::generate_video::providers::artcraft::flux_3_draft::cost::ArtcraftFlux3DraftCostState;
use crate::generate::generate_video::providers::artcraft::flux_3_draft::request::ArtcraftFlux3DraftRequestState;
use crate::generate::generate_video::providers::artcraft::kling_3p0_standard::cost::ArtcraftKling3p0StandardCostState;
use crate::generate::generate_video::providers::artcraft::kling_3p0_standard::request::ArtcraftKling3p0StandardRequestState;
use crate::generate::generate_video::providers::artcraft::minimax_h3::cost::ArtcraftMinimaxH3CostState;
@@ -93,10 +89,6 @@ use crate::generate::generate_video::providers::fal::kling_2_6_pro::cost::FalKli
use crate::generate::generate_video::providers::fal::kling_2_6_pro::request::FalKling2p6ProRequestState;
use crate::generate::generate_video::providers::fal::kling_3p0_pro::cost::FalKling3p0ProCostState;
use crate::generate::generate_video::providers::fal::kling_3p0_pro::request::FalKling3p0ProRequestState;
use crate::generate::generate_video::providers::fal::flux_3::cost::FalFlux3CostState;
use crate::generate::generate_video::providers::fal::flux_3::request::FalFlux3RequestState;
use crate::generate::generate_video::providers::fal::flux_3_draft::cost::FalFlux3DraftCostState;
use crate::generate::generate_video::providers::fal::flux_3_draft::request::FalFlux3DraftRequestState;
use crate::generate::generate_video::providers::fal::kling_3p0_standard::cost::FalKling3p0StandardCostState;
use crate::generate::generate_video::providers::fal::kling_3p0_standard::request::FalKling3p0StandardRequestState;
use crate::generate::generate_video::providers::fal::minimax_h3::cost::FalMinimaxH3CostState;
@@ -148,8 +140,6 @@ use crate::generate::generate_video::providers::kinovi::seedance_2p5_preview::re
pub enum VideoGenerationRequest {
ArtcraftGrokImagineVideo(ArtcraftGrokImagineVideoRequestState),
ArtcraftGrokImagineVideo1p5(ArtcraftGrokImagineVideo1p5RequestState),
ArtcraftFlux3(ArtcraftFlux3RequestState),
ArtcraftFlux3Draft(ArtcraftFlux3DraftRequestState),
ArtcraftHappyHorse1p0(ArtcraftHappyHorse1p0RequestState),
ArtcraftKling16Pro(ArtcraftKling16ProRequestState),
ArtcraftKling21Master(ArtcraftKling21MasterRequestState),
@@ -185,8 +175,6 @@ pub enum VideoGenerationRequest {
ArtcraftVeo3p1Lite(ArtcraftVeo3p1LiteRequestState),
ArtcraftViduQ3(ArtcraftViduQ3RequestState),
ArtcraftViduQ3Turbo(ArtcraftViduQ3TurboRequestState),
FalFlux3(FalFlux3RequestState),
FalFlux3Draft(FalFlux3DraftRequestState),
FalKling16Pro(FalKling16ProRequestState),
FalKling21Master(FalKling21MasterRequestState),
FalKling21Pro(FalKling21ProRequestState),
@@ -231,8 +219,6 @@ impl VideoGenerationRequest {
Self::ArtcraftKling2p5TurboPro(_) => RouterProvider::Artcraft,
Self::ArtcraftKling2p6Pro(_) => RouterProvider::Artcraft,
Self::ArtcraftKling3p0Pro(_) => RouterProvider::Artcraft,
Self::ArtcraftFlux3(_) => RouterProvider::Artcraft,
Self::ArtcraftFlux3Draft(_) => RouterProvider::Artcraft,
Self::ArtcraftKling3p0Standard(_) => RouterProvider::Artcraft,
Self::ArtcraftMinimaxH3(_) => RouterProvider::Artcraft,
Self::ArtcraftPreviewModel(_) => RouterProvider::Artcraft,
@@ -267,8 +253,6 @@ impl VideoGenerationRequest {
Self::FalKling2p5TurboPro(_) => RouterProvider::Fal,
Self::FalKling2p6Pro(_) => RouterProvider::Fal,
Self::FalKling3p0Pro(_) => RouterProvider::Fal,
Self::FalFlux3(_) => RouterProvider::Fal,
Self::FalFlux3Draft(_) => RouterProvider::Fal,
Self::FalKling3p0Standard(_) => RouterProvider::Fal,
Self::FalMinimaxH3(_) => RouterProvider::Fal,
Self::FalSeedance10Lite(_) => RouterProvider::Fal,
@@ -307,8 +291,6 @@ impl VideoGenerationRequest {
VideoGenerationRequest::ArtcraftKling2p5TurboPro(request) => Ok(ArtcraftKling2p5TurboProCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::ArtcraftKling2p6Pro(request) => Ok(ArtcraftKling2p6ProCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::ArtcraftKling3p0Pro(request) => Ok(ArtcraftKling3p0ProCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::ArtcraftFlux3(request) => Ok(ArtcraftFlux3CostState::from_request(request).estimate_cost()),
VideoGenerationRequest::ArtcraftFlux3Draft(request) => Ok(ArtcraftFlux3DraftCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::ArtcraftKling3p0Standard(request) => Ok(ArtcraftKling3p0StandardCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::ArtcraftMinimaxH3(request) => Ok(ArtcraftMinimaxH3CostState::from_request(request).estimate_cost()),
VideoGenerationRequest::ArtcraftPreviewModel(request) => Ok(ArtcraftPreviewModelCostState::from_request(request).estimate_cost()),
@@ -343,8 +325,6 @@ impl VideoGenerationRequest {
VideoGenerationRequest::FalKling2p5TurboPro(request) => Ok(FalKling2p5TurboProCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::FalKling2p6Pro(request) => Ok(FalKling2p6ProCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::FalKling3p0Pro(request) => Ok(FalKling3p0ProCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::FalFlux3(request) => Ok(FalFlux3CostState::from_request(request).estimate_cost()),
VideoGenerationRequest::FalFlux3Draft(request) => Ok(FalFlux3DraftCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::FalKling3p0Standard(request) => Ok(FalKling3p0StandardCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::FalMinimaxH3(request) => Ok(FalMinimaxH3CostState::from_request(request).estimate_cost()),
VideoGenerationRequest::FalSeedance10Lite(request) => Ok(FalSeedance10LiteCostState::from_request(request).estimate_cost()),
@@ -419,14 +399,6 @@ impl VideoGenerationRequest {
let client_ref = client.get_artcraft_client_ref()?;
request.send(client_ref).await
},
VideoGenerationRequest::ArtcraftFlux3(request) => {
let client_ref = client.get_artcraft_client_ref()?;
request.send(client_ref).await
},
VideoGenerationRequest::ArtcraftFlux3Draft(request) => {
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
@@ -563,14 +535,6 @@ impl VideoGenerationRequest {
let client_ref = client.get_fal_client_ref()?;
request.send(client_ref).await
},
VideoGenerationRequest::FalFlux3(request) => {
let client_ref = client.get_fal_client_ref()?;
request.send(client_ref).await
},
VideoGenerationRequest::FalFlux3Draft(request) => {
let client_ref = client.get_fal_client_ref()?;
request.send(client_ref).await
},
VideoGenerationRequest::FalSeedance10Lite(request) => {
let client_ref = client.get_fal_client_ref()?;
request.send(client_ref).await
@@ -1,105 +0,0 @@
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::api::video::enhance::flux_3_draft_enhance::raw_request::{
Flux3DraftEnhanceInput, Flux3DraftEnhanceOutput,
};
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
/// Re-render a Flux 3 Draft generation at full quality (1080p, with
/// synchronized audio) from its `draft_cache` bundle.
#[derive(Clone, Debug)]
pub struct Flux3DraftEnhanceRequest {
/// URL of the `draft_cache` bundle returned by a Flux 3 Draft generation.
pub draft_cache_url: String,
/// Safety tolerance, 0 (strictest) to 4 (most permissive). fal's default is
/// `2` when `None`.
pub safety_tolerance: Option<u8>,
/// Duration of the draft being enhanced, in seconds. NOT sent to fal — the
/// cache bundle already fixes the duration — but used by the cost
/// calculator, which otherwise cannot know the billable length. Estimated
/// at 5 seconds (the shortest draft) when `None`.
pub expected_duration_seconds: Option<u8>,
}
impl FalEndpoint for Flux3DraftEnhanceRequest {
const ENDPOINT: &str = "blackforestlabs/flux-3/draft-enhance";
type RawRequest = Flux3DraftEnhanceInput;
type RawResponse = Flux3DraftEnhanceOutput;
fn to_raw_request(&self) -> Result<Self::RawRequest, FalErrorPlus> {
Ok(Self::RawRequest {
draft_cache_url: self.draft_cache_url.clone(),
safety_tolerance: self.safety_tolerance,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::creds::fal_api_key::FalApiKey;
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
use errors::AnyhowResult;
use std::fs::read_to_string;
// ── Real requests (manually run; require a live key and cost money) ──
#[tokio::test]
#[ignore] // manually run — needs a fresh draft_cache URL from a prior draft run, and incurs cost
async fn test_draft_enhance_queue() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
// Paste a `draft_cache` URL from a recent Flux 3 Draft generation here.
let draft_cache_url = read_to_string("/tmp/flux3_draft_cache_url.txt")?;
let request = Flux3DraftEnhanceRequest {
draft_cache_url: draft_cache_url.trim().to_string(),
safety_tolerance: None,
expected_duration_seconds: Some(5),
};
let result = request.send_queue_request(&api_key).await?;
println!("Queue result — request_id: {}", result.request_id);
assert!(!result.request_id.is_empty());
Ok(())
}
// ── Wire-shape sanity (no API calls) ──
#[test]
fn raw_request_maps_all_fields() {
let request = Flux3DraftEnhanceRequest {
draft_cache_url: "https://example.com/cache.bin".to_string(),
safety_tolerance: Some(3),
expected_duration_seconds: Some(10),
};
let raw = request.to_raw_request().unwrap();
assert_eq!(raw.draft_cache_url, "https://example.com/cache.bin");
assert_eq!(raw.safety_tolerance, Some(3));
}
/// `expected_duration_seconds` is cost-calculator metadata and must never
/// reach the wire.
#[test]
fn expected_duration_is_not_serialized() {
let request = Flux3DraftEnhanceRequest {
draft_cache_url: "https://example.com/cache.bin".to_string(),
safety_tolerance: None,
expected_duration_seconds: Some(20),
};
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
assert_eq!(json, serde_json::json!({
"draft_cache_url": "https://example.com/cache.bin",
}));
}
#[test]
fn endpoint_path_is_canonical() {
assert_eq!(Flux3DraftEnhanceRequest::ENDPOINT, "blackforestlabs/flux-3/draft-enhance");
}
// NB: Pricing tests are in cost.rs
}
@@ -1,62 +0,0 @@
use crate::requests::api::video::enhance::flux_3_draft_enhance::api::Flux3DraftEnhanceRequest;
use crate::requests::api::video::text::flux_3::cost::flux_3_video_cost_cents;
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
// Flux 3 draft-enhance pricing (see
// https://fal.ai/models/blackforestlabs/flux-3/draft-enhance):
// $0.29 per second of the enhanced video (full-quality 1080p render);
// synchronized audio is included at no extra cost.
//
// The request carries only a `draft_cache_url`, so the billable duration is
// taken from the request's `expected_duration_seconds` hint (5s — the
// shortest draft — when unset).
const ENHANCE_RATE_CENTS_PER_SEC: u64 = 29; // $0.29/sec
impl FalRequestCostCalculator for Flux3DraftEnhanceRequest {
fn calculate_cost_in_cents(&self) -> UsdCents {
let duration_secs = u64::from(self.expected_duration_seconds.unwrap_or(5));
flux_3_video_cost_cents(ENHANCE_RATE_CENTS_PER_SEC, duration_secs)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_request(expected_duration_seconds: Option<u8>) -> Flux3DraftEnhanceRequest {
Flux3DraftEnhanceRequest {
draft_cache_url: "https://example.com/cache.bin".to_string(),
safety_tolerance: None,
expected_duration_seconds,
}
}
mod cost_table {
use super::*;
// (expected_duration, expected_cents) — 29¢ × secs; None estimates at 5s.
const COST_TABLE: &[(Option<u8>, u64)] = &[
(Some(5), 145),
(Some(10), 290),
(Some(20), 580),
(None, 145),
];
#[test]
fn matches_cost_table() {
for &(duration, expected) in COST_TABLE {
let got = make_request(duration).calculate_cost_in_cents();
assert_eq!(got, expected, "duration={duration:?}");
}
}
/// Enhance matches the full-quality 1080p generation rate — drafting then
/// enhancing ($0.06 + $0.29) intentionally costs more than generating at
/// full quality directly ($0.29 at 1080p).
#[test]
fn enhance_rate_matches_full_quality_1080p_rate() {
use crate::requests::api::video::text::flux_3::cost::flux_3_standard_rate_cents_per_sec;
assert_eq!(ENHANCE_RATE_CENTS_PER_SEC, flux_3_standard_rate_cents_per_sec(true));
}
}
}
@@ -1,3 +0,0 @@
pub mod api;
pub mod cost;
pub mod raw_request;
@@ -1,23 +0,0 @@
use serde::{Deserialize, Serialize};
/// Over-the-wire input shape for `blackforestlabs/flux-3/draft-enhance`.
/// fal's schema: <https://fal.ai/models/blackforestlabs/flux-3/draft-enhance/api>
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct Flux3DraftEnhanceInput {
/// URL of the `draft_cache` bundle returned by a Flux 3 Draft generation.
pub draft_cache_url: String,
/// Safety tolerance, 0 (strictest) to 4 (most permissive). fal default: 2.
#[serde(skip_serializing_if = "Option::is_none")]
pub safety_tolerance: Option<u8>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Flux3DraftEnhanceVideoFile {
pub url: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Flux3DraftEnhanceOutput {
pub video: Flux3DraftEnhanceVideoFile,
}
@@ -1 +0,0 @@
pub mod flux_3_draft_enhance;
@@ -1,133 +0,0 @@
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::api::video::extend::flux_3::raw_request::{
Flux3ExtendVideoInput, Flux3ExtendVideoOutput,
};
use crate::requests::api::video::text::flux_3::api::{
Flux3AspectRatio, Flux3Duration, Flux3Resolution,
};
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
#[derive(Clone, Debug)]
pub struct Flux3ExtendVideoRequest {
/// Text prompt describing how the video should continue.
pub prompt: String,
/// URL of the video to extend.
pub video_url: String,
/// Duration of the extension in seconds. fal's default is `auto` when
/// `None`.
pub duration: Option<Flux3Duration>,
/// Output resolution. fal's default is `720p` when `None`.
pub resolution: Option<Flux3Resolution>,
/// Aspect ratio. fal's default is `auto` when `None`.
pub aspect_ratio: Option<Flux3AspectRatio>,
/// Whether to generate synchronized audio. fal's default is `true` when
/// `None`. Audio does not affect the bill.
pub generate_audio: Option<bool>,
/// Safety tolerance, 0 (strictest) to 4 (most permissive). fal's default is
/// `2` when `None`.
pub safety_tolerance: Option<u8>,
}
impl FalEndpoint for Flux3ExtendVideoRequest {
const ENDPOINT: &str = "blackforestlabs/flux-3/extend-video";
type RawRequest = Flux3ExtendVideoInput;
type RawResponse = Flux3ExtendVideoOutput;
fn to_raw_request(&self) -> Result<Self::RawRequest, FalErrorPlus> {
Ok(Self::RawRequest {
prompt: self.prompt.clone(),
video_url: self.video_url.clone(),
duration: self.duration,
resolution: self.resolution.map(|r| r.to_str().to_string()),
aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()),
generate_audio: self.generate_audio,
safety_tolerance: self.safety_tolerance,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::creds::fal_api_key::FalApiKey;
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
use errors::AnyhowResult;
use std::fs::read_to_string;
// ── Real requests (manually run; require a live key and cost money) ──
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_extend_video_queue() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = Flux3ExtendVideoRequest {
prompt: "the camera keeps drifting forward as the scene continues".to_string(),
video_url: "https://storage.googleapis.com/falserverless/example_inputs/hunyuan_video_input.mp4".to_string(),
duration: Some(Flux3Duration::Seconds(5)),
resolution: Some(Flux3Resolution::SevenTwentyP),
aspect_ratio: None,
generate_audio: Some(false),
safety_tolerance: None,
};
let result = request.send_queue_request(&api_key).await?;
println!("Queue result — request_id: {}", result.request_id);
assert!(!result.request_id.is_empty());
Ok(())
}
// ── Wire-shape sanity (no API calls) ──
#[test]
fn raw_request_maps_all_fields() {
let request = Flux3ExtendVideoRequest {
prompt: "p".to_string(),
video_url: "https://example.com/source.mp4".to_string(),
duration: Some(Flux3Duration::Seconds(10)),
resolution: Some(Flux3Resolution::TenEightyP),
aspect_ratio: Some(Flux3AspectRatio::SixteenByNine),
generate_audio: Some(true),
safety_tolerance: Some(2),
};
let raw = request.to_raw_request().unwrap();
assert_eq!(raw.prompt, "p");
assert_eq!(raw.video_url, "https://example.com/source.mp4");
assert_eq!(raw.duration, Some(Flux3Duration::Seconds(10)));
assert_eq!(raw.resolution.as_deref(), Some("1080p"));
assert_eq!(raw.aspect_ratio.as_deref(), Some("16:9"));
}
#[test]
fn raw_request_omits_unset_optionals() {
let request = Flux3ExtendVideoRequest {
prompt: "minimal".to_string(),
video_url: "https://example.com/source.mp4".to_string(),
duration: None,
resolution: None,
aspect_ratio: None,
generate_audio: None,
safety_tolerance: None,
};
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
assert_eq!(json, serde_json::json!({
"prompt": "minimal",
"video_url": "https://example.com/source.mp4",
}));
}
#[test]
fn endpoint_path_is_canonical() {
assert_eq!(Flux3ExtendVideoRequest::ENDPOINT, "blackforestlabs/flux-3/extend-video");
}
// NB: Pricing tests are in cost.rs
}
@@ -1,80 +0,0 @@
use crate::requests::api::video::extend::flux_3::api::Flux3ExtendVideoRequest;
use crate::requests::api::video::text::flux_3::cost::{
flux_3_estimated_duration_secs, flux_3_is_1080p, flux_3_video_cost_cents,
};
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
// Flux 3 extend-video pricing (see https://fal.ai/models/blackforestlabs/flux-3/extend-video):
// 720p: $0.41 per second of generated video
// 1080p: $0.53 per second of generated video
//
// Extend bills higher than the other Flux 3 modalities (which are $0.17 and
// $0.29; see the text module). The source video is free; pricing depends on
// resolution and the generated extension's duration only.
const EXTEND_RATE_720P_CENTS_PER_SEC: u64 = 41; // $0.41/sec
const EXTEND_RATE_1080P_CENTS_PER_SEC: u64 = 53; // $0.53/sec
impl FalRequestCostCalculator for Flux3ExtendVideoRequest {
fn calculate_cost_in_cents(&self) -> UsdCents {
let duration_secs = flux_3_estimated_duration_secs(self.duration);
let rate = if flux_3_is_1080p(self.resolution) {
EXTEND_RATE_1080P_CENTS_PER_SEC
} else {
EXTEND_RATE_720P_CENTS_PER_SEC
};
flux_3_video_cost_cents(rate, duration_secs)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::requests::api::video::text::flux_3::api::{Flux3Duration, Flux3Resolution};
fn make_request(
duration: Option<Flux3Duration>,
resolution: Option<Flux3Resolution>,
) -> Flux3ExtendVideoRequest {
Flux3ExtendVideoRequest {
prompt: "test".to_string(),
video_url: "https://example.com/source.mp4".to_string(),
duration,
resolution,
aspect_ratio: None,
generate_audio: None,
safety_tolerance: None,
}
}
mod cost_table {
use super::*;
// (duration, resolution, expected_cents)
// Math: rate × secs where rate (cents) = 41 (720p) or 53 (1080p);
// None/auto durations are estimated at the 5s floor.
const COST_TABLE: &[(Option<Flux3Duration>, Option<Flux3Resolution>, u64)] = &[
(Some(Flux3Duration::Seconds(5)), Some(Flux3Resolution::SevenTwentyP), 205),
(Some(Flux3Duration::Seconds(20)), Some(Flux3Resolution::SevenTwentyP), 820),
(Some(Flux3Duration::Seconds(5)), Some(Flux3Resolution::TenEightyP), 265),
(Some(Flux3Duration::Seconds(20)), Some(Flux3Resolution::TenEightyP), 1060),
// Defaults: duration=None/auto→5s estimate, resolution=None→720p
(None, None, 205),
(Some(Flux3Duration::Auto), None, 205),
];
#[test]
fn matches_cost_table() {
for &(duration, resolution, expected) in COST_TABLE {
let got = make_request(duration, resolution).calculate_cost_in_cents();
assert_eq!(got, expected, "duration={duration:?} resolution={resolution:?}");
}
}
#[test]
fn extend_is_more_expensive_than_standard_generation() {
use crate::requests::api::video::text::flux_3::cost::flux_3_standard_rate_cents_per_sec;
assert!(EXTEND_RATE_720P_CENTS_PER_SEC > flux_3_standard_rate_cents_per_sec(false));
assert!(EXTEND_RATE_1080P_CENTS_PER_SEC > flux_3_standard_rate_cents_per_sec(true));
}
}
}
@@ -1,3 +0,0 @@
pub mod api;
pub mod cost;
pub mod raw_request;
@@ -1,48 +0,0 @@
use serde::{Deserialize, Serialize};
use crate::requests::api::video::text::flux_3::api::Flux3Duration;
/// Over-the-wire input shape for `blackforestlabs/flux-3/extend-video`.
/// fal's schema: <https://fal.ai/models/blackforestlabs/flux-3/extend-video/api>
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct Flux3ExtendVideoInput {
/// Text prompt describing how the video should continue.
pub prompt: String,
/// URL of the video to extend.
pub video_url: String,
/// Duration of the extension in seconds ("auto" or 5–20). fal default:
/// "auto".
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<Flux3Duration>,
/// Output resolution.
/// Possible values: "720p", "1080p". fal default: "720p".
#[serde(skip_serializing_if = "Option::is_none")]
pub resolution: Option<String>,
/// Aspect ratio.
/// Possible values: "auto", "21:9", "2:1", "16:9", "4:3", "1:1", "3:4",
/// "9:16". fal default: "auto".
#[serde(skip_serializing_if = "Option::is_none")]
pub aspect_ratio: Option<String>,
/// Whether to generate synchronized audio. fal default: true.
#[serde(skip_serializing_if = "Option::is_none")]
pub generate_audio: Option<bool>,
/// Safety tolerance, 0 (strictest) to 4 (most permissive). fal default: 2.
#[serde(skip_serializing_if = "Option::is_none")]
pub safety_tolerance: Option<u8>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Flux3ExtendVideoVideoFile {
pub url: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Flux3ExtendVideoOutput {
pub video: Flux3ExtendVideoVideoFile,
}
@@ -1,128 +0,0 @@
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::api::video::extend::flux_3_draft::raw_request::{
Flux3DraftExtendVideoInput, Flux3DraftExtendVideoOutput,
};
use crate::requests::api::video::text::flux_3::api::{Flux3AspectRatio, Flux3Duration};
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
/// Flux 3 Draft extend-video: lower cost, lower fidelity (always 720p). The
/// response includes a `draft_cache` that `draft-enhance` can upgrade to a
/// full-quality 1080p render.
#[derive(Clone, Debug)]
pub struct Flux3DraftExtendVideoRequest {
/// Text prompt describing how the video should continue.
pub prompt: String,
/// URL of the video to extend.
pub video_url: String,
/// Duration of the extension in seconds. fal's default is `auto` when
/// `None`.
pub duration: Option<Flux3Duration>,
/// Aspect ratio. fal's default is `auto` when `None`.
pub aspect_ratio: Option<Flux3AspectRatio>,
/// Whether to generate synchronized audio. fal's default is `true` when
/// `None`. Audio does not affect the bill.
pub generate_audio: Option<bool>,
/// Safety tolerance, 0 (strictest) to 4 (most permissive). fal's default is
/// `2` when `None`.
pub safety_tolerance: Option<u8>,
}
impl FalEndpoint for Flux3DraftExtendVideoRequest {
const ENDPOINT: &str = "blackforestlabs/flux-3/extend-video/draft";
type RawRequest = Flux3DraftExtendVideoInput;
type RawResponse = Flux3DraftExtendVideoOutput;
fn to_raw_request(&self) -> Result<Self::RawRequest, FalErrorPlus> {
Ok(Self::RawRequest {
prompt: self.prompt.clone(),
video_url: self.video_url.clone(),
duration: self.duration,
aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()),
generate_audio: self.generate_audio,
safety_tolerance: self.safety_tolerance,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::creds::fal_api_key::FalApiKey;
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
use errors::AnyhowResult;
use std::fs::read_to_string;
// ── Real requests (manually run; require a live key and cost money) ──
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_extend_video_queue() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = Flux3DraftExtendVideoRequest {
prompt: "the camera keeps drifting forward as the scene continues".to_string(),
video_url: "https://storage.googleapis.com/falserverless/example_inputs/hunyuan_video_input.mp4".to_string(),
duration: Some(Flux3Duration::Seconds(5)),
aspect_ratio: None,
generate_audio: Some(false),
safety_tolerance: None,
};
let result = request.send_queue_request(&api_key).await?;
println!("Queue result — request_id: {}", result.request_id);
assert!(!result.request_id.is_empty());
Ok(())
}
// ── Wire-shape sanity (no API calls) ──
#[test]
fn raw_request_maps_all_fields() {
let request = Flux3DraftExtendVideoRequest {
prompt: "p".to_string(),
video_url: "https://example.com/source.mp4".to_string(),
duration: Some(Flux3Duration::Auto),
aspect_ratio: Some(Flux3AspectRatio::Auto),
generate_audio: Some(true),
safety_tolerance: Some(4),
};
let raw = request.to_raw_request().unwrap();
assert_eq!(raw.prompt, "p");
assert_eq!(raw.video_url, "https://example.com/source.mp4");
assert_eq!(raw.duration, Some(Flux3Duration::Auto));
assert_eq!(raw.aspect_ratio.as_deref(), Some("auto"));
assert_eq!(raw.generate_audio, Some(true));
assert_eq!(raw.safety_tolerance, Some(4));
}
#[test]
fn raw_request_omits_unset_optionals() {
let request = Flux3DraftExtendVideoRequest {
prompt: "minimal".to_string(),
video_url: "https://example.com/source.mp4".to_string(),
duration: None,
aspect_ratio: None,
generate_audio: None,
safety_tolerance: None,
};
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
assert_eq!(json, serde_json::json!({
"prompt": "minimal",
"video_url": "https://example.com/source.mp4",
}));
}
#[test]
fn endpoint_path_is_canonical() {
assert_eq!(Flux3DraftExtendVideoRequest::ENDPOINT, "blackforestlabs/flux-3/extend-video/draft");
}
// NB: Pricing tests are in cost.rs
}
@@ -1,64 +0,0 @@
use crate::requests::api::video::extend::flux_3_draft::api::Flux3DraftExtendVideoRequest;
use crate::requests::api::video::text::flux_3::cost::{
flux_3_estimated_duration_secs, flux_3_video_cost_cents,
};
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
// Flux 3 Draft extend-video pricing (see
// https://fal.ai/models/blackforestlabs/flux-3/extend-video/draft):
// $0.12 per second of generated draft video (always 720p)
//
// Draft extend bills higher than the other draft modalities ($0.06/sec; see
// the draft text module). The source video is free; pricing depends on the
// generated extension's duration only.
const DRAFT_EXTEND_RATE_CENTS_PER_SEC: u64 = 12; // $0.12/sec
impl FalRequestCostCalculator for Flux3DraftExtendVideoRequest {
fn calculate_cost_in_cents(&self) -> UsdCents {
let duration_secs = flux_3_estimated_duration_secs(self.duration);
flux_3_video_cost_cents(DRAFT_EXTEND_RATE_CENTS_PER_SEC, duration_secs)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::requests::api::video::text::flux_3::api::Flux3Duration;
fn make_request(duration: Option<Flux3Duration>) -> Flux3DraftExtendVideoRequest {
Flux3DraftExtendVideoRequest {
prompt: "test".to_string(),
video_url: "https://example.com/source.mp4".to_string(),
duration,
aspect_ratio: None,
generate_audio: None,
safety_tolerance: None,
}
}
mod cost_table {
use super::*;
// (duration, expected_cents) — 12¢ × secs; None/auto estimate at 5s.
const COST_TABLE: &[(Option<Flux3Duration>, u64)] = &[
(Some(Flux3Duration::Seconds(5)), 60),
(Some(Flux3Duration::Seconds(20)), 240),
(None, 60),
(Some(Flux3Duration::Auto), 60),
];
#[test]
fn matches_cost_table() {
for &(duration, expected) in COST_TABLE {
let got = make_request(duration).calculate_cost_in_cents();
assert_eq!(got, expected, "duration={duration:?}");
}
}
#[test]
fn draft_extend_is_double_the_draft_generation_rate() {
use crate::requests::api::video::text::flux_3_draft::cost::flux_3_draft_generation_rate_cents_per_sec;
assert_eq!(DRAFT_EXTEND_RATE_CENTS_PER_SEC, flux_3_draft_generation_rate_cents_per_sec() * 2);
}
}
}
@@ -1,3 +0,0 @@
pub mod api;
pub mod cost;
pub mod raw_request;
@@ -1,47 +0,0 @@
use serde::{Deserialize, Serialize};
use crate::requests::api::video::text::flux_3::api::Flux3Duration;
/// Over-the-wire input shape for `blackforestlabs/flux-3/extend-video/draft`.
/// fal's schema: <https://fal.ai/models/blackforestlabs/flux-3/extend-video/draft/api>
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct Flux3DraftExtendVideoInput {
/// Text prompt describing how the video should continue.
pub prompt: String,
/// URL of the video to extend.
pub video_url: String,
/// Duration of the extension in seconds ("auto" or 5–20). fal default:
/// "auto".
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<Flux3Duration>,
/// Aspect ratio.
/// Possible values: "auto", "21:9", "2:1", "16:9", "4:3", "1:1", "3:4",
/// "9:16". fal default: "auto".
#[serde(skip_serializing_if = "Option::is_none")]
pub aspect_ratio: Option<String>,
/// Whether to generate synchronized audio. fal default: true.
#[serde(skip_serializing_if = "Option::is_none")]
pub generate_audio: Option<bool>,
/// Safety tolerance, 0 (strictest) to 4 (most permissive). fal default: 2.
#[serde(skip_serializing_if = "Option::is_none")]
pub safety_tolerance: Option<u8>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Flux3DraftExtendVideoVideoFile {
pub url: String,
}
/// Draft outputs carry the 720p draft video plus a durable encrypted cache
/// bundle whose URL can be passed to `draft-enhance` for a full-quality
/// 1080p render.
#[derive(Debug, Serialize, Deserialize)]
pub struct Flux3DraftExtendVideoOutput {
pub video: Flux3DraftExtendVideoVideoFile,
pub draft_cache: Flux3DraftExtendVideoVideoFile,
}
@@ -1,4 +1,2 @@
pub mod flux_3;
pub mod flux_3_draft;
pub mod veo_3p1;
pub mod veo_3p1_fast;
@@ -1,159 +0,0 @@
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::api::video::image::flux_3::raw_request::{
Flux3ImageToVideoInput, Flux3ImageToVideoOutput,
};
use crate::requests::api::video::text::flux_3::api::{
Flux3AspectRatio, Flux3Duration, Flux3Resolution,
};
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
#[derive(Clone, Debug)]
pub struct Flux3ImageToVideoRequest {
/// Text prompt for video generation.
pub prompt: String,
/// URL of the image to animate.
pub image_url: String,
/// Duration in seconds. fal's default is `auto` when `None`.
pub duration: Option<Flux3Duration>,
/// Output resolution. fal's default is `720p` when `None`.
pub resolution: Option<Flux3Resolution>,
/// Aspect ratio. fal's default is `auto` when `None`.
pub aspect_ratio: Option<Flux3AspectRatio>,
/// Whether to generate synchronized audio. fal's default is `true` when
/// `None`. Audio does not affect the bill.
pub generate_audio: Option<bool>,
/// Safety tolerance, 0 (strictest) to 4 (most permissive). fal's default is
/// `2` when `None`.
pub safety_tolerance: Option<u8>,
}
impl FalEndpoint for Flux3ImageToVideoRequest {
const ENDPOINT: &str = "blackforestlabs/flux-3/image-to-video";
type RawRequest = Flux3ImageToVideoInput;
type RawResponse = Flux3ImageToVideoOutput;
fn to_raw_request(&self) -> Result<Self::RawRequest, FalErrorPlus> {
Ok(Self::RawRequest {
prompt: self.prompt.clone(),
image_url: self.image_url.clone(),
duration: self.duration,
resolution: self.resolution.map(|r| r.to_str().to_string()),
aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()),
generate_audio: self.generate_audio,
safety_tolerance: self.safety_tolerance,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::creds::fal_api_key::FalApiKey;
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
use errors::AnyhowResult;
use std::fs::read_to_string;
const EXAMPLE_IMAGE_URL: &str =
"https://storage.googleapis.com/falserverless/example_inputs/veo31-flf2v-input-1.jpeg";
// ── Real requests (manually run; require a live key and cost money) ──
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_image_to_video_webhook() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = Flux3ImageToVideoRequest {
prompt: "the camera slowly pulls back, revealing the full landscape".to_string(),
image_url: EXAMPLE_IMAGE_URL.to_string(),
duration: Some(Flux3Duration::Seconds(5)),
resolution: Some(Flux3Resolution::SevenTwentyP),
aspect_ratio: None,
generate_audio: Some(false),
safety_tolerance: None,
};
let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?;
println!("Webhook result: {:?}", result);
assert!(result.request_id.is_some() || result.gateway_request_id.is_some());
Ok(())
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_image_to_video_queue() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = Flux3ImageToVideoRequest {
prompt: "gentle wind moves through the scene as clouds drift overhead".to_string(),
image_url: EXAMPLE_IMAGE_URL.to_string(),
duration: Some(Flux3Duration::Seconds(5)),
resolution: Some(Flux3Resolution::SevenTwentyP),
aspect_ratio: None,
generate_audio: Some(false),
safety_tolerance: None,
};
let result = request.send_queue_request(&api_key).await?;
println!("Queue result — request_id: {}", result.request_id);
assert!(!result.request_id.is_empty());
Ok(())
}
// ── Wire-shape sanity (no API calls) ──
#[test]
fn raw_request_maps_all_fields() {
let request = Flux3ImageToVideoRequest {
prompt: "p".to_string(),
image_url: "https://example.com/frame.png".to_string(),
duration: Some(Flux3Duration::Auto),
resolution: Some(Flux3Resolution::TenEightyP),
aspect_ratio: Some(Flux3AspectRatio::Auto),
generate_audio: Some(true),
safety_tolerance: Some(0),
};
let raw = request.to_raw_request().unwrap();
assert_eq!(raw.prompt, "p");
assert_eq!(raw.image_url, "https://example.com/frame.png");
assert_eq!(raw.duration, Some(Flux3Duration::Auto));
assert_eq!(raw.resolution.as_deref(), Some("1080p"));
assert_eq!(raw.aspect_ratio.as_deref(), Some("auto"));
assert_eq!(raw.generate_audio, Some(true));
assert_eq!(raw.safety_tolerance, Some(0));
}
#[test]
fn raw_request_omits_unset_optionals() {
let request = Flux3ImageToVideoRequest {
prompt: "minimal".to_string(),
image_url: "https://example.com/frame.png".to_string(),
duration: None,
resolution: None,
aspect_ratio: None,
generate_audio: None,
safety_tolerance: None,
};
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
assert_eq!(json, serde_json::json!({
"prompt": "minimal",
"image_url": "https://example.com/frame.png",
}));
}
#[test]
fn endpoint_path_is_canonical() {
assert_eq!(Flux3ImageToVideoRequest::ENDPOINT, "blackforestlabs/flux-3/image-to-video");
}
// NB: Pricing tests are in cost.rs
}
@@ -1,64 +0,0 @@
use crate::requests::api::video::image::flux_3::api::Flux3ImageToVideoRequest;
use crate::requests::api::video::text::flux_3::cost::{
flux_3_estimated_duration_secs, flux_3_is_1080p, flux_3_standard_rate_cents_per_sec,
flux_3_video_cost_cents,
};
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
// Image-to-video shares the standard Flux 3 per-second pricing (see the text
// module for the canonical rate table):
// 720p: $0.17/sec
// 1080p: $0.29/sec
// The input image is free; pricing depends on resolution and duration only.
impl FalRequestCostCalculator for Flux3ImageToVideoRequest {
fn calculate_cost_in_cents(&self) -> UsdCents {
let duration_secs = flux_3_estimated_duration_secs(self.duration);
let rate = flux_3_standard_rate_cents_per_sec(flux_3_is_1080p(self.resolution));
flux_3_video_cost_cents(rate, duration_secs)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::requests::api::video::text::flux_3::api::{Flux3Duration, Flux3Resolution};
fn make_request(
duration: Option<Flux3Duration>,
resolution: Option<Flux3Resolution>,
) -> Flux3ImageToVideoRequest {
Flux3ImageToVideoRequest {
prompt: "test".to_string(),
image_url: "https://example.com/frame.png".to_string(),
duration,
resolution,
aspect_ratio: None,
generate_audio: None,
safety_tolerance: None,
}
}
mod cost_table {
use super::*;
// (duration, resolution, expected_cents)
const COST_TABLE: &[(Option<Flux3Duration>, Option<Flux3Resolution>, u64)] = &[
(Some(Flux3Duration::Seconds(5)), Some(Flux3Resolution::SevenTwentyP), 85),
(Some(Flux3Duration::Seconds(20)), Some(Flux3Resolution::SevenTwentyP), 340),
(Some(Flux3Duration::Seconds(5)), Some(Flux3Resolution::TenEightyP), 145),
(Some(Flux3Duration::Seconds(20)), Some(Flux3Resolution::TenEightyP), 580),
// Defaults: duration=None/auto→5s estimate, resolution=None→720p
(None, None, 85),
(Some(Flux3Duration::Auto), Some(Flux3Resolution::TenEightyP), 145),
];
#[test]
fn matches_cost_table() {
for &(duration, resolution, expected) in COST_TABLE {
let got = make_request(duration, resolution).calculate_cost_in_cents();
assert_eq!(got, expected, "duration={duration:?} resolution={resolution:?}");
}
}
}
}
@@ -1,3 +0,0 @@
pub mod api;
pub mod cost;
pub mod raw_request;
@@ -1,47 +0,0 @@
use serde::{Deserialize, Serialize};
use crate::requests::api::video::text::flux_3::api::Flux3Duration;
/// Over-the-wire input shape for `blackforestlabs/flux-3/image-to-video`.
/// fal's schema: <https://fal.ai/models/blackforestlabs/flux-3/image-to-video/api>
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct Flux3ImageToVideoInput {
/// Text prompt for video generation.
pub prompt: String,
/// URL of the image to animate.
pub image_url: String,
/// Duration in seconds ("auto" or 5–20). fal default: "auto".
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<Flux3Duration>,
/// Output resolution.
/// Possible values: "720p", "1080p". fal default: "720p".
#[serde(skip_serializing_if = "Option::is_none")]
pub resolution: Option<String>,
/// Aspect ratio.
/// Possible values: "auto", "21:9", "2:1", "16:9", "4:3", "1:1", "3:4",
/// "9:16". fal default: "auto".
#[serde(skip_serializing_if = "Option::is_none")]
pub aspect_ratio: Option<String>,
/// Whether to generate synchronized audio. fal default: true.
#[serde(skip_serializing_if = "Option::is_none")]
pub generate_audio: Option<bool>,
/// Safety tolerance, 0 (strictest) to 4 (most permissive). fal default: 2.
#[serde(skip_serializing_if = "Option::is_none")]
pub safety_tolerance: Option<u8>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Flux3ImageToVideoVideoFile {
pub url: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Flux3ImageToVideoOutput {
pub video: Flux3ImageToVideoVideoFile,
}
@@ -1,151 +0,0 @@
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::api::video::image::flux_3_draft::raw_request::{
Flux3DraftImageToVideoInput, Flux3DraftImageToVideoOutput,
};
use crate::requests::api::video::text::flux_3::api::{Flux3AspectRatio, Flux3Duration};
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
/// Flux 3 Draft image-to-video: lower cost, lower fidelity (always 720p). The
/// response includes a `draft_cache` that `draft-enhance` can upgrade to a
/// full-quality 1080p render.
#[derive(Clone, Debug)]
pub struct Flux3DraftImageToVideoRequest {
/// Text prompt for video generation.
pub prompt: String,
/// URL of the image to animate.
pub image_url: String,
/// Duration in seconds. fal's default is `auto` when `None`.
pub duration: Option<Flux3Duration>,
/// Aspect ratio. fal's default is `auto` when `None`.
pub aspect_ratio: Option<Flux3AspectRatio>,
/// Whether to generate synchronized audio. fal's default is `true` when
/// `None`. Audio does not affect the bill.
pub generate_audio: Option<bool>,
/// Safety tolerance, 0 (strictest) to 4 (most permissive). fal's default is
/// `2` when `None`.
pub safety_tolerance: Option<u8>,
}
impl FalEndpoint for Flux3DraftImageToVideoRequest {
const ENDPOINT: &str = "blackforestlabs/flux-3/image-to-video/draft";
type RawRequest = Flux3DraftImageToVideoInput;
type RawResponse = Flux3DraftImageToVideoOutput;
fn to_raw_request(&self) -> Result<Self::RawRequest, FalErrorPlus> {
Ok(Self::RawRequest {
prompt: self.prompt.clone(),
image_url: self.image_url.clone(),
duration: self.duration,
aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()),
generate_audio: self.generate_audio,
safety_tolerance: self.safety_tolerance,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::creds::fal_api_key::FalApiKey;
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
use errors::AnyhowResult;
use std::fs::read_to_string;
const EXAMPLE_IMAGE_URL: &str =
"https://storage.googleapis.com/falserverless/example_inputs/veo31-flf2v-input-1.jpeg";
// ── Real requests (manually run; require a live key and cost money) ──
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_image_to_video_webhook() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = Flux3DraftImageToVideoRequest {
prompt: "the camera slowly pulls back, revealing the full landscape".to_string(),
image_url: EXAMPLE_IMAGE_URL.to_string(),
duration: Some(Flux3Duration::Seconds(5)),
aspect_ratio: None,
generate_audio: Some(false),
safety_tolerance: None,
};
let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?;
println!("Webhook result: {:?}", result);
assert!(result.request_id.is_some() || result.gateway_request_id.is_some());
Ok(())
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_image_to_video_queue() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = Flux3DraftImageToVideoRequest {
prompt: "gentle wind moves through the scene as clouds drift overhead".to_string(),
image_url: EXAMPLE_IMAGE_URL.to_string(),
duration: Some(Flux3Duration::Seconds(5)),
aspect_ratio: None,
generate_audio: Some(false),
safety_tolerance: None,
};
let result = request.send_queue_request(&api_key).await?;
println!("Queue result — request_id: {}", result.request_id);
assert!(!result.request_id.is_empty());
Ok(())
}
// ── Wire-shape sanity (no API calls) ──
#[test]
fn raw_request_maps_all_fields() {
let request = Flux3DraftImageToVideoRequest {
prompt: "p".to_string(),
image_url: "https://example.com/frame.png".to_string(),
duration: Some(Flux3Duration::Seconds(8)),
aspect_ratio: Some(Flux3AspectRatio::NineBySixteen),
generate_audio: Some(false),
safety_tolerance: Some(1),
};
let raw = request.to_raw_request().unwrap();
assert_eq!(raw.prompt, "p");
assert_eq!(raw.image_url, "https://example.com/frame.png");
assert_eq!(raw.duration, Some(Flux3Duration::Seconds(8)));
assert_eq!(raw.aspect_ratio.as_deref(), Some("9:16"));
assert_eq!(raw.generate_audio, Some(false));
assert_eq!(raw.safety_tolerance, Some(1));
}
#[test]
fn raw_request_omits_unset_optionals() {
let request = Flux3DraftImageToVideoRequest {
prompt: "minimal".to_string(),
image_url: "https://example.com/frame.png".to_string(),
duration: None,
aspect_ratio: None,
generate_audio: None,
safety_tolerance: None,
};
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
assert_eq!(json, serde_json::json!({
"prompt": "minimal",
"image_url": "https://example.com/frame.png",
}));
}
#[test]
fn endpoint_path_is_canonical() {
assert_eq!(Flux3DraftImageToVideoRequest::ENDPOINT, "blackforestlabs/flux-3/image-to-video/draft");
}
// NB: Pricing tests are in cost.rs
}
@@ -1,54 +0,0 @@
use crate::requests::api::video::image::flux_3_draft::api::Flux3DraftImageToVideoRequest;
use crate::requests::api::video::text::flux_3::cost::{
flux_3_estimated_duration_secs, flux_3_video_cost_cents,
};
use crate::requests::api::video::text::flux_3_draft::cost::flux_3_draft_generation_rate_cents_per_sec;
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
// Draft image-to-video shares the Flux 3 Draft generation pricing (see the
// draft text module for the canonical rate):
// $0.06 per second of generated draft video (always 720p)
impl FalRequestCostCalculator for Flux3DraftImageToVideoRequest {
fn calculate_cost_in_cents(&self) -> UsdCents {
let duration_secs = flux_3_estimated_duration_secs(self.duration);
flux_3_video_cost_cents(flux_3_draft_generation_rate_cents_per_sec(), duration_secs)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::requests::api::video::text::flux_3::api::Flux3Duration;
fn make_request(duration: Option<Flux3Duration>) -> Flux3DraftImageToVideoRequest {
Flux3DraftImageToVideoRequest {
prompt: "test".to_string(),
image_url: "https://example.com/frame.png".to_string(),
duration,
aspect_ratio: None,
generate_audio: None,
safety_tolerance: None,
}
}
mod cost_table {
use super::*;
// (duration, expected_cents) — 6¢ × secs; None/auto estimate at 5s.
const COST_TABLE: &[(Option<Flux3Duration>, u64)] = &[
(Some(Flux3Duration::Seconds(5)), 30),
(Some(Flux3Duration::Seconds(20)), 120),
(None, 30),
(Some(Flux3Duration::Auto), 30),
];
#[test]
fn matches_cost_table() {
for &(duration, expected) in COST_TABLE {
let got = make_request(duration).calculate_cost_in_cents();
assert_eq!(got, expected, "duration={duration:?}");
}
}
}
}
@@ -1,3 +0,0 @@
pub mod api;
pub mod cost;
pub mod raw_request;
@@ -1,46 +0,0 @@
use serde::{Deserialize, Serialize};
use crate::requests::api::video::text::flux_3::api::Flux3Duration;
/// Over-the-wire input shape for `blackforestlabs/flux-3/image-to-video/draft`.
/// fal's schema: <https://fal.ai/models/blackforestlabs/flux-3/image-to-video/draft/api>
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct Flux3DraftImageToVideoInput {
/// Text prompt for video generation.
pub prompt: String,
/// URL of the image to animate.
pub image_url: String,
/// Duration in seconds ("auto" or 5–20). fal default: "auto".
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<Flux3Duration>,
/// Aspect ratio.
/// Possible values: "auto", "21:9", "2:1", "16:9", "4:3", "1:1", "3:4",
/// "9:16". fal default: "auto".
#[serde(skip_serializing_if = "Option::is_none")]
pub aspect_ratio: Option<String>,
/// Whether to generate synchronized audio. fal default: true.
#[serde(skip_serializing_if = "Option::is_none")]
pub generate_audio: Option<bool>,
/// Safety tolerance, 0 (strictest) to 4 (most permissive). fal default: 2.
#[serde(skip_serializing_if = "Option::is_none")]
pub safety_tolerance: Option<u8>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Flux3DraftImageToVideoVideoFile {
pub url: String,
}
/// Draft outputs carry the 720p draft video plus a durable encrypted cache
/// bundle whose URL can be passed to `draft-enhance` for a full-quality
/// 1080p render.
#[derive(Debug, Serialize, Deserialize)]
pub struct Flux3DraftImageToVideoOutput {
pub video: Flux3DraftImageToVideoVideoFile,
pub draft_cache: Flux3DraftImageToVideoVideoFile,
}
@@ -1,5 +1,3 @@
pub mod flux_3;
pub mod flux_3_draft;
pub mod kling_1p6_pro_image_to_video;
pub mod kling_2p5_turbo_pro_image_to_video;
pub mod kling_2p6_pro_image_to_video;
@@ -1,148 +0,0 @@
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::api::video::images::flux_3::raw_request::{
Flux3FirstLastFrameToVideoInput, Flux3FirstLastFrameToVideoOutput,
};
use crate::requests::api::video::text::flux_3::api::{Flux3AspectRatio, Flux3Resolution};
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
#[derive(Clone, Debug)]
pub struct Flux3FirstLastFrameToVideoRequest {
/// Text prompt for video generation.
pub prompt: String,
/// URL of the first frame.
pub start_image_url: String,
/// URL of the last frame.
pub end_image_url: String,
/// Duration in seconds. Valid range 5–20; fal's default is `5` when `None`.
pub duration: Option<u8>,
/// Output resolution. fal's default is `720p` when `None`.
pub resolution: Option<Flux3Resolution>,
/// Aspect ratio. fal's default is `auto` when `None`.
pub aspect_ratio: Option<Flux3AspectRatio>,
/// Whether to generate synchronized audio. fal's default is `true` when
/// `None`. Audio does not affect the bill.
pub generate_audio: Option<bool>,
/// Safety tolerance, 0 (strictest) to 4 (most permissive). fal's default is
/// `2` when `None`.
pub safety_tolerance: Option<u8>,
}
impl FalEndpoint for Flux3FirstLastFrameToVideoRequest {
const ENDPOINT: &str = "blackforestlabs/flux-3/first-last-frame-to-video";
type RawRequest = Flux3FirstLastFrameToVideoInput;
type RawResponse = Flux3FirstLastFrameToVideoOutput;
fn to_raw_request(&self) -> Result<Self::RawRequest, FalErrorPlus> {
Ok(Self::RawRequest {
prompt: self.prompt.clone(),
start_image_url: self.start_image_url.clone(),
end_image_url: self.end_image_url.clone(),
duration: self.duration,
resolution: self.resolution.map(|r| r.to_str().to_string()),
aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()),
generate_audio: self.generate_audio,
safety_tolerance: self.safety_tolerance,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::creds::fal_api_key::FalApiKey;
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
use errors::AnyhowResult;
use std::fs::read_to_string;
const START_IMAGE_URL: &str =
"https://storage.googleapis.com/falserverless/example_inputs/veo31-flf2v-input-1.jpeg";
const END_IMAGE_URL: &str =
"https://storage.googleapis.com/falserverless/example_inputs/veo31-flf2v-input-2.jpeg";
// ── Real requests (manually run; require a live key and cost money) ──
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_first_last_frame_to_video_queue() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = Flux3FirstLastFrameToVideoRequest {
prompt: "the scene smoothly transitions from the first frame to the last".to_string(),
start_image_url: START_IMAGE_URL.to_string(),
end_image_url: END_IMAGE_URL.to_string(),
duration: Some(5),
resolution: Some(Flux3Resolution::SevenTwentyP),
aspect_ratio: None,
generate_audio: Some(false),
safety_tolerance: None,
};
let result = request.send_queue_request(&api_key).await?;
println!("Queue result — request_id: {}", result.request_id);
assert!(!result.request_id.is_empty());
Ok(())
}
// ── Wire-shape sanity (no API calls) ──
#[test]
fn raw_request_maps_all_fields() {
let request = Flux3FirstLastFrameToVideoRequest {
prompt: "p".to_string(),
start_image_url: "https://example.com/first.png".to_string(),
end_image_url: "https://example.com/last.png".to_string(),
duration: Some(12),
resolution: Some(Flux3Resolution::TenEightyP),
aspect_ratio: Some(Flux3AspectRatio::FourByThree),
generate_audio: Some(true),
safety_tolerance: Some(3),
};
let raw = request.to_raw_request().unwrap();
assert_eq!(raw.prompt, "p");
assert_eq!(raw.start_image_url, "https://example.com/first.png");
assert_eq!(raw.end_image_url, "https://example.com/last.png");
assert_eq!(raw.duration, Some(12));
assert_eq!(raw.resolution.as_deref(), Some("1080p"));
assert_eq!(raw.aspect_ratio.as_deref(), Some("4:3"));
assert_eq!(raw.generate_audio, Some(true));
assert_eq!(raw.safety_tolerance, Some(3));
}
#[test]
fn raw_request_omits_unset_optionals() {
let request = Flux3FirstLastFrameToVideoRequest {
prompt: "minimal".to_string(),
start_image_url: "https://example.com/first.png".to_string(),
end_image_url: "https://example.com/last.png".to_string(),
duration: None,
resolution: None,
aspect_ratio: None,
generate_audio: None,
safety_tolerance: None,
};
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
assert_eq!(json, serde_json::json!({
"prompt": "minimal",
"start_image_url": "https://example.com/first.png",
"end_image_url": "https://example.com/last.png",
}));
}
#[test]
fn endpoint_path_is_canonical() {
assert_eq!(
Flux3FirstLastFrameToVideoRequest::ENDPOINT,
"blackforestlabs/flux-3/first-last-frame-to-video");
}
// NB: Pricing tests are in cost.rs
}
@@ -1,64 +0,0 @@
use crate::requests::api::video::images::flux_3::api::Flux3FirstLastFrameToVideoRequest;
use crate::requests::api::video::text::flux_3::cost::{
flux_3_is_1080p, flux_3_standard_rate_cents_per_sec, flux_3_video_cost_cents,
};
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
// First-last-frame-to-video shares the standard Flux 3 per-second pricing
// (see the text module for the canonical rate table):
// 720p: $0.17/sec
// 1080p: $0.29/sec
// The input frames are free; pricing depends on resolution and duration only.
impl FalRequestCostCalculator for Flux3FirstLastFrameToVideoRequest {
fn calculate_cost_in_cents(&self) -> UsdCents {
// fal default when unset: duration = 5s.
let duration_secs = u64::from(self.duration.unwrap_or(5));
let rate = flux_3_standard_rate_cents_per_sec(flux_3_is_1080p(self.resolution));
flux_3_video_cost_cents(rate, duration_secs)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::requests::api::video::text::flux_3::api::Flux3Resolution;
fn make_request(
duration: Option<u8>,
resolution: Option<Flux3Resolution>,
) -> Flux3FirstLastFrameToVideoRequest {
Flux3FirstLastFrameToVideoRequest {
prompt: "test".to_string(),
start_image_url: "https://example.com/first.png".to_string(),
end_image_url: "https://example.com/last.png".to_string(),
duration,
resolution,
aspect_ratio: None,
generate_audio: None,
safety_tolerance: None,
}
}
mod cost_table {
use super::*;
// (duration, resolution, expected_cents)
const COST_TABLE: &[(Option<u8>, Option<Flux3Resolution>, u64)] = &[
(Some(5), Some(Flux3Resolution::SevenTwentyP), 85),
(Some(20), Some(Flux3Resolution::SevenTwentyP), 340),
(Some(5), Some(Flux3Resolution::TenEightyP), 145),
(Some(20), Some(Flux3Resolution::TenEightyP), 580),
// Defaults: duration=None→5s, resolution=None→720p
(None, None, 85),
];
#[test]
fn matches_cost_table() {
for &(duration, resolution, expected) in COST_TABLE {
let got = make_request(duration, resolution).calculate_cost_in_cents();
assert_eq!(got, expected, "duration={duration:?} resolution={resolution:?}");
}
}
}
}
@@ -1,3 +0,0 @@
pub mod api;
pub mod cost;
pub mod raw_request;
@@ -1,48 +0,0 @@
use serde::{Deserialize, Serialize};
/// Over-the-wire input shape for `blackforestlabs/flux-3/first-last-frame-to-video`.
/// fal's schema: <https://fal.ai/models/blackforestlabs/flux-3/first-last-frame-to-video/api>
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct Flux3FirstLastFrameToVideoInput {
/// Text prompt for video generation.
pub prompt: String,
/// URL of the first frame.
pub start_image_url: String,
/// URL of the last frame.
pub end_image_url: String,
/// Duration in seconds (5–20). fal default: 5.
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<u8>,
/// Output resolution.
/// Possible values: "720p", "1080p". fal default: "720p".
#[serde(skip_serializing_if = "Option::is_none")]
pub resolution: Option<String>,
/// Aspect ratio.
/// Possible values: "auto", "21:9", "2:1", "16:9", "4:3", "1:1", "3:4",
/// "9:16". fal default: "auto".
#[serde(skip_serializing_if = "Option::is_none")]
pub aspect_ratio: Option<String>,
/// Whether to generate synchronized audio. fal default: true.
#[serde(skip_serializing_if = "Option::is_none")]
pub generate_audio: Option<bool>,
/// Safety tolerance, 0 (strictest) to 4 (most permissive). fal default: 2.
#[serde(skip_serializing_if = "Option::is_none")]
pub safety_tolerance: Option<u8>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Flux3FirstLastFrameToVideoVideoFile {
pub url: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Flux3FirstLastFrameToVideoOutput {
pub video: Flux3FirstLastFrameToVideoVideoFile,
}
@@ -1,143 +0,0 @@
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::api::video::images::flux_3_draft::raw_request::{
Flux3DraftFirstLastFrameToVideoInput, Flux3DraftFirstLastFrameToVideoOutput,
};
use crate::requests::api::video::text::flux_3::api::Flux3AspectRatio;
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
/// Flux 3 Draft first-last-frame-to-video: lower cost, lower fidelity (always
/// 720p). The response includes a `draft_cache` that `draft-enhance` can
/// upgrade to a full-quality 1080p render.
#[derive(Clone, Debug)]
pub struct Flux3DraftFirstLastFrameToVideoRequest {
/// Text prompt for video generation.
pub prompt: String,
/// URL of the first frame.
pub start_image_url: String,
/// URL of the last frame.
pub end_image_url: String,
/// Duration in seconds. Valid range 5–20; fal's default is `5` when `None`.
pub duration: Option<u8>,
/// Aspect ratio. fal's default is `auto` when `None`.
pub aspect_ratio: Option<Flux3AspectRatio>,
/// Whether to generate synchronized audio. fal's default is `true` when
/// `None`. Audio does not affect the bill.
pub generate_audio: Option<bool>,
/// Safety tolerance, 0 (strictest) to 4 (most permissive). fal's default is
/// `2` when `None`.
pub safety_tolerance: Option<u8>,
}
impl FalEndpoint for Flux3DraftFirstLastFrameToVideoRequest {
const ENDPOINT: &str = "blackforestlabs/flux-3/first-last-frame-to-video/draft";
type RawRequest = Flux3DraftFirstLastFrameToVideoInput;
type RawResponse = Flux3DraftFirstLastFrameToVideoOutput;
fn to_raw_request(&self) -> Result<Self::RawRequest, FalErrorPlus> {
Ok(Self::RawRequest {
prompt: self.prompt.clone(),
start_image_url: self.start_image_url.clone(),
end_image_url: self.end_image_url.clone(),
duration: self.duration,
aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()),
generate_audio: self.generate_audio,
safety_tolerance: self.safety_tolerance,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::creds::fal_api_key::FalApiKey;
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
use errors::AnyhowResult;
use std::fs::read_to_string;
const START_IMAGE_URL: &str =
"https://storage.googleapis.com/falserverless/example_inputs/veo31-flf2v-input-1.jpeg";
const END_IMAGE_URL: &str =
"https://storage.googleapis.com/falserverless/example_inputs/veo31-flf2v-input-2.jpeg";
// ── Real requests (manually run; require a live key and cost money) ──
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_first_last_frame_to_video_queue() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = Flux3DraftFirstLastFrameToVideoRequest {
prompt: "the scene smoothly transitions from the first frame to the last".to_string(),
start_image_url: START_IMAGE_URL.to_string(),
end_image_url: END_IMAGE_URL.to_string(),
duration: Some(5),
aspect_ratio: None,
generate_audio: Some(false),
safety_tolerance: None,
};
let result = request.send_queue_request(&api_key).await?;
println!("Queue result — request_id: {}", result.request_id);
assert!(!result.request_id.is_empty());
Ok(())
}
// ── Wire-shape sanity (no API calls) ──
#[test]
fn raw_request_maps_all_fields() {
let request = Flux3DraftFirstLastFrameToVideoRequest {
prompt: "p".to_string(),
start_image_url: "https://example.com/first.png".to_string(),
end_image_url: "https://example.com/last.png".to_string(),
duration: Some(7),
aspect_ratio: Some(Flux3AspectRatio::Square),
generate_audio: Some(false),
safety_tolerance: Some(2),
};
let raw = request.to_raw_request().unwrap();
assert_eq!(raw.prompt, "p");
assert_eq!(raw.start_image_url, "https://example.com/first.png");
assert_eq!(raw.end_image_url, "https://example.com/last.png");
assert_eq!(raw.duration, Some(7));
assert_eq!(raw.aspect_ratio.as_deref(), Some("1:1"));
assert_eq!(raw.generate_audio, Some(false));
assert_eq!(raw.safety_tolerance, Some(2));
}
#[test]
fn raw_request_omits_unset_optionals() {
let request = Flux3DraftFirstLastFrameToVideoRequest {
prompt: "minimal".to_string(),
start_image_url: "https://example.com/first.png".to_string(),
end_image_url: "https://example.com/last.png".to_string(),
duration: None,
aspect_ratio: None,
generate_audio: None,
safety_tolerance: None,
};
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
assert_eq!(json, serde_json::json!({
"prompt": "minimal",
"start_image_url": "https://example.com/first.png",
"end_image_url": "https://example.com/last.png",
}));
}
#[test]
fn endpoint_path_is_canonical() {
assert_eq!(
Flux3DraftFirstLastFrameToVideoRequest::ENDPOINT,
"blackforestlabs/flux-3/first-last-frame-to-video/draft");
}
// NB: Pricing tests are in cost.rs
}
@@ -1,52 +0,0 @@
use crate::requests::api::video::images::flux_3_draft::api::Flux3DraftFirstLastFrameToVideoRequest;
use crate::requests::api::video::text::flux_3::cost::flux_3_video_cost_cents;
use crate::requests::api::video::text::flux_3_draft::cost::flux_3_draft_generation_rate_cents_per_sec;
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
// Draft first-last-frame-to-video shares the Flux 3 Draft generation pricing
// (see the draft text module for the canonical rate):
// $0.06 per second of generated draft video (always 720p)
impl FalRequestCostCalculator for Flux3DraftFirstLastFrameToVideoRequest {
fn calculate_cost_in_cents(&self) -> UsdCents {
// fal default when unset: duration = 5s.
let duration_secs = u64::from(self.duration.unwrap_or(5));
flux_3_video_cost_cents(flux_3_draft_generation_rate_cents_per_sec(), duration_secs)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_request(duration: Option<u8>) -> Flux3DraftFirstLastFrameToVideoRequest {
Flux3DraftFirstLastFrameToVideoRequest {
prompt: "test".to_string(),
start_image_url: "https://example.com/first.png".to_string(),
end_image_url: "https://example.com/last.png".to_string(),
duration,
aspect_ratio: None,
generate_audio: None,
safety_tolerance: None,
}
}
mod cost_table {
use super::*;
// (duration, expected_cents) — 6¢ × secs; None defaults to 5s.
const COST_TABLE: &[(Option<u8>, u64)] = &[
(Some(5), 30),
(Some(20), 120),
(None, 30),
];
#[test]
fn matches_cost_table() {
for &(duration, expected) in COST_TABLE {
let got = make_request(duration).calculate_cost_in_cents();
assert_eq!(got, expected, "duration={duration:?}");
}
}
}
}
@@ -1,3 +0,0 @@
pub mod api;
pub mod cost;
pub mod raw_request;
@@ -1,47 +0,0 @@
use serde::{Deserialize, Serialize};
/// Over-the-wire input shape for `blackforestlabs/flux-3/first-last-frame-to-video/draft`.
/// fal's schema: <https://fal.ai/models/blackforestlabs/flux-3/first-last-frame-to-video/draft/api>
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct Flux3DraftFirstLastFrameToVideoInput {
/// Text prompt for video generation.
pub prompt: String,
/// URL of the first frame.
pub start_image_url: String,
/// URL of the last frame.
pub end_image_url: String,
/// Duration in seconds (5–20). fal default: 5.
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<u8>,
/// Aspect ratio.
/// Possible values: "auto", "21:9", "2:1", "16:9", "4:3", "1:1", "3:4",
/// "9:16". fal default: "auto".
#[serde(skip_serializing_if = "Option::is_none")]
pub aspect_ratio: Option<String>,
/// Whether to generate synchronized audio. fal default: true.
#[serde(skip_serializing_if = "Option::is_none")]
pub generate_audio: Option<bool>,
/// Safety tolerance, 0 (strictest) to 4 (most permissive). fal default: 2.
#[serde(skip_serializing_if = "Option::is_none")]
pub safety_tolerance: Option<u8>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Flux3DraftFirstLastFrameToVideoVideoFile {
pub url: String,
}
/// Draft outputs carry the 720p draft video plus a durable encrypted cache
/// bundle whose URL can be passed to `draft-enhance` for a full-quality
/// 1080p render.
#[derive(Debug, Serialize, Deserialize)]
pub struct Flux3DraftFirstLastFrameToVideoOutput {
pub video: Flux3DraftFirstLastFrameToVideoVideoFile,
pub draft_cache: Flux3DraftFirstLastFrameToVideoVideoFile,
}
@@ -1,5 +1,3 @@
pub mod flux_3;
pub mod flux_3_draft;
pub mod veo_3p1;
pub mod veo_3p1_fast;
pub mod veo_3p1_lite;
@@ -1,150 +0,0 @@
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::api::video::keyframes::flux_3::raw_request::{
Flux3Keyframe, Flux3KeyframesToVideoInput, Flux3KeyframesToVideoOutput,
};
use crate::requests::api::video::text::flux_3::api::{Flux3AspectRatio, Flux3Resolution};
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
#[derive(Clone, Debug)]
pub struct Flux3KeyframesToVideoRequest {
/// Text prompt for video generation.
pub prompt: String,
/// Keyframe images pinned to frame positions in the generated 24 fps video
/// (1 to 10 entries with unique `frame_index` values, each at most
/// `duration * 24`).
pub keyframes: Vec<Flux3Keyframe>,
/// Duration in seconds. Valid range 5–20; fal's default is `5` when `None`.
pub duration: Option<u8>,
/// Output resolution. fal's default is `720p` when `None`.
pub resolution: Option<Flux3Resolution>,
/// Aspect ratio. fal's default is `auto` when `None`.
pub aspect_ratio: Option<Flux3AspectRatio>,
/// Whether to generate synchronized audio. fal's default is `true` when
/// `None`. Audio does not affect the bill.
pub generate_audio: Option<bool>,
/// Safety tolerance, 0 (strictest) to 4 (most permissive). fal's default is
/// `2` when `None`.
pub safety_tolerance: Option<u8>,
}
impl FalEndpoint for Flux3KeyframesToVideoRequest {
const ENDPOINT: &str = "blackforestlabs/flux-3/keyframes-to-video";
type RawRequest = Flux3KeyframesToVideoInput;
type RawResponse = Flux3KeyframesToVideoOutput;
fn to_raw_request(&self) -> Result<Self::RawRequest, FalErrorPlus> {
Ok(Self::RawRequest {
prompt: self.prompt.clone(),
keyframes: self.keyframes.clone(),
duration: self.duration,
resolution: self.resolution.map(|r| r.to_str().to_string()),
aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()),
generate_audio: self.generate_audio,
safety_tolerance: self.safety_tolerance,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::creds::fal_api_key::FalApiKey;
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
use errors::AnyhowResult;
use std::fs::read_to_string;
const KEYFRAME_ONE_URL: &str =
"https://storage.googleapis.com/falserverless/example_inputs/veo31-flf2v-input-1.jpeg";
const KEYFRAME_TWO_URL: &str =
"https://storage.googleapis.com/falserverless/example_inputs/veo31-flf2v-input-2.jpeg";
// ── Real requests (manually run; require a live key and cost money) ──
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_keyframes_to_video_queue() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = Flux3KeyframesToVideoRequest {
prompt: "the scene flows naturally between the pinned keyframes".to_string(),
keyframes: vec![
Flux3Keyframe { image_url: KEYFRAME_ONE_URL.to_string(), frame_index: 0 },
Flux3Keyframe { image_url: KEYFRAME_TWO_URL.to_string(), frame_index: 96 },
],
duration: Some(5),
resolution: Some(Flux3Resolution::SevenTwentyP),
aspect_ratio: None,
generate_audio: Some(false),
safety_tolerance: None,
};
let result = request.send_queue_request(&api_key).await?;
println!("Queue result — request_id: {}", result.request_id);
assert!(!result.request_id.is_empty());
Ok(())
}
// ── Wire-shape sanity (no API calls) ──
#[test]
fn raw_request_maps_all_fields() {
let request = Flux3KeyframesToVideoRequest {
prompt: "p".to_string(),
keyframes: vec![
Flux3Keyframe { image_url: "https://example.com/a.png".to_string(), frame_index: 0 },
Flux3Keyframe { image_url: "https://example.com/b.png".to_string(), frame_index: 120 },
],
duration: Some(10),
resolution: Some(Flux3Resolution::TenEightyP),
aspect_ratio: Some(Flux3AspectRatio::SixteenByNine),
generate_audio: Some(true),
safety_tolerance: Some(2),
};
let raw = request.to_raw_request().unwrap();
assert_eq!(raw.prompt, "p");
assert_eq!(raw.keyframes.len(), 2);
assert_eq!(raw.keyframes[0].image_url, "https://example.com/a.png");
assert_eq!(raw.keyframes[0].frame_index, 0);
assert_eq!(raw.keyframes[1].frame_index, 120);
assert_eq!(raw.duration, Some(10));
assert_eq!(raw.resolution.as_deref(), Some("1080p"));
assert_eq!(raw.aspect_ratio.as_deref(), Some("16:9"));
}
#[test]
fn raw_request_omits_unset_optionals() {
let request = Flux3KeyframesToVideoRequest {
prompt: "minimal".to_string(),
keyframes: vec![
Flux3Keyframe { image_url: "https://example.com/a.png".to_string(), frame_index: 0 },
],
duration: None,
resolution: None,
aspect_ratio: None,
generate_audio: None,
safety_tolerance: None,
};
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
assert_eq!(json, serde_json::json!({
"prompt": "minimal",
"keyframes": [
{ "image_url": "https://example.com/a.png", "frame_index": 0 },
],
}));
}
#[test]
fn endpoint_path_is_canonical() {
assert_eq!(Flux3KeyframesToVideoRequest::ENDPOINT, "blackforestlabs/flux-3/keyframes-to-video");
}
// NB: Pricing tests are in cost.rs
}
@@ -1,78 +0,0 @@
use crate::requests::api::video::keyframes::flux_3::api::Flux3KeyframesToVideoRequest;
use crate::requests::api::video::text::flux_3::cost::{
flux_3_is_1080p, flux_3_standard_rate_cents_per_sec, flux_3_video_cost_cents,
};
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
// Keyframes-to-video shares the standard Flux 3 per-second pricing (see the
// text module for the canonical rate table):
// 720p: $0.17/sec
// 1080p: $0.29/sec
// Keyframe images are free; pricing depends on resolution and duration only.
impl FalRequestCostCalculator for Flux3KeyframesToVideoRequest {
fn calculate_cost_in_cents(&self) -> UsdCents {
// fal default when unset: duration = 5s.
let duration_secs = u64::from(self.duration.unwrap_or(5));
let rate = flux_3_standard_rate_cents_per_sec(flux_3_is_1080p(self.resolution));
flux_3_video_cost_cents(rate, duration_secs)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::requests::api::video::keyframes::flux_3::raw_request::Flux3Keyframe;
use crate::requests::api::video::text::flux_3::api::Flux3Resolution;
fn make_request(
duration: Option<u8>,
resolution: Option<Flux3Resolution>,
keyframe_count: u32,
) -> Flux3KeyframesToVideoRequest {
Flux3KeyframesToVideoRequest {
prompt: "test".to_string(),
keyframes: (0..keyframe_count)
.map(|i| Flux3Keyframe {
image_url: format!("https://example.com/kf-{i}.png"),
frame_index: i * 24,
})
.collect(),
duration,
resolution,
aspect_ratio: None,
generate_audio: None,
safety_tolerance: None,
}
}
mod cost_table {
use super::*;
// (duration, resolution, expected_cents)
const COST_TABLE: &[(Option<u8>, Option<Flux3Resolution>, u64)] = &[
(Some(5), Some(Flux3Resolution::SevenTwentyP), 85),
(Some(20), Some(Flux3Resolution::SevenTwentyP), 340),
(Some(5), Some(Flux3Resolution::TenEightyP), 145),
(Some(20), Some(Flux3Resolution::TenEightyP), 580),
// Defaults: duration=None→5s, resolution=None→720p
(None, None, 85),
];
#[test]
fn matches_cost_table() {
for &(duration, resolution, expected) in COST_TABLE {
let got = make_request(duration, resolution, 2).calculate_cost_in_cents();
assert_eq!(got, expected, "duration={duration:?} resolution={resolution:?}");
}
}
/// The number of keyframes does not affect the bill.
#[test]
fn cost_is_independent_of_keyframe_count() {
let one = make_request(Some(10), None, 1).calculate_cost_in_cents();
let ten = make_request(Some(10), None, 10).calculate_cost_in_cents();
assert_eq!(one, ten);
}
}
}
@@ -1,3 +0,0 @@
pub mod api;
pub mod cost;
pub mod raw_request;
@@ -1,58 +0,0 @@
use serde::{Deserialize, Serialize};
/// Over-the-wire input shape for `blackforestlabs/flux-3/keyframes-to-video`.
/// fal's schema: <https://fal.ai/models/blackforestlabs/flux-3/keyframes-to-video/api>
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct Flux3KeyframesToVideoInput {
/// Text prompt for video generation.
pub prompt: String,
/// Keyframe images pinned to frame positions (1 to 10 entries with unique
/// `frame_index` values).
pub keyframes: Vec<Flux3Keyframe>,
/// Duration in seconds (5–20). fal default: 5.
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<u8>,
/// Output resolution.
/// Possible values: "720p", "1080p". fal default: "720p".
#[serde(skip_serializing_if = "Option::is_none")]
pub resolution: Option<String>,
/// Aspect ratio.
/// Possible values: "auto", "21:9", "2:1", "16:9", "4:3", "1:1", "3:4",
/// "9:16". fal default: "auto".
#[serde(skip_serializing_if = "Option::is_none")]
pub aspect_ratio: Option<String>,
/// Whether to generate synchronized audio. fal default: true.
#[serde(skip_serializing_if = "Option::is_none")]
pub generate_audio: Option<bool>,
/// Safety tolerance, 0 (strictest) to 4 (most permissive). fal default: 2.
#[serde(skip_serializing_if = "Option::is_none")]
pub safety_tolerance: Option<u8>,
}
/// A keyframe image pinned to a frame position in the generated 24 fps video.
/// Shared by the full-quality and draft keyframes endpoints.
#[derive(Clone, Debug, Serialize, Deserialize, Default, Eq, PartialEq)]
pub struct Flux3Keyframe {
/// URL of the keyframe image (PNG, JPEG, or WebP).
pub image_url: String,
/// Frame position of this keyframe in the generated 24 fps video. Must be
/// unique and at most `duration * 24`.
pub frame_index: u32,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Flux3KeyframesToVideoVideoFile {
pub url: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Flux3KeyframesToVideoOutput {
pub video: Flux3KeyframesToVideoVideoFile,
}
@@ -1,147 +0,0 @@
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::api::video::keyframes::flux_3::raw_request::Flux3Keyframe;
use crate::requests::api::video::keyframes::flux_3_draft::raw_request::{
Flux3DraftKeyframesToVideoInput, Flux3DraftKeyframesToVideoOutput,
};
use crate::requests::api::video::text::flux_3::api::Flux3AspectRatio;
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
/// Flux 3 Draft keyframes-to-video: lower cost, lower fidelity (always 720p).
/// The response includes a `draft_cache` that `draft-enhance` can upgrade to
/// a full-quality 1080p render.
#[derive(Clone, Debug)]
pub struct Flux3DraftKeyframesToVideoRequest {
/// Text prompt for video generation.
pub prompt: String,
/// Keyframe images pinned to frame positions in the generated 24 fps video
/// (1 to 10 entries with unique `frame_index` values, each at most
/// `duration * 24`).
pub keyframes: Vec<Flux3Keyframe>,
/// Duration in seconds. Valid range 5–20; fal's default is `5` when `None`.
pub duration: Option<u8>,
/// Aspect ratio. fal's default is `auto` when `None`.
pub aspect_ratio: Option<Flux3AspectRatio>,
/// Whether to generate synchronized audio. fal's default is `true` when
/// `None`. Audio does not affect the bill.
pub generate_audio: Option<bool>,
/// Safety tolerance, 0 (strictest) to 4 (most permissive). fal's default is
/// `2` when `None`.
pub safety_tolerance: Option<u8>,
}
impl FalEndpoint for Flux3DraftKeyframesToVideoRequest {
const ENDPOINT: &str = "blackforestlabs/flux-3/keyframes-to-video/draft";
type RawRequest = Flux3DraftKeyframesToVideoInput;
type RawResponse = Flux3DraftKeyframesToVideoOutput;
fn to_raw_request(&self) -> Result<Self::RawRequest, FalErrorPlus> {
Ok(Self::RawRequest {
prompt: self.prompt.clone(),
keyframes: self.keyframes.clone(),
duration: self.duration,
aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()),
generate_audio: self.generate_audio,
safety_tolerance: self.safety_tolerance,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::creds::fal_api_key::FalApiKey;
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
use errors::AnyhowResult;
use std::fs::read_to_string;
const KEYFRAME_ONE_URL: &str =
"https://storage.googleapis.com/falserverless/example_inputs/veo31-flf2v-input-1.jpeg";
const KEYFRAME_TWO_URL: &str =
"https://storage.googleapis.com/falserverless/example_inputs/veo31-flf2v-input-2.jpeg";
// ── Real requests (manually run; require a live key and cost money) ──
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_keyframes_to_video_queue() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = Flux3DraftKeyframesToVideoRequest {
prompt: "the scene flows naturally between the pinned keyframes".to_string(),
keyframes: vec![
Flux3Keyframe { image_url: KEYFRAME_ONE_URL.to_string(), frame_index: 0 },
Flux3Keyframe { image_url: KEYFRAME_TWO_URL.to_string(), frame_index: 96 },
],
duration: Some(5),
aspect_ratio: None,
generate_audio: Some(false),
safety_tolerance: None,
};
let result = request.send_queue_request(&api_key).await?;
println!("Queue result — request_id: {}", result.request_id);
assert!(!result.request_id.is_empty());
Ok(())
}
// ── Wire-shape sanity (no API calls) ──
#[test]
fn raw_request_maps_all_fields() {
let request = Flux3DraftKeyframesToVideoRequest {
prompt: "p".to_string(),
keyframes: vec![
Flux3Keyframe { image_url: "https://example.com/a.png".to_string(), frame_index: 48 },
],
duration: Some(6),
aspect_ratio: Some(Flux3AspectRatio::TwentyOneByNine),
generate_audio: Some(false),
safety_tolerance: Some(0),
};
let raw = request.to_raw_request().unwrap();
assert_eq!(raw.prompt, "p");
assert_eq!(raw.keyframes.len(), 1);
assert_eq!(raw.keyframes[0].frame_index, 48);
assert_eq!(raw.duration, Some(6));
assert_eq!(raw.aspect_ratio.as_deref(), Some("21:9"));
assert_eq!(raw.generate_audio, Some(false));
assert_eq!(raw.safety_tolerance, Some(0));
}
#[test]
fn raw_request_omits_unset_optionals() {
let request = Flux3DraftKeyframesToVideoRequest {
prompt: "minimal".to_string(),
keyframes: vec![
Flux3Keyframe { image_url: "https://example.com/a.png".to_string(), frame_index: 0 },
],
duration: None,
aspect_ratio: None,
generate_audio: None,
safety_tolerance: None,
};
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
assert_eq!(json, serde_json::json!({
"prompt": "minimal",
"keyframes": [
{ "image_url": "https://example.com/a.png", "frame_index": 0 },
],
}));
}
#[test]
fn endpoint_path_is_canonical() {
assert_eq!(
Flux3DraftKeyframesToVideoRequest::ENDPOINT,
"blackforestlabs/flux-3/keyframes-to-video/draft");
}
// NB: Pricing tests are in cost.rs
}
@@ -1,54 +0,0 @@
use crate::requests::api::video::keyframes::flux_3_draft::api::Flux3DraftKeyframesToVideoRequest;
use crate::requests::api::video::text::flux_3::cost::flux_3_video_cost_cents;
use crate::requests::api::video::text::flux_3_draft::cost::flux_3_draft_generation_rate_cents_per_sec;
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
// Draft keyframes-to-video shares the Flux 3 Draft generation pricing (see
// the draft text module for the canonical rate):
// $0.06 per second of generated draft video (always 720p)
impl FalRequestCostCalculator for Flux3DraftKeyframesToVideoRequest {
fn calculate_cost_in_cents(&self) -> UsdCents {
// fal default when unset: duration = 5s.
let duration_secs = u64::from(self.duration.unwrap_or(5));
flux_3_video_cost_cents(flux_3_draft_generation_rate_cents_per_sec(), duration_secs)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::requests::api::video::keyframes::flux_3::raw_request::Flux3Keyframe;
fn make_request(duration: Option<u8>) -> Flux3DraftKeyframesToVideoRequest {
Flux3DraftKeyframesToVideoRequest {
prompt: "test".to_string(),
keyframes: vec![
Flux3Keyframe { image_url: "https://example.com/a.png".to_string(), frame_index: 0 },
],
duration,
aspect_ratio: None,
generate_audio: None,
safety_tolerance: None,
}
}
mod cost_table {
use super::*;
// (duration, expected_cents) — 6¢ × secs; None defaults to 5s.
const COST_TABLE: &[(Option<u8>, u64)] = &[
(Some(5), 30),
(Some(20), 120),
(None, 30),
];
#[test]
fn matches_cost_table() {
for &(duration, expected) in COST_TABLE {
let got = make_request(duration).calculate_cost_in_cents();
assert_eq!(got, expected, "duration={duration:?}");
}
}
}
}
@@ -1,3 +0,0 @@
pub mod api;
pub mod cost;
pub mod raw_request;
@@ -1,47 +0,0 @@
use serde::{Deserialize, Serialize};
use crate::requests::api::video::keyframes::flux_3::raw_request::Flux3Keyframe;
/// Over-the-wire input shape for `blackforestlabs/flux-3/keyframes-to-video/draft`.
/// fal's schema: <https://fal.ai/models/blackforestlabs/flux-3/keyframes-to-video/draft/api>
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct Flux3DraftKeyframesToVideoInput {
/// Text prompt for video generation.
pub prompt: String,
/// Keyframe images pinned to frame positions (1 to 10 entries with unique
/// `frame_index` values).
pub keyframes: Vec<Flux3Keyframe>,
/// Duration in seconds (5–20). fal default: 5.
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<u8>,
/// Aspect ratio.
/// Possible values: "auto", "21:9", "2:1", "16:9", "4:3", "1:1", "3:4",
/// "9:16". fal default: "auto".
#[serde(skip_serializing_if = "Option::is_none")]
pub aspect_ratio: Option<String>,
/// Whether to generate synchronized audio. fal default: true.
#[serde(skip_serializing_if = "Option::is_none")]
pub generate_audio: Option<bool>,
/// Safety tolerance, 0 (strictest) to 4 (most permissive). fal default: 2.
#[serde(skip_serializing_if = "Option::is_none")]
pub safety_tolerance: Option<u8>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Flux3DraftKeyframesToVideoVideoFile {
pub url: String,
}
/// Draft outputs carry the 720p draft video plus a durable encrypted cache
/// bundle whose URL can be passed to `draft-enhance` for a full-quality
/// 1080p render.
#[derive(Debug, Serialize, Deserialize)]
pub struct Flux3DraftKeyframesToVideoOutput {
pub video: Flux3DraftKeyframesToVideoVideoFile,
pub draft_cache: Flux3DraftKeyframesToVideoVideoFile,
}
@@ -1,2 +0,0 @@
pub mod flux_3;
pub mod flux_3_draft;
@@ -1,8 +1,6 @@
pub mod elements;
pub mod enhance;
pub mod extend;
pub mod image;
pub mod images;
pub mod keyframes;
pub mod reference;
pub mod text;
@@ -1,294 +0,0 @@
use std::fmt;
use serde::de::{self, Visitor};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::api::video::text::flux_3::raw_request::{
Flux3TextToVideoInput, Flux3TextToVideoOutput,
};
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
#[derive(Clone, Debug)]
pub struct Flux3TextToVideoRequest {
/// Text prompt for video generation.
pub prompt: String,
/// Duration in seconds. fal's default is `auto` when `None` (the model
/// picks a duration that fits the prompt).
pub duration: Option<Flux3Duration>,
/// Output resolution. fal's default is `720p` when `None`.
pub resolution: Option<Flux3Resolution>,
/// Aspect ratio. fal's default is `auto` when `None`.
pub aspect_ratio: Option<Flux3AspectRatio>,
/// Whether to generate synchronized audio. fal's default is `true` when
/// `None`. Audio does not affect the bill.
pub generate_audio: Option<bool>,
/// Safety tolerance, 0 (strictest) to 4 (most permissive). fal's default is
/// `2` when `None`.
pub safety_tolerance: Option<u8>,
}
/// Flux 3 durations: either `auto` (the model decides) or a whole number of
/// seconds in 5–20. Serializes as the string `"auto"` or a bare integer.
/// Shared by every Flux 3 / Flux 3 Draft video modality.
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Flux3Duration {
Auto,
Seconds(u8),
}
/// Flux 3 resolutions. 1080p bills at a higher per-second rate than 720p (see
/// the cost modules). Draft endpoints always render 720p and take no
/// resolution input. Shared by every full-quality Flux 3 video modality.
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Flux3Resolution {
SevenTwentyP,
TenEightyP,
}
/// Flux 3 aspect ratios. `auto` derives the ratio from the inputs (or the
/// prompt for text-to-video). Shared by every Flux 3 / Flux 3 Draft video
/// modality.
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Flux3AspectRatio {
Auto,
TwentyOneByNine,
TwoByOne,
SixteenByNine,
FourByThree,
Square,
ThreeByFour,
NineBySixteen,
}
impl Serialize for Flux3Duration {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
match self {
Self::Auto => serializer.serialize_str("auto"),
Self::Seconds(seconds) => serializer.serialize_u8(*seconds),
}
}
}
impl<'de> Deserialize<'de> for Flux3Duration {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
struct Flux3DurationVisitor;
impl Visitor<'_> for Flux3DurationVisitor {
type Value = Flux3Duration;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
formatter.write_str("the string \"auto\" or an integer number of seconds")
}
fn visit_str<E: de::Error>(self, value: &str) -> Result<Flux3Duration, E> {
if value == "auto" {
Ok(Flux3Duration::Auto)
} else {
Err(E::invalid_value(de::Unexpected::Str(value), &self))
}
}
fn visit_u64<E: de::Error>(self, value: u64) -> Result<Flux3Duration, E> {
u8::try_from(value)
.map(Flux3Duration::Seconds)
.map_err(|_| E::invalid_value(de::Unexpected::Unsigned(value), &self))
}
fn visit_i64<E: de::Error>(self, value: i64) -> Result<Flux3Duration, E> {
u8::try_from(value)
.map(Flux3Duration::Seconds)
.map_err(|_| E::invalid_value(de::Unexpected::Signed(value), &self))
}
}
deserializer.deserialize_any(Flux3DurationVisitor)
}
}
impl Flux3Resolution {
/// 1080p is billed at the higher per-second rate.
pub fn is_1080p(&self) -> bool {
matches!(self, Self::TenEightyP)
}
pub(crate) fn to_str(&self) -> &'static str {
match self {
Self::SevenTwentyP => "720p",
Self::TenEightyP => "1080p",
}
}
}
impl Flux3AspectRatio {
pub(crate) fn to_str(&self) -> &'static str {
match self {
Self::Auto => "auto",
Self::TwentyOneByNine => "21:9",
Self::TwoByOne => "2:1",
Self::SixteenByNine => "16:9",
Self::FourByThree => "4:3",
Self::Square => "1:1",
Self::ThreeByFour => "3:4",
Self::NineBySixteen => "9:16",
}
}
}
impl FalEndpoint for Flux3TextToVideoRequest {
const ENDPOINT: &str = "blackforestlabs/flux-3/text-to-video";
type RawRequest = Flux3TextToVideoInput;
type RawResponse = Flux3TextToVideoOutput;
fn to_raw_request(&self) -> Result<Self::RawRequest, FalErrorPlus> {
Ok(Self::RawRequest {
prompt: self.prompt.clone(),
duration: self.duration,
resolution: self.resolution.map(|r| r.to_str().to_string()),
aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()),
generate_audio: self.generate_audio,
safety_tolerance: self.safety_tolerance,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::creds::fal_api_key::FalApiKey;
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
use errors::AnyhowResult;
use std::fs::read_to_string;
// ── Real requests (manually run; require a live key and cost money) ──
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_text_to_video_webhook() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = Flux3TextToVideoRequest {
prompt: "a white kitten chases a butterfly across a sunlit garden".to_string(),
duration: Some(Flux3Duration::Seconds(5)),
resolution: Some(Flux3Resolution::SevenTwentyP),
aspect_ratio: Some(Flux3AspectRatio::SixteenByNine),
generate_audio: Some(false),
safety_tolerance: None,
};
let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?;
println!("Webhook result: {:?}", result);
assert!(result.request_id.is_some() || result.gateway_request_id.is_some());
Ok(())
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_text_to_video_queue() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = Flux3TextToVideoRequest {
prompt: "a wave crashes against a rocky shoreline at sunset".to_string(),
duration: Some(Flux3Duration::Seconds(5)),
resolution: Some(Flux3Resolution::SevenTwentyP),
aspect_ratio: Some(Flux3AspectRatio::SixteenByNine),
generate_audio: Some(false),
safety_tolerance: None,
};
let result = request.send_queue_request(&api_key).await?;
println!("Queue result — request_id: {}", result.request_id);
assert!(!result.request_id.is_empty());
Ok(())
}
// ── Wire-shape sanity (no API calls) ──
#[test]
fn raw_request_maps_all_fields() {
let request = Flux3TextToVideoRequest {
prompt: "p".to_string(),
duration: Some(Flux3Duration::Seconds(12)),
resolution: Some(Flux3Resolution::TenEightyP),
aspect_ratio: Some(Flux3AspectRatio::TwoByOne),
generate_audio: Some(false),
safety_tolerance: Some(4),
};
let raw = request.to_raw_request().unwrap();
assert_eq!(raw.prompt, "p");
assert_eq!(raw.duration, Some(Flux3Duration::Seconds(12)));
assert_eq!(raw.resolution.as_deref(), Some("1080p"));
assert_eq!(raw.aspect_ratio.as_deref(), Some("2:1"));
assert_eq!(raw.generate_audio, Some(false));
assert_eq!(raw.safety_tolerance, Some(4));
}
#[test]
fn raw_request_omits_unset_optionals() {
let request = Flux3TextToVideoRequest {
prompt: "minimal".to_string(),
duration: None,
resolution: None,
aspect_ratio: None,
generate_audio: None,
safety_tolerance: None,
};
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
assert_eq!(json, serde_json::json!({ "prompt": "minimal" }));
}
#[test]
fn duration_serializes_as_auto_string_or_bare_integer() {
assert_eq!(serde_json::to_value(Flux3Duration::Auto).unwrap(), serde_json::json!("auto"));
assert_eq!(serde_json::to_value(Flux3Duration::Seconds(12)).unwrap(), serde_json::json!(12));
}
#[test]
fn duration_deserializes_from_auto_string_or_integer() {
assert_eq!(serde_json::from_value::<Flux3Duration>(serde_json::json!("auto")).unwrap(), Flux3Duration::Auto);
assert_eq!(serde_json::from_value::<Flux3Duration>(serde_json::json!(7)).unwrap(), Flux3Duration::Seconds(7));
assert!(serde_json::from_value::<Flux3Duration>(serde_json::json!("fast")).is_err());
}
#[test]
fn every_resolution_maps_to_wire_string() {
for (variant, expected, is_1080p) in [
(Flux3Resolution::SevenTwentyP, "720p", false),
(Flux3Resolution::TenEightyP, "1080p", true),
] {
assert_eq!(variant.to_str(), expected);
assert_eq!(variant.is_1080p(), is_1080p);
}
}
#[test]
fn every_aspect_ratio_maps_to_wire_string() {
for (variant, expected) in [
(Flux3AspectRatio::Auto, "auto"),
(Flux3AspectRatio::TwentyOneByNine, "21:9"),
(Flux3AspectRatio::TwoByOne, "2:1"),
(Flux3AspectRatio::SixteenByNine, "16:9"),
(Flux3AspectRatio::FourByThree, "4:3"),
(Flux3AspectRatio::Square, "1:1"),
(Flux3AspectRatio::ThreeByFour, "3:4"),
(Flux3AspectRatio::NineBySixteen, "9:16"),
] {
assert_eq!(variant.to_str(), expected);
}
}
#[test]
fn endpoint_path_is_canonical() {
assert_eq!(Flux3TextToVideoRequest::ENDPOINT, "blackforestlabs/flux-3/text-to-video");
}
// NB: Pricing tests are in cost.rs
}
@@ -1,136 +0,0 @@
use crate::requests::api::video::text::flux_3::api::{
Flux3Duration, Flux3Resolution, Flux3TextToVideoRequest,
};
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
// Flux 3 pricing (see https://fal.ai/models/blackforestlabs/flux-3/text-to-video):
// 720p: $0.17 per second of generated video
// 1080p: $0.29 per second of generated video
//
// The same rates apply to the image-to-video, first-last-frame-to-video, and
// keyframes-to-video modalities (extend-video bills higher; see its cost
// module). Audio, aspect ratio, and safety tolerance are free. Both rates are
// exact whole cents.
const RATE_720P_CENTS_PER_SEC: u64 = 17; // $0.17/sec
const RATE_1080P_CENTS_PER_SEC: u64 = 29; // $0.29/sec
/// Duration assumed by cost estimates when the request leaves duration unset
/// or `auto` — fal bills the actual generated seconds, and 5s (the shortest
/// selectable duration) is the quotable floor.
const ASSUMED_AUTO_DURATION_SECS: u64 = 5;
/// Per-second rate in whole cents for the standard (non-extend) Flux 3
/// modalities. `is_1080p` selects the 1080p tier.
pub(crate) fn flux_3_standard_rate_cents_per_sec(is_1080p: bool) -> u64 {
if is_1080p { RATE_1080P_CENTS_PER_SEC } else { RATE_720P_CENTS_PER_SEC }
}
/// Per-second rate × duration → whole cents (all Flux 3 rates are exact
/// cents, so no rounding is needed).
pub(crate) fn flux_3_video_cost_cents(rate_cents_per_sec: u64, duration_secs: u64) -> UsdCents {
rate_cents_per_sec * duration_secs
}
/// Billable seconds for a flexible (`auto`-capable) duration. `None` and
/// `Auto` estimate at the 5-second floor.
pub(crate) fn flux_3_estimated_duration_secs(duration: Option<Flux3Duration>) -> u64 {
match duration {
None | Some(Flux3Duration::Auto) => ASSUMED_AUTO_DURATION_SECS,
Some(Flux3Duration::Seconds(seconds)) => u64::from(seconds),
}
}
/// Whether an optional resolution lands on the 1080p tier (fal defaults to
/// 720p when unset).
pub(crate) fn flux_3_is_1080p(resolution: Option<Flux3Resolution>) -> bool {
resolution.map_or(false, |r| r.is_1080p())
}
impl FalRequestCostCalculator for Flux3TextToVideoRequest {
fn calculate_cost_in_cents(&self) -> UsdCents {
let duration_secs = flux_3_estimated_duration_secs(self.duration);
let rate = flux_3_standard_rate_cents_per_sec(flux_3_is_1080p(self.resolution));
flux_3_video_cost_cents(rate, duration_secs)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::requests::api::video::text::flux_3::api::Flux3AspectRatio;
fn make_request(
duration: Option<Flux3Duration>,
resolution: Option<Flux3Resolution>,
) -> Flux3TextToVideoRequest {
Flux3TextToVideoRequest {
prompt: "test".to_string(),
duration,
resolution,
aspect_ratio: Some(Flux3AspectRatio::SixteenByNine),
generate_audio: Some(true),
safety_tolerance: None,
}
}
mod cost_table {
use super::*;
// (duration, resolution, expected_cents)
// Math: rate × secs where rate (cents) = 17 (720p) or 29 (1080p);
// None/auto durations are estimated at the 5s floor.
const COST_TABLE: &[(Option<Flux3Duration>, Option<Flux3Resolution>, u64)] = &[
// 720p → $0.17/s
(Some(Flux3Duration::Seconds(5)), Some(Flux3Resolution::SevenTwentyP), 85),
(Some(Flux3Duration::Seconds(10)), Some(Flux3Resolution::SevenTwentyP), 170),
(Some(Flux3Duration::Seconds(20)), Some(Flux3Resolution::SevenTwentyP), 340),
// 1080p → $0.29/s
(Some(Flux3Duration::Seconds(5)), Some(Flux3Resolution::TenEightyP), 145),
(Some(Flux3Duration::Seconds(20)), Some(Flux3Resolution::TenEightyP), 580),
// Defaults: duration=None/auto→5s estimate, resolution=None→720p
(None, None, 85),
(Some(Flux3Duration::Auto), None, 85),
(Some(Flux3Duration::Auto), Some(Flux3Resolution::TenEightyP), 145),
(None, Some(Flux3Resolution::TenEightyP), 145),
];
#[test]
fn matches_cost_table() {
for &(duration, resolution, expected) in COST_TABLE {
let got = make_request(duration, resolution).calculate_cost_in_cents();
assert_eq!(got, expected, "duration={duration:?} resolution={resolution:?}");
}
}
#[test]
fn ten_eighty_p_is_more_expensive_than_720p() {
let low = make_request(Some(Flux3Duration::Seconds(8)), Some(Flux3Resolution::SevenTwentyP)).calculate_cost_in_cents();
let high = make_request(Some(Flux3Duration::Seconds(8)), Some(Flux3Resolution::TenEightyP)).calculate_cost_in_cents();
assert!(high > low, "1080p={high}¢ should exceed 720p={low}¢");
}
#[test]
fn cost_scales_linearly_with_duration() {
let five = make_request(Some(Flux3Duration::Seconds(5)), None).calculate_cost_in_cents();
let ten = make_request(Some(Flux3Duration::Seconds(10)), None).calculate_cost_in_cents();
assert_eq!(ten, five * 2);
}
/// Audio, aspect ratio, and safety tolerance do not affect the bill.
#[test]
fn cost_ignores_audio_aspect_ratio_and_safety_tolerance() {
let baseline = make_request(Some(Flux3Duration::Seconds(8)), None).calculate_cost_in_cents();
for generate_audio in [None, Some(false), Some(true)] {
for safety_tolerance in [None, Some(0), Some(4)] {
let cost = Flux3TextToVideoRequest {
aspect_ratio: Some(Flux3AspectRatio::NineBySixteen),
generate_audio,
safety_tolerance,
..make_request(Some(Flux3Duration::Seconds(8)), None)
}.calculate_cost_in_cents();
assert_eq!(cost, baseline, "audio={generate_audio:?} safety={safety_tolerance:?}");
}
}
}
}
}
@@ -1,3 +0,0 @@
pub mod api;
pub mod cost;
pub mod raw_request;
@@ -1,45 +0,0 @@
use serde::{Deserialize, Serialize};
use crate::requests::api::video::text::flux_3::api::Flux3Duration;
/// Over-the-wire input shape for `blackforestlabs/flux-3/text-to-video`.
/// fal's schema: <https://fal.ai/models/blackforestlabs/flux-3/text-to-video/api>
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct Flux3TextToVideoInput {
/// Text prompt for video generation.
pub prompt: String,
/// Duration in seconds ("auto" or 5–20). fal default: "auto" (the model
/// picks a duration that fits the prompt).
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<Flux3Duration>,
/// Output resolution.
/// Possible values: "720p", "1080p". fal default: "720p".
#[serde(skip_serializing_if = "Option::is_none")]
pub resolution: Option<String>,
/// Aspect ratio.
/// Possible values: "auto", "21:9", "2:1", "16:9", "4:3", "1:1", "3:4",
/// "9:16". fal default: "auto".
#[serde(skip_serializing_if = "Option::is_none")]
pub aspect_ratio: Option<String>,
/// Whether to generate synchronized audio. fal default: true.
#[serde(skip_serializing_if = "Option::is_none")]
pub generate_audio: Option<bool>,
/// Safety tolerance, 0 (strictest) to 4 (most permissive). fal default: 2.
#[serde(skip_serializing_if = "Option::is_none")]
pub safety_tolerance: Option<u8>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Flux3VideoFile {
pub url: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Flux3TextToVideoOutput {
pub video: Flux3VideoFile,
}
@@ -1,149 +0,0 @@
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::api::video::text::flux_3::api::{Flux3AspectRatio, Flux3Duration};
use crate::requests::api::video::text::flux_3_draft::raw_request::{
Flux3DraftTextToVideoInput, Flux3DraftTextToVideoOutput,
};
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
/// Flux 3 Draft text-to-video: lower cost, lower fidelity (always 720p). The
/// response includes a `draft_cache` that `draft-enhance` can upgrade to a
/// full-quality 1080p render.
#[derive(Clone, Debug)]
pub struct Flux3DraftTextToVideoRequest {
/// Text prompt for video generation.
pub prompt: String,
/// Duration in seconds. fal's default is `auto` when `None` (the model
/// picks a duration that fits the prompt).
pub duration: Option<Flux3Duration>,
/// Aspect ratio. fal's default is `auto` when `None`.
pub aspect_ratio: Option<Flux3AspectRatio>,
/// Whether to generate synchronized audio. fal's default is `true` when
/// `None`. Audio does not affect the bill.
pub generate_audio: Option<bool>,
/// Safety tolerance, 0 (strictest) to 4 (most permissive). fal's default is
/// `2` when `None`.
pub safety_tolerance: Option<u8>,
}
impl FalEndpoint for Flux3DraftTextToVideoRequest {
const ENDPOINT: &str = "blackforestlabs/flux-3/text-to-video/draft";
type RawRequest = Flux3DraftTextToVideoInput;
type RawResponse = Flux3DraftTextToVideoOutput;
fn to_raw_request(&self) -> Result<Self::RawRequest, FalErrorPlus> {
Ok(Self::RawRequest {
prompt: self.prompt.clone(),
duration: self.duration,
aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()),
generate_audio: self.generate_audio,
safety_tolerance: self.safety_tolerance,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::creds::fal_api_key::FalApiKey;
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
use errors::AnyhowResult;
use std::fs::read_to_string;
// ── Real requests (manually run; require a live key and cost money) ──
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_text_to_video_webhook() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = Flux3DraftTextToVideoRequest {
prompt: "a white kitten chases a butterfly across a sunlit garden".to_string(),
duration: Some(Flux3Duration::Seconds(5)),
aspect_ratio: Some(Flux3AspectRatio::SixteenByNine),
generate_audio: Some(false),
safety_tolerance: None,
};
let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?;
println!("Webhook result: {:?}", result);
assert!(result.request_id.is_some() || result.gateway_request_id.is_some());
Ok(())
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_text_to_video_queue() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = Flux3DraftTextToVideoRequest {
prompt: "a wave crashes against a rocky shoreline at sunset".to_string(),
duration: Some(Flux3Duration::Seconds(5)),
aspect_ratio: Some(Flux3AspectRatio::SixteenByNine),
generate_audio: Some(false),
safety_tolerance: None,
};
let result = request.send_queue_request(&api_key).await?;
println!("Queue result — request_id: {}", result.request_id);
assert!(!result.request_id.is_empty());
Ok(())
}
// ── Wire-shape sanity (no API calls) ──
#[test]
fn raw_request_maps_all_fields() {
let request = Flux3DraftTextToVideoRequest {
prompt: "p".to_string(),
duration: Some(Flux3Duration::Seconds(12)),
aspect_ratio: Some(Flux3AspectRatio::ThreeByFour),
generate_audio: Some(false),
safety_tolerance: Some(4),
};
let raw = request.to_raw_request().unwrap();
assert_eq!(raw.prompt, "p");
assert_eq!(raw.duration, Some(Flux3Duration::Seconds(12)));
assert_eq!(raw.aspect_ratio.as_deref(), Some("3:4"));
assert_eq!(raw.generate_audio, Some(false));
assert_eq!(raw.safety_tolerance, Some(4));
}
#[test]
fn raw_request_omits_unset_optionals() {
let request = Flux3DraftTextToVideoRequest {
prompt: "minimal".to_string(),
duration: None,
aspect_ratio: None,
generate_audio: None,
safety_tolerance: None,
};
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
assert_eq!(json, serde_json::json!({ "prompt": "minimal" }));
}
#[test]
fn draft_output_parses_video_and_draft_cache() {
let json = serde_json::json!({
"video": { "url": "https://example.com/draft.mp4" },
"draft_cache": { "url": "https://example.com/cache.bin" },
"extra_field": true,
});
let output: Flux3DraftTextToVideoOutput = serde_json::from_value(json).unwrap();
assert_eq!(output.video.url, "https://example.com/draft.mp4");
assert_eq!(output.draft_cache.url, "https://example.com/cache.bin");
}
#[test]
fn endpoint_path_is_canonical() {
assert_eq!(Flux3DraftTextToVideoRequest::ENDPOINT, "blackforestlabs/flux-3/text-to-video/draft");
}
// NB: Pricing tests are in cost.rs
}
@@ -1,70 +0,0 @@
use crate::requests::api::video::text::flux_3::cost::{
flux_3_estimated_duration_secs, flux_3_video_cost_cents,
};
use crate::requests::api::video::text::flux_3_draft::api::Flux3DraftTextToVideoRequest;
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
// Flux 3 Draft pricing (see https://fal.ai/models/blackforestlabs/flux-3/text-to-video/draft):
// $0.06 per second of generated draft video (always 720p)
//
// The same rate applies to the draft image-to-video, first-last-frame, and
// keyframes modalities (draft extend-video bills higher; see its cost
// module). Audio, aspect ratio, and safety tolerance are free.
const DRAFT_RATE_CENTS_PER_SEC: u64 = 6; // $0.06/sec
/// Per-second rate in whole cents for the standard (non-extend) Flux 3 Draft
/// generation modalities.
pub(crate) fn flux_3_draft_generation_rate_cents_per_sec() -> u64 {
DRAFT_RATE_CENTS_PER_SEC
}
impl FalRequestCostCalculator for Flux3DraftTextToVideoRequest {
fn calculate_cost_in_cents(&self) -> UsdCents {
let duration_secs = flux_3_estimated_duration_secs(self.duration);
flux_3_video_cost_cents(flux_3_draft_generation_rate_cents_per_sec(), duration_secs)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::requests::api::video::text::flux_3::api::Flux3Duration;
fn make_request(duration: Option<Flux3Duration>) -> Flux3DraftTextToVideoRequest {
Flux3DraftTextToVideoRequest {
prompt: "test".to_string(),
duration,
aspect_ratio: None,
generate_audio: Some(true),
safety_tolerance: None,
}
}
mod cost_table {
use super::*;
// (duration, expected_cents)
// Math: 6¢ × secs; None/auto durations are estimated at the 5s floor.
const COST_TABLE: &[(Option<Flux3Duration>, u64)] = &[
(Some(Flux3Duration::Seconds(5)), 30),
(Some(Flux3Duration::Seconds(10)), 60),
(Some(Flux3Duration::Seconds(20)), 120),
(None, 30),
(Some(Flux3Duration::Auto), 30),
];
#[test]
fn matches_cost_table() {
for &(duration, expected) in COST_TABLE {
let got = make_request(duration).calculate_cost_in_cents();
assert_eq!(got, expected, "duration={duration:?}");
}
}
#[test]
fn draft_is_cheaper_than_full_quality() {
use crate::requests::api::video::text::flux_3::cost::flux_3_standard_rate_cents_per_sec;
assert!(flux_3_draft_generation_rate_cents_per_sec() < flux_3_standard_rate_cents_per_sec(false));
}
}
}
@@ -1,3 +0,0 @@
pub mod api;
pub mod cost;
pub mod raw_request;
@@ -1,44 +0,0 @@
use serde::{Deserialize, Serialize};
use crate::requests::api::video::text::flux_3::api::Flux3Duration;
/// Over-the-wire input shape for `blackforestlabs/flux-3/text-to-video/draft`.
/// fal's schema: <https://fal.ai/models/blackforestlabs/flux-3/text-to-video/draft/api>
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct Flux3DraftTextToVideoInput {
/// Text prompt for video generation.
pub prompt: String,
/// Duration in seconds ("auto" or 5–20). fal default: "auto" (the model
/// picks a duration that fits the prompt).
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<Flux3Duration>,
/// Aspect ratio.
/// Possible values: "auto", "21:9", "2:1", "16:9", "4:3", "1:1", "3:4",
/// "9:16". fal default: "auto".
#[serde(skip_serializing_if = "Option::is_none")]
pub aspect_ratio: Option<String>,
/// Whether to generate synchronized audio. fal default: true.
#[serde(skip_serializing_if = "Option::is_none")]
pub generate_audio: Option<bool>,
/// Safety tolerance, 0 (strictest) to 4 (most permissive). fal default: 2.
#[serde(skip_serializing_if = "Option::is_none")]
pub safety_tolerance: Option<u8>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Flux3DraftVideoFile {
pub url: String,
}
/// Draft outputs carry the 720p draft video plus a durable encrypted cache
/// bundle whose URL can be passed to `draft-enhance` for a full-quality
/// 1080p render.
#[derive(Debug, Serialize, Deserialize)]
pub struct Flux3DraftTextToVideoOutput {
pub video: Flux3DraftVideoFile,
pub draft_cache: Flux3DraftVideoFile,
}
@@ -1,5 +1,3 @@
pub mod flux_3;
pub mod flux_3_draft;
pub mod kling_1p6_pro_text_to_video;
pub mod kling_2p5_turbo_pro_text_to_video;
pub mod kling_2p6_pro_text_to_video;
@@ -62,10 +62,6 @@ fn video_model_to_common_model_type(model: RouterVideoModel) -> Option<CommonMod
RouterVideoModel::Seedance2p0Fast => Some(CommonModelType::Seedance2p0Fast),
RouterVideoModel::HappyHorse1p0 => Some(CommonModelType::HappyHorse1p0),
RouterVideoModel::MinimaxH3 => Some(CommonModelType::MinimaxH3),
RouterVideoModel::MinimaxH3Turbo => Some(CommonModelType::MinimaxH3Turbo),
RouterVideoModel::MinimaxH3Ultra => Some(CommonModelType::MinimaxH3Ultra),
RouterVideoModel::Flux3 => Some(CommonModelType::Flux3),
RouterVideoModel::Flux3Draft => Some(CommonModelType::Flux3Draft),
RouterVideoModel::Sora2 => Some(CommonModelType::Sora2),
RouterVideoModel::Sora2Pro => Some(CommonModelType::Sora2Pro),
RouterVideoModel::Veo2 => Some(CommonModelType::Veo2),
@@ -1,25 +0,0 @@
use enums::by_table::generic_inference_jobs::inference_job_type::InferenceJobType;
use enums::by_table::generic_inference_jobs::inference_model_type::InferenceModelType;
/// The first-party Minimax H3 model tier a job runs on.
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub enum FirstPartyMinimaxH3Model {
Turbo,
Ultra,
}
impl FirstPartyMinimaxH3Model {
pub fn inference_job_type(&self) -> InferenceJobType {
match self {
Self::Turbo => InferenceJobType::ArtcraftMinimaxH3Turbo,
Self::Ultra => InferenceJobType::ArtcraftMinimaxH3Ultra,
}
}
pub fn inference_model_type(&self) -> InferenceModelType {
match self {
Self::Turbo => InferenceModelType::MinimaxH3Turbo,
Self::Ultra => InferenceModelType::MinimaxH3Ultra,
}
}
}
@@ -1,99 +0,0 @@
//! Look up a first-party Minimax H3 job by token.
//!
//! Only returns rows whose `job_type` is one of the first-party Minimax H3
//! job types — the internal worker API must never read (or later mutate)
//! unrelated jobs, so an unknown or non-Minimax token yields `Ok(None)`.
use sqlx::{Executor, MySql};
use std::marker::PhantomData;
use enums::by_table::generic_inference_jobs::inference_job_type::InferenceJobType;
use enums::common::job_status_plus::JobStatusPlus;
use enums::common::platform_type::PlatformType;
use tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken;
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use tokens::tokens::media_files::MediaFileToken;
use tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken;
use tokens::tokens::prompts::PromptToken;
use tokens::tokens::users::UserToken;
pub struct GetFirstPartyMinimaxH3JobByTokenArgs<'e, 'c, E>
where E: 'e + Executor<'c, Database = MySql>
{
pub job_token: &'e InferenceJobToken,
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
}
#[derive(Debug)]
pub struct FirstPartyMinimaxH3JobDetails {
pub job_token: InferenceJobToken,
pub status: JobStatusPlus,
pub job_type: InferenceJobType,
pub maybe_creator_user_token: Option<UserToken>,
pub maybe_creator_anonymous_visitor_token: Option<AnonymousVisitorTrackingToken>,
pub creator_ip_address: String,
pub maybe_prompt_token: Option<PromptToken>,
pub maybe_debug_log_event_token: Option<DebugLogEventToken>,
pub maybe_platform_type: Option<PlatformType>,
/// Set once the job has succeeded: the resulting media file.
pub maybe_result_media_file_token: Option<MediaFileToken>,
}
pub async fn get_first_party_minimax_h3_job_by_token<'e, 'c : 'e, E>(
args: GetFirstPartyMinimaxH3JobByTokenArgs<'e, 'c, E>
) -> Result<Option<FirstPartyMinimaxH3JobDetails>, sqlx::Error>
where E: 'e + Executor<'c, Database = MySql>
{
let maybe_row = sqlx::query!(
r#"
SELECT
token as `job_token: tokens::tokens::generic_inference_jobs::InferenceJobToken`,
status as `status: enums::common::job_status_plus::JobStatusPlus`,
job_type as `job_type: enums::by_table::generic_inference_jobs::inference_job_type::InferenceJobType`,
maybe_creator_user_token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,
maybe_creator_anonymous_visitor_token as `maybe_creator_anonymous_visitor_token: tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken`,
creator_ip_address,
maybe_prompt_token as `maybe_prompt_token: tokens::tokens::prompts::PromptToken`,
maybe_debug_log_event_token as `maybe_debug_log_event_token: tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken`,
platform_type as `maybe_platform_type: enums::common::platform_type::PlatformType`,
on_success_result_entity_token as `maybe_result_media_file_token: tokens::tokens::media_files::MediaFileToken`
FROM generic_inference_jobs
WHERE token = ?
AND job_type IN ('artcraft_minimax_h3_turbo', 'artcraft_minimax_h3_ultra')
"#,
args.job_token.as_str(),
)
.fetch_optional(args.mysql_executor)
.await?;
let Some(row) = maybe_row else {
return Ok(None);
};
let job_type = match row.job_type {
Some(job_type) => job_type,
// Unreachable given the WHERE clause, but don't panic on it.
None => return Ok(None),
};
Ok(Some(FirstPartyMinimaxH3JobDetails {
job_token: row.job_token,
status: row.status,
job_type,
maybe_creator_user_token: row.maybe_creator_user_token,
maybe_creator_anonymous_visitor_token: row.maybe_creator_anonymous_visitor_token,
creator_ip_address: row.creator_ip_address,
maybe_prompt_token: row.maybe_prompt_token,
maybe_debug_log_event_token: row.maybe_debug_log_event_token,
maybe_platform_type: row.maybe_platform_type,
maybe_result_media_file_token: row.maybe_result_media_file_token,
}))
}
@@ -1,145 +0,0 @@
//! Insert a first-party Minimax H3 (Turbo or Ultra) inference job.
//!
//! Unlike the provider-flavored inserts (Fal, KinoviWeb, …), these jobs run
//! on our own GPU inference, so there is no external third party and no
//! external third party id — those columns are left NULL. A scheduler picks
//! the pending rows up later.
use log::info;
use sqlx::{Executor, MySql};
use std::marker::PhantomData;
use enums::by_table::generic_inference_jobs::inference_category::InferenceCategory;
use enums::by_table::generic_inference_jobs::inference_job_product_category::InferenceJobProductCategory;
use enums::common::job_status_plus::JobStatusPlus;
use enums::common::platform_type::PlatformType;
use enums::common::visibility::Visibility;
use tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken;
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken;
use tokens::tokens::prompts::PromptToken;
use tokens::tokens::users::UserToken;
use tokens::tokens::wallet_ledger_entries::WalletLedgerEntryToken;
use crate::errors::database_query_error::DatabaseQueryError;
use crate::payloads::generic_inference_args::generic_inference_args::GenericInferenceArgs;
use crate::queries::generic_inference::common::job_cost_estimates::JobCostEstimates;
use crate::queries::generic_inference::first_party::minimax_h3::first_party_minimax_h3_model::FirstPartyMinimaxH3Model;
use crate::queries::generic_inference::common::insert_full_generic_inference_job_record::{
insert_full_generic_inference_job_record,
InsertFullGenericInferenceJobRecordArgs,
};
pub struct InsertGenericInferenceForFirstPartyMinimaxH3WithAprioriJobTokenArgs<'e, 'c, E>
where E: 'e + Executor<'c, Database = MySql>
{
/// Which first-party Minimax H3 model to enqueue.
pub minimax_model: FirstPartyMinimaxH3Model,
pub uuid_idempotency_token: &'e str,
// NOTE: We'll generate this ahead of time so we can save it with billing info!
pub apriori_job_token: &'e InferenceJobToken,
pub maybe_inference_args: Option<GenericInferenceArgs>,
pub maybe_prompt_token: Option<&'e PromptToken>,
pub maybe_wallet_ledger_entry_token: Option<&'e WalletLedgerEntryToken>,
/// User-facing cost in our own credits. None for the free Turbo tier.
pub maybe_system_cost_credits: Option<u32>,
/// User-facing cost in USD cents. None for the free Turbo tier.
pub maybe_system_cost_usd_cents: Option<u32>,
pub maybe_creator_user_token: Option<&'e UserToken>,
pub maybe_avt_token: Option<&'e AnonymousVisitorTrackingToken>,
pub creator_ip_address: &'e str,
pub creator_set_visibility: Visibility,
/// The platform the enqueuing request came from, inferred from its User-Agent.
pub maybe_platform_type: Option<PlatformType>,
pub maybe_debug_log_event_token: Option<&'e DebugLogEventToken>,
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
}
pub async fn insert_generic_inference_job_for_first_party_minimax_h3_with_apriori_job_token<'e, 'c : 'e, E>(
args: InsertGenericInferenceForFirstPartyMinimaxH3WithAprioriJobTokenArgs<'e, 'c, E>
) -> Result<InferenceJobToken, DatabaseQueryError>
where E: 'e + Executor<'c, Database = MySql>
{
let job_type = args.minimax_model.inference_job_type();
let model_type = args.minimax_model.inference_model_type();
let cost_estimates = JobCostEstimates {
// First-party jobs have no external provider, so no provider-side costs.
maybe_external_third_party_cost_credits: None,
maybe_external_third_party_cost_usd_cents: None,
maybe_system_cost_credits: args.maybe_system_cost_credits,
maybe_system_cost_usd_cents: args.maybe_system_cost_usd_cents,
};
let record_id = insert_full_generic_inference_job_record(InsertFullGenericInferenceJobRecordArgs {
token: args.apriori_job_token,
uuid_idempotency_token: args.uuid_idempotency_token,
job_type,
// No external provider: a scheduler will run these on our own GPUs.
maybe_external_third_party: None,
maybe_external_third_party_id: None,
maybe_product_category: Some(InferenceJobProductCategory::ArtcraftMinimaxH3),
inference_category: InferenceCategory::VideoGeneration,
maybe_model_type: Some(model_type),
maybe_prompt_token: args.maybe_prompt_token,
maybe_wallet_ledger_entry_token: args.maybe_wallet_ledger_entry_token,
maybe_inference_args: args.maybe_inference_args,
maybe_creator_user_token: args.maybe_creator_user_token,
maybe_avt_token: args.maybe_avt_token,
creator_ip_address: args.creator_ip_address,
creator_set_visibility: args.creator_set_visibility,
maybe_platform_type: args.maybe_platform_type,
maybe_cost_estimates: Some(cost_estimates),
maybe_debug_log_event_token: args.maybe_debug_log_event_token,
maybe_frontend_failure_category: None,
maybe_failure_reason: None,
status: JobStatusPlus::Pending,
mysql_executor: args.mysql_executor,
phantom: args.phantom,
// Web-only fields — first-party enqueues never set these.
maybe_model_token: None,
maybe_input_source_token: None,
maybe_input_source_token_type: None,
maybe_download_url: None,
maybe_cover_image_media_file_token: None,
maybe_raw_inference_text: None,
maybe_routing_tag: None,
priority_level: 0,
requires_keepalive: false,
max_duration_seconds: 0,
is_debug_request: false,
}).await?;
info!("Insert generic inference job for first-party Minimax H3 ({:?}): {} with record ID {}",
args.minimax_model, args.apriori_job_token, record_id);
Ok(args.apriori_job_token.clone())
}
@@ -1,88 +0,0 @@
//! Permanently mark a first-party Minimax H3 job as failed.
//!
//! Never flips an already-terminal job: a late or duplicate failure report
//! against a `complete_success` / `complete_failure` row is silently ignored.
use sqlx::{Executor, MySql};
use std::convert::TryFrom;
use std::marker::PhantomData;
use enums::by_table::generic_inference_jobs::frontend_failure_category::FrontendFailureCategory;
use enums::common::job_status_plus::JobStatusPlus;
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
/// `failure_reason` / `internal_debugging_failure_reason` are VARCHAR(512).
const MAX_FAILURE_REASON_CHARS: usize = 512;
pub struct MarkFirstPartyMinimaxH3JobFailedArgs<'e, 'c, E>
where E: 'e + Executor<'c, Database = MySql>
{
pub job_token: &'e InferenceJobToken,
pub maybe_frontend_failure_category: Option<FrontendFailureCategory>,
/// User-facing failure reason. Clamped to 512 characters.
pub maybe_failure_reason: Option<&'e str>,
/// Internal-only stack trace or error. Clamped to 512 characters.
pub maybe_internal_debugging_failure_reason: Option<&'e str>,
/// Total wall-clock runtime of the failed attempt, in milliseconds.
/// NB: Written to `success_execution_millis` — the column name is
/// unfortunate, but we want the runtime data for failures too.
pub maybe_execution_duration_millis: Option<u64>,
/// Inference-only runtime of the failed attempt, in milliseconds.
/// NB: Written to `success_inference_execution_millis` — the column name is
/// unfortunate, but we want the runtime data for failures too.
pub maybe_inference_duration_millis: Option<u64>,
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
}
pub async fn mark_first_party_minimax_h3_job_failed<'e, 'c : 'e, E>(
args: MarkFirstPartyMinimaxH3JobFailedArgs<'e, 'c, E>
) -> Result<(), sqlx::Error>
where E: 'e + Executor<'c, Database = MySql>
{
let maybe_failure_reason = args.maybe_failure_reason
.map(|reason| clamp_chars(reason, MAX_FAILURE_REASON_CHARS));
let maybe_internal_debugging_failure_reason = args.maybe_internal_debugging_failure_reason
.map(|reason| clamp_chars(reason, MAX_FAILURE_REASON_CHARS));
sqlx::query!(
r#"
UPDATE generic_inference_jobs
SET
status = ?,
frontend_failure_category = ?,
failure_reason = ?,
internal_debugging_failure_reason = ?,
success_execution_millis = ?,
success_inference_execution_millis = ?
WHERE token = ?
AND status NOT IN ('complete_success', 'complete_failure')
"#,
JobStatusPlus::CompleteFailure.to_str(),
args.maybe_frontend_failure_category.map(|category| category.to_str()),
maybe_failure_reason,
maybe_internal_debugging_failure_reason,
args.maybe_execution_duration_millis.and_then(|millis| u32::try_from(millis).ok()),
args.maybe_inference_duration_millis.and_then(|millis| u32::try_from(millis).ok()),
args.job_token.as_str(),
)
.execute(args.mysql_executor)
.await?;
Ok(())
}
fn clamp_chars(value: &str, max_chars: usize) -> &str {
match value.char_indices().nth(max_chars) {
Some((byte_index, _)) => &value[..byte_index],
None => value,
}
}
@@ -1,68 +0,0 @@
//! Mark a first-party Minimax H3 job as started and record which worker
//! obtained it.
//!
//! Takes a transaction (not a generic executor): this must run in the same
//! transaction as [`select_pending_first_party_minimax_h3_job_for_update`]
//! so the select-then-mark pair is atomic.
//!
//! [`select_pending_first_party_minimax_h3_job_for_update`]:
//! crate::queries::generic_inference::first_party::minimax_h3::select_pending_first_party_minimax_h3_job_for_update
use sqlx::{MySql, Transaction};
use enums::common::job_status_plus::JobStatusPlus;
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
/// `assigned_worker` / `assigned_cluster` are VARCHAR(128).
const MAX_WORKER_FIELD_CHARS: usize = 128;
pub struct MarkFirstPartyMinimaxH3JobStartedArgs<'a, 'tx> {
pub job_token: &'a InferenceJobToken,
/// The worker's hostname (linux hostname, k8s pod name), when reported.
pub maybe_worker_hostname: Option<&'a str>,
/// The cluster the worker runs in (e.g. "runpod", "lambda"), when reported.
pub maybe_cluster_name: Option<&'a str>,
pub transaction: &'a mut Transaction<'tx, MySql>,
}
pub async fn mark_first_party_minimax_h3_job_started(
args: MarkFirstPartyMinimaxH3JobStartedArgs<'_, '_>
) -> Result<(), sqlx::Error> {
let maybe_worker_hostname = args.maybe_worker_hostname
.map(|hostname| clamp_chars(hostname, MAX_WORKER_FIELD_CHARS));
let maybe_cluster_name = args.maybe_cluster_name
.map(|cluster| clamp_chars(cluster, MAX_WORKER_FIELD_CHARS));
sqlx::query!(
r#"
UPDATE generic_inference_jobs
SET
status = ?,
assigned_worker = ?,
last_assigned_worker = ?,
assigned_cluster = ?,
first_started_at = NOW()
WHERE token = ?
"#,
JobStatusPlus::Started.to_str(),
maybe_worker_hostname,
maybe_worker_hostname,
maybe_cluster_name,
args.job_token.as_str(),
)
.execute(&mut **args.transaction)
.await?;
Ok(())
}
fn clamp_chars(value: &str, max_chars: usize) -> &str {
match value.char_indices().nth(max_chars) {
Some((byte_index, _)) => &value[..byte_index],
None => value,
}
}
@@ -1,67 +0,0 @@
//! Mark a first-party Minimax H3 job as successfully completed, pointing at
//! the resulting media file. Mirrors the Fal webhook success write.
//!
//! Never flips an already-terminal job: a late or duplicate success report
//! against a `complete_success` / `complete_failure` row is silently ignored.
use sqlx::{Executor, MySql};
use std::convert::TryFrom;
use std::marker::PhantomData;
use enums::by_table::generic_inference_jobs::inference_result_type::InferenceResultType;
use enums::common::job_status_plus::JobStatusPlus;
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use tokens::tokens::media_files::MediaFileToken;
pub struct MarkFirstPartyMinimaxH3JobSucceededArgs<'e, 'c, E>
where E: 'e + Executor<'c, Database = MySql>
{
pub job_token: &'e InferenceJobToken,
/// The media file created from the worker's uploaded video.
pub media_file_token: &'e MediaFileToken,
/// Total wall-clock runtime of the job, in milliseconds.
pub maybe_execution_duration_millis: Option<u64>,
/// Inference-only runtime of the job, in milliseconds.
pub maybe_inference_duration_millis: Option<u64>,
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
}
pub async fn mark_first_party_minimax_h3_job_succeeded<'e, 'c : 'e, E>(
args: MarkFirstPartyMinimaxH3JobSucceededArgs<'e, 'c, E>
) -> Result<(), sqlx::Error>
where E: 'e + Executor<'c, Database = MySql>
{
sqlx::query!(
r#"
UPDATE generic_inference_jobs
SET
status = ?,
on_success_result_entity_type = ?,
on_success_result_entity_token = ?,
failure_reason = NULL,
internal_debugging_failure_reason = NULL,
success_execution_millis = ?,
success_inference_execution_millis = ?,
retry_at = NULL,
successfully_completed_at = NOW()
WHERE token = ?
AND status NOT IN ('complete_success', 'complete_failure')
"#,
JobStatusPlus::CompleteSuccess.to_str(),
InferenceResultType::MediaFile.to_str(),
args.media_file_token.as_str(),
args.maybe_execution_duration_millis.and_then(|millis| u32::try_from(millis).ok()),
args.maybe_inference_duration_millis.and_then(|millis| u32::try_from(millis).ok()),
args.job_token.as_str(),
)
.execute(args.mysql_executor)
.await?;
Ok(())
}

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