mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-05 01:38:44 +00:00
veo2 text-to-image was missing
This commit is contained in:
1 parent
049a04281c
commit
5fada6b30f
16 files changed
+486
-206
No files matched your search
Generated
+1
@@ -770,6 +770,7 @@ dependencies = [
|
||||
"anyhow",
|
||||
"artcraft_api_defs",
|
||||
"artcraft_client",
|
||||
"enums",
|
||||
"fal_client",
|
||||
"log",
|
||||
"muapi_client",
|
||||
|
||||
@@ -2,6 +2,7 @@ pub mod analytics;
|
||||
pub mod credits;
|
||||
pub mod generate;
|
||||
pub mod jobs;
|
||||
pub mod omni_gen;
|
||||
pub mod media_files;
|
||||
pub mod prompts;
|
||||
pub mod stripe_artcraft;
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
pub mod video;
|
||||
+1
@@ -0,0 +1 @@
|
||||
pub mod omni_gen_video;
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
use crate::credentials::storyteller_credential_set::StorytellerCredentialSet;
|
||||
use crate::error::storyteller_error::StorytellerError;
|
||||
use crate::utils::api_host::ApiHost;
|
||||
use crate::utils::basic_json_post_request::basic_json_post_request;
|
||||
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
|
||||
use artcraft_api_defs::omni_gen::generate_response::omni_gen_video_generate_response::OmniGenVideoGenerateResponse;
|
||||
|
||||
pub const OMNI_GEN_VIDEO_GENERATE_PATH: &str = "/v1/omni_gen/generate/video";
|
||||
|
||||
pub async fn omni_gen_video_generate(
|
||||
api_host: &ApiHost,
|
||||
maybe_creds: Option<&StorytellerCredentialSet>,
|
||||
request: OmniGenVideoCostAndGenerateRequest,
|
||||
) -> Result<OmniGenVideoGenerateResponse, StorytellerError> {
|
||||
Ok(basic_json_post_request(
|
||||
api_host,
|
||||
OMNI_GEN_VIDEO_GENERATE_PATH,
|
||||
maybe_creds,
|
||||
request,
|
||||
).await?)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod generate;
|
||||
@@ -17,6 +17,7 @@ workspace-build-acceleration.workspace = true
|
||||
|
||||
# Internal
|
||||
artcraft_api_defs.workspace = true
|
||||
enums.workspace = true
|
||||
artcraft_client.workspace = true
|
||||
fal_client.workspace = true
|
||||
muapi_client.workspace = true
|
||||
|
||||
+57
-16
@@ -6,31 +6,72 @@ use crate::generate::generate_video::generate_video_response::{
|
||||
};
|
||||
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_veo_2::PlanArtcraftVeo2;
|
||||
use artcraft_api_defs::generate::video::generate_veo_2_image_to_video::GenerateVeo2ImageToVideoRequest;
|
||||
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
|
||||
use artcraft_client::endpoints::generate::video::generate_veo_2_image_to_video::generate_veo_2_image_to_video;
|
||||
use artcraft_client::endpoints::omni_gen::generate::video::omni_gen_video::omni_gen_video_generate;
|
||||
use enums::common::generation::common_video_model::CommonVideoModel;
|
||||
|
||||
pub async fn execute_artcraft_veo_2(
|
||||
plan: &PlanArtcraftVeo2<'_>,
|
||||
artcraft_client: &RouterArtcraftClient,
|
||||
) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
|
||||
let request = GenerateVeo2ImageToVideoRequest {
|
||||
uuid_idempotency_token: plan.idempotency_token.clone(),
|
||||
media_file_token: plan.start_frame.map(|t| t.to_owned()),
|
||||
prompt: plan.prompt.map(|p| p.to_string()),
|
||||
aspect_ratio: plan.aspect_ratio.clone(),
|
||||
duration: plan.duration.clone(),
|
||||
let inference_job_token = match plan.start_frame {
|
||||
Some(media_file_token) => {
|
||||
// Image-to-video: use the legacy dedicated endpoint.
|
||||
let request = GenerateVeo2ImageToVideoRequest {
|
||||
uuid_idempotency_token: plan.idempotency_token.clone(),
|
||||
media_file_token: Some(media_file_token.to_owned()),
|
||||
prompt: plan.prompt.map(|p| p.to_string()),
|
||||
aspect_ratio: plan.aspect_ratio.clone(),
|
||||
duration: plan.duration.clone(),
|
||||
};
|
||||
|
||||
let response = generate_veo_2_image_to_video(
|
||||
&artcraft_client.api_host,
|
||||
Some(&artcraft_client.credentials),
|
||||
request,
|
||||
)
|
||||
.await
|
||||
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Storyteller(err)))?;
|
||||
|
||||
response.inference_job_token
|
||||
}
|
||||
None => {
|
||||
// Text-to-video: use the omni-gen unified endpoint.
|
||||
let request = OmniGenVideoCostAndGenerateRequest {
|
||||
idempotency_token: Some(plan.idempotency_token.clone()),
|
||||
model: Some(CommonVideoModel::Veo2),
|
||||
prompt: plan.prompt.map(|p| p.to_string()),
|
||||
negative_prompt: None,
|
||||
start_frame_image_media_token: None,
|
||||
end_frame_image_media_token: None,
|
||||
reference_image_media_tokens: None,
|
||||
reference_video_media_tokens: None,
|
||||
reference_audio_media_tokens: None,
|
||||
reference_character_tokens: None,
|
||||
resolution: None,
|
||||
aspect_ratio: None,
|
||||
quality: None,
|
||||
duration_seconds: None,
|
||||
video_batch_count: None,
|
||||
generate_audio: None,
|
||||
};
|
||||
|
||||
let response = omni_gen_video_generate(
|
||||
&artcraft_client.api_host,
|
||||
Some(&artcraft_client.credentials),
|
||||
request,
|
||||
)
|
||||
.await
|
||||
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Storyteller(err)))?;
|
||||
|
||||
response.inference_job_token
|
||||
}
|
||||
};
|
||||
|
||||
let response = generate_veo_2_image_to_video(
|
||||
&artcraft_client.api_host,
|
||||
Some(&artcraft_client.credentials),
|
||||
request,
|
||||
)
|
||||
.await
|
||||
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Storyteller(err)))?;
|
||||
|
||||
let all_tokens = vec![response.inference_job_token.clone()];
|
||||
let all_tokens = vec![inference_job_token.clone()];
|
||||
Ok(GenerateVideoResponse::Artcraft(ArtcraftVideoResponsePayload {
|
||||
inference_job_token: response.inference_job_token,
|
||||
inference_job_token,
|
||||
all_inference_job_tokens: all_tokens,
|
||||
}))
|
||||
}
|
||||
+33
-12
@@ -4,28 +4,49 @@ use crate::errors::provider_error::ProviderError;
|
||||
use crate::generate::generate_video::generate_video_response::{
|
||||
FalVideoResponsePayload, GenerateVideoResponse,
|
||||
};
|
||||
use crate::generate::generate_video::plan::fal::plan_generate_video_fal_veo_2::PlanFalVeo2;
|
||||
use crate::generate::generate_video::plan::fal::plan_generate_video_fal_veo_2::{
|
||||
FalVeo2Mode, PlanFalVeo2,
|
||||
};
|
||||
use fal_client::requests::webhook::video::image::enqueue_veo_2_image_to_video_webhook::{
|
||||
enqueue_veo_2_image_to_video_webhook, Veo2Args,
|
||||
};
|
||||
use fal_client::requests::webhook::video::text::enqueue_veo_2_text_to_video_webhook::{
|
||||
enqueue_veo_2_text_to_video_webhook, Veo2TextToVideoArgs,
|
||||
};
|
||||
|
||||
pub async fn execute_fal_veo_2(
|
||||
plan: &PlanFalVeo2,
|
||||
fal_client: &RouterFalClient,
|
||||
) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
|
||||
let args = Veo2Args {
|
||||
image_url: plan.start_frame_url.as_str(),
|
||||
prompt: plan.prompt.as_str(),
|
||||
api_key: &fal_client.api_key,
|
||||
duration: plan.duration,
|
||||
aspect_ratio: plan.aspect_ratio,
|
||||
webhook_url: fal_client.webhook_url.as_str(),
|
||||
let webhook_response = match &plan.mode {
|
||||
FalVeo2Mode::TextToVideo => {
|
||||
let args = Veo2TextToVideoArgs {
|
||||
prompt: plan.prompt.as_str(),
|
||||
negative_prompt: plan.negative_prompt.as_deref(),
|
||||
api_key: &fal_client.api_key,
|
||||
duration: plan.duration,
|
||||
aspect_ratio: plan.aspect_ratio,
|
||||
webhook_url: fal_client.webhook_url.as_str(),
|
||||
};
|
||||
enqueue_veo_2_text_to_video_webhook(args)
|
||||
.await
|
||||
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?
|
||||
}
|
||||
FalVeo2Mode::ImageToVideo { image_url } => {
|
||||
let args = Veo2Args {
|
||||
image_url: image_url.as_str(),
|
||||
prompt: plan.prompt.as_str(),
|
||||
api_key: &fal_client.api_key,
|
||||
duration: plan.duration,
|
||||
aspect_ratio: plan.aspect_ratio,
|
||||
webhook_url: fal_client.webhook_url.as_str(),
|
||||
};
|
||||
enqueue_veo_2_image_to_video_webhook(args)
|
||||
.await
|
||||
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?
|
||||
}
|
||||
};
|
||||
|
||||
let webhook_response = enqueue_veo_2_image_to_video_webhook(args)
|
||||
.await
|
||||
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?;
|
||||
|
||||
Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload {
|
||||
request_id: webhook_response.request_id,
|
||||
gateway_request_id: webhook_response.gateway_request_id,
|
||||
|
||||
+2
-6
@@ -33,12 +33,8 @@ pub fn plan_generate_video_artcraft_veo_2<'a>(
|
||||
// Artcraft cost path. Cost only depends on duration, so URL-form refs are
|
||||
// accepted and dropped.
|
||||
Some(ImageRef::Url(_)) => None,
|
||||
None => {
|
||||
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "start_frame",
|
||||
value: "Veo 2 requires a starting frame".to_string(),
|
||||
}))
|
||||
}
|
||||
// No start_frame = text-to-video mode.
|
||||
None => None,
|
||||
};
|
||||
|
||||
if request.end_frame.is_some() {
|
||||
|
||||
+23
-16
@@ -9,10 +9,19 @@ use fal_client::requests::webhook::video::image::enqueue_veo_2_image_to_video_we
|
||||
Veo2AspectRatio, Veo2Duration,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum FalVeo2Mode {
|
||||
TextToVideo,
|
||||
ImageToVideo {
|
||||
image_url: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PlanFalVeo2 {
|
||||
pub prompt: String,
|
||||
pub start_frame_url: String,
|
||||
pub negative_prompt: Option<String>,
|
||||
pub mode: FalVeo2Mode,
|
||||
pub aspect_ratio: Veo2AspectRatio,
|
||||
pub duration: Veo2Duration,
|
||||
}
|
||||
@@ -22,35 +31,32 @@ pub fn plan_generate_video_fal_veo_2<'a>(
|
||||
) -> Result<VideoGenerationPlan<'a>, ArtcraftRouterError> {
|
||||
let strategy = request.request_mismatch_mitigation_strategy;
|
||||
|
||||
let start_frame_url = resolve_required_image_url(request.start_frame, "start_frame")?;
|
||||
if request.end_frame.is_some() {
|
||||
return Err(unsupported("end_frame", "Veo 2 does not support an ending frame"));
|
||||
}
|
||||
|
||||
let mode = match request.start_frame {
|
||||
Some(ImageRef::Url(url)) => FalVeo2Mode::ImageToVideo {
|
||||
image_url: url.to_string(),
|
||||
},
|
||||
Some(ImageRef::MediaFileToken(_)) => {
|
||||
return Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls));
|
||||
}
|
||||
None => FalVeo2Mode::TextToVideo,
|
||||
};
|
||||
|
||||
let aspect_ratio = plan_aspect_ratio(request.aspect_ratio, strategy)?;
|
||||
let duration = plan_duration(request.duration_seconds, strategy)?;
|
||||
|
||||
Ok(VideoGenerationPlan::FalVeo2(PlanFalVeo2 {
|
||||
prompt: request.prompt.unwrap_or("").to_string(),
|
||||
start_frame_url,
|
||||
negative_prompt: request.negative_prompt.map(|s| s.to_string()),
|
||||
mode,
|
||||
aspect_ratio,
|
||||
duration,
|
||||
}))
|
||||
}
|
||||
|
||||
fn resolve_required_image_url(
|
||||
image_ref: Option<ImageRef<'_>>,
|
||||
field: &'static str,
|
||||
) -> Result<String, ArtcraftRouterError> {
|
||||
match image_ref {
|
||||
Some(ImageRef::Url(url)) => Ok(url.to_string()),
|
||||
Some(ImageRef::MediaFileToken(_)) => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls))
|
||||
}
|
||||
None => Err(unsupported(field, "Veo 2 requires a starting frame")),
|
||||
}
|
||||
}
|
||||
|
||||
fn plan_aspect_ratio(
|
||||
aspect_ratio: Option<CommonAspectRatio>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
@@ -60,6 +66,7 @@ fn plan_aspect_ratio(
|
||||
None
|
||||
| Some(CommonAspectRatio::Auto)
|
||||
| Some(CommonAspectRatio::Auto2k)
|
||||
| Some(CommonAspectRatio::Auto3k)
|
||||
| Some(CommonAspectRatio::Auto4k) => Ok(Ar::Auto),
|
||||
|
||||
Some(CommonAspectRatio::WideSixteenByNine) | Some(CommonAspectRatio::Wide) => Ok(Ar::WideSixteenNine),
|
||||
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
use crate::requests::api::fal_request::FalRequest;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Default)]
|
||||
pub struct Veo2TextToVideoInput {
|
||||
pub prompt: String,
|
||||
|
||||
/// Options: "16:9", "9:16", "1:1"
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub aspect_ratio: Option<String>,
|
||||
|
||||
/// Options: "5s", "6s", "7s", "8s"
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub duration: Option<String>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub negative_prompt: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Veo2TextToVideoVideoFile {
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Veo2TextToVideoOutput {
|
||||
pub video: Veo2TextToVideoVideoFile,
|
||||
}
|
||||
|
||||
pub fn veo_2_text_to_video(
|
||||
params: Veo2TextToVideoInput,
|
||||
) -> FalRequest<Veo2TextToVideoInput, Veo2TextToVideoOutput> {
|
||||
FalRequest::new("fal-ai/veo2", params)
|
||||
}
|
||||
@@ -7,5 +7,6 @@ pub mod http_pixverse_v5_text_to_video;
|
||||
pub mod http_seedance_1p5_pro_text_to_video;
|
||||
pub mod http_sora_2_pro_text_to_video;
|
||||
pub mod http_sora_2_text_to_video;
|
||||
pub mod http_veo_2_text_to_video;
|
||||
pub mod http_veo_3p1_fast_text_to_video;
|
||||
pub mod http_veo_3p1_text_to_video;
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
use crate::creds::fal_api_key::FalApiKey;
|
||||
use crate::error::classify_fal_error::classify_fal_error;
|
||||
use crate::error::fal_error_plus::FalErrorPlus;
|
||||
use crate::requests::api::webhook_response::WebhookResponse;
|
||||
use crate::requests::http::video::text::http_veo_2_text_to_video::{
|
||||
veo_2_text_to_video, Veo2TextToVideoInput,
|
||||
};
|
||||
use crate::requests::traits::fal_request_cost_calculator_trait::{
|
||||
FalRequestCostCalculator, UsdCents,
|
||||
};
|
||||
// Re-use the same duration/aspect-ratio enums as image-to-video.
|
||||
pub use crate::requests::webhook::video::image::enqueue_veo_2_image_to_video_webhook::{
|
||||
Veo2AspectRatio, Veo2Duration,
|
||||
};
|
||||
use reqwest::IntoUrl;
|
||||
|
||||
pub struct Veo2TextToVideoArgs<'a, V: IntoUrl> {
|
||||
pub prompt: &'a str,
|
||||
pub negative_prompt: Option<&'a str>,
|
||||
pub duration: Veo2Duration,
|
||||
pub aspect_ratio: Veo2AspectRatio,
|
||||
pub webhook_url: V,
|
||||
pub api_key: &'a FalApiKey,
|
||||
}
|
||||
|
||||
impl<V: IntoUrl> FalRequestCostCalculator for Veo2TextToVideoArgs<'_, V> {
|
||||
fn calculate_cost_in_cents(&self) -> UsdCents {
|
||||
// Same pricing as image-to-video:
|
||||
// "For 5s video your request will cost $2.50.
|
||||
// For every additional second you will be charged $0.50."
|
||||
match self.duration {
|
||||
Veo2Duration::Default => 250,
|
||||
Veo2Duration::FiveSeconds => 250,
|
||||
Veo2Duration::SixSeconds => 300,
|
||||
Veo2Duration::SevenSeconds => 350,
|
||||
Veo2Duration::EightSeconds => 400,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Veo 2 Text-to-Video
|
||||
/// https://fal.ai/models/fal-ai/veo2
|
||||
pub async fn enqueue_veo_2_text_to_video_webhook<V: IntoUrl>(
|
||||
args: Veo2TextToVideoArgs<'_, V>,
|
||||
) -> Result<WebhookResponse, FalErrorPlus> {
|
||||
let duration = match args.duration {
|
||||
Veo2Duration::Default => None,
|
||||
Veo2Duration::FiveSeconds => Some("5s".to_string()),
|
||||
Veo2Duration::SixSeconds => Some("6s".to_string()),
|
||||
Veo2Duration::SevenSeconds => Some("7s".to_string()),
|
||||
Veo2Duration::EightSeconds => Some("8s".to_string()),
|
||||
};
|
||||
|
||||
let aspect_ratio = match args.aspect_ratio {
|
||||
Veo2AspectRatio::Auto => None, // Let the API default (16:9)
|
||||
Veo2AspectRatio::AutoPreferPortrait => Some("9:16".to_string()),
|
||||
Veo2AspectRatio::WideSixteenNine => Some("16:9".to_string()),
|
||||
Veo2AspectRatio::TallNineSixteen => Some("9:16".to_string()),
|
||||
};
|
||||
|
||||
let request = Veo2TextToVideoInput {
|
||||
prompt: args.prompt.to_string(),
|
||||
aspect_ratio,
|
||||
duration,
|
||||
negative_prompt: args.negative_prompt.map(|s| s.to_string()),
|
||||
};
|
||||
|
||||
let result = veo_2_text_to_video(request)
|
||||
.with_api_key(&args.api_key.0)
|
||||
.queue_webhook(args.webhook_url)
|
||||
.await;
|
||||
|
||||
result.map_err(|err| classify_fal_error(err))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::creds::fal_api_key::FalApiKey;
|
||||
use crate::requests::webhook::video::text::enqueue_veo_2_text_to_video_webhook::{
|
||||
enqueue_veo_2_text_to_video_webhook, Veo2TextToVideoArgs, Veo2AspectRatio, Veo2Duration,
|
||||
};
|
||||
use errors::AnyhowResult;
|
||||
use std::fs::read_to_string;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_veo_2_text_to_video() -> AnyhowResult<()> {
|
||||
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
|
||||
let api_key = FalApiKey::from_str(&secret);
|
||||
|
||||
let args = Veo2TextToVideoArgs {
|
||||
prompt: "a drone shot of a coastal sunset with waves crashing on rocks",
|
||||
negative_prompt: None,
|
||||
api_key: &api_key,
|
||||
duration: Veo2Duration::FiveSeconds,
|
||||
aspect_ratio: Veo2AspectRatio::WideSixteenNine,
|
||||
webhook_url: "https://example.com/webhook",
|
||||
};
|
||||
|
||||
let _result = enqueue_veo_2_text_to_video_webhook(args).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -7,5 +7,6 @@ pub mod enqueue_pixverse_v5_text_to_video_webhook;
|
||||
pub mod enqueue_seedance_1p5_pro_text_to_video_webhook;
|
||||
pub mod enqueue_sora_2_pro_text_to_video_webhook;
|
||||
pub mod enqueue_sora_2_text_to_video_webhook;
|
||||
pub mod enqueue_veo_2_text_to_video_webhook;
|
||||
pub mod enqueue_veo_3p1_fast_text_to_video_webhook;
|
||||
pub mod enqueue_veo_3p1_text_to_video_webhook;
|
||||
+205
-156
@@ -1,7 +1,6 @@
|
||||
//! Tests for omni-gen video against the Veo 2 model.
|
||||
//!
|
||||
//! Veo 2 is image-to-video only — a start_frame is always required.
|
||||
//! There is no text-to-video mode.
|
||||
//! Veo 2 supports both text-to-video and image-to-video (via start_frame).
|
||||
//!
|
||||
//! Pricing: $2.50 for the first 5 seconds, +$0.50 per additional second.
|
||||
//! 5s → 250¢, 6s → 300¢, 7s → 350¢, 8s → 400¢
|
||||
@@ -61,66 +60,51 @@ mod tests {
|
||||
(token, map)
|
||||
}
|
||||
|
||||
fn distill(
|
||||
fn distill_text(request: &OmniGenVideoCostAndGenerateRequest) -> DistilledVideoRequest {
|
||||
let empty: HashMap<MediaFileToken, Url> = HashMap::new();
|
||||
distill_video_request(request, Some(&empty))
|
||||
.expect("distill_video_request should succeed for Veo 2 (text)")
|
||||
}
|
||||
|
||||
fn distill_image(
|
||||
request: &OmniGenVideoCostAndGenerateRequest,
|
||||
hydration: &HashMap<MediaFileToken, Url>,
|
||||
) -> DistilledVideoRequest {
|
||||
distill_video_request(request, Some(hydration))
|
||||
.expect("distill_video_request should succeed for Veo 2")
|
||||
.expect("distill_video_request should succeed for Veo 2 (image)")
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// COST
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Pricing: 5s = 250¢, +50¢ per additional second above 5.
|
||||
// Default duration = 5s = 250¢.
|
||||
mod cost {
|
||||
use super::*;
|
||||
|
||||
mod image {
|
||||
mod text {
|
||||
use super::*;
|
||||
|
||||
fn cost(duration: Option<u16>) -> u64 {
|
||||
let (start, map) = fake_token("mf_start0000000000000000000000");
|
||||
let request = make_request(Some("p"), None, duration, Some(start));
|
||||
distill(&request, &map).cost.cost_in_usd_cents.unwrap()
|
||||
}
|
||||
|
||||
// ── Duration-based pricing ────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn default_duration_costs_250_cents() {
|
||||
assert_eq!(cost(None), 250);
|
||||
let request = make_request(Some("p"), None, duration, None);
|
||||
distill_text(&request).cost.cost_in_usd_cents.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn five_seconds_costs_250_cents() {
|
||||
assert_eq!(cost(Some(5)), 250);
|
||||
}
|
||||
fn default_duration_costs_250() { assert_eq!(cost(None), 250); }
|
||||
|
||||
#[test]
|
||||
fn six_seconds_costs_300_cents() {
|
||||
assert_eq!(cost(Some(6)), 300);
|
||||
}
|
||||
fn five_seconds_costs_250() { assert_eq!(cost(Some(5)), 250); }
|
||||
|
||||
#[test]
|
||||
fn seven_seconds_costs_350_cents() {
|
||||
assert_eq!(cost(Some(7)), 350);
|
||||
}
|
||||
fn six_seconds_costs_300() { assert_eq!(cost(Some(6)), 300); }
|
||||
|
||||
#[test]
|
||||
fn eight_seconds_costs_400_cents() {
|
||||
assert_eq!(cost(Some(8)), 400);
|
||||
}
|
||||
fn seven_seconds_costs_350() { assert_eq!(cost(Some(7)), 350); }
|
||||
|
||||
#[test]
|
||||
fn duration_above_8_clamps_to_400_cents() {
|
||||
// PayMoreUpgrade clamps over-max to 8s.
|
||||
assert_eq!(cost(Some(15)), 400);
|
||||
}
|
||||
fn eight_seconds_costs_400() { assert_eq!(cost(Some(8)), 400); }
|
||||
|
||||
// ── Cost is independent of aspect ratio ───────────────────────────────
|
||||
#[test]
|
||||
fn duration_above_8_clamps_to_400() { assert_eq!(cost(Some(15)), 400); }
|
||||
|
||||
#[test]
|
||||
fn cost_independent_of_aspect_ratio() {
|
||||
@@ -132,32 +116,14 @@ mod tests {
|
||||
Some(CommonAspectRatio::Square),
|
||||
];
|
||||
for ar in ars {
|
||||
let (start, map) = fake_token("mf_start0000000000000000000000");
|
||||
let request = make_request(Some("p"), ar, None, Some(start));
|
||||
assert_eq!(
|
||||
distill(&request, &map).cost.cost_in_usd_cents.unwrap(), 250,
|
||||
"expected 250¢ regardless of aspect ratio {:?}", ar,
|
||||
);
|
||||
let request = make_request(Some("p"), ar, None, None);
|
||||
assert_eq!(distill_text(&request).cost.cost_in_usd_cents.unwrap(), 250);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Cost is independent of prompt ─────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn cost_independent_of_prompt() {
|
||||
let (s1, m1) = fake_token("mf_start0000000000000000000000");
|
||||
let (s2, m2) = fake_token("mf_start0000000000000000000000");
|
||||
let a = distill(&make_request(Some("a cat"), None, None, Some(s1)), &m1);
|
||||
let b = distill(&make_request(None, None, None, Some(s2)), &m2);
|
||||
assert_eq!(a.cost.cost_in_usd_cents, b.cost.cost_in_usd_cents);
|
||||
}
|
||||
|
||||
// ── Metadata flags ────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn cost_metadata_flags_are_default() {
|
||||
let (start, map) = fake_token("mf_start0000000000000000000000");
|
||||
let d = distill(&make_request(Some("p"), None, None, Some(start)), &map);
|
||||
let d = distill_text(&make_request(Some("p"), None, None, None));
|
||||
assert!(!d.cost.is_free);
|
||||
assert!(!d.cost.is_unlimited);
|
||||
assert!(!d.cost.is_rate_limited);
|
||||
@@ -165,9 +131,46 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
mod text {
|
||||
// Veo 2 is image-to-video only; there is no text-to-video mode.
|
||||
// (start_frame is required and the plan errors without it.)
|
||||
mod image {
|
||||
use super::*;
|
||||
|
||||
fn cost(duration: Option<u16>) -> u64 {
|
||||
let (start, map) = fake_token("mf_start0000000000000000000000");
|
||||
let request = make_request(Some("p"), None, duration, Some(start));
|
||||
distill_image(&request, &map).cost.cost_in_usd_cents.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_duration_costs_250() { assert_eq!(cost(None), 250); }
|
||||
|
||||
#[test]
|
||||
fn five_seconds_costs_250() { assert_eq!(cost(Some(5)), 250); }
|
||||
|
||||
#[test]
|
||||
fn six_seconds_costs_300() { assert_eq!(cost(Some(6)), 300); }
|
||||
|
||||
#[test]
|
||||
fn seven_seconds_costs_350() { assert_eq!(cost(Some(7)), 350); }
|
||||
|
||||
#[test]
|
||||
fn eight_seconds_costs_400() { assert_eq!(cost(Some(8)), 400); }
|
||||
|
||||
#[test]
|
||||
fn duration_above_8_clamps_to_400() { assert_eq!(cost(Some(15)), 400); }
|
||||
|
||||
#[test]
|
||||
fn image_and_text_cost_match() {
|
||||
for dur in [5u16, 6, 7, 8] {
|
||||
let text = distill_text(&make_request(Some("p"), None, Some(dur), None))
|
||||
.cost.cost_in_usd_cents.unwrap();
|
||||
let (start, map) = fake_token("mf_start0000000000000000000000");
|
||||
let img = distill_image(
|
||||
&make_request(Some("p"), None, Some(dur), Some(start)),
|
||||
&map,
|
||||
).cost.cost_in_usd_cents.unwrap();
|
||||
assert_eq!(text, img, "text/image cost diverged at dur={}", dur);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,57 +181,45 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
use artcraft_router::generate::generate_video::video_generation_plan::VideoGenerationPlan;
|
||||
use artcraft_router::generate::generate_video::plan::fal::plan_generate_video_fal_veo_2::PlanFalVeo2;
|
||||
use artcraft_router::generate::generate_video::plan::fal::plan_generate_video_fal_veo_2::{
|
||||
FalVeo2Mode, PlanFalVeo2,
|
||||
};
|
||||
use fal_client::requests::webhook::video::image::enqueue_veo_2_image_to_video_webhook::{
|
||||
Veo2AspectRatio, Veo2Duration,
|
||||
};
|
||||
|
||||
fn with_plan<F: FnOnce(&PlanFalVeo2)>(
|
||||
fn with_text_plan<F: FnOnce(&PlanFalVeo2)>(
|
||||
request: &OmniGenVideoCostAndGenerateRequest,
|
||||
hydration: &HashMap<MediaFileToken, Url>,
|
||||
assertion: F,
|
||||
) {
|
||||
let distilled = distill(request, hydration);
|
||||
let distilled = distill_text(request);
|
||||
match distilled.plan() {
|
||||
VideoGenerationPlan::FalVeo2(plan) => assertion(plan),
|
||||
other => panic!("expected FalVeo2, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
mod image {
|
||||
fn with_image_plan<F: FnOnce(&PlanFalVeo2)>(
|
||||
request: &OmniGenVideoCostAndGenerateRequest,
|
||||
hydration: &HashMap<MediaFileToken, Url>,
|
||||
assertion: F,
|
||||
) {
|
||||
let distilled = distill_image(request, hydration);
|
||||
match distilled.plan() {
|
||||
VideoGenerationPlan::FalVeo2(plan) => assertion(plan),
|
||||
other => panic!("expected FalVeo2, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
mod text {
|
||||
use super::*;
|
||||
|
||||
// ── Start frame hydration ─────────────────────────────────────────────
|
||||
// ── Mode detection ────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn start_frame_url_matches_hydration_map() {
|
||||
let token = MediaFileToken::new_from_str("mf_myframe0000000000000000000000");
|
||||
let expected_url = "https://cdn.example.com/frames/my_frame.png";
|
||||
let mut map = HashMap::new();
|
||||
map.insert(token.clone(), Url::parse(expected_url).unwrap());
|
||||
|
||||
let request = make_request(Some("p"), None, None, Some(token));
|
||||
with_plan(&request, &map, |plan| {
|
||||
assert_eq!(plan.start_frame_url, expected_url);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn start_frame_from_larger_hydration_map() {
|
||||
let start = MediaFileToken::new_from_str("mf_start0000000000000000000000");
|
||||
let extra1 = MediaFileToken::new_from_str("mf_extra1000000000000000000000000");
|
||||
let extra2 = MediaFileToken::new_from_str("mf_extra2000000000000000000000000");
|
||||
|
||||
let start_url = "https://cdn.example.com/start.png";
|
||||
|
||||
let mut map = HashMap::new();
|
||||
map.insert(start.clone(), Url::parse(start_url).unwrap());
|
||||
map.insert(extra1, Url::parse("https://cdn.example.com/extra1.png").unwrap());
|
||||
map.insert(extra2, Url::parse("https://cdn.example.com/extra2.png").unwrap());
|
||||
|
||||
let request = make_request(Some("p"), None, None, Some(start));
|
||||
with_plan(&request, &map, |plan| {
|
||||
assert_eq!(plan.start_frame_url, start_url);
|
||||
fn text_mode_is_text_to_video() {
|
||||
with_text_plan(&make_request(Some("p"), None, None, None), |plan| {
|
||||
assert!(matches!(plan.mode, FalVeo2Mode::TextToVideo));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -236,18 +227,14 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn prompt_is_passed_through() {
|
||||
let (start, map) = fake_token("mf_start0000000000000000000000");
|
||||
let request = make_request(Some("a horse on mars"), None, None, Some(start));
|
||||
with_plan(&request, &map, |plan| {
|
||||
with_text_plan(&make_request(Some("a horse on mars"), None, None, None), |plan| {
|
||||
assert_eq!(plan.prompt, "a horse on mars");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_prompt_is_empty_string() {
|
||||
let (start, map) = fake_token("mf_start0000000000000000000000");
|
||||
let request = make_request(None, None, None, Some(start));
|
||||
with_plan(&request, &map, |plan| {
|
||||
with_text_plan(&make_request(None, None, None, None), |plan| {
|
||||
assert_eq!(plan.prompt, "");
|
||||
});
|
||||
}
|
||||
@@ -256,9 +243,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn default_aspect_ratio_is_auto() {
|
||||
let (start, map) = fake_token("mf_start0000000000000000000000");
|
||||
let request = make_request(Some("p"), None, None, Some(start));
|
||||
with_plan(&request, &map, |plan| {
|
||||
with_text_plan(&make_request(Some("p"), None, None, None), |plan| {
|
||||
assert!(matches!(plan.aspect_ratio, Veo2AspectRatio::Auto));
|
||||
});
|
||||
}
|
||||
@@ -266,31 +251,25 @@ mod tests {
|
||||
#[test]
|
||||
fn auto_yields_auto() {
|
||||
for ar in [CommonAspectRatio::Auto, CommonAspectRatio::Auto2k, CommonAspectRatio::Auto4k] {
|
||||
let (start, map) = fake_token("mf_start0000000000000000000000");
|
||||
let request = make_request(Some("p"), Some(ar), None, Some(start));
|
||||
with_plan(&request, &map, |plan| {
|
||||
assert!(matches!(plan.aspect_ratio, Veo2AspectRatio::Auto), "expected Auto for {:?}", ar);
|
||||
with_text_plan(&make_request(Some("p"), Some(ar), None, None), |plan| {
|
||||
assert!(matches!(plan.aspect_ratio, Veo2AspectRatio::Auto));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wide_16x9_yields_wide_sixteen_nine() {
|
||||
fn wide_16x9_yields_wide() {
|
||||
for ar in [CommonAspectRatio::WideSixteenByNine, CommonAspectRatio::Wide] {
|
||||
let (start, map) = fake_token("mf_start0000000000000000000000");
|
||||
let request = make_request(Some("p"), Some(ar), None, Some(start));
|
||||
with_plan(&request, &map, |plan| {
|
||||
with_text_plan(&make_request(Some("p"), Some(ar), None, None), |plan| {
|
||||
assert!(matches!(plan.aspect_ratio, Veo2AspectRatio::WideSixteenNine));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tall_9x16_yields_tall_nine_sixteen() {
|
||||
fn tall_9x16_yields_tall() {
|
||||
for ar in [CommonAspectRatio::TallNineBySixteen, CommonAspectRatio::Tall] {
|
||||
let (start, map) = fake_token("mf_start0000000000000000000000");
|
||||
let request = make_request(Some("p"), Some(ar), None, Some(start));
|
||||
with_plan(&request, &map, |plan| {
|
||||
with_text_plan(&make_request(Some("p"), Some(ar), None, None), |plan| {
|
||||
assert!(matches!(plan.aspect_ratio, Veo2AspectRatio::TallNineSixteen));
|
||||
});
|
||||
}
|
||||
@@ -298,23 +277,12 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn unsupported_aspect_ratio_falls_back_to_auto() {
|
||||
let unsupported = [
|
||||
CommonAspectRatio::Square,
|
||||
CommonAspectRatio::SquareHd,
|
||||
CommonAspectRatio::WideFourByThree,
|
||||
CommonAspectRatio::WideFiveByFour,
|
||||
CommonAspectRatio::WideThreeByTwo,
|
||||
CommonAspectRatio::WideTwentyOneByNine,
|
||||
CommonAspectRatio::TallThreeByFour,
|
||||
CommonAspectRatio::TallFourByFive,
|
||||
CommonAspectRatio::TallTwoByThree,
|
||||
CommonAspectRatio::TallNineByTwentyOne,
|
||||
];
|
||||
for ar in unsupported {
|
||||
let (start, map) = fake_token("mf_start0000000000000000000000");
|
||||
let request = make_request(Some("p"), Some(ar), None, Some(start));
|
||||
with_plan(&request, &map, |plan| {
|
||||
assert!(matches!(plan.aspect_ratio, Veo2AspectRatio::Auto), "expected Auto fallback for {:?}", ar);
|
||||
for ar in [
|
||||
CommonAspectRatio::Square, CommonAspectRatio::SquareHd,
|
||||
CommonAspectRatio::WideFourByThree, CommonAspectRatio::TallThreeByFour,
|
||||
] {
|
||||
with_text_plan(&make_request(Some("p"), Some(ar), None, None), |plan| {
|
||||
assert!(matches!(plan.aspect_ratio, Veo2AspectRatio::Auto));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -323,62 +291,143 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn default_duration_is_default() {
|
||||
let (start, map) = fake_token("mf_start0000000000000000000000");
|
||||
let request = make_request(Some("p"), None, None, Some(start));
|
||||
with_plan(&request, &map, |plan| {
|
||||
with_text_plan(&make_request(Some("p"), None, None, None), |plan| {
|
||||
assert!(matches!(plan.duration, Veo2Duration::Default));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_5_yields_five_seconds() {
|
||||
let (start, map) = fake_token("mf_start0000000000000000000000");
|
||||
let request = make_request(Some("p"), None, Some(5), Some(start));
|
||||
with_plan(&request, &map, |plan| {
|
||||
fn duration_5_yields_five() {
|
||||
with_text_plan(&make_request(Some("p"), None, Some(5), None), |plan| {
|
||||
assert!(matches!(plan.duration, Veo2Duration::FiveSeconds));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_6_yields_six_seconds() {
|
||||
let (start, map) = fake_token("mf_start0000000000000000000000");
|
||||
let request = make_request(Some("p"), None, Some(6), Some(start));
|
||||
with_plan(&request, &map, |plan| {
|
||||
fn duration_6_yields_six() {
|
||||
with_text_plan(&make_request(Some("p"), None, Some(6), None), |plan| {
|
||||
assert!(matches!(plan.duration, Veo2Duration::SixSeconds));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_7_yields_seven_seconds() {
|
||||
let (start, map) = fake_token("mf_start0000000000000000000000");
|
||||
let request = make_request(Some("p"), None, Some(7), Some(start));
|
||||
with_plan(&request, &map, |plan| {
|
||||
fn duration_7_yields_seven() {
|
||||
with_text_plan(&make_request(Some("p"), None, Some(7), None), |plan| {
|
||||
assert!(matches!(plan.duration, Veo2Duration::SevenSeconds));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_8_yields_eight_seconds() {
|
||||
let (start, map) = fake_token("mf_start0000000000000000000000");
|
||||
let request = make_request(Some("p"), None, Some(8), Some(start));
|
||||
with_plan(&request, &map, |plan| {
|
||||
fn duration_8_yields_eight() {
|
||||
with_text_plan(&make_request(Some("p"), None, Some(8), None), |plan| {
|
||||
assert!(matches!(plan.duration, Veo2Duration::EightSeconds));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_above_8_clamps_to_eight() {
|
||||
let (start, map) = fake_token("mf_start0000000000000000000000");
|
||||
let request = make_request(Some("p"), None, Some(20), Some(start));
|
||||
with_plan(&request, &map, |plan| {
|
||||
with_text_plan(&make_request(Some("p"), None, Some(20), None), |plan| {
|
||||
assert!(matches!(plan.duration, Veo2Duration::EightSeconds));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
mod text {
|
||||
// Veo 2 is image-to-video only; there is no text-to-video mode.
|
||||
// (start_frame is required and the plan errors without it.)
|
||||
mod image {
|
||||
use super::*;
|
||||
|
||||
// ── Mode detection / start frame hydration ────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn start_frame_triggers_image_to_video() {
|
||||
let (start, map) = fake_token("mf_start0000000000000000000000");
|
||||
let request = make_request(Some("p"), None, None, Some(start));
|
||||
with_image_plan(&request, &map, |plan| {
|
||||
match &plan.mode {
|
||||
FalVeo2Mode::ImageToVideo { image_url } => {
|
||||
assert!(image_url.starts_with("https://fake.example.com/"));
|
||||
}
|
||||
other => panic!("expected ImageToVideo, got {:?}", other),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn start_frame_url_matches_hydration_map_exactly() {
|
||||
let token = MediaFileToken::new_from_str("mf_myframe0000000000000000000000");
|
||||
let expected_url = "https://cdn.example.com/frames/my_frame.png";
|
||||
let mut map = HashMap::new();
|
||||
map.insert(token.clone(), Url::parse(expected_url).unwrap());
|
||||
|
||||
let request = make_request(Some("p"), None, None, Some(token));
|
||||
with_image_plan(&request, &map, |plan| {
|
||||
match &plan.mode {
|
||||
FalVeo2Mode::ImageToVideo { image_url } => {
|
||||
assert_eq!(image_url, expected_url);
|
||||
}
|
||||
other => panic!("expected ImageToVideo, got {:?}", other),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Prompt passthrough in image mode ──────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn prompt_is_passed_through_in_image_mode() {
|
||||
let (start, map) = fake_token("mf_start0000000000000000000000");
|
||||
let request = make_request(Some("a horse on mars"), None, None, Some(start));
|
||||
with_image_plan(&request, &map, |plan| {
|
||||
assert_eq!(plan.prompt, "a horse on mars");
|
||||
});
|
||||
}
|
||||
|
||||
// ── Aspect ratio in image mode ────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn aspect_ratio_passes_through_in_image_mode() {
|
||||
let (start, map) = fake_token("mf_start0000000000000000000000");
|
||||
let request = make_request(Some("p"), Some(CommonAspectRatio::WideSixteenByNine), None, Some(start));
|
||||
with_image_plan(&request, &map, |plan| {
|
||||
assert!(matches!(plan.aspect_ratio, Veo2AspectRatio::WideSixteenNine));
|
||||
});
|
||||
}
|
||||
|
||||
// ── Duration pass-throughs in image mode ──────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn duration_5_in_image_mode() {
|
||||
let (start, map) = fake_token("mf_start0000000000000000000000");
|
||||
let request = make_request(Some("p"), None, Some(5), Some(start));
|
||||
with_image_plan(&request, &map, |plan| {
|
||||
assert!(matches!(plan.duration, Veo2Duration::FiveSeconds));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_8_in_image_mode() {
|
||||
let (start, map) = fake_token("mf_start0000000000000000000000");
|
||||
let request = make_request(Some("p"), None, Some(8), Some(start));
|
||||
with_image_plan(&request, &map, |plan| {
|
||||
assert!(matches!(plan.duration, Veo2Duration::EightSeconds));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_above_8_clamps_in_image_mode() {
|
||||
let (start, map) = fake_token("mf_start0000000000000000000000");
|
||||
let request = make_request(Some("p"), None, Some(20), Some(start));
|
||||
with_image_plan(&request, &map, |plan| {
|
||||
assert!(matches!(plan.duration, Veo2Duration::EightSeconds));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_duration_in_image_mode() {
|
||||
let (start, map) = fake_token("mf_start0000000000000000000000");
|
||||
let request = make_request(Some("p"), None, None, Some(start));
|
||||
with_image_plan(&request, &map, |plan| {
|
||||
assert!(matches!(plan.duration, Veo2Duration::Default));
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user