mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-25 19:29:29 -04:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
42694bd361 | ||
|
|
33909e0420 | ||
|
|
963521d87b | ||
|
|
4323743fe7 | ||
|
|
752fad12b3 | ||
|
|
99b831c3b0 | ||
|
|
4a1d1acddd | ||
|
|
47d6ac6841 | ||
|
|
689bda8bf3 | ||
|
|
7e36149d13 | ||
|
|
2bbeb3de78 | ||
|
|
eeacbf6719 |
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nUPDATE users\nSET\n is_banned = ?,\n maybe_mod_comments = ?,\n maybe_mod_user_token = ?,\n version = version + 1\n\nWHERE users.token = ?\nLIMIT 1\n ",
|
||||
"query": "\nUPDATE users\nSET\n is_banned = ?,\n maybe_mod_comments = ?,\n maybe_mod_user_token = ?,\n version = version + 1\n\nWHERE users.token = ?\nLIMIT 1\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
@@ -8,5 +8,5 @@
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "eab69a8d679b2dcfb06a9ccc79851f6c693d0c372142ff0e7ded89c95e4bcf44"
|
||||
"hash": "3d746dcb479005f1f4ae5e157a5ace7626eda4849d44c6c2d7079fd346eb4edc"
|
||||
}
|
||||
Generated
+1
@@ -770,6 +770,7 @@ dependencies = [
|
||||
"anyhow",
|
||||
"artcraft_api_defs",
|
||||
"artcraft_client",
|
||||
"enums",
|
||||
"fal_client",
|
||||
"log",
|
||||
"muapi_client",
|
||||
|
||||
+2
-1
@@ -16,7 +16,8 @@ pub struct GenerateVeo2ImageToVideoRequest {
|
||||
|
||||
/// Optional text prompt.
|
||||
pub prompt: Option<String>,
|
||||
|
||||
|
||||
/// **NB(bt): ASPECT RATIO IS ACTUALLY NOT USED IN VEO 2 IMAGE-TO-VIDEO**
|
||||
/// Optional: aspect ratio of the generated video.
|
||||
pub aspect_ratio: Option<GenerateVeo2AspectRatio>,
|
||||
|
||||
|
||||
+11
@@ -21,6 +21,9 @@ pub struct GenerateVeo3FastImageToVideoRequest {
|
||||
pub resolution: Option<GenerateVeo3FastResolution>,
|
||||
|
||||
/// Optional: aspect ratio of the generated video.
|
||||
pub aspect_ratio: Option<GenerateVeo3FastAspectRatio>,
|
||||
|
||||
/// Optional: duration of the generated video.
|
||||
pub duration: Option<GenerateVeo3FastDuration>,
|
||||
|
||||
/// Optional: Generate an audio track along with the video
|
||||
@@ -34,6 +37,14 @@ pub enum GenerateVeo3FastResolution {
|
||||
TenEightyP, // 1080p
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum GenerateVeo3FastAspectRatio {
|
||||
Auto,
|
||||
SixteenByNine,
|
||||
NineBySixteen,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum GenerateVeo3FastDuration {
|
||||
|
||||
+4
@@ -42,6 +42,10 @@ pub struct GenerateVeo3ImageToVideoRequest {
|
||||
pub enum GenerateVeo3AspectRatio {
|
||||
WideSixteenNine, // 16:9
|
||||
TallNineSixteen, // 9:16
|
||||
|
||||
/// NB: DO NOT REMOVE. This is an unsupported resolution for Veo 3, but we can't
|
||||
/// impact existing callers by removing it. The handler will convert this to a
|
||||
/// supported resolution or 400 the request instead.
|
||||
Square, // 1:1
|
||||
}
|
||||
|
||||
|
||||
+4
@@ -120,6 +120,9 @@ pub struct OmniGenImageModelDetails {
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub batch_size_default: Option<u16>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub is_disabled: Option<bool>,
|
||||
}
|
||||
|
||||
impl Default for OmniGenImageModelDetails {
|
||||
@@ -145,6 +148,7 @@ impl Default for OmniGenImageModelDetails {
|
||||
batch_size_max: None,
|
||||
batch_size_options: None,
|
||||
batch_size_default: None,
|
||||
is_disabled: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+4
@@ -163,6 +163,9 @@ pub struct OmniGenVideoModelDetails {
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub batch_size_default: Option<u16>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub is_disabled: Option<bool>,
|
||||
}
|
||||
|
||||
impl Default for OmniGenVideoModelDetails {
|
||||
@@ -202,6 +205,7 @@ impl Default for OmniGenVideoModelDetails {
|
||||
batch_size_max: None,
|
||||
batch_size_options: None,
|
||||
batch_size_default: None,
|
||||
is_disabled: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: Some(plan.start_frame.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,
|
||||
}))
|
||||
}
|
||||
|
||||
+59
-18
@@ -6,33 +6,74 @@ use crate::generate::generate_video::generate_video_response::{
|
||||
};
|
||||
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_veo_3::PlanArtcraftVeo3;
|
||||
use artcraft_api_defs::generate::video::generate_veo_3_image_to_video::GenerateVeo3ImageToVideoRequest;
|
||||
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_3_image_to_video::generate_veo_3_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_3(
|
||||
plan: &PlanArtcraftVeo3<'_>,
|
||||
artcraft_client: &RouterArtcraftClient,
|
||||
) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
|
||||
let request = GenerateVeo3ImageToVideoRequest {
|
||||
uuid_idempotency_token: plan.idempotency_token.clone(),
|
||||
media_file_token: Some(plan.start_frame.to_owned()),
|
||||
prompt: plan.prompt.map(|p| p.to_string()),
|
||||
aspect_ratio: plan.aspect_ratio,
|
||||
resolution: plan.resolution,
|
||||
duration: plan.duration,
|
||||
generate_audio: plan.generate_audio,
|
||||
let inference_job_token = match plan.start_frame {
|
||||
Some(media_file_token) => {
|
||||
// Image-to-video: use the legacy dedicated endpoint.
|
||||
let request = GenerateVeo3ImageToVideoRequest {
|
||||
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,
|
||||
resolution: plan.resolution,
|
||||
duration: plan.duration,
|
||||
generate_audio: plan.generate_audio,
|
||||
};
|
||||
|
||||
let response = generate_veo_3_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::Veo3),
|
||||
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: plan.generate_audio,
|
||||
};
|
||||
|
||||
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_3_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,
|
||||
}))
|
||||
}
|
||||
|
||||
+2
-1
@@ -14,8 +14,9 @@ pub async fn execute_artcraft_veo_3_fast(
|
||||
) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
|
||||
let request = GenerateVeo3FastImageToVideoRequest {
|
||||
uuid_idempotency_token: plan.idempotency_token.clone(),
|
||||
media_file_token: Some(plan.start_frame.to_owned()),
|
||||
media_file_token: plan.start_frame.map(|t| t.to_owned()),
|
||||
prompt: plan.prompt.map(|p| p.to_string()),
|
||||
aspect_ratio: plan.aspect_ratio,
|
||||
resolution: plan.resolution,
|
||||
duration: plan.duration,
|
||||
generate_audio: plan.generate_audio,
|
||||
|
||||
+35
-13
@@ -4,28 +4,50 @@ 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,
|
||||
enqueue_veo_2_image_to_video_webhook, Veo2Args, Veo2AspectRatio,
|
||||
};
|
||||
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.unwrap_or(Veo2AspectRatio::Auto),
|
||||
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 } => {
|
||||
// Image-to-video does not support aspect_ratio — the output
|
||||
// inherits the source image's aspect ratio.
|
||||
let args = Veo2Args {
|
||||
image_url: image_url.as_str(),
|
||||
prompt: plan.prompt.as_str(),
|
||||
api_key: &fal_client.api_key,
|
||||
duration: plan.duration,
|
||||
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,
|
||||
|
||||
+92
-16
@@ -4,30 +4,106 @@ 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_3::PlanFalVeo3;
|
||||
use fal_client::requests::webhook::video::image::enqueue_veo_3_image_to_video_webhook::{
|
||||
enqueue_veo_3_image_to_video_webhook, Veo3Args,
|
||||
use crate::generate::generate_video::plan::fal::plan_generate_video_fal_veo_3::{
|
||||
FalVeo3Duration, FalVeo3I2vAspectRatio, FalVeo3Mode, FalVeo3Resolution,
|
||||
FalVeo3T2vAspectRatio, PlanFalVeo3,
|
||||
};
|
||||
use fal_client::requests::webhook::video::image::enqueue_veo_3_image_to_video_webhook::{
|
||||
enqueue_veo_3_image_to_video_webhook, Veo3Args, Veo3I2vAspectRatio, Veo3I2vDuration,
|
||||
Veo3I2vResolution,
|
||||
};
|
||||
use fal_client::requests::webhook::video::text::enqueue_veo_3_text_to_video_webhook::{
|
||||
enqueue_veo_3_text_to_video_webhook, Veo3T2vAspectRatio, Veo3T2vDuration, Veo3T2vResolution,
|
||||
Veo3TextToVideoArgs,
|
||||
};
|
||||
|
||||
fn to_i2v_duration(d: FalVeo3Duration) -> Veo3I2vDuration {
|
||||
match d {
|
||||
FalVeo3Duration::Default => Veo3I2vDuration::Default,
|
||||
FalVeo3Duration::FourSeconds => Veo3I2vDuration::FourSeconds,
|
||||
FalVeo3Duration::SixSeconds => Veo3I2vDuration::SixSeconds,
|
||||
FalVeo3Duration::EightSeconds => Veo3I2vDuration::EightSeconds,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_t2v_duration(d: FalVeo3Duration) -> Veo3T2vDuration {
|
||||
match d {
|
||||
FalVeo3Duration::Default => Veo3T2vDuration::Default,
|
||||
FalVeo3Duration::FourSeconds => Veo3T2vDuration::FourSeconds,
|
||||
FalVeo3Duration::SixSeconds => Veo3T2vDuration::SixSeconds,
|
||||
FalVeo3Duration::EightSeconds => Veo3T2vDuration::EightSeconds,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_i2v_resolution(r: FalVeo3Resolution) -> Veo3I2vResolution {
|
||||
match r {
|
||||
FalVeo3Resolution::Default => Veo3I2vResolution::Default,
|
||||
FalVeo3Resolution::SevenTwentyP => Veo3I2vResolution::SevenTwentyP,
|
||||
FalVeo3Resolution::TenEightyP => Veo3I2vResolution::TenEightyP,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_t2v_resolution(r: FalVeo3Resolution) -> Veo3T2vResolution {
|
||||
match r {
|
||||
FalVeo3Resolution::Default => Veo3T2vResolution::Default,
|
||||
FalVeo3Resolution::SevenTwentyP => Veo3T2vResolution::SevenTwentyP,
|
||||
FalVeo3Resolution::TenEightyP => Veo3T2vResolution::TenEightyP,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_t2v_aspect_ratio(ar: FalVeo3T2vAspectRatio) -> Veo3T2vAspectRatio {
|
||||
match ar {
|
||||
FalVeo3T2vAspectRatio::Default => Veo3T2vAspectRatio::Default,
|
||||
FalVeo3T2vAspectRatio::WideSixteenNine => Veo3T2vAspectRatio::WideSixteenNine,
|
||||
FalVeo3T2vAspectRatio::TallNineSixteen => Veo3T2vAspectRatio::TallNineSixteen,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_i2v_aspect_ratio(ar: FalVeo3I2vAspectRatio) -> Veo3I2vAspectRatio {
|
||||
match ar {
|
||||
FalVeo3I2vAspectRatio::Auto => Veo3I2vAspectRatio::Auto,
|
||||
FalVeo3I2vAspectRatio::WideSixteenNine => Veo3I2vAspectRatio::WideSixteenNine,
|
||||
FalVeo3I2vAspectRatio::TallNineSixteen => Veo3I2vAspectRatio::TallNineSixteen,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn execute_fal_veo_3(
|
||||
plan: &PlanFalVeo3,
|
||||
fal_client: &RouterFalClient,
|
||||
) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
|
||||
let args = Veo3Args {
|
||||
image_url: plan.start_frame_url.as_str(),
|
||||
prompt: plan.prompt.as_str(),
|
||||
duration: plan.duration,
|
||||
aspect_ratio: plan.aspect_ratio,
|
||||
resolution: plan.resolution,
|
||||
generate_audio: plan.generate_audio,
|
||||
api_key: &fal_client.api_key,
|
||||
webhook_url: fal_client.webhook_url.as_str(),
|
||||
let webhook_response = match &plan.mode {
|
||||
FalVeo3Mode::TextToVideo => {
|
||||
let args = Veo3TextToVideoArgs {
|
||||
prompt: plan.prompt.as_str(),
|
||||
negative_prompt: plan.negative_prompt.as_deref(),
|
||||
api_key: &fal_client.api_key,
|
||||
duration: to_t2v_duration(plan.duration),
|
||||
aspect_ratio: plan.t2v_aspect_ratio.map(to_t2v_aspect_ratio).unwrap_or(Veo3T2vAspectRatio::Default),
|
||||
resolution: to_t2v_resolution(plan.resolution),
|
||||
generate_audio: plan.generate_audio,
|
||||
webhook_url: fal_client.webhook_url.as_str(),
|
||||
};
|
||||
enqueue_veo_3_text_to_video_webhook(args)
|
||||
.await
|
||||
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?
|
||||
}
|
||||
FalVeo3Mode::ImageToVideo { image_url } => {
|
||||
let args = Veo3Args {
|
||||
image_url: image_url.as_str(),
|
||||
prompt: plan.prompt.as_str(),
|
||||
duration: to_i2v_duration(plan.duration),
|
||||
aspect_ratio: plan.i2v_aspect_ratio.map(to_i2v_aspect_ratio).unwrap_or(Veo3I2vAspectRatio::Auto),
|
||||
resolution: to_i2v_resolution(plan.resolution),
|
||||
generate_audio: plan.generate_audio,
|
||||
api_key: &fal_client.api_key,
|
||||
webhook_url: fal_client.webhook_url.as_str(),
|
||||
};
|
||||
enqueue_veo_3_image_to_video_webhook(args)
|
||||
.await
|
||||
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?
|
||||
}
|
||||
};
|
||||
|
||||
let webhook_response = enqueue_veo_3_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,
|
||||
|
||||
+1
@@ -16,6 +16,7 @@ pub async fn execute_fal_veo_3_fast(
|
||||
let args = Veo3FastArgs {
|
||||
prompt: plan.prompt.as_str(),
|
||||
image_url: plan.start_frame_url.as_str(),
|
||||
aspect_ratio: plan.aspect_ratio,
|
||||
duration: plan.duration,
|
||||
api_key: &fal_client.api_key,
|
||||
resolution: plan.resolution,
|
||||
|
||||
+19
-12
@@ -13,7 +13,10 @@ use tokens::tokens::media_files::MediaFileToken;
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PlanArtcraftVeo2<'a> {
|
||||
pub prompt: Option<&'a str>,
|
||||
pub start_frame: &'a MediaFileToken,
|
||||
/// Required for execution. May be None on the cost path when the omni-gen
|
||||
/// distillation hydrated the media token to a URL (which the Artcraft
|
||||
/// provider can't use but cost estimation doesn't need).
|
||||
pub start_frame: Option<&'a MediaFileToken>,
|
||||
pub aspect_ratio: Option<GenerateVeo2AspectRatio>,
|
||||
pub duration: Option<GenerateVeo2Duration>,
|
||||
pub idempotency_token: String,
|
||||
@@ -25,16 +28,13 @@ pub fn plan_generate_video_artcraft_veo_2<'a>(
|
||||
let strategy = request.request_mismatch_mitigation_strategy;
|
||||
|
||||
let start_frame = match request.start_frame {
|
||||
Some(ImageRef::MediaFileToken(t)) => t,
|
||||
Some(ImageRef::Url(_)) => {
|
||||
return Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
|
||||
}
|
||||
None => {
|
||||
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "start_frame",
|
||||
value: "Veo 2 requires a starting frame".to_string(),
|
||||
}))
|
||||
}
|
||||
Some(ImageRef::MediaFileToken(t)) => Some(t),
|
||||
// Omni-gen distillation hydrates media tokens to URLs before running the
|
||||
// Artcraft cost path. Cost only depends on duration, so URL-form refs are
|
||||
// accepted and dropped.
|
||||
Some(ImageRef::Url(_)) => None,
|
||||
// No start_frame = text-to-video mode.
|
||||
None => None,
|
||||
};
|
||||
|
||||
if request.end_frame.is_some() {
|
||||
@@ -44,7 +44,14 @@ pub fn plan_generate_video_artcraft_veo_2<'a>(
|
||||
}));
|
||||
}
|
||||
|
||||
let aspect_ratio = plan_aspect_ratio(request.aspect_ratio, strategy)?;
|
||||
// Aspect ratio only applies to text-to-video; image-to-video inherits
|
||||
// the source frame's aspect ratio.
|
||||
let is_image_mode = request.start_frame.is_some();
|
||||
let aspect_ratio = if is_image_mode {
|
||||
None
|
||||
} else {
|
||||
plan_aspect_ratio(request.aspect_ratio, strategy)?
|
||||
};
|
||||
let duration = plan_duration(request.duration_seconds, strategy)?;
|
||||
|
||||
Ok(VideoGenerationPlan::ArtcraftVeo2(PlanArtcraftVeo2 {
|
||||
|
||||
+40
-17
@@ -14,10 +14,16 @@ use tokens::tokens::media_files::MediaFileToken;
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PlanArtcraftVeo3<'a> {
|
||||
pub prompt: Option<&'a str>,
|
||||
pub start_frame: &'a MediaFileToken,
|
||||
/// Required for image-to-video. None for text-to-video or when the
|
||||
/// omni-gen distillation hydrated the token to a URL.
|
||||
pub start_frame: Option<&'a MediaFileToken>,
|
||||
pub aspect_ratio: Option<GenerateVeo3AspectRatio>,
|
||||
pub resolution: Option<GenerateVeo3Resolution>,
|
||||
pub duration: Option<GenerateVeo3Duration>,
|
||||
/// The raw requested duration in seconds, preserved for cost estimation.
|
||||
/// The `duration` field above maps everything to `EightSeconds` for the
|
||||
/// legacy endpoint, but cost should reflect the actual requested value.
|
||||
pub duration_seconds_raw: Option<u16>,
|
||||
pub generate_audio: Option<bool>,
|
||||
pub idempotency_token: String,
|
||||
}
|
||||
@@ -28,16 +34,13 @@ pub fn plan_generate_video_artcraft_veo_3<'a>(
|
||||
let strategy = request.request_mismatch_mitigation_strategy;
|
||||
|
||||
let start_frame = match request.start_frame {
|
||||
Some(ImageRef::MediaFileToken(t)) => t,
|
||||
Some(ImageRef::Url(_)) => {
|
||||
return Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
|
||||
}
|
||||
None => {
|
||||
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "start_frame",
|
||||
value: "Veo 3 requires a starting frame".to_string(),
|
||||
}))
|
||||
}
|
||||
Some(ImageRef::MediaFileToken(t)) => Some(t),
|
||||
// Omni-gen distillation hydrates media tokens to URLs before running the
|
||||
// Artcraft cost path. Cost only depends on duration/audio, so URL-form
|
||||
// refs are accepted and dropped.
|
||||
Some(ImageRef::Url(_)) => None,
|
||||
// No start_frame = text-to-video mode.
|
||||
None => None,
|
||||
};
|
||||
|
||||
if request.end_frame.is_some() {
|
||||
@@ -47,7 +50,14 @@ pub fn plan_generate_video_artcraft_veo_3<'a>(
|
||||
}));
|
||||
}
|
||||
|
||||
let aspect_ratio = plan_aspect_ratio(request.aspect_ratio, strategy)?;
|
||||
// Aspect ratio only applies to text-to-video; image-to-video inherits
|
||||
// the source frame's aspect ratio.
|
||||
let is_image_mode = request.start_frame.is_some();
|
||||
let aspect_ratio = if is_image_mode {
|
||||
None
|
||||
} else {
|
||||
plan_aspect_ratio(request.aspect_ratio, strategy)?
|
||||
};
|
||||
let resolution = plan_resolution(request.resolution, strategy)?;
|
||||
let duration = plan_duration(request.duration_seconds, strategy)?;
|
||||
|
||||
@@ -57,6 +67,7 @@ pub fn plan_generate_video_artcraft_veo_3<'a>(
|
||||
aspect_ratio,
|
||||
resolution,
|
||||
duration,
|
||||
duration_seconds_raw: request.duration_seconds,
|
||||
generate_audio: request.generate_audio,
|
||||
idempotency_token: request.get_or_generate_idempotency_token(),
|
||||
}))
|
||||
@@ -112,14 +123,19 @@ fn plan_resolution(
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy storyteller endpoint for Veo 3 only exposes EightSeconds.
|
||||
// The legacy storyteller endpoint for Veo 3 only exposes EightSeconds, but
|
||||
// the artcraft plan stores the raw duration_seconds from the omni request so
|
||||
// that cost estimation works for all supported durations (4/6/8).
|
||||
fn plan_duration(
|
||||
duration_seconds: Option<u16>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<Option<GenerateVeo3Duration>, ArtcraftRouterError> {
|
||||
match duration_seconds {
|
||||
None => Ok(None),
|
||||
Some(8) => Ok(Some(GenerateVeo3Duration::EightSeconds)),
|
||||
// The api_defs enum only has EightSeconds; all durations map to it for
|
||||
// the legacy endpoint. The actual seconds are tracked via
|
||||
// `duration_seconds_raw` below for cost estimation.
|
||||
Some(4) | Some(6) | Some(8) => Ok(Some(GenerateVeo3Duration::EightSeconds)),
|
||||
Some(other) => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
@@ -127,18 +143,25 @@ fn plan_duration(
|
||||
value: format!("{}", other),
|
||||
}))
|
||||
}
|
||||
_ => Ok(Some(GenerateVeo3Duration::EightSeconds)),
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(Some(GenerateVeo3Duration::EightSeconds)),
|
||||
RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Some(GenerateVeo3Duration::EightSeconds)),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
impl PlanArtcraftVeo3<'_> {
|
||||
/// Mirrors the Fal client default (8s) used by the legacy generate handler.
|
||||
/// Duration in seconds for cost estimation. Uses the raw requested
|
||||
/// duration, clamped to {4, 6, 8}, defaulting to 8s.
|
||||
pub fn duration_seconds_for_cost(&self) -> u64 {
|
||||
8
|
||||
match self.duration_seconds_raw {
|
||||
Some(s) if s <= 4 => 4,
|
||||
Some(s) if s <= 6 => 6,
|
||||
_ => 8, // None, 7, 8, or above → 8s
|
||||
}
|
||||
}
|
||||
|
||||
/// Legacy Veo 3 generate handler defaults `generate_audio` to false.
|
||||
/// The omni path defaults to true (matching the fal plan builder).
|
||||
pub fn generate_audio_for_cost(&self) -> bool {
|
||||
self.generate_audio.unwrap_or(false)
|
||||
}
|
||||
|
||||
+36
-12
@@ -1,3 +1,4 @@
|
||||
use crate::api::common_aspect_ratio::CommonAspectRatio;
|
||||
use crate::api::common_resolution::CommonResolution;
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
|
||||
@@ -6,14 +7,15 @@ use crate::errors::client_error::ClientError;
|
||||
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
|
||||
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
|
||||
use artcraft_api_defs::generate::video::generate_veo_3_fast_image_to_video::{
|
||||
GenerateVeo3FastDuration, GenerateVeo3FastResolution,
|
||||
GenerateVeo3FastAspectRatio, GenerateVeo3FastDuration, GenerateVeo3FastResolution,
|
||||
};
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PlanArtcraftVeo3Fast<'a> {
|
||||
pub prompt: Option<&'a str>,
|
||||
pub start_frame: &'a MediaFileToken,
|
||||
pub start_frame: Option<&'a MediaFileToken>,
|
||||
pub aspect_ratio: Option<GenerateVeo3FastAspectRatio>,
|
||||
pub resolution: Option<GenerateVeo3FastResolution>,
|
||||
pub duration: Option<GenerateVeo3FastDuration>,
|
||||
pub generate_audio: Option<bool>,
|
||||
@@ -26,16 +28,9 @@ pub fn plan_generate_video_artcraft_veo_3_fast<'a>(
|
||||
let strategy = request.request_mismatch_mitigation_strategy;
|
||||
|
||||
let start_frame = match request.start_frame {
|
||||
Some(ImageRef::MediaFileToken(t)) => t,
|
||||
Some(ImageRef::Url(_)) => {
|
||||
return Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
|
||||
}
|
||||
None => {
|
||||
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "start_frame",
|
||||
value: "Veo 3 Fast requires a starting frame".to_string(),
|
||||
}))
|
||||
}
|
||||
Some(ImageRef::MediaFileToken(t)) => Some(t),
|
||||
Some(ImageRef::Url(_)) => None,
|
||||
None => None,
|
||||
};
|
||||
|
||||
if request.end_frame.is_some() {
|
||||
@@ -45,12 +40,14 @@ pub fn plan_generate_video_artcraft_veo_3_fast<'a>(
|
||||
}));
|
||||
}
|
||||
|
||||
let aspect_ratio = plan_aspect_ratio(request.aspect_ratio, strategy)?;
|
||||
let resolution = plan_resolution(request.resolution, strategy)?;
|
||||
let duration = plan_duration(request.duration_seconds, strategy)?;
|
||||
|
||||
Ok(VideoGenerationPlan::ArtcraftVeo3Fast(PlanArtcraftVeo3Fast {
|
||||
prompt: request.prompt,
|
||||
start_frame,
|
||||
aspect_ratio,
|
||||
resolution,
|
||||
duration,
|
||||
generate_audio: request.generate_audio,
|
||||
@@ -58,6 +55,33 @@ pub fn plan_generate_video_artcraft_veo_3_fast<'a>(
|
||||
}))
|
||||
}
|
||||
|
||||
fn plan_aspect_ratio(
|
||||
aspect_ratio: Option<CommonAspectRatio>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<Option<GenerateVeo3FastAspectRatio>, ArtcraftRouterError> {
|
||||
use GenerateVeo3FastAspectRatio as Ar;
|
||||
match aspect_ratio {
|
||||
None
|
||||
| Some(CommonAspectRatio::Auto)
|
||||
| Some(CommonAspectRatio::Auto2k)
|
||||
| Some(CommonAspectRatio::Auto3k)
|
||||
| Some(CommonAspectRatio::Auto4k) => Ok(Some(Ar::Auto)),
|
||||
|
||||
Some(CommonAspectRatio::WideSixteenByNine) | Some(CommonAspectRatio::Wide) => Ok(Some(Ar::SixteenByNine)),
|
||||
Some(CommonAspectRatio::TallNineBySixteen) | Some(CommonAspectRatio::Tall) => Ok(Some(Ar::NineBySixteen)),
|
||||
|
||||
Some(unsupported) => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "aspect_ratio",
|
||||
value: format!("{:?}", unsupported),
|
||||
}))
|
||||
}
|
||||
_ => Ok(Some(Ar::Auto)),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn plan_resolution(
|
||||
resolution: Option<CommonResolution>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
|
||||
+32
-18
@@ -9,11 +9,22 @@ 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 aspect_ratio: Veo2AspectRatio,
|
||||
pub negative_prompt: Option<String>,
|
||||
pub mode: FalVeo2Mode,
|
||||
/// Only set for text-to-video. Image-to-video inherits the source frame's
|
||||
/// aspect ratio and doesn't accept this parameter.
|
||||
pub aspect_ratio: Option<Veo2AspectRatio>,
|
||||
pub duration: Veo2Duration,
|
||||
}
|
||||
|
||||
@@ -22,35 +33,37 @@ 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 aspect_ratio = plan_aspect_ratio(request.aspect_ratio, strategy)?;
|
||||
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,
|
||||
};
|
||||
|
||||
// Aspect ratio only applies to text-to-video; image-to-video inherits
|
||||
// the source frame's aspect ratio.
|
||||
let aspect_ratio = match &mode {
|
||||
FalVeo2Mode::TextToVideo => Some(plan_aspect_ratio(request.aspect_ratio, strategy)?),
|
||||
FalVeo2Mode::ImageToVideo { .. } => None,
|
||||
};
|
||||
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 +73,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),
|
||||
|
||||
+117
-38
@@ -7,16 +7,65 @@ use crate::errors::client_error::ClientError;
|
||||
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
|
||||
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
|
||||
use fal_client::requests::webhook::video::image::enqueue_veo_3_image_to_video_webhook::{
|
||||
Veo3AspectRatio, Veo3Duration, Veo3Resolution,
|
||||
Veo3I2vAspectRatio, Veo3I2vDuration, Veo3I2vResolution,
|
||||
};
|
||||
use fal_client::requests::webhook::video::text::enqueue_veo_3_text_to_video_webhook::{
|
||||
Veo3T2vAspectRatio, Veo3T2vDuration, Veo3T2vResolution,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum FalVeo3Mode {
|
||||
TextToVideo,
|
||||
ImageToVideo {
|
||||
image_url: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Router-level duration shared between both modes.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum FalVeo3Duration {
|
||||
Default,
|
||||
FourSeconds,
|
||||
SixSeconds,
|
||||
EightSeconds,
|
||||
}
|
||||
|
||||
/// Router-level resolution shared between both modes.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum FalVeo3Resolution {
|
||||
Default,
|
||||
SevenTwentyP,
|
||||
TenEightyP,
|
||||
}
|
||||
|
||||
/// Router-level aspect ratio for text-to-video only (no Auto, no Square).
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum FalVeo3T2vAspectRatio {
|
||||
Default,
|
||||
WideSixteenNine,
|
||||
TallNineSixteen,
|
||||
}
|
||||
|
||||
/// Router-level aspect ratio for image-to-video only (has Auto, no Square).
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum FalVeo3I2vAspectRatio {
|
||||
Auto,
|
||||
WideSixteenNine,
|
||||
TallNineSixteen,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PlanFalVeo3 {
|
||||
pub prompt: String,
|
||||
pub start_frame_url: String,
|
||||
pub aspect_ratio: Veo3AspectRatio,
|
||||
pub resolution: Veo3Resolution,
|
||||
pub duration: Veo3Duration,
|
||||
pub negative_prompt: Option<String>,
|
||||
pub mode: FalVeo3Mode,
|
||||
/// Only set for text-to-video. Image-to-video inherits the source frame's
|
||||
/// aspect ratio and uses `i2v_aspect_ratio` instead.
|
||||
pub t2v_aspect_ratio: Option<FalVeo3T2vAspectRatio>,
|
||||
/// Only set for image-to-video.
|
||||
pub i2v_aspect_ratio: Option<FalVeo3I2vAspectRatio>,
|
||||
pub resolution: FalVeo3Resolution,
|
||||
pub duration: FalVeo3Duration,
|
||||
pub generate_audio: bool,
|
||||
}
|
||||
|
||||
@@ -25,58 +74,88 @@ pub fn plan_generate_video_fal_veo_3<'a>(
|
||||
) -> Result<VideoGenerationPlan<'a>, ArtcraftRouterError> {
|
||||
let strategy = request.request_mismatch_mitigation_strategy;
|
||||
|
||||
let start_frame_url = resolve_required_image_url(request.start_frame)?;
|
||||
if request.end_frame.is_some() {
|
||||
return Err(unsupported("end_frame", "Veo 3 does not support an ending frame"));
|
||||
}
|
||||
|
||||
let aspect_ratio = plan_aspect_ratio(request.aspect_ratio, strategy)?;
|
||||
let mode = match request.start_frame {
|
||||
Some(ImageRef::Url(url)) => FalVeo3Mode::ImageToVideo {
|
||||
image_url: url.to_string(),
|
||||
},
|
||||
Some(ImageRef::MediaFileToken(_)) => {
|
||||
return Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls));
|
||||
}
|
||||
None => FalVeo3Mode::TextToVideo,
|
||||
};
|
||||
|
||||
let (t2v_aspect_ratio, i2v_aspect_ratio) = match &mode {
|
||||
FalVeo3Mode::TextToVideo => {
|
||||
(Some(plan_t2v_aspect_ratio(request.aspect_ratio, strategy)?), None)
|
||||
}
|
||||
FalVeo3Mode::ImageToVideo { .. } => {
|
||||
(None, Some(plan_i2v_aspect_ratio(request.aspect_ratio, strategy)?))
|
||||
}
|
||||
};
|
||||
let resolution = plan_resolution(request.resolution, strategy)?;
|
||||
let duration = plan_duration(request.duration_seconds, strategy)?;
|
||||
let generate_audio = request.generate_audio.unwrap_or(true);
|
||||
|
||||
Ok(VideoGenerationPlan::FalVeo3(PlanFalVeo3 {
|
||||
prompt: request.prompt.unwrap_or("").to_string(),
|
||||
start_frame_url,
|
||||
aspect_ratio,
|
||||
negative_prompt: request.negative_prompt.map(|s| s.to_string()),
|
||||
mode,
|
||||
t2v_aspect_ratio,
|
||||
i2v_aspect_ratio,
|
||||
resolution,
|
||||
duration,
|
||||
generate_audio,
|
||||
}))
|
||||
}
|
||||
|
||||
fn resolve_required_image_url(
|
||||
image_ref: Option<ImageRef<'_>>,
|
||||
) -> 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("start_frame", "Veo 3 requires a starting frame")),
|
||||
/// Text-to-video: only 16:9 and 9:16 (no Auto, no Square).
|
||||
fn plan_t2v_aspect_ratio(
|
||||
aspect_ratio: Option<CommonAspectRatio>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<FalVeo3T2vAspectRatio, ArtcraftRouterError> {
|
||||
use FalVeo3T2vAspectRatio as Ar;
|
||||
match aspect_ratio {
|
||||
None => Ok(Ar::Default),
|
||||
|
||||
Some(CommonAspectRatio::WideSixteenByNine) | Some(CommonAspectRatio::Wide) => Ok(Ar::WideSixteenNine),
|
||||
Some(CommonAspectRatio::TallNineBySixteen) | Some(CommonAspectRatio::Tall) => Ok(Ar::TallNineSixteen),
|
||||
|
||||
// Everything else (Auto*, Square, unsupported) → Default (16:9).
|
||||
Some(_) => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(unsupported("aspect_ratio", &format!("{:?}", aspect_ratio.unwrap())))
|
||||
}
|
||||
_ => Ok(Ar::Default),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn plan_aspect_ratio(
|
||||
/// Image-to-video: Auto, 16:9, 9:16 (no Square).
|
||||
fn plan_i2v_aspect_ratio(
|
||||
aspect_ratio: Option<CommonAspectRatio>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<Veo3AspectRatio, ArtcraftRouterError> {
|
||||
use Veo3AspectRatio as Ar;
|
||||
) -> Result<FalVeo3I2vAspectRatio, ArtcraftRouterError> {
|
||||
use FalVeo3I2vAspectRatio as Ar;
|
||||
match aspect_ratio {
|
||||
None
|
||||
| Some(CommonAspectRatio::Auto)
|
||||
| Some(CommonAspectRatio::Auto2k)
|
||||
| Some(CommonAspectRatio::Auto4k) => Ok(Ar::Default),
|
||||
| Some(CommonAspectRatio::Auto3k)
|
||||
| Some(CommonAspectRatio::Auto4k) => Ok(Ar::Auto),
|
||||
|
||||
Some(CommonAspectRatio::Square) | Some(CommonAspectRatio::SquareHd) => Ok(Ar::Square),
|
||||
Some(CommonAspectRatio::WideSixteenByNine) | Some(CommonAspectRatio::Wide) => Ok(Ar::WideSixteenNine),
|
||||
Some(CommonAspectRatio::TallNineBySixteen) | Some(CommonAspectRatio::Tall) => Ok(Ar::TallNineSixteen),
|
||||
|
||||
Some(unsupported_ar) => match strategy {
|
||||
// Everything else (Square, unsupported) → Auto.
|
||||
Some(_) => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(unsupported("aspect_ratio", &format!("{:?}", unsupported_ar)))
|
||||
Err(unsupported("aspect_ratio", &format!("{:?}", aspect_ratio.unwrap())))
|
||||
}
|
||||
_ => Ok(Ar::Default),
|
||||
_ => Ok(Ar::Auto),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -84,8 +163,8 @@ fn plan_aspect_ratio(
|
||||
fn plan_resolution(
|
||||
resolution: Option<CommonResolution>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<Veo3Resolution, ArtcraftRouterError> {
|
||||
use Veo3Resolution as R;
|
||||
) -> Result<FalVeo3Resolution, ArtcraftRouterError> {
|
||||
use FalVeo3Resolution as R;
|
||||
match resolution {
|
||||
None => Ok(R::Default),
|
||||
Some(CommonResolution::SevenTwentyP) => Ok(R::SevenTwentyP),
|
||||
@@ -103,18 +182,18 @@ fn plan_resolution(
|
||||
fn plan_duration(
|
||||
duration_seconds: Option<u16>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<Veo3Duration, ArtcraftRouterError> {
|
||||
) -> Result<FalVeo3Duration, ArtcraftRouterError> {
|
||||
match duration_seconds {
|
||||
None => Ok(Veo3Duration::Default),
|
||||
Some(4) => Ok(Veo3Duration::FourSeconds),
|
||||
Some(6) => Ok(Veo3Duration::SixSeconds),
|
||||
Some(8) => Ok(Veo3Duration::EightSeconds),
|
||||
None => Ok(FalVeo3Duration::Default),
|
||||
Some(4) => Ok(FalVeo3Duration::FourSeconds),
|
||||
Some(6) => Ok(FalVeo3Duration::SixSeconds),
|
||||
Some(8) => Ok(FalVeo3Duration::EightSeconds),
|
||||
Some(other) => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(unsupported("duration_seconds", &format!("{}", other)))
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(Veo3Duration::EightSeconds),
|
||||
RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Veo3Duration::FourSeconds),
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(FalVeo3Duration::EightSeconds),
|
||||
RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(FalVeo3Duration::FourSeconds),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -129,9 +208,9 @@ fn unsupported(field: &'static str, value: &str) -> ArtcraftRouterError {
|
||||
impl PlanFalVeo3 {
|
||||
pub fn duration_seconds_for_cost(&self) -> u64 {
|
||||
match self.duration {
|
||||
Veo3Duration::Default | Veo3Duration::EightSeconds => 8,
|
||||
Veo3Duration::SixSeconds => 6,
|
||||
Veo3Duration::FourSeconds => 4,
|
||||
FalVeo3Duration::Default | FalVeo3Duration::EightSeconds => 8,
|
||||
FalVeo3Duration::SixSeconds => 6,
|
||||
FalVeo3Duration::FourSeconds => 4,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+29
-1
@@ -5,14 +5,16 @@ use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::errors::client_error::ClientError;
|
||||
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
|
||||
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
|
||||
use crate::api::common_aspect_ratio::CommonAspectRatio;
|
||||
use fal_client::requests::webhook::video::image::enqueue_veo_3_fast_image_to_video_webhook::{
|
||||
Veo3FastDuration, Veo3FastResolution,
|
||||
Veo3FastAspectRatio, Veo3FastDuration, Veo3FastResolution,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PlanFalVeo3Fast {
|
||||
pub prompt: String,
|
||||
pub start_frame_url: String,
|
||||
pub aspect_ratio: Veo3FastAspectRatio,
|
||||
pub resolution: Veo3FastResolution,
|
||||
pub duration: Veo3FastDuration,
|
||||
pub generate_audio: bool,
|
||||
@@ -28,6 +30,7 @@ pub fn plan_generate_video_fal_veo_3_fast<'a>(
|
||||
return Err(unsupported("end_frame", "Veo 3 Fast does not support an ending frame"));
|
||||
}
|
||||
|
||||
let aspect_ratio = plan_aspect_ratio(request.aspect_ratio, strategy)?;
|
||||
let resolution = plan_resolution(request.resolution, strategy)?;
|
||||
let duration = plan_duration(request.duration_seconds, strategy)?;
|
||||
let generate_audio = request.generate_audio.unwrap_or(true);
|
||||
@@ -35,6 +38,7 @@ pub fn plan_generate_video_fal_veo_3_fast<'a>(
|
||||
Ok(VideoGenerationPlan::FalVeo3Fast(PlanFalVeo3Fast {
|
||||
prompt: request.prompt.unwrap_or("").to_string(),
|
||||
start_frame_url,
|
||||
aspect_ratio,
|
||||
resolution,
|
||||
duration,
|
||||
generate_audio,
|
||||
@@ -53,6 +57,30 @@ fn resolve_required_image_url(
|
||||
}
|
||||
}
|
||||
|
||||
fn plan_aspect_ratio(
|
||||
aspect_ratio: Option<CommonAspectRatio>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<Veo3FastAspectRatio, ArtcraftRouterError> {
|
||||
use Veo3FastAspectRatio as Ar;
|
||||
match 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),
|
||||
Some(CommonAspectRatio::TallNineBySixteen) | Some(CommonAspectRatio::Tall) => Ok(Ar::TallNineSixteen),
|
||||
|
||||
Some(unsupported_ar) => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(unsupported("aspect_ratio", &format!("{:?}", unsupported_ar)))
|
||||
}
|
||||
_ => Ok(Ar::Auto),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn plan_resolution(
|
||||
resolution: Option<CommonResolution>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
|
||||
-4
@@ -7,10 +7,6 @@ pub struct Veo2ImageToVideoInput {
|
||||
|
||||
pub prompt: String,
|
||||
|
||||
/// Options: "auto", "16:9", "9:16"
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub aspect_ratio: Option<String>,
|
||||
|
||||
/// Options: "4s", "6s", "8s"
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub duration: Option<String>,
|
||||
|
||||
+4
@@ -7,6 +7,10 @@ pub struct Veo3FastImageToVideoInput {
|
||||
|
||||
pub prompt: String,
|
||||
|
||||
/// Options: "auto", "16:9", "9:16"
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub aspect_ratio: Option<String>,
|
||||
|
||||
/// Options: "720p", "1080p"
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub resolution: Option<String>,
|
||||
|
||||
+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)
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
use crate::requests::api::fal_request::FalRequest;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Default)]
|
||||
pub struct Veo3TextToVideoInput {
|
||||
pub prompt: String,
|
||||
|
||||
/// Options: "16:9", "9:16"
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub aspect_ratio: Option<String>,
|
||||
|
||||
/// Options: "720p", "1080p"
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub resolution: Option<String>,
|
||||
|
||||
/// Options: "4s", "6s", "8s"
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub duration: Option<String>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub generate_audio: Option<bool>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub negative_prompt: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Veo3TextToVideoVideoFile {
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Veo3TextToVideoOutput {
|
||||
pub video: Veo3TextToVideoVideoFile,
|
||||
}
|
||||
|
||||
pub fn veo_3_text_to_video(
|
||||
params: Veo3TextToVideoInput,
|
||||
) -> FalRequest<Veo3TextToVideoInput, Veo3TextToVideoOutput> {
|
||||
FalRequest::new("fal-ai/veo3", params)
|
||||
}
|
||||
@@ -7,5 +7,7 @@ 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_3_text_to_video;
|
||||
pub mod http_veo_3p1_fast_text_to_video;
|
||||
pub mod http_veo_3p1_text_to_video;
|
||||
|
||||
+12
-19
@@ -1,21 +1,22 @@
|
||||
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::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
|
||||
use crate::requests::http::video::image::http_veo_2_image_to_video::{veo_2_image_to_video, Veo2ImageToVideoInput};
|
||||
use crate::requests::api::webhook_response::WebhookResponse;
|
||||
use crate::requests::http::video::image::http_veo_2_image_to_video::{veo_2_image_to_video, Veo2ImageToVideoInput};
|
||||
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
|
||||
use reqwest::IntoUrl;
|
||||
|
||||
/// Args for Veo 2 image-to-video. Note: image-to-video does NOT support
|
||||
/// aspect_ratio — the output inherits the source image's aspect ratio.
|
||||
pub struct Veo2Args<'a, U: IntoUrl, V: IntoUrl> {
|
||||
pub image_url: U,
|
||||
pub webhook_url: V,
|
||||
pub prompt: &'a str,
|
||||
pub api_key: &'a FalApiKey,
|
||||
pub duration: Veo2Duration,
|
||||
pub aspect_ratio: Veo2AspectRatio,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Veo2Duration {
|
||||
Default, // NB: Default is 5 seconds
|
||||
FiveSeconds,
|
||||
@@ -24,6 +25,9 @@ pub enum Veo2Duration {
|
||||
EightSeconds,
|
||||
}
|
||||
|
||||
/// Aspect ratio enum — used by the text-to-video endpoint only.
|
||||
/// Kept here because it's the canonical location that the text-to-video
|
||||
/// webhook and the router plan both re-export from.
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
pub enum Veo2AspectRatio {
|
||||
Auto,
|
||||
@@ -59,22 +63,13 @@ pub async fn enqueue_veo_2_image_to_video_webhook<U: IntoUrl, V: IntoUrl>(
|
||||
Veo2Duration::SevenSeconds => Some("7s".to_string()),
|
||||
Veo2Duration::EightSeconds => Some("8s".to_string()),
|
||||
};
|
||||
|
||||
let aspect_ratio = match args.aspect_ratio {
|
||||
Veo2AspectRatio::Auto => Some("auto".to_string()),
|
||||
Veo2AspectRatio::AutoPreferPortrait => Some("auto_prefer_portrait".to_string()),
|
||||
Veo2AspectRatio::WideSixteenNine => Some("16:9".to_string()),
|
||||
Veo2AspectRatio::TallNineSixteen => Some("9:16".to_string()),
|
||||
};
|
||||
|
||||
let image_url = args.image_url.as_str().to_string();
|
||||
|
||||
let request = Veo2ImageToVideoInput {
|
||||
image_url,
|
||||
prompt: args.prompt.to_string(),
|
||||
aspect_ratio,
|
||||
duration,
|
||||
// Maybe expose these later
|
||||
};
|
||||
|
||||
let result = veo_2_image_to_video(request)
|
||||
@@ -89,26 +84,24 @@ pub async fn enqueue_veo_2_image_to_video_webhook<U: IntoUrl, V: IntoUrl>(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::creds::fal_api_key::FalApiKey;
|
||||
use crate::requests::webhook::video::image::enqueue_veo_2_image_to_video_webhook::{enqueue_veo_2_image_to_video_webhook, Veo2Args, Veo2AspectRatio, Veo2Duration};
|
||||
use crate::requests::webhook::video::image::enqueue_veo_2_image_to_video_webhook::{enqueue_veo_2_image_to_video_webhook, Veo2Args, Veo2Duration};
|
||||
use errors::AnyhowResult;
|
||||
use std::fs::read_to_string;
|
||||
use test_data::web::image_urls::MOUNTAIN_TREE_IMAGE_URL;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_kling21_pro_video() -> AnyhowResult<()> {
|
||||
let image_url = "https://cdn-2.fakeyou.com/media/3/4/h/f/s/34hfsmt8e38rvne6mwa4pwbxr6292sgy/image_34hfsmt8e38rvne6mwa4pwbxr6292sgy.png";
|
||||
|
||||
async fn test_veo_2_image_to_video() -> AnyhowResult<()> {
|
||||
// XXX: Don't commit secrets!
|
||||
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
|
||||
|
||||
let api_key = FalApiKey::from_str(&secret);
|
||||
|
||||
let args = Veo2Args {
|
||||
image_url: image_url,
|
||||
image_url: MOUNTAIN_TREE_IMAGE_URL,
|
||||
prompt: "a shot of the mountains as the sun sets and reveals the moon and stars",
|
||||
api_key: &api_key,
|
||||
duration: Veo2Duration::Default,
|
||||
aspect_ratio: Veo2AspectRatio::WideSixteenNine,
|
||||
webhook_url: "https://example.com/webhook",
|
||||
};
|
||||
|
||||
|
||||
+18
-2
@@ -9,6 +9,7 @@ use reqwest::IntoUrl;
|
||||
pub struct Veo3FastArgs<'a, U: IntoUrl, V: IntoUrl> {
|
||||
pub prompt: &'a str,
|
||||
pub image_url: U,
|
||||
pub aspect_ratio: Veo3FastAspectRatio,
|
||||
pub duration: Veo3FastDuration,
|
||||
pub api_key: &'a FalApiKey,
|
||||
pub resolution: Veo3FastResolution,
|
||||
@@ -24,6 +25,13 @@ pub enum Veo3FastDuration {
|
||||
EightSeconds,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
pub enum Veo3FastAspectRatio {
|
||||
Auto,
|
||||
WideSixteenNine, // 16:9
|
||||
TallNineSixteen, // 9:16
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
pub enum Veo3FastResolution {
|
||||
Default,
|
||||
@@ -62,7 +70,13 @@ pub async fn enqueue_veo_3_fast_image_to_video_webhook<U: IntoUrl, V: IntoUrl>(
|
||||
Veo3FastDuration::EightSeconds => Some("8s".to_string()),
|
||||
};
|
||||
|
||||
let resolution= match args.resolution {
|
||||
let aspect_ratio = match args.aspect_ratio {
|
||||
Veo3FastAspectRatio::Auto => Some("auto".to_string()),
|
||||
Veo3FastAspectRatio::WideSixteenNine => Some("16:9".to_string()),
|
||||
Veo3FastAspectRatio::TallNineSixteen => Some("9:16".to_string()),
|
||||
};
|
||||
|
||||
let resolution = match args.resolution {
|
||||
Veo3FastResolution::Default => None,
|
||||
Veo3FastResolution::SevenTwentyP => Some("720p".to_string()),
|
||||
Veo3FastResolution::TenEightyP => Some("1080p".to_string()),
|
||||
@@ -73,6 +87,7 @@ pub async fn enqueue_veo_3_fast_image_to_video_webhook<U: IntoUrl, V: IntoUrl>(
|
||||
let request = Veo3FastImageToVideoInput {
|
||||
image_url,
|
||||
prompt: args.prompt.to_string(),
|
||||
aspect_ratio,
|
||||
resolution,
|
||||
duration,
|
||||
generate_audio: Some(args.generate_audio),
|
||||
@@ -90,7 +105,7 @@ pub async fn enqueue_veo_3_fast_image_to_video_webhook<U: IntoUrl, V: IntoUrl>(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::creds::fal_api_key::FalApiKey;
|
||||
use crate::requests::webhook::video::image::enqueue_veo_3_fast_image_to_video_webhook::{enqueue_veo_3_fast_image_to_video_webhook, Veo3FastArgs, Veo3FastDuration, Veo3FastResolution};
|
||||
use crate::requests::webhook::video::image::enqueue_veo_3_fast_image_to_video_webhook::{enqueue_veo_3_fast_image_to_video_webhook, Veo3FastArgs, Veo3FastAspectRatio, Veo3FastDuration, Veo3FastResolution};
|
||||
use errors::AnyhowResult;
|
||||
use std::fs::read_to_string;
|
||||
use test_data::web::image_urls::ERNEST_GHOST_TREX_IMAGE_URL;
|
||||
@@ -109,6 +124,7 @@ mod tests {
|
||||
image_url: image_url,
|
||||
prompt: "man is standing next to a ghost and t-rex, they begin to chase him as the camera pulls back to show the wider scene",
|
||||
api_key: &api_key,
|
||||
aspect_ratio: Veo3FastAspectRatio::WideSixteenNine,
|
||||
duration: Veo3FastDuration::EightSeconds,
|
||||
generate_audio: true,
|
||||
resolution: Veo3FastResolution::TenEightyP,
|
||||
|
||||
+41
-39
@@ -9,32 +9,35 @@ use reqwest::IntoUrl;
|
||||
pub struct Veo3Args<'a, U: IntoUrl, V: IntoUrl> {
|
||||
pub image_url: U,
|
||||
pub prompt: &'a str,
|
||||
pub duration: Veo3Duration,
|
||||
pub aspect_ratio: Veo3AspectRatio,
|
||||
pub resolution: Veo3Resolution,
|
||||
pub duration: Veo3I2vDuration,
|
||||
pub aspect_ratio: Veo3I2vAspectRatio,
|
||||
pub resolution: Veo3I2vResolution,
|
||||
pub generate_audio: bool,
|
||||
pub api_key: &'a FalApiKey,
|
||||
pub webhook_url: V,
|
||||
}
|
||||
|
||||
/// Duration for Veo 3 image-to-video. Default is 8 seconds.
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
pub enum Veo3Duration {
|
||||
pub enum Veo3I2vDuration {
|
||||
Default, // Default is 8 seconds
|
||||
FourSeconds,
|
||||
SixSeconds,
|
||||
EightSeconds,
|
||||
}
|
||||
|
||||
/// Aspect ratio for Veo 3 image-to-video.
|
||||
/// Supports Auto (inherit source), 16:9, and 9:16. No Square.
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
pub enum Veo3AspectRatio {
|
||||
Default,
|
||||
WideSixteenNine,
|
||||
TallNineSixteen,
|
||||
Square,
|
||||
pub enum Veo3I2vAspectRatio {
|
||||
Auto,
|
||||
WideSixteenNine, // 16:9
|
||||
TallNineSixteen, // 9:16
|
||||
}
|
||||
|
||||
/// Resolution for Veo 3 image-to-video.
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
pub enum Veo3Resolution {
|
||||
pub enum Veo3I2vResolution {
|
||||
Default,
|
||||
SevenTwentyP,
|
||||
TenEightyP,
|
||||
@@ -48,14 +51,14 @@ impl <U: IntoUrl, V: IntoUrl> FalRequestCostCalculator for Veo3Args<'_, U, V> {
|
||||
// For example, a 5s video with audio on will cost $2."
|
||||
|
||||
match (self.duration, self.generate_audio) {
|
||||
(Veo3Duration::FourSeconds, false) => 80,
|
||||
(Veo3Duration::SixSeconds, false) => 120,
|
||||
(Veo3Duration::EightSeconds, false) => 160,
|
||||
(Veo3Duration::Default, false) => 160, // NB: Default is 8 seconds
|
||||
(Veo3Duration::FourSeconds, true) => 160,
|
||||
(Veo3Duration::SixSeconds, true) => 240,
|
||||
(Veo3Duration::EightSeconds, true) => 320,
|
||||
(Veo3Duration::Default, true) => 320, // NB: Default is 8 seconds320
|
||||
(Veo3I2vDuration::FourSeconds, false) => 80,
|
||||
(Veo3I2vDuration::SixSeconds, false) => 120,
|
||||
(Veo3I2vDuration::EightSeconds, false) => 160,
|
||||
(Veo3I2vDuration::Default, false) => 160,
|
||||
(Veo3I2vDuration::FourSeconds, true) => 160,
|
||||
(Veo3I2vDuration::SixSeconds, true) => 240,
|
||||
(Veo3I2vDuration::EightSeconds, true) => 320,
|
||||
(Veo3I2vDuration::Default, true) => 320,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -67,23 +70,22 @@ pub async fn enqueue_veo_3_image_to_video_webhook<U: IntoUrl, V: IntoUrl>(
|
||||
args: Veo3Args<'_, U, V>
|
||||
) -> Result<WebhookResponse, FalErrorPlus> {
|
||||
let duration = match args.duration {
|
||||
Veo3Duration::Default => None,
|
||||
Veo3Duration::FourSeconds => Some("4s".to_string()),
|
||||
Veo3Duration::SixSeconds => Some("6s".to_string()),
|
||||
Veo3Duration::EightSeconds => Some("8s".to_string()),
|
||||
Veo3I2vDuration::Default => None,
|
||||
Veo3I2vDuration::FourSeconds => Some("4s".to_string()),
|
||||
Veo3I2vDuration::SixSeconds => Some("6s".to_string()),
|
||||
Veo3I2vDuration::EightSeconds => Some("8s".to_string()),
|
||||
};
|
||||
|
||||
let aspect_ratio = match args.aspect_ratio {
|
||||
Veo3AspectRatio::Default => None,
|
||||
Veo3AspectRatio::WideSixteenNine => Some("16:9".to_string()),
|
||||
Veo3AspectRatio::TallNineSixteen => Some("9:16".to_string()),
|
||||
Veo3AspectRatio::Square => Some("1:1".to_string()),
|
||||
Veo3I2vAspectRatio::Auto => Some("auto".to_string()),
|
||||
Veo3I2vAspectRatio::WideSixteenNine => Some("16:9".to_string()),
|
||||
Veo3I2vAspectRatio::TallNineSixteen => Some("9:16".to_string()),
|
||||
};
|
||||
|
||||
let resolution= match args.resolution {
|
||||
Veo3Resolution::Default => None,
|
||||
Veo3Resolution::SevenTwentyP => Some("720p".to_string()),
|
||||
Veo3Resolution::TenEightyP => Some("1080p".to_string()),
|
||||
|
||||
let resolution = match args.resolution {
|
||||
Veo3I2vResolution::Default => None,
|
||||
Veo3I2vResolution::SevenTwentyP => Some("720p".to_string()),
|
||||
Veo3I2vResolution::TenEightyP => Some("1080p".to_string()),
|
||||
};
|
||||
|
||||
let image_url = args.image_url.as_str().to_string();
|
||||
@@ -109,7 +111,10 @@ pub async fn enqueue_veo_3_image_to_video_webhook<U: IntoUrl, V: IntoUrl>(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::creds::fal_api_key::FalApiKey;
|
||||
use crate::requests::webhook::video::image::enqueue_veo_3_image_to_video_webhook::{enqueue_veo_3_image_to_video_webhook, Veo3Args, Veo3AspectRatio, Veo3Duration, Veo3Resolution};
|
||||
use crate::requests::webhook::video::image::enqueue_veo_3_image_to_video_webhook::{
|
||||
enqueue_veo_3_image_to_video_webhook, Veo3Args, Veo3I2vAspectRatio, Veo3I2vDuration,
|
||||
Veo3I2vResolution,
|
||||
};
|
||||
use errors::AnyhowResult;
|
||||
use std::fs::read_to_string;
|
||||
use test_data::web::image_urls::TREX_SKELETON_IMAGE_URL;
|
||||
@@ -117,24 +122,21 @@ mod tests {
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test() -> AnyhowResult<()> {
|
||||
// XXX: Don't commit secrets!
|
||||
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
|
||||
|
||||
let api_key = FalApiKey::from_str(&secret);
|
||||
|
||||
let args = Veo3Args {
|
||||
image_url: TREX_SKELETON_IMAGE_URL,
|
||||
prompt: "the t-rex skeleton starts walking towards the camera and roars",
|
||||
api_key: &api_key,
|
||||
duration: Veo3Duration::EightSeconds,
|
||||
aspect_ratio: Veo3AspectRatio::WideSixteenNine,
|
||||
resolution: Veo3Resolution::TenEightyP,
|
||||
duration: Veo3I2vDuration::EightSeconds,
|
||||
aspect_ratio: Veo3I2vAspectRatio::WideSixteenNine,
|
||||
resolution: Veo3I2vResolution::TenEightyP,
|
||||
generate_audio: true,
|
||||
webhook_url: "https://example.com/webhook",
|
||||
};
|
||||
|
||||
let result = enqueue_veo_3_image_to_video_webhook(args).await?;
|
||||
|
||||
let _result = enqueue_veo_3_image_to_video_webhook(args).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
+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(())
|
||||
}
|
||||
}
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
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_3_text_to_video::{
|
||||
veo_3_text_to_video, Veo3TextToVideoInput,
|
||||
};
|
||||
use crate::requests::traits::fal_request_cost_calculator_trait::{
|
||||
FalRequestCostCalculator, UsdCents,
|
||||
};
|
||||
use reqwest::IntoUrl;
|
||||
|
||||
pub struct Veo3TextToVideoArgs<'a, V: IntoUrl> {
|
||||
pub prompt: &'a str,
|
||||
pub negative_prompt: Option<&'a str>,
|
||||
pub duration: Veo3T2vDuration,
|
||||
pub aspect_ratio: Veo3T2vAspectRatio,
|
||||
pub resolution: Veo3T2vResolution,
|
||||
pub generate_audio: bool,
|
||||
pub webhook_url: V,
|
||||
pub api_key: &'a FalApiKey,
|
||||
}
|
||||
|
||||
/// Duration for Veo 3 text-to-video. Default is 8 seconds.
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
pub enum Veo3T2vDuration {
|
||||
Default, // Default is 8 seconds
|
||||
FourSeconds,
|
||||
SixSeconds,
|
||||
EightSeconds,
|
||||
}
|
||||
|
||||
/// Aspect ratio for Veo 3 text-to-video.
|
||||
/// Only 16:9 and 9:16 are supported. No Auto, no Square.
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
pub enum Veo3T2vAspectRatio {
|
||||
Default, // Default is 16:9
|
||||
WideSixteenNine, // 16:9
|
||||
TallNineSixteen, // 9:16
|
||||
}
|
||||
|
||||
/// Resolution for Veo 3 text-to-video.
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
pub enum Veo3T2vResolution {
|
||||
Default,
|
||||
SevenTwentyP,
|
||||
TenEightyP,
|
||||
}
|
||||
|
||||
impl<V: IntoUrl> FalRequestCostCalculator for Veo3TextToVideoArgs<'_, V> {
|
||||
fn calculate_cost_in_cents(&self) -> UsdCents {
|
||||
// $0.20/sec audio off, $0.40/sec audio on.
|
||||
match (self.duration, self.generate_audio) {
|
||||
(Veo3T2vDuration::FourSeconds, false) => 80,
|
||||
(Veo3T2vDuration::SixSeconds, false) => 120,
|
||||
(Veo3T2vDuration::EightSeconds, false) => 160,
|
||||
(Veo3T2vDuration::Default, false) => 160,
|
||||
(Veo3T2vDuration::FourSeconds, true) => 160,
|
||||
(Veo3T2vDuration::SixSeconds, true) => 240,
|
||||
(Veo3T2vDuration::EightSeconds, true) => 320,
|
||||
(Veo3T2vDuration::Default, true) => 320,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Veo 3 Text-to-Video
|
||||
/// https://fal.ai/models/fal-ai/veo3
|
||||
pub async fn enqueue_veo_3_text_to_video_webhook<V: IntoUrl>(
|
||||
args: Veo3TextToVideoArgs<'_, V>,
|
||||
) -> Result<WebhookResponse, FalErrorPlus> {
|
||||
let duration = match args.duration {
|
||||
Veo3T2vDuration::Default => None,
|
||||
Veo3T2vDuration::FourSeconds => Some("4s".to_string()),
|
||||
Veo3T2vDuration::SixSeconds => Some("6s".to_string()),
|
||||
Veo3T2vDuration::EightSeconds => Some("8s".to_string()),
|
||||
};
|
||||
|
||||
let aspect_ratio = match args.aspect_ratio {
|
||||
Veo3T2vAspectRatio::Default => None,
|
||||
Veo3T2vAspectRatio::WideSixteenNine => Some("16:9".to_string()),
|
||||
Veo3T2vAspectRatio::TallNineSixteen => Some("9:16".to_string()),
|
||||
};
|
||||
|
||||
let resolution = match args.resolution {
|
||||
Veo3T2vResolution::Default => None,
|
||||
Veo3T2vResolution::SevenTwentyP => Some("720p".to_string()),
|
||||
Veo3T2vResolution::TenEightyP => Some("1080p".to_string()),
|
||||
};
|
||||
|
||||
let request = Veo3TextToVideoInput {
|
||||
prompt: args.prompt.to_string(),
|
||||
aspect_ratio,
|
||||
resolution,
|
||||
duration,
|
||||
generate_audio: Some(args.generate_audio),
|
||||
negative_prompt: args.negative_prompt.map(|s| s.to_string()),
|
||||
};
|
||||
|
||||
let result = veo_3_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_3_text_to_video_webhook::{
|
||||
enqueue_veo_3_text_to_video_webhook, Veo3TextToVideoArgs, Veo3T2vAspectRatio,
|
||||
Veo3T2vDuration, Veo3T2vResolution,
|
||||
};
|
||||
use errors::AnyhowResult;
|
||||
use std::fs::read_to_string;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_veo_3_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 = Veo3TextToVideoArgs {
|
||||
prompt: "a drone shot of a coastal sunset with waves crashing on rocks",
|
||||
negative_prompt: None,
|
||||
api_key: &api_key,
|
||||
duration: Veo3T2vDuration::EightSeconds,
|
||||
aspect_ratio: Veo3T2vAspectRatio::WideSixteenNine,
|
||||
resolution: Veo3T2vResolution::TenEightyP,
|
||||
generate_audio: true,
|
||||
webhook_url: "https://example.com/webhook",
|
||||
};
|
||||
|
||||
let _result = enqueue_veo_3_text_to_video_webhook(args).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -7,5 +7,7 @@ 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_3_text_to_video_webhook;
|
||||
pub mod enqueue_veo_3p1_fast_text_to_video_webhook;
|
||||
pub mod enqueue_veo_3p1_text_to_video_webhook;
|
||||
+1
@@ -41,6 +41,7 @@ pub (super) async fn handle_artcraft_veo3_fast(
|
||||
generate_audio: request.generate_audio,
|
||||
duration: None, // TODO: Parameterize
|
||||
resolution: None, // TODO: Parameterize
|
||||
aspect_ratio: None, // TODO: Parameterize
|
||||
};
|
||||
|
||||
let result = generate_veo_3_fast_image_to_video(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
|
||||
/// The version of the ArtCraft desktop app reported in the UI and via Analytics.
|
||||
/// This should match the CI builds.
|
||||
pub const ARTCRAFT_VERSION: &str = "0.27.0";
|
||||
pub const ARTCRAFT_VERSION: &str = "0.28.0";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2.0.0-rc",
|
||||
"productName": "ArtCraft",
|
||||
"version": "0.27.0",
|
||||
"version": "0.28.0",
|
||||
"identifier": "ai.artcraft.app",
|
||||
"build": {
|
||||
"frontendDist": "../../../frontend/apps/artcraft/dist",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2.0.0-rc",
|
||||
"productName": "ArtCraft",
|
||||
"version": "0.27.0",
|
||||
"version": "0.28.0",
|
||||
"identifier": "ai.artcraft.app",
|
||||
"build": {
|
||||
"frontendDist": "../../../frontend/apps/artcraft/dist",
|
||||
|
||||
+24
-20
@@ -1,20 +1,27 @@
|
||||
use anyhow::anyhow;
|
||||
use sqlx::MySqlPool;
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
use errors::AnyhowResult;
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
pub struct SetUserBanStatsArgs<'a> {
|
||||
pub struct SetUserBanStatusArgs<'a, 'c: 'a, E>
|
||||
where E: 'a + Executor<'c, Database = MySql>
|
||||
{
|
||||
pub subject_user_token: &'a UserToken,
|
||||
pub is_banned: bool,
|
||||
pub mod_user_token: &'a UserToken,
|
||||
pub maybe_mod_comments: Option<&'a str>,
|
||||
pub mysql_pool: &'a MySqlPool,
|
||||
pub mysql_executor: E,
|
||||
pub phantom: PhantomData<&'c E>,
|
||||
}
|
||||
|
||||
pub async fn set_user_ban_status(args: SetUserBanStatsArgs<'_>) -> AnyhowResult<()> {
|
||||
let query_result = sqlx::query!(
|
||||
r#"
|
||||
pub async fn set_user_ban_status<'a, 'c, E>(
|
||||
args: SetUserBanStatusArgs<'a, 'c, E>,
|
||||
) -> Result<(), sqlx::Error>
|
||||
where E: 'a + Executor<'c, Database = MySql>
|
||||
{
|
||||
sqlx::query!(
|
||||
r#"
|
||||
UPDATE users
|
||||
SET
|
||||
is_banned = ?,
|
||||
@@ -24,17 +31,14 @@ SET
|
||||
|
||||
WHERE users.token = ?
|
||||
LIMIT 1
|
||||
"#,
|
||||
args.is_banned,
|
||||
args.maybe_mod_comments,
|
||||
args.mod_user_token,
|
||||
args.subject_user_token,
|
||||
)
|
||||
.execute(args.mysql_pool)
|
||||
.await;
|
||||
"#,
|
||||
args.is_banned,
|
||||
args.maybe_mod_comments,
|
||||
args.mod_user_token,
|
||||
args.subject_user_token,
|
||||
)
|
||||
.execute(args.mysql_executor)
|
||||
.await?;
|
||||
|
||||
match query_result {
|
||||
Ok(_) => Ok(()),
|
||||
Err(err) => Err(anyhow!("error with query: {:?}", err)),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -20,6 +20,22 @@ pub enum StaffAuditAction {
|
||||
/// Staff redeemed an impersonation request, creating a session.
|
||||
#[serde(rename = "impersonate_user_redeem")]
|
||||
ImpersonateUserRedeem,
|
||||
|
||||
/// Staff banned a user.
|
||||
#[serde(rename = "ban_user")]
|
||||
BanUser,
|
||||
|
||||
/// Staff unbanned a user.
|
||||
#[serde(rename = "unban_user")]
|
||||
UnbanUser,
|
||||
|
||||
/// Staff added banked balance to a wallet.
|
||||
#[serde(rename = "add_wallet_banked_balance")]
|
||||
AddWalletBankedBalance,
|
||||
|
||||
/// Staff sent a manual alert via the pager system.
|
||||
#[serde(rename = "send_alert")]
|
||||
SendAlert,
|
||||
}
|
||||
|
||||
impl_enum_display_and_debug_using_to_str!(StaffAuditAction);
|
||||
@@ -31,6 +47,10 @@ impl StaffAuditAction {
|
||||
match self {
|
||||
Self::ImpersonateUserRequest => "impersonate_user_request",
|
||||
Self::ImpersonateUserRedeem => "impersonate_user_redeem",
|
||||
Self::BanUser => "ban_user",
|
||||
Self::UnbanUser => "unban_user",
|
||||
Self::AddWalletBankedBalance => "add_wallet_banked_balance",
|
||||
Self::SendAlert => "send_alert",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +58,10 @@ impl StaffAuditAction {
|
||||
match value {
|
||||
"impersonate_user_request" => Ok(Self::ImpersonateUserRequest),
|
||||
"impersonate_user_redeem" => Ok(Self::ImpersonateUserRedeem),
|
||||
"ban_user" => Ok(Self::BanUser),
|
||||
"unban_user" => Ok(Self::UnbanUser),
|
||||
"add_wallet_banked_balance" => Ok(Self::AddWalletBankedBalance),
|
||||
"send_alert" => Ok(Self::SendAlert),
|
||||
_ => Err(format!("invalid StaffAuditAction value: {:?}", value)),
|
||||
}
|
||||
}
|
||||
@@ -46,6 +70,10 @@ impl StaffAuditAction {
|
||||
BTreeSet::from([
|
||||
Self::ImpersonateUserRequest,
|
||||
Self::ImpersonateUserRedeem,
|
||||
Self::BanUser,
|
||||
Self::UnbanUser,
|
||||
Self::AddWalletBankedBalance,
|
||||
Self::SendAlert,
|
||||
])
|
||||
}
|
||||
}
|
||||
@@ -62,24 +90,36 @@ mod tests {
|
||||
fn test_serialization() {
|
||||
assert_serialization(StaffAuditAction::ImpersonateUserRequest, "impersonate_user_request");
|
||||
assert_serialization(StaffAuditAction::ImpersonateUserRedeem, "impersonate_user_redeem");
|
||||
assert_serialization(StaffAuditAction::BanUser, "ban_user");
|
||||
assert_serialization(StaffAuditAction::UnbanUser, "unban_user");
|
||||
assert_serialization(StaffAuditAction::AddWalletBankedBalance, "add_wallet_banked_balance");
|
||||
assert_serialization(StaffAuditAction::SendAlert, "send_alert");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_str() {
|
||||
assert_eq!(StaffAuditAction::ImpersonateUserRequest.to_str(), "impersonate_user_request");
|
||||
assert_eq!(StaffAuditAction::ImpersonateUserRedeem.to_str(), "impersonate_user_redeem");
|
||||
assert_eq!(StaffAuditAction::BanUser.to_str(), "ban_user");
|
||||
assert_eq!(StaffAuditAction::UnbanUser.to_str(), "unban_user");
|
||||
assert_eq!(StaffAuditAction::AddWalletBankedBalance.to_str(), "add_wallet_banked_balance");
|
||||
assert_eq!(StaffAuditAction::SendAlert.to_str(), "send_alert");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_str() {
|
||||
assert_eq!(StaffAuditAction::from_str("impersonate_user_request").unwrap(), StaffAuditAction::ImpersonateUserRequest);
|
||||
assert_eq!(StaffAuditAction::from_str("impersonate_user_redeem").unwrap(), StaffAuditAction::ImpersonateUserRedeem);
|
||||
assert_eq!(StaffAuditAction::from_str("ban_user").unwrap(), StaffAuditAction::BanUser);
|
||||
assert_eq!(StaffAuditAction::from_str("unban_user").unwrap(), StaffAuditAction::UnbanUser);
|
||||
assert_eq!(StaffAuditAction::from_str("add_wallet_banked_balance").unwrap(), StaffAuditAction::AddWalletBankedBalance);
|
||||
assert_eq!(StaffAuditAction::from_str("send_alert").unwrap(), StaffAuditAction::SendAlert);
|
||||
assert!(StaffAuditAction::from_str("invalid").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_variants() {
|
||||
const EXPECTED_COUNT: usize = 2;
|
||||
const EXPECTED_COUNT: usize = 6;
|
||||
assert_eq!(StaffAuditAction::all_variants().len(), EXPECTED_COUNT);
|
||||
}
|
||||
}
|
||||
|
||||
+11
-1
@@ -16,6 +16,10 @@ pub enum StaffAuditEntityType {
|
||||
/// A user account.
|
||||
#[serde(rename = "user")]
|
||||
User,
|
||||
|
||||
/// A wallet.
|
||||
#[serde(rename = "wallet")]
|
||||
Wallet,
|
||||
}
|
||||
|
||||
impl_enum_display_and_debug_using_to_str!(StaffAuditEntityType);
|
||||
@@ -26,12 +30,14 @@ impl StaffAuditEntityType {
|
||||
pub fn to_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::User => "user",
|
||||
Self::Wallet => "wallet",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_str(value: &str) -> Result<Self, String> {
|
||||
match value {
|
||||
"user" => Ok(Self::User),
|
||||
"wallet" => Ok(Self::Wallet),
|
||||
_ => Err(format!("invalid StaffAuditEntityType value: {:?}", value)),
|
||||
}
|
||||
}
|
||||
@@ -39,6 +45,7 @@ impl StaffAuditEntityType {
|
||||
pub fn all_variants() -> BTreeSet<Self> {
|
||||
BTreeSet::from([
|
||||
Self::User,
|
||||
Self::Wallet,
|
||||
])
|
||||
}
|
||||
}
|
||||
@@ -54,22 +61,25 @@ mod tests {
|
||||
#[test]
|
||||
fn test_serialization() {
|
||||
assert_serialization(StaffAuditEntityType::User, "user");
|
||||
assert_serialization(StaffAuditEntityType::Wallet, "wallet");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_str() {
|
||||
assert_eq!(StaffAuditEntityType::User.to_str(), "user");
|
||||
assert_eq!(StaffAuditEntityType::Wallet.to_str(), "wallet");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_str() {
|
||||
assert_eq!(StaffAuditEntityType::from_str("user").unwrap(), StaffAuditEntityType::User);
|
||||
assert_eq!(StaffAuditEntityType::from_str("wallet").unwrap(), StaffAuditEntityType::Wallet);
|
||||
assert!(StaffAuditEntityType::from_str("invalid").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_variants() {
|
||||
const EXPECTED_COUNT: usize = 1;
|
||||
const EXPECTED_COUNT: usize = 2;
|
||||
assert_eq!(StaffAuditEntityType::all_variants().len(), EXPECTED_COUNT);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ fn build_omni_gen_video_models() -> Vec<OmniGenVideoModelDetails> {
|
||||
});
|
||||
|
||||
models.push(OmniGenVideoModelDetails {
|
||||
is_disabled: Some(true), // TODO: Temporarily disable
|
||||
model: CommonVideoModel::Seedance2p0,
|
||||
full_name: None,
|
||||
text_prompt_supported: Some(true),
|
||||
@@ -79,11 +80,14 @@ fn build_omni_gen_video_models() -> Vec<OmniGenVideoModelDetails> {
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
// TODO(bt,2026-04-10): Veo 2 image-to-video doesn't support aspect ratio
|
||||
models.push(OmniGenVideoModelDetails {
|
||||
is_disabled: Some(true), // TODO: Temporarily disable
|
||||
model: CommonVideoModel::Veo2,
|
||||
full_name: Some("Google Veo 2".to_string()),
|
||||
text_prompt_supported: Some(true),
|
||||
starting_keyframe_supported: Some(true),
|
||||
// TODO(bt,2026-04-10): Veo 2 image-to-video doesn't support aspect ratio
|
||||
aspect_ratio_options: Some(vec![
|
||||
CommonAspectRatio::Auto,
|
||||
CommonAspectRatio::WideSixteenByNine,
|
||||
@@ -97,6 +101,7 @@ fn build_omni_gen_video_models() -> Vec<OmniGenVideoModelDetails> {
|
||||
});
|
||||
|
||||
models.push(OmniGenVideoModelDetails {
|
||||
is_disabled: Some(true), // TODO: Temporarily disable
|
||||
model: CommonVideoModel::Veo3,
|
||||
full_name: Some("Google Veo 3".to_string()),
|
||||
text_prompt_supported: Some(true),
|
||||
@@ -105,8 +110,9 @@ fn build_omni_gen_video_models() -> Vec<OmniGenVideoModelDetails> {
|
||||
aspect_ratio_options: Some(vec![
|
||||
CommonAspectRatio::WideSixteenByNine,
|
||||
CommonAspectRatio::TallNineBySixteen,
|
||||
CommonAspectRatio::Square,
|
||||
CommonAspectRatio::Auto, // TODO: Only for image-to-video
|
||||
]),
|
||||
// TODO: image-to-video aspect ratio options
|
||||
aspect_ratio_default: Some(CommonAspectRatio::WideSixteenByNine),
|
||||
resolution_options: Some(vec![
|
||||
CommonResolution::SevenTwentyP,
|
||||
@@ -137,6 +143,7 @@ fn build_omni_gen_video_models() -> Vec<OmniGenVideoModelDetails> {
|
||||
});
|
||||
|
||||
models.push(OmniGenVideoModelDetails {
|
||||
is_disabled: Some(true), // TODO: Temporarily disable
|
||||
model: CommonVideoModel::Veo3p1,
|
||||
full_name: Some("Google Veo 3.1".to_string()),
|
||||
text_prompt_supported: Some(true),
|
||||
@@ -161,6 +168,7 @@ fn build_omni_gen_video_models() -> Vec<OmniGenVideoModelDetails> {
|
||||
});
|
||||
|
||||
models.push(OmniGenVideoModelDetails {
|
||||
is_disabled: Some(true), // TODO: Temporarily disable
|
||||
model: CommonVideoModel::Veo3p1Fast,
|
||||
full_name: Some("Google Veo 3.1 Fast".to_string()),
|
||||
text_prompt_supported: Some(true),
|
||||
@@ -185,6 +193,7 @@ fn build_omni_gen_video_models() -> Vec<OmniGenVideoModelDetails> {
|
||||
});
|
||||
|
||||
models.push(OmniGenVideoModelDetails {
|
||||
is_disabled: Some(true), // TODO: Temporarily disable
|
||||
model: CommonVideoModel::Kling16Pro,
|
||||
full_name: Some("Kling 1.6 Pro".to_string()),
|
||||
starting_keyframe_supported: Some(true),
|
||||
@@ -201,6 +210,7 @@ fn build_omni_gen_video_models() -> Vec<OmniGenVideoModelDetails> {
|
||||
});
|
||||
|
||||
models.push(OmniGenVideoModelDetails {
|
||||
is_disabled: Some(true), // TODO: Temporarily disable
|
||||
model: CommonVideoModel::Kling21Pro,
|
||||
full_name: Some("Kling 2.1 Pro".to_string()),
|
||||
starting_keyframe_supported: Some(true),
|
||||
@@ -217,6 +227,7 @@ fn build_omni_gen_video_models() -> Vec<OmniGenVideoModelDetails> {
|
||||
});
|
||||
|
||||
models.push(OmniGenVideoModelDetails {
|
||||
is_disabled: Some(true), // TODO: Temporarily disable
|
||||
model: CommonVideoModel::Kling21Master,
|
||||
full_name: Some("Kling 2.1 Master".to_string()),
|
||||
starting_keyframe_supported: Some(true),
|
||||
@@ -232,6 +243,7 @@ fn build_omni_gen_video_models() -> Vec<OmniGenVideoModelDetails> {
|
||||
});
|
||||
|
||||
models.push(OmniGenVideoModelDetails {
|
||||
is_disabled: Some(true), // TODO: Temporarily disable
|
||||
model: CommonVideoModel::Kling2p5TurboPro,
|
||||
full_name: Some("Kling 2.5 Turbo Pro".to_string()),
|
||||
text_prompt_supported: Some(true),
|
||||
@@ -249,6 +261,7 @@ fn build_omni_gen_video_models() -> Vec<OmniGenVideoModelDetails> {
|
||||
});
|
||||
|
||||
models.push(OmniGenVideoModelDetails {
|
||||
is_disabled: Some(true), // TODO: Temporarily disable
|
||||
model: CommonVideoModel::Kling2p6Pro,
|
||||
full_name: Some("Kling 2.6 Pro".to_string()),
|
||||
text_prompt_supported: Some(true),
|
||||
@@ -266,6 +279,7 @@ fn build_omni_gen_video_models() -> Vec<OmniGenVideoModelDetails> {
|
||||
});
|
||||
|
||||
models.push(OmniGenVideoModelDetails {
|
||||
is_disabled: Some(true), // TODO: Temporarily disable
|
||||
model: CommonVideoModel::Kling3p0Pro,
|
||||
full_name: Some("Kling 3.0 Pro".to_string()),
|
||||
text_prompt_supported: Some(true),
|
||||
@@ -285,6 +299,7 @@ fn build_omni_gen_video_models() -> Vec<OmniGenVideoModelDetails> {
|
||||
});
|
||||
|
||||
models.push(OmniGenVideoModelDetails {
|
||||
is_disabled: Some(true), // TODO: Temporarily disable
|
||||
model: CommonVideoModel::Kling3p0Standard,
|
||||
full_name: Some("Kling 3.0 Standard".to_string()),
|
||||
text_prompt_supported: Some(true),
|
||||
@@ -304,6 +319,7 @@ fn build_omni_gen_video_models() -> Vec<OmniGenVideoModelDetails> {
|
||||
});
|
||||
|
||||
models.push(OmniGenVideoModelDetails {
|
||||
is_disabled: Some(true), // TODO: Temporarily disable
|
||||
model: CommonVideoModel::Seedance10Lite,
|
||||
full_name: Some("Seedance 1.0 Lite".to_string()),
|
||||
starting_keyframe_supported: Some(true),
|
||||
@@ -330,6 +346,7 @@ fn build_omni_gen_video_models() -> Vec<OmniGenVideoModelDetails> {
|
||||
});
|
||||
|
||||
models.push(OmniGenVideoModelDetails {
|
||||
is_disabled: Some(true), // TODO: Temporarily disable
|
||||
model: CommonVideoModel::Sora2,
|
||||
full_name: Some("Sora 2".to_string()),
|
||||
text_prompt_supported: Some(true),
|
||||
@@ -347,6 +364,7 @@ fn build_omni_gen_video_models() -> Vec<OmniGenVideoModelDetails> {
|
||||
});
|
||||
|
||||
models.push(OmniGenVideoModelDetails {
|
||||
is_disabled: Some(true), // TODO: Temporarily disable
|
||||
model: CommonVideoModel::Sora2Pro,
|
||||
full_name: Some("Sora 2 Pro".to_string()),
|
||||
text_prompt_supported: Some(true),
|
||||
|
||||
@@ -87,6 +87,7 @@ use crate::http_server::endpoints::media_files::upsert_upload::write_scene_file:
|
||||
use crate::http_server::endpoints::model_download::enqueue_gptsovits_model_download_handler::*;
|
||||
use crate::http_server::endpoints::moderation::user_feature_flags::edit_user_feature_flags_handler::*;
|
||||
use crate::http_server::endpoints::moderation::staff_audit_logs::moderator_list_staff_audit_logs_handler::*;
|
||||
use crate::http_server::endpoints::moderation::user_bans::moderation_ban_user_handler::*;
|
||||
use crate::http_server::endpoints::moderation::user_sessions::moderator_list_user_session_impersonation_requests_for_user_handler::*;
|
||||
use crate::http_server::endpoints::moderation::user_sessions::moderator_list_user_session_impersonation_requests_handler::*;
|
||||
use crate::http_server::endpoints::moderation::user_sessions::moderator_user_session_impersonation_request_handler::*;
|
||||
@@ -512,6 +513,7 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
|
||||
crate::http_server::endpoints::moderation::wallets::moderator_create_wallet_for_user_handler::moderator_create_wallet_for_user_handler,
|
||||
crate::http_server::endpoints::moderation::wallets::moderator_get_wallet_handler::moderator_get_wallet_handler,
|
||||
crate::http_server::endpoints::moderation::staff_audit_logs::moderator_list_staff_audit_logs_handler::moderator_list_staff_audit_logs_handler,
|
||||
crate::http_server::endpoints::moderation::user_bans::moderation_ban_user_handler::moderation_ban_user_handler,
|
||||
crate::http_server::endpoints::moderation::user_sessions::moderator_list_user_session_impersonation_requests_for_user_handler::moderator_list_user_session_impersonation_requests_for_user_handler,
|
||||
crate::http_server::endpoints::moderation::user_sessions::moderator_list_user_session_impersonation_requests_handler::moderator_list_user_session_impersonation_requests_handler,
|
||||
crate::http_server::endpoints::moderation::user_sessions::moderator_user_session_impersonation_request_handler::moderator_user_session_impersonation_request_handler,
|
||||
@@ -947,6 +949,8 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
|
||||
MediaFilesByBatchListItem,
|
||||
ModelWeightForList,
|
||||
ModelWeightSearchResult,
|
||||
ModerationBanUserRequest,
|
||||
ModerationBanUserSuccessResponse,
|
||||
ModerationImpersonateRequest,
|
||||
ModerationImpersonateSuccessResponse,
|
||||
ModernInferenceQueueStats,
|
||||
|
||||
+4
-14
@@ -148,13 +148,9 @@ pub async fn generate_veo_2_image_to_video_handler(
|
||||
.map(|prompt| prompt.trim())
|
||||
.unwrap_or_else(|| "");
|
||||
|
||||
let aspect_ratio = match &request.aspect_ratio {
|
||||
Some(GenerateVeo2AspectRatio::Auto) => Veo2AspectRatio::Auto,
|
||||
Some(GenerateVeo2AspectRatio::AutoPreferPortrait) => Veo2AspectRatio::AutoPreferPortrait,
|
||||
Some(GenerateVeo2AspectRatio::WideSixteenNine) => Veo2AspectRatio::WideSixteenNine,
|
||||
Some(GenerateVeo2AspectRatio::TallNineSixteen) => Veo2AspectRatio::TallNineSixteen,
|
||||
None => Veo2AspectRatio::WideSixteenNine, // Default to 16:9
|
||||
};
|
||||
if let Some(aspect_ratio) = &request.aspect_ratio {
|
||||
warn!("Aspect ratio is not read in Veo 2 image-to-video");
|
||||
}
|
||||
|
||||
let duration = match &request.duration {
|
||||
Some(GenerateVeo2Duration::FiveSeconds) => Veo2Duration::FiveSeconds,
|
||||
@@ -169,7 +165,6 @@ pub async fn generate_veo_2_image_to_video_handler(
|
||||
webhook_url: &server_state.fal.webhook_url,
|
||||
duration,
|
||||
prompt,
|
||||
aspect_ratio,
|
||||
api_key: &server_state.fal.api_key,
|
||||
};
|
||||
|
||||
@@ -222,12 +217,7 @@ pub async fn generate_veo_2_image_to_video_handler(
|
||||
maybe_negative_prompt: None,
|
||||
maybe_other_args: None,
|
||||
maybe_generation_mode: Some(CommonGenerationMode::Keyframe), // TODO: This endpoint only supports keyframes for now
|
||||
maybe_aspect_ratio: request.aspect_ratio.as_ref().map(|ar| match ar {
|
||||
GenerateVeo2AspectRatio::Auto => CommonAspectRatio::Auto,
|
||||
GenerateVeo2AspectRatio::AutoPreferPortrait => CommonAspectRatio::Auto,
|
||||
GenerateVeo2AspectRatio::WideSixteenNine => CommonAspectRatio::WideSixteenByNine,
|
||||
GenerateVeo2AspectRatio::TallNineSixteen => CommonAspectRatio::TallNineBySixteen,
|
||||
}),
|
||||
maybe_aspect_ratio: None, // NB: The aspect ratio is not read for Veo 2 image-to-video
|
||||
maybe_resolution: None,
|
||||
maybe_batch_count: None,
|
||||
maybe_generate_audio: None,
|
||||
|
||||
+15
-3
@@ -11,7 +11,7 @@ use crate::state::server_state::ServerState;
|
||||
use actix_web::web::Json;
|
||||
use actix_web::{web, HttpRequest};
|
||||
use anyhow::anyhow;
|
||||
use artcraft_api_defs::generate::video::generate_veo_3_fast_image_to_video::{GenerateVeo3FastDuration, GenerateVeo3FastImageToVideoRequest, GenerateVeo3FastImageToVideoResponse, GenerateVeo3FastResolution};
|
||||
use artcraft_api_defs::generate::video::generate_veo_3_fast_image_to_video::{GenerateVeo3FastAspectRatio, GenerateVeo3FastDuration, GenerateVeo3FastImageToVideoRequest, GenerateVeo3FastImageToVideoResponse, GenerateVeo3FastResolution};
|
||||
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
|
||||
use enums::by_table::prompts::prompt_type::PromptType;
|
||||
use enums::common::generation_provider::GenerationProvider;
|
||||
@@ -19,7 +19,7 @@ use enums::common::generation::common_model_type::CommonModelType;
|
||||
use enums::common::visibility::Visibility;
|
||||
use enums::common::generation::common_generation_mode::CommonGenerationMode;
|
||||
use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator;
|
||||
use fal_client::requests::webhook::video::image::enqueue_veo_3_fast_image_to_video_webhook::{enqueue_veo_3_fast_image_to_video_webhook, Veo3FastArgs, Veo3FastDuration, Veo3FastResolution};
|
||||
use fal_client::requests::webhook::video::image::enqueue_veo_3_fast_image_to_video_webhook::{enqueue_veo_3_fast_image_to_video_webhook, Veo3FastArgs, Veo3FastAspectRatio, Veo3FastDuration, Veo3FastResolution};
|
||||
use http_server_common::request::get_request_ip::get_request_ip;
|
||||
use log::{error, info, warn};
|
||||
use mysql_queries::queries::generic_inference::fal::insert_generic_inference_job_for_fal_queue::insert_generic_inference_job_for_fal_queue;
|
||||
@@ -141,6 +141,13 @@ pub async fn generate_veo_3_fast_image_to_video_handler(
|
||||
.map(|prompt| prompt.trim())
|
||||
.unwrap_or_else(|| "");
|
||||
|
||||
let aspect_ratio = match &request.aspect_ratio {
|
||||
Some(GenerateVeo3FastAspectRatio::Auto) => Veo3FastAspectRatio::Auto,
|
||||
Some(GenerateVeo3FastAspectRatio::SixteenByNine) => Veo3FastAspectRatio::WideSixteenNine,
|
||||
Some(GenerateVeo3FastAspectRatio::NineBySixteen) => Veo3FastAspectRatio::TallNineSixteen,
|
||||
None => Veo3FastAspectRatio::Auto,
|
||||
};
|
||||
|
||||
let resolution = match &request.resolution {
|
||||
Some(GenerateVeo3FastResolution::SevenTwentyP) => Veo3FastResolution::SevenTwentyP,
|
||||
Some(GenerateVeo3FastResolution::TenEightyP) => Veo3FastResolution::TenEightyP,
|
||||
@@ -158,6 +165,7 @@ pub async fn generate_veo_3_fast_image_to_video_handler(
|
||||
let args = Veo3FastArgs {
|
||||
image_url: media_links.cdn_url,
|
||||
prompt,
|
||||
aspect_ratio,
|
||||
duration,
|
||||
resolution,
|
||||
generate_audio,
|
||||
@@ -214,7 +222,11 @@ pub async fn generate_veo_3_fast_image_to_video_handler(
|
||||
maybe_negative_prompt: None,
|
||||
maybe_other_args: None,
|
||||
maybe_generation_mode: Some(CommonGenerationMode::Keyframe), // TODO: This endpoint only supports keyframes for now
|
||||
maybe_aspect_ratio: None,
|
||||
maybe_aspect_ratio: request.aspect_ratio.as_ref().map(|ar| match ar {
|
||||
GenerateVeo3FastAspectRatio::Auto => enums::common::generation::common_aspect_ratio::CommonAspectRatio::Auto,
|
||||
GenerateVeo3FastAspectRatio::SixteenByNine => enums::common::generation::common_aspect_ratio::CommonAspectRatio::WideSixteenByNine,
|
||||
GenerateVeo3FastAspectRatio::NineBySixteen => enums::common::generation::common_aspect_ratio::CommonAspectRatio::TallNineBySixteen,
|
||||
}),
|
||||
maybe_resolution: None,
|
||||
maybe_batch_count: None,
|
||||
maybe_generate_audio: Some(generate_audio),
|
||||
|
||||
+14
-13
@@ -14,13 +14,13 @@ use anyhow::anyhow;
|
||||
use artcraft_api_defs::generate::video::generate_veo_3_image_to_video::{GenerateVeo3AspectRatio, GenerateVeo3Duration, GenerateVeo3ImageToVideoRequest, GenerateVeo3ImageToVideoResponse, GenerateVeo3Resolution};
|
||||
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
|
||||
use enums::by_table::prompts::prompt_type::PromptType;
|
||||
use enums::common::generation_provider::GenerationProvider;
|
||||
use enums::common::generation::common_model_type::CommonModelType;
|
||||
use enums::common::visibility::Visibility;
|
||||
use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
|
||||
use enums::common::generation::common_generation_mode::CommonGenerationMode;
|
||||
use enums::common::generation::common_model_type::CommonModelType;
|
||||
use enums::common::generation_provider::GenerationProvider;
|
||||
use enums::common::visibility::Visibility;
|
||||
use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator;
|
||||
use fal_client::requests::webhook::video::image::enqueue_veo_3_image_to_video_webhook::{enqueue_veo_3_image_to_video_webhook, Veo3Args, Veo3AspectRatio, Veo3Duration, Veo3Resolution};
|
||||
use fal_client::requests::webhook::video::image::enqueue_veo_3_image_to_video_webhook::{enqueue_veo_3_image_to_video_webhook, Veo3Args, Veo3I2vAspectRatio, Veo3I2vDuration, Veo3I2vResolution};
|
||||
use http_server_common::request::get_request_ip::get_request_ip;
|
||||
use log::{error, info, warn};
|
||||
use mysql_queries::queries::generic_inference::fal::insert_generic_inference_job_for_fal_queue::insert_generic_inference_job_for_fal_queue;
|
||||
@@ -143,21 +143,22 @@ pub async fn generate_veo_3_image_to_video_handler(
|
||||
.unwrap_or_else(|| "");
|
||||
|
||||
let aspect_ratio = match &request.aspect_ratio {
|
||||
Some(GenerateVeo3AspectRatio::WideSixteenNine) => Veo3AspectRatio::WideSixteenNine,
|
||||
Some(GenerateVeo3AspectRatio::TallNineSixteen) => Veo3AspectRatio::TallNineSixteen,
|
||||
Some(GenerateVeo3AspectRatio::Square) => Veo3AspectRatio::Square,
|
||||
None => Veo3AspectRatio::WideSixteenNine,
|
||||
Some(GenerateVeo3AspectRatio::WideSixteenNine) => Veo3I2vAspectRatio::WideSixteenNine,
|
||||
Some(GenerateVeo3AspectRatio::TallNineSixteen) => Veo3I2vAspectRatio::TallNineSixteen,
|
||||
// Square is not supported by Veo 3 i2v — fall back to Auto.
|
||||
Some(GenerateVeo3AspectRatio::Square) => Veo3I2vAspectRatio::Auto,
|
||||
None => Veo3I2vAspectRatio::WideSixteenNine,
|
||||
};
|
||||
|
||||
let resolution = match &request.resolution {
|
||||
Some(GenerateVeo3Resolution::SevenTwentyP) => Veo3Resolution::SevenTwentyP,
|
||||
Some(GenerateVeo3Resolution::TenEightyP) => Veo3Resolution::TenEightyP,
|
||||
None => Veo3Resolution::Default,
|
||||
Some(GenerateVeo3Resolution::SevenTwentyP) => Veo3I2vResolution::SevenTwentyP,
|
||||
Some(GenerateVeo3Resolution::TenEightyP) => Veo3I2vResolution::TenEightyP,
|
||||
None => Veo3I2vResolution::Default,
|
||||
};
|
||||
|
||||
let duration = match &request.duration {
|
||||
Some(GenerateVeo3Duration::EightSeconds) => Veo3Duration::EightSeconds,
|
||||
None => Veo3Duration::EightSeconds,
|
||||
Some(GenerateVeo3Duration::EightSeconds) => Veo3I2vDuration::EightSeconds,
|
||||
None => Veo3I2vDuration::EightSeconds,
|
||||
};
|
||||
|
||||
let generate_audio = request.generate_audio
|
||||
|
||||
+29
-6
@@ -1,3 +1,4 @@
|
||||
use std::marker::PhantomData;
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_web::web::Json;
|
||||
@@ -5,11 +6,14 @@ use actix_web::{web, HttpRequest};
|
||||
use log::warn;
|
||||
|
||||
use artcraft_api_defs::moderation::alerts::moderation_send_alert::{ModerationSendAlertRequest, ModerationSendAlertResponse, ModerationSendAlertUrgency};
|
||||
use pager::client::pager::Pager;
|
||||
use pager::notification::notification_details::NotificationDetails;
|
||||
use enums::by_table::staff_audit_logs::staff_audit_action::StaffAuditAction;
|
||||
use http_server_common::request::get_request_ip::get_request_ip;
|
||||
use mysql_queries::queries::staff_audit_logs::insert_staff_audit_log::{
|
||||
insert_staff_audit_log, InsertStaffAuditLogArgs,
|
||||
};
|
||||
use pager::notification::notification_details_builder::NotificationDetailsBuilder;
|
||||
use pager::notification::notification_urgency::NotificationUrgency;
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
|
||||
use crate::http_server::web_utils::user_session::require_moderator::{require_moderator, UseDatabase};
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
@@ -29,11 +33,16 @@ pub async fn moderation_send_alert_handler(
|
||||
http_request: HttpRequest,
|
||||
request: Json<ModerationSendAlertRequest>,
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<Json<ModerationSendAlertResponse>, CommonWebError> {
|
||||
) -> Result<Json<ModerationSendAlertResponse>, AdvancedCommonWebError> {
|
||||
|
||||
let user_session = require_moderator(&http_request, &server_state, UseDatabase::GrabNewConnection)
|
||||
.await
|
||||
.map_err(|_| CommonWebError::NotAuthorized)?;
|
||||
.map_err(|err| {
|
||||
warn!("Moderator check failed: {:?}", err);
|
||||
AdvancedCommonWebError::NotAuthorized
|
||||
})?;
|
||||
|
||||
let ip_address = get_request_ip(&http_request);
|
||||
|
||||
let title = request.title.clone()
|
||||
.unwrap_or_else(|| "Moderation Alert".to_string());
|
||||
@@ -68,9 +77,23 @@ pub async fn moderation_send_alert_handler(
|
||||
.enqueue_page(notification)
|
||||
.map_err(|err| {
|
||||
warn!("moderation_send_alert error: {:?}", err);
|
||||
CommonWebError::ServerError
|
||||
AdvancedCommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
// Insert staff audit log.
|
||||
let _audit_token = insert_staff_audit_log(InsertStaffAuditLogArgs {
|
||||
audit_action: StaffAuditAction::SendAlert,
|
||||
maybe_entity_type: None,
|
||||
maybe_entity_token: None,
|
||||
staff_user_token: &user_session.user_token,
|
||||
actor_ip_address: &ip_address,
|
||||
mysql_executor: &server_state.mysql_pool,
|
||||
phantom: PhantomData,
|
||||
}).await.map_err(|err| {
|
||||
warn!("Failed to insert staff audit log: {:?}", err);
|
||||
AdvancedCommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
Ok(Json(ModerationSendAlertResponse {
|
||||
success: true,
|
||||
}))
|
||||
|
||||
-142
@@ -1,142 +0,0 @@
|
||||
use std::fmt;
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_web::error::ResponseError;
|
||||
use actix_web::http::StatusCode;
|
||||
use actix_web::{web, HttpRequest, HttpResponse};
|
||||
use log::warn;
|
||||
|
||||
use mysql_queries::queries::users::user::update::set_user_ban_status::{set_user_ban_status, SetUserBanStatsArgs};
|
||||
use mysql_queries::queries::users::user_profiles::get_user_profile_by_username::get_user_profile_by_username;
|
||||
|
||||
use crate::http_server::web_utils::response_success_helpers::simple_json_success;
|
||||
use crate::http_server::web_utils::serialize_as_json_error::serialize_as_json_error;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct BanUserRequest {
|
||||
pub username: String,
|
||||
pub mod_notes: String,
|
||||
pub is_banned: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct BanUserSuccessResponse {
|
||||
pub success: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
pub struct BanUserErrorResponse {
|
||||
pub success: bool,
|
||||
pub error_type: BanUserErrorType,
|
||||
pub error_message: String,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, Serialize)]
|
||||
pub enum BanUserErrorType {
|
||||
ServerError,
|
||||
Unauthorized,
|
||||
UserNotFound,
|
||||
}
|
||||
|
||||
impl BanUserErrorResponse {
|
||||
fn unauthorized() -> Self {
|
||||
Self {
|
||||
success: false,
|
||||
error_type: BanUserErrorType::Unauthorized,
|
||||
error_message: "unauthorized".to_string()
|
||||
}
|
||||
}
|
||||
fn server_error() -> Self {
|
||||
Self {
|
||||
success: false,
|
||||
error_type: BanUserErrorType::ServerError,
|
||||
error_message: "server error".to_string()
|
||||
}
|
||||
}
|
||||
fn not_found() -> Self {
|
||||
Self {
|
||||
success: false,
|
||||
error_type: BanUserErrorType::UserNotFound,
|
||||
error_message: "user not found".to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ResponseError for BanUserErrorResponse {
|
||||
fn status_code(&self) -> StatusCode {
|
||||
match self.error_type {
|
||||
BanUserErrorType::ServerError => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
BanUserErrorType::Unauthorized => StatusCode::UNAUTHORIZED,
|
||||
BanUserErrorType::UserNotFound => StatusCode::NOT_FOUND,
|
||||
}
|
||||
}
|
||||
|
||||
fn error_response(&self) -> HttpResponse {
|
||||
serialize_as_json_error(self)
|
||||
}
|
||||
}
|
||||
|
||||
// NB: Not using DeriveMore since Clion doesn't understand it.
|
||||
impl fmt::Display for BanUserErrorResponse {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{:?}", self.error_type)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn ban_user_handler(
|
||||
http_request: HttpRequest,
|
||||
request: web::Json<BanUserRequest>,
|
||||
server_state: web::Data<Arc<ServerState>>
|
||||
) -> Result<HttpResponse, BanUserErrorResponse> {
|
||||
|
||||
let maybe_user_session = server_state
|
||||
.session_checker
|
||||
.maybe_get_user_session(&http_request, &server_state.mysql_pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
warn!("Session checker error: {:?}", e);
|
||||
BanUserErrorResponse::server_error()
|
||||
})?;
|
||||
|
||||
let user_session = match maybe_user_session {
|
||||
Some(session) => session,
|
||||
None => {
|
||||
warn!("not logged in");
|
||||
return Err(BanUserErrorResponse::unauthorized());
|
||||
}
|
||||
};
|
||||
|
||||
if !user_session.can_ban_users {
|
||||
warn!("user is not allowed to add bans: {:?}", user_session.user_token);
|
||||
return Err(BanUserErrorResponse::unauthorized());
|
||||
}
|
||||
|
||||
let username_lower = request.username.to_lowercase();
|
||||
|
||||
let user_lookup_result = get_user_profile_by_username(
|
||||
&username_lower, &server_state.mysql_pool).await;
|
||||
|
||||
let user_profile = match user_lookup_result {
|
||||
Ok(Some(result)) => result,
|
||||
Ok(None) => return Err(BanUserErrorResponse::not_found()),
|
||||
Err(err) => {
|
||||
warn!("lookup error: {:?}", err);
|
||||
return Err(BanUserErrorResponse::server_error());
|
||||
}
|
||||
};
|
||||
|
||||
set_user_ban_status(SetUserBanStatsArgs {
|
||||
subject_user_token: &user_profile.user_token,
|
||||
is_banned: request.is_banned,
|
||||
mod_user_token: &user_session.user_token,
|
||||
maybe_mod_comments: Some(&request.mod_notes),
|
||||
mysql_pool: &server_state.mysql_pool,
|
||||
}).await
|
||||
.map_err(|err| {
|
||||
warn!("Add IP ban DB error: {:?}", err);
|
||||
BanUserErrorResponse::server_error()
|
||||
})?;
|
||||
|
||||
Ok(simple_json_success())
|
||||
}
|
||||
+1
-1
@@ -1 +1 @@
|
||||
pub mod ban_user;
|
||||
pub mod moderation_ban_user_handler;
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
use std::marker::PhantomData;
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_web::web::Json;
|
||||
use actix_web::{web, HttpRequest};
|
||||
use log::{info, warn};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use enums::by_table::staff_audit_logs::staff_audit_action::StaffAuditAction;
|
||||
use enums::by_table::staff_audit_logs::staff_audit_entity_type::StaffAuditEntityType;
|
||||
use http_server_common::request::get_request_ip::get_request_ip;
|
||||
use mysql_queries::queries::staff_audit_logs::insert_staff_audit_log::{
|
||||
insert_staff_audit_log, InsertStaffAuditLogArgs,
|
||||
};
|
||||
use mysql_queries::queries::users::user::update::set_user_ban_status::{
|
||||
set_user_ban_status, SetUserBanStatusArgs,
|
||||
};
|
||||
use mysql_queries::queries::users::user_profiles::get_user_profile_by_username::get_user_profile_by_username;
|
||||
|
||||
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
|
||||
use crate::http_server::web_utils::user_session::require_moderator::{
|
||||
require_moderator, UseDatabase,
|
||||
};
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
// --- Request ---
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct ModerationBanUserRequest {
|
||||
pub username: String,
|
||||
pub mod_notes: String,
|
||||
pub is_banned: bool,
|
||||
}
|
||||
|
||||
// --- Response ---
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct ModerationBanUserSuccessResponse {
|
||||
pub success: bool,
|
||||
}
|
||||
|
||||
// --- Handler ---
|
||||
|
||||
/// Ban or unban a user. Moderators only.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
tag = "Moderation",
|
||||
path = "/moderation/user_bans/manage_ban",
|
||||
request_body = ModerationBanUserRequest,
|
||||
responses(
|
||||
(status = 200, description = "User ban status updated", body = ModerationBanUserSuccessResponse),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 404, description = "User not found"),
|
||||
(status = 500, description = "Server error"),
|
||||
),
|
||||
)]
|
||||
pub async fn moderation_ban_user_handler(
|
||||
http_request: HttpRequest,
|
||||
request: Json<ModerationBanUserRequest>,
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<Json<ModerationBanUserSuccessResponse>, AdvancedCommonWebError> {
|
||||
|
||||
// 1. Require moderator with ban permissions.
|
||||
let user_session = require_moderator(
|
||||
&http_request,
|
||||
&server_state,
|
||||
UseDatabase::GrabNewConnection,
|
||||
).await.map_err(|err| {
|
||||
warn!("Moderator check failed: {:?}", err);
|
||||
AdvancedCommonWebError::NotAuthorized
|
||||
})?;
|
||||
|
||||
if !user_session.can_ban_users {
|
||||
warn!("User {} is not allowed to ban users", user_session.user_token.as_str());
|
||||
return Err(AdvancedCommonWebError::NotAuthorized);
|
||||
}
|
||||
|
||||
// 2. Look up the target user by username.
|
||||
let username_lower = request.username.to_lowercase();
|
||||
|
||||
let user_profile = get_user_profile_by_username(
|
||||
&username_lower,
|
||||
&server_state.mysql_pool,
|
||||
).await.map_err(|err| {
|
||||
warn!("User lookup error: {:?}", err);
|
||||
AdvancedCommonWebError::from_anyhow_error(err)
|
||||
})?.ok_or_else(|| {
|
||||
warn!("User not found for ban: {}", username_lower);
|
||||
AdvancedCommonWebError::NotFound
|
||||
})?;
|
||||
|
||||
let ip_address = get_request_ip(&http_request);
|
||||
|
||||
let audit_action = if request.is_banned {
|
||||
StaffAuditAction::BanUser
|
||||
} else {
|
||||
StaffAuditAction::UnbanUser
|
||||
};
|
||||
|
||||
info!(
|
||||
"Moderator {} {} user {} ({})",
|
||||
user_session.user_token.as_str(),
|
||||
audit_action.to_str(),
|
||||
user_profile.user_token.as_str(),
|
||||
username_lower,
|
||||
);
|
||||
|
||||
// 3. Begin transaction: ban + audit log.
|
||||
let mut transaction = server_state.mysql_pool.begin().await
|
||||
.map_err(|err| {
|
||||
warn!("Failed to begin transaction: {:?}", err);
|
||||
AdvancedCommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
// 4. Set ban status.
|
||||
set_user_ban_status(SetUserBanStatusArgs {
|
||||
subject_user_token: &user_profile.user_token,
|
||||
is_banned: request.is_banned,
|
||||
mod_user_token: &user_session.user_token,
|
||||
maybe_mod_comments: Some(&request.mod_notes),
|
||||
mysql_executor: &mut *transaction,
|
||||
phantom: PhantomData,
|
||||
}).await.map_err(|err| {
|
||||
warn!("Failed to set user ban status: {:?}", err);
|
||||
AdvancedCommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
// 5. Insert staff audit log.
|
||||
let _audit_token = insert_staff_audit_log(InsertStaffAuditLogArgs {
|
||||
audit_action,
|
||||
maybe_entity_type: Some(StaffAuditEntityType::User),
|
||||
maybe_entity_token: Some(user_profile.user_token.as_str()),
|
||||
staff_user_token: &user_session.user_token,
|
||||
actor_ip_address: &ip_address,
|
||||
mysql_executor: &mut *transaction,
|
||||
phantom: PhantomData,
|
||||
}).await.map_err(|err| {
|
||||
warn!("Failed to insert staff audit log: {:?}", err);
|
||||
AdvancedCommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
// 6. Commit transaction.
|
||||
transaction.commit().await.map_err(|err| {
|
||||
warn!("Failed to commit transaction: {:?}", err);
|
||||
AdvancedCommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
info!(
|
||||
"User {} ban status set to {} by moderator {}",
|
||||
user_profile.user_token.as_str(),
|
||||
request.is_banned,
|
||||
user_session.user_token.as_str(),
|
||||
);
|
||||
|
||||
Ok(Json(ModerationBanUserSuccessResponse {
|
||||
success: true,
|
||||
}))
|
||||
}
|
||||
+41
-8
@@ -1,19 +1,26 @@
|
||||
use std::marker::PhantomData;
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_web::web::{Json, Path};
|
||||
use actix_web::{web, HttpRequest};
|
||||
use log::warn;
|
||||
use log::{info, warn};
|
||||
|
||||
use artcraft_api_defs::moderation::wallets::moderator_add_banked_balance_to_wallet::{
|
||||
ModeratorAddBankedBalanceToWalletPathInfo,
|
||||
ModeratorAddBankedBalanceToWalletRequest,
|
||||
ModeratorAddBankedBalanceToWalletResponse,
|
||||
};
|
||||
use enums::by_table::staff_audit_logs::staff_audit_action::StaffAuditAction;
|
||||
use enums::by_table::staff_audit_logs::staff_audit_entity_type::StaffAuditEntityType;
|
||||
use enums::by_table::wallet_ledger_entries::wallet_ledger_entry_type::WalletLedgerEntryType;
|
||||
use http_server_common::request::get_request_ip::get_request_ip;
|
||||
use mysql_queries::queries::staff_audit_logs::insert_staff_audit_log::{
|
||||
insert_staff_audit_log, InsertStaffAuditLogArgs,
|
||||
};
|
||||
use mysql_queries::queries::wallets::add_durable_banked_balance_to_wallet::add_durable_banked_balance_to_wallet;
|
||||
|
||||
use tokens::tokens::wallets::WalletToken;
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
|
||||
use crate::http_server::web_utils::user_session::require_moderator::{require_moderator, UseDatabase};
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
@@ -37,17 +44,29 @@ pub async fn moderator_add_banked_balance_to_wallet_handler(
|
||||
path: Path<ModeratorAddBankedBalanceToWalletPathInfo>,
|
||||
request: Json<ModeratorAddBankedBalanceToWalletRequest>,
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<Json<ModeratorAddBankedBalanceToWalletResponse>, CommonWebError> {
|
||||
) -> Result<Json<ModeratorAddBankedBalanceToWalletResponse>, AdvancedCommonWebError> {
|
||||
|
||||
let _user_session = require_moderator(&http_request, &server_state, UseDatabase::GrabNewConnection)
|
||||
let user_session = require_moderator(&http_request, &server_state, UseDatabase::GrabNewConnection)
|
||||
.await
|
||||
.map_err(|_| CommonWebError::NotAuthorized)?;
|
||||
.map_err(|err| {
|
||||
warn!("Moderator check failed: {:?}", err);
|
||||
AdvancedCommonWebError::NotAuthorized
|
||||
})?;
|
||||
|
||||
let ip_address = get_request_ip(&http_request);
|
||||
|
||||
info!(
|
||||
"Moderator {} adding {} banked credits to wallet {}",
|
||||
user_session.user_token.as_str(),
|
||||
request.credits,
|
||||
path.wallet_token.as_str(),
|
||||
);
|
||||
|
||||
let mut transaction = server_state.mysql_pool.begin()
|
||||
.await
|
||||
.map_err(|err| {
|
||||
warn!("moderator_add_banked_balance_to_wallet transaction begin error: {:?}", err);
|
||||
CommonWebError::ServerError
|
||||
AdvancedCommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let _result = add_durable_banked_balance_to_wallet(
|
||||
@@ -60,14 +79,28 @@ pub async fn moderator_add_banked_balance_to_wallet_handler(
|
||||
.await
|
||||
.map_err(|err| {
|
||||
warn!("moderator_add_banked_balance_to_wallet error: {:?}", err);
|
||||
CommonWebError::ServerError
|
||||
AdvancedCommonWebError::from_anyhow_error(err)
|
||||
})?;
|
||||
|
||||
// Insert staff audit log.
|
||||
let _audit_token = insert_staff_audit_log(InsertStaffAuditLogArgs {
|
||||
audit_action: StaffAuditAction::AddWalletBankedBalance,
|
||||
maybe_entity_type: Some(StaffAuditEntityType::Wallet),
|
||||
maybe_entity_token: Some(path.wallet_token.as_str()),
|
||||
staff_user_token: &user_session.user_token,
|
||||
actor_ip_address: &ip_address,
|
||||
mysql_executor: &mut *transaction,
|
||||
phantom: PhantomData,
|
||||
}).await.map_err(|err| {
|
||||
warn!("Failed to insert staff audit log: {:?}", err);
|
||||
AdvancedCommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
transaction.commit()
|
||||
.await
|
||||
.map_err(|err| {
|
||||
warn!("moderator_add_banked_balance_to_wallet commit error: {:?}", err);
|
||||
CommonWebError::ServerError
|
||||
AdvancedCommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
Ok(Json(ModeratorAddBankedBalanceToWalletResponse {
|
||||
|
||||
+2
@@ -1 +1,3 @@
|
||||
mod test_seedance_1p5_pro;
|
||||
mod test_veo_2;
|
||||
mod test_veo_3;
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
//! Tests for omni-gen video distillation against the Seedance 1.5 Pro model.
|
||||
//! Tests for omni-gen video against the Seedance 1.5 Pro model.
|
||||
//!
|
||||
//! Seedance 1.5 Pro supports both text-to-video and image-to-video (via
|
||||
//! start_frame / end_frame). Pricing is based on resolution × duration ×
|
||||
|
||||
+442
@@ -0,0 +1,442 @@
|
||||
//! Tests for omni-gen video against the Veo 2 model.
|
||||
//!
|
||||
//! Veo 2 supports both text-to-video and image-to-video (via start_frame).
|
||||
//!
|
||||
//! Pricing: $2.50 for the first 5 seconds, +$0.50 per additional second.
|
||||
//! 5s → 250¢, 6s → 300¢, 7s → 350¢, 8s → 400¢
|
||||
//!
|
||||
//! Supported aspect ratios: Auto, WideSixteenNine, TallNineSixteen.
|
||||
//! Unsupported ratios fall back to Auto under PayMoreUpgrade.
|
||||
//!
|
||||
//! Supported durations: 5–8 seconds. Over-max clamps to 8s (PayMoreUpgrade).
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashMap;
|
||||
|
||||
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
|
||||
use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
|
||||
use enums::common::generation::common_video_model::CommonVideoModel;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
use url::Url;
|
||||
|
||||
use crate::http_server::endpoints::omni_gen::generate::video::distill_video_request::{
|
||||
distill_video_request, DistilledVideoRequest,
|
||||
};
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
fn make_request(
|
||||
prompt: Option<&str>,
|
||||
aspect_ratio: Option<CommonAspectRatio>,
|
||||
duration_seconds: Option<u16>,
|
||||
start_frame_token: Option<MediaFileToken>,
|
||||
) -> OmniGenVideoCostAndGenerateRequest {
|
||||
OmniGenVideoCostAndGenerateRequest {
|
||||
idempotency_token: Some("11111111-1111-1111-1111-111111111111".to_string()),
|
||||
model: Some(CommonVideoModel::Veo2),
|
||||
prompt: prompt.map(|s| s.to_string()),
|
||||
negative_prompt: None,
|
||||
start_frame_image_media_token: start_frame_token,
|
||||
end_frame_image_media_token: None,
|
||||
reference_image_media_tokens: None,
|
||||
reference_video_media_tokens: None,
|
||||
reference_audio_media_tokens: None,
|
||||
reference_character_tokens: None,
|
||||
resolution: None,
|
||||
aspect_ratio,
|
||||
quality: None,
|
||||
duration_seconds,
|
||||
video_batch_count: None,
|
||||
generate_audio: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn fake_token(name: &str) -> (MediaFileToken, HashMap<MediaFileToken, Url>) {
|
||||
let token = MediaFileToken::new_from_str(name);
|
||||
let url = Url::parse(&format!("https://fake.example.com/{}.png", name)).unwrap();
|
||||
let mut map = HashMap::new();
|
||||
map.insert(token.clone(), url);
|
||||
(token, map)
|
||||
}
|
||||
|
||||
fn distill_text(request: &OmniGenVideoCostAndGenerateRequest) -> DistilledVideoRequest {
|
||||
let empty: HashMap<MediaFileToken, Url> = HashMap::new();
|
||||
distill_video_request(request, Some(&empty))
|
||||
.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 (image)")
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// COST
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
mod cost {
|
||||
use super::*;
|
||||
|
||||
mod text {
|
||||
use super::*;
|
||||
|
||||
fn cost(duration: Option<u16>) -> u64 {
|
||||
let request = make_request(Some("p"), None, duration, None);
|
||||
distill_text(&request).cost.cost_in_usd_cents.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_duration_costs_250() { assert_eq!(cost(None), 250); }
|
||||
|
||||
#[test]
|
||||
fn five_seconds_costs_250() { assert_eq!(cost(Some(5)), 250); }
|
||||
|
||||
#[test]
|
||||
fn six_seconds_costs_300() { assert_eq!(cost(Some(6)), 300); }
|
||||
|
||||
#[test]
|
||||
fn seven_seconds_costs_350() { assert_eq!(cost(Some(7)), 350); }
|
||||
|
||||
#[test]
|
||||
fn eight_seconds_costs_400() { assert_eq!(cost(Some(8)), 400); }
|
||||
|
||||
#[test]
|
||||
fn duration_above_8_clamps_to_400() { assert_eq!(cost(Some(15)), 400); }
|
||||
|
||||
#[test]
|
||||
fn cost_independent_of_aspect_ratio() {
|
||||
let ars = [
|
||||
None,
|
||||
Some(CommonAspectRatio::Auto),
|
||||
Some(CommonAspectRatio::WideSixteenByNine),
|
||||
Some(CommonAspectRatio::TallNineBySixteen),
|
||||
Some(CommonAspectRatio::Square),
|
||||
];
|
||||
for ar in ars {
|
||||
let request = make_request(Some("p"), ar, None, None);
|
||||
assert_eq!(distill_text(&request).cost.cost_in_usd_cents.unwrap(), 250);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_metadata_flags_are_default() {
|
||||
let d = distill_text(&make_request(Some("p"), None, None, None));
|
||||
assert!(!d.cost.is_free);
|
||||
assert!(!d.cost.is_unlimited);
|
||||
assert!(!d.cost.is_rate_limited);
|
||||
assert!(!d.cost.has_watermark);
|
||||
}
|
||||
}
|
||||
|
||||
mod image {
|
||||
use super::*;
|
||||
|
||||
fn cost(duration: Option<u16>) -> u64 {
|
||||
let (start, map) = fake_token("mf_start0000000000000000000000");
|
||||
let request = make_request(Some("p"), None, duration, Some(start));
|
||||
distill_image(&request, &map).cost.cost_in_usd_cents.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_duration_costs_250() { assert_eq!(cost(None), 250); }
|
||||
|
||||
#[test]
|
||||
fn five_seconds_costs_250() { assert_eq!(cost(Some(5)), 250); }
|
||||
|
||||
#[test]
|
||||
fn six_seconds_costs_300() { assert_eq!(cost(Some(6)), 300); }
|
||||
|
||||
#[test]
|
||||
fn seven_seconds_costs_350() { assert_eq!(cost(Some(7)), 350); }
|
||||
|
||||
#[test]
|
||||
fn eight_seconds_costs_400() { assert_eq!(cost(Some(8)), 400); }
|
||||
|
||||
#[test]
|
||||
fn duration_above_8_clamps_to_400() { assert_eq!(cost(Some(15)), 400); }
|
||||
|
||||
#[test]
|
||||
fn image_and_text_cost_match() {
|
||||
for dur in [5u16, 6, 7, 8] {
|
||||
let text = distill_text(&make_request(Some("p"), None, Some(dur), None))
|
||||
.cost.cost_in_usd_cents.unwrap();
|
||||
let (start, map) = fake_token("mf_start0000000000000000000000");
|
||||
let img = distill_image(
|
||||
&make_request(Some("p"), None, Some(dur), Some(start)),
|
||||
&map,
|
||||
).cost.cost_in_usd_cents.unwrap();
|
||||
assert_eq!(text, img, "text/image cost diverged at dur={}", dur);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// PLAN
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
mod plan {
|
||||
use super::*;
|
||||
|
||||
use artcraft_router::generate::generate_video::video_generation_plan::VideoGenerationPlan;
|
||||
use artcraft_router::generate::generate_video::plan::fal::plan_generate_video_fal_veo_2::{
|
||||
FalVeo2Mode, PlanFalVeo2,
|
||||
};
|
||||
use fal_client::requests::webhook::video::image::enqueue_veo_2_image_to_video_webhook::{
|
||||
Veo2AspectRatio, Veo2Duration,
|
||||
};
|
||||
|
||||
fn with_text_plan<F: FnOnce(&PlanFalVeo2)>(
|
||||
request: &OmniGenVideoCostAndGenerateRequest,
|
||||
assertion: F,
|
||||
) {
|
||||
let distilled = distill_text(request);
|
||||
match distilled.plan() {
|
||||
VideoGenerationPlan::FalVeo2(plan) => assertion(plan),
|
||||
other => panic!("expected FalVeo2, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
fn with_image_plan<F: FnOnce(&PlanFalVeo2)>(
|
||||
request: &OmniGenVideoCostAndGenerateRequest,
|
||||
hydration: &HashMap<MediaFileToken, Url>,
|
||||
assertion: F,
|
||||
) {
|
||||
let distilled = distill_image(request, hydration);
|
||||
match distilled.plan() {
|
||||
VideoGenerationPlan::FalVeo2(plan) => assertion(plan),
|
||||
other => panic!("expected FalVeo2, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
mod text {
|
||||
use super::*;
|
||||
|
||||
// ── Mode detection ────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn text_mode_is_text_to_video() {
|
||||
with_text_plan(&make_request(Some("p"), None, None, None), |plan| {
|
||||
assert!(matches!(plan.mode, FalVeo2Mode::TextToVideo));
|
||||
});
|
||||
}
|
||||
|
||||
// ── Prompt passthrough ────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn prompt_is_passed_through() {
|
||||
with_text_plan(&make_request(Some("a horse on mars"), None, None, None), |plan| {
|
||||
assert_eq!(plan.prompt, "a horse on mars");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_prompt_is_empty_string() {
|
||||
with_text_plan(&make_request(None, None, None, None), |plan| {
|
||||
assert_eq!(plan.prompt, "");
|
||||
});
|
||||
}
|
||||
|
||||
// ── Aspect ratio mappings ─────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn default_aspect_ratio_is_auto() {
|
||||
with_text_plan(&make_request(Some("p"), None, None, None), |plan| {
|
||||
assert!(matches!(plan.aspect_ratio, Some(Veo2AspectRatio::Auto)));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_yields_auto() {
|
||||
for ar in [CommonAspectRatio::Auto, CommonAspectRatio::Auto2k, CommonAspectRatio::Auto4k] {
|
||||
with_text_plan(&make_request(Some("p"), Some(ar), None, None), |plan| {
|
||||
assert!(matches!(plan.aspect_ratio, Some(Veo2AspectRatio::Auto)));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wide_16x9_yields_wide() {
|
||||
for ar in [CommonAspectRatio::WideSixteenByNine, CommonAspectRatio::Wide] {
|
||||
with_text_plan(&make_request(Some("p"), Some(ar), None, None), |plan| {
|
||||
assert!(matches!(plan.aspect_ratio, Some(Veo2AspectRatio::WideSixteenNine)));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tall_9x16_yields_tall() {
|
||||
for ar in [CommonAspectRatio::TallNineBySixteen, CommonAspectRatio::Tall] {
|
||||
with_text_plan(&make_request(Some("p"), Some(ar), None, None), |plan| {
|
||||
assert!(matches!(plan.aspect_ratio, Some(Veo2AspectRatio::TallNineSixteen)));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_aspect_ratio_falls_back_to_auto() {
|
||||
for ar in [
|
||||
CommonAspectRatio::Square, CommonAspectRatio::SquareHd,
|
||||
CommonAspectRatio::WideFourByThree, CommonAspectRatio::TallThreeByFour,
|
||||
] {
|
||||
with_text_plan(&make_request(Some("p"), Some(ar), None, None), |plan| {
|
||||
assert!(matches!(plan.aspect_ratio, Some(Veo2AspectRatio::Auto)));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Duration mappings ─────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn default_duration_is_default() {
|
||||
with_text_plan(&make_request(Some("p"), None, None, None), |plan| {
|
||||
assert!(matches!(plan.duration, Veo2Duration::Default));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_5_yields_five() {
|
||||
with_text_plan(&make_request(Some("p"), None, Some(5), None), |plan| {
|
||||
assert!(matches!(plan.duration, Veo2Duration::FiveSeconds));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_6_yields_six() {
|
||||
with_text_plan(&make_request(Some("p"), None, Some(6), None), |plan| {
|
||||
assert!(matches!(plan.duration, Veo2Duration::SixSeconds));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_7_yields_seven() {
|
||||
with_text_plan(&make_request(Some("p"), None, Some(7), None), |plan| {
|
||||
assert!(matches!(plan.duration, Veo2Duration::SevenSeconds));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_8_yields_eight() {
|
||||
with_text_plan(&make_request(Some("p"), None, Some(8), None), |plan| {
|
||||
assert!(matches!(plan.duration, Veo2Duration::EightSeconds));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_above_8_clamps_to_eight() {
|
||||
with_text_plan(&make_request(Some("p"), None, Some(20), None), |plan| {
|
||||
assert!(matches!(plan.duration, Veo2Duration::EightSeconds));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
mod image {
|
||||
use super::*;
|
||||
|
||||
// ── Mode detection / start frame hydration ────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn start_frame_triggers_image_to_video() {
|
||||
let (start, map) = fake_token("mf_start0000000000000000000000");
|
||||
let request = make_request(Some("p"), None, None, Some(start));
|
||||
with_image_plan(&request, &map, |plan| {
|
||||
match &plan.mode {
|
||||
FalVeo2Mode::ImageToVideo { image_url } => {
|
||||
assert!(image_url.starts_with("https://fake.example.com/"));
|
||||
}
|
||||
other => panic!("expected ImageToVideo, got {:?}", other),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn start_frame_url_matches_hydration_map_exactly() {
|
||||
let token = MediaFileToken::new_from_str("mf_myframe0000000000000000000000");
|
||||
let expected_url = "https://cdn.example.com/frames/my_frame.png";
|
||||
let mut map = HashMap::new();
|
||||
map.insert(token.clone(), Url::parse(expected_url).unwrap());
|
||||
|
||||
let request = make_request(Some("p"), None, None, Some(token));
|
||||
with_image_plan(&request, &map, |plan| {
|
||||
match &plan.mode {
|
||||
FalVeo2Mode::ImageToVideo { image_url } => {
|
||||
assert_eq!(image_url, expected_url);
|
||||
}
|
||||
other => panic!("expected ImageToVideo, got {:?}", other),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Prompt passthrough in image mode ──────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn prompt_is_passed_through_in_image_mode() {
|
||||
let (start, map) = fake_token("mf_start0000000000000000000000");
|
||||
let request = make_request(Some("a horse on mars"), None, None, Some(start));
|
||||
with_image_plan(&request, &map, |plan| {
|
||||
assert_eq!(plan.prompt, "a horse on mars");
|
||||
});
|
||||
}
|
||||
|
||||
// ── Aspect ratio is None in image mode (i2v inherits source AR) ────────
|
||||
|
||||
#[test]
|
||||
fn aspect_ratio_is_none_in_image_mode() {
|
||||
let (start, map) = fake_token("mf_start0000000000000000000000");
|
||||
let request = make_request(Some("p"), Some(CommonAspectRatio::WideSixteenByNine), None, Some(start));
|
||||
with_image_plan(&request, &map, |plan| {
|
||||
assert!(plan.aspect_ratio.is_none(), "image-to-video should not have aspect_ratio");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aspect_ratio_is_none_in_image_mode_even_with_tall() {
|
||||
let (start, map) = fake_token("mf_start0000000000000000000000");
|
||||
let request = make_request(Some("p"), Some(CommonAspectRatio::TallNineBySixteen), None, Some(start));
|
||||
with_image_plan(&request, &map, |plan| {
|
||||
assert!(plan.aspect_ratio.is_none());
|
||||
});
|
||||
}
|
||||
|
||||
// ── Duration pass-throughs in image mode ──────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn duration_5_in_image_mode() {
|
||||
let (start, map) = fake_token("mf_start0000000000000000000000");
|
||||
let request = make_request(Some("p"), None, Some(5), Some(start));
|
||||
with_image_plan(&request, &map, |plan| {
|
||||
assert!(matches!(plan.duration, Veo2Duration::FiveSeconds));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_8_in_image_mode() {
|
||||
let (start, map) = fake_token("mf_start0000000000000000000000");
|
||||
let request = make_request(Some("p"), None, Some(8), Some(start));
|
||||
with_image_plan(&request, &map, |plan| {
|
||||
assert!(matches!(plan.duration, Veo2Duration::EightSeconds));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_above_8_clamps_in_image_mode() {
|
||||
let (start, map) = fake_token("mf_start0000000000000000000000");
|
||||
let request = make_request(Some("p"), None, Some(20), Some(start));
|
||||
with_image_plan(&request, &map, |plan| {
|
||||
assert!(matches!(plan.duration, Veo2Duration::EightSeconds));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_duration_in_image_mode() {
|
||||
let (start, map) = fake_token("mf_start0000000000000000000000");
|
||||
let request = make_request(Some("p"), None, None, Some(start));
|
||||
with_image_plan(&request, &map, |plan| {
|
||||
assert!(matches!(plan.duration, Veo2Duration::Default));
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+589
@@ -0,0 +1,589 @@
|
||||
//! Tests for omni-gen video against the Veo 3 model.
|
||||
//!
|
||||
//! Veo 3 supports both text-to-video and image-to-video (via start_frame).
|
||||
//!
|
||||
//! Pricing: $0.20/sec (audio off) or $0.40/sec (audio on).
|
||||
//! Default: 8s, audio off (artcraft legacy) → 160¢.
|
||||
//! 4s no-audio → 80¢, 6s no-audio → 120¢, 8s no-audio → 160¢
|
||||
//! 4s audio → 160¢, 6s audio → 240¢, 8s audio → 320¢
|
||||
//!
|
||||
//! Supported aspect ratios (text mode only): Default, 16:9, 9:16, Square.
|
||||
//! Image-to-video inherits the source frame's aspect ratio.
|
||||
//!
|
||||
//! Supported resolutions: 720p, 1080p.
|
||||
//! Supported durations: 4s, 6s, 8s. Over-max clamps to 8s (PayMoreUpgrade).
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashMap;
|
||||
|
||||
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
|
||||
use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
|
||||
use enums::common::generation::common_resolution::CommonResolution;
|
||||
use enums::common::generation::common_video_model::CommonVideoModel;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
use url::Url;
|
||||
|
||||
use crate::http_server::endpoints::omni_gen::generate::video::distill_video_request::{
|
||||
distill_video_request, DistilledVideoRequest,
|
||||
};
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
fn make_request(
|
||||
prompt: Option<&str>,
|
||||
aspect_ratio: Option<CommonAspectRatio>,
|
||||
resolution: Option<CommonResolution>,
|
||||
duration_seconds: Option<u16>,
|
||||
generate_audio: Option<bool>,
|
||||
start_frame_token: Option<MediaFileToken>,
|
||||
) -> OmniGenVideoCostAndGenerateRequest {
|
||||
OmniGenVideoCostAndGenerateRequest {
|
||||
idempotency_token: Some("11111111-1111-1111-1111-111111111111".to_string()),
|
||||
model: Some(CommonVideoModel::Veo3),
|
||||
prompt: prompt.map(|s| s.to_string()),
|
||||
negative_prompt: None,
|
||||
start_frame_image_media_token: start_frame_token,
|
||||
end_frame_image_media_token: None,
|
||||
reference_image_media_tokens: None,
|
||||
reference_video_media_tokens: None,
|
||||
reference_audio_media_tokens: None,
|
||||
reference_character_tokens: None,
|
||||
resolution,
|
||||
aspect_ratio,
|
||||
quality: None,
|
||||
duration_seconds,
|
||||
video_batch_count: None,
|
||||
generate_audio,
|
||||
}
|
||||
}
|
||||
|
||||
fn fake_token(name: &str) -> (MediaFileToken, HashMap<MediaFileToken, Url>) {
|
||||
let token = MediaFileToken::new_from_str(name);
|
||||
let url = Url::parse(&format!("https://fake.example.com/{}.png", name)).unwrap();
|
||||
let mut map = HashMap::new();
|
||||
map.insert(token.clone(), url);
|
||||
(token, map)
|
||||
}
|
||||
|
||||
fn distill_text(request: &OmniGenVideoCostAndGenerateRequest) -> DistilledVideoRequest {
|
||||
let empty: HashMap<MediaFileToken, Url> = HashMap::new();
|
||||
distill_video_request(request, Some(&empty))
|
||||
.expect("distill should succeed for Veo 3 (text)")
|
||||
}
|
||||
|
||||
fn distill_image(
|
||||
request: &OmniGenVideoCostAndGenerateRequest,
|
||||
hydration: &HashMap<MediaFileToken, Url>,
|
||||
) -> DistilledVideoRequest {
|
||||
distill_video_request(request, Some(hydration))
|
||||
.expect("distill should succeed for Veo 3 (image)")
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// COST
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Artcraft cost path: always 8s (legacy). $0.20/sec no-audio, $0.40/sec audio.
|
||||
// Default audio = false (artcraft legacy).
|
||||
mod cost {
|
||||
use super::*;
|
||||
|
||||
mod text {
|
||||
use super::*;
|
||||
|
||||
fn cost(duration: Option<u16>, audio: Option<bool>) -> u64 {
|
||||
let request = make_request(Some("p"), None, None, duration, audio, None);
|
||||
distill_text(&request).cost.cost_in_usd_cents.unwrap()
|
||||
}
|
||||
|
||||
// ── No audio: $0.20/sec ──────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn default_no_audio_costs_160() { assert_eq!(cost(None, None), 160); } // 8s × 20¢
|
||||
|
||||
#[test]
|
||||
fn four_seconds_no_audio_costs_80() { assert_eq!(cost(Some(4), Some(false)), 80); }
|
||||
|
||||
#[test]
|
||||
fn six_seconds_no_audio_costs_120() { assert_eq!(cost(Some(6), Some(false)), 120); }
|
||||
|
||||
#[test]
|
||||
fn eight_seconds_no_audio_costs_160() { assert_eq!(cost(Some(8), Some(false)), 160); }
|
||||
|
||||
// ── Audio on: $0.40/sec ───────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn four_seconds_audio_on_costs_160() { assert_eq!(cost(Some(4), Some(true)), 160); }
|
||||
|
||||
#[test]
|
||||
fn six_seconds_audio_on_costs_240() { assert_eq!(cost(Some(6), Some(true)), 240); }
|
||||
|
||||
#[test]
|
||||
fn eight_seconds_audio_on_costs_320() { assert_eq!(cost(Some(8), Some(true)), 320); }
|
||||
|
||||
#[test]
|
||||
fn default_duration_audio_on_costs_320() { assert_eq!(cost(None, Some(true)), 320); }
|
||||
|
||||
// ── Over-max clamps to 8s ─────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn duration_above_8_clamps_to_160_no_audio() { assert_eq!(cost(Some(20), Some(false)), 160); }
|
||||
|
||||
#[test]
|
||||
fn duration_above_8_clamps_to_320_audio() { assert_eq!(cost(Some(20), Some(true)), 320); }
|
||||
|
||||
// ── Cost is independent of aspect ratio ───────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn cost_independent_of_aspect_ratio() {
|
||||
for ar in [None, Some(CommonAspectRatio::Auto), Some(CommonAspectRatio::WideSixteenByNine), Some(CommonAspectRatio::TallNineBySixteen)] {
|
||||
let request = make_request(Some("p"), ar, None, None, None, None);
|
||||
assert_eq!(distill_text(&request).cost.cost_in_usd_cents.unwrap(), 160);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Cost is independent of resolution ─────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn cost_independent_of_resolution() {
|
||||
for res in [None, Some(CommonResolution::SevenTwentyP), Some(CommonResolution::TenEightyP)] {
|
||||
let request = make_request(Some("p"), None, res, None, None, None);
|
||||
assert_eq!(distill_text(&request).cost.cost_in_usd_cents.unwrap(), 160);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Metadata flags ────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn cost_metadata_flags_are_default() {
|
||||
let d = distill_text(&make_request(Some("p"), None, None, None, None, None));
|
||||
assert!(!d.cost.is_free);
|
||||
assert!(!d.cost.is_unlimited);
|
||||
assert!(!d.cost.is_rate_limited);
|
||||
assert!(!d.cost.has_watermark);
|
||||
}
|
||||
}
|
||||
|
||||
mod image {
|
||||
use super::*;
|
||||
|
||||
fn cost_img(duration: Option<u16>, audio: Option<bool>) -> u64 {
|
||||
let (start, map) = fake_token("mf_start0000000000000000000000");
|
||||
let request = make_request(Some("p"), None, None, duration, audio, Some(start));
|
||||
distill_image(&request, &map).cost.cost_in_usd_cents.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_costs_160() { assert_eq!(cost_img(None, None), 160); }
|
||||
|
||||
#[test]
|
||||
fn four_seconds_no_audio_costs_80() { assert_eq!(cost_img(Some(4), Some(false)), 80); }
|
||||
|
||||
#[test]
|
||||
fn six_seconds_audio_on_costs_240() { assert_eq!(cost_img(Some(6), Some(true)), 240); }
|
||||
|
||||
#[test]
|
||||
fn eight_seconds_audio_on_costs_320() { assert_eq!(cost_img(Some(8), Some(true)), 320); }
|
||||
|
||||
#[test]
|
||||
fn image_and_text_cost_match() {
|
||||
let text = distill_text(&make_request(Some("p"), None, None, None, None, None))
|
||||
.cost.cost_in_usd_cents.unwrap();
|
||||
let (start, map) = fake_token("mf_start0000000000000000000000");
|
||||
let img = distill_image(
|
||||
&make_request(Some("p"), None, None, None, None, Some(start)),
|
||||
&map,
|
||||
).cost.cost_in_usd_cents.unwrap();
|
||||
assert_eq!(text, img);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// PLAN
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
mod plan {
|
||||
use super::*;
|
||||
|
||||
use artcraft_router::generate::generate_video::video_generation_plan::VideoGenerationPlan;
|
||||
use artcraft_router::generate::generate_video::plan::fal::plan_generate_video_fal_veo_3::{
|
||||
FalVeo3Duration, FalVeo3I2vAspectRatio, FalVeo3Mode, FalVeo3Resolution,
|
||||
FalVeo3T2vAspectRatio, PlanFalVeo3,
|
||||
};
|
||||
|
||||
fn with_text_plan<F: FnOnce(&PlanFalVeo3)>(
|
||||
request: &OmniGenVideoCostAndGenerateRequest,
|
||||
assertion: F,
|
||||
) {
|
||||
let distilled = distill_text(request);
|
||||
match distilled.plan() {
|
||||
VideoGenerationPlan::FalVeo3(plan) => assertion(plan),
|
||||
other => panic!("expected FalVeo3, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
fn with_image_plan<F: FnOnce(&PlanFalVeo3)>(
|
||||
request: &OmniGenVideoCostAndGenerateRequest,
|
||||
hydration: &HashMap<MediaFileToken, Url>,
|
||||
assertion: F,
|
||||
) {
|
||||
let distilled = distill_image(request, hydration);
|
||||
match distilled.plan() {
|
||||
VideoGenerationPlan::FalVeo3(plan) => assertion(plan),
|
||||
other => panic!("expected FalVeo3, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
mod text {
|
||||
use super::*;
|
||||
|
||||
// ── Mode detection ────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn text_mode_is_text_to_video() {
|
||||
with_text_plan(&make_request(Some("p"), None, None, None, None, None), |plan| {
|
||||
assert!(matches!(plan.mode, FalVeo3Mode::TextToVideo));
|
||||
});
|
||||
}
|
||||
|
||||
// ── Prompt passthrough ────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn prompt_is_passed_through() {
|
||||
with_text_plan(&make_request(Some("a horse on mars"), None, None, None, None, None), |plan| {
|
||||
assert_eq!(plan.prompt, "a horse on mars");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_prompt_is_empty_string() {
|
||||
with_text_plan(&make_request(None, None, None, None, None, None), |plan| {
|
||||
assert_eq!(plan.prompt, "");
|
||||
});
|
||||
}
|
||||
|
||||
// ── Aspect ratio mappings (text mode only) ────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn default_aspect_ratio_is_default() {
|
||||
with_text_plan(&make_request(Some("p"), None, None, None, None, None), |plan| {
|
||||
assert!(matches!(plan.t2v_aspect_ratio, Some(FalVeo3T2vAspectRatio::Default)));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wide_16x9_yields_wide() {
|
||||
for ar in [CommonAspectRatio::WideSixteenByNine, CommonAspectRatio::Wide] {
|
||||
with_text_plan(&make_request(Some("p"), Some(ar), None, None, None, None), |plan| {
|
||||
assert!(matches!(plan.t2v_aspect_ratio, Some(FalVeo3T2vAspectRatio::WideSixteenNine)));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tall_9x16_yields_tall() {
|
||||
for ar in [CommonAspectRatio::TallNineBySixteen, CommonAspectRatio::Tall] {
|
||||
with_text_plan(&make_request(Some("p"), Some(ar), None, None, None, None), |plan| {
|
||||
assert!(matches!(plan.t2v_aspect_ratio, Some(FalVeo3T2vAspectRatio::TallNineSixteen)));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn square_falls_back_to_default() {
|
||||
// Veo 3 t2v has no Square — falls back to Default (16:9).
|
||||
for ar in [CommonAspectRatio::Square, CommonAspectRatio::SquareHd] {
|
||||
with_text_plan(&make_request(Some("p"), Some(ar), None, None, None, None), |plan| {
|
||||
assert!(matches!(plan.t2v_aspect_ratio, Some(FalVeo3T2vAspectRatio::Default)));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_falls_back_to_default() {
|
||||
// Veo 3 t2v has no Auto — falls back to Default (16:9).
|
||||
for ar in [CommonAspectRatio::Auto, CommonAspectRatio::Auto2k, CommonAspectRatio::Auto4k] {
|
||||
with_text_plan(&make_request(Some("p"), Some(ar), None, None, None, None), |plan| {
|
||||
assert!(matches!(plan.t2v_aspect_ratio, Some(FalVeo3T2vAspectRatio::Default)));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_aspect_ratio_falls_back_to_default() {
|
||||
for ar in [CommonAspectRatio::WideFourByThree, CommonAspectRatio::TallThreeByFour, CommonAspectRatio::WideFiveByFour] {
|
||||
with_text_plan(&make_request(Some("p"), Some(ar), None, None, None, None), |plan| {
|
||||
assert!(matches!(plan.t2v_aspect_ratio, Some(FalVeo3T2vAspectRatio::Default)));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Duration mappings ─────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn default_duration_is_default() {
|
||||
with_text_plan(&make_request(Some("p"), None, None, None, None, None), |plan| {
|
||||
assert!(matches!(plan.duration, FalVeo3Duration::Default));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_4_yields_four() {
|
||||
with_text_plan(&make_request(Some("p"), None, None, Some(4), None, None), |plan| {
|
||||
assert!(matches!(plan.duration, FalVeo3Duration::FourSeconds));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_6_yields_six() {
|
||||
with_text_plan(&make_request(Some("p"), None, None, Some(6), None, None), |plan| {
|
||||
assert!(matches!(plan.duration, FalVeo3Duration::SixSeconds));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_8_yields_eight() {
|
||||
with_text_plan(&make_request(Some("p"), None, None, Some(8), None, None), |plan| {
|
||||
assert!(matches!(plan.duration, FalVeo3Duration::EightSeconds));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_above_8_clamps_to_eight() {
|
||||
with_text_plan(&make_request(Some("p"), None, None, Some(20), None, None), |plan| {
|
||||
assert!(matches!(plan.duration, FalVeo3Duration::EightSeconds));
|
||||
});
|
||||
}
|
||||
|
||||
// ── Resolution mappings ───────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn default_resolution_is_default() {
|
||||
with_text_plan(&make_request(Some("p"), None, None, None, None, None), |plan| {
|
||||
assert!(matches!(plan.resolution, FalVeo3Resolution::Default));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seven_twenty_p_yields_seven_twenty_p() {
|
||||
with_text_plan(&make_request(Some("p"), None, Some(CommonResolution::SevenTwentyP), None, None, None), |plan| {
|
||||
assert!(matches!(plan.resolution, FalVeo3Resolution::SevenTwentyP));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ten_eighty_p_yields_ten_eighty_p() {
|
||||
with_text_plan(&make_request(Some("p"), None, Some(CommonResolution::TenEightyP), None, None, None), |plan| {
|
||||
assert!(matches!(plan.resolution, FalVeo3Resolution::TenEightyP));
|
||||
});
|
||||
}
|
||||
|
||||
// ── Generate audio passthrough ────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn default_audio_is_true() {
|
||||
// Fal plan defaults generate_audio to true.
|
||||
with_text_plan(&make_request(Some("p"), None, None, None, None, None), |plan| {
|
||||
assert!(plan.generate_audio);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_true_passes_through() {
|
||||
with_text_plan(&make_request(Some("p"), None, None, None, Some(true), None), |plan| {
|
||||
assert!(plan.generate_audio);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_false_passes_through() {
|
||||
with_text_plan(&make_request(Some("p"), None, None, None, Some(false), None), |plan| {
|
||||
assert!(!plan.generate_audio);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
mod image {
|
||||
use super::*;
|
||||
|
||||
// ── Mode detection / start frame hydration ────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn start_frame_triggers_image_to_video() {
|
||||
let (start, map) = fake_token("mf_start0000000000000000000000");
|
||||
let request = make_request(Some("p"), None, None, None, None, Some(start));
|
||||
with_image_plan(&request, &map, |plan| {
|
||||
match &plan.mode {
|
||||
FalVeo3Mode::ImageToVideo { image_url } => {
|
||||
assert!(image_url.starts_with("https://fake.example.com/"));
|
||||
}
|
||||
other => panic!("expected ImageToVideo, got {:?}", other),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn start_frame_url_matches_hydration_map_exactly() {
|
||||
let token = MediaFileToken::new_from_str("mf_myframe0000000000000000000000");
|
||||
let expected_url = "https://cdn.example.com/frames/my_frame.png";
|
||||
let mut map = HashMap::new();
|
||||
map.insert(token.clone(), Url::parse(expected_url).unwrap());
|
||||
|
||||
let request = make_request(Some("p"), None, None, None, None, Some(token));
|
||||
with_image_plan(&request, &map, |plan| {
|
||||
match &plan.mode {
|
||||
FalVeo3Mode::ImageToVideo { image_url } => {
|
||||
assert_eq!(image_url, expected_url);
|
||||
}
|
||||
other => panic!("expected ImageToVideo, got {:?}", other),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Aspect ratio is None in image mode ────────────────────────────────
|
||||
|
||||
// ── Aspect ratio in image mode: uses i2v (has Auto, no Square) ─────────
|
||||
|
||||
#[test]
|
||||
fn t2v_aspect_ratio_is_none_in_image_mode() {
|
||||
let (start, map) = fake_token("mf_start0000000000000000000000");
|
||||
let request = make_request(Some("p"), Some(CommonAspectRatio::WideSixteenByNine), None, None, None, Some(start));
|
||||
with_image_plan(&request, &map, |plan| {
|
||||
assert!(plan.t2v_aspect_ratio.is_none());
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn i2v_default_aspect_ratio_is_auto() {
|
||||
let (start, map) = fake_token("mf_start0000000000000000000000");
|
||||
let request = make_request(Some("p"), None, None, None, None, Some(start));
|
||||
with_image_plan(&request, &map, |plan| {
|
||||
assert!(matches!(plan.i2v_aspect_ratio, Some(FalVeo3I2vAspectRatio::Auto)));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn i2v_wide_16x9_yields_wide() {
|
||||
let (start, map) = fake_token("mf_start0000000000000000000000");
|
||||
let request = make_request(Some("p"), Some(CommonAspectRatio::WideSixteenByNine), None, None, None, Some(start));
|
||||
with_image_plan(&request, &map, |plan| {
|
||||
assert!(matches!(plan.i2v_aspect_ratio, Some(FalVeo3I2vAspectRatio::WideSixteenNine)));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn i2v_tall_9x16_yields_tall() {
|
||||
let (start, map) = fake_token("mf_start0000000000000000000000");
|
||||
let request = make_request(Some("p"), Some(CommonAspectRatio::TallNineBySixteen), None, None, None, Some(start));
|
||||
with_image_plan(&request, &map, |plan| {
|
||||
assert!(matches!(plan.i2v_aspect_ratio, Some(FalVeo3I2vAspectRatio::TallNineSixteen)));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn i2v_auto_yields_auto() {
|
||||
for ar in [CommonAspectRatio::Auto, CommonAspectRatio::Auto2k, CommonAspectRatio::Auto4k] {
|
||||
let (start, map) = fake_token("mf_start0000000000000000000000");
|
||||
let request = make_request(Some("p"), Some(ar), None, None, None, Some(start));
|
||||
with_image_plan(&request, &map, |plan| {
|
||||
assert!(matches!(plan.i2v_aspect_ratio, Some(FalVeo3I2vAspectRatio::Auto)));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn i2v_square_falls_back_to_auto() {
|
||||
for ar in [CommonAspectRatio::Square, CommonAspectRatio::SquareHd] {
|
||||
let (start, map) = fake_token("mf_start0000000000000000000000");
|
||||
let request = make_request(Some("p"), Some(ar), None, None, None, Some(start));
|
||||
with_image_plan(&request, &map, |plan| {
|
||||
assert!(matches!(plan.i2v_aspect_ratio, Some(FalVeo3I2vAspectRatio::Auto)));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn i2v_unsupported_falls_back_to_auto() {
|
||||
for ar in [CommonAspectRatio::WideFourByThree, CommonAspectRatio::TallThreeByFour] {
|
||||
let (start, map) = fake_token("mf_start0000000000000000000000");
|
||||
let request = make_request(Some("p"), Some(ar), None, None, None, Some(start));
|
||||
with_image_plan(&request, &map, |plan| {
|
||||
assert!(matches!(plan.i2v_aspect_ratio, Some(FalVeo3I2vAspectRatio::Auto)));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Prompt passthrough in image mode ──────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn prompt_is_passed_through_in_image_mode() {
|
||||
let (start, map) = fake_token("mf_start0000000000000000000000");
|
||||
let request = make_request(Some("a horse on mars"), None, None, None, None, Some(start));
|
||||
with_image_plan(&request, &map, |plan| {
|
||||
assert_eq!(plan.prompt, "a horse on mars");
|
||||
});
|
||||
}
|
||||
|
||||
// ── Duration pass-throughs in image mode ──────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn duration_4_in_image_mode() {
|
||||
let (start, map) = fake_token("mf_start0000000000000000000000");
|
||||
let request = make_request(Some("p"), None, None, Some(4), None, Some(start));
|
||||
with_image_plan(&request, &map, |plan| {
|
||||
assert!(matches!(plan.duration, FalVeo3Duration::FourSeconds));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_8_in_image_mode() {
|
||||
let (start, map) = fake_token("mf_start0000000000000000000000");
|
||||
let request = make_request(Some("p"), None, None, Some(8), None, Some(start));
|
||||
with_image_plan(&request, &map, |plan| {
|
||||
assert!(matches!(plan.duration, FalVeo3Duration::EightSeconds));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_duration_in_image_mode() {
|
||||
let (start, map) = fake_token("mf_start0000000000000000000000");
|
||||
let request = make_request(Some("p"), None, None, None, None, Some(start));
|
||||
with_image_plan(&request, &map, |plan| {
|
||||
assert!(matches!(plan.duration, FalVeo3Duration::Default));
|
||||
});
|
||||
}
|
||||
|
||||
// ── Resolution in image mode ──────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn resolution_passes_through_in_image_mode() {
|
||||
let (start, map) = fake_token("mf_start0000000000000000000000");
|
||||
let request = make_request(Some("p"), None, Some(CommonResolution::TenEightyP), None, None, Some(start));
|
||||
with_image_plan(&request, &map, |plan| {
|
||||
assert!(matches!(plan.resolution, FalVeo3Resolution::TenEightyP));
|
||||
});
|
||||
}
|
||||
|
||||
// ── Audio in image mode ───────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn audio_passes_through_in_image_mode() {
|
||||
let (start, map) = fake_token("mf_start0000000000000000000000");
|
||||
let request = make_request(Some("p"), None, None, None, Some(false), Some(start));
|
||||
with_image_plan(&request, &map, |plan| {
|
||||
assert!(!plan.generate_audio);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_audio_is_true_in_image_mode() {
|
||||
let (start, map) = fake_token("mf_start0000000000000000000000");
|
||||
let request = make_request(Some("p"), None, None, None, None, Some(start));
|
||||
with_image_plan(&request, &map, |plan| {
|
||||
assert!(plan.generate_audio);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -29,7 +29,7 @@ use crate::http_server::endpoints::moderation::wallets::moderator_get_wallet_han
|
||||
use crate::http_server::endpoints::moderation::jobs::get_tts_inference_queue_count::get_tts_inference_queue_count_handler;
|
||||
use crate::http_server::endpoints::moderation::jobs::get_w2l_inference_queue_count::get_w2l_inference_queue_count_handler;
|
||||
use crate::http_server::endpoints::moderation::jobs::kill_tts_inference_jobs::kill_tts_inference_jobs_handler;
|
||||
use crate::http_server::endpoints::moderation::user_bans::ban_user::ban_user_handler;
|
||||
use crate::http_server::endpoints::moderation::user_bans::moderation_ban_user_handler::moderation_ban_user_handler;
|
||||
use crate::http_server::endpoints::moderation::user::moderator_list_subscribing_users_by_signup_date::moderator_list_subscribing_users_by_signup_date_handler;
|
||||
use crate::http_server::endpoints::moderation::user::moderator_list_users_by_signup_date::moderator_list_users_by_signup_date_handler;
|
||||
use crate::http_server::endpoints::moderation::user::moderator_user_lookup_by_stripe_customer_id_handler::moderator_user_lookup_by_stripe_customer_id_handler;
|
||||
@@ -183,7 +183,7 @@ pub fn add_moderator_routes<T, B> (app: App<T>) -> App<T>
|
||||
web::scope("/user_bans")
|
||||
.service(
|
||||
web::resource("/manage_ban")
|
||||
.route(web::post().to(ban_user_handler))
|
||||
.route(web::post().to(moderation_ban_user_handler))
|
||||
.route(web::head().to(|| HttpResponse::Ok()))
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { faEye, faEyeSlash, faSpinnerThird } from "@fortawesome/pro-solid-svg-icons";
|
||||
import {
|
||||
faEye,
|
||||
faEyeSlash,
|
||||
faSpinnerThird,
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import { Input } from "@storyteller/ui-input";
|
||||
import { useState } from "react";
|
||||
import { UsersApi } from "@storyteller/api";
|
||||
import { GoogleLoginButton } from "./GoogleLoginButton";
|
||||
|
||||
interface SignupFormProps {
|
||||
onSuccess: (isNewUser?: boolean) => void;
|
||||
signupSource: string;
|
||||
className?: string;
|
||||
showGoogleButton?: boolean;
|
||||
autoFocus?: boolean;
|
||||
}
|
||||
|
||||
@@ -17,7 +20,6 @@ export const SignupForm = ({
|
||||
onSuccess,
|
||||
signupSource,
|
||||
className = "",
|
||||
showGoogleButton = true,
|
||||
autoFocus = false,
|
||||
}: SignupFormProps) => {
|
||||
const [email, setEmail] = useState("");
|
||||
@@ -101,62 +103,70 @@ export const SignupForm = ({
|
||||
)}
|
||||
*/}
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-500/10 border border-red-500/20 text-red-400 px-4 py-3 rounded-xl text-sm text-center animate-in fade-in slide-in-from-top-2">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold text-white/60 uppercase tracking-wide ml-1">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
autoFocus={autoFocus}
|
||||
className="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSignup()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold text-white/60 uppercase tracking-wide ml-1">
|
||||
Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Min. 8 characters"
|
||||
className="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors pr-12"
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSignup()}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 text-white/30 hover:text-white/60 transition-colors"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<FontAwesomeIcon icon={showPassword ? faEyeSlash : faEye} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="w-full bg-primary hover:bg-primary-600 text-white border-none justify-center font-bold h-12 mt-2"
|
||||
onClick={handleSignup}
|
||||
disabled={isLoading}
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleSignup();
|
||||
}}
|
||||
>
|
||||
{isLoading ? (
|
||||
<FontAwesomeIcon icon={faSpinnerThird} className="animate-spin" />
|
||||
) : (
|
||||
"Create Account"
|
||||
{error && (
|
||||
<div className="bg-red-500/10 border border-red-500/20 text-red-500 px-4 py-3 rounded-xl text-sm text-center">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold text-white/60 uppercase tracking-wide ml-1">
|
||||
Email
|
||||
</label>
|
||||
<Input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
autoFocus={autoFocus}
|
||||
inputClassName="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold text-white/60 uppercase tracking-wide ml-1">
|
||||
Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Min. 8 characters"
|
||||
inputClassName="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors pr-12"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 text-white/30 hover:text-white/60 transition-colors"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<FontAwesomeIcon icon={showPassword ? faEyeSlash : faEye} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-2">
|
||||
<Button
|
||||
className="w-full bg-primary hover:bg-primary-600 text-white border-none justify-center font-bold h-10"
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<FontAwesomeIcon icon={faSpinnerThird} className="animate-spin" />
|
||||
) : (
|
||||
"Create Account"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
+20
-15
@@ -1,7 +1,10 @@
|
||||
import { type ReactNode } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faSpinnerThird, type IconDefinition } from "@fortawesome/pro-solid-svg-icons";
|
||||
import {
|
||||
faSpinnerThird,
|
||||
type IconDefinition,
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import { PopoverMenu, type PopoverItem } from "@storyteller/ui-popover";
|
||||
import Seo from "../../components/seo";
|
||||
@@ -71,7 +74,7 @@ export function CreateMediaPageShell({
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 z-0 flex justify-center">
|
||||
<div className="h-[600px] w-[600px] rounded-full bg-gradient-to-br from-primary/30 via-blue-500/20 to-teal-400/10 opacity-40 blur-[120px]" />
|
||||
</div>
|
||||
<div className="relative z-10 flex min-h-screen flex-col items-center justify-center px-4 pt-20">
|
||||
<div className="relative z-10 flex min-h-screen flex-col items-center justify-center px-4">
|
||||
<FontAwesomeIcon
|
||||
icon={heroIcon}
|
||||
className="mb-6 text-5xl text-white/20"
|
||||
@@ -108,14 +111,15 @@ export function CreateMediaPageShell({
|
||||
<div className="flex h-screen w-full bg-[#101014] text-white">
|
||||
<Seo title={title} description={description} />
|
||||
|
||||
{/* Glow orbs */}
|
||||
{glowOrbs ?? (
|
||||
<div className="pointer-events-none fixed inset-0 z-0 overflow-hidden">
|
||||
<div className="absolute left-1/2 top-[-10%] h-[700px] w-[700px] -translate-x-1/2 rounded-full bg-gradient-to-br from-blue-700 via-blue-500 to-[#00AABA] opacity-[0.12] blur-[120px] transform-gpu" />
|
||||
<div className="absolute bottom-[-15%] right-[-10%] h-[500px] w-[500px] rounded-full bg-gradient-to-br from-purple-600 via-blue-500 to-[#00AABA] opacity-[0.08] blur-[120px] transform-gpu" />
|
||||
<div className="absolute bottom-[20%] left-[-10%] h-[400px] w-[400px] rounded-full bg-gradient-to-br from-blue-600 to-pink-500 opacity-[0.06] blur-[140px] transform-gpu" />
|
||||
</div>
|
||||
)}
|
||||
{/* Glow orbs — only show on empty state, hide when gallery has content */}
|
||||
{!hasContent &&
|
||||
(glowOrbs ?? (
|
||||
<div className="pointer-events-none fixed inset-0 z-0 overflow-hidden">
|
||||
<div className="absolute left-1/2 top-[-10%] h-[700px] w-[700px] -translate-x-1/2 rounded-full bg-gradient-to-br from-blue-700 via-blue-500 to-[#00AABA] opacity-[0.12] blur-[120px] transform-gpu" />
|
||||
<div className="absolute bottom-[-15%] right-[-10%] h-[500px] w-[500px] rounded-full bg-gradient-to-br from-purple-600 via-blue-500 to-[#00AABA] opacity-[0.08] blur-[120px] transform-gpu" />
|
||||
<div className="absolute bottom-[20%] left-[-10%] h-[400px] w-[400px] rounded-full bg-gradient-to-br from-blue-600 to-pink-500 opacity-[0.06] blur-[140px] transform-gpu" />
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="relative z-[1] h-full w-full">
|
||||
<div className="flex h-full w-full flex-col">
|
||||
@@ -134,12 +138,10 @@ export function CreateMediaPageShell({
|
||||
|
||||
{hasContent && (
|
||||
<div
|
||||
className="h-full w-full overflow-y-auto pt-20"
|
||||
className="h-full w-full overflow-y-auto pt-[60px] sm:pt-[78px]"
|
||||
style={{ paddingBottom: bottomOffset }}
|
||||
>
|
||||
<div className="px-2 md:px-3 lg:px-4">
|
||||
{gridContent}
|
||||
</div>
|
||||
<div className="px-3">{gridContent}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -149,7 +151,10 @@ export function CreateMediaPageShell({
|
||||
className="animate-fade-in-up fixed bottom-3 left-6 z-20 hidden items-center gap-5 lg:flex"
|
||||
style={{ animationDelay: "300ms" }}
|
||||
>
|
||||
<div className="rounded-xl border border-white/10 px-3 py-2 shadow-lg backdrop-blur-xl" style={{ backgroundColor: "rgba(30, 30, 33, 0.85)" }}>
|
||||
<div
|
||||
className="rounded-xl border border-white/10 px-3 py-2 shadow-lg backdrop-blur-xl"
|
||||
style={{ backgroundColor: "rgba(30, 30, 33, 0.85)" }}
|
||||
>
|
||||
<PopoverMenu
|
||||
items={modelItems}
|
||||
onSelect={onModelChange}
|
||||
|
||||
@@ -2,6 +2,10 @@ import { memo, useState } from "react";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faCube, faImage, faVideo } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { PLACEHOLDER_IMAGES } from "@storyteller/common";
|
||||
import {
|
||||
getModelCreatorIconPath,
|
||||
getModelDisplayName,
|
||||
} from "../../lib/omni-gen-hooks";
|
||||
import type { GalleryItem } from "./useGalleryData";
|
||||
|
||||
// ── Persistent aspect ratio cache ─────────────────────────────────────────
|
||||
@@ -71,6 +75,26 @@ export const GalleryCard = memo(function GalleryCard({
|
||||
containIntrinsicSize: "auto 200px",
|
||||
};
|
||||
|
||||
const modelDisplayName = item.modelId
|
||||
? getModelDisplayName(item.modelId)
|
||||
: null;
|
||||
const modelIconPath = item.modelId
|
||||
? getModelCreatorIconPath(item.modelId)
|
||||
: null;
|
||||
|
||||
const mediaIcon =
|
||||
item.mediaClass === "video"
|
||||
? faVideo
|
||||
: item.mediaClass === "dimensional"
|
||||
? faCube
|
||||
: faImage;
|
||||
const mediaLabel =
|
||||
item.mediaClass === "video"
|
||||
? "Video"
|
||||
: item.mediaClass === "dimensional"
|
||||
? "3D"
|
||||
: "Image";
|
||||
|
||||
return (
|
||||
<button
|
||||
className="group relative block w-full overflow-hidden rounded-lg bg-ui-controls/40 leading-none transition-shadow hover:ring-2 hover:ring-primary-400/60 focus:outline-none focus:ring-2 focus:ring-primary-400"
|
||||
@@ -105,29 +129,29 @@ export const GalleryCard = memo(function GalleryCard({
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<FontAwesomeIcon
|
||||
icon={
|
||||
item.mediaClass === "video"
|
||||
? faVideo
|
||||
: item.mediaClass === "dimensional"
|
||||
? faCube
|
||||
: faImage
|
||||
}
|
||||
icon={mediaIcon}
|
||||
className="text-xl text-white/20"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{item.mediaClass === "video" && (
|
||||
<div className="absolute bottom-1.5 left-1.5 flex items-center gap-1 rounded bg-black/60 px-1.5 py-0.5 text-[10px] text-white/80">
|
||||
<FontAwesomeIcon icon={faVideo} className="text-[8px]" />
|
||||
Video
|
||||
|
||||
{/* Hover overlay with media type + model badges */}
|
||||
<div className="absolute inset-x-0 bottom-0 flex items-center gap-1.5 bg-gradient-to-t from-black/60 to-transparent px-2 pb-2 pt-6 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<div className="flex items-center gap-1 rounded bg-black/60 px-1.5 py-0.5 text-[10px] text-white/80">
|
||||
<FontAwesomeIcon icon={mediaIcon} className="text-[8px]" />
|
||||
{mediaLabel}
|
||||
</div>
|
||||
)}
|
||||
{item.mediaClass === "dimensional" && (
|
||||
<div className="absolute bottom-1.5 left-1.5 flex items-center gap-1 rounded bg-black/60 px-1.5 py-0.5 text-[10px] text-white/80">
|
||||
<FontAwesomeIcon icon={faCube} className="text-[8px]" />
|
||||
3D
|
||||
</div>
|
||||
)}
|
||||
{modelDisplayName && modelIconPath && (
|
||||
<div className="flex items-center gap-1 rounded bg-black/60 px-1.5 py-0.5 text-[10px] text-white/80">
|
||||
<img
|
||||
src={modelIconPath}
|
||||
alt=""
|
||||
className="h-3 w-3 icon-auto-contrast"
|
||||
/>
|
||||
<span className="max-w-[100px] truncate">{modelDisplayName}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
+9
-8
@@ -16,10 +16,10 @@ const BREAKPOINT_COLS = {
|
||||
640: 2,
|
||||
};
|
||||
|
||||
// 4px gap on both axes — use Tailwind arbitrary values for exact pixels.
|
||||
// ml-[-4px] on container offsets the first column's pl-[4px].
|
||||
const MASONRY_CLASS = "flex w-auto ml-[-4px]";
|
||||
const COLUMN_CLASS = "pl-[4px]";
|
||||
// 12px gap on both axes (≈ Tailwind gap-3).
|
||||
// ml-[-12px] on container offsets the first column's pl-[12px].
|
||||
const MASONRY_CLASS = "flex w-auto ml-[-12px]";
|
||||
const COLUMN_CLASS = "pl-[12px]";
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -82,10 +82,11 @@ export function GenerationGalleryGrid({
|
||||
columnClassName={COLUMN_CLASS}
|
||||
>
|
||||
{inProgressJobs.map((job) => (
|
||||
<div key={job.id} className="mb-[4px]">
|
||||
<div key={job.id} className="mb-[12px]">
|
||||
<PendingCard
|
||||
id={job.id}
|
||||
prompt={job.prompt}
|
||||
modelId={job.modelId}
|
||||
modelLabel={job.modelLabel}
|
||||
progress={job.progress}
|
||||
estimatedTimeLeftMs={job.estimatedTimeLeftMs}
|
||||
@@ -93,7 +94,7 @@ export function GenerationGalleryGrid({
|
||||
</div>
|
||||
))}
|
||||
{failedJobs.map((job) => (
|
||||
<div key={job.id} className="mb-[4px]">
|
||||
<div key={job.id} className="mb-[12px]">
|
||||
<FailedCard
|
||||
id={job.id}
|
||||
prompt={job.prompt}
|
||||
@@ -105,12 +106,12 @@ export function GenerationGalleryGrid({
|
||||
</div>
|
||||
))}
|
||||
{newlyCompletedItems.map((item) => (
|
||||
<div key={`new-${item.id}`} className="mb-[4px]">
|
||||
<div key={`new-${item.id}`} className="mb-[12px]">
|
||||
<GalleryCard item={item} onClick={onGalleryItemClick} />
|
||||
</div>
|
||||
))}
|
||||
{filteredGalleryItems.map((item) => (
|
||||
<div key={item.id} className="mb-[4px]">
|
||||
<div key={item.id} className="mb-[12px]">
|
||||
<GalleryCard item={item} onClick={onGalleryItemClick} />
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { memo } from "react";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faSpinnerThird } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { getModelCreatorIconPath } from "../../lib/omni-gen-hooks";
|
||||
|
||||
export interface PendingCardProps {
|
||||
id: string;
|
||||
modelId: string;
|
||||
modelLabel: string;
|
||||
prompt: string;
|
||||
progress?: number;
|
||||
@@ -22,6 +24,7 @@ const formatTimeLeft = (ms: number): string => {
|
||||
};
|
||||
|
||||
export const PendingCard = memo(function PendingCard({
|
||||
modelId,
|
||||
modelLabel,
|
||||
prompt,
|
||||
progress,
|
||||
@@ -36,6 +39,8 @@ export const PendingCard = memo(function PendingCard({
|
||||
? formatTimeLeft(estimatedTimeLeftMs)
|
||||
: null;
|
||||
|
||||
const iconPath = getModelCreatorIconPath(modelId);
|
||||
|
||||
return (
|
||||
<div className="relative aspect-square w-full overflow-hidden rounded-lg bg-white/[0.03]">
|
||||
<div className="animate-shimmer h-full w-full" />
|
||||
@@ -53,19 +58,28 @@ export const PendingCard = memo(function PendingCard({
|
||||
<span className="text-[10px] text-white/30">{timeLabel}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/60 to-transparent px-3 pb-2.5 pt-6">
|
||||
<p className="truncate text-xs text-white/70">{prompt}</p>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<p className="truncate text-[10px] text-white/40">{modelLabel}</p>
|
||||
{progressPercent != null && (
|
||||
<div className="h-1 min-w-0 flex-1 rounded-full bg-white/10">
|
||||
<div
|
||||
className="h-1 rounded-full bg-primary-400 transition-all duration-500"
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/70 via-black/40 to-transparent px-3 pb-2.5 pt-8">
|
||||
<p className="line-clamp-3 text-xs leading-relaxed text-white/80">
|
||||
{prompt}
|
||||
</p>
|
||||
<div className="mt-1.5 flex items-center gap-1.5">
|
||||
<img
|
||||
src={iconPath}
|
||||
alt=""
|
||||
className="h-3.5 w-3.5 flex-shrink-0 icon-auto-contrast"
|
||||
/>
|
||||
<p className="truncate text-[10px] font-medium text-white/50">
|
||||
{modelLabel}
|
||||
</p>
|
||||
</div>
|
||||
{progressPercent != null && (
|
||||
<div className="mt-1.5 h-1 w-full rounded-full bg-white/10">
|
||||
<div
|
||||
className="h-1 rounded-full bg-primary-400 transition-all duration-500"
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface GalleryItem {
|
||||
fullImage: string | null;
|
||||
createdAt: string;
|
||||
mediaClass: string;
|
||||
modelId?: string;
|
||||
batchImageToken?: string;
|
||||
}
|
||||
|
||||
@@ -56,7 +57,7 @@ export function useGalleryData(options: {
|
||||
const thumbnail = isDimensional
|
||||
? null
|
||||
: getMediaThumbnail(item.media_links, item.media_class, {
|
||||
size: THUMBNAIL_SIZES.MEDIUM,
|
||||
size: THUMBNAIL_SIZES.LARGE,
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -66,6 +67,7 @@ export function useGalleryData(options: {
|
||||
fullImage: item.media_links?.cdn_url || null,
|
||||
createdAt: item.created_at,
|
||||
mediaClass: item.media_class || "image",
|
||||
modelId: item.maybe_model_type || undefined,
|
||||
batchImageToken: item.maybe_batch_token,
|
||||
};
|
||||
}, []);
|
||||
|
||||
+59
-6
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { JobsApi, JobStatus } from "@storyteller/api";
|
||||
import { JobsApi, JobStatus, MediaFilesApi } from "@storyteller/api";
|
||||
import type { Job } from "@storyteller/api";
|
||||
import { getMediaThumbnail, THUMBNAIL_SIZES } from "@storyteller/common";
|
||||
import {
|
||||
getModelDisplayName,
|
||||
getProviderDisplayName,
|
||||
@@ -13,6 +14,7 @@ import type { GalleryItem } from "./useGalleryData";
|
||||
export interface InProgressJob {
|
||||
id: string;
|
||||
prompt: string;
|
||||
modelId: string;
|
||||
modelLabel: string;
|
||||
progress: number;
|
||||
estimatedTimeLeftMs?: number;
|
||||
@@ -21,6 +23,7 @@ export interface InProgressJob {
|
||||
export interface FailedJob {
|
||||
id: string;
|
||||
prompt: string;
|
||||
modelId: string;
|
||||
modelLabel: string;
|
||||
failureReason?: string;
|
||||
failureMessage?: string;
|
||||
@@ -102,6 +105,7 @@ function jobToInProgress(job: Job): InProgressJob {
|
||||
return {
|
||||
id: job.job_token,
|
||||
prompt: getPrompt(job),
|
||||
modelId: job.request.maybe_model_type ?? "",
|
||||
modelLabel: getModelLabel(job),
|
||||
progress,
|
||||
estimatedTimeLeftMs,
|
||||
@@ -124,6 +128,7 @@ function jobToFailed(job: Job): FailedJob {
|
||||
return {
|
||||
id: job.job_token,
|
||||
prompt: getPrompt(job),
|
||||
modelId: job.request.maybe_model_type ?? "",
|
||||
modelLabel: getModelLabel(job),
|
||||
failureReason,
|
||||
failureMessage,
|
||||
@@ -145,9 +150,51 @@ function jobToGalleryItem(job: Job): GalleryItem | null {
|
||||
createdAt:
|
||||
result.maybe_successfully_completed_at || job.updated_at,
|
||||
mediaClass: mediaType === "video" ? "video" : "image",
|
||||
modelId: job.request.maybe_model_type ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** Expand a single GalleryItem into its batch siblings (if any). */
|
||||
async function expandBatchItems(
|
||||
item: GalleryItem,
|
||||
mediaFilesApi: MediaFilesApi,
|
||||
): Promise<GalleryItem[]> {
|
||||
try {
|
||||
const mediaResponse = await mediaFilesApi.GetMediaFileByToken({
|
||||
mediaFileToken: item.id,
|
||||
});
|
||||
const batchToken = (mediaResponse.data as any)?.maybe_batch_token;
|
||||
if (!batchToken) return [item];
|
||||
|
||||
const batchResponse = await mediaFilesApi.GetMediaFilesByBatchToken({
|
||||
batchToken,
|
||||
});
|
||||
if (!batchResponse.success || !batchResponse.data?.length) return [item];
|
||||
|
||||
return batchResponse.data
|
||||
.map((file: any): GalleryItem | null => {
|
||||
const cdnUrl = file.media_links?.cdn_url;
|
||||
if (!cdnUrl) return null;
|
||||
const thumbnail = getMediaThumbnail(file.media_links, item.mediaClass, {
|
||||
size: THUMBNAIL_SIZES.LARGE,
|
||||
});
|
||||
return {
|
||||
id: file.token,
|
||||
label: item.label,
|
||||
thumbnail: thumbnail || cdnUrl,
|
||||
fullImage: cdnUrl,
|
||||
createdAt: item.createdAt,
|
||||
mediaClass: item.mediaClass,
|
||||
modelId: item.modelId,
|
||||
batchImageToken: batchToken,
|
||||
};
|
||||
})
|
||||
.filter((i): i is GalleryItem => i !== null);
|
||||
} catch {
|
||||
return [item];
|
||||
}
|
||||
}
|
||||
|
||||
// ── Hook ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useGenerationJobs(options: {
|
||||
@@ -155,6 +202,7 @@ export function useGenerationJobs(options: {
|
||||
}) {
|
||||
const { mediaType } = options;
|
||||
const apiRef = useRef(new JobsApi());
|
||||
const mediaApiRef = useRef(new MediaFilesApi());
|
||||
|
||||
const [inProgress, setInProgress] = useState<InProgressJob[]>([]);
|
||||
const [failed, setFailed] = useState<FailedJob[]>([]);
|
||||
@@ -209,11 +257,16 @@ export function useGenerationJobs(options: {
|
||||
.map(jobToGalleryItem)
|
||||
.filter((item): item is GalleryItem => item !== null);
|
||||
if (items.length > 0) {
|
||||
setNewlyCompleted((prev) => {
|
||||
// Deduplicate by id
|
||||
const existingIds = new Set(prev.map((i) => i.id));
|
||||
const fresh = items.filter((i) => !existingIds.has(i.id));
|
||||
return [...fresh, ...prev];
|
||||
// Expand batch items (fetch siblings) then update state
|
||||
Promise.all(
|
||||
items.map((item) => expandBatchItems(item, mediaApiRef.current)),
|
||||
).then((expanded) => {
|
||||
const allItems = expanded.flat();
|
||||
setNewlyCompleted((prev) => {
|
||||
const existingIds = new Set(prev.map((i) => i.id));
|
||||
const fresh = allItems.filter((i) => !existingIds.has(i.id));
|
||||
return [...fresh, ...prev];
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,14 +2,7 @@ import { Modal } from "@storyteller/ui-modal";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import { LoadingSpinner } from "@storyteller/ui-loading-spinner";
|
||||
import { toast } from "@storyteller/ui-toaster";
|
||||
import { Tooltip } from "@storyteller/ui-tooltip";
|
||||
import {
|
||||
useEffect,
|
||||
useState,
|
||||
useMemo,
|
||||
useCallback,
|
||||
useRef,
|
||||
} from "react";
|
||||
import { useEffect, useState, useMemo, useCallback, useRef } from "react";
|
||||
import { MediaFilesApi, PromptsApi } from "@storyteller/api";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
@@ -39,7 +32,6 @@ import {
|
||||
} from "@storyteller/model-list";
|
||||
import { ActionReminderModal } from "@storyteller/ui-action-reminder-modal";
|
||||
import { Viewer3D } from "@storyteller/ui-viewer-3d";
|
||||
import dayjs from "dayjs";
|
||||
import useEmblaCarousel from "embla-carousel-react";
|
||||
import type { EmblaOptionsType } from "embla-carousel";
|
||||
|
||||
@@ -58,7 +50,7 @@ export interface LightboxItem {
|
||||
}
|
||||
|
||||
interface ContextImage {
|
||||
media_links: { cdn_url: string; maybe_thumbnail_template: string };
|
||||
media_links: { cdn_url: string; maybe_thumbnail_template: string | null };
|
||||
media_token: string;
|
||||
semantic: string;
|
||||
}
|
||||
@@ -147,6 +139,8 @@ interface LightboxProps {
|
||||
onNavigatePrev?: () => void;
|
||||
onNavigateNext?: () => void;
|
||||
onDeleted?: (id: string) => void;
|
||||
/** When false, suppress batch carousel (batch siblings shown as separate gallery cards instead). Default true. */
|
||||
showBatchCarousel?: boolean;
|
||||
}
|
||||
|
||||
export function Lightbox({
|
||||
@@ -161,6 +155,7 @@ export function Lightbox({
|
||||
onNavigatePrev,
|
||||
onNavigateNext,
|
||||
onDeleted,
|
||||
showBatchCarousel = true,
|
||||
}: LightboxProps) {
|
||||
const [mediaLoaded, setMediaLoaded] = useState(false);
|
||||
const [promptData, setPromptData] = useState<PromptData>(EMPTY_PROMPT);
|
||||
@@ -171,7 +166,9 @@ export function Lightbox({
|
||||
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
|
||||
const [isPromptExpanded, setIsPromptExpanded] = useState(false);
|
||||
const [isPromptClamped, setIsPromptClamped] = useState(false);
|
||||
const [discoveredBatchToken, setDiscoveredBatchToken] = useState<string | null>(null);
|
||||
const [discoveredBatchToken, setDiscoveredBatchToken] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const promptRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const promptCopy = useCopyFeedback();
|
||||
@@ -215,10 +212,7 @@ export function Lightbox({
|
||||
if (batchToken) setDiscoveredBatchToken(batchToken);
|
||||
}
|
||||
|
||||
if (
|
||||
mediaResponse.success &&
|
||||
mediaResponse.data?.maybe_prompt_token
|
||||
) {
|
||||
if (mediaResponse.success && mediaResponse.data?.maybe_prompt_token) {
|
||||
const promptResponse = await promptsApi.GetPromptsByToken({
|
||||
token: mediaResponse.data.maybe_prompt_token,
|
||||
});
|
||||
@@ -248,7 +242,9 @@ export function Lightbox({
|
||||
}, [mediaToken, isOpen, mediaFilesApi, promptsApi]);
|
||||
|
||||
// Fetch batch images (from prop or auto-discovered batch token)
|
||||
const effectiveBatchToken = propBatchImageToken || discoveredBatchToken;
|
||||
const effectiveBatchToken = showBatchCarousel
|
||||
? propBatchImageToken || discoveredBatchToken
|
||||
: undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (!effectiveBatchToken || !isOpen) {
|
||||
@@ -363,11 +359,19 @@ export function Lightbox({
|
||||
|
||||
const selectedImageUrl = effectiveImageUrls[selectedIndex] ?? null;
|
||||
const selectedMediaToken = useMemo(() => {
|
||||
return batchTokens?.[selectedIndex] ?? propMediaTokens?.[selectedIndex] ?? mediaToken;
|
||||
return (
|
||||
batchTokens?.[selectedIndex] ??
|
||||
propMediaTokens?.[selectedIndex] ??
|
||||
mediaToken
|
||||
);
|
||||
}, [batchTokens, propMediaTokens, selectedIndex, mediaToken]);
|
||||
|
||||
const isVideo = selectedImageUrl ? isVideoUrl(selectedImageUrl) : (propMediaClass === "video");
|
||||
const is3D = selectedImageUrl ? is3DModelUrl(selectedImageUrl) : (propMediaClass === "dimensional");
|
||||
const isVideo = selectedImageUrl
|
||||
? isVideoUrl(selectedImageUrl)
|
||||
: propMediaClass === "video";
|
||||
const is3D = selectedImageUrl
|
||||
? is3DModelUrl(selectedImageUrl)
|
||||
: propMediaClass === "dimensional";
|
||||
|
||||
// Keyboard navigation
|
||||
useEffect(() => {
|
||||
@@ -410,12 +414,13 @@ export function Lightbox({
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
className="rounded-xl h-[680px] w-[1100px] max-w-[95vw] max-h-[90vh] p-0"
|
||||
className="rounded-xl h-[90vh] sm:h-[680px] w-full sm:w-[1100px] max-w-[95vw] max-h-[90vh] p-0 border-white/5 shadow-2xl"
|
||||
backdropClassName="!bg-black/80"
|
||||
showClose={false}
|
||||
>
|
||||
<div className="flex h-full">
|
||||
<div className="flex flex-col sm:flex-row h-full">
|
||||
{/* Media preview panel */}
|
||||
<div className="group/nav relative flex h-full flex-1 items-center justify-center overflow-hidden rounded-l-xl bg-black">
|
||||
<div className="group/nav relative flex h-[45vh] sm:h-full flex-1 items-center justify-center overflow-hidden rounded-t-xl sm:rounded-l-xl sm:rounded-tr-none bg-black">
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
@@ -444,8 +449,16 @@ export function Lightbox({
|
||||
autoPlay
|
||||
muted
|
||||
playsInline
|
||||
disablePictureInPicture
|
||||
controlsList="nodownload noplaybackrate nofullscreen"
|
||||
className="h-full w-full object-contain"
|
||||
onLoadedData={() => setMediaLoaded(true)}
|
||||
ref={(el) => {
|
||||
if (el) {
|
||||
el.setAttribute("webkit-playsinline", "true");
|
||||
el.setAttribute("x-webkit-airplay", "deny");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<source src={selectedImageUrl} type="video/mp4" />
|
||||
</video>
|
||||
@@ -493,7 +506,10 @@ export function Lightbox({
|
||||
|
||||
{effectiveImageUrls.length > 1 && (
|
||||
<div className="mt-3 px-2 pb-2">
|
||||
<div className="embla-thumbs overflow-hidden" ref={emblaThumbsRef}>
|
||||
<div
|
||||
className="embla-thumbs overflow-hidden"
|
||||
ref={emblaThumbsRef}
|
||||
>
|
||||
<div className="embla-thumbs__container flex gap-2 justify-center">
|
||||
{effectiveImageUrls.map((url, idx) => (
|
||||
<button
|
||||
@@ -521,7 +537,7 @@ export function Lightbox({
|
||||
)}
|
||||
|
||||
{!mediaLoaded && selectedImageUrl && !isVideo && !is3D && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-ui-panel">
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black">
|
||||
<LoadingSpinner className="h-12 w-12 text-base-fg" />
|
||||
</div>
|
||||
)}
|
||||
@@ -533,7 +549,7 @@ export function Lightbox({
|
||||
e.stopPropagation();
|
||||
onNavigatePrev();
|
||||
}}
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 z-30 flex h-10 w-10 items-center justify-center rounded-full bg-black/50 text-white/70 opacity-0 transition-opacity duration-200 hover:bg-black/70 hover:text-white group-hover/nav:opacity-100"
|
||||
className="absolute left-2 sm:left-3 top-1/2 -translate-y-1/2 z-30 flex h-9 w-9 sm:h-10 sm:w-10 items-center justify-center rounded-full bg-black/50 text-white/70 sm:opacity-0 transition-opacity duration-200 hover:bg-black/70 hover:text-white sm:group-hover/nav:opacity-100"
|
||||
aria-label="Previous item"
|
||||
>
|
||||
<FontAwesomeIcon icon={faChevronLeft} className="text-lg" />
|
||||
@@ -545,7 +561,7 @@ export function Lightbox({
|
||||
e.stopPropagation();
|
||||
onNavigateNext();
|
||||
}}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 z-30 flex h-10 w-10 items-center justify-center rounded-full bg-black/50 text-white/70 opacity-0 transition-opacity duration-200 hover:bg-black/70 hover:text-white group-hover/nav:opacity-100"
|
||||
className="absolute right-2 sm:right-3 top-1/2 -translate-y-1/2 z-30 flex h-9 w-9 sm:h-10 sm:w-10 items-center justify-center rounded-full bg-black/50 text-white/70 sm:opacity-0 transition-opacity duration-200 hover:bg-black/70 hover:text-white sm:group-hover/nav:opacity-100"
|
||||
aria-label="Next item"
|
||||
>
|
||||
<FontAwesomeIcon icon={faChevronRight} className="text-lg" />
|
||||
@@ -554,8 +570,8 @@ export function Lightbox({
|
||||
</div>
|
||||
|
||||
{/* Info sidebar */}
|
||||
<div className="flex h-full w-[300px] shrink-0 flex-col bg-ui-panel rounded-r-xl">
|
||||
<div className="flex-1 overflow-y-auto p-5 flex flex-col gap-5 min-h-0">
|
||||
<div className="flex w-full sm:w-[300px] shrink-0 flex-col bg-ui-panel rounded-b-xl sm:rounded-b-none sm:rounded-r-xl min-h-0 flex-1 sm:flex-none sm:h-full overflow-hidden">
|
||||
<div className="flex-1 overflow-y-auto p-4 flex flex-col gap-5 min-h-0">
|
||||
{promptData.loading ? (
|
||||
<div className="space-y-6 animate-pulse">
|
||||
<div className="space-y-2">
|
||||
@@ -594,9 +610,7 @@ export function Lightbox({
|
||||
icon={promptCopy.copied ? faCheck : faCopy}
|
||||
className="h-3 w-3"
|
||||
/>
|
||||
<span>
|
||||
{promptCopy.copied ? "Copied" : "Copy"}
|
||||
</span>
|
||||
<span>{promptCopy.copied ? "Copied" : "Copy"}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -616,9 +630,7 @@ export function Lightbox({
|
||||
(isPromptClamped || isPromptExpanded) && (
|
||||
<button
|
||||
className="flex w-full items-center justify-center gap-1 text-xs text-white/70 hover:text-white transition-colors py-1"
|
||||
onClick={() =>
|
||||
setIsPromptExpanded((prev) => !prev)
|
||||
}
|
||||
onClick={() => setIsPromptExpanded((prev) => !prev)}
|
||||
>
|
||||
<span>
|
||||
{isPromptExpanded ? "Show less" : "Show more"}
|
||||
@@ -639,10 +651,12 @@ export function Lightbox({
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{promptData.contextImages.map(
|
||||
(contextImage, index) => {
|
||||
const { thumbnail } =
|
||||
getContextImageThumbnail(contextImage, {
|
||||
const { thumbnail } = getContextImageThumbnail(
|
||||
contextImage,
|
||||
{
|
||||
size: THUMBNAIL_SIZES.SMALL,
|
||||
});
|
||||
},
|
||||
);
|
||||
return (
|
||||
<a
|
||||
key={contextImage.media_token}
|
||||
@@ -716,7 +730,7 @@ export function Lightbox({
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="p-4 pt-2 space-y-2 border-t border-white/5">
|
||||
<div className="p-4 space-y-2 border-t border-white/5">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
className="w-full border border-ui-panel-border bg-ui-controls/40 hover:bg-ui-controls/60 text-white"
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import {
|
||||
Disclosure,
|
||||
DisclosureButton,
|
||||
DisclosurePanel,
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuItem,
|
||||
@@ -12,7 +14,12 @@ import { Link, useLocation } from "react-router-dom";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import { UsersApi, UserInfo, CreditsApi } from "@storyteller/api";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faCoins, faGrid2 } from "@fortawesome/pro-solid-svg-icons";
|
||||
import {
|
||||
faCoins,
|
||||
faGrid2,
|
||||
faBars,
|
||||
faXmark,
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import { TaskQueue } from "./task-queue";
|
||||
|
||||
const NAV_ITEMS = [
|
||||
@@ -94,7 +101,6 @@ export default function Navbar() {
|
||||
const handleLogout = async () => {
|
||||
const api = new UsersApi();
|
||||
await api.Logout();
|
||||
// Reload to clear state/cache
|
||||
window.location.href = "/";
|
||||
};
|
||||
|
||||
@@ -108,243 +114,316 @@ export default function Navbar() {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Disclosure
|
||||
as="nav"
|
||||
className={twMerge(
|
||||
"z-20 fixed top-0 left-0 w-full transition-all duration-200 bg-transparent",
|
||||
scrolled || alwaysSolid
|
||||
? "bg-[#1b1b1f]/70 backdrop-blur-lg"
|
||||
: "bg-transparent",
|
||||
)}
|
||||
>
|
||||
<div className="mx-auto max-w-screen sm:px-6 lg:px-8 px-4 md:px-16 xl:px-4">
|
||||
<div className="flex h-16 justify-between">
|
||||
<div className="flex">
|
||||
<div className="flex shrink-0 items-center">
|
||||
<Link to="/">
|
||||
<img
|
||||
alt="ArtCraft"
|
||||
src="/images/artcraft-logo.png"
|
||||
className="h-6 w-auto"
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="hidden md:ml-10 md:flex md:items-center md:space-x-6">
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const isCurrent =
|
||||
item.href === "/"
|
||||
? location.pathname === "/"
|
||||
: location.pathname === item.href ||
|
||||
location.pathname.startsWith(item.href + "/");
|
||||
return (
|
||||
<Link
|
||||
key={item.name}
|
||||
to={item.href}
|
||||
aria-current={isCurrent ? "page" : undefined}
|
||||
className={twMerge(
|
||||
"nav-link",
|
||||
isCurrent
|
||||
? "text-white"
|
||||
: "text-white/60 hover:text-white",
|
||||
"relative rounded-md text-[15px] font-semibold transition-all",
|
||||
)}
|
||||
>
|
||||
<span className="relative z-10">{item.name}</span>
|
||||
<span
|
||||
className={twMerge(
|
||||
"pointer-events-none absolute left-0 right-0 -bottom-1 h-[2px] overflow-hidden",
|
||||
isCurrent ? "" : "",
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span
|
||||
className={twMerge(
|
||||
"link-underline block h-full w-full bg-primary/90",
|
||||
isCurrent ? "visible-line" : "",
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
{isLoading ? (
|
||||
// Loading placeholder
|
||||
<div className="hidden md:ml-4 md:flex items-center gap-2.5 opacity-0"></div>
|
||||
) : user ? (
|
||||
// Logged In State
|
||||
<div className="hidden md:ml-4 md:flex items-center gap-2.5">
|
||||
<Link
|
||||
to="/pricing"
|
||||
className="text-[15px] font-semibold text-white/60 hover:text-white transition-colors"
|
||||
>
|
||||
Pricing
|
||||
</Link>
|
||||
{credits !== null && (
|
||||
<div className="flex items-center gap-2 px-4 text-[15px] font-semibold text-white/90">
|
||||
<FontAwesomeIcon
|
||||
icon={faCoins}
|
||||
className="text-primary text-sm"
|
||||
<Disclosure as="nav" className="z-20 fixed top-0 left-0 w-full">
|
||||
{({ open }) => (
|
||||
<div
|
||||
className={twMerge(
|
||||
"transition-all duration-200",
|
||||
scrolled || alwaysSolid || open ? "bg-[#1b1b1f]" : "bg-transparent",
|
||||
)}
|
||||
>
|
||||
<div className="mx-auto max-w-screen px-4">
|
||||
<div className="flex h-12 sm:h-16 justify-between">
|
||||
<div className="flex">
|
||||
<div className="flex shrink-0 items-center">
|
||||
<Link to="/">
|
||||
<img
|
||||
alt="ArtCraft"
|
||||
src="/images/artcraft-logo.png"
|
||||
className="h-5 sm:h-6 w-auto"
|
||||
/>
|
||||
{credits.toLocaleString()} Credits
|
||||
</Link>
|
||||
</div>
|
||||
<div className="hidden md:ml-10 md:flex md:items-center md:space-x-6">
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const isCurrent =
|
||||
item.href === "/"
|
||||
? location.pathname === "/"
|
||||
: location.pathname === item.href ||
|
||||
location.pathname.startsWith(item.href + "/");
|
||||
return (
|
||||
<Link
|
||||
key={item.name}
|
||||
to={item.href}
|
||||
aria-current={isCurrent ? "page" : undefined}
|
||||
className={twMerge(
|
||||
"nav-link",
|
||||
isCurrent
|
||||
? "text-white"
|
||||
: "text-white/60 hover:text-white",
|
||||
"relative rounded-md text-[15px] font-semibold transition-all",
|
||||
)}
|
||||
>
|
||||
<span className="relative z-10">{item.name}</span>
|
||||
<span
|
||||
className={twMerge(
|
||||
"pointer-events-none absolute left-0 right-0 -bottom-1 h-[2px] overflow-hidden",
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span
|
||||
className={twMerge(
|
||||
"link-underline block h-full w-full bg-primary/90",
|
||||
isCurrent ? "visible-line" : "",
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
{isLoading ? (
|
||||
<div className="hidden md:ml-4 md:flex items-center gap-2.5 opacity-0"></div>
|
||||
) : user ? (
|
||||
// ── Desktop: Logged In ─────────────────────────
|
||||
<div className="hidden md:ml-4 md:flex items-center gap-2.5">
|
||||
<Link
|
||||
to="/pricing"
|
||||
className="text-[15px] font-semibold text-white/60 hover:text-white transition-colors"
|
||||
>
|
||||
Pricing
|
||||
</Link>
|
||||
{credits !== null && (
|
||||
<div className="flex items-center gap-2 px-4 text-[15px] font-semibold text-white/90">
|
||||
<FontAwesomeIcon
|
||||
icon={faCoins}
|
||||
className="text-primary text-sm"
|
||||
/>
|
||||
{credits.toLocaleString()} Credits
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Link
|
||||
to="/library"
|
||||
className="flex h-[38px] items-center gap-2 rounded-lg px-3 text-sm font-medium text-base-fg bg-ui-controls hover:bg-ui-controls/80 border border-ui-controls-border shadow-sm transition-all duration-150 active:scale-95"
|
||||
>
|
||||
<FontAwesomeIcon icon={faGrid2} className="text-xs" />
|
||||
My Library
|
||||
</Link>
|
||||
|
||||
<TaskQueue />
|
||||
|
||||
<Menu as="div" className="relative ml-3">
|
||||
<div>
|
||||
<MenuButton className="flex rounded-full bg-gray-800 text-sm focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-gray-800">
|
||||
<span className="sr-only">Open user menu</span>
|
||||
<img
|
||||
className="h-8 w-8 rounded-full border border-white/10"
|
||||
src={`https://www.gravatar.com/avatar/${user.email_gravatar_hash}?d=mp`}
|
||||
alt=""
|
||||
/>
|
||||
</MenuButton>
|
||||
</div>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<MenuItems
|
||||
modal={false}
|
||||
className="absolute right-0 z-10 mt-2 w-48 origin-top-right rounded-md bg-[#1C1C20] py-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none border border-white/10"
|
||||
>
|
||||
<div className="px-4 py-3 border-b border-white/10">
|
||||
<p className="text-sm text-white font-semibold truncate">
|
||||
{user.display_name || user.username}
|
||||
</p>
|
||||
</div>
|
||||
<MenuItem>
|
||||
{({ active }) => (
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className={twMerge(
|
||||
active ? "bg-white/5" : "",
|
||||
"block w-full text-left px-4 py-2 text-sm text-white/70",
|
||||
)}
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
)}
|
||||
</MenuItem>
|
||||
</MenuItems>
|
||||
</Transition>
|
||||
</Menu>
|
||||
</div>
|
||||
) : (
|
||||
// ── Desktop: Logged Out ────────────────────────
|
||||
<div className="hidden md:ml-4 md:flex md:shrink-0 md:items-center gap-6">
|
||||
<Link
|
||||
to="/pricing"
|
||||
className="text-[15px] font-semibold text-white/60 hover:text-white transition-colors"
|
||||
>
|
||||
Pricing
|
||||
</Link>
|
||||
<div className="flex gap-2">
|
||||
<Link to="/login">
|
||||
<Button
|
||||
variant="primary"
|
||||
className="bg-white text-black hover:bg-white/90 text-sm font-semibold px-4 py-2 rounded-lg shadow-md"
|
||||
>
|
||||
Login
|
||||
</Button>
|
||||
</Link>
|
||||
<Link to="/signup">
|
||||
<Button
|
||||
variant="primary"
|
||||
className="text-sm font-semibold px-4 py-2 rounded-lg shadow-md"
|
||||
>
|
||||
Sign up
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Link
|
||||
to="/library"
|
||||
className="flex h-[38px] items-center gap-2 rounded-lg px-3 text-sm font-medium text-base-fg bg-ui-controls hover:bg-ui-controls/80 border border-ui-controls-border shadow-sm transition-all duration-150 active:scale-95"
|
||||
>
|
||||
<FontAwesomeIcon icon={faGrid2} className="text-xs" />
|
||||
My Library
|
||||
</Link>
|
||||
{/* ── Mobile: hamburger + task queue ──────────── */}
|
||||
<div className="flex items-center gap-2 md:hidden">
|
||||
{user && <TaskQueue />}
|
||||
<DisclosureButton className="flex h-9 w-9 items-center justify-center rounded-lg text-white/70 hover:bg-white/10 hover:text-white transition-colors">
|
||||
<span className="sr-only">Open main menu</span>
|
||||
<FontAwesomeIcon
|
||||
icon={open ? faXmark : faBars}
|
||||
className="text-lg"
|
||||
/>
|
||||
</DisclosureButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TaskQueue />
|
||||
{/* ── Mobile slide-down panel ─────────────────────── */}
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition duration-150 ease-out"
|
||||
enterFrom="opacity-0 -translate-y-2"
|
||||
enterTo="opacity-100 translate-y-0"
|
||||
leave="transition duration-100 ease-in"
|
||||
leaveFrom="opacity-100 translate-y-0"
|
||||
leaveTo="opacity-0 -translate-y-2"
|
||||
>
|
||||
<DisclosurePanel className="md:hidden border-t border-white/10 bg-[#1b1b1f]/95 backdrop-blur-xl">
|
||||
<div className="px-3 pb-3 pt-3">
|
||||
{/* Nav links */}
|
||||
<div className="flex flex-col">
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const isCurrent =
|
||||
item.href === "/"
|
||||
? location.pathname === "/"
|
||||
: location.pathname === item.href ||
|
||||
location.pathname.startsWith(item.href + "/");
|
||||
return (
|
||||
<DisclosureButton
|
||||
key={item.name}
|
||||
as={Link}
|
||||
to={item.href}
|
||||
className={twMerge(
|
||||
"rounded-md px-3 py-[7px] text-[13px] font-medium transition-colors",
|
||||
isCurrent
|
||||
? "bg-white/10 text-white"
|
||||
: "text-white/60 active:bg-white/5",
|
||||
)}
|
||||
>
|
||||
{item.name}
|
||||
</DisclosureButton>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Menu as="div" className="relative ml-3">
|
||||
<div>
|
||||
<MenuButton className="flex rounded-full bg-gray-800 text-sm focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-gray-800">
|
||||
<span className="sr-only">Open user menu</span>
|
||||
<div className="my-3 border-t border-white/[0.06]" />
|
||||
|
||||
{!isLoading && user ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<DisclosureButton
|
||||
as={Link}
|
||||
to="/library"
|
||||
className="flex h-8 items-center gap-1.5 rounded-md px-2.5 text-[12px] font-medium text-white/60 bg-white/[0.06] active:bg-white/10 transition-colors"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={faGrid2}
|
||||
className="text-[10px]"
|
||||
/>
|
||||
Library
|
||||
</DisclosureButton>
|
||||
<DisclosureButton
|
||||
as={Link}
|
||||
to="/pricing"
|
||||
className="flex h-8 items-center rounded-md px-2.5 text-[12px] font-medium text-white/60 bg-white/[0.06] active:bg-white/10 transition-colors"
|
||||
>
|
||||
Pricing
|
||||
</DisclosureButton>
|
||||
{credits !== null && (
|
||||
<span className="flex items-center gap-2 ml-auto text-[12px] font-medium text-white/80">
|
||||
<FontAwesomeIcon
|
||||
icon={faCoins}
|
||||
className="text-primary text-[10px]"
|
||||
/>
|
||||
{credits.toLocaleString()} Credits
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<img
|
||||
className="h-8 w-8 rounded-full border border-white/10"
|
||||
className="h-7 w-7 rounded-full border border-white/10 shrink-0"
|
||||
src={`https://www.gravatar.com/avatar/${user.email_gravatar_hash}?d=mp`}
|
||||
alt=""
|
||||
/>
|
||||
</MenuButton>
|
||||
<span className="truncate text-[13px] font-medium text-white/80 flex-1">
|
||||
{user.display_name || user.username}
|
||||
</span>
|
||||
<DisclosureButton
|
||||
as="button"
|
||||
onClick={handleLogout}
|
||||
className="flex h-7 items-center rounded-md px-2.5 text-[12px] font-medium text-red-400/70 active:bg-red-500/10 transition-colors shrink-0"
|
||||
>
|
||||
Sign out
|
||||
</DisclosureButton>
|
||||
</div>
|
||||
</div>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<MenuItems
|
||||
modal={false}
|
||||
className="absolute right-0 z-10 mt-2 w-48 origin-top-right rounded-md bg-[#1C1C20] py-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none border border-white/10"
|
||||
>
|
||||
<div className="px-4 py-3 border-b border-white/10">
|
||||
<p className="text-sm text-white font-semibold truncate">
|
||||
{user.display_name || user.username}
|
||||
</p>
|
||||
</div>
|
||||
<MenuItem>
|
||||
{({ active }) => (
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className={twMerge(
|
||||
active ? "bg-white/5" : "",
|
||||
"block w-full text-left px-4 py-2 text-sm text-white/70",
|
||||
)}
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
)}
|
||||
</MenuItem>
|
||||
</MenuItems>
|
||||
</Transition>
|
||||
</Menu>
|
||||
) : !isLoading ? (
|
||||
<div className="flex gap-2">
|
||||
<DisclosureButton as={Link} to="/login" className="flex-1">
|
||||
<Button className="w-full bg-white text-black hover:bg-white/90 text-[13px] font-semibold h-9 rounded-lg justify-center">
|
||||
Login
|
||||
</Button>
|
||||
</DisclosureButton>
|
||||
<DisclosureButton as={Link} to="/signup" className="flex-1">
|
||||
<Button
|
||||
variant="primary"
|
||||
className="w-full text-[13px] font-semibold h-9 rounded-lg justify-center"
|
||||
>
|
||||
Sign up
|
||||
</Button>
|
||||
</DisclosureButton>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
// Logged Out State
|
||||
<div className="hidden md:ml-4 md:flex md:shrink-0 md:items-center gap-6">
|
||||
<Link
|
||||
to="/pricing"
|
||||
className="text-[15px] font-semibold text-white/60 hover:text-white transition-colors"
|
||||
>
|
||||
Pricing
|
||||
</Link>
|
||||
<div className="flex gap-2">
|
||||
<Link to="/login">
|
||||
<Button
|
||||
variant="primary"
|
||||
className="bg-white text-black hover:bg-white/90 text-sm font-semibold px-4 py-2 rounded-lg shadow-md"
|
||||
>
|
||||
Login
|
||||
</Button>
|
||||
</Link>
|
||||
<Link to="/signup">
|
||||
<Button
|
||||
variant="primary"
|
||||
className="text-sm font-semibold px-4 py-2 rounded-lg shadow-md"
|
||||
>
|
||||
Sign up
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DisclosurePanel>
|
||||
</Transition>
|
||||
|
||||
{/* Mobile Menu Button - simplified for now */}
|
||||
<div className="-ml-2 flex items-center md:hidden gap-3">
|
||||
{!user && (
|
||||
<div className="flex gap-2">
|
||||
<Link to="/login">
|
||||
<Button className="bg-white/60 text-black hover:bg-white/90 text-xs font-semibold px-3 py-1.5 h-auto rounded-lg">
|
||||
Login
|
||||
</Button>
|
||||
</Link>
|
||||
<Link to="/signup">
|
||||
<Button
|
||||
variant="primary"
|
||||
className="text-xs font-semibold px-3 py-1.5 h-auto"
|
||||
>
|
||||
Sign up
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
{user && (
|
||||
<img
|
||||
className="h-8 w-8 rounded-full border border-white/10"
|
||||
src={`https://www.gravatar.com/avatar/${user.email_gravatar_hash}?d=mp`}
|
||||
alt=""
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<style>{`
|
||||
.link-underline {
|
||||
transform-origin: left center;
|
||||
transform: scaleX(0) translateX(0);
|
||||
opacity: 0;
|
||||
transition: transform 220ms ease, opacity 220ms ease;
|
||||
}
|
||||
.nav-link:hover .link-underline {
|
||||
transform: scaleX(1) translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
.visible-line {
|
||||
transform: scaleX(1) translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* <DisclosurePanel className="md:hidden">
|
||||
<div className="space-y-1 px-2 pt-2 pb-3 sm:px-3">
|
||||
{navigation.map((item) => (
|
||||
<DisclosureButton
|
||||
key={item.name}
|
||||
as="a"
|
||||
href={item.href}
|
||||
aria-current={item.current ? "page" : undefined}
|
||||
className={twMerge(
|
||||
item.current
|
||||
? "bg-gray-900 text-white"
|
||||
: "text-gray-300 hover:bg-gray-700 hover:text-white",
|
||||
"block rounded-md px-3 py-2 text-base font-medium"
|
||||
)}
|
||||
>
|
||||
{item.name}
|
||||
</DisclosureButton>
|
||||
))}
|
||||
</div>
|
||||
</DisclosurePanel> */}
|
||||
<style>{`
|
||||
.link-underline {
|
||||
transform-origin: left center;
|
||||
transform: scaleX(0) translateX(0);
|
||||
opacity: 0;
|
||||
transition: transform 220ms ease, opacity 220ms ease;
|
||||
}
|
||||
.nav-link:hover .link-underline {
|
||||
transform: scaleX(1) translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
.visible-line {
|
||||
transform: scaleX(1) translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
`}</style>
|
||||
)}
|
||||
</Disclosure>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
faBomb,
|
||||
faCircleExclamation,
|
||||
faTriangleExclamation,
|
||||
faCopy,
|
||||
faCheck,
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import { Modal } from "@storyteller/ui-modal";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
@@ -27,6 +29,8 @@ import dayjs from "dayjs";
|
||||
import { ActionReminderModal } from "@storyteller/ui-action-reminder-modal";
|
||||
import { Lightbox } from "../lightbox/lightbox";
|
||||
import { showToast } from "../toast/toast";
|
||||
import { getModelCreatorIconPath } from "../../lib/omni-gen-hooks";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -39,6 +43,7 @@ type InProgressTask = {
|
||||
canDismiss?: boolean;
|
||||
estimatedTimeLeftMs?: number;
|
||||
modelType?: string;
|
||||
prompt?: string;
|
||||
};
|
||||
|
||||
type CompletedTask = {
|
||||
@@ -50,6 +55,7 @@ type CompletedTask = {
|
||||
updatedAt?: Date;
|
||||
imageUrls?: string[];
|
||||
mediaTokens?: string[];
|
||||
prompt?: string;
|
||||
};
|
||||
|
||||
type FailedTask = {
|
||||
@@ -60,6 +66,7 @@ type FailedTask = {
|
||||
status: string;
|
||||
failureReason?: string;
|
||||
failureMessage?: string;
|
||||
prompt?: string;
|
||||
};
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
@@ -106,6 +113,92 @@ const FAILED_STATUSES = new Set([
|
||||
JobStatus.CANCELLED_BY_SYSTEM,
|
||||
]);
|
||||
|
||||
// ── Shared sub-components ─────────────────────────────────────────────────
|
||||
|
||||
const PromptLine = ({
|
||||
prompt,
|
||||
className,
|
||||
}: {
|
||||
prompt: string;
|
||||
className?: string;
|
||||
}) => {
|
||||
const [marqueePlaying, setMarqueePlaying] = useState(false);
|
||||
const [promptOverflows, setPromptOverflows] = useState(false);
|
||||
const promptRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
const el = promptRef.current;
|
||||
if (el) setPromptOverflows(el.scrollWidth > el.clientWidth);
|
||||
}, [prompt]);
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
content={prompt.length > 300 ? prompt.slice(0, 300) + "\u2026" : prompt}
|
||||
position="bottom"
|
||||
strategy="fixed"
|
||||
className="max-w-[280px] text-wrap text-xs"
|
||||
zIndex={50}
|
||||
delay={400}
|
||||
>
|
||||
<div
|
||||
className={twMerge("mt-1 overflow-hidden", className)}
|
||||
onMouseEnter={
|
||||
promptOverflows ? () => setMarqueePlaying(true) : undefined
|
||||
}
|
||||
onMouseLeave={
|
||||
promptOverflows ? () => setMarqueePlaying(false) : undefined
|
||||
}
|
||||
>
|
||||
<div
|
||||
ref={promptRef}
|
||||
key={marqueePlaying ? "playing" : "idle"}
|
||||
className="whitespace-nowrap text-[11px] italic text-base-fg/40"
|
||||
style={
|
||||
marqueePlaying
|
||||
? {
|
||||
animation: "marquee 6.5s linear infinite",
|
||||
animationDelay: "0.5s",
|
||||
animationFillMode: "both",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{prompt}
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
const CopyPromptButton = ({ prompt }: { prompt: string }) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
return (
|
||||
<Tooltip
|
||||
content={copied ? "Copied!" : "Copy prompt"}
|
||||
position="bottom"
|
||||
strategy="fixed"
|
||||
className="text-xs"
|
||||
zIndex={50}
|
||||
delay={300}
|
||||
>
|
||||
<button
|
||||
className="flex h-6 w-6 items-center justify-center rounded-full text-base-fg/60 hover:bg-ui-controls"
|
||||
aria-label="Copy prompt"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigator.clipboard.writeText(prompt);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 3000);
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={copied ? faCheck : faCopy}
|
||||
className={copied ? "text-green-400" : ""}
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Card Components ────────────────────────────────────────────────────────
|
||||
|
||||
const InProgressCard = ({
|
||||
@@ -123,6 +216,10 @@ const InProgressCard = ({
|
||||
? formatTimeLeft(task.estimatedTimeLeftMs)
|
||||
: null;
|
||||
const isSeedance2 = task.modelType === "seedance_2p0";
|
||||
const modelIconPath = task.modelType
|
||||
? getModelCreatorIconPath(task.modelType)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="rounded-md p-2 transition-colors hover:bg-ui-controls/40">
|
||||
<div className="flex items-center gap-2.5">
|
||||
@@ -136,6 +233,13 @@ const InProgressCard = ({
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center gap-1.5 truncate font-medium text-base-fg/90">
|
||||
{modelIconPath && (
|
||||
<img
|
||||
src={modelIconPath}
|
||||
alt=""
|
||||
className="h-3.5 w-3.5 flex-shrink-0 icon-auto-contrast"
|
||||
/>
|
||||
)}
|
||||
{task.title}
|
||||
{isSeedance2 && (
|
||||
<Tooltip
|
||||
@@ -175,19 +279,23 @@ const InProgressCard = ({
|
||||
{timeLabel && (
|
||||
<div className="mt-1 text-xs text-base-fg/50">{timeLabel}</div>
|
||||
)}
|
||||
{task.prompt && <PromptLine prompt={task.prompt} className="mt-0" />}
|
||||
</div>
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1">
|
||||
{task.prompt && <CopyPromptButton prompt={task.prompt} />}
|
||||
{onDismiss && (
|
||||
<button
|
||||
className="flex h-6 w-6 items-center justify-center rounded-full text-base-fg/60 hover:bg-ui-controls"
|
||||
aria-label="Dismiss"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDismiss();
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faXmark} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{onDismiss && (
|
||||
<button
|
||||
className="ml-auto h-6 w-6 rounded-full p-1 text-base-fg/60 hover:bg-ui-controls"
|
||||
aria-label="Dismiss"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDismiss();
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faXmark} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -225,7 +333,7 @@ const CompletedCard = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium text-base-fg/90">
|
||||
{task.title}
|
||||
</div>
|
||||
@@ -239,19 +347,23 @@ const CompletedCard = ({
|
||||
{dayjs(task.completedAt).format("MMM D, h:mm A")}
|
||||
</div>
|
||||
)}
|
||||
{task.prompt && <PromptLine prompt={task.prompt} />}
|
||||
</div>
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1">
|
||||
{task.prompt && <CopyPromptButton prompt={task.prompt} />}
|
||||
{onDismiss && (
|
||||
<button
|
||||
className="flex h-6 w-6 items-center justify-center rounded-full text-base-fg/60 hover:bg-ui-controls"
|
||||
aria-label="Dismiss"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDismiss();
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faXmark} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{onDismiss && (
|
||||
<button
|
||||
className="ml-auto h-6 w-6 rounded-full p-1 text-base-fg/60 hover:bg-ui-controls"
|
||||
aria-label="Dismiss"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDismiss();
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faXmark} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -285,57 +397,62 @@ const FailedCard = ({
|
||||
{task.subtitle}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-1 flex items-center gap-1.5">
|
||||
<span className="rounded bg-red-500/15 px-1.5 py-0 text-[11px] font-medium text-red-400">
|
||||
<div className="mt-1 flex min-w-0 items-center gap-1.5 overflow-hidden">
|
||||
<span className="shrink-0 rounded bg-red-500/15 px-1.5 py-0 text-[11px] font-medium text-red-400">
|
||||
{statusLabel}
|
||||
</span>
|
||||
{task.failureReason && (
|
||||
<Tooltip
|
||||
content={task.failureReason}
|
||||
position="bottom"
|
||||
strategy="fixed"
|
||||
className="max-w-[280px] text-wrap bg-red-500/90 text-xs"
|
||||
zIndex={50}
|
||||
delay={300}
|
||||
>
|
||||
<span className="truncate text-[11px] text-red-400/60">
|
||||
{task.failureReason}
|
||||
</span>
|
||||
</Tooltip>
|
||||
<div className="min-w-0 overflow-hidden">
|
||||
<Tooltip
|
||||
content={
|
||||
task.failureMessage ? (
|
||||
<div>
|
||||
<div className="font-semibold">
|
||||
{task.failureReason}
|
||||
</div>
|
||||
<div className="mt-0.5 font-normal opacity-80">
|
||||
{task.failureMessage}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
task.failureReason
|
||||
)
|
||||
}
|
||||
position="bottom"
|
||||
strategy="fixed"
|
||||
className="max-w-[280px] text-wrap bg-danger text-xs"
|
||||
zIndex={50}
|
||||
delay={300}
|
||||
>
|
||||
<div className="cursor-default truncate text-[11px] text-red-400/80 underline decoration-red-400/30 decoration-dashed underline-offset-2">
|
||||
{task.failureReason}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{task.failureMessage && (
|
||||
<Tooltip
|
||||
content={task.failureMessage}
|
||||
position="bottom"
|
||||
strategy="fixed"
|
||||
className="max-w-[280px] text-wrap text-xs"
|
||||
zIndex={50}
|
||||
delay={200}
|
||||
>
|
||||
<div className="mt-0.5 cursor-default truncate text-[11px] text-base-fg/40">
|
||||
{task.failureMessage}
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
{task.prompt && <PromptLine prompt={task.prompt} />}
|
||||
{task.failedAt && (
|
||||
<div className="mt-0.5 text-[11px] text-base-fg/40">
|
||||
{dayjs(task.failedAt).format("MMM D, h:mm A")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{onDismiss && (
|
||||
<button
|
||||
className="ml-auto h-6 w-6 rounded-full p-1 text-base-fg/60 hover:bg-ui-controls"
|
||||
aria-label="Dismiss"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDismiss();
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faXmark} />
|
||||
</button>
|
||||
)}
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1">
|
||||
{task.prompt && <CopyPromptButton prompt={task.prompt} />}
|
||||
{onDismiss && (
|
||||
<button
|
||||
className="flex h-6 w-6 items-center justify-center rounded-full text-base-fg/60 hover:bg-ui-controls"
|
||||
aria-label="Dismiss"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDismiss();
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faXmark} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -343,6 +460,10 @@ const FailedCard = ({
|
||||
|
||||
// ── Job → Task transformers ────────────────────────────────────────────────
|
||||
|
||||
function getPrompt(job: Job): string {
|
||||
return job.request.maybe_raw_inference_text || "";
|
||||
}
|
||||
|
||||
function formatTitleParts(job: Job) {
|
||||
const taskTypeStr = job.request.inference_category?.toLowerCase() ?? "";
|
||||
const modelTypeStr = job.request.maybe_model_type ?? "";
|
||||
@@ -418,6 +539,7 @@ function jobsToInProgress(jobs: Job[]): InProgressTask[] {
|
||||
canDismiss,
|
||||
estimatedTimeLeftMs,
|
||||
modelType: modelType ?? undefined,
|
||||
prompt: getPrompt(j) || undefined,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -453,6 +575,7 @@ function jobsToCompleted(jobs: Job[]): CompletedTask[] {
|
||||
mediaTokens: j.maybe_result?.entity_token
|
||||
? [j.maybe_result.entity_token]
|
||||
: [],
|
||||
prompt: getPrompt(j) || undefined,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -473,8 +596,7 @@ function jobsToFailed(jobs: Job[]): FailedTask[] {
|
||||
undefined
|
||||
: j.status.maybe_extra_status_description || undefined;
|
||||
const failureMessage =
|
||||
j.status.maybe_extra_status_description &&
|
||||
failureCategory !== "unknown"
|
||||
j.status.maybe_extra_status_description && failureCategory !== "unknown"
|
||||
? j.status.maybe_extra_status_description
|
||||
: undefined;
|
||||
|
||||
@@ -486,6 +608,7 @@ function jobsToFailed(jobs: Job[]): FailedTask[] {
|
||||
status: j.status.status,
|
||||
failureReason,
|
||||
failureMessage,
|
||||
prompt: getPrompt(j) || undefined,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -502,7 +625,9 @@ export const TaskQueue = () => {
|
||||
|
||||
// Lightbox state
|
||||
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||||
const [lightboxMediaToken, setLightboxMediaToken] = useState<string | undefined>();
|
||||
const [lightboxMediaToken, setLightboxMediaToken] = useState<
|
||||
string | undefined
|
||||
>();
|
||||
const [lightboxCdnUrl, setLightboxCdnUrl] = useState<string | undefined>();
|
||||
const prevCompletedIdsRef = useRef<Set<string>>(new Set());
|
||||
const prevFailedIdsRef = useRef<Set<string>>(new Set());
|
||||
@@ -795,9 +920,7 @@ export const TaskQueue = () => {
|
||||
<InProgressCard
|
||||
key={t.id}
|
||||
task={t}
|
||||
onDismiss={
|
||||
t.canDismiss ? () => dismissTask(t.id) : undefined
|
||||
}
|
||||
onDismiss={t.canDismiss ? () => dismissTask(t.id) : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -868,7 +991,7 @@ export const TaskQueue = () => {
|
||||
return (
|
||||
<>
|
||||
<Tooltip content="Task Queue" position="bottom" closeOnClick={true}>
|
||||
<div className="relative">
|
||||
<div className="relative task-queue-trigger">
|
||||
{badgeCount > 0 && (
|
||||
<div className="absolute -right-1 -top-1 z-20 flex h-[17px] w-[17px] items-center justify-center rounded-full bg-primary-400 text-[13px] font-medium text-white">
|
||||
{badgeCount}
|
||||
@@ -877,7 +1000,7 @@ export const TaskQueue = () => {
|
||||
<PopoverMenu
|
||||
mode="default"
|
||||
buttonClassName="h-[38px] w-[38px] !p-0 relative"
|
||||
panelClassName="w-[360px] p-2 bg-ui-panel mt-2.5"
|
||||
panelClassName="w-[calc(100vw-5rem)] sm:w-[400px] p-2 bg-ui-panel mt-2.5"
|
||||
position="bottom"
|
||||
align="end"
|
||||
triggerIcon={
|
||||
@@ -994,6 +1117,17 @@ export const TaskQueue = () => {
|
||||
mediaToken={lightboxMediaToken}
|
||||
cdnUrl={lightboxCdnUrl}
|
||||
/>
|
||||
|
||||
{/* On mobile, make the PopoverMenu's wrapper static so the absolute
|
||||
panel positions against the fixed navbar (full width), then pin
|
||||
the panel to viewport edges with left/right insets. */}
|
||||
<style>{`
|
||||
@media (max-width: 639px) {
|
||||
.task-queue-trigger .relative.inline-block {
|
||||
position: static !important;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,915 @@
|
||||
import { useState, useRef, useCallback, useEffect } from "react";
|
||||
import { Modal } from "@storyteller/ui-modal";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
faPlus,
|
||||
faArrowLeft,
|
||||
faUpload,
|
||||
faUserGroup,
|
||||
faSpinnerThird,
|
||||
faXmark,
|
||||
faPen,
|
||||
faTrashAlt,
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import {
|
||||
CharactersApi,
|
||||
Character,
|
||||
MediaUploadApi,
|
||||
} from "@storyteller/api";
|
||||
import { toast } from "@storyteller/ui-toaster";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import { useCharactersStore } from "./characters-store";
|
||||
|
||||
interface CharactersModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSelectCharacter?: (character: Character) => void;
|
||||
}
|
||||
|
||||
type ModalView = "list" | "create" | "edit";
|
||||
|
||||
interface UploadedImage {
|
||||
file: File;
|
||||
url: string;
|
||||
mediaToken?: string;
|
||||
}
|
||||
|
||||
interface PendingCharacter {
|
||||
name: string;
|
||||
previewUrl?: string;
|
||||
}
|
||||
|
||||
const POLL_INTERVAL_MS = 5000;
|
||||
|
||||
export const CharactersModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSelectCharacter,
|
||||
}: CharactersModalProps) => {
|
||||
const [view, setView] = useState<ModalView>("list");
|
||||
const [editingCharacter, setEditingCharacter] = useState<Character | null>(
|
||||
null,
|
||||
);
|
||||
const [pendingCharacters, setPendingCharacters] = useState<
|
||||
PendingCharacter[]
|
||||
>([]);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
|
||||
const handleClose = () => {
|
||||
setView("list");
|
||||
setEditingCharacter(null);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleEdit = (character: Character) => {
|
||||
setEditingCharacter(character);
|
||||
setView("edit");
|
||||
};
|
||||
|
||||
const handleEditDone = () => {
|
||||
setEditingCharacter(null);
|
||||
setView("list");
|
||||
setRefreshKey((k) => k + 1);
|
||||
};
|
||||
|
||||
const handleCreated = (pending: PendingCharacter) => {
|
||||
setPendingCharacters((prev) => [pending, ...prev]);
|
||||
setView("list");
|
||||
setRefreshKey((k) => k + 1);
|
||||
};
|
||||
|
||||
const removePending = useCallback((name: string) => {
|
||||
setPendingCharacters((prev) => prev.filter((p) => p.name !== name));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={handleClose}
|
||||
title={view === "list" ? "Characters" : undefined}
|
||||
className="max-w-[800px] h-[600px] max-h-[80vh] overflow-hidden"
|
||||
>
|
||||
<div
|
||||
className="overflow-y-auto"
|
||||
style={{
|
||||
maxHeight:
|
||||
view === "list"
|
||||
? "min(calc(600px - 5rem), calc(80vh - 5rem))"
|
||||
: "min(calc(600px - 2.5rem), calc(80vh - 2.5rem))",
|
||||
}}
|
||||
>
|
||||
{view === "list" ? (
|
||||
<CharacterListView
|
||||
key={refreshKey}
|
||||
onCreateClick={() => setView("create")}
|
||||
onSelectCharacter={onSelectCharacter}
|
||||
onEditCharacter={handleEdit}
|
||||
pendingCharacters={pendingCharacters}
|
||||
onPendingResolved={removePending}
|
||||
/>
|
||||
) : view === "create" ? (
|
||||
<NewCharacterView
|
||||
onBack={() => setView("list")}
|
||||
onCreated={handleCreated}
|
||||
/>
|
||||
) : editingCharacter ? (
|
||||
<EditCharacterView
|
||||
character={editingCharacter}
|
||||
onBack={() => {
|
||||
setEditingCharacter(null);
|
||||
setView("list");
|
||||
}}
|
||||
onSaved={handleEditDone}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Character List View
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const CharacterListView = ({
|
||||
onCreateClick,
|
||||
onSelectCharacter,
|
||||
onEditCharacter,
|
||||
pendingCharacters,
|
||||
onPendingResolved,
|
||||
}: {
|
||||
onCreateClick: () => void;
|
||||
onSelectCharacter?: (character: Character) => void;
|
||||
onEditCharacter: (character: Character) => void;
|
||||
pendingCharacters: PendingCharacter[];
|
||||
onPendingResolved: (name: string) => void;
|
||||
}) => {
|
||||
const [characters, setCharacters] = useState<Character[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [cursor, setCursor] = useState<number | undefined>(undefined);
|
||||
const [confirmDelete, setConfirmDelete] = useState<Character | null>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||
const loadingMoreRef = useRef(false);
|
||||
const storeSetCharacters = useCharactersStore((s) => s.setCharacters);
|
||||
const storeSetLoaded = useCharactersStore((s) => s.setLoaded);
|
||||
const storeRemoveCharacter = useCharactersStore((s) => s.removeCharacter);
|
||||
|
||||
const syncToStore = useCallback(
|
||||
(chars: Character[]) => {
|
||||
storeSetCharacters(
|
||||
chars.map((c) => ({
|
||||
character_token: c.token,
|
||||
name: c.name,
|
||||
avatar_image_url: c.maybe_avatar?.cdn_url,
|
||||
})),
|
||||
);
|
||||
storeSetLoaded(true);
|
||||
},
|
||||
[storeSetCharacters, storeSetLoaded],
|
||||
);
|
||||
|
||||
const fetchCharacters = useCallback(
|
||||
async (nextCursor?: number) => {
|
||||
if (loadingMoreRef.current) return;
|
||||
loadingMoreRef.current = true;
|
||||
|
||||
try {
|
||||
const api = new CharactersApi();
|
||||
const res = await api.ListCharacters({ cursor: nextCursor });
|
||||
|
||||
if (res.success && res.data) {
|
||||
setCharacters((prev) => {
|
||||
const updated = nextCursor ? [...prev, ...res.data!] : res.data!;
|
||||
syncToStore(updated);
|
||||
return updated;
|
||||
});
|
||||
const nextPage = res.pagination?.next_cursor;
|
||||
setCursor(nextPage ?? undefined);
|
||||
setHasMore(!!nextPage);
|
||||
}
|
||||
} catch {
|
||||
storeSetLoaded(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
loadingMoreRef.current = false;
|
||||
}
|
||||
},
|
||||
[syncToStore, storeSetLoaded],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCharacters();
|
||||
}, [fetchCharacters]);
|
||||
|
||||
// Poll for pending characters becoming active
|
||||
useEffect(() => {
|
||||
if (pendingCharacters.length === 0) return;
|
||||
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const api = new CharactersApi();
|
||||
const res = await api.ListCharacters({});
|
||||
if (res.success && res.data) {
|
||||
const serverNames = new Set(res.data.map((c) => c.name));
|
||||
for (const pending of pendingCharacters) {
|
||||
if (serverNames.has(pending.name)) {
|
||||
onPendingResolved(pending.name);
|
||||
}
|
||||
}
|
||||
setCharacters(res.data);
|
||||
syncToStore(res.data);
|
||||
}
|
||||
} catch {
|
||||
// Silently retry on next interval
|
||||
}
|
||||
}, POLL_INTERVAL_MS);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [pendingCharacters, onPendingResolved, syncToStore]);
|
||||
|
||||
// Infinite scroll via IntersectionObserver
|
||||
useEffect(() => {
|
||||
if (!sentinelRef.current || !hasMore) return;
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0]?.isIntersecting && hasMore && cursor) {
|
||||
fetchCharacters(cursor);
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 },
|
||||
);
|
||||
observer.observe(sentinelRef.current);
|
||||
return () => observer.disconnect();
|
||||
}, [hasMore, cursor, fetchCharacters]);
|
||||
|
||||
const handleDelete = async (character: Character) => {
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
const api = new CharactersApi();
|
||||
const res = await api.DeleteCharacter({
|
||||
characterToken: character.token,
|
||||
});
|
||||
|
||||
if (res.success) {
|
||||
setCharacters((prev) =>
|
||||
prev.filter((c) => c.token !== character.token),
|
||||
);
|
||||
storeRemoveCharacter(character.token);
|
||||
toast.success(`Character "${character.name}" deleted`);
|
||||
} else {
|
||||
toast.error(res.errorMessage || "Failed to delete character");
|
||||
}
|
||||
} catch {
|
||||
toast.error("Failed to delete character");
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
setConfirmDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{loading && characters.length === 0 ? (
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex flex-col overflow-hidden rounded-lg border border-transparent bg-white/[0.05]"
|
||||
>
|
||||
<div className="aspect-square w-full overflow-hidden">
|
||||
<div
|
||||
className="h-full w-full bg-white/[0.06]"
|
||||
style={{
|
||||
animation: `charPulse 1.8s ease-in-out ${i * 0.07}s infinite`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="px-2 py-1.5 flex justify-center bg-white/[0.04]">
|
||||
<div
|
||||
className="h-3 w-2/3 rounded bg-white/[0.08]"
|
||||
style={{
|
||||
animation: `charPulse 1.8s ease-in-out ${i * 0.07 + 0.1}s infinite`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<style>{`
|
||||
@keyframes charPulse {
|
||||
0%, 100% { opacity: 0.4; }
|
||||
50% { opacity: 0.8; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{/* Create New card */}
|
||||
<button
|
||||
onClick={onCreateClick}
|
||||
className="flex flex-col items-center justify-center gap-2 overflow-hidden rounded-lg border-2 border-dashed border-white/10 bg-white/[0.05] text-white/60 transition-colors hover:border-white/25 hover:text-white/80"
|
||||
>
|
||||
<div className="flex aspect-square w-full flex-col items-center justify-center gap-2">
|
||||
<FontAwesomeIcon icon={faPlus} className="text-lg" />
|
||||
<span className="text-sm font-medium">Create New</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Pending (creating) characters */}
|
||||
{pendingCharacters.map((pending) => (
|
||||
<div
|
||||
key={`pending-${pending.name}`}
|
||||
className="relative flex flex-col overflow-hidden rounded-lg border border-transparent bg-white/[0.05]"
|
||||
>
|
||||
<div className="aspect-square w-full overflow-hidden bg-white/[0.05]">
|
||||
{pending.previewUrl ? (
|
||||
<img
|
||||
src={pending.previewUrl}
|
||||
alt={pending.name}
|
||||
className="h-full w-full object-cover object-top opacity-50"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-white/20">
|
||||
<FontAwesomeIcon icon={faUserGroup} className="text-2xl" />
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2 bg-black/40">
|
||||
<FontAwesomeIcon
|
||||
icon={faSpinnerThird}
|
||||
className="text-lg text-white/80 animate-spin"
|
||||
/>
|
||||
<span className="text-xs font-medium text-white/80">
|
||||
Creating...
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-2 py-1.5 text-center">
|
||||
<p className="truncate text-xs font-medium text-white/50">
|
||||
{pending.name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{characters.map((character) => {
|
||||
const isUserCreated = character.is_user_created !== false;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={character.token}
|
||||
className="group relative flex flex-col overflow-hidden rounded-lg border border-transparent bg-white/[0.05] transition-colors hover:border-white/25 hover:bg-white/10"
|
||||
>
|
||||
<button
|
||||
onClick={() => onSelectCharacter?.(character)}
|
||||
className="flex flex-1 flex-col"
|
||||
>
|
||||
<div className="aspect-square w-full overflow-hidden bg-white/[0.05]">
|
||||
{character.maybe_avatar?.cdn_url ? (
|
||||
<img
|
||||
src={character.maybe_avatar.cdn_url}
|
||||
alt={character.name}
|
||||
className="h-full w-full object-cover object-top"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-white/20">
|
||||
<FontAwesomeIcon
|
||||
icon={faUserGroup}
|
||||
className="text-2xl"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-2 py-1.5">
|
||||
<p className="truncate text-xs font-medium text-white/80">
|
||||
{character.name}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Edit / Delete overlay buttons (user-created only) */}
|
||||
{isUserCreated && (
|
||||
<div className="absolute right-1.5 top-1.5 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEditCharacter(character);
|
||||
}}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-full bg-black/60 text-white/80 transition-colors hover:bg-black/80"
|
||||
>
|
||||
<FontAwesomeIcon icon={faPen} className="text-[10px]" />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setConfirmDelete(character);
|
||||
}}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-full bg-black/60 text-white/80 transition-colors hover:bg-red-500"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={faTrashAlt}
|
||||
className="text-[10px]"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sentinel for infinite scroll */}
|
||||
{hasMore && (
|
||||
<div ref={sentinelRef} className="flex justify-center py-4">
|
||||
<FontAwesomeIcon
|
||||
icon={faSpinnerThird}
|
||||
className="text-white/30 animate-spin"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete confirmation overlay */}
|
||||
{confirmDelete && (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||
<div className="mx-4 w-full max-w-sm rounded-xl border border-white/10 bg-[#1e1e22] p-6 shadow-2xl">
|
||||
<h3 className="mb-2 text-lg font-semibold text-white">
|
||||
Delete character?
|
||||
</h3>
|
||||
<p className="mb-5 text-sm text-white/70">
|
||||
This will permanently delete{" "}
|
||||
<strong>{confirmDelete.name}</strong>. This action cannot be
|
||||
undone.
|
||||
</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="border-none"
|
||||
onClick={() => setConfirmDelete(null)}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
className="bg-red-500 hover:bg-red-600"
|
||||
onClick={() => handleDelete(confirmDelete)}
|
||||
loading={isDeleting}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Edit Character View
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const EditCharacterView = ({
|
||||
character,
|
||||
onBack,
|
||||
onSaved,
|
||||
}: {
|
||||
character: Character;
|
||||
onBack: () => void;
|
||||
onSaved: () => void;
|
||||
}) => {
|
||||
const updateCharacterInStore = useCharactersStore((s) => s.updateCharacter);
|
||||
const [name, setName] = useState(character.name);
|
||||
const [description, setDescription] = useState(
|
||||
character.maybe_description ?? "",
|
||||
);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const hasChanges =
|
||||
name.trim() !== character.name ||
|
||||
(description.trim() || "") !== (character.maybe_description ?? "");
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!name.trim()) {
|
||||
toast.error("Name cannot be empty");
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const api = new CharactersApi();
|
||||
const res = await api.EditCharacter({
|
||||
token: character.token,
|
||||
updated_name: name.trim() !== character.name ? name.trim() : undefined,
|
||||
updated_description:
|
||||
(description.trim() || "") !== (character.maybe_description ?? "")
|
||||
? description.trim() || null
|
||||
: undefined,
|
||||
});
|
||||
|
||||
if (res.success) {
|
||||
toast.success("Character updated");
|
||||
updateCharacterInStore(character.token, { name: name.trim() });
|
||||
onSaved();
|
||||
} else {
|
||||
toast.error(res.errorMessage || "Failed to update character");
|
||||
}
|
||||
} catch {
|
||||
toast.error("Failed to update character");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 pb-0">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="flex items-center justify-center text-white/60 transition-colors hover:text-white"
|
||||
>
|
||||
<FontAwesomeIcon icon={faArrowLeft} />
|
||||
</button>
|
||||
<h2 className="text-xl font-bold text-white">Edit Character</h2>
|
||||
</div>
|
||||
|
||||
{/* Avatar preview */}
|
||||
{character.maybe_avatar?.cdn_url && (
|
||||
<div className="flex h-56 max-h-56 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-white/[0.05]">
|
||||
<img
|
||||
src={character.maybe_avatar.cdn_url}
|
||||
alt={character.name}
|
||||
className="max-h-full max-w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Name input */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="edit-character-name" className="text-sm font-medium text-white/80">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
id="edit-character-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Character name"
|
||||
autoComplete="off"
|
||||
className="w-full rounded-lg border border-white/10 bg-white/[0.07] px-3 py-2 text-sm text-white placeholder-white/50 outline-none transition-colors focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description input */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="edit-character-description" className="text-sm font-medium text-white/80">
|
||||
Description <span className="font-normal text-white/40">(optional)</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="edit-character-description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Description..."
|
||||
rows={3}
|
||||
autoComplete="off"
|
||||
className="w-full resize-none rounded-lg border border-white/10 bg-white/[0.07] px-3 py-2 text-sm text-white placeholder-white/50 outline-none transition-colors focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<Button variant="secondary" className="border-none" onClick={onBack}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleSave}
|
||||
loading={saving}
|
||||
disabled={saving || !name.trim() || !hasChanges}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// New Character View
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const NewCharacterView = ({
|
||||
onBack,
|
||||
onCreated,
|
||||
}: {
|
||||
onBack: () => void;
|
||||
onCreated: (pending: PendingCharacter) => void;
|
||||
}) => {
|
||||
const addCharacterToStore = useCharactersStore((s) => s.addCharacter);
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [images, setImages] = useState<UploadedImage[]>([]);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const dropZoneRef = useRef<HTMLDivElement>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
const processFiles = useCallback(
|
||||
async (files: FileList | File[]) => {
|
||||
const imageFiles = Array.from(files).filter((f) =>
|
||||
f.type.startsWith("image/"),
|
||||
);
|
||||
if (imageFiles.length === 0) {
|
||||
toast.error("Please upload image files");
|
||||
return;
|
||||
}
|
||||
|
||||
const file = imageFiles[0]!;
|
||||
const newImages: UploadedImage[] = [
|
||||
{ file, url: URL.createObjectURL(file) },
|
||||
];
|
||||
|
||||
setImages((prev) => {
|
||||
prev.forEach((img) => URL.revokeObjectURL(img.url));
|
||||
return newImages;
|
||||
});
|
||||
|
||||
setUploading(true);
|
||||
const uploadApi = new MediaUploadApi();
|
||||
const updatedImages: UploadedImage[] = [];
|
||||
|
||||
for (const img of newImages) {
|
||||
try {
|
||||
const res = await uploadApi.UploadImage({
|
||||
uuid: uuidv4(),
|
||||
blob: img.file,
|
||||
fileName: img.file.name,
|
||||
maybe_title: `character_ref_${name || "unnamed"}`,
|
||||
});
|
||||
|
||||
if (res.success && res.data) {
|
||||
updatedImages.push({ ...img, mediaToken: res.data });
|
||||
} else {
|
||||
toast.error(`Failed to upload ${img.file.name}`);
|
||||
updatedImages.push(img);
|
||||
}
|
||||
} catch {
|
||||
toast.error(`Failed to upload ${img.file.name}`);
|
||||
updatedImages.push(img);
|
||||
}
|
||||
}
|
||||
|
||||
setImages((prev) =>
|
||||
prev.map((existing) => {
|
||||
const updated = updatedImages.find((u) => u.url === existing.url);
|
||||
return updated || existing;
|
||||
}),
|
||||
);
|
||||
setUploading(false);
|
||||
},
|
||||
[name],
|
||||
);
|
||||
|
||||
const handleDragEnter = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(true);
|
||||
}, []);
|
||||
|
||||
const handleDragOver = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!isDragging) setIsDragging(true);
|
||||
if (e.dataTransfer) e.dataTransfer.dropEffect = "copy";
|
||||
},
|
||||
[isDragging],
|
||||
);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const rect = (e.currentTarget as HTMLDivElement).getBoundingClientRect();
|
||||
const { clientX: x, clientY: y } = e;
|
||||
if (x < rect.left || x >= rect.right || y < rect.top || y >= rect.bottom) {
|
||||
setIsDragging(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
if (e.dataTransfer.files?.length) {
|
||||
processFiles(e.dataTransfer.files);
|
||||
}
|
||||
},
|
||||
[processFiles],
|
||||
);
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files?.length) {
|
||||
processFiles(e.target.files);
|
||||
}
|
||||
};
|
||||
|
||||
const removeImage = (index: number) => {
|
||||
setImages((prev) => {
|
||||
const removed = prev[index];
|
||||
if (removed) URL.revokeObjectURL(removed.url);
|
||||
return prev.filter((_, i) => i !== index);
|
||||
});
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!name.trim()) {
|
||||
toast.error("Please enter a character name");
|
||||
return;
|
||||
}
|
||||
|
||||
const uploadedImages = images.filter((img) => img.mediaToken);
|
||||
if (uploadedImages.length < 1) {
|
||||
toast.error("Please upload a reference image");
|
||||
return;
|
||||
}
|
||||
|
||||
setCreating(true);
|
||||
try {
|
||||
const api = new CharactersApi();
|
||||
const res = await api.CreateCharacter({
|
||||
image_media_token: uploadedImages[0]!.mediaToken!,
|
||||
model: "seedance_2p0",
|
||||
uuid_idempotency_token: uuidv4(),
|
||||
character_name: name.trim(),
|
||||
character_description: description.trim() || null,
|
||||
});
|
||||
|
||||
if (res.success && res.data) {
|
||||
toast.success(`Character "${name.trim()}" is being created`);
|
||||
addCharacterToStore({
|
||||
character_token: res.data.inference_job_token,
|
||||
name: name.trim(),
|
||||
avatar_image_url: uploadedImages[0]!.url,
|
||||
});
|
||||
onCreated({
|
||||
name: name.trim(),
|
||||
previewUrl: uploadedImages[0]!.url,
|
||||
});
|
||||
} else {
|
||||
toast.error(res.errorMessage || "Failed to create character");
|
||||
}
|
||||
} catch {
|
||||
toast.error("Failed to create character");
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Cleanup object URLs on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
images.forEach((img) => URL.revokeObjectURL(img.url));
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Header with back button */}
|
||||
<div className="flex items-center gap-3 pb-0">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="flex items-center justify-center text-white/60 transition-colors hover:text-white"
|
||||
>
|
||||
<FontAwesomeIcon icon={faArrowLeft} />
|
||||
</button>
|
||||
<h2 className="text-xl font-bold text-white">New Character</h2>
|
||||
</div>
|
||||
|
||||
{/* Image upload area */}
|
||||
<div
|
||||
ref={dropZoneRef}
|
||||
className={twMerge(
|
||||
"flex h-56 max-h-56 shrink-0 cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed border-white/20 bg-white/[0.05] transition-colors overflow-hidden",
|
||||
isDragging && "border-blue-400 bg-blue-500/10",
|
||||
)}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
{images.length > 0 ? (
|
||||
<div
|
||||
className="group relative flex h-full w-full items-center justify-center"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<img
|
||||
src={images[0]!.url}
|
||||
alt="Reference"
|
||||
className="max-h-full max-w-full object-contain"
|
||||
/>
|
||||
{!images[0]!.mediaToken && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/50">
|
||||
<FontAwesomeIcon
|
||||
icon={faSpinnerThird}
|
||||
className="text-white animate-spin"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeImage(0);
|
||||
}}
|
||||
className="absolute right-2 top-2 flex h-7 w-7 items-center justify-center rounded-full bg-black/60 text-white/80 opacity-0 transition-all group-hover:opacity-100 hover:bg-red-500"
|
||||
>
|
||||
<FontAwesomeIcon icon={faXmark} className="text-sm" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center text-white/60">
|
||||
<FontAwesomeIcon
|
||||
icon={faUpload}
|
||||
className="mb-2 text-xl text-white/40"
|
||||
/>
|
||||
<p className="text-sm">Upload reference image</p>
|
||||
<p className="mb-3 text-xs text-white/40">
|
||||
Click or drag an image here
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
{/* Name input */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="character-name" className="text-sm font-medium text-white/80">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
id="character-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Character name"
|
||||
autoComplete="off"
|
||||
className="w-full rounded-lg border border-white/10 bg-white/[0.07] px-3 py-2 text-sm text-white placeholder-white/50 outline-none transition-colors focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description input */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="character-description" className="text-sm font-medium text-white/80">
|
||||
Description <span className="font-normal text-white/40">(optional)</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="character-description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Description..."
|
||||
rows={3}
|
||||
autoComplete="off"
|
||||
className="w-full resize-none rounded-lg border border-white/10 bg-white/[0.07] px-3 py-2 text-sm text-white placeholder-white/50 outline-none transition-colors focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<Button variant="secondary" className="border-none" onClick={onBack}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleCreate}
|
||||
loading={creating}
|
||||
disabled={
|
||||
creating ||
|
||||
uploading ||
|
||||
!name.trim() ||
|
||||
images.filter((i) => i.mediaToken).length < 1
|
||||
}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -158,7 +158,7 @@ export const ImagePickerModal = ({
|
||||
className="max-w-7xl"
|
||||
>
|
||||
<div className="flex flex-col" style={{ height: "min(80vh, 800px)" }}>
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto p-4">
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto">
|
||||
{images.length === 0 && loading ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<FontAwesomeIcon
|
||||
@@ -171,7 +171,7 @@ export const ImagePickerModal = ({
|
||||
No images found
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-3 gap-2 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-7">
|
||||
<div className="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 pt-1">
|
||||
{images.map((img) => {
|
||||
const isSelected = selected.has(img.token);
|
||||
return (
|
||||
@@ -213,7 +213,7 @@ export const ImagePickerModal = ({
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between border-t border-white/10 px-4 py-3">
|
||||
<div className="flex items-center justify-between pt-3">
|
||||
<span className="text-sm text-white/50">
|
||||
{selected.size} of {maxSelect} selected
|
||||
</span>
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
faPlus,
|
||||
faSpinner,
|
||||
faSpinnerThird,
|
||||
faTrashAlt,
|
||||
faXmark,
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import { faImage } from "@fortawesome/pro-regular-svg-icons";
|
||||
@@ -44,7 +43,7 @@ interface ImagePromptRowProps {
|
||||
referenceImages: RefImage[];
|
||||
setReferenceImages: (images: RefImage[]) => void;
|
||||
onPickFromLibrary?: () => void;
|
||||
onClearAll?: () => void;
|
||||
onClearAll?: () => void; // unused, kept for API compat
|
||||
className?: string;
|
||||
|
||||
// Video mode props
|
||||
@@ -53,6 +52,7 @@ interface ImagePromptRowProps {
|
||||
endFrameImage?: RefImage;
|
||||
setEndFrameImage?: (image?: RefImage) => void;
|
||||
showEndFrameSection?: boolean;
|
||||
onPickEndFrameFromLibrary?: () => void;
|
||||
}
|
||||
|
||||
export const ImagePromptRow = ({
|
||||
@@ -67,6 +67,7 @@ export const ImagePromptRow = ({
|
||||
endFrameImage,
|
||||
setEndFrameImage,
|
||||
showEndFrameSection,
|
||||
onPickEndFrameFromLibrary,
|
||||
}: ImagePromptRowProps) => {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const endFrameInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -227,13 +228,16 @@ export const ImagePromptRow = ({
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={twMerge("glass flex rounded-t-xl", className)}
|
||||
className={twMerge(
|
||||
"glass flex flex-col sm:flex-row rounded-t-xl",
|
||||
className,
|
||||
)}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 gap-2 px-3 py-2">
|
||||
<div className="flex grow flex-col gap-1">
|
||||
<div className="flex grow flex-col gap-1 min-w-32">
|
||||
<div className="flex items-center gap-2 text-white/90">
|
||||
<FontAwesomeIcon icon={faImage} className="h-3.5 w-3.5" />
|
||||
<span className="flex items-center gap-1.5 text-sm font-medium">
|
||||
@@ -248,7 +252,7 @@ export const ImagePromptRow = ({
|
||||
<span className="text-[13px] text-white/60">{sectionSubtitle}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{allowReorder ? (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
@@ -325,7 +329,7 @@ export const ImagePromptRow = ({
|
||||
>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="flex aspect-square w-14 items-center justify-center overflow-hidden rounded-lg border-2 border-dashed border-white/25 bg-white/5 transition-all hover:border-white/40 hover:bg-white/10"
|
||||
className="flex aspect-square w-10 sm:w-14 items-center justify-center overflow-hidden rounded-lg border-2 border-dashed border-white/25 bg-white/5 transition-all hover:border-white/40 hover:bg-white/10"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={faPlus}
|
||||
@@ -336,7 +340,7 @@ export const ImagePromptRow = ({
|
||||
) : (
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="flex aspect-square w-14 items-center justify-center overflow-hidden rounded-lg border-2 border-dashed border-white/25 bg-white/5 transition-all hover:border-white/40 hover:bg-white/10"
|
||||
className="flex aspect-square w-10 sm:w-14 items-center justify-center overflow-hidden rounded-lg border-2 border-dashed border-white/25 bg-white/5 transition-all hover:border-white/40 hover:bg-white/10"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={faPlus}
|
||||
@@ -349,10 +353,10 @@ export const ImagePromptRow = ({
|
||||
|
||||
{/* End frame section */}
|
||||
{isVideo && showEndFrameSection && (
|
||||
<div className="flex min-w-0 flex-1 items-stretch gap-3 pe-3">
|
||||
<div className="flex min-w-0 flex-1 items-stretch gap-2 px-3 py-2 sm:py-0 sm:pe-3 sm:ps-0 border-t sm:border-t-0 border-white/10">
|
||||
<div className="flex grow gap-1">
|
||||
<div className="w-[1px] bg-white/10" />
|
||||
<div className="flex grow flex-col gap-1 p-2">
|
||||
<div className="hidden sm:block w-[1px] bg-white/10" />
|
||||
<div className="flex grow flex-col gap-1 sm:p-2">
|
||||
<div className="flex items-center gap-2 text-white/90">
|
||||
<FontAwesomeIcon icon={faImage} className="h-3.5 w-3.5" />
|
||||
<span className="flex items-center gap-1.5 text-sm font-medium">
|
||||
@@ -367,7 +371,7 @@ export const ImagePromptRow = ({
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{endFrameImage ? (
|
||||
<div className="group relative aspect-square w-14 overflow-hidden rounded-lg border-2 border-white/30 transition-all hover:border-white/80">
|
||||
<div className="group relative aspect-square w-10 sm:w-14 overflow-hidden rounded-lg border-2 border-white/30 transition-all hover:border-white/80">
|
||||
<img
|
||||
src={endFrameImage.url}
|
||||
alt="End frame"
|
||||
@@ -375,23 +379,61 @@ export const ImagePromptRow = ({
|
||||
/>
|
||||
<button
|
||||
onClick={() => setEndFrameImage?.(undefined)}
|
||||
className="absolute right-[2px] top-[2px] flex h-5 w-5 cursor-pointer items-center justify-center rounded-full bg-black/50 text-white opacity-0 backdrop-blur-md transition-colors hover:bg-black group-hover:opacity-100"
|
||||
className="absolute right-[2px] top-[2px] flex h-5 w-5 cursor-pointer items-center justify-center rounded-full bg-black/50 text-white sm:opacity-0 backdrop-blur-md transition-colors hover:bg-black sm:group-hover:opacity-100"
|
||||
>
|
||||
<FontAwesomeIcon icon={faXmark} className="h-2.5 w-2.5" />
|
||||
</button>
|
||||
</div>
|
||||
) : uploadingEndFrame ? (
|
||||
<div className="flex aspect-square w-14 items-center justify-center overflow-hidden rounded-lg border-2 border-white/30 bg-white/5">
|
||||
<div className="flex aspect-square w-10 sm:w-14 items-center justify-center overflow-hidden rounded-lg border-2 border-white/30 bg-white/5">
|
||||
<FontAwesomeIcon
|
||||
icon={faSpinnerThird}
|
||||
spin
|
||||
className="h-6 w-6 text-white"
|
||||
/>
|
||||
</div>
|
||||
) : onPickEndFrameFromLibrary ? (
|
||||
<Tooltip
|
||||
interactive
|
||||
position="top"
|
||||
delay={100}
|
||||
className="bg-ui-controls text-base-fg border border-ui-panel-border p-2 -mb-0.5"
|
||||
closeOnClick
|
||||
content={
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => endFrameInputRef.current?.click()}
|
||||
icon={faPlus}
|
||||
className="w-full"
|
||||
>
|
||||
Upload
|
||||
</Button>
|
||||
<Button
|
||||
variant="action"
|
||||
onClick={onPickEndFrameFromLibrary}
|
||||
icon={faImages}
|
||||
className="w-full bg-white/15 hover:bg-white/20"
|
||||
>
|
||||
Pick from library
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<button
|
||||
onClick={() => endFrameInputRef.current?.click()}
|
||||
className="flex aspect-square w-10 sm:w-14 items-center justify-center overflow-hidden rounded-lg border-2 border-dashed border-white/25 bg-white/5 transition-all hover:border-white/40 hover:bg-white/10"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={faPlus}
|
||||
className="text-2xl text-white/80"
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => endFrameInputRef.current?.click()}
|
||||
className="flex aspect-square w-14 items-center justify-center overflow-hidden rounded-lg border-2 border-dashed border-white/25 bg-white/5 transition-all hover:border-white/40 hover:bg-white/10"
|
||||
className="flex aspect-square w-10 sm:w-14 items-center justify-center overflow-hidden rounded-lg border-2 border-dashed border-white/25 bg-white/5 transition-all hover:border-white/40 hover:bg-white/10"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={faPlus}
|
||||
@@ -402,21 +444,6 @@ export const ImagePromptRow = ({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Clear all refs */}
|
||||
{onClearAll && (
|
||||
<div className="flex items-center">
|
||||
<div className="h-full w-[1px] bg-white/10" />
|
||||
<div className="p-2">
|
||||
<button
|
||||
onClick={onClearAll}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-md text-white/50 transition-colors hover:bg-white/10 hover:text-white/80"
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrashAlt} className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
@@ -431,7 +458,7 @@ const ImageThumbnail = ({
|
||||
image: RefImage;
|
||||
onRemove: (id: string) => void;
|
||||
}) => (
|
||||
<div className="group glass relative aspect-square w-14 overflow-hidden rounded-lg border-2 border-white/30 transition-all hover:border-white/80">
|
||||
<div className="group glass relative aspect-square w-10 sm:w-14 overflow-hidden rounded-lg border-2 border-white/30 transition-all hover:border-white/80">
|
||||
<img
|
||||
src={image.url}
|
||||
alt="Reference"
|
||||
@@ -442,7 +469,7 @@ const ImageThumbnail = ({
|
||||
e.stopPropagation();
|
||||
onRemove(image.id);
|
||||
}}
|
||||
className="absolute right-[2px] top-[2px] flex h-5 w-5 cursor-pointer items-center justify-center rounded-full bg-black/50 text-white opacity-0 backdrop-blur-md transition-colors hover:bg-black group-hover:opacity-100"
|
||||
className="absolute right-[2px] top-[2px] flex h-5 w-5 cursor-pointer items-center justify-center rounded-full bg-black/50 text-white sm:opacity-0 backdrop-blur-md transition-colors hover:bg-black sm:group-hover:opacity-100"
|
||||
>
|
||||
<FontAwesomeIcon icon={faXmark} className="h-2.5 w-2.5" />
|
||||
</button>
|
||||
@@ -479,7 +506,7 @@ const SortableImage = ({
|
||||
style={style}
|
||||
{...(allowReorder ? { ...attributes, ...listeners } : {})}
|
||||
className={twMerge(
|
||||
"group glass relative aspect-square w-14 overflow-hidden rounded-lg border-2 border-white/30 transition-opacity",
|
||||
"group glass relative aspect-square w-10 sm:w-14 overflow-hidden rounded-lg border-2 border-white/30 transition-opacity",
|
||||
allowReorder
|
||||
? "cursor-move hover:border-white/80"
|
||||
: "cursor-pointer hover:border-white/80",
|
||||
@@ -498,7 +525,7 @@ const SortableImage = ({
|
||||
}}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
className="absolute right-[2px] top-[2px] flex h-5 w-5 cursor-pointer items-center justify-center rounded-full bg-black/50 text-white opacity-0 backdrop-blur-md transition-colors hover:bg-black group-hover:opacity-100"
|
||||
className="absolute right-[2px] top-[2px] flex h-5 w-5 cursor-pointer items-center justify-center rounded-full bg-black/50 text-white sm:opacity-0 backdrop-blur-md transition-colors hover:bg-black sm:group-hover:opacity-100"
|
||||
>
|
||||
<FontAwesomeIcon icon={faXmark} className="h-2.5 w-2.5" />
|
||||
</button>
|
||||
@@ -510,7 +537,7 @@ const UploadingThumbnail = ({ file }: { file: File }) => {
|
||||
const previewUrl = useMemo(() => URL.createObjectURL(file), [file]);
|
||||
useEffect(() => () => URL.revokeObjectURL(previewUrl), [previewUrl]);
|
||||
return (
|
||||
<div className="glass relative aspect-square w-14 overflow-hidden rounded-lg border-2 border-white/30">
|
||||
<div className="glass relative aspect-square w-10 sm:w-14 overflow-hidden rounded-lg border-2 border-white/30">
|
||||
<div className="absolute inset-0">
|
||||
<img
|
||||
src={previewUrl}
|
||||
|
||||
@@ -179,7 +179,7 @@ export const MediaReferenceRow = ({
|
||||
onChange={handleAudioUpload}
|
||||
/>
|
||||
<div
|
||||
className={twMerge("glass flex", className)}
|
||||
className={twMerge("glass flex flex-col sm:flex-row", className)}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
@@ -200,7 +200,7 @@ export const MediaReferenceRow = ({
|
||||
{totalVideoDuration}/{maxVideoRefDuration}s
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{referenceVideos.map((video) => (
|
||||
<VideoRefTile
|
||||
key={video.id}
|
||||
@@ -209,7 +209,7 @@ export const MediaReferenceRow = ({
|
||||
/>
|
||||
))}
|
||||
{uploadingVideo && (
|
||||
<div className="flex aspect-square w-14 items-center justify-center overflow-hidden rounded-lg border-2 border-white/30 bg-white/5">
|
||||
<div className="flex aspect-square w-10 sm:w-14 items-center justify-center overflow-hidden rounded-lg border-2 border-white/30 bg-white/5">
|
||||
<FontAwesomeIcon
|
||||
icon={faSpinnerThird}
|
||||
spin
|
||||
@@ -220,7 +220,7 @@ export const MediaReferenceRow = ({
|
||||
{canAddVideo && (
|
||||
<button
|
||||
onClick={() => videoInputRef.current?.click()}
|
||||
className="flex aspect-square w-14 items-center justify-center overflow-hidden rounded-lg border-2 border-dashed border-white/25 bg-white/5 transition-all hover:border-white/40 hover:bg-white/10"
|
||||
className="flex aspect-square w-10 sm:w-14 items-center justify-center overflow-hidden rounded-lg border-2 border-dashed border-white/25 bg-white/5 transition-all hover:border-white/40 hover:bg-white/10"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={faPlus}
|
||||
@@ -231,7 +231,7 @@ export const MediaReferenceRow = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-[1px] self-stretch bg-white/10" />
|
||||
<div className="h-[1px] sm:h-auto sm:w-[1px] self-stretch bg-white/10 mx-3 sm:mx-0" />
|
||||
|
||||
{/* Audio section */}
|
||||
<div className="flex grow gap-2 px-3 py-2">
|
||||
@@ -249,7 +249,7 @@ export const MediaReferenceRow = ({
|
||||
{totalAudioDuration}/{maxAudioRefDuration}s
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{referenceAudios.map((audio, i) => (
|
||||
<AudioRefTile
|
||||
key={audio.id}
|
||||
@@ -259,7 +259,7 @@ export const MediaReferenceRow = ({
|
||||
/>
|
||||
))}
|
||||
{uploadingAudio && (
|
||||
<div className="flex aspect-square w-14 items-center justify-center overflow-hidden rounded-lg border-2 border-white/30 bg-white/5">
|
||||
<div className="flex aspect-square w-10 sm:w-14 items-center justify-center overflow-hidden rounded-lg border-2 border-white/30 bg-white/5">
|
||||
<FontAwesomeIcon
|
||||
icon={faSpinnerThird}
|
||||
spin
|
||||
@@ -270,7 +270,7 @@ export const MediaReferenceRow = ({
|
||||
{canAddAudio && (
|
||||
<button
|
||||
onClick={() => audioInputRef.current?.click()}
|
||||
className="flex aspect-square w-14 items-center justify-center overflow-hidden rounded-lg border-2 border-dashed border-white/25 bg-white/5 transition-all hover:border-white/40 hover:bg-white/10"
|
||||
className="flex aspect-square w-10 sm:w-14 items-center justify-center overflow-hidden rounded-lg border-2 border-dashed border-white/25 bg-white/5 transition-all hover:border-white/40 hover:bg-white/10"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={faPlus}
|
||||
@@ -294,7 +294,7 @@ const VideoRefTile = ({
|
||||
video: RefVideo;
|
||||
onRemove: (id: string) => void;
|
||||
}) => (
|
||||
<div className="group relative aspect-square w-14 overflow-hidden rounded-lg border-2 border-white/30 transition-all hover:border-white/80">
|
||||
<div className="group relative aspect-square w-10 sm:w-14 overflow-hidden rounded-lg border-2 border-white/30 transition-all hover:border-white/80">
|
||||
<video
|
||||
src={video.url}
|
||||
muted
|
||||
@@ -309,7 +309,7 @@ const VideoRefTile = ({
|
||||
e.stopPropagation();
|
||||
onRemove(video.id);
|
||||
}}
|
||||
className="absolute right-[2px] top-[2px] flex h-5 w-5 cursor-pointer items-center justify-center rounded-full bg-black/50 text-white opacity-0 backdrop-blur-md transition-colors hover:bg-black group-hover:opacity-100"
|
||||
className="absolute right-[2px] top-[2px] flex h-5 w-5 cursor-pointer items-center justify-center rounded-full bg-black/50 text-white sm:opacity-0 backdrop-blur-md transition-colors hover:bg-black sm:group-hover:opacity-100"
|
||||
>
|
||||
<FontAwesomeIcon icon={faXmark} className="h-2.5 w-2.5" />
|
||||
</button>
|
||||
@@ -348,7 +348,7 @@ const AudioRefTile = ({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="group relative flex aspect-square w-14 cursor-pointer items-center justify-center overflow-hidden rounded-lg border-2 border-white/30 transition-all hover:border-white/80">
|
||||
<div className="group relative flex aspect-square w-10 sm:w-14 cursor-pointer items-center justify-center overflow-hidden rounded-lg border-2 border-white/30 transition-all hover:border-white/80">
|
||||
<button
|
||||
onClick={handleTogglePlay}
|
||||
className="flex h-full w-full items-center justify-center"
|
||||
@@ -369,7 +369,7 @@ const AudioRefTile = ({
|
||||
e.stopPropagation();
|
||||
onRemove(audio.id);
|
||||
}}
|
||||
className="absolute right-[2px] top-[2px] flex h-5 w-5 cursor-pointer items-center justify-center rounded-full bg-black/50 text-white opacity-0 backdrop-blur-md transition-colors hover:bg-black group-hover:opacity-100"
|
||||
className="absolute right-[2px] top-[2px] flex h-5 w-5 cursor-pointer items-center justify-center rounded-full bg-black/50 text-white sm:opacity-0 backdrop-blur-md transition-colors hover:bg-black sm:group-hover:opacity-100"
|
||||
>
|
||||
<FontAwesomeIcon icon={faXmark} className="h-2.5 w-2.5" />
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,654 @@
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faUserGroup } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { faVideo, faMusic } from "@fortawesome/pro-regular-svg-icons";
|
||||
import type { MentionItem } from "./types";
|
||||
|
||||
interface MentionTextareaProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
mentionItems: MentionItem[];
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
onKeyDown?: (e: React.KeyboardEvent<HTMLDivElement>) => void;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
disabled?: boolean;
|
||||
colorMap: Record<string, string>;
|
||||
}
|
||||
|
||||
interface MentionState {
|
||||
isOpen: boolean;
|
||||
triggerIndex: number;
|
||||
query: string;
|
||||
activeIndex: number;
|
||||
}
|
||||
|
||||
function escapeRegex(s: string): string {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function escapeHTML(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cursor helpers for contentEditable
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function getCaretOffset(el: HTMLElement): number {
|
||||
try {
|
||||
const sel = window.getSelection();
|
||||
if (!sel?.rangeCount || !sel.anchorNode || !el.contains(sel.anchorNode)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const anchorNode = sel.anchorNode;
|
||||
const anchorOffset = sel.anchorOffset;
|
||||
let offset = 0;
|
||||
|
||||
if (anchorNode === el) {
|
||||
for (let i = 0; i < anchorOffset; i++) {
|
||||
const child = el.childNodes[i];
|
||||
if (!child) break;
|
||||
if (child.nodeType === Node.TEXT_NODE) {
|
||||
offset += child.textContent?.length ?? 0;
|
||||
} else if (child.nodeName === "BR") {
|
||||
offset += 1;
|
||||
} else {
|
||||
offset += child.textContent?.length ?? 0;
|
||||
}
|
||||
}
|
||||
return offset;
|
||||
}
|
||||
|
||||
function countBefore(parent: Node): boolean {
|
||||
for (const child of Array.from(parent.childNodes)) {
|
||||
if (child === anchorNode) {
|
||||
if (child.nodeType === Node.TEXT_NODE) {
|
||||
offset += anchorOffset;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (child.contains(anchorNode)) {
|
||||
return countBefore(child);
|
||||
}
|
||||
if (child.nodeType === Node.TEXT_NODE) {
|
||||
offset += child.textContent?.length ?? 0;
|
||||
} else if (child.nodeName === "BR") {
|
||||
offset += 1;
|
||||
} else {
|
||||
offset += child.textContent?.length ?? 0;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
countBefore(el);
|
||||
return offset;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function setCaretOffset(el: HTMLElement, offset: number) {
|
||||
try {
|
||||
let remaining = offset;
|
||||
|
||||
function findPosition(parent: Node): boolean {
|
||||
for (let i = 0; i < parent.childNodes.length; i++) {
|
||||
const child = parent.childNodes[i];
|
||||
if (child.nodeType === Node.TEXT_NODE) {
|
||||
const len = child.textContent?.length ?? 0;
|
||||
if (remaining <= len) {
|
||||
const sel = window.getSelection();
|
||||
const range = document.createRange();
|
||||
range.setStart(child, remaining);
|
||||
range.collapse(true);
|
||||
sel?.removeAllRanges();
|
||||
sel?.addRange(range);
|
||||
return true;
|
||||
}
|
||||
remaining -= len;
|
||||
} else if (child.nodeName === "BR") {
|
||||
if (remaining === 0) {
|
||||
const sel = window.getSelection();
|
||||
const range = document.createRange();
|
||||
range.setStart(parent, i);
|
||||
range.collapse(true);
|
||||
sel?.removeAllRanges();
|
||||
sel?.addRange(range);
|
||||
return true;
|
||||
}
|
||||
remaining -= 1;
|
||||
} else if (child.nodeType === Node.ELEMENT_NODE) {
|
||||
if (findPosition(child)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!findPosition(el)) {
|
||||
const sel = window.getSelection();
|
||||
if (sel) {
|
||||
sel.selectAllChildren(el);
|
||||
sel.collapseToEnd();
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// DOM changed during caret restore
|
||||
}
|
||||
}
|
||||
|
||||
function scrollCaretIntoView(el: HTMLElement) {
|
||||
try {
|
||||
const sel = window.getSelection();
|
||||
if (!sel?.rangeCount || !el.contains(sel.anchorNode)) return;
|
||||
|
||||
const range = sel.getRangeAt(0).cloneRange();
|
||||
range.collapse(false);
|
||||
|
||||
const span = document.createElement("span");
|
||||
span.textContent = "\u200B";
|
||||
range.insertNode(span);
|
||||
|
||||
const spanRect = span.getBoundingClientRect();
|
||||
const elRect = el.getBoundingClientRect();
|
||||
|
||||
if (spanRect.bottom > elRect.bottom) {
|
||||
el.scrollTop += spanRect.bottom - elRect.bottom;
|
||||
} else if (spanRect.top < elRect.top) {
|
||||
el.scrollTop -= elRect.top - spanRect.top;
|
||||
}
|
||||
|
||||
const parent = span.parentNode;
|
||||
if (parent) {
|
||||
const next = span.nextSibling;
|
||||
parent.removeChild(span);
|
||||
|
||||
const restored = document.createRange();
|
||||
if (next) {
|
||||
restored.setStartBefore(next);
|
||||
} else if (parent.lastChild) {
|
||||
restored.setStartAfter(parent.lastChild);
|
||||
} else {
|
||||
restored.selectNodeContents(parent);
|
||||
}
|
||||
restored.collapse(true);
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(restored);
|
||||
}
|
||||
} catch {
|
||||
// DOM changed during measurement
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mention Dropdown
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function MentionDropdown({
|
||||
items,
|
||||
activeIndex,
|
||||
onSelect,
|
||||
onHover,
|
||||
position,
|
||||
}: {
|
||||
items: MentionItem[];
|
||||
activeIndex: number;
|
||||
onSelect: (item: MentionItem) => void;
|
||||
onHover: (index: number) => void;
|
||||
position: { left: number; bottom: number };
|
||||
}) {
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const list = listRef.current;
|
||||
if (!list) return;
|
||||
const activeItem = list.children[1]?.children[activeIndex] as HTMLElement;
|
||||
activeItem?.scrollIntoView({ block: "nearest" });
|
||||
}, [activeIndex]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={listRef}
|
||||
className="absolute z-50 mb-1 w-64 max-h-72 overflow-y-auto rounded-lg border border-white/10 bg-[#2a2a2e] shadow-lg backdrop-blur-xl"
|
||||
style={{ left: position.left, bottom: position.bottom }}
|
||||
>
|
||||
<div className="px-3 py-1.5 text-[11px] font-semibold uppercase tracking-wider text-white/50">
|
||||
Mentions
|
||||
</div>
|
||||
<div>
|
||||
{items.map((item, i) => (
|
||||
<button
|
||||
key={item.label}
|
||||
type="button"
|
||||
className={twMerge(
|
||||
"flex w-full items-center gap-2.5 px-3 py-2 text-sm text-white transition-colors cursor-pointer",
|
||||
i === activeIndex ? "bg-white/10" : "hover:bg-white/5",
|
||||
)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
onSelect(item);
|
||||
}}
|
||||
onMouseEnter={() => onHover(i)}
|
||||
>
|
||||
<div className="h-8 w-8 flex-shrink-0 overflow-hidden rounded-md border border-white/20 flex items-center justify-center bg-black/20">
|
||||
{(item.type === "character" || item.type === "image") && item.preview ? (
|
||||
<img
|
||||
src={item.preview}
|
||||
alt={item.label}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : item.type === "character" ? (
|
||||
<FontAwesomeIcon
|
||||
icon={faUserGroup}
|
||||
className="h-3.5 w-3.5 text-white/60"
|
||||
/>
|
||||
) : item.type === "video" && item.preview ? (
|
||||
<video
|
||||
src={item.preview}
|
||||
muted
|
||||
preload="metadata"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<FontAwesomeIcon
|
||||
icon={item.type === "video" ? faVideo : faMusic}
|
||||
className="h-3.5 w-3.5 text-white/60"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<span className="font-medium">{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MentionTextarea Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const MentionTextarea = forwardRef<HTMLDivElement, MentionTextareaProps>(
|
||||
function MentionTextarea(
|
||||
{
|
||||
value,
|
||||
onChange,
|
||||
mentionItems,
|
||||
placeholder,
|
||||
className,
|
||||
onKeyDown: externalOnKeyDown,
|
||||
onFocus,
|
||||
onBlur,
|
||||
disabled,
|
||||
colorMap,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const editorRef = useRef<HTMLDivElement>(null);
|
||||
useImperativeHandle(ref, () => editorRef.current!, []);
|
||||
const isInternalUpdate = useRef(false);
|
||||
const isComposing = useRef(false);
|
||||
const pendingCaret = useRef<number | null>(null);
|
||||
|
||||
const [mentionState, setMentionState] = useState<MentionState>({
|
||||
isOpen: false,
|
||||
triggerIndex: -1,
|
||||
query: "",
|
||||
activeIndex: 0,
|
||||
});
|
||||
|
||||
const [dropdownPos, setDropdownPos] = useState<{
|
||||
left: number;
|
||||
bottom: number;
|
||||
}>({ left: 0, bottom: 0 });
|
||||
|
||||
const filteredItems = useMemo(() => {
|
||||
if (!mentionState.isOpen) return [];
|
||||
return mentionItems.filter((item) =>
|
||||
mentionState.query
|
||||
? item.label.toLowerCase().includes(mentionState.query.toLowerCase())
|
||||
: true,
|
||||
);
|
||||
}, [mentionItems, mentionState.isOpen, mentionState.query]);
|
||||
|
||||
// Build a regex that matches any known mention label
|
||||
const mentionRegex = useMemo(() => {
|
||||
const labels = mentionItems.map((item) => item.label);
|
||||
if (labels.length === 0) return null;
|
||||
const sorted = [...labels].sort((a, b) => b.length - a.length);
|
||||
const pattern = sorted.map((l) => escapeRegex(l)).join("|");
|
||||
return new RegExp(`(${pattern})`, "g");
|
||||
}, [mentionItems]);
|
||||
|
||||
// Build innerHTML with colored @mentions inline
|
||||
const buildHTML = useCallback(
|
||||
(text: string): string => {
|
||||
if (!text) return "";
|
||||
if (!mentionRegex) {
|
||||
let html = escapeHTML(text);
|
||||
html = html.replace(/\n/g, "<br>");
|
||||
if (html.endsWith("<br>")) html += "<br>";
|
||||
return html;
|
||||
}
|
||||
|
||||
let html = "";
|
||||
let lastIndex = 0;
|
||||
const regex = new RegExp(mentionRegex);
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
const fullMatch = match[0];
|
||||
const color = colorMap[fullMatch];
|
||||
|
||||
if (match.index > lastIndex) {
|
||||
html += escapeHTML(text.slice(lastIndex, match.index));
|
||||
}
|
||||
|
||||
if (color) {
|
||||
html += `<span style="color:${color}">${escapeHTML(fullMatch)}</span>`;
|
||||
} else {
|
||||
html += escapeHTML(fullMatch);
|
||||
}
|
||||
|
||||
lastIndex = match.index + fullMatch.length;
|
||||
}
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
html += escapeHTML(text.slice(lastIndex));
|
||||
}
|
||||
|
||||
html = html.replace(/\n/g, "<br>");
|
||||
if (html.endsWith("<br>")) {
|
||||
html += "<br>";
|
||||
}
|
||||
|
||||
return html;
|
||||
},
|
||||
[colorMap, mentionRegex],
|
||||
);
|
||||
|
||||
// Sync DOM when value changes from parent
|
||||
useEffect(() => {
|
||||
if (isInternalUpdate.current) {
|
||||
isInternalUpdate.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const el = editorRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const sel = window.getSelection();
|
||||
if (document.activeElement === el && sel && !sel.isCollapsed) return;
|
||||
|
||||
try {
|
||||
const caret = pendingCaret.current ?? getCaretOffset(el);
|
||||
el.innerHTML = buildHTML(value);
|
||||
if (pendingCaret.current !== null) {
|
||||
setCaretOffset(el, pendingCaret.current);
|
||||
pendingCaret.current = null;
|
||||
} else if (document.activeElement === el) {
|
||||
setCaretOffset(el, caret);
|
||||
}
|
||||
} catch {
|
||||
el.innerHTML = buildHTML(value);
|
||||
}
|
||||
}, [value, buildHTML]);
|
||||
|
||||
// Get pixel coordinates of a text offset relative to the wrapper
|
||||
const getOffsetRect = useCallback((charOffset: number) => {
|
||||
try {
|
||||
const el = editorRef.current;
|
||||
if (!el) return null;
|
||||
|
||||
const saved = getCaretOffset(el);
|
||||
setCaretOffset(el, charOffset);
|
||||
const sel = window.getSelection();
|
||||
if (!sel?.rangeCount) {
|
||||
setCaretOffset(el, saved);
|
||||
return null;
|
||||
}
|
||||
const range = sel.getRangeAt(0);
|
||||
const rect = range.getBoundingClientRect();
|
||||
const wrapperRect = el.parentElement!.getBoundingClientRect();
|
||||
setCaretOffset(el, saved);
|
||||
|
||||
return {
|
||||
left: rect.left - wrapperRect.left,
|
||||
bottom: wrapperRect.bottom - rect.top,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Detect @mention trigger from cursor position
|
||||
const detectMention = useCallback(
|
||||
(text: string, cursorPos: number) => {
|
||||
const textBefore = text.slice(0, cursorPos);
|
||||
const lastAt = textBefore.lastIndexOf("@");
|
||||
if (lastAt !== -1 && (lastAt === 0 || /\s/.test(text[lastAt - 1]))) {
|
||||
const query = text.slice(lastAt, cursorPos);
|
||||
if (!query.includes("\n")) {
|
||||
const pos = getOffsetRect(lastAt);
|
||||
if (pos) setDropdownPos(pos);
|
||||
setMentionState({
|
||||
isOpen: true,
|
||||
triggerIndex: lastAt,
|
||||
query,
|
||||
activeIndex: 0,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
setMentionState((prev) =>
|
||||
prev.isOpen ? { ...prev, isOpen: false } : prev,
|
||||
);
|
||||
},
|
||||
[getOffsetRect],
|
||||
);
|
||||
|
||||
const handleInput = useCallback(() => {
|
||||
if (isComposing.current) return;
|
||||
const el = editorRef.current;
|
||||
if (!el) return;
|
||||
|
||||
try {
|
||||
let text = el.innerText;
|
||||
if (text.endsWith("\n")) {
|
||||
text = text.slice(0, -1);
|
||||
}
|
||||
|
||||
const caret = getCaretOffset(el);
|
||||
const html = buildHTML(text);
|
||||
if (el.innerHTML !== html) {
|
||||
el.innerHTML = html;
|
||||
setCaretOffset(el, caret);
|
||||
}
|
||||
|
||||
isInternalUpdate.current = true;
|
||||
onChange(text);
|
||||
detectMention(text, caret);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
scrollCaretIntoView(el);
|
||||
});
|
||||
} catch {
|
||||
const text = el.innerText?.replace(/\n$/, "") ?? "";
|
||||
isInternalUpdate.current = true;
|
||||
onChange(text);
|
||||
}
|
||||
}, [onChange, buildHTML, detectMention]);
|
||||
|
||||
const handleCompositionStart = useCallback(() => {
|
||||
isComposing.current = true;
|
||||
}, []);
|
||||
|
||||
const handleCompositionEnd = useCallback(() => {
|
||||
isComposing.current = false;
|
||||
handleInput();
|
||||
}, [handleInput]);
|
||||
|
||||
// Select a mention from the dropdown
|
||||
const handleSelect = useCallback(
|
||||
(item: MentionItem) => {
|
||||
const el = editorRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const caretPos = getCaretOffset(el);
|
||||
const before = value.slice(0, mentionState.triggerIndex);
|
||||
const after = value.slice(caretPos);
|
||||
const mention = `${item.label} `;
|
||||
const newValue = before + mention + after;
|
||||
|
||||
pendingCaret.current = before.length + mention.length;
|
||||
|
||||
setMentionState({
|
||||
isOpen: false,
|
||||
triggerIndex: -1,
|
||||
query: "",
|
||||
activeIndex: 0,
|
||||
});
|
||||
|
||||
onChange(newValue);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
el.focus();
|
||||
});
|
||||
},
|
||||
[value, mentionState.triggerIndex, onChange],
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (mentionState.isOpen && filteredItems.length > 0) {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setMentionState((prev) => ({
|
||||
...prev,
|
||||
activeIndex: Math.min(
|
||||
prev.activeIndex + 1,
|
||||
filteredItems.length - 1,
|
||||
),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setMentionState((prev) => ({
|
||||
...prev,
|
||||
activeIndex: Math.max(prev.activeIndex - 1, 0),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
if (e.key === "Enter" || e.key === "Tab") {
|
||||
e.preventDefault();
|
||||
handleSelect(filteredItems[mentionState.activeIndex]);
|
||||
return;
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
setMentionState((prev) => ({ ...prev, isOpen: false }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key === "Enter" && (e.shiftKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
document.execCommand("insertLineBreak");
|
||||
handleInput();
|
||||
scrollCaretIntoView(editorRef.current!);
|
||||
return;
|
||||
}
|
||||
|
||||
externalOnKeyDown?.(e);
|
||||
},
|
||||
[
|
||||
mentionState.isOpen,
|
||||
mentionState.activeIndex,
|
||||
filteredItems,
|
||||
handleSelect,
|
||||
externalOnKeyDown,
|
||||
handleInput,
|
||||
],
|
||||
);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
const el = editorRef.current;
|
||||
if (!el) return;
|
||||
const sel = window.getSelection();
|
||||
if (sel && !sel.isCollapsed) return;
|
||||
detectMention(value, getCaretOffset(el));
|
||||
}, [value, detectMention]);
|
||||
|
||||
const handlePaste = useCallback(
|
||||
(e: React.ClipboardEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
const text = e.clipboardData.getData("text/plain");
|
||||
document.execCommand("insertText", false, text);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative flex-1 min-w-0 pb-[7px]">
|
||||
{!value && placeholder && (
|
||||
<div
|
||||
className={twMerge(
|
||||
className,
|
||||
"absolute inset-0 pointer-events-none text-white/50 z-[1]",
|
||||
)}
|
||||
>
|
||||
{placeholder}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={editorRef}
|
||||
contentEditable={!disabled}
|
||||
onInput={handleInput}
|
||||
onCompositionStart={handleCompositionStart}
|
||||
onCompositionEnd={handleCompositionEnd}
|
||||
onKeyDown={handleKeyDown}
|
||||
onClick={handleClick}
|
||||
onPaste={handlePaste}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
className={twMerge(
|
||||
className,
|
||||
"outline-none whitespace-pre-wrap break-words overflow-y-auto",
|
||||
)}
|
||||
/>
|
||||
|
||||
{mentionState.isOpen && filteredItems.length > 0 && (
|
||||
<MentionDropdown
|
||||
items={filteredItems}
|
||||
activeIndex={mentionState.activeIndex}
|
||||
onSelect={handleSelect}
|
||||
onHover={(i) =>
|
||||
setMentionState((prev) => ({ ...prev, activeIndex: i }))
|
||||
}
|
||||
position={dropdownPos}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -9,10 +9,15 @@ import {
|
||||
} from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faMusic, faVideo } from "@fortawesome/pro-solid-svg-icons";
|
||||
import {
|
||||
faMusic,
|
||||
faUserGroup,
|
||||
faVideo,
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import { GenerateButton } from "@storyteller/ui-button";
|
||||
import { Tooltip } from "@storyteller/ui-tooltip";
|
||||
import { ImagePromptRow } from "./ImagePromptRow";
|
||||
import { MentionTextarea } from "./MentionTextarea";
|
||||
import type { RefImage, MentionItem } from "./types";
|
||||
|
||||
// ── @-mention color palette ─────────────────────────────────────────────
|
||||
@@ -33,7 +38,13 @@ const VIDEO_COLORS = [
|
||||
|
||||
const AUDIO_COLORS = ["rgb(192, 132, 252)", "rgb(232, 121, 249)"];
|
||||
|
||||
function getMentionColor(label: string): string {
|
||||
const CHARACTER_COLORS = [
|
||||
"rgb(45, 212, 191)", // teal
|
||||
"rgb(34, 197, 94)", // emerald
|
||||
"rgb(14, 165, 233)", // sky
|
||||
];
|
||||
|
||||
function getMentionColor(label: string, mentionItems?: MentionItem[]): string {
|
||||
const imgMatch = label.match(/^@Image(\d+)$/);
|
||||
if (imgMatch)
|
||||
return IMAGE_COLORS[(parseInt(imgMatch[1]) - 1) % IMAGE_COLORS.length];
|
||||
@@ -43,6 +54,12 @@ function getMentionColor(label: string): string {
|
||||
const audMatch = label.match(/^@Audio(\d+)$/);
|
||||
if (audMatch)
|
||||
return AUDIO_COLORS[(parseInt(audMatch[1]) - 1) % AUDIO_COLORS.length];
|
||||
// Character mentions: match by name from mentionItems
|
||||
if (mentionItems) {
|
||||
const charItems = mentionItems.filter((m) => m.type === "character");
|
||||
const idx = charItems.findIndex((m) => m.label === label);
|
||||
if (idx !== -1) return CHARACTER_COLORS[idx % CHARACTER_COLORS.length];
|
||||
}
|
||||
return "rgb(255, 255, 255)";
|
||||
}
|
||||
|
||||
@@ -77,16 +94,16 @@ interface PromptBoxProps {
|
||||
|
||||
// Pick from library
|
||||
onPickFromLibrary?: () => void;
|
||||
onPickEndFrameFromLibrary?: () => void;
|
||||
// Clear all references (images, end frame, videos, audios)
|
||||
onClearAllRefs?: () => void;
|
||||
|
||||
// Clear session button
|
||||
showClearSession?: boolean;
|
||||
onClearSession?: () => void;
|
||||
|
||||
// Media reference row (video/audio refs, rendered between image row and prompt)
|
||||
mediaReferenceRow?: ReactNode;
|
||||
|
||||
// Model selector (rendered above the toolbar, typically hidden on desktop via lg:hidden)
|
||||
modelSelector?: ReactNode;
|
||||
|
||||
// @-mention support (enables colored prompt overlay + autocomplete)
|
||||
mentionItems?: MentionItem[];
|
||||
}
|
||||
@@ -114,16 +131,17 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
|
||||
leftToolbar,
|
||||
rightToolbar,
|
||||
onPickFromLibrary,
|
||||
onPickEndFrameFromLibrary,
|
||||
onClearAllRefs,
|
||||
showClearSession,
|
||||
onClearSession,
|
||||
mediaReferenceRow,
|
||||
modelSelector,
|
||||
mentionItems,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const highlightRef = useRef<HTMLDivElement>(null);
|
||||
const mentionEditorRef = useRef<HTMLDivElement>(null);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const [showImagePrompts, setShowImagePrompts] = useState(false);
|
||||
|
||||
@@ -157,6 +175,14 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
|
||||
}
|
||||
}, [prompt]);
|
||||
|
||||
// Move caret to end on mount so autoFocus doesn't leave it at position 0
|
||||
useEffect(() => {
|
||||
const ta = textareaRef.current;
|
||||
if (ta && ta.value.length > 0) {
|
||||
ta.setSelectionRange(ta.value.length, ta.value.length);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Sync scroll between textarea and highlight overlay
|
||||
const handleScroll = useCallback(() => {
|
||||
if (highlightRef.current && textareaRef.current) {
|
||||
@@ -259,16 +285,45 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
|
||||
],
|
||||
);
|
||||
|
||||
// Build a regex that matches all known @-mention labels
|
||||
const mentionRegex = useMemo(() => {
|
||||
if (!mentionItems?.length) return null;
|
||||
// Escape special regex characters in labels and sort longest first
|
||||
const escaped = mentionItems
|
||||
.map((m) => m.label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
|
||||
.sort((a, b) => b.length - a.length);
|
||||
return new RegExp(`(${escaped.join("|")})`, "g");
|
||||
}, [mentionItems]);
|
||||
|
||||
// Set of valid mention labels for fast lookup
|
||||
const mentionLabelSet = useMemo(
|
||||
() => new Set(mentionItems?.map((m) => m.label)),
|
||||
[mentionItems],
|
||||
);
|
||||
|
||||
// Build label → color map for MentionTextarea
|
||||
const mentionColorMap = useMemo(() => {
|
||||
if (!mentionItems?.length) return {};
|
||||
const map: Record<string, string> = {};
|
||||
for (const item of mentionItems) {
|
||||
map[item.label] = getMentionColor(item.label, mentionItems);
|
||||
}
|
||||
return map;
|
||||
}, [mentionItems]);
|
||||
|
||||
// Render highlighted prompt with colored @-mentions
|
||||
const renderHighlightedPrompt = useCallback(() => {
|
||||
if (!hasMentionItems) return null;
|
||||
const parts = prompt.split(/(@(?:Image|Video|Audio)\d+)/g);
|
||||
if (!hasMentionItems || !mentionRegex) return null;
|
||||
const parts = prompt.split(mentionRegex);
|
||||
return parts.map((part, i) => {
|
||||
if (/^@(?:Image|Video|Audio)\d+$/.test(part)) {
|
||||
if (mentionLabelSet.has(part)) {
|
||||
return (
|
||||
<span
|
||||
key={i}
|
||||
style={{ color: getMentionColor(part), fontWeight: 600 }}
|
||||
style={{
|
||||
color: getMentionColor(part, mentionItems),
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{part}
|
||||
</span>
|
||||
@@ -276,21 +331,10 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
|
||||
}
|
||||
return <span key={i}>{part}</span>;
|
||||
});
|
||||
}, [prompt, hasMentionItems]);
|
||||
}, [prompt, hasMentionItems, mentionRegex, mentionLabelSet, mentionItems]);
|
||||
|
||||
return (
|
||||
<div ref={ref}>
|
||||
{showClearSession && (
|
||||
<div className="mb-2 flex w-full justify-end">
|
||||
<button
|
||||
onClick={onClearSession}
|
||||
className="rounded-md bg-red-500/20 px-3 py-1 text-xs text-white/70 transition-colors hover:bg-red-500/30"
|
||||
>
|
||||
Clear session
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative flex flex-col">
|
||||
{isImageRowVisible && (
|
||||
<ImagePromptRow
|
||||
@@ -304,6 +348,7 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
|
||||
endFrameImage={endFrameImage}
|
||||
setEndFrameImage={onEndFrameImageChange}
|
||||
showEndFrameSection={showEndFrameSection}
|
||||
onPickEndFrameFromLibrary={onPickEndFrameFromLibrary}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -311,7 +356,7 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
|
||||
|
||||
<div
|
||||
className={twMerge(
|
||||
"glass rounded-xl p-4 !transition-all duration-200",
|
||||
"glass rounded-xl p-3 sm:p-4 !transition-all duration-200",
|
||||
hasAnyRowAbove && "rounded-t-none",
|
||||
isFocused && "ring-1 ring-primary",
|
||||
)}
|
||||
@@ -350,95 +395,135 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
|
||||
)}
|
||||
|
||||
<div className="relative flex-1">
|
||||
{/* Highlighted prompt overlay for @-mentions */}
|
||||
{hasMentionItems && (
|
||||
<div
|
||||
ref={highlightRef}
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0 max-h-[5.5em] overflow-y-auto whitespace-pre-wrap break-words text-sm text-white"
|
||||
>
|
||||
{renderHighlightedPrompt()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
rows={1}
|
||||
autoFocus
|
||||
placeholder={placeholder}
|
||||
className={twMerge(
|
||||
"max-h-[5.5em] w-full flex-1 resize-none overflow-y-auto bg-transparent text-md text-white placeholder-white/50 focus:outline-none",
|
||||
hasMentionItems && "text-transparent caret-white",
|
||||
)}
|
||||
value={prompt}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
onScroll={handleScroll}
|
||||
/>
|
||||
|
||||
{/* @-mention autocomplete dropdown */}
|
||||
{mentionOpen && filteredMentionItems.length > 0 && (
|
||||
<div className="absolute bottom-full left-0 z-50 mb-1 w-64 overflow-hidden rounded-lg border border-white/10 bg-[#2a2a2e] shadow-lg backdrop-blur-xl">
|
||||
<div className="px-3 py-1.5 text-[11px] font-semibold uppercase tracking-wider text-white/50">
|
||||
Reference Files
|
||||
</div>
|
||||
{filteredMentionItems.map((item, i) => (
|
||||
<button
|
||||
key={item.label}
|
||||
className={twMerge(
|
||||
"flex w-full cursor-pointer items-center gap-2.5 px-3 py-2 text-sm text-white transition-colors",
|
||||
i === mentionIndex
|
||||
? "bg-white/10"
|
||||
: "hover:bg-white/5",
|
||||
)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
insertMention(item.label);
|
||||
}}
|
||||
onMouseEnter={() => setMentionIndex(i)}
|
||||
{hasMentionItems && mentionItems ? (
|
||||
<MentionTextarea
|
||||
ref={mentionEditorRef}
|
||||
value={prompt}
|
||||
onChange={onPromptChange}
|
||||
mentionItems={mentionItems}
|
||||
placeholder={placeholder}
|
||||
className="max-h-[5.5em] w-full text-white"
|
||||
colorMap={mentionColorMap}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey && !e.metaKey) {
|
||||
e.preventDefault();
|
||||
onSubmit();
|
||||
}
|
||||
}}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{hasMentionItems && (
|
||||
<div
|
||||
ref={highlightRef}
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0 max-h-[5.5em] overflow-y-auto whitespace-pre-wrap break-words text-sm text-white"
|
||||
>
|
||||
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center overflow-hidden rounded-md border border-white/20 bg-black/20">
|
||||
{item.type === "image" && item.preview ? (
|
||||
<img
|
||||
src={item.preview}
|
||||
alt={item.label}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : item.type === "video" && item.preview ? (
|
||||
<video
|
||||
src={item.preview}
|
||||
muted
|
||||
preload="metadata"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<FontAwesomeIcon
|
||||
icon={item.type === "video" ? faVideo : faMusic}
|
||||
className="h-3.5 w-3.5 text-white/60"
|
||||
/>
|
||||
)}
|
||||
{renderHighlightedPrompt()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
rows={1}
|
||||
autoFocus
|
||||
placeholder={placeholder}
|
||||
className={twMerge(
|
||||
"max-h-[5.5em] w-full flex-1 resize-none overflow-y-auto bg-transparent text-md text-white placeholder-white/50 focus:outline-none",
|
||||
hasMentionItems && "text-transparent caret-white",
|
||||
)}
|
||||
value={prompt}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
onScroll={handleScroll}
|
||||
/>
|
||||
|
||||
{mentionOpen && filteredMentionItems.length > 0 && (
|
||||
<div className="absolute bottom-full left-0 z-50 mb-1 w-64 max-w-[calc(100vw-3rem)] overflow-hidden rounded-lg border border-white/10 bg-[#2a2a2e] shadow-lg backdrop-blur-xl">
|
||||
<div className="px-3 py-1.5 text-[11px] font-semibold uppercase tracking-wider text-white/50">
|
||||
Mentions
|
||||
</div>
|
||||
<span
|
||||
className="font-medium"
|
||||
style={{ color: getMentionColor(item.label) }}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{filteredMentionItems.map((item, i) => (
|
||||
<button
|
||||
key={item.label}
|
||||
className={twMerge(
|
||||
"flex w-full cursor-pointer items-center gap-2.5 px-3 py-2 text-sm text-white transition-colors",
|
||||
i === mentionIndex
|
||||
? "bg-white/10"
|
||||
: "hover:bg-white/5",
|
||||
)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
insertMention(item.label);
|
||||
}}
|
||||
onMouseEnter={() => setMentionIndex(i)}
|
||||
>
|
||||
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center overflow-hidden rounded-md border border-white/20 bg-black/20">
|
||||
{(item.type === "image" ||
|
||||
item.type === "character") &&
|
||||
item.preview ? (
|
||||
<img
|
||||
src={item.preview}
|
||||
alt={item.label}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : item.type === "video" && item.preview ? (
|
||||
<video
|
||||
src={item.preview}
|
||||
muted
|
||||
preload="metadata"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : item.type === "character" ? (
|
||||
<FontAwesomeIcon
|
||||
icon={faUserGroup}
|
||||
className="h-3.5 w-3.5 text-white/60"
|
||||
/>
|
||||
) : (
|
||||
<FontAwesomeIcon
|
||||
icon={
|
||||
item.type === "video" ? faVideo : faMusic
|
||||
}
|
||||
className="h-3.5 w-3.5 text-white/60"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className="font-medium"
|
||||
style={{
|
||||
color: getMentionColor(
|
||||
item.label,
|
||||
mentionItems,
|
||||
),
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">{leftToolbar}</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Toolbar */}
|
||||
<div className="mt-2 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex flex-wrap items-center gap-1.5 sm:gap-2">
|
||||
{modelSelector && (
|
||||
<div className="lg:hidden">{modelSelector}</div>
|
||||
)}
|
||||
{leftToolbar}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 sm:shrink-0">
|
||||
{rightToolbar}
|
||||
<GenerateButton
|
||||
className="flex items-center border-none bg-primary px-3 text-sm text-white disabled:cursor-not-allowed disabled:opacity-50"
|
||||
className="flex flex-1 sm:flex-none items-center justify-center border-none bg-primary px-3 text-sm text-white disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onClick={onSubmit}
|
||||
disabled={disabled ?? (!prompt.trim() || isSubmitting)}
|
||||
loading={isSubmitting}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
export interface StoredCharacter {
|
||||
character_token: string;
|
||||
name: string;
|
||||
avatar_image_url?: string;
|
||||
}
|
||||
|
||||
interface CharactersStore {
|
||||
characters: StoredCharacter[];
|
||||
loaded: boolean;
|
||||
setCharacters: (characters: StoredCharacter[]) => void;
|
||||
addCharacter: (character: StoredCharacter) => void;
|
||||
updateCharacter: (token: string, updates: Partial<StoredCharacter>) => void;
|
||||
removeCharacter: (token: string) => void;
|
||||
setLoaded: (loaded: boolean) => void;
|
||||
}
|
||||
|
||||
export const useCharactersStore = create<CharactersStore>()((set) => ({
|
||||
characters: [],
|
||||
loaded: false,
|
||||
setCharacters: (characters) => set({ characters }),
|
||||
addCharacter: (character) =>
|
||||
set((state) => ({ characters: [...state.characters, character] })),
|
||||
updateCharacter: (token, updates) =>
|
||||
set((state) => ({
|
||||
characters: state.characters.map((c) =>
|
||||
c.character_token === token ? { ...c, ...updates } : c,
|
||||
),
|
||||
})),
|
||||
removeCharacter: (token) =>
|
||||
set((state) => ({
|
||||
characters: state.characters.filter((c) => c.character_token !== token),
|
||||
})),
|
||||
setLoaded: (loaded) => set({ loaded }),
|
||||
}));
|
||||
@@ -2,4 +2,7 @@ export { PromptBox } from "./PromptBox";
|
||||
export { ImagePromptRow } from "./ImagePromptRow";
|
||||
export { ImagePickerModal } from "./ImagePickerModal";
|
||||
export { MediaReferenceRow } from "./MediaReferenceRow";
|
||||
export { CharactersModal } from "./CharactersModal";
|
||||
export { useCharactersStore } from "./characters-store";
|
||||
export type { StoredCharacter } from "./characters-store";
|
||||
export type { RefImage, RefVideo, RefAudio, MentionItem } from "./types";
|
||||
|
||||
@@ -23,6 +23,6 @@ export interface RefAudio {
|
||||
|
||||
export interface MentionItem {
|
||||
label: string;
|
||||
type: "image" | "video" | "audio";
|
||||
type: "image" | "video" | "audio" | "character";
|
||||
preview?: string;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface ImageCostParams {
|
||||
model: string;
|
||||
aspectRatio?: string;
|
||||
resolution?: string;
|
||||
quality?: string;
|
||||
numImages: number;
|
||||
hasReferenceImages: boolean;
|
||||
imageMediaTokenCount?: number;
|
||||
@@ -29,6 +30,7 @@ export function useImageCostEstimate(params: ImageCostParams): number | null {
|
||||
model: params.model,
|
||||
aspect_ratio: params.aspectRatio ?? null,
|
||||
resolution: params.resolution ?? null,
|
||||
quality: params.quality ?? null,
|
||||
image_batch_count: params.numImages,
|
||||
image_media_tokens: params.hasReferenceImages
|
||||
? new Array(params.imageMediaTokenCount ?? 1).fill("placeholder")
|
||||
@@ -69,6 +71,7 @@ export interface VideoCostParams {
|
||||
aspectRatio?: string;
|
||||
resolution?: string | null;
|
||||
duration?: number | null;
|
||||
numVideos?: number;
|
||||
hasStartFrame: boolean;
|
||||
hasEndFrame: boolean;
|
||||
isReferenceMode: boolean;
|
||||
@@ -94,6 +97,7 @@ export function useVideoCostEstimate(params: VideoCostParams): number | null {
|
||||
resolution: params.resolution ?? null,
|
||||
duration_seconds: params.duration ?? null,
|
||||
generate_audio: params.generateAudio ?? null,
|
||||
video_batch_count: params.numVideos ?? 1,
|
||||
};
|
||||
|
||||
// Wire up frame/reference tokens based on mode
|
||||
@@ -128,6 +132,7 @@ export function useVideoCostEstimate(params: VideoCostParams): number | null {
|
||||
params.aspectRatio,
|
||||
params.resolution,
|
||||
params.duration,
|
||||
params.numVideos,
|
||||
params.hasStartFrame,
|
||||
params.hasEndFrame,
|
||||
params.isReferenceMode,
|
||||
|
||||
@@ -36,7 +36,7 @@ function fetchImageModelsOnce() {
|
||||
(res) => {
|
||||
imageModelsFetching = false;
|
||||
if (res.success && res.models?.length) {
|
||||
imageModelsCache = res.models;
|
||||
imageModelsCache = res.models.filter((m) => m.is_disabled !== true);
|
||||
} else {
|
||||
imageModelsError = "No image models returned from API";
|
||||
console.warn("[OmniGen] Image models response:", res);
|
||||
@@ -61,7 +61,7 @@ function fetchVideoModelsOnce() {
|
||||
(res) => {
|
||||
videoModelsFetching = false;
|
||||
if (res.success && res.models?.length) {
|
||||
videoModelsCache = res.models;
|
||||
videoModelsCache = res.models.filter((m) => m.is_disabled !== true);
|
||||
} else {
|
||||
videoModelsError = "No video models returned from API";
|
||||
console.warn("[OmniGen] Video models response:", res);
|
||||
|
||||
@@ -85,6 +85,7 @@ const ASPECT_RATIO_PROPORTIONS: Record<string, [number, number]> = {
|
||||
tall_nine_by_twenty_one: [9, 21],
|
||||
auto: [1, 1],
|
||||
auto_2k: [1, 1],
|
||||
auto_3k: [1, 1],
|
||||
auto_4k: [1, 1],
|
||||
};
|
||||
|
||||
|
||||
+2
-1
@@ -9,7 +9,7 @@ interface AspectRatioPickerProps {
|
||||
handleAspectRatioSelect: (selected: string) => void;
|
||||
}
|
||||
|
||||
const AUTO_RATIOS = new Set(["auto", "auto_2k", "auto_4k"]);
|
||||
const AUTO_RATIOS = new Set(["auto", "auto_2k", "auto_3k", "auto_4k"]);
|
||||
|
||||
export const AspectRatioPicker = ({
|
||||
aspectRatioOptions,
|
||||
@@ -74,6 +74,7 @@ const ASPECT_RATIO_LABELS: Record<string, string> = {
|
||||
tall_nine_by_sixteen: "9:16 (Tall)",
|
||||
tall_nine_by_twenty_one: "9:21 (Tall)",
|
||||
auto_2k: "Auto (2K)",
|
||||
auto_3k: "Auto (3K)",
|
||||
auto_4k: "Auto (4K)",
|
||||
square_hd: "Square (HD)",
|
||||
wide: "Wide",
|
||||
|
||||
+3
-1
@@ -10,6 +10,7 @@ interface GenerationCountPickerProps {
|
||||
batchSizeOptions?: number[] | null;
|
||||
currentCount: number;
|
||||
handleCountChange: (count: number) => void;
|
||||
panelTitle?: string;
|
||||
}
|
||||
|
||||
export const GenerationCountPicker = ({
|
||||
@@ -17,6 +18,7 @@ export const GenerationCountPicker = ({
|
||||
batchSizeOptions,
|
||||
currentCount,
|
||||
handleCountChange,
|
||||
panelTitle = "No. of images",
|
||||
}: GenerationCountPickerProps) => {
|
||||
const maxCount = batchSizeMax ?? DEFAULT_GENERATION_COUNT;
|
||||
const hasPredefinedOptions = !!batchSizeOptions?.length;
|
||||
@@ -59,7 +61,7 @@ export const GenerationCountPicker = ({
|
||||
items={generationCountOptions}
|
||||
onSelect={onSelect}
|
||||
mode="toggle"
|
||||
panelTitle="No. of images"
|
||||
panelTitle={panelTitle}
|
||||
triggerIcon={<FontAwesomeIcon icon={faCopy} className="h-4 w-4" />}
|
||||
buttonClassName="h-9"
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faGem } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { PopoverItem, PopoverMenu } from "@storyteller/ui-popover";
|
||||
import { Tooltip } from "@storyteller/ui-tooltip";
|
||||
|
||||
interface QualityPickerProps {
|
||||
qualityOptions: string[];
|
||||
defaultQuality?: string;
|
||||
currentQuality?: string;
|
||||
handleQualitySelect: (selected: string) => void;
|
||||
}
|
||||
|
||||
const QUALITY_LABELS: Record<string, string> = {
|
||||
high: "High",
|
||||
medium: "Medium",
|
||||
low: "Low",
|
||||
};
|
||||
|
||||
const LABEL_TO_QUALITY: Record<string, string> = Object.fromEntries(
|
||||
Object.entries(QUALITY_LABELS).map(([k, v]) => [v, k]),
|
||||
);
|
||||
|
||||
export const QualityPicker = ({
|
||||
qualityOptions,
|
||||
defaultQuality,
|
||||
currentQuality,
|
||||
handleQualitySelect,
|
||||
}: QualityPickerProps) => {
|
||||
const activeQuality = currentQuality ?? defaultQuality ?? undefined;
|
||||
|
||||
const handleSelectAdapter = (item: PopoverItem) => {
|
||||
const quality = LABEL_TO_QUALITY[item.label];
|
||||
if (quality) handleQualitySelect(quality);
|
||||
};
|
||||
|
||||
const qualityList: PopoverItem[] = qualityOptions.map((q) => ({
|
||||
label: QUALITY_LABELS[q] ?? q,
|
||||
selected: activeQuality === q,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
content="Quality"
|
||||
position="top"
|
||||
className="z-50"
|
||||
closeOnClick={true}
|
||||
>
|
||||
<PopoverMenu
|
||||
items={qualityList}
|
||||
onSelect={handleSelectAdapter}
|
||||
mode="toggle"
|
||||
panelTitle="Quality"
|
||||
triggerIcon={
|
||||
<FontAwesomeIcon icon={faGem} className="h-3.5 w-3.5" />
|
||||
}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
@@ -24,6 +24,7 @@ export type ImageUiState = {
|
||||
aspectRatio: string;
|
||||
numImages: number;
|
||||
resolution: string | undefined;
|
||||
quality: string | undefined;
|
||||
};
|
||||
|
||||
type CreateImageState = {
|
||||
@@ -49,6 +50,7 @@ const DEFAULT_UI: ImageUiState = {
|
||||
aspectRatio: "square",
|
||||
numImages: 1,
|
||||
resolution: undefined,
|
||||
quality: undefined,
|
||||
};
|
||||
|
||||
export const useCreateImageStore = create<CreateImageState>((set) => ({
|
||||
|
||||
@@ -2,7 +2,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { faImage } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { FilterMediaClasses } from "@storyteller/api";
|
||||
import type { OmniGenImageModelInfo } from "@storyteller/api";
|
||||
import { type PopoverItem } from "@storyteller/ui-popover";
|
||||
import { PopoverMenu, type PopoverItem } from "@storyteller/ui-popover";
|
||||
import { Tooltip } from "@storyteller/ui-tooltip";
|
||||
import {
|
||||
PromptBox,
|
||||
ImagePickerModal,
|
||||
@@ -19,13 +20,11 @@ import {
|
||||
} from "../../components/generation-gallery";
|
||||
import { Lightbox } from "../../components/lightbox/lightbox";
|
||||
import { useCreateImageStore } from "./create-image-store";
|
||||
import {
|
||||
enqueueImageGeneration,
|
||||
startPolling,
|
||||
} from "./generate-image-api";
|
||||
import { enqueueImageGeneration, startPolling } from "./generate-image-api";
|
||||
import { AspectRatioPicker } from "./components/AspectRatioPicker";
|
||||
import { GenerationCountPicker } from "./components/GenerationCountPicker";
|
||||
import { ResolutionPicker } from "./components/ResolutionPicker";
|
||||
import { QualityPicker } from "./components/QualityPicker";
|
||||
import { useImageCostEstimate } from "../../lib/cost-estimate-api";
|
||||
import {
|
||||
useOmniGenImageModels,
|
||||
@@ -77,9 +76,11 @@ export default function CreateImage() {
|
||||
const selectedModel = useMemo((): OmniGenImageModelInfo | undefined => {
|
||||
if (!apiModels.length) return undefined;
|
||||
if (ui.selectedModelId) {
|
||||
return apiModels.find((m) => m.model === ui.selectedModelId) ??
|
||||
return (
|
||||
apiModels.find((m) => m.model === ui.selectedModelId) ??
|
||||
apiModels.find((m) => m.model === DEFAULT_MODEL_ID) ??
|
||||
apiModels[0];
|
||||
apiModels[0]
|
||||
);
|
||||
}
|
||||
return apiModels.find((m) => m.model === DEFAULT_MODEL_ID) ?? apiModels[0];
|
||||
}, [apiModels, ui.selectedModelId]);
|
||||
@@ -101,13 +102,17 @@ export default function CreateImage() {
|
||||
(v: string | undefined) => setUi({ resolution: v }),
|
||||
[setUi],
|
||||
);
|
||||
const quality = ui.quality;
|
||||
const setQuality = useCallback(
|
||||
(v: string | undefined) => setUi({ quality: v }),
|
||||
[setUi],
|
||||
);
|
||||
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [referenceImages, setReferenceImages] = useState<RefImage[]>([]);
|
||||
const [isImagePickerOpen, setIsImagePickerOpen] = useState(false);
|
||||
|
||||
// Batch store (enqueue flow only)
|
||||
const batches = useCreateImageStore((s) => s.batches);
|
||||
const startBatch = useCreateImageStore((s) => s.startBatch);
|
||||
const setBatchJobToken = useCreateImageStore((s) => s.setBatchJobToken);
|
||||
const completeBatch = useCreateImageStore((s) => s.completeBatch);
|
||||
@@ -128,20 +133,25 @@ export default function CreateImage() {
|
||||
|
||||
// Lightbox
|
||||
const flatItems = useMemo(() => {
|
||||
const filtered = gallery.items.filter((i) => !newlyCompletedTokens.has(i.id));
|
||||
const filtered = gallery.items.filter(
|
||||
(i) => !newlyCompletedTokens.has(i.id),
|
||||
);
|
||||
return [...jobs.newlyCompleted, ...filtered];
|
||||
}, [jobs.newlyCompleted, gallery.items, newlyCompletedTokens]);
|
||||
|
||||
const lightbox = useLightboxNav(flatItems);
|
||||
|
||||
// Derived
|
||||
const hasAspectRatios = (selectedModel?.aspect_ratio_options?.length ?? 0) > 0;
|
||||
const hasAspectRatios =
|
||||
(selectedModel?.aspect_ratio_options?.length ?? 0) > 0;
|
||||
const hasResolutions = (selectedModel?.resolution_options?.length ?? 0) > 0;
|
||||
const hasQualityOptions = (selectedModel?.quality_options?.length ?? 0) > 0;
|
||||
|
||||
const estimatedCredits = useImageCostEstimate({
|
||||
model: selectedModel?.model ?? "",
|
||||
aspectRatio: aspectRatio,
|
||||
resolution: hasResolutions ? resolution : undefined,
|
||||
quality: hasQualityOptions ? quality : undefined,
|
||||
numImages,
|
||||
hasReferenceImages: referenceImages.length > 0,
|
||||
});
|
||||
@@ -205,6 +215,7 @@ export default function CreateImage() {
|
||||
model.batch_size_default ?? 1,
|
||||
),
|
||||
resolution: model.resolution_default ?? undefined,
|
||||
quality: model.default_quality ?? undefined,
|
||||
});
|
||||
},
|
||||
[setUi],
|
||||
@@ -214,12 +225,14 @@ export default function CreateImage() {
|
||||
(images: { token: string; url: string; thumbnailUrl: string }[]) => {
|
||||
const maxImages = selectedModel?.image_refs_max ?? 1;
|
||||
const availableSlots = Math.max(0, maxImages - referenceImages.length);
|
||||
const newImages: RefImage[] = images.slice(0, availableSlots).map((img) => ({
|
||||
id: Math.random().toString(36).substring(7),
|
||||
url: img.thumbnailUrl || img.url,
|
||||
file: new File([], "library-image"),
|
||||
mediaToken: img.token,
|
||||
}));
|
||||
const newImages: RefImage[] = images
|
||||
.slice(0, availableSlots)
|
||||
.map((img) => ({
|
||||
id: Math.random().toString(36).substring(7),
|
||||
url: img.thumbnailUrl || img.url,
|
||||
file: new File([], "library-image"),
|
||||
mediaToken: img.token,
|
||||
}));
|
||||
setReferenceImages([...referenceImages, ...newImages]);
|
||||
},
|
||||
[referenceImages, selectedModel],
|
||||
@@ -248,7 +261,10 @@ export default function CreateImage() {
|
||||
numImages,
|
||||
aspectRatio: aspectRatio,
|
||||
resolution: hasResolutions ? resolution : undefined,
|
||||
imageMediaTokens: imageMediaTokens?.length ? imageMediaTokens : undefined,
|
||||
quality: hasQualityOptions ? quality : undefined,
|
||||
imageMediaTokens: imageMediaTokens?.length
|
||||
? imageMediaTokens
|
||||
: undefined,
|
||||
});
|
||||
|
||||
if (!result.success || !result.jobToken) {
|
||||
@@ -281,8 +297,20 @@ export default function CreateImage() {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
}, [
|
||||
prompt, isGenerating, selectedModel, numImages, aspectRatio, resolution,
|
||||
hasResolutions, referenceImages, startBatch, setBatchJobToken, completeBatch, failBatch,
|
||||
prompt,
|
||||
isGenerating,
|
||||
selectedModel,
|
||||
numImages,
|
||||
aspectRatio,
|
||||
resolution,
|
||||
hasResolutions,
|
||||
quality,
|
||||
hasQualityOptions,
|
||||
referenceImages,
|
||||
startBatch,
|
||||
setBatchJobToken,
|
||||
completeBatch,
|
||||
failBatch,
|
||||
]);
|
||||
|
||||
// ── Render ────────────────────────────────────────────────────────────
|
||||
@@ -318,7 +346,7 @@ export default function CreateImage() {
|
||||
}
|
||||
promptBox={
|
||||
<div
|
||||
className="animate-fade-in-up fixed bottom-3 left-0 right-0 z-30 mx-auto w-full max-w-[730px] px-4"
|
||||
className="animate-fade-in-up fixed bottom-2 sm:bottom-3 left-0 right-0 z-30 mx-auto w-full max-w-[900px] px-2 sm:px-4"
|
||||
style={{ animationDelay: "150ms" }}
|
||||
>
|
||||
<PromptBox
|
||||
@@ -330,18 +358,38 @@ export default function CreateImage() {
|
||||
credits={estimatedCredits}
|
||||
placeholder="Describe what you want in the image..."
|
||||
supportsImagePrompts={!!selectedModel?.image_refs_supported}
|
||||
maxImagePromptCount={selectedModel?.image_refs_max ?? 1}
|
||||
maxImagePromptCount={selectedModel?.image_refs_max ?? 6}
|
||||
referenceImages={referenceImages}
|
||||
onReferenceImagesChange={setReferenceImages}
|
||||
onPickFromLibrary={() => setIsImagePickerOpen(true)}
|
||||
showClearSession={batches.length > 0}
|
||||
onClearSession={useCreateImageStore.getState().reset}
|
||||
modelSelector={
|
||||
<Tooltip content="Model" position="top" className="z-50" closeOnClick>
|
||||
<PopoverMenu
|
||||
items={modelItems}
|
||||
onSelect={handleModelChange}
|
||||
mode="toggle"
|
||||
panelTitle="Select Model"
|
||||
showIconsInList
|
||||
triggerIcon={
|
||||
<img
|
||||
src={getModelCreatorIconPath(selectedModel?.model ?? "")}
|
||||
alt=""
|
||||
className="h-4 w-4 icon-auto-contrast"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Tooltip>
|
||||
}
|
||||
leftToolbar={
|
||||
<>
|
||||
{hasAspectRatios && selectedModel && (
|
||||
<AspectRatioPicker
|
||||
aspectRatioOptions={selectedModel.aspect_ratio_options ?? []}
|
||||
defaultAspectRatio={selectedModel.aspect_ratio_default ?? undefined}
|
||||
aspectRatioOptions={
|
||||
selectedModel.aspect_ratio_options ?? []
|
||||
}
|
||||
defaultAspectRatio={
|
||||
selectedModel.aspect_ratio_default ?? undefined
|
||||
}
|
||||
currentAspectRatio={aspectRatio}
|
||||
handleAspectRatioSelect={setAspectRatio}
|
||||
/>
|
||||
@@ -349,11 +397,23 @@ export default function CreateImage() {
|
||||
{hasResolutions && selectedModel && (
|
||||
<ResolutionPicker
|
||||
resolutionOptions={selectedModel.resolution_options ?? []}
|
||||
defaultResolution={selectedModel.resolution_default ?? undefined}
|
||||
defaultResolution={
|
||||
selectedModel.resolution_default ?? undefined
|
||||
}
|
||||
currentResolution={resolution}
|
||||
handleResolutionSelect={setResolution}
|
||||
/>
|
||||
)}
|
||||
{hasQualityOptions && selectedModel && (
|
||||
<QualityPicker
|
||||
qualityOptions={selectedModel.quality_options ?? []}
|
||||
defaultQuality={
|
||||
selectedModel.default_quality ?? undefined
|
||||
}
|
||||
currentQuality={quality}
|
||||
handleQualitySelect={setQuality}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
rightToolbar={
|
||||
@@ -385,6 +445,7 @@ export default function CreateImage() {
|
||||
cdnUrl={lightbox.lightboxItem?.fullImage}
|
||||
mediaClass={lightbox.lightboxItem?.mediaClass}
|
||||
batchImageToken={lightbox.lightboxItem?.batchImageToken}
|
||||
showBatchCarousel={false}
|
||||
onNavigatePrev={lightbox.navigatePrev}
|
||||
onNavigateNext={lightbox.navigateNext}
|
||||
onDeleted={gallery.removeItem}
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface GenerateImageParams {
|
||||
numImages?: number;
|
||||
aspectRatio?: string;
|
||||
resolution?: string;
|
||||
quality?: string;
|
||||
imageMediaTokens?: string[];
|
||||
}
|
||||
|
||||
@@ -24,6 +25,7 @@ export async function enqueueImageGeneration(
|
||||
idempotency_token: crypto.randomUUID(),
|
||||
aspect_ratio: params.aspectRatio ?? null,
|
||||
resolution: params.resolution ?? null,
|
||||
quality: params.quality ?? null,
|
||||
image_batch_count: params.numImages ?? 1,
|
||||
image_media_tokens: params.imageMediaTokens?.length
|
||||
? params.imageMediaTokens
|
||||
|
||||
@@ -27,6 +27,7 @@ export type VideoUiState = {
|
||||
resolution: string | null;
|
||||
generateWithSound: boolean;
|
||||
inputMode: VideoInputMode;
|
||||
numVideos: number;
|
||||
};
|
||||
|
||||
type CreateVideoState = {
|
||||
@@ -50,6 +51,7 @@ const DEFAULT_UI: VideoUiState = {
|
||||
resolution: null,
|
||||
generateWithSound: false,
|
||||
inputMode: "keyframe",
|
||||
numVideos: 1,
|
||||
};
|
||||
|
||||
export const useCreateVideoStore = create<CreateVideoState>((set) => ({
|
||||
|
||||
@@ -3,18 +3,20 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
faClock,
|
||||
faFilm,
|
||||
faTriangleExclamation,
|
||||
faWaveformLines,
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import { FilterMediaClasses } from "@storyteller/api";
|
||||
import { CharactersApi, FilterMediaClasses } from "@storyteller/api";
|
||||
import type { OmniGenVideoModelInfo } from "@storyteller/api";
|
||||
import { ToggleButton } from "@storyteller/ui-button";
|
||||
import { PopoverMenu, type PopoverItem } from "@storyteller/ui-popover";
|
||||
import { SliderV2 } from "@storyteller/ui-sliderv2";
|
||||
import { Tooltip } from "@storyteller/ui-tooltip";
|
||||
import {
|
||||
PromptBox,
|
||||
ImagePickerModal,
|
||||
MediaReferenceRow,
|
||||
CharactersModal,
|
||||
useCharactersStore,
|
||||
type RefImage,
|
||||
type RefVideo,
|
||||
type RefAudio,
|
||||
@@ -35,7 +37,11 @@ import {
|
||||
enqueueVideoGeneration,
|
||||
startVideoPolling,
|
||||
} from "./generate-video-api";
|
||||
import { AspectRatioIcon, AutoIcon } from "../create-image/components/AspectRatioIcon";
|
||||
import {
|
||||
AspectRatioIcon,
|
||||
AutoIcon,
|
||||
} from "../create-image/components/AspectRatioIcon";
|
||||
import { GenerationCountPicker } from "../create-image/components/GenerationCountPicker";
|
||||
import { useVideoCostEstimate } from "../../lib/cost-estimate-api";
|
||||
import {
|
||||
useOmniGenVideoModels,
|
||||
@@ -49,7 +55,7 @@ const DEFAULT_MODEL_ID = "seedance_2p0";
|
||||
|
||||
const VIDEO_FILTER = [FilterMediaClasses.VIDEO];
|
||||
|
||||
const AUTO_RATIOS = new Set(["auto", "auto_2k", "auto_4k"]);
|
||||
const AUTO_RATIOS = new Set(["auto", "auto_2k", "auto_3k", "auto_4k"]);
|
||||
|
||||
// ── Aspect ratio labels (shared with image page) ─────────────────────────
|
||||
|
||||
@@ -67,12 +73,28 @@ const AR_LABELS: Record<string, string> = {
|
||||
tall_nine_by_sixteen: "9:16 (Tall)",
|
||||
tall_nine_by_twenty_one: "9:21 (Tall)",
|
||||
auto_2k: "Auto (2K)",
|
||||
auto_3k: "Auto (3K)",
|
||||
auto_4k: "Auto (4K)",
|
||||
square_hd: "Square (HD)",
|
||||
wide: "Wide",
|
||||
tall: "Tall",
|
||||
};
|
||||
|
||||
const RES_LABELS: Record<string, string> = {
|
||||
half_k: "0.5K",
|
||||
four_eighty_p: "480p",
|
||||
seven_twenty_p: "720p",
|
||||
one_k: "1K",
|
||||
ten_eighty_p: "1080p",
|
||||
two_k: "2K",
|
||||
three_k: "3K",
|
||||
four_k: "4K",
|
||||
};
|
||||
|
||||
const LABEL_TO_RES: Record<string, string> = Object.fromEntries(
|
||||
Object.entries(RES_LABELS).map(([k, v]) => [v, k]),
|
||||
);
|
||||
|
||||
// ── Model lookup ─────────────────────────────────────────────────────────
|
||||
|
||||
let _modelLookup = new Map<string, OmniGenVideoModelInfo>();
|
||||
@@ -112,6 +134,32 @@ function buildSizePopoverItems(
|
||||
}));
|
||||
}
|
||||
|
||||
function resolveDurationForModel(
|
||||
model: OmniGenVideoModelInfo,
|
||||
current: number | null,
|
||||
): number | null {
|
||||
if (current == null) return model.duration_seconds_default ?? null;
|
||||
if (
|
||||
model.duration_seconds_min != null &&
|
||||
model.duration_seconds_max != null
|
||||
) {
|
||||
if (
|
||||
current >= model.duration_seconds_min &&
|
||||
current <= model.duration_seconds_max
|
||||
) {
|
||||
return current;
|
||||
}
|
||||
return model.duration_seconds_default ?? model.duration_seconds_min;
|
||||
}
|
||||
if (model.duration_seconds_options?.length) {
|
||||
if (model.duration_seconds_options.includes(current)) return current;
|
||||
return (
|
||||
model.duration_seconds_default ?? model.duration_seconds_options[0]!
|
||||
);
|
||||
}
|
||||
return model.duration_seconds_default ?? null;
|
||||
}
|
||||
|
||||
// ── Component ────────────────────────────────────────────────────────────
|
||||
|
||||
export default function CreateVideo() {
|
||||
@@ -128,9 +176,11 @@ export default function CreateVideo() {
|
||||
const selectedModel = useMemo((): OmniGenVideoModelInfo | undefined => {
|
||||
if (!apiModels.length) return undefined;
|
||||
if (ui.selectedModelId) {
|
||||
return apiModels.find((m) => m.model === ui.selectedModelId) ??
|
||||
return (
|
||||
apiModels.find((m) => m.model === ui.selectedModelId) ??
|
||||
apiModels.find((m) => m.model === DEFAULT_MODEL_ID) ??
|
||||
apiModels[0];
|
||||
apiModels[0]
|
||||
);
|
||||
}
|
||||
return apiModels.find((m) => m.model === DEFAULT_MODEL_ID) ?? apiModels[0];
|
||||
}, [apiModels, ui.selectedModelId]);
|
||||
@@ -153,6 +203,11 @@ export default function CreateVideo() {
|
||||
[setUi],
|
||||
);
|
||||
const generateWithSound = ui.generateWithSound;
|
||||
const numVideos = ui.numVideos;
|
||||
const setNumVideos = useCallback(
|
||||
(v: number) => setUi({ numVideos: v }),
|
||||
[setUi],
|
||||
);
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
|
||||
// Reference media
|
||||
@@ -161,9 +216,37 @@ export default function CreateVideo() {
|
||||
const [referenceVideos, setReferenceVideos] = useState<RefVideo[]>([]);
|
||||
const [referenceAudios, setReferenceAudios] = useState<RefAudio[]>([]);
|
||||
const [isImagePickerOpen, setIsImagePickerOpen] = useState(false);
|
||||
const [isEndFramePickerOpen, setIsEndFramePickerOpen] = useState(false);
|
||||
const [isCharactersModalOpen, setIsCharactersModalOpen] = useState(false);
|
||||
|
||||
// Characters store for @-mentions
|
||||
const storedCharacters = useCharactersStore((s) => s.characters);
|
||||
const charactersLoaded = useCharactersStore((s) => s.loaded);
|
||||
const storeSetCharacters = useCharactersStore((s) => s.setCharacters);
|
||||
const storeSetLoaded = useCharactersStore((s) => s.setLoaded);
|
||||
|
||||
// Load characters on mount if not already loaded
|
||||
useEffect(() => {
|
||||
if (charactersLoaded) return;
|
||||
const api = new CharactersApi();
|
||||
api
|
||||
.ListCharacters()
|
||||
.then((res) => {
|
||||
if (res.success && res.data) {
|
||||
storeSetCharacters(
|
||||
res.data.map((c) => ({
|
||||
character_token: c.token,
|
||||
name: c.name,
|
||||
avatar_image_url: c.maybe_avatar?.cdn_url,
|
||||
})),
|
||||
);
|
||||
}
|
||||
storeSetLoaded(true);
|
||||
})
|
||||
.catch(() => storeSetLoaded(true));
|
||||
}, [charactersLoaded, storeSetCharacters, storeSetLoaded]);
|
||||
|
||||
// Batch store (enqueue flow only)
|
||||
const batches = useCreateVideoStore((s) => s.batches);
|
||||
const startBatch = useCreateVideoStore((s) => s.startBatch);
|
||||
const setBatchJobToken = useCreateVideoStore((s) => s.setBatchJobToken);
|
||||
const completeBatch = useCreateVideoStore((s) => s.completeBatch);
|
||||
@@ -172,7 +255,8 @@ export default function CreateVideo() {
|
||||
|
||||
// Derived model capabilities
|
||||
const hasSizeOptions = (selectedModel?.aspect_ratio_options?.length ?? 0) > 0;
|
||||
const hasResolutionOptions = (selectedModel?.resolution_options?.length ?? 0) > 0;
|
||||
const hasResolutionOptions =
|
||||
(selectedModel?.resolution_options?.length ?? 0) > 0;
|
||||
const hasSound = !!selectedModel?.show_generate_with_sound_toggle;
|
||||
const supportsImagePrompts =
|
||||
!!selectedModel?.starting_keyframe_supported ||
|
||||
@@ -184,8 +268,11 @@ export default function CreateVideo() {
|
||||
!!selectedModel?.audio_references_supported;
|
||||
const inputMode = ui.inputMode;
|
||||
const isReferenceMode = supportsRefMode && inputMode === "reference";
|
||||
const hasEndFrame = !!(selectedModel?.ending_keyframe_supported && !isReferenceMode);
|
||||
const needsImage = !!selectedModel?.starting_keyframe_required && referenceImages.length === 0;
|
||||
const hasEndFrame = !!(
|
||||
selectedModel?.ending_keyframe_supported && !isReferenceMode
|
||||
);
|
||||
const needsImage =
|
||||
!!selectedModel?.starting_keyframe_required && referenceImages.length === 0;
|
||||
|
||||
// Jobs + gallery
|
||||
const jobs = useGenerationJobs({ mediaType: "video" });
|
||||
@@ -201,7 +288,9 @@ export default function CreateVideo() {
|
||||
|
||||
// Lightbox
|
||||
const flatItems = useMemo(() => {
|
||||
const filtered = gallery.items.filter((i) => !newlyCompletedTokens.has(i.id));
|
||||
const filtered = gallery.items.filter(
|
||||
(i) => !newlyCompletedTokens.has(i.id),
|
||||
);
|
||||
return [...jobs.newlyCompleted, ...filtered];
|
||||
}, [jobs.newlyCompleted, gallery.items, newlyCompletedTokens]);
|
||||
|
||||
@@ -213,6 +302,7 @@ export default function CreateVideo() {
|
||||
aspectRatio: selectedSize,
|
||||
resolution,
|
||||
duration: duration ?? selectedModel?.duration_seconds_default ?? null,
|
||||
numVideos,
|
||||
hasStartFrame: !isReferenceMode && referenceImages.length > 0,
|
||||
hasEndFrame: !isReferenceMode && hasEndFrame && !!endFrameImage,
|
||||
isReferenceMode,
|
||||
@@ -220,51 +310,102 @@ export default function CreateVideo() {
|
||||
generateAudio: hasSound ? generateWithSound : undefined,
|
||||
});
|
||||
|
||||
// Characters are only supported for seedance_2p0
|
||||
const isSeedance2p0 = selectedModel?.model === "seedance_2p0";
|
||||
const activeCharacters = isSeedance2p0 ? storedCharacters : [];
|
||||
|
||||
// Popover items
|
||||
const mentionItems = useMemo((): MentionItem[] => {
|
||||
if (!isReferenceMode) return [];
|
||||
return [
|
||||
...referenceImages.map((img, i) => ({
|
||||
label: `@Image${i + 1}`,
|
||||
type: "image" as const,
|
||||
preview: img.url,
|
||||
})),
|
||||
...referenceVideos.map((vid, i) => ({
|
||||
label: `@Video${i + 1}`,
|
||||
type: "video" as const,
|
||||
preview: vid.url,
|
||||
})),
|
||||
...referenceAudios.map((_aud, i) => ({
|
||||
label: `@Audio${i + 1}`,
|
||||
type: "audio" as const,
|
||||
preview: undefined,
|
||||
})),
|
||||
];
|
||||
}, [isReferenceMode, referenceImages, referenceVideos, referenceAudios]);
|
||||
const refItems: MentionItem[] = isReferenceMode
|
||||
? [
|
||||
...referenceImages.map((img, i) => ({
|
||||
label: `@Image${i + 1}`,
|
||||
type: "image" as const,
|
||||
preview: img.url,
|
||||
})),
|
||||
...referenceVideos.map((vid, i) => ({
|
||||
label: `@Video${i + 1}`,
|
||||
type: "video" as const,
|
||||
preview: vid.url,
|
||||
})),
|
||||
...referenceAudios.map((_aud, i) => ({
|
||||
label: `@Audio${i + 1}`,
|
||||
type: "audio" as const,
|
||||
preview: undefined,
|
||||
})),
|
||||
]
|
||||
: [];
|
||||
const charItems: MentionItem[] = activeCharacters.map((char) => ({
|
||||
label: `@${char.name}`,
|
||||
type: "character" as const,
|
||||
preview: char.avatar_image_url,
|
||||
}));
|
||||
return [...refItems, ...charItems];
|
||||
}, [
|
||||
isReferenceMode,
|
||||
referenceImages,
|
||||
referenceVideos,
|
||||
referenceAudios,
|
||||
activeCharacters,
|
||||
]);
|
||||
|
||||
const modelItems = useMemo(
|
||||
() => buildModelPopoverItems(apiModels, selectedModel?.model ?? ""),
|
||||
[apiModels, selectedModel?.model],
|
||||
);
|
||||
const sizeItems = useMemo(
|
||||
() => buildSizePopoverItems(selectedModel?.aspect_ratio_options ?? [], selectedSize),
|
||||
() =>
|
||||
buildSizePopoverItems(
|
||||
selectedModel?.aspect_ratio_options ?? [],
|
||||
selectedSize,
|
||||
),
|
||||
[selectedModel?.aspect_ratio_options, selectedSize],
|
||||
);
|
||||
const durationItems = useMemo(
|
||||
(): PopoverItem[] | null =>
|
||||
selectedModel?.duration_seconds_options
|
||||
? selectedModel.duration_seconds_options.map((d) => ({
|
||||
label: `${d}s`,
|
||||
selected: d === (duration ?? selectedModel.duration_seconds_default),
|
||||
}))
|
||||
: null,
|
||||
[selectedModel, duration],
|
||||
const durationRange = useMemo((): { min: number; max: number } | null => {
|
||||
if (!selectedModel) return null;
|
||||
if (
|
||||
selectedModel.duration_seconds_min != null &&
|
||||
selectedModel.duration_seconds_max != null &&
|
||||
selectedModel.duration_seconds_max > selectedModel.duration_seconds_min
|
||||
) {
|
||||
return {
|
||||
min: selectedModel.duration_seconds_min,
|
||||
max: selectedModel.duration_seconds_max,
|
||||
};
|
||||
}
|
||||
if (
|
||||
selectedModel.duration_seconds_options &&
|
||||
selectedModel.duration_seconds_options.length > 1
|
||||
) {
|
||||
const opts = [...selectedModel.duration_seconds_options].sort(
|
||||
(a, b) => a - b,
|
||||
);
|
||||
return { min: opts[0]!, max: opts[opts.length - 1]! };
|
||||
}
|
||||
return null;
|
||||
}, [selectedModel]);
|
||||
const effectiveDuration =
|
||||
duration ?? selectedModel?.duration_seconds_default ?? 5;
|
||||
const [localDuration, setLocalDuration] = useState(effectiveDuration);
|
||||
const durationTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
useEffect(() => {
|
||||
clearTimeout(durationTimerRef.current);
|
||||
setLocalDuration(effectiveDuration);
|
||||
return () => clearTimeout(durationTimerRef.current);
|
||||
}, [effectiveDuration]);
|
||||
const handleDurationSlide = useCallback(
|
||||
(v: number) => {
|
||||
setLocalDuration(v);
|
||||
clearTimeout(durationTimerRef.current);
|
||||
durationTimerRef.current = setTimeout(() => setDuration(v), 300);
|
||||
},
|
||||
[setDuration],
|
||||
);
|
||||
const resolutionItems = useMemo(
|
||||
(): PopoverItem[] | null =>
|
||||
selectedModel?.resolution_options
|
||||
? selectedModel.resolution_options.map((r) => ({
|
||||
label: r,
|
||||
label: RES_LABELS[r] ?? r,
|
||||
selected: r === (resolution ?? selectedModel.resolution_default),
|
||||
}))
|
||||
: null,
|
||||
@@ -334,18 +475,40 @@ export default function CreateVideo() {
|
||||
(item: PopoverItem) => {
|
||||
const model = item.action ? _modelLookup.get(item.action) : undefined;
|
||||
if (!model) return;
|
||||
const currentDuration = useCreateVideoStore.getState().ui.duration;
|
||||
const nextDuration = resolveDurationForModel(model, currentDuration);
|
||||
setUi({
|
||||
selectedModelId: model.model,
|
||||
selectedSize: model.aspect_ratio_default ?? "wide_sixteen_by_nine",
|
||||
duration: model.duration_seconds_default ?? null,
|
||||
duration: nextDuration,
|
||||
resolution: model.resolution_default ?? null,
|
||||
generateWithSound: false,
|
||||
inputMode: "keyframe",
|
||||
numVideos: Math.min(
|
||||
model.batch_size_max ?? 4,
|
||||
model.batch_size_default ?? 1,
|
||||
),
|
||||
});
|
||||
setReferenceImages([]);
|
||||
setEndFrameImage(undefined);
|
||||
setReferenceVideos([]);
|
||||
setReferenceAudios([]);
|
||||
|
||||
// Only clear media that the new model doesn't support
|
||||
const newSupportsKeyframe =
|
||||
!!model.starting_keyframe_supported ||
|
||||
!!model.starting_keyframe_required;
|
||||
if (!newSupportsKeyframe) {
|
||||
setReferenceImages([]);
|
||||
}
|
||||
if (!model.ending_keyframe_supported) {
|
||||
setEndFrameImage(undefined);
|
||||
}
|
||||
|
||||
const newSupportsRefs =
|
||||
!!model.image_references_supported ||
|
||||
!!model.video_references_supported ||
|
||||
!!model.audio_references_supported;
|
||||
if (!newSupportsRefs) {
|
||||
setReferenceVideos([]);
|
||||
setReferenceAudios([]);
|
||||
}
|
||||
},
|
||||
[setUi],
|
||||
);
|
||||
@@ -357,16 +520,9 @@ export default function CreateVideo() {
|
||||
[setSelectedSize],
|
||||
);
|
||||
|
||||
const handleDurationChange = useCallback(
|
||||
(item: PopoverItem) => {
|
||||
const seconds = parseInt(item.label, 10);
|
||||
if (!isNaN(seconds)) setDuration(seconds);
|
||||
},
|
||||
[setDuration],
|
||||
);
|
||||
|
||||
const handleResolutionChange = useCallback(
|
||||
(item: PopoverItem) => setResolution(item.label),
|
||||
(item: PopoverItem) =>
|
||||
setResolution(LABEL_TO_RES[item.label] ?? item.label),
|
||||
[setResolution],
|
||||
);
|
||||
|
||||
@@ -387,96 +543,165 @@ export default function CreateVideo() {
|
||||
|
||||
const handleLibraryImageSelect = useCallback(
|
||||
(images: { token: string; url: string; thumbnailUrl: string }[]) => {
|
||||
const maxImages = isReferenceMode ? (selectedModel?.image_references_max ?? 3) : 1;
|
||||
const maxImages = isReferenceMode
|
||||
? (selectedModel?.image_references_max ?? 3)
|
||||
: 1;
|
||||
const availableSlots = Math.max(0, maxImages - referenceImages.length);
|
||||
const newImages: RefImage[] = images.slice(0, availableSlots).map((img) => ({
|
||||
id: Math.random().toString(36).substring(7),
|
||||
url: img.thumbnailUrl || img.url,
|
||||
file: new File([], "library-image"),
|
||||
mediaToken: img.token,
|
||||
}));
|
||||
const newImages: RefImage[] = images
|
||||
.slice(0, availableSlots)
|
||||
.map((img) => ({
|
||||
id: Math.random().toString(36).substring(7),
|
||||
url: img.thumbnailUrl || img.url,
|
||||
file: new File([], "library-image"),
|
||||
mediaToken: img.token,
|
||||
}));
|
||||
setReferenceImages([...referenceImages, ...newImages]);
|
||||
},
|
||||
[referenceImages, isReferenceMode, selectedModel],
|
||||
);
|
||||
|
||||
const handleEndFrameLibrarySelect = useCallback(
|
||||
(images: { token: string; url: string; thumbnailUrl: string }[]) => {
|
||||
const img = images[0];
|
||||
if (!img) return;
|
||||
setEndFrameImage({
|
||||
id: Math.random().toString(36).substring(7),
|
||||
url: img.thumbnailUrl || img.url,
|
||||
file: new File([], "library-image"),
|
||||
mediaToken: img.token,
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleGenerate = useCallback(async () => {
|
||||
if (!prompt.trim() || isGenerating || needsImage || !selectedModel) return;
|
||||
setIsGenerating(true);
|
||||
const batchId = startBatch(prompt, selectedModel.full_name ?? selectedModel.model);
|
||||
|
||||
try {
|
||||
const startFrameToken =
|
||||
!isReferenceMode && supportsImagePrompts && referenceImages.length > 0
|
||||
? referenceImages[0].mediaToken
|
||||
: undefined;
|
||||
const endFrameToken =
|
||||
!isReferenceMode && hasEndFrame && endFrameImage?.mediaToken
|
||||
? endFrameImage.mediaToken
|
||||
: undefined;
|
||||
const referenceImageTokens =
|
||||
isReferenceMode && referenceImages.length > 0
|
||||
? referenceImages.map((img) => img.mediaToken).filter((t) => t.length > 0)
|
||||
: undefined;
|
||||
const referenceVideoTokens =
|
||||
isReferenceMode && referenceVideos.length > 0
|
||||
? referenceVideos.map((v) => v.mediaToken).filter((t) => t.length > 0)
|
||||
: undefined;
|
||||
const referenceAudioTokens =
|
||||
isReferenceMode && referenceAudios.length > 0
|
||||
? referenceAudios.map((a) => a.mediaToken).filter((t) => t.length > 0)
|
||||
: undefined;
|
||||
const startFrameToken =
|
||||
!isReferenceMode && supportsImagePrompts && referenceImages.length > 0
|
||||
? referenceImages[0].mediaToken
|
||||
: undefined;
|
||||
const endFrameToken =
|
||||
!isReferenceMode && hasEndFrame && endFrameImage?.mediaToken
|
||||
? endFrameImage.mediaToken
|
||||
: undefined;
|
||||
const referenceImageTokens =
|
||||
isReferenceMode && referenceImages.length > 0
|
||||
? referenceImages
|
||||
.map((img) => img.mediaToken)
|
||||
.filter((t) => t.length > 0)
|
||||
: undefined;
|
||||
const referenceVideoTokens =
|
||||
isReferenceMode && referenceVideos.length > 0
|
||||
? referenceVideos.map((v) => v.mediaToken).filter((t) => t.length > 0)
|
||||
: undefined;
|
||||
const referenceAudioTokens =
|
||||
isReferenceMode && referenceAudios.length > 0
|
||||
? referenceAudios.map((a) => a.mediaToken).filter((t) => t.length > 0)
|
||||
: undefined;
|
||||
|
||||
const result = await enqueueVideoGeneration({
|
||||
prompt: prompt.trim(),
|
||||
model: selectedModel.model,
|
||||
aspectRatio: selectedSize,
|
||||
duration: duration ?? selectedModel.duration_seconds_default ?? undefined,
|
||||
resolution: hasResolutionOptions
|
||||
? (resolution ?? selectedModel.resolution_default ?? undefined)
|
||||
: undefined,
|
||||
generateAudio: hasSound ? generateWithSound : undefined,
|
||||
startFrameImageMediaToken: startFrameToken?.length ? startFrameToken : undefined,
|
||||
endFrameImageMediaToken: endFrameToken?.length ? endFrameToken : undefined,
|
||||
referenceImageMediaTokens: referenceImageTokens?.length ? referenceImageTokens : undefined,
|
||||
referenceVideoMediaTokens: referenceVideoTokens?.length ? referenceVideoTokens : undefined,
|
||||
referenceAudioMediaTokens: referenceAudioTokens?.length ? referenceAudioTokens : undefined,
|
||||
});
|
||||
// Extract character tokens from @-mentions in prompt
|
||||
const mentionedCharacters = activeCharacters.filter((c) =>
|
||||
prompt.includes(`@${c.name}`),
|
||||
);
|
||||
const referenceCharacterTokens =
|
||||
mentionedCharacters.length > 0
|
||||
? mentionedCharacters.map((c) => c.character_token)
|
||||
: undefined;
|
||||
|
||||
if (!result.success || !result.jobToken) {
|
||||
failBatch(batchId, result.error ?? "Failed to start generation");
|
||||
setIsGenerating(false);
|
||||
return;
|
||||
const baseParams = {
|
||||
prompt: prompt.trim(),
|
||||
model: selectedModel.model,
|
||||
numVideos: 1,
|
||||
aspectRatio: selectedSize,
|
||||
duration:
|
||||
duration ?? selectedModel.duration_seconds_default ?? undefined,
|
||||
resolution: hasResolutionOptions
|
||||
? (resolution ?? selectedModel.resolution_default ?? undefined)
|
||||
: undefined,
|
||||
generateAudio: hasSound ? generateWithSound : undefined,
|
||||
startFrameImageMediaToken: startFrameToken?.length
|
||||
? startFrameToken
|
||||
: undefined,
|
||||
endFrameImageMediaToken: endFrameToken?.length
|
||||
? endFrameToken
|
||||
: undefined,
|
||||
referenceImageMediaTokens: referenceImageTokens?.length
|
||||
? referenceImageTokens
|
||||
: undefined,
|
||||
referenceVideoMediaTokens: referenceVideoTokens?.length
|
||||
? referenceVideoTokens
|
||||
: undefined,
|
||||
referenceAudioMediaTokens: referenceAudioTokens?.length
|
||||
? referenceAudioTokens
|
||||
: undefined,
|
||||
referenceCharacterTokens,
|
||||
};
|
||||
|
||||
// Enqueue each video as a separate job so they complete independently
|
||||
const modelLabel = selectedModel.full_name ?? selectedModel.model;
|
||||
const count = Math.max(1, numVideos);
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const batchId = startBatch(prompt, modelLabel);
|
||||
|
||||
try {
|
||||
const result = await enqueueVideoGeneration(baseParams);
|
||||
|
||||
if (!result.success || !result.jobToken) {
|
||||
failBatch(batchId, result.error ?? "Failed to start generation");
|
||||
continue;
|
||||
}
|
||||
|
||||
setBatchJobToken(batchId, result.jobToken);
|
||||
|
||||
const stopPolling = startVideoPolling(
|
||||
result.jobToken,
|
||||
(video) => {
|
||||
completeBatch(batchId, video);
|
||||
pollingCleanupsRef.current.delete(batchId);
|
||||
window.dispatchEvent(new Event("task-queue-update"));
|
||||
},
|
||||
(reason) => {
|
||||
failBatch(batchId, reason);
|
||||
pollingCleanupsRef.current.delete(batchId);
|
||||
window.dispatchEvent(new Event("task-queue-update"));
|
||||
},
|
||||
);
|
||||
pollingCleanupsRef.current.set(batchId, stopPolling);
|
||||
} catch {
|
||||
failBatch(batchId, "Network error - please try again");
|
||||
}
|
||||
|
||||
setBatchJobToken(batchId, result.jobToken);
|
||||
window.dispatchEvent(new Event("credits-change"));
|
||||
window.dispatchEvent(new Event("task-queue-update"));
|
||||
|
||||
const stopPolling = startVideoPolling(
|
||||
result.jobToken,
|
||||
(video) => {
|
||||
completeBatch(batchId, video);
|
||||
pollingCleanupsRef.current.delete(batchId);
|
||||
window.dispatchEvent(new Event("task-queue-update"));
|
||||
},
|
||||
(reason) => {
|
||||
failBatch(batchId, reason);
|
||||
pollingCleanupsRef.current.delete(batchId);
|
||||
window.dispatchEvent(new Event("task-queue-update"));
|
||||
},
|
||||
);
|
||||
pollingCleanupsRef.current.set(batchId, stopPolling);
|
||||
} catch {
|
||||
failBatch(batchId, "Network error - please try again");
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
|
||||
window.dispatchEvent(new Event("credits-change"));
|
||||
window.dispatchEvent(new Event("task-queue-update"));
|
||||
setIsGenerating(false);
|
||||
}, [
|
||||
prompt, isGenerating, needsImage, isReferenceMode, selectedModel, selectedSize,
|
||||
duration, resolution, generateWithSound, hasResolutionOptions, hasSound,
|
||||
supportsImagePrompts, hasEndFrame, referenceImages, endFrameImage,
|
||||
referenceVideos, referenceAudios, startBatch, setBatchJobToken, completeBatch, failBatch,
|
||||
prompt,
|
||||
isGenerating,
|
||||
needsImage,
|
||||
isReferenceMode,
|
||||
selectedModel,
|
||||
selectedSize,
|
||||
numVideos,
|
||||
duration,
|
||||
resolution,
|
||||
generateWithSound,
|
||||
hasResolutionOptions,
|
||||
hasSound,
|
||||
supportsImagePrompts,
|
||||
hasEndFrame,
|
||||
referenceImages,
|
||||
endFrameImage,
|
||||
referenceVideos,
|
||||
referenceAudios,
|
||||
activeCharacters,
|
||||
startBatch,
|
||||
setBatchJobToken,
|
||||
completeBatch,
|
||||
failBatch,
|
||||
]);
|
||||
|
||||
// ── Render ────────────────────────────────────────────────────────────
|
||||
@@ -521,10 +746,10 @@ export default function CreateVideo() {
|
||||
}
|
||||
promptBox={
|
||||
<div
|
||||
className="animate-fade-in-up fixed bottom-3 left-0 right-0 z-30 mx-auto w-full max-w-[730px] px-4"
|
||||
className="animate-fade-in-up fixed bottom-2 sm:bottom-3 left-0 right-0 z-30 mx-auto w-full max-w-[900px] px-2 sm:px-4"
|
||||
style={{ animationDelay: "150ms" }}
|
||||
>
|
||||
{selectedModel?.model === "seedance_2p0" && (
|
||||
{/* {selectedModel?.model === "seedance_2p0" && (
|
||||
<div className="mb-2 flex items-start gap-2.5 rounded-lg border border-yellow-500/40 px-3.5 py-2.5 text-xs text-yellow-200 shadow-lg backdrop-blur-xl bg-yellow-800/60">
|
||||
<FontAwesomeIcon icon={faTriangleExclamation} className="mt-0.5 h-3.5 w-3.5 flex-shrink-0 text-yellow-400" />
|
||||
<span>
|
||||
@@ -532,7 +757,7 @@ export default function CreateVideo() {
|
||||
Seedance may reject safe inputs unexpectedly. Try several short generations before longer ones.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
)} */}
|
||||
<PromptBox
|
||||
ref={promptBoxRef}
|
||||
prompt={prompt}
|
||||
@@ -542,7 +767,9 @@ export default function CreateVideo() {
|
||||
credits={estimatedCredits}
|
||||
placeholder="Describe the video you want to generate..."
|
||||
supportsImagePrompts={supportsImagePrompts}
|
||||
maxImagePromptCount={isReferenceMode ? (selectedModel?.image_references_max ?? 3) : 1}
|
||||
maxImagePromptCount={
|
||||
isReferenceMode ? (selectedModel?.image_references_max ?? 3) : 1
|
||||
}
|
||||
referenceImages={referenceImages}
|
||||
onReferenceImagesChange={setReferenceImages}
|
||||
isVideo
|
||||
@@ -550,15 +777,38 @@ export default function CreateVideo() {
|
||||
endFrameImage={endFrameImage}
|
||||
onEndFrameImageChange={setEndFrameImage}
|
||||
showEndFrameSection={hasEndFrame}
|
||||
onPickFromLibrary={supportsImagePrompts ? () => setIsImagePickerOpen(true) : undefined}
|
||||
onPickFromLibrary={
|
||||
supportsImagePrompts
|
||||
? () => setIsImagePickerOpen(true)
|
||||
: undefined
|
||||
}
|
||||
onPickEndFrameFromLibrary={
|
||||
hasEndFrame ? () => setIsEndFramePickerOpen(true) : undefined
|
||||
}
|
||||
modelSelector={
|
||||
<Tooltip content="Model" position="top" className="z-50" closeOnClick>
|
||||
<PopoverMenu
|
||||
items={modelItems}
|
||||
onSelect={handleModelChange}
|
||||
mode="toggle"
|
||||
panelTitle="Select Model"
|
||||
showIconsInList
|
||||
triggerIcon={
|
||||
<img
|
||||
src={getModelCreatorIconPath(selectedModel?.model ?? "")}
|
||||
alt=""
|
||||
className="h-4 w-4 icon-auto-contrast"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Tooltip>
|
||||
}
|
||||
onClearAllRefs={() => {
|
||||
setReferenceImages([]);
|
||||
setEndFrameImage(undefined);
|
||||
setReferenceVideos([]);
|
||||
setReferenceAudios([]);
|
||||
}}
|
||||
showClearSession={batches.length > 0}
|
||||
onClearSession={useCreateVideoStore.getState().reset}
|
||||
mentionItems={mentionItems.length > 0 ? mentionItems : undefined}
|
||||
mediaReferenceRow={
|
||||
isReferenceMode ? (
|
||||
@@ -566,18 +816,38 @@ export default function CreateVideo() {
|
||||
referenceVideos={referenceVideos}
|
||||
onReferenceVideosChange={setReferenceVideos}
|
||||
maxVideoCount={selectedModel?.video_references_max ?? 3}
|
||||
maxVideoRefDuration={selectedModel?.video_references_max_total_duration_seconds ?? 30}
|
||||
maxVideoRefDuration={
|
||||
selectedModel?.video_references_max_total_duration_seconds ??
|
||||
30
|
||||
}
|
||||
referenceAudios={referenceAudios}
|
||||
onReferenceAudiosChange={setReferenceAudios}
|
||||
maxAudioCount={selectedModel?.audio_references_max ?? 2}
|
||||
maxAudioRefDuration={selectedModel?.audio_references_max_total_duration_seconds ?? 30}
|
||||
maxAudioRefDuration={
|
||||
selectedModel?.audio_references_max_total_duration_seconds ??
|
||||
30
|
||||
}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
rightToolbar={
|
||||
<GenerationCountPicker
|
||||
batchSizeMax={selectedModel?.batch_size_max ?? 4}
|
||||
batchSizeOptions={selectedModel?.batch_size_options}
|
||||
currentCount={numVideos}
|
||||
handleCountChange={setNumVideos}
|
||||
panelTitle="No. of videos"
|
||||
/>
|
||||
}
|
||||
leftToolbar={
|
||||
<>
|
||||
{hasSizeOptions && (
|
||||
<Tooltip content="Aspect Ratio" position="top" className="z-50" closeOnClick>
|
||||
<Tooltip
|
||||
content="Aspect Ratio"
|
||||
position="top"
|
||||
className="z-50"
|
||||
closeOnClick
|
||||
>
|
||||
<PopoverMenu
|
||||
items={sizeItems}
|
||||
onSelect={handleSizeChange}
|
||||
@@ -595,37 +865,104 @@ export default function CreateVideo() {
|
||||
</Tooltip>
|
||||
)}
|
||||
{resolutionItems && (
|
||||
<Tooltip content="Resolution" position="top" className="z-50" closeOnClick>
|
||||
<PopoverMenu items={resolutionItems} onSelect={handleResolutionChange} mode="toggle" panelTitle="Resolution" />
|
||||
</Tooltip>
|
||||
)}
|
||||
{durationItems && (
|
||||
<Tooltip content="Duration" position="top" className="z-50" closeOnClick>
|
||||
<Tooltip
|
||||
content="Resolution"
|
||||
position="top"
|
||||
className="z-50"
|
||||
closeOnClick
|
||||
>
|
||||
<PopoverMenu
|
||||
items={durationItems}
|
||||
onSelect={handleDurationChange}
|
||||
items={resolutionItems}
|
||||
onSelect={handleResolutionChange}
|
||||
mode="toggle"
|
||||
panelTitle="Duration"
|
||||
triggerIcon={<FontAwesomeIcon icon={faClock} className="h-3.5 w-3.5" />}
|
||||
panelTitle="Resolution"
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
{durationRange && (
|
||||
<Tooltip content="Duration" position="top" className="z-50">
|
||||
<PopoverMenu
|
||||
mode="default"
|
||||
panelTitle="Duration"
|
||||
triggerIcon={
|
||||
<FontAwesomeIcon
|
||||
icon={faClock}
|
||||
className="h-3.5 w-3.5"
|
||||
/>
|
||||
}
|
||||
triggerLabel={`${effectiveDuration}s`}
|
||||
>
|
||||
<div className="w-[min(16rem,calc(100vw-2rem))] pb-0.5">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex-1">
|
||||
<SliderV2
|
||||
min={durationRange.min}
|
||||
max={durationRange.max}
|
||||
value={localDuration}
|
||||
onChange={handleDurationSlide}
|
||||
step={1}
|
||||
suffix="s"
|
||||
variant="filled"
|
||||
/>
|
||||
</div>
|
||||
<span className="text-base-fg min-w-6 shrink-0 text-sm font-medium tabular-nums">
|
||||
{localDuration}s
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-base-fg/40 mt-1.5 flex justify-between px-0.5 text-[11px] tabular-nums">
|
||||
<span>{durationRange.min}s</span>
|
||||
<span>{durationRange.max}s</span>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverMenu>
|
||||
</Tooltip>
|
||||
)}
|
||||
{hasSound && (
|
||||
<Tooltip content={generateWithSound ? "Sound: ON" : "Sound: OFF"} position="top" className="z-50" delay={200}>
|
||||
<Tooltip
|
||||
content={generateWithSound ? "Sound: ON" : "Sound: OFF"}
|
||||
position="top"
|
||||
className="z-50"
|
||||
delay={200}
|
||||
>
|
||||
<ToggleButton
|
||||
isActive={generateWithSound}
|
||||
icon={faWaveformLines}
|
||||
activeIcon={faWaveformLines}
|
||||
onClick={() => setUi({ generateWithSound: !generateWithSound })}
|
||||
className={generateWithSound ? "bg-primary/40 hover:bg-primary/50 border-primary/30" : undefined}
|
||||
onClick={() =>
|
||||
setUi({ generateWithSound: !generateWithSound })
|
||||
}
|
||||
className={
|
||||
generateWithSound
|
||||
? "bg-primary/40 hover:bg-primary/50 border-primary/30"
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
{inputModeItems && (
|
||||
<Tooltip content="Input Mode" position="top" className="z-50" closeOnClick>
|
||||
<PopoverMenu items={inputModeItems} onSelect={handleInputModeChange} mode="toggle" panelTitle="Input Mode" />
|
||||
<Tooltip
|
||||
content="Input Mode"
|
||||
position="top"
|
||||
className="z-50"
|
||||
closeOnClick
|
||||
>
|
||||
<PopoverMenu
|
||||
items={inputModeItems}
|
||||
onSelect={handleInputModeChange}
|
||||
mode="toggle"
|
||||
panelTitle="Input Mode"
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
{isSeedance2p0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsCharactersModalOpen(true)}
|
||||
className="flex h-9 items-center justify-center gap-1 rounded-lg border border-ui-controls-border bg-ui-controls px-3 text-sm font-medium text-base-fg shadow-sm transition-all duration-150 hover:bg-ui-controls/80 active:scale-95"
|
||||
>
|
||||
@Characters
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
@@ -639,9 +976,28 @@ export default function CreateVideo() {
|
||||
onSelect={handleLibraryImageSelect}
|
||||
maxSelect={Math.max(
|
||||
1,
|
||||
(isReferenceMode ? (selectedModel?.image_references_max ?? 3) : 1) - referenceImages.length,
|
||||
(isReferenceMode
|
||||
? (selectedModel?.image_references_max ?? 3)
|
||||
: 1) - referenceImages.length,
|
||||
)}
|
||||
/>
|
||||
<ImagePickerModal
|
||||
isOpen={isEndFramePickerOpen}
|
||||
onClose={() => setIsEndFramePickerOpen(false)}
|
||||
onSelect={handleEndFrameLibrarySelect}
|
||||
maxSelect={1}
|
||||
/>
|
||||
<CharactersModal
|
||||
isOpen={isCharactersModalOpen}
|
||||
onClose={() => setIsCharactersModalOpen(false)}
|
||||
onSelectCharacter={(character) => {
|
||||
const mention = `@${character.name}`;
|
||||
const spaceBefore =
|
||||
prompt.length > 0 && !prompt.endsWith(" ") ? " " : "";
|
||||
setPrompt(prompt + spaceBefore + mention + " ");
|
||||
setIsCharactersModalOpen(false);
|
||||
}}
|
||||
/>
|
||||
<Lightbox
|
||||
isOpen={lightbox.lightboxOpen}
|
||||
onClose={lightbox.closeLightbox}
|
||||
@@ -649,6 +1005,7 @@ export default function CreateVideo() {
|
||||
cdnUrl={lightbox.lightboxItem?.fullImage}
|
||||
mediaClass={lightbox.lightboxItem?.mediaClass}
|
||||
batchImageToken={lightbox.lightboxItem?.batchImageToken}
|
||||
showBatchCarousel={false}
|
||||
onNavigatePrev={lightbox.navigatePrev}
|
||||
onNavigateNext={lightbox.navigateNext}
|
||||
onDeleted={gallery.removeItem}
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { GeneratedVideo } from "./create-video-store";
|
||||
export interface GenerateVideoParams {
|
||||
prompt: string;
|
||||
model: string;
|
||||
numVideos?: number;
|
||||
aspectRatio?: string;
|
||||
duration?: number;
|
||||
resolution?: string;
|
||||
@@ -16,6 +17,7 @@ export interface GenerateVideoParams {
|
||||
referenceImageMediaTokens?: string[];
|
||||
referenceVideoMediaTokens?: string[];
|
||||
referenceAudioMediaTokens?: string[];
|
||||
referenceCharacterTokens?: string[];
|
||||
}
|
||||
|
||||
// ── Enqueue generation ───────────────────────────────────────────────────
|
||||
@@ -31,6 +33,7 @@ export async function enqueueVideoGeneration(
|
||||
resolution: params.resolution ?? null,
|
||||
duration_seconds: params.duration ?? null,
|
||||
generate_audio: params.generateAudio ?? null,
|
||||
video_batch_count: params.numVideos ?? 1,
|
||||
start_frame_image_media_token: params.startFrameImageMediaToken ?? null,
|
||||
end_frame_image_media_token: params.endFrameImageMediaToken ?? null,
|
||||
reference_image_media_tokens: params.referenceImageMediaTokens?.length
|
||||
@@ -42,6 +45,9 @@ export async function enqueueVideoGeneration(
|
||||
reference_audio_media_tokens: params.referenceAudioMediaTokens?.length
|
||||
? params.referenceAudioMediaTokens
|
||||
: null,
|
||||
reference_character_tokens: params.referenceCharacterTokens?.length
|
||||
? params.referenceCharacterTokens
|
||||
: null,
|
||||
};
|
||||
|
||||
try {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import { Input } from "@storyteller/ui-input";
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { PasswordResetApi } from "@storyteller/api";
|
||||
@@ -44,12 +45,6 @@ const ForgotPassword = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
handleRequestReset();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative min-h-screen bg-[#101014] text-white bg-dots flex flex-col">
|
||||
<Seo
|
||||
@@ -59,7 +54,7 @@ const ForgotPassword = () => {
|
||||
<div className="dotted-pattern absolute inset-0 z-[0] opacity-30" />
|
||||
|
||||
<main className="relative z-10 flex-1 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md bg-[#1C1C20] border border-white/10 rounded-3xl p-8 shadow-2xl">
|
||||
<div className="w-full max-w-md bg-[#1C1C20] border border-white/10 rounded-2xl p-6 py-8 shadow-2xl">
|
||||
{!submitted ? (
|
||||
<>
|
||||
<div className="text-center mb-8">
|
||||
@@ -69,7 +64,13 @@ const ForgotPassword = () => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleRequestReset();
|
||||
}}
|
||||
>
|
||||
{error && (
|
||||
<div className="bg-red-500/10 border border-red-500/20 text-red-500 px-4 py-3 rounded-xl text-sm text-center">
|
||||
{error}
|
||||
@@ -80,30 +81,34 @@ const ForgotPassword = () => {
|
||||
<label className="text-xs font-bold text-white/60 uppercase tracking-wide ml-1">
|
||||
Email or Username
|
||||
</label>
|
||||
<input
|
||||
<Input
|
||||
id="reset-email"
|
||||
type="text"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="you@example.com"
|
||||
className="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
|
||||
inputClassName="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
id="send-reset-btn"
|
||||
className="w-full bg-primary hover:bg-primary-600 text-white border-none justify-center font-bold h-12 mt-2"
|
||||
onClick={handleRequestReset}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<FontAwesomeIcon icon={faSpinnerThird} className="animate-spin" />
|
||||
) : (
|
||||
"Send Reset Code"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="pt-2">
|
||||
<Button
|
||||
id="send-reset-btn"
|
||||
className="w-full bg-primary hover:bg-primary-600 text-white border-none justify-center font-bold h-10"
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<FontAwesomeIcon
|
||||
icon={faSpinnerThird}
|
||||
className="animate-spin"
|
||||
/>
|
||||
) : (
|
||||
"Send Reset Code"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
@@ -116,12 +121,12 @@ const ForgotPassword = () => {
|
||||
<span className="text-white font-medium">{email}</span>
|
||||
</p>
|
||||
<Link to="/forgot-password/verify">
|
||||
<Button className="w-full bg-primary hover:bg-primary-600 text-white border-none justify-center font-bold h-12 mb-3">
|
||||
<Button className="w-full bg-primary hover:bg-primary-600 text-white border-none justify-center font-bold h-10 mb-3">
|
||||
Enter Verification Code
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
className="w-full bg-white/10 hover:bg-white/20 text-white border-none justify-center font-bold h-12"
|
||||
className="w-full bg-white/10 hover:bg-white/20 text-white border-none justify-center font-bold h-10"
|
||||
onClick={() => setSubmitted(false)}
|
||||
>
|
||||
Try another email
|
||||
@@ -139,6 +144,10 @@ const ForgotPassword = () => {
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div className="relative z-10 py-6 text-center text-white/20 text-xs">
|
||||
© {new Date().getFullYear()} ArtCraft. All rights reserved.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import {
|
||||
faArrowLeft,
|
||||
faLock,
|
||||
faEye,
|
||||
faEyeSlash,
|
||||
faSpinnerThird,
|
||||
faKey,
|
||||
faCheckCircle,
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import { Input } from "@storyteller/ui-input";
|
||||
import { useState } from "react";
|
||||
import { Link, useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { PasswordResetApi, BillingApi } from "@storyteller/api";
|
||||
@@ -102,12 +101,6 @@ const VerifyReset = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
handleRedeemReset();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative min-h-screen bg-[#101014] text-white bg-dots flex flex-col">
|
||||
<Seo
|
||||
@@ -117,7 +110,7 @@ const VerifyReset = () => {
|
||||
<div className="dotted-pattern absolute inset-0 z-[0] opacity-30" />
|
||||
|
||||
<main className="relative z-10 flex-1 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md bg-[#1C1C20] border border-white/10 rounded-3xl p-8 shadow-2xl">
|
||||
<div className="w-full max-w-md bg-[#1C1C20] border border-white/10 rounded-2xl p-6 py-8 shadow-2xl">
|
||||
{!success ? (
|
||||
<>
|
||||
<div className="text-center mb-8">
|
||||
@@ -129,7 +122,13 @@ const VerifyReset = () => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-5">
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleRedeemReset();
|
||||
}}
|
||||
>
|
||||
{error && (
|
||||
<div className="bg-red-500/10 border border-red-500/20 text-red-500 px-4 py-3 rounded-xl text-sm text-center">
|
||||
{error}
|
||||
@@ -141,26 +140,17 @@ const VerifyReset = () => {
|
||||
<label className="text-xs font-bold text-white/60 uppercase tracking-wide ml-1">
|
||||
Verification Code
|
||||
</label>
|
||||
<div className="relative">
|
||||
<FontAwesomeIcon
|
||||
icon={faKey}
|
||||
className="absolute left-4 top-1/2 -translate-y-1/2 text-white/20"
|
||||
/>
|
||||
<input
|
||||
id="verification-code"
|
||||
type="text"
|
||||
value={verificationCode}
|
||||
onChange={(e) => setVerificationCode(e.target.value)}
|
||||
placeholder="Enter verification code"
|
||||
className={`w-full bg-black/20 border ${
|
||||
fieldErrors.verificationCode
|
||||
? "border-red-500/50"
|
||||
: "border-white/10"
|
||||
} focus:border-primary/50 rounded-xl pl-11 pr-4 py-3 text-white placeholder-white/20 outline-none transition-colors`}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
id="verification-code"
|
||||
type="text"
|
||||
value={verificationCode}
|
||||
onChange={(e) => setVerificationCode(e.target.value)}
|
||||
placeholder="Enter verification code"
|
||||
isError={!!fieldErrors.verificationCode}
|
||||
inputClassName="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
|
||||
/>
|
||||
{fieldErrors.verificationCode && (
|
||||
<p className="text-red-400 text-xs ml-1 mt-1">
|
||||
<p className="text-red-400 text-xs ml-1">
|
||||
{fieldErrors.verificationCode}
|
||||
</p>
|
||||
)}
|
||||
@@ -172,21 +162,14 @@ const VerifyReset = () => {
|
||||
New Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<FontAwesomeIcon
|
||||
icon={faLock}
|
||||
className="absolute left-4 top-1/2 -translate-y-1/2 text-white/20"
|
||||
/>
|
||||
<input
|
||||
<Input
|
||||
id="new-password"
|
||||
type={showNewPassword ? "text" : "password"}
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
placeholder="Enter new password"
|
||||
className={`w-full bg-black/20 border ${
|
||||
fieldErrors.newPassword
|
||||
? "border-red-500/50"
|
||||
: "border-white/10"
|
||||
} focus:border-primary/50 rounded-xl pl-11 pr-12 py-3 text-white placeholder-white/20 outline-none transition-colors`}
|
||||
isError={!!fieldErrors.newPassword}
|
||||
inputClassName="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors pr-12"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@@ -199,7 +182,7 @@ const VerifyReset = () => {
|
||||
</button>
|
||||
</div>
|
||||
{fieldErrors.newPassword && (
|
||||
<p className="text-red-400 text-xs ml-1 mt-1">
|
||||
<p className="text-red-400 text-xs ml-1">
|
||||
{fieldErrors.newPassword}
|
||||
</p>
|
||||
)}
|
||||
@@ -211,22 +194,14 @@ const VerifyReset = () => {
|
||||
Verify New Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<FontAwesomeIcon
|
||||
icon={faLock}
|
||||
className="absolute left-4 top-1/2 -translate-y-1/2 text-white/20"
|
||||
/>
|
||||
<input
|
||||
<Input
|
||||
id="confirm-password"
|
||||
type={showConfirmPassword ? "text" : "password"}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Enter new password again"
|
||||
className={`w-full bg-black/20 border ${
|
||||
fieldErrors.confirmPassword
|
||||
? "border-red-500/50"
|
||||
: "border-white/10"
|
||||
} focus:border-primary/50 rounded-xl pl-11 pr-12 py-3 text-white placeholder-white/20 outline-none transition-colors`}
|
||||
isError={!!fieldErrors.confirmPassword}
|
||||
inputClassName="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors pr-12"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@@ -241,25 +216,30 @@ const VerifyReset = () => {
|
||||
</button>
|
||||
</div>
|
||||
{fieldErrors.confirmPassword && (
|
||||
<p className="text-red-400 text-xs ml-1 mt-1">
|
||||
<p className="text-red-400 text-xs ml-1">
|
||||
{fieldErrors.confirmPassword}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
id="change-password-btn"
|
||||
className="w-full bg-primary hover:bg-primary-600 text-white border-none justify-center font-bold h-12 mt-2"
|
||||
onClick={handleRedeemReset}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<FontAwesomeIcon icon={faSpinnerThird} className="animate-spin" />
|
||||
) : (
|
||||
"Change Password"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="pt-2">
|
||||
<Button
|
||||
id="change-password-btn"
|
||||
className="w-full bg-primary hover:bg-primary-600 text-white border-none justify-center font-bold h-10"
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<FontAwesomeIcon
|
||||
icon={faSpinnerThird}
|
||||
className="animate-spin"
|
||||
/>
|
||||
) : (
|
||||
"Change Password"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="mt-8 text-center text-sm">
|
||||
<Link
|
||||
@@ -285,7 +265,7 @@ const VerifyReset = () => {
|
||||
</p>
|
||||
<Button
|
||||
id="back-to-homepage-btn"
|
||||
className="w-full bg-primary hover:bg-primary-600 text-white border-none justify-center font-bold h-12"
|
||||
className="w-full bg-primary hover:bg-primary-600 text-white border-none justify-center font-bold h-10"
|
||||
onClick={() => navigate(redirectTo)}
|
||||
>
|
||||
{redirectLabel}
|
||||
@@ -295,6 +275,10 @@ const VerifyReset = () => {
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div className="relative z-10 py-6 text-center text-white/20 text-xs">
|
||||
© {new Date().getFullYear()} ArtCraft. All rights reserved.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -234,7 +234,7 @@ const LandingSD2 = () => {
|
||||
description="Seedance 2.0 is available now in ArtCraft. Generate jaw-dropping AI videos with the fastest open desktop app for artists."
|
||||
/>
|
||||
{/* Hero Section */}
|
||||
<main className="relative mb-8 md:mb-16 pt-24 sm:pt-24 min-h-[400px] sm:min-h-[600px] md:min-h-[700px] flex items-center justify-center px-2 sm:px-4 md:px-0">
|
||||
<main className="relative mb-8 md:mb-16 pt-20 sm:pt-24 min-h-[400px] sm:min-h-[600px] md:min-h-[700px] flex items-center justify-center px-2 sm:px-4 md:px-0">
|
||||
{/* Glowing Gradient Orb Background */}
|
||||
<div
|
||||
className="absolute inset-0 flex items-center justify-center pointer-events-none z-0 overflow-hidden"
|
||||
@@ -298,9 +298,7 @@ const LandingSD2 = () => {
|
||||
className="relative mx-auto mt-4 w-full max-w-[1200px]"
|
||||
data-animate
|
||||
>
|
||||
<div
|
||||
className="relative rounded-[20px] sm:rounded-[32px] overflow-hidden bg-white/10 backdrop-blur-md md:backdrop-blur-xl shadow-2xl p-2 sm:p-4 w-full mx-auto transition-transform duration-300 transform-gpu"
|
||||
>
|
||||
<div className="relative rounded-[20px] sm:rounded-[32px] overflow-hidden bg-white/10 backdrop-blur-md md:backdrop-blur-xl shadow-2xl p-2 sm:p-4 w-full mx-auto transition-transform duration-300 transform-gpu">
|
||||
<div style={{ paddingTop: "56.25%" }} className="relative">
|
||||
<iframe
|
||||
src={HERO_VIMEO_URL}
|
||||
@@ -359,10 +357,11 @@ const LandingSD2 = () => {
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => setActiveFeature(index)}
|
||||
className={`snap-center shrink-0 px-5 py-2.5 rounded-full text-sm font-bold border transition-all duration-300 whitespace-nowrap ${activeFeature === index
|
||||
className={`snap-center shrink-0 px-5 py-2.5 rounded-full text-sm font-bold border transition-all duration-300 whitespace-nowrap ${
|
||||
activeFeature === index
|
||||
? "bg-primary border-primary text-white shadow-lg shadow-primary/20"
|
||||
: "bg-white/5 border-white/10 text-white/60 hover:bg-white/10"
|
||||
}`}
|
||||
}`}
|
||||
>
|
||||
{feature.title}
|
||||
</button>
|
||||
@@ -421,26 +420,29 @@ const LandingSD2 = () => {
|
||||
>
|
||||
<div className="max-w-lg">
|
||||
<div
|
||||
className={`w-12 h-12 mb-6 rounded-2xl flex items-center justify-center text-2xl transition-all duration-500 ${activeFeature === index
|
||||
className={`w-12 h-12 mb-6 rounded-2xl flex items-center justify-center text-2xl transition-all duration-500 ${
|
||||
activeFeature === index
|
||||
? "bg-primary text-white shadow-lg shadow-primary/30 scale-110"
|
||||
: "bg-white/10 text-white/50"
|
||||
}`}
|
||||
}`}
|
||||
>
|
||||
<FontAwesomeIcon icon={feature.icon} />
|
||||
</div>
|
||||
<h3
|
||||
className={`text-3xl xl:text-4xl font-bold mb-6 transition-all duration-500 ${activeFeature === index
|
||||
className={`text-3xl xl:text-4xl font-bold mb-6 transition-all duration-500 ${
|
||||
activeFeature === index
|
||||
? "text-white"
|
||||
: "text-white/40"
|
||||
}`}
|
||||
}`}
|
||||
>
|
||||
{feature.title}
|
||||
</h3>
|
||||
<p
|
||||
className={`text-xl leading-relaxed transition-all duration-500 ${activeFeature === index
|
||||
className={`text-xl leading-relaxed transition-all duration-500 ${
|
||||
activeFeature === index
|
||||
? "text-white/80"
|
||||
: "text-white/30"
|
||||
}`}
|
||||
}`}
|
||||
>
|
||||
{feature.description}
|
||||
</p>
|
||||
@@ -816,7 +818,10 @@ const LandingSD2 = () => {
|
||||
</p>
|
||||
|
||||
<div className="relative z-10 w-full flex flex-col items-center">
|
||||
<LandingActionButtons onDownloadClick={onDownloadClick} creditsButtonText="Get Now" />
|
||||
<LandingActionButtons
|
||||
onDownloadClick={onDownloadClick}
|
||||
creditsButtonText="Get Now"
|
||||
/>
|
||||
<div className="mt-8 text-sm text-white/80 font-medium">
|
||||
Free to use.
|
||||
</div>
|
||||
|
||||
@@ -219,7 +219,6 @@ const Landing = () => {
|
||||
}
|
||||
}, [activeFeature]);
|
||||
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={mainRef}
|
||||
@@ -230,7 +229,7 @@ const Landing = () => {
|
||||
description="ArtCraft is an Open Desktop app for generating AI Video and Images. You own ArtCraft!"
|
||||
/>
|
||||
{/* Hero Section */}
|
||||
<main className="relative mb-8 md:mb-16 pt-24 sm:pt-24 min-h-[400px] sm:min-h-[600px] md:min-h-[700px] flex items-center justify-center px-2 sm:px-4 md:px-0">
|
||||
<main className="relative mb-8 md:mb-16 pt-20 sm:pt-24 min-h-[400px] sm:min-h-[600px] md:min-h-[700px] flex items-center justify-center px-2 sm:px-4 md:px-0">
|
||||
{/* Glowing Gradient Orb Background */}
|
||||
<div
|
||||
className="absolute inset-0 flex items-center justify-center pointer-events-none z-0 overflow-hidden"
|
||||
@@ -293,10 +292,11 @@ const Landing = () => {
|
||||
className="relative mx-auto mt-4 w-full max-w-[1200px]"
|
||||
data-animate
|
||||
>
|
||||
<div
|
||||
className="relative rounded-[20px] sm:rounded-[32px] overflow-hidden bg-white/10 backdrop-blur-md md:backdrop-blur-xl shadow-2xl p-2 sm:p-4 w-full mx-auto transition-transform duration-300 transform-gpu"
|
||||
>
|
||||
<div className="relative w-full rounded-2xl overflow-hidden" style={{ paddingTop: "56.25%" }}>
|
||||
<div className="relative rounded-[20px] sm:rounded-[32px] overflow-hidden bg-white/10 backdrop-blur-md md:backdrop-blur-xl shadow-2xl p-2 sm:p-4 w-full mx-auto transition-transform duration-300 transform-gpu">
|
||||
<div
|
||||
className="relative w-full rounded-2xl overflow-hidden"
|
||||
style={{ paddingTop: "56.25%" }}
|
||||
>
|
||||
<iframe
|
||||
src="https://player.vimeo.com/video/1179924350?h=8b9b3f0f35&autoplay=1&muted=1&loop=1&background=0&byline=0&portrait=0&title=0"
|
||||
className="absolute inset-0 w-full h-full rounded-2xl"
|
||||
|
||||
@@ -139,7 +139,7 @@ export default function Library() {
|
||||
const thumbnail = isDimensional
|
||||
? null
|
||||
: getMediaThumbnail(item.media_links, item.media_class, {
|
||||
size: THUMBNAIL_SIZES.MEDIUM,
|
||||
size: THUMBNAIL_SIZES.LARGE,
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -315,13 +315,15 @@ export default function Library() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative min-h-screen w-full bg-dots pt-20 pb-8 px-4 md:px-8 lg:px-12">
|
||||
<div className="relative min-h-screen w-full bg-dots pt-14 sm:pt-20 pb-8 px-3 sm:px-4 md:px-8 lg:px-12">
|
||||
<div className="mx-auto max-w-[1600px]">
|
||||
{/* Header — sticky below navbar */}
|
||||
<div className="sticky top-16 z-10 -mx-4 md:-mx-8 lg:-mx-12 px-4 md:px-8 lg:px-12 pb-4 pt-4 bg-[#101014]">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-bold text-white">My Library</h1>
|
||||
<div className="sticky top-12 sm:top-16 z-10 -mx-3 sm:-mx-4 md:-mx-8 lg:-mx-12 px-3 sm:px-4 md:px-8 lg:px-12 pb-3 pt-3 bg-[#101014]">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<h1 className="text-lg sm:text-2xl font-bold text-white">
|
||||
My Library
|
||||
</h1>
|
||||
<button
|
||||
onClick={() => {
|
||||
setAllItems([]);
|
||||
@@ -331,30 +333,30 @@ export default function Library() {
|
||||
isLoadingRef.current = false;
|
||||
loadItems(true);
|
||||
}}
|
||||
className="h-8 w-8 flex items-center justify-center rounded-lg text-white/50 hover:text-white hover:bg-ui-controls/40 transition-colors"
|
||||
className="h-7 w-7 sm:h-8 sm:w-8 flex items-center justify-center rounded-lg text-white/50 hover:text-white hover:bg-ui-controls/40 transition-colors"
|
||||
title="Refresh library"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={faArrowsRotate}
|
||||
className={`text-sm ${initialLoading ? "animate-spin" : ""}`}
|
||||
className={`text-xs sm:text-sm ${initialLoading ? "animate-spin" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filter tabs */}
|
||||
<div className="flex items-center gap-1 bg-ui-controls/40 rounded-lg p-1">
|
||||
<div className="flex items-center gap-1 bg-ui-controls/40 rounded-lg p-1 overflow-x-auto">
|
||||
{FILTERS.map((filter) => (
|
||||
<button
|
||||
key={filter.id}
|
||||
onClick={() => navigate(filter.route)}
|
||||
className={`flex items-center gap-2 px-4 py-1.5 rounded-md text-sm font-medium transition-colors ${
|
||||
className={`flex items-center gap-1.5 sm:gap-2 px-2.5 sm:px-4 py-1 sm:py-1.5 rounded-md text-xs sm:text-sm font-medium transition-colors whitespace-nowrap ${
|
||||
activeFilter === filter.id
|
||||
? "bg-ui-controls text-white"
|
||||
: "text-white/60 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
<FontAwesomeIcon icon={filter.icon} className="text-xs" />
|
||||
{filter.label}
|
||||
<span className="hidden sm:inline">{filter.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -368,7 +370,7 @@ export default function Library() {
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<div className="h-4 w-24 rounded bg-white/[0.06] mb-3" />
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-2">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-2 sm:gap-3">
|
||||
{Array.from({ length: 18 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
@@ -417,7 +419,7 @@ export default function Library() {
|
||||
<h3 className="text-sm font-medium text-white/50 mb-2">
|
||||
{date}
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-2">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-2 sm:gap-3">
|
||||
{dateItems.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
@@ -507,6 +509,7 @@ export default function Library() {
|
||||
cdnUrl={lightboxItem?.fullImage}
|
||||
mediaClass={lightboxItem?.mediaClass}
|
||||
batchImageToken={lightboxItem?.batchImageToken}
|
||||
showBatchCarousel={false}
|
||||
onNavigatePrev={navigatePrev}
|
||||
onNavigateNext={navigateNext}
|
||||
onDeleted={handleItemDeleted}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { faEye, faEyeSlash, faSpinnerThird } from "@fortawesome/pro-solid-svg-icons";
|
||||
import {
|
||||
faEye,
|
||||
faEyeSlash,
|
||||
faSpinnerThird,
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import { Input } from "@storyteller/ui-input";
|
||||
import { useState } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { UsersApi } from "@storyteller/api";
|
||||
import Seo from "../../components/seo";
|
||||
import { GoogleLoginButton } from "../../components/auth";
|
||||
|
||||
const Login = () => {
|
||||
const navigate = useNavigate();
|
||||
@@ -58,7 +62,7 @@ const Login = () => {
|
||||
<div className="dotted-pattern absolute inset-0 z-[0] opacity-30" />
|
||||
|
||||
<main className="relative z-10 flex-1 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md bg-[#1C1C20] border border-white/10 rounded-3xl p-8 shadow-2xl">
|
||||
<div className="w-full max-w-md bg-[#1C1C20] border border-white/10 rounded-2xl p-6 py-8 shadow-2xl">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-2xl font-bold mb-2">Welcome Back</h1>
|
||||
<p className="text-white/60 text-sm">Log in to your account</p>
|
||||
@@ -81,7 +85,13 @@ const Login = () => {
|
||||
</div>
|
||||
*/}
|
||||
|
||||
<div className="space-y-4">
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleLogin();
|
||||
}}
|
||||
>
|
||||
{error && (
|
||||
<div className="bg-red-500/10 border border-red-500/20 text-red-500 px-4 py-3 rounded-xl text-sm text-center">
|
||||
{error}
|
||||
@@ -92,12 +102,12 @@ const Login = () => {
|
||||
<label className="text-xs font-bold text-white/60 uppercase tracking-wide ml-1">
|
||||
Email or Username
|
||||
</label>
|
||||
<input
|
||||
<Input
|
||||
type="text"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@example.com or username"
|
||||
className="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
|
||||
inputClassName="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
@@ -113,12 +123,12 @@ const Login = () => {
|
||||
</Link>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<input
|
||||
<Input
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Min. 8 characters"
|
||||
className="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors pr-12"
|
||||
inputClassName="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors pr-12"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@@ -130,18 +140,23 @@ const Login = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="w-full bg-primary hover:bg-primary-600 text-white border-none justify-center font-bold h-12 mt-2"
|
||||
onClick={handleLogin}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<FontAwesomeIcon icon={faSpinnerThird} className="animate-spin" />
|
||||
) : (
|
||||
"Log in"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="pt-2">
|
||||
<Button
|
||||
className="w-full bg-primary hover:bg-primary-600 text-white border-none justify-center font-bold h-10"
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<FontAwesomeIcon
|
||||
icon={faSpinnerThird}
|
||||
className="animate-spin"
|
||||
/>
|
||||
) : (
|
||||
"Log in"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 text-center text-sm text-white/60">
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import { Input } from "@storyteller/ui-input";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
faUser,
|
||||
faEnvelope,
|
||||
faLock,
|
||||
faCheckCircle,
|
||||
faExclamationCircle,
|
||||
faInfoCircle,
|
||||
faSpinnerThird,
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import Seo from "../../components/seo";
|
||||
import { UsersApi, BillingApi } from "@storyteller/api";
|
||||
@@ -360,9 +358,9 @@ const Onboarding = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-[#1A1A1E] border border-white/10 rounded-3xl p-8 md:p-12">
|
||||
<div className="bg-[#1C1C20] border border-white/10 rounded-2xl p-6 py-8 shadow-2xl">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-3xl md:text-3xl font-bold mb-4 text-white">
|
||||
<h1 className="text-2xl font-bold mb-2">
|
||||
{currentStep === "password" &&
|
||||
(isNewAccount ? "Secure Your Account" : "Set Your Password")}
|
||||
{currentStep === "email" &&
|
||||
@@ -371,7 +369,7 @@ const Onboarding = () => {
|
||||
: "Verify Your Email")}
|
||||
{currentStep === "username" && "Choose A Username"}
|
||||
</h1>
|
||||
<p className="text-white/70">
|
||||
<p className="text-white/60 text-sm">
|
||||
{currentStep === "password" &&
|
||||
"Create a password to access your account across devices."}
|
||||
{currentStep === "email" &&
|
||||
@@ -383,43 +381,41 @@ const Onboarding = () => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div className="bg-red-500/10 border border-red-500/20 text-red-500 px-4 py-3 rounded-xl text-sm text-center">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentStep === "password" && (
|
||||
<>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block text-sm font-medium text-white/80 mb-2"
|
||||
>
|
||||
<FontAwesomeIcon icon={faLock} className="mr-2" />
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold text-white/60 uppercase tracking-wide ml-1">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
<Input
|
||||
type="password"
|
||||
id="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
className="w-full px-4 py-3 bg-[#252529] border border-white/10 rounded-lg text-white placeholder-white/40 focus:outline-none focus:border-primary transition-colors"
|
||||
inputClassName="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
|
||||
placeholder="Enter your password"
|
||||
minLength={8}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="passwordConfirmation"
|
||||
className="block text-sm font-medium text-white/80 mb-2"
|
||||
>
|
||||
<FontAwesomeIcon icon={faLock} className="mr-2" />
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold text-white/60 uppercase tracking-wide ml-1">
|
||||
Confirm Password
|
||||
</label>
|
||||
<input
|
||||
<Input
|
||||
type="password"
|
||||
id="passwordConfirmation"
|
||||
value={passwordConfirmation}
|
||||
onChange={(e) => setPasswordConfirmation(e.target.value)}
|
||||
required
|
||||
className="w-full px-4 py-3 bg-[#252529] border border-white/10 rounded-lg text-white placeholder-white/40 focus:outline-none focus:border-primary transition-colors"
|
||||
inputClassName="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
|
||||
placeholder="Confirm your password"
|
||||
minLength={8}
|
||||
/>
|
||||
@@ -428,85 +424,49 @@ const Onboarding = () => {
|
||||
)}
|
||||
|
||||
{currentStep === "email" && (
|
||||
<div>
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="block text-sm font-medium text-white/80 mb-2"
|
||||
>
|
||||
<FontAwesomeIcon icon={faEnvelope} className="mr-2" />
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold text-white/60 uppercase tracking-wide ml-1">
|
||||
Email Address
|
||||
</label>
|
||||
<input
|
||||
<Input
|
||||
type="email"
|
||||
id="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
className="w-full px-4 py-3 bg-[#252529] border border-white/10 rounded-lg text-white placeholder-white/40 focus:outline-none focus:border-primary transition-colors"
|
||||
inputClassName="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
|
||||
placeholder="your@email.com"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentStep === "username" && (
|
||||
<div>
|
||||
<label
|
||||
htmlFor="username"
|
||||
className="block text-sm font-medium text-white/80 mb-2"
|
||||
>
|
||||
<FontAwesomeIcon icon={faUser} className="mr-2" />
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold text-white/60 uppercase tracking-wide ml-1">
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
<Input
|
||||
type="text"
|
||||
id="username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="w-full px-4 py-3 bg-[#252529] border border-white/10 rounded-lg text-white placeholder-white/40 focus:outline-none focus:border-primary transition-colors"
|
||||
inputClassName="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
|
||||
placeholder="Your display name"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-500/10 border border-red-500/30 rounded-lg p-4 text-red-400 text-sm flex items-start gap-3">
|
||||
<FontAwesomeIcon
|
||||
icon={faExclamationCircle}
|
||||
className="text-red-400 mt-0.5 flex-shrink-0"
|
||||
/>
|
||||
<span className="flex-1">{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3">
|
||||
<div className="flex gap-3 pt-2">
|
||||
<Button
|
||||
type="submit"
|
||||
className="flex-1 bg-primary hover:bg-primary-600 px-6 py-3 text-sm font-bold rounded-xl justify-center"
|
||||
className="flex-1 bg-primary hover:bg-primary-600 text-white border-none justify-center font-bold h-10"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<svg
|
||||
className="animate-spin h-4 w-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
Saving...
|
||||
</span>
|
||||
<FontAwesomeIcon
|
||||
icon={faSpinnerThird}
|
||||
className="animate-spin"
|
||||
/>
|
||||
) : (
|
||||
"Continue"
|
||||
)}
|
||||
@@ -515,7 +475,7 @@ const Onboarding = () => {
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSkip}
|
||||
className="bg-white/10 hover:bg-white/20 px-6 py-3 text-sm font-bold rounded-xl justify-center"
|
||||
className="bg-white/10 hover:bg-white/20 text-white border-none justify-center font-bold h-10 px-6"
|
||||
>
|
||||
Skip
|
||||
</Button>
|
||||
|
||||
@@ -92,7 +92,7 @@ const Pricing = () => {
|
||||
</div>
|
||||
</main>
|
||||
) : (
|
||||
<main className="relative z-10 pt-32 pb-20 px-4 sm:px-6 lg:px-8">
|
||||
<main className="relative z-10 pt-28 sm:pt-32 pb-20 px-4 sm:px-6 lg:px-8">
|
||||
<PricingTable
|
||||
title="Invest in Yourself"
|
||||
subtitle="You'll get a ton of generations and you'll be investing in a tool that you'll always own."
|
||||
|
||||
@@ -14,7 +14,7 @@ const Signup = () => {
|
||||
<div className="dotted-pattern absolute inset-0 z-[0] opacity-30" />
|
||||
|
||||
<main className="relative z-10 flex-1 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md bg-[#1C1C20] border border-white/10 rounded-3xl p-8 shadow-2xl">
|
||||
<div className="w-full max-w-md bg-[#1C1C20] border border-white/10 rounded-2xl p-6 py-8 shadow-2xl">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-2xl font-bold mb-2">Create an Account</h1>
|
||||
<p className="text-white/60 text-sm">Join thousands of creators</p>
|
||||
|
||||
@@ -104,6 +104,15 @@
|
||||
animation: toast-in 0.2s ease-out both;
|
||||
}
|
||||
|
||||
@keyframes marquee {
|
||||
0% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
|
||||
@@ -65,6 +65,7 @@ module.exports = {
|
||||
background: "#242424",
|
||||
panel: "#1F1F1F",
|
||||
"panel-border": "#3F3F3F",
|
||||
border: "#3F3F3F",
|
||||
controls: "#3E3E41",
|
||||
"controls-button": "#3E3E41",
|
||||
"controls-border": "#3f3f3f",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user