mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-06 06:14:52 -05:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a8612d57e | ||
|
|
73856fca85 | ||
|
|
3d27c620e4 | ||
|
|
062b23d3a0 | ||
|
|
129fc3d593 | ||
|
|
ad40798a12 | ||
|
|
3db8e66204 |
@@ -9,3 +9,9 @@ ENABLE_PAGING=true
|
||||
# Doesn't make sense for jobs:
|
||||
# ENABLE_PAGING_FOR_500S=true
|
||||
|
||||
# We don't need to look back that far in development
|
||||
MAX_JOB_AGE_THRESHOLD_HOURS=5
|
||||
|
||||
# Don't collide with storyteller-web in development
|
||||
HTTP_BIND_ADDRESS=0.0.0.0:11223
|
||||
|
||||
|
||||
@@ -2,3 +2,6 @@
|
||||
|
||||
# Development
|
||||
SERVER_ENVIRONMENT='development'
|
||||
|
||||
# Don't collide with storyteller-web in development
|
||||
HTTP_BIND_ADDRESS=0.0.0.0:13333
|
||||
|
||||
@@ -9,8 +9,8 @@ COOKIE_DOMAIN=.jungle.horse
|
||||
WEBSITE_HOMEPAGE_REDIRECT=http://jungle.horse
|
||||
|
||||
# Paging
|
||||
ENABLE_PAGING=true
|
||||
ENABLE_PAGING_FOR_500S=true
|
||||
ENABLE_PAGING=false
|
||||
ENABLE_PAGING_FOR_500S=false
|
||||
|
||||
# Test the rate limiter bypass (obviously these are dummy values)
|
||||
AI_STREAMER_USERNAMES="PewDiePie,xQc"
|
||||
|
||||
@@ -48,7 +48,6 @@ 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),
|
||||
|
||||
+26
-19
@@ -54,8 +54,11 @@ pub struct DistilledVideoRequest {
|
||||
/// Cost estimate as computed by the Artcraft provider — this is what we bill on.
|
||||
pub cost: VideoGenerationCostEstimate,
|
||||
|
||||
/// Execution plan as computed by the Fal provider — what we hand to the router.
|
||||
/// Execution plan as computed by the execution provider — what we hand to the router.
|
||||
plan: VideoGenerationPlan<'static>,
|
||||
|
||||
/// The provider used for execution (Fal, Seedance2Pro, etc.).
|
||||
pub execution_provider: Provider,
|
||||
}
|
||||
|
||||
impl DistilledVideoRequest {
|
||||
@@ -76,6 +79,7 @@ impl DistilledVideoRequest {
|
||||
pub fn distill_video_request(
|
||||
request: &OmniGenVideoCostAndGenerateRequest,
|
||||
media_file_hydration_map: Option<&HashMap<MediaFileToken, Url>>,
|
||||
execution_provider: Provider,
|
||||
) -> Result<DistilledVideoRequest, AdvancedCommonWebError> {
|
||||
// 1. Convert the raw API request into a router request.
|
||||
let initial = hydrate_to_router_request(request)?;
|
||||
@@ -109,9 +113,8 @@ pub fn distill_video_request(
|
||||
media_file_hydration_map,
|
||||
)?;
|
||||
|
||||
// 3. Build a `GenerateVideoRequest<'static>` whose borrows point into the
|
||||
// owned boxes. SAFETY: same invariant as the image version — each box
|
||||
// lives in `Self` for the full lifetime of `DistilledVideoRequest`.
|
||||
// 3. Build static lifetime references for text fields.
|
||||
// SAFETY: each box lives in `Self` for the full lifetime of `DistilledVideoRequest`.
|
||||
let prompt_static: Option<&'static str> = owned_prompt.as_deref().map(|s: &String| {
|
||||
let raw: *const str = s.as_str();
|
||||
unsafe { &*raw }
|
||||
@@ -127,6 +130,23 @@ pub fn distill_video_request(
|
||||
unsafe { &*raw }
|
||||
});
|
||||
|
||||
// 4. Cost estimate (Artcraft provider).
|
||||
// `initial` already has media fields in token form (from hydrate_to_router_request),
|
||||
// which is exactly what the Artcraft cost builder needs. Just swap the provider.
|
||||
let cost: VideoGenerationCostEstimate = {
|
||||
let cost_request = GenerateVideoRequest {
|
||||
provider: Provider::Artcraft,
|
||||
..initial
|
||||
};
|
||||
let cost_plan = cost_request.build().map_err(|e| {
|
||||
warn!("Failed to build cost plan during video distillation: {}", e);
|
||||
AdvancedCommonWebError::from_error(e)
|
||||
})?;
|
||||
cost_plan.estimate_costs()
|
||||
};
|
||||
|
||||
// 5. Execution plan (execution provider).
|
||||
// The execution plan uses URL-form media references (resolved from the hydration map).
|
||||
let start_frame_static: Option<ImageRef<'static>> =
|
||||
owned_start_frame_url.as_deref().map(|s: &String| {
|
||||
let raw: *const str = s.as_str();
|
||||
@@ -155,7 +175,7 @@ pub fn distill_video_request(
|
||||
|
||||
let request_static: GenerateVideoRequest<'static> = GenerateVideoRequest {
|
||||
model: initial.model,
|
||||
provider: Provider::Fal,
|
||||
provider: execution_provider,
|
||||
prompt: prompt_static,
|
||||
negative_prompt: negative_prompt_static,
|
||||
start_frame: start_frame_static,
|
||||
@@ -173,20 +193,6 @@ pub fn distill_video_request(
|
||||
idempotency_token: idempotency_static,
|
||||
};
|
||||
|
||||
// 4. Cost estimate (Artcraft provider).
|
||||
let cost_request = GenerateVideoRequest {
|
||||
provider: Provider::Artcraft,
|
||||
..request_static
|
||||
};
|
||||
let cost_plan = cost_request.build().map_err(|e| {
|
||||
warn!("Failed to build cost plan during video distillation: {}", e);
|
||||
AdvancedCommonWebError::from_error(e)
|
||||
})?;
|
||||
let cost: VideoGenerationCostEstimate = cost_plan.estimate_costs();
|
||||
drop(cost_plan);
|
||||
drop(cost_request);
|
||||
|
||||
// 5. Execution plan (Fal provider).
|
||||
let plan: VideoGenerationPlan<'static> = {
|
||||
let request_ref: &'static GenerateVideoRequest<'static> = unsafe {
|
||||
let raw: *const GenerateVideoRequest<'static> = &request_static;
|
||||
@@ -210,6 +216,7 @@ pub fn distill_video_request(
|
||||
request: request_static,
|
||||
cost,
|
||||
plan,
|
||||
execution_provider,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+305
@@ -0,0 +1,305 @@
|
||||
//! Execution helpers for the omni-gen video generation handler.
|
||||
//!
|
||||
//! Routes generation to the appropriate provider (Fal or Seedance2Pro/Kinovi)
|
||||
//! based on the distilled request's `execution_provider`.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use log::{info, warn};
|
||||
use url::Url;
|
||||
|
||||
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
|
||||
use artcraft_router::api::provider::Provider;
|
||||
use artcraft_router::generate::generate_video::generate_video_response::GenerateVideoResponse;
|
||||
use enums::common::generation::common_generation_mode::CommonGenerationMode;
|
||||
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
|
||||
use seedance2pro_client::requests::generate_video::generate_video::{
|
||||
generate_video, BatchCount, GenerateVideoArgs, ModelType, Resolution,
|
||||
};
|
||||
use seedance2pro_client::requests::prepare_file_upload::prepare_file_upload::{
|
||||
prepare_file_upload, PrepareFileUploadArgs,
|
||||
};
|
||||
use seedance2pro_client::requests::upload_file::upload_file::{upload_file, UploadFileArgs};
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
use url_utils::extension::extract_extension_from_url::{
|
||||
extract_extension_from_url, ExtractExtensions,
|
||||
};
|
||||
|
||||
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
|
||||
use crate::state::server_state::ServerState;
|
||||
use crate::util::http_download_url_to_bytes::http_download_url_to_bytes;
|
||||
|
||||
use super::distill_video_request::DistilledVideoRequest;
|
||||
|
||||
/// Result of a successful generation, regardless of provider.
|
||||
pub struct GenerationResult {
|
||||
/// The external job ID used to track the generation (first order_id for Seedance).
|
||||
pub external_job_id: String,
|
||||
|
||||
/// Whether this is a Seedance2Pro generation (changes DB insertion path).
|
||||
pub is_seedance2pro: bool,
|
||||
|
||||
/// For Seedance2Pro batch jobs, the list of all order IDs.
|
||||
pub maybe_seedance_order_ids: Option<Vec<String>>,
|
||||
|
||||
/// The generation mode (Text, Keyframe, Reference).
|
||||
pub generation_mode: CommonGenerationMode,
|
||||
}
|
||||
|
||||
/// Execute video generation via the appropriate provider.
|
||||
pub async fn execute_generation(
|
||||
distilled: &DistilledVideoRequest,
|
||||
request: &OmniGenVideoCostAndGenerateRequest,
|
||||
server_state: &ServerState,
|
||||
media_file_hydration_map: Option<&HashMap<MediaFileToken, Url>>,
|
||||
) -> Result<GenerationResult, AdvancedCommonWebError> {
|
||||
match distilled.execution_provider {
|
||||
Provider::Seedance2Pro => {
|
||||
execute_generation_kinovi(
|
||||
distilled,
|
||||
request,
|
||||
server_state,
|
||||
media_file_hydration_map,
|
||||
).await
|
||||
}
|
||||
_ => {
|
||||
execute_generation_fal(
|
||||
distilled,
|
||||
request,
|
||||
server_state,
|
||||
).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute generation via Fal (the existing path).
|
||||
async fn execute_generation_fal(
|
||||
distilled: &DistilledVideoRequest,
|
||||
request: &OmniGenVideoCostAndGenerateRequest,
|
||||
server_state: &ServerState,
|
||||
) -> Result<GenerationResult, AdvancedCommonWebError> {
|
||||
let fal_client = artcraft_router::client::router_fal_client::RouterFalClient::new(
|
||||
server_state.fal.api_key.clone(),
|
||||
server_state.fal.webhook_url.clone(),
|
||||
);
|
||||
let router_client = artcraft_router::client::router_client::RouterClient::Fal(fal_client);
|
||||
|
||||
let generation_response = distilled.plan().generate_video(&router_client)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
warn!("Video generation failed (Fal): {:?}", e);
|
||||
AdvancedCommonWebError::from_error(e)
|
||||
})?;
|
||||
|
||||
let external_job_id = match &generation_response {
|
||||
GenerateVideoResponse::Artcraft(p) => p.inference_job_token.as_str().to_string(),
|
||||
GenerateVideoResponse::Muapi(p) => p.request_id.as_str().to_string(),
|
||||
GenerateVideoResponse::Seedance2Pro(p) => p.order_id.clone(),
|
||||
GenerateVideoResponse::Fal(p) => p.request_id.clone().unwrap_or_default(),
|
||||
};
|
||||
|
||||
let generation_mode = if request.start_frame_image_media_token.is_some() {
|
||||
CommonGenerationMode::Keyframe
|
||||
} else {
|
||||
CommonGenerationMode::Text
|
||||
};
|
||||
|
||||
Ok(GenerationResult {
|
||||
external_job_id,
|
||||
is_seedance2pro: false,
|
||||
maybe_seedance_order_ids: None,
|
||||
generation_mode,
|
||||
})
|
||||
}
|
||||
|
||||
/// Execute generation via Seedance2Pro/Kinovi.
|
||||
///
|
||||
/// Downloads all referenced media from our CDN, re-uploads to Seedance2Pro's
|
||||
/// CDN, then calls the Seedance2Pro generate endpoint.
|
||||
async fn execute_generation_kinovi(
|
||||
distilled: &DistilledVideoRequest,
|
||||
request: &OmniGenVideoCostAndGenerateRequest,
|
||||
server_state: &ServerState,
|
||||
media_file_hydration_map: Option<&HashMap<MediaFileToken, Url>>,
|
||||
) -> Result<GenerationResult, AdvancedCommonWebError> {
|
||||
let session = Seedance2ProSession::from_cookies_string(
|
||||
server_state.seedance2pro.cookies.clone()
|
||||
);
|
||||
|
||||
let empty_map = HashMap::new();
|
||||
let hydration_map = media_file_hydration_map.unwrap_or(&empty_map);
|
||||
|
||||
// Upload each media reference to Seedance2Pro CDN.
|
||||
let start_frame_url = upload_token_to_seedance2pro(
|
||||
&session, hydration_map, request.start_frame_image_media_token.as_ref(),
|
||||
).await?;
|
||||
|
||||
let end_frame_url = upload_token_to_seedance2pro(
|
||||
&session, hydration_map, request.end_frame_image_media_token.as_ref(),
|
||||
).await?;
|
||||
|
||||
let reference_image_urls = upload_tokens_to_seedance2pro(
|
||||
&session, hydration_map, request.reference_image_media_tokens.as_deref(),
|
||||
).await?;
|
||||
|
||||
let reference_video_urls = upload_tokens_to_seedance2pro(
|
||||
&session, hydration_map, request.reference_video_media_tokens.as_deref(),
|
||||
).await?;
|
||||
|
||||
let reference_audio_urls = upload_tokens_to_seedance2pro(
|
||||
&session, hydration_map, request.reference_audio_media_tokens.as_deref(),
|
||||
).await?;
|
||||
|
||||
// Determine generation mode.
|
||||
let is_keyframe = request.start_frame_image_media_token.is_some()
|
||||
|| request.end_frame_image_media_token.is_some();
|
||||
let is_reference = request.reference_image_media_tokens.as_ref().map_or(false, |t| !t.is_empty())
|
||||
|| request.reference_video_media_tokens.as_ref().map_or(false, |t| !t.is_empty())
|
||||
|| request.reference_audio_media_tokens.as_ref().map_or(false, |t| !t.is_empty());
|
||||
|
||||
let generation_mode = if is_keyframe {
|
||||
CommonGenerationMode::Keyframe
|
||||
} else if is_reference {
|
||||
CommonGenerationMode::Reference
|
||||
} else {
|
||||
CommonGenerationMode::Text
|
||||
};
|
||||
|
||||
// Map aspect ratio / duration / batch from the request.
|
||||
let resolution = Resolution::Landscape16x9; // TODO: Map from request.aspect_ratio
|
||||
let duration_seconds = request.duration_seconds.unwrap_or(5).clamp(4, 15) as u8;
|
||||
let batch_count = match request.video_batch_count {
|
||||
Some(2) => BatchCount::Two,
|
||||
Some(4) => BatchCount::Four,
|
||||
_ => BatchCount::One,
|
||||
};
|
||||
|
||||
let prompt = request.prompt.clone().unwrap_or_default();
|
||||
|
||||
let video_gen_args = GenerateVideoArgs {
|
||||
session: &session,
|
||||
model_type: ModelType::Seedance2Pro,
|
||||
prompt,
|
||||
resolution,
|
||||
duration_seconds,
|
||||
batch_count,
|
||||
start_frame_url,
|
||||
end_frame_url,
|
||||
reference_image_urls,
|
||||
reference_video_urls,
|
||||
reference_audio_urls,
|
||||
character_ids: None, // TODO: resolve_kinovi_character_ids when character support is added
|
||||
use_face_blur_hack: None,
|
||||
host_override: None,
|
||||
};
|
||||
|
||||
let gen_response = generate_video(video_gen_args).await
|
||||
.map_err(|err| {
|
||||
warn!("Seedance2Pro generate_video failed: {:?}", err);
|
||||
AdvancedCommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
info!(
|
||||
"Seedance2Pro generation: task_id={}, order_id={}",
|
||||
gen_response.task_id, gen_response.order_id
|
||||
);
|
||||
|
||||
let order_ids: Vec<String> = match gen_response.order_ids {
|
||||
Some(ids) if !ids.is_empty() => ids,
|
||||
_ => vec![gen_response.order_id.clone()],
|
||||
};
|
||||
|
||||
Ok(GenerationResult {
|
||||
external_job_id: gen_response.order_id,
|
||||
is_seedance2pro: true,
|
||||
maybe_seedance_order_ids: Some(order_ids),
|
||||
generation_mode,
|
||||
})
|
||||
}
|
||||
|
||||
// --- Upload helpers ---
|
||||
|
||||
/// Upload a single media token (if present) to Seedance2Pro CDN.
|
||||
async fn upload_token_to_seedance2pro(
|
||||
session: &Seedance2ProSession,
|
||||
hydration_map: &HashMap<MediaFileToken, Url>,
|
||||
maybe_token: Option<&MediaFileToken>,
|
||||
) -> Result<Option<String>, AdvancedCommonWebError> {
|
||||
let token = match maybe_token {
|
||||
None => return Ok(None),
|
||||
Some(t) => t,
|
||||
};
|
||||
|
||||
let url = hydration_map.get(token).ok_or_else(|| {
|
||||
AdvancedCommonWebError::BadInputWithSimpleMessage(
|
||||
format!("Media token not found in hydration map: {:?}", token))
|
||||
})?;
|
||||
|
||||
let uploaded = upload_url_to_seedance2pro(session, url).await?;
|
||||
Ok(Some(uploaded))
|
||||
}
|
||||
|
||||
/// Upload a list of media tokens (if present) to Seedance2Pro CDN.
|
||||
async fn upload_tokens_to_seedance2pro(
|
||||
session: &Seedance2ProSession,
|
||||
hydration_map: &HashMap<MediaFileToken, Url>,
|
||||
maybe_tokens: Option<&[MediaFileToken]>,
|
||||
) -> Result<Option<Vec<String>>, AdvancedCommonWebError> {
|
||||
let tokens = match maybe_tokens {
|
||||
None => return Ok(None),
|
||||
Some(tokens) if tokens.is_empty() => return Ok(None),
|
||||
Some(tokens) => tokens,
|
||||
};
|
||||
|
||||
let mut urls = Vec::with_capacity(tokens.len());
|
||||
for token in tokens {
|
||||
let url = hydration_map.get(token).ok_or_else(|| {
|
||||
AdvancedCommonWebError::BadInputWithSimpleMessage(
|
||||
format!("Media token not found in hydration map: {:?}", token))
|
||||
})?;
|
||||
urls.push(upload_url_to_seedance2pro(session, url).await?);
|
||||
}
|
||||
Ok(Some(urls))
|
||||
}
|
||||
|
||||
/// Download from our CDN and re-upload to Seedance2Pro.
|
||||
async fn upload_url_to_seedance2pro(
|
||||
session: &Seedance2ProSession,
|
||||
our_cdn_url: &Url,
|
||||
) -> Result<String, AdvancedCommonWebError> {
|
||||
let extension = extract_extension_from_url(our_cdn_url, &ExtractExtensions::All)
|
||||
.map(|ext| ext.without_period().to_string())
|
||||
.unwrap_or_else(|| "png".to_string());
|
||||
|
||||
let file_bytes = http_download_url_to_bytes(our_cdn_url.as_str())
|
||||
.await
|
||||
.map_err(|err| {
|
||||
warn!("Error downloading media from CDN for Seedance2Pro upload: {:?}", err);
|
||||
AdvancedCommonWebError::from_error(err)
|
||||
})?
|
||||
.to_vec();
|
||||
|
||||
let prepare_result = prepare_file_upload(PrepareFileUploadArgs {
|
||||
session,
|
||||
extension,
|
||||
host_override: None,
|
||||
})
|
||||
.await
|
||||
.map_err(|err| {
|
||||
warn!("Error preparing seedance2pro file upload: {:?}", err);
|
||||
AdvancedCommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let upload_result = upload_file(UploadFileArgs {
|
||||
upload_url: prepare_result.upload_url,
|
||||
file_bytes,
|
||||
host_override: None,
|
||||
})
|
||||
.await
|
||||
.map_err(|err| {
|
||||
warn!("Error uploading file to seedance2pro: {:?}", err);
|
||||
AdvancedCommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
Ok(upload_result.public_url)
|
||||
}
|
||||
+1
@@ -1,6 +1,7 @@
|
||||
pub mod omni_gen_video_generate_handler;
|
||||
pub mod distill_video_request;
|
||||
pub(crate) mod distill_helper;
|
||||
mod execute_generation;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
+104
-60
@@ -9,9 +9,10 @@ use url::Url;
|
||||
|
||||
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
|
||||
use artcraft_api_defs::omni_gen::generate_response::omni_gen_video_generate_response::OmniGenVideoGenerateResponse;
|
||||
use artcraft_router::api::provider::Provider;
|
||||
use enums::common::generation::common_video_model::CommonVideoModel;
|
||||
use enums::by_table::prompt_context_items::prompt_context_semantic_type::PromptContextSemanticType;
|
||||
use enums::by_table::prompts::prompt_type::PromptType;
|
||||
use enums::common::generation::common_generation_mode::CommonGenerationMode;
|
||||
use enums::common::generation::common_model_type::CommonModelType;
|
||||
use enums::common::generation_provider::GenerationProvider;
|
||||
use enums::common::visibility::Visibility;
|
||||
@@ -21,6 +22,10 @@ use mysql_queries::queries::generic_inference::fal::insert_generic_inference_job
|
||||
insert_generic_inference_job_for_fal_queue_with_apriori_job_token,
|
||||
InsertGenericInferenceForFalWithAprioriJobTokenArgs,
|
||||
};
|
||||
use mysql_queries::queries::generic_inference::seedance2pro::insert_generic_inference_job_for_seedance2pro_queue_with_apriori_job_token::{
|
||||
insert_generic_inference_job_for_seedance2pro_queue_with_apriori_job_token,
|
||||
InsertGenericInferenceForSeedance2ProWithAprioriJobTokenArgs,
|
||||
};
|
||||
use mysql_queries::queries::idepotency_tokens::insert_idempotency_token::insert_idempotency_token;
|
||||
use mysql_queries::queries::prompt_context_items::insert_batch_prompt_context_items::{
|
||||
insert_batch_prompt_context_items, InsertBatchArgs, PromptContextItem,
|
||||
@@ -37,6 +42,7 @@ use crate::state::server_state::ServerState;
|
||||
use crate::util::lookup::lookup_image_urls_as_map::lookup_image_urls_as_map;
|
||||
|
||||
use super::distill_video_request::distill_video_request;
|
||||
use super::execute_generation::execute_generation;
|
||||
|
||||
/// Generate a video using the omni-gen unified endpoint.
|
||||
#[utoipa::path(
|
||||
@@ -149,12 +155,20 @@ pub async fn omni_gen_video_generate_handler(
|
||||
}
|
||||
};
|
||||
|
||||
// ==================== DETERMINE PROVIDER ==================== //
|
||||
|
||||
let execution_provider = match request.model {
|
||||
Some(CommonVideoModel::Seedance2p0) => Provider::Seedance2Pro,
|
||||
_ => Provider::Fal,
|
||||
};
|
||||
|
||||
// ==================== DISTILL ==================== //
|
||||
|
||||
let distilled = distill_video_request(&request, media_file_hydration_map.as_ref())?;
|
||||
let distilled = distill_video_request(&request, media_file_hydration_map.as_ref(), execution_provider)?;
|
||||
|
||||
info!(">>> Distilled cost: {:?}", distilled.cost);
|
||||
info!(">>> Distilled plan: {:?}", distilled.plan());
|
||||
info!(">>> Execution provider: {:?}", distilled.execution_provider);
|
||||
|
||||
// ==================== BILLING ==================== //
|
||||
|
||||
@@ -175,33 +189,12 @@ pub async fn omni_gen_video_generate_handler(
|
||||
|
||||
// ==================== EXECUTE GENERATION ==================== //
|
||||
|
||||
let fal_client = artcraft_router::client::router_fal_client::RouterFalClient::new(
|
||||
server_state.fal.api_key.clone(),
|
||||
server_state.fal.webhook_url.clone(),
|
||||
);
|
||||
let router_client = artcraft_router::client::router_client::RouterClient::Fal(fal_client);
|
||||
|
||||
let generation_response = distilled.plan().generate_video(&router_client)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
warn!("Video generation failed: {:?}", e);
|
||||
AdvancedCommonWebError::from_error(e)
|
||||
})?;
|
||||
|
||||
let external_job_id = match &generation_response {
|
||||
artcraft_router::generate::generate_video::generate_video_response::GenerateVideoResponse::Artcraft(p) => {
|
||||
p.inference_job_token.as_str().to_string()
|
||||
}
|
||||
artcraft_router::generate::generate_video::generate_video_response::GenerateVideoResponse::Muapi(p) => {
|
||||
p.request_id.as_str().to_string()
|
||||
}
|
||||
artcraft_router::generate::generate_video::generate_video_response::GenerateVideoResponse::Seedance2Pro(p) => {
|
||||
p.order_id.clone()
|
||||
}
|
||||
artcraft_router::generate::generate_video::generate_video_response::GenerateVideoResponse::Fal(p) => {
|
||||
p.request_id.clone().unwrap_or_default()
|
||||
}
|
||||
};
|
||||
let gen_result = execute_generation(
|
||||
&distilled,
|
||||
&request,
|
||||
&server_state,
|
||||
media_file_hydration_map.as_ref(),
|
||||
).await?;
|
||||
|
||||
// ==================== DB TRANSACTION ==================== //
|
||||
|
||||
@@ -217,12 +210,6 @@ pub async fn omni_gen_video_generate_handler(
|
||||
|
||||
// -- Prompt --
|
||||
|
||||
let generation_mode = if request.start_frame_image_media_token.is_some() {
|
||||
CommonGenerationMode::Keyframe
|
||||
} else {
|
||||
CommonGenerationMode::Text
|
||||
};
|
||||
|
||||
let prompt_result = insert_prompt(InsertPromptArgs {
|
||||
maybe_apriori_prompt_token: None,
|
||||
prompt_type: PromptType::ArtcraftApp,
|
||||
@@ -232,7 +219,7 @@ pub async fn omni_gen_video_generate_handler(
|
||||
maybe_positive_prompt: request.prompt.as_deref(),
|
||||
maybe_negative_prompt: request.negative_prompt.as_deref(),
|
||||
maybe_other_args: None,
|
||||
maybe_generation_mode: Some(generation_mode),
|
||||
maybe_generation_mode: Some(gen_result.generation_mode),
|
||||
maybe_aspect_ratio: None,
|
||||
maybe_resolution: None,
|
||||
maybe_batch_count: request.video_batch_count.map(|c| c as u8),
|
||||
@@ -301,31 +288,88 @@ pub async fn omni_gen_video_generate_handler(
|
||||
|
||||
// -- Inference job --
|
||||
|
||||
let db_result = insert_generic_inference_job_for_fal_queue_with_apriori_job_token(
|
||||
InsertGenericInferenceForFalWithAprioriJobTokenArgs {
|
||||
apriori_job_token: &apriori_job_token,
|
||||
uuid_idempotency_token: &idempotency_token,
|
||||
maybe_external_third_party_id: &external_job_id,
|
||||
fal_category: FalCategory::VideoGeneration,
|
||||
maybe_inference_args: None,
|
||||
maybe_prompt_token: prompt_token.as_ref(),
|
||||
maybe_creator_user_token: Some(user_token),
|
||||
maybe_avt_token: maybe_avt_token.as_ref(),
|
||||
creator_ip_address: &ip_address,
|
||||
creator_set_visibility: Visibility::Public,
|
||||
mysql_executor: &mut *transaction,
|
||||
starting_job_status_override: None,
|
||||
maybe_frontend_failure_category: None,
|
||||
maybe_failure_reason: None,
|
||||
phantom: Default::default(),
|
||||
}
|
||||
).await;
|
||||
let job_token = if gen_result.is_seedance2pro {
|
||||
// Seedance2Pro path: insert one job per order_id (batch support).
|
||||
let fallback_ids = vec![gen_result.external_job_id.clone()];
|
||||
let order_ids = gen_result.maybe_seedance_order_ids
|
||||
.as_deref()
|
||||
.unwrap_or(&fallback_ids);
|
||||
|
||||
let job_token = match db_result {
|
||||
Ok(token) => token,
|
||||
Err(err) => {
|
||||
warn!("Error inserting inference job: {:?}", err);
|
||||
return Err(AdvancedCommonWebError::from_error(err));
|
||||
let mut all_job_tokens: Vec<InferenceJobToken> = Vec::with_capacity(order_ids.len());
|
||||
|
||||
for (i, order_id) in order_ids.iter().enumerate() {
|
||||
let job_token = if i == 0 {
|
||||
apriori_job_token.clone()
|
||||
} else {
|
||||
InferenceJobToken::generate()
|
||||
};
|
||||
|
||||
let idempotency_str = if i == 0 {
|
||||
idempotency_token.clone()
|
||||
} else {
|
||||
format!("{}-batch-{}", idempotency_token, i)
|
||||
};
|
||||
|
||||
let db_result = insert_generic_inference_job_for_seedance2pro_queue_with_apriori_job_token(
|
||||
InsertGenericInferenceForSeedance2ProWithAprioriJobTokenArgs {
|
||||
apriori_job_token: &job_token,
|
||||
uuid_idempotency_token: &idempotency_str,
|
||||
maybe_external_third_party_id: order_id,
|
||||
maybe_inference_args: None,
|
||||
maybe_prompt_token: prompt_token.as_ref(),
|
||||
maybe_wallet_ledger_entry_token: None,
|
||||
maybe_creator_user_token: Some(user_token),
|
||||
maybe_avt_token: maybe_avt_token.as_ref(),
|
||||
creator_ip_address: &ip_address,
|
||||
creator_set_visibility: Visibility::Public,
|
||||
mysql_executor: &mut *transaction,
|
||||
phantom: Default::default(),
|
||||
}
|
||||
).await;
|
||||
|
||||
match db_result {
|
||||
Ok(token) => all_job_tokens.push(token),
|
||||
Err(err) => {
|
||||
warn!("Error inserting seedance2pro inference job (order_id={}): {:?}", order_id, err);
|
||||
if i == 0 {
|
||||
return Err(AdvancedCommonWebError::from_error(err));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
all_job_tokens.first().cloned().ok_or_else(|| {
|
||||
error!("No inference job token was created");
|
||||
AdvancedCommonWebError::server_error_with_message("No inference job token was created")
|
||||
})?
|
||||
} else {
|
||||
// Fal / other providers path.
|
||||
let db_result = insert_generic_inference_job_for_fal_queue_with_apriori_job_token(
|
||||
InsertGenericInferenceForFalWithAprioriJobTokenArgs {
|
||||
apriori_job_token: &apriori_job_token,
|
||||
uuid_idempotency_token: &idempotency_token,
|
||||
maybe_external_third_party_id: &gen_result.external_job_id,
|
||||
fal_category: FalCategory::VideoGeneration,
|
||||
maybe_inference_args: None,
|
||||
maybe_prompt_token: prompt_token.as_ref(),
|
||||
maybe_creator_user_token: Some(user_token),
|
||||
maybe_avt_token: maybe_avt_token.as_ref(),
|
||||
creator_ip_address: &ip_address,
|
||||
creator_set_visibility: Visibility::Public,
|
||||
mysql_executor: &mut *transaction,
|
||||
starting_job_status_override: None,
|
||||
maybe_frontend_failure_category: None,
|
||||
maybe_failure_reason: None,
|
||||
phantom: Default::default(),
|
||||
}
|
||||
).await;
|
||||
|
||||
match db_result {
|
||||
Ok(token) => token,
|
||||
Err(err) => {
|
||||
warn!("Error inserting inference job: {:?}", err);
|
||||
return Err(AdvancedCommonWebError::from_error(err));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
mod test_kinovi_seedance_2p0;
|
||||
+459
@@ -0,0 +1,459 @@
|
||||
//! Tests for omni-gen video against the Seedance 2.0 model (Kinovi/Seedance2Pro provider).
|
||||
//!
|
||||
//! Seedance 2.0 is special: it uses the Seedance2Pro/Kinovi provider for execution
|
||||
//! instead of Fal. The cost estimation uses Artcraft provider with media-token-form
|
||||
//! references, while the execution plan uses URL-form references.
|
||||
//!
|
||||
//! Pricing: 16¢ per second × batch count.
|
||||
//! - 4s/batch 1 = 64¢, 5s/batch 1 = 80¢, 15s/batch 1 = 240¢
|
||||
//! - Batch 2 = 2×, Batch 4 = 4×
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashMap;
|
||||
|
||||
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
|
||||
use artcraft_router::api::provider::Provider;
|
||||
use artcraft_router::generate::generate_video::video_generation_plan::VideoGenerationPlan;
|
||||
use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
|
||||
use enums::common::generation::common_video_model::CommonVideoModel;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
use url::Url;
|
||||
|
||||
use crate::http_server::endpoints::omni_gen::generate::video::distill_video_request::{
|
||||
distill_video_request, DistilledVideoRequest,
|
||||
};
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
fn make_request(
|
||||
prompt: Option<&str>,
|
||||
aspect_ratio: Option<CommonAspectRatio>,
|
||||
duration_seconds: Option<u16>,
|
||||
video_batch_count: Option<u16>,
|
||||
start_frame_token: Option<MediaFileToken>,
|
||||
end_frame_token: Option<MediaFileToken>,
|
||||
reference_image_tokens: Option<Vec<MediaFileToken>>,
|
||||
reference_video_tokens: Option<Vec<MediaFileToken>>,
|
||||
reference_audio_tokens: Option<Vec<MediaFileToken>>,
|
||||
) -> OmniGenVideoCostAndGenerateRequest {
|
||||
OmniGenVideoCostAndGenerateRequest {
|
||||
idempotency_token: Some("11111111-1111-1111-1111-111111111111".to_string()),
|
||||
model: Some(CommonVideoModel::Seedance2p0),
|
||||
prompt: prompt.map(|s| s.to_string()),
|
||||
negative_prompt: None,
|
||||
start_frame_image_media_token: start_frame_token,
|
||||
end_frame_image_media_token: end_frame_token,
|
||||
reference_image_media_tokens: reference_image_tokens,
|
||||
reference_video_media_tokens: reference_video_tokens,
|
||||
reference_audio_media_tokens: reference_audio_tokens,
|
||||
reference_character_tokens: None,
|
||||
resolution: None,
|
||||
aspect_ratio,
|
||||
quality: None,
|
||||
duration_seconds,
|
||||
video_batch_count,
|
||||
generate_audio: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn text_only_request(
|
||||
duration_seconds: Option<u16>,
|
||||
video_batch_count: Option<u16>,
|
||||
) -> OmniGenVideoCostAndGenerateRequest {
|
||||
make_request(
|
||||
Some("a cat dancing"),
|
||||
None,
|
||||
duration_seconds,
|
||||
video_batch_count,
|
||||
None, None, None, None, None,
|
||||
)
|
||||
}
|
||||
|
||||
fn fake_token(name: &str) -> MediaFileToken {
|
||||
MediaFileToken::new_from_str(name)
|
||||
}
|
||||
|
||||
fn build_hydration_map(tokens: &[&MediaFileToken]) -> HashMap<MediaFileToken, Url> {
|
||||
tokens.iter().enumerate().map(|(i, t)| {
|
||||
let url = Url::parse(&format!("https://cdn.example.com/media/{}.png", i)).unwrap();
|
||||
((*t).clone(), url)
|
||||
}).collect()
|
||||
}
|
||||
|
||||
fn distill_text(request: &OmniGenVideoCostAndGenerateRequest) -> DistilledVideoRequest {
|
||||
let empty: HashMap<MediaFileToken, Url> = HashMap::new();
|
||||
distill_video_request(request, Some(&empty), Provider::Seedance2Pro)
|
||||
.expect("distill should succeed for Seedance 2.0 (text)")
|
||||
}
|
||||
|
||||
fn distill_with_map(
|
||||
request: &OmniGenVideoCostAndGenerateRequest,
|
||||
hydration: &HashMap<MediaFileToken, Url>,
|
||||
) -> DistilledVideoRequest {
|
||||
distill_video_request(request, Some(hydration), Provider::Seedance2Pro)
|
||||
.expect("distill should succeed for Seedance 2.0 (with media)")
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// COST TESTS
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
mod cost_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn cost_is_present() {
|
||||
let d = distill_text(&text_only_request(Some(5), None));
|
||||
assert!(d.cost.cost_in_credits.is_some());
|
||||
assert!(d.cost.cost_in_usd_cents.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_is_not_free() {
|
||||
let d = distill_text(&text_only_request(Some(5), None));
|
||||
assert!(!d.cost.is_free);
|
||||
}
|
||||
|
||||
// Pricing: 16¢/sec × batch_count
|
||||
|
||||
#[test]
|
||||
fn cost_4s_batch_1() {
|
||||
let d = distill_text(&text_only_request(Some(4), Some(1)));
|
||||
assert_eq!(d.cost.cost_in_usd_cents, Some(64));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_5s_batch_1() {
|
||||
let d = distill_text(&text_only_request(Some(5), Some(1)));
|
||||
assert_eq!(d.cost.cost_in_usd_cents, Some(80));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_6s_batch_1() {
|
||||
let d = distill_text(&text_only_request(Some(6), Some(1)));
|
||||
assert_eq!(d.cost.cost_in_usd_cents, Some(96));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_10s_batch_1() {
|
||||
let d = distill_text(&text_only_request(Some(10), Some(1)));
|
||||
assert_eq!(d.cost.cost_in_usd_cents, Some(160));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_15s_batch_1() {
|
||||
let d = distill_text(&text_only_request(Some(15), Some(1)));
|
||||
assert_eq!(d.cost.cost_in_usd_cents, Some(240));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_5s_batch_2() {
|
||||
let d = distill_text(&text_only_request(Some(5), Some(2)));
|
||||
assert_eq!(d.cost.cost_in_usd_cents, Some(160));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_5s_batch_4() {
|
||||
let d = distill_text(&text_only_request(Some(5), Some(4)));
|
||||
assert_eq!(d.cost.cost_in_usd_cents, Some(320));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_15s_batch_4() {
|
||||
let d = distill_text(&text_only_request(Some(15), Some(4)));
|
||||
assert_eq!(d.cost.cost_in_usd_cents, Some(960));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_same_with_or_without_start_frame() {
|
||||
// Cost should not change based on media presence — it's duration × batch.
|
||||
let text_d = distill_text(&text_only_request(Some(5), Some(1)));
|
||||
|
||||
let start_token = fake_token("start_frame");
|
||||
let map = build_hydration_map(&[&start_token]);
|
||||
let req = make_request(
|
||||
Some("a cat"), None, Some(5), Some(1),
|
||||
Some(start_token), None, None, None, None,
|
||||
);
|
||||
let frame_d = distill_with_map(&req, &map);
|
||||
|
||||
assert_eq!(text_d.cost.cost_in_usd_cents, frame_d.cost.cost_in_usd_cents);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_estimation_uses_media_tokens_not_urls() {
|
||||
// The Artcraft cost plan should use token-form references.
|
||||
// If it used URLs, the Artcraft plan builder would reject them.
|
||||
// This test passes if distillation succeeds at all — the Artcraft
|
||||
// builder would error out on URL-form refs.
|
||||
let start_token = fake_token("start_frame");
|
||||
let ref_img = fake_token("ref_img_1");
|
||||
let ref_vid = fake_token("ref_vid_1");
|
||||
let ref_aud = fake_token("ref_aud_1");
|
||||
|
||||
let map = build_hydration_map(&[&start_token, &ref_img, &ref_vid, &ref_aud]);
|
||||
|
||||
let req = make_request(
|
||||
Some("test"), None, Some(5), Some(1),
|
||||
Some(start_token), None,
|
||||
Some(vec![ref_img]),
|
||||
Some(vec![ref_vid]),
|
||||
Some(vec![ref_aud]),
|
||||
);
|
||||
|
||||
// If this succeeds, the Artcraft cost path handled tokens correctly.
|
||||
let d = distill_with_map(&req, &map);
|
||||
assert!(d.cost.cost_in_credits.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_duration_produces_valid_cost() {
|
||||
// When duration_seconds is None, the model should use a default.
|
||||
let d = distill_text(&text_only_request(None, None));
|
||||
assert!(d.cost.cost_in_usd_cents.unwrap_or(0) > 0);
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// GENERATION PLAN TESTS
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
mod generation_plan_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn provider_is_seedance2pro() {
|
||||
let d = distill_text(&text_only_request(Some(5), None));
|
||||
assert!(
|
||||
matches!(d.execution_provider, Provider::Seedance2Pro),
|
||||
"expected Seedance2Pro provider, got {:?}", d.execution_provider
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_is_seedance2pro_variant() {
|
||||
let d = distill_text(&text_only_request(Some(5), None));
|
||||
assert!(
|
||||
matches!(d.plan(), VideoGenerationPlan::Seedance2proSeedance2p0(_)),
|
||||
"expected Seedance2proSeedance2p0 plan variant, got {:?}", d.plan()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_only_has_no_media_urls() {
|
||||
let d = distill_text(&text_only_request(Some(5), None));
|
||||
if let VideoGenerationPlan::Seedance2proSeedance2p0(plan) = d.plan() {
|
||||
assert!(plan.start_frame_url.is_none());
|
||||
assert!(plan.end_frame_url.is_none());
|
||||
assert!(plan.reference_image_urls.is_none());
|
||||
assert!(plan.reference_video_urls.is_none());
|
||||
assert!(plan.reference_audio_urls.is_none());
|
||||
} else {
|
||||
panic!("wrong plan variant");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_only_prompt_is_passed_through() {
|
||||
let d = distill_text(&text_only_request(Some(5), None));
|
||||
if let VideoGenerationPlan::Seedance2proSeedance2p0(plan) = d.plan() {
|
||||
assert_eq!(plan.prompt.as_deref(), Some("a cat dancing"));
|
||||
} else {
|
||||
panic!("wrong plan variant");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn start_frame_uses_url_not_token() {
|
||||
let start_token = fake_token("start_frame");
|
||||
let map = build_hydration_map(&[&start_token]);
|
||||
|
||||
let req = make_request(
|
||||
Some("test"), None, Some(5), None,
|
||||
Some(start_token), None, None, None, None,
|
||||
);
|
||||
let d = distill_with_map(&req, &map);
|
||||
|
||||
if let VideoGenerationPlan::Seedance2proSeedance2p0(plan) = d.plan() {
|
||||
let url = plan.start_frame_url.as_ref().expect("start_frame_url should be set");
|
||||
assert!(url.starts_with("https://"), "expected URL, got: {}", url);
|
||||
assert!(!url.contains("m_"), "URL should not look like a media token");
|
||||
} else {
|
||||
panic!("wrong plan variant");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn end_frame_uses_url_not_token() {
|
||||
let end_token = fake_token("end_frame");
|
||||
let map = build_hydration_map(&[&end_token]);
|
||||
|
||||
let req = make_request(
|
||||
Some("test"), None, Some(5), None,
|
||||
None, Some(end_token), None, None, None,
|
||||
);
|
||||
let d = distill_with_map(&req, &map);
|
||||
|
||||
if let VideoGenerationPlan::Seedance2proSeedance2p0(plan) = d.plan() {
|
||||
let url = plan.end_frame_url.as_ref().expect("end_frame_url should be set");
|
||||
assert!(url.starts_with("https://"), "expected URL, got: {}", url);
|
||||
} else {
|
||||
panic!("wrong plan variant");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn start_and_end_frame_both_resolved() {
|
||||
let start = fake_token("sf");
|
||||
let end = fake_token("ef");
|
||||
let map = build_hydration_map(&[&start, &end]);
|
||||
|
||||
let req = make_request(
|
||||
Some("test"), None, Some(5), None,
|
||||
Some(start), Some(end), None, None, None,
|
||||
);
|
||||
let d = distill_with_map(&req, &map);
|
||||
|
||||
if let VideoGenerationPlan::Seedance2proSeedance2p0(plan) = d.plan() {
|
||||
assert!(plan.start_frame_url.is_some());
|
||||
assert!(plan.end_frame_url.is_some());
|
||||
} else {
|
||||
panic!("wrong plan variant");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_images_use_urls() {
|
||||
let img1 = fake_token("img1");
|
||||
let img2 = fake_token("img2");
|
||||
let map = build_hydration_map(&[&img1, &img2]);
|
||||
|
||||
let req = make_request(
|
||||
Some("test"), None, Some(5), None,
|
||||
None, None,
|
||||
Some(vec![img1, img2]),
|
||||
None, None,
|
||||
);
|
||||
let d = distill_with_map(&req, &map);
|
||||
|
||||
if let VideoGenerationPlan::Seedance2proSeedance2p0(plan) = d.plan() {
|
||||
let urls = plan.reference_image_urls.as_ref().expect("reference_image_urls should be set");
|
||||
assert_eq!(urls.len(), 2);
|
||||
for url in urls {
|
||||
assert!(url.starts_with("https://"), "expected URL, got: {}", url);
|
||||
}
|
||||
} else {
|
||||
panic!("wrong plan variant");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_videos_use_urls() {
|
||||
let vid1 = fake_token("vid1");
|
||||
let map = build_hydration_map(&[&vid1]);
|
||||
|
||||
let req = make_request(
|
||||
Some("test"), None, Some(5), None,
|
||||
None, None, None,
|
||||
Some(vec![vid1]),
|
||||
None,
|
||||
);
|
||||
let d = distill_with_map(&req, &map);
|
||||
|
||||
if let VideoGenerationPlan::Seedance2proSeedance2p0(plan) = d.plan() {
|
||||
let urls = plan.reference_video_urls.as_ref().expect("reference_video_urls should be set");
|
||||
assert_eq!(urls.len(), 1);
|
||||
assert!(urls[0].starts_with("https://"));
|
||||
} else {
|
||||
panic!("wrong plan variant");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_audio_use_urls() {
|
||||
let aud1 = fake_token("aud1");
|
||||
let map = build_hydration_map(&[&aud1]);
|
||||
|
||||
let req = make_request(
|
||||
Some("test"), None, Some(5), None,
|
||||
None, None, None, None,
|
||||
Some(vec![aud1]),
|
||||
);
|
||||
let d = distill_with_map(&req, &map);
|
||||
|
||||
if let VideoGenerationPlan::Seedance2proSeedance2p0(plan) = d.plan() {
|
||||
let urls = plan.reference_audio_urls.as_ref().expect("reference_audio_urls should be set");
|
||||
assert_eq!(urls.len(), 1);
|
||||
assert!(urls[0].starts_with("https://"));
|
||||
} else {
|
||||
panic!("wrong plan variant");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_reference_types_together() {
|
||||
let sf = fake_token("sf");
|
||||
let ef = fake_token("ef");
|
||||
let img = fake_token("img");
|
||||
let vid = fake_token("vid");
|
||||
let aud = fake_token("aud");
|
||||
let map = build_hydration_map(&[&sf, &ef, &img, &vid, &aud]);
|
||||
|
||||
let req = make_request(
|
||||
Some("full test"), None, Some(8), Some(2),
|
||||
Some(sf), Some(ef),
|
||||
Some(vec![img]),
|
||||
Some(vec![vid]),
|
||||
Some(vec![aud]),
|
||||
);
|
||||
let d = distill_with_map(&req, &map);
|
||||
|
||||
if let VideoGenerationPlan::Seedance2proSeedance2p0(plan) = d.plan() {
|
||||
assert!(plan.start_frame_url.is_some());
|
||||
assert!(plan.end_frame_url.is_some());
|
||||
assert_eq!(plan.reference_image_urls.as_ref().map(|v| v.len()), Some(1));
|
||||
assert_eq!(plan.reference_video_urls.as_ref().map(|v| v.len()), Some(1));
|
||||
assert_eq!(plan.reference_audio_urls.as_ref().map(|v| v.len()), Some(1));
|
||||
assert!(plan.prompt.as_deref() == Some("full test"));
|
||||
} else {
|
||||
panic!("wrong plan variant");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_is_clamped_to_valid_range() {
|
||||
// Duration below 4 should clamp to 4.
|
||||
let d = distill_text(&text_only_request(Some(1), None));
|
||||
if let VideoGenerationPlan::Seedance2proSeedance2p0(plan) = d.plan() {
|
||||
assert!(plan.duration_seconds >= 4, "expected >= 4, got {}", plan.duration_seconds);
|
||||
} else {
|
||||
panic!("wrong plan variant");
|
||||
}
|
||||
|
||||
// Duration above 15 should clamp to 15.
|
||||
let d = distill_text(&text_only_request(Some(99), None));
|
||||
if let VideoGenerationPlan::Seedance2proSeedance2p0(plan) = d.plan() {
|
||||
assert!(plan.duration_seconds <= 15, "expected <= 15, got {}", plan.duration_seconds);
|
||||
} else {
|
||||
panic!("wrong plan variant");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_prompt_defaults_to_empty() {
|
||||
let req = make_request(
|
||||
None, None, Some(5), None,
|
||||
None, None, None, None, None,
|
||||
);
|
||||
let d = distill_text(&req);
|
||||
if let VideoGenerationPlan::Seedance2proSeedance2p0(plan) = d.plan() {
|
||||
// Prompt should be None or empty string.
|
||||
let prompt = plan.prompt.as_deref().unwrap_or("");
|
||||
assert!(prompt.is_empty() || plan.prompt.is_none());
|
||||
} else {
|
||||
panic!("wrong plan variant");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
@@ -1,3 +1,4 @@
|
||||
mod test_seedance_1p5_pro;
|
||||
mod test_veo_2;
|
||||
mod test_veo_3;
|
||||
mod kinovi;
|
||||
|
||||
+3
-2
@@ -19,6 +19,7 @@ mod tests {
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
use url::Url;
|
||||
|
||||
use artcraft_router::api::provider::Provider;
|
||||
use crate::http_server::endpoints::omni_gen::generate::video::distill_video_request::{
|
||||
distill_video_request, DistilledVideoRequest,
|
||||
};
|
||||
@@ -75,7 +76,7 @@ mod tests {
|
||||
|
||||
fn distill_text(request: &OmniGenVideoCostAndGenerateRequest) -> DistilledVideoRequest {
|
||||
let empty: HashMap<MediaFileToken, Url> = HashMap::new();
|
||||
distill_video_request(request, Some(&empty))
|
||||
distill_video_request(request, Some(&empty), Provider::Fal)
|
||||
.expect("distill_video_request should succeed for Seedance 1.5 Pro (text)")
|
||||
}
|
||||
|
||||
@@ -83,7 +84,7 @@ mod tests {
|
||||
request: &OmniGenVideoCostAndGenerateRequest,
|
||||
hydration: &HashMap<MediaFileToken, Url>,
|
||||
) -> DistilledVideoRequest {
|
||||
distill_video_request(request, Some(hydration))
|
||||
distill_video_request(request, Some(hydration), Provider::Fal)
|
||||
.expect("distill_video_request should succeed for Seedance 1.5 Pro (image)")
|
||||
}
|
||||
|
||||
|
||||
+3
-2
@@ -20,6 +20,7 @@ mod tests {
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
use url::Url;
|
||||
|
||||
use artcraft_router::api::provider::Provider;
|
||||
use crate::http_server::endpoints::omni_gen::generate::video::distill_video_request::{
|
||||
distill_video_request, DistilledVideoRequest,
|
||||
};
|
||||
@@ -62,7 +63,7 @@ mod tests {
|
||||
|
||||
fn distill_text(request: &OmniGenVideoCostAndGenerateRequest) -> DistilledVideoRequest {
|
||||
let empty: HashMap<MediaFileToken, Url> = HashMap::new();
|
||||
distill_video_request(request, Some(&empty))
|
||||
distill_video_request(request, Some(&empty), Provider::Fal)
|
||||
.expect("distill_video_request should succeed for Veo 2 (text)")
|
||||
}
|
||||
|
||||
@@ -70,7 +71,7 @@ mod tests {
|
||||
request: &OmniGenVideoCostAndGenerateRequest,
|
||||
hydration: &HashMap<MediaFileToken, Url>,
|
||||
) -> DistilledVideoRequest {
|
||||
distill_video_request(request, Some(hydration))
|
||||
distill_video_request(request, Some(hydration), Provider::Fal)
|
||||
.expect("distill_video_request should succeed for Veo 2 (image)")
|
||||
}
|
||||
|
||||
|
||||
+3
-2
@@ -24,6 +24,7 @@ mod tests {
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
use url::Url;
|
||||
|
||||
use artcraft_router::api::provider::Provider;
|
||||
use crate::http_server::endpoints::omni_gen::generate::video::distill_video_request::{
|
||||
distill_video_request, DistilledVideoRequest,
|
||||
};
|
||||
@@ -68,7 +69,7 @@ mod tests {
|
||||
|
||||
fn distill_text(request: &OmniGenVideoCostAndGenerateRequest) -> DistilledVideoRequest {
|
||||
let empty: HashMap<MediaFileToken, Url> = HashMap::new();
|
||||
distill_video_request(request, Some(&empty))
|
||||
distill_video_request(request, Some(&empty), Provider::Fal)
|
||||
.expect("distill should succeed for Veo 3 (text)")
|
||||
}
|
||||
|
||||
@@ -76,7 +77,7 @@ mod tests {
|
||||
request: &OmniGenVideoCostAndGenerateRequest,
|
||||
hydration: &HashMap<MediaFileToken, Url>,
|
||||
) -> DistilledVideoRequest {
|
||||
distill_video_request(request, Some(hydration))
|
||||
distill_video_request(request, Some(hydration), Provider::Fal)
|
||||
.expect("distill should succeed for Veo 3 (image)")
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,14 @@
|
||||
/checkout* /index.html 200
|
||||
/checkout/* /index.html 200
|
||||
|
||||
/create-image /index.html 200
|
||||
/create-image/ /index.html 200
|
||||
/create-image/* /index.html 200
|
||||
|
||||
/create-video /index.html 200
|
||||
/create-video/ /index.html 200
|
||||
/create-video/* /index.html 200
|
||||
|
||||
/library /index.html 200
|
||||
/library/ /index.html 200
|
||||
/library/* /index.html 200
|
||||
|
||||
@@ -4,8 +4,8 @@ import Media from "../pages/media";
|
||||
import PressKit from "../pages/press-kit";
|
||||
import Navbar from "../components/navbar";
|
||||
import { ToastContainer } from "../components/toast/toast";
|
||||
// import CreateImage from "../pages/create-image";
|
||||
// import CreateVideo from "../pages/create-video";
|
||||
import CreateImage from "../pages/create-image";
|
||||
import CreateVideo from "../pages/create-video";
|
||||
import Landing2 from "../pages/landing2";
|
||||
import LandingSD2 from "../pages/landing-sd2";
|
||||
import TutorialsPage from "../pages/tutorials";
|
||||
@@ -30,8 +30,8 @@ export function App() {
|
||||
|
||||
<Routes>
|
||||
<Route path="/" element={<Landing2 />} />
|
||||
{/* <Route path="/create-image" element={<CreateImage />} />
|
||||
<Route path="/create-video" element={<CreateVideo />} /> */}
|
||||
<Route path="/create-image" element={<CreateImage />} />
|
||||
<Route path="/create-video" element={<CreateVideo />} />
|
||||
<Route path="/seedance-2" element={<LandingSD2 />} />
|
||||
<Route path="/download" element={<Download />} />
|
||||
<Route path="/media" element={<Media />} />
|
||||
|
||||
+1
-1
@@ -94,7 +94,7 @@ function jobToInProgress(job: Job): InProgressJob {
|
||||
(m) => m.tauriId === modelType || m.id === modelType,
|
||||
)
|
||||
: undefined;
|
||||
duration = model?.progressBarTime ?? (isVideo ? 120000 : 30000);
|
||||
duration = model?.progressBarTime ?? (isVideo ? 900000 : 30000);
|
||||
taskDurationCache.set(job.job_token, duration);
|
||||
}
|
||||
|
||||
|
||||
@@ -24,8 +24,8 @@ import { TaskQueue } from "./task-queue";
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ name: "Home", href: "/" },
|
||||
// { name: "Image", href: "/create-image" },
|
||||
// { name: "Video", href: "/create-video" },
|
||||
{ name: "Image", href: "/create-image" },
|
||||
{ name: "Video", href: "/create-video" },
|
||||
{ name: "Tutorials", href: "/tutorials" },
|
||||
{ name: "News", href: "/news" },
|
||||
{ name: "FAQ", href: "/faq" },
|
||||
|
||||
@@ -519,7 +519,7 @@ function jobsToInProgress(jobs: Job[]): InProgressTask[] {
|
||||
(m) => m.tauriId === modelType || m.id === modelType,
|
||||
)
|
||||
: undefined;
|
||||
duration = model?.progressBarTime ?? (isVideo ? 120000 : 30000);
|
||||
duration = model?.progressBarTime ?? (isVideo ? 900000 : 30000);
|
||||
taskDurationCache.set(j.job_token, duration);
|
||||
}
|
||||
|
||||
|
||||
@@ -396,7 +396,7 @@ export default function Library() {
|
||||
) : allItems.length === 0 && !loading ? (
|
||||
<div className="flex flex-col items-center justify-center py-20">
|
||||
<p className="text-white/40 text-sm mb-4">No items yet.</p>
|
||||
{/* <div className="flex gap-3">
|
||||
<div className="flex gap-3">
|
||||
<Link to="/create-image">
|
||||
<Button variant="primary" className="text-sm px-4 py-2">
|
||||
Create Image
|
||||
@@ -410,7 +410,7 @@ export default function Library() {
|
||||
Create Video
|
||||
</Button>
|
||||
</Link>
|
||||
</div> */}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
|
||||
@@ -3,6 +3,7 @@ import { UserInfo, ActiveSubscriptions } from "~/models";
|
||||
import { authentication, flushAllBackgroundLoadedMedia } from "~/signals";
|
||||
import { useCreditsState } from "@storyteller/credits";
|
||||
import { useSubscriptionState } from "@storyteller/subscription";
|
||||
import { clearGalleryCache } from "@storyteller/ui-gallery-modal";
|
||||
|
||||
export const updateAuthStatus = (newStatus: AUTH_STATUS) => {
|
||||
console.log('updateAuthStatus()', newStatus)
|
||||
@@ -40,4 +41,5 @@ export const setLogoutStates = () => {
|
||||
flushAllBackgroundLoadedMedia();
|
||||
useCreditsState.getState().reset();
|
||||
useSubscriptionState.getState().reset();
|
||||
clearGalleryCache();
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useRef } from "react";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
faCheck,
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
faTrashCan,
|
||||
faUpload,
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import { LoadingSpinner } from "@storyteller/ui-loading-spinner";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import galleryDnd from "./galleryDnd";
|
||||
import { Tooltip } from "@storyteller/ui-tooltip";
|
||||
@@ -57,6 +58,34 @@ export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
|
||||
const imgRef = useRef<HTMLImageElement>(null);
|
||||
const dragStarted = useRef(false);
|
||||
|
||||
// For freshly-completed videos the backend may still be generating the
|
||||
// preview GIF, so the thumbnail URL 404s for a while. Show a spinner and
|
||||
// refresh the image every 5s until it loads.
|
||||
const isVideo = item.mediaClass === "video";
|
||||
const [retryAttempt, setRetryAttempt] = useState(0);
|
||||
const [videoLoaded, setVideoLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setRetryAttempt(0);
|
||||
setVideoLoaded(false);
|
||||
}, [item.thumbnail]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVideo || videoLoaded || !item.thumbnail) return;
|
||||
const t = setInterval(() => setRetryAttempt((n) => n + 1), 5000);
|
||||
return () => clearInterval(t);
|
||||
}, [isVideo, videoLoaded, item.thumbnail]);
|
||||
|
||||
const imgSrc =
|
||||
isVideo && item.thumbnail && retryAttempt > 0
|
||||
? `${item.thumbnail}${item.thumbnail.includes("?") ? "&" : "?"}_r=${retryAttempt}`
|
||||
: item.thumbnail ?? undefined;
|
||||
|
||||
const handleImgError = (e: React.SyntheticEvent<HTMLImageElement>) => {
|
||||
if (isVideo) return;
|
||||
onImageError(e);
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
showActionReminder({
|
||||
reminderType: "default",
|
||||
@@ -174,22 +203,33 @@ export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
|
||||
<span className="text-white/60">Image not available</span>
|
||||
</div>
|
||||
) : (
|
||||
<img
|
||||
data-gallery-draggable-1="true"
|
||||
// NB: "loading=lazy" is necessary to prevent loading GIGABYTES of images!
|
||||
// It is a bit finnicky, too: you must include this attribute
|
||||
// BEFORE the `src` attribute, or it won't work.
|
||||
loading="lazy"
|
||||
ref={imgRef}
|
||||
src={item.thumbnail}
|
||||
alt={item.label}
|
||||
className={twMerge(
|
||||
"h-full w-full bg-black/30",
|
||||
imageFit === "contain" ? "object-contain" : "object-cover",
|
||||
<>
|
||||
<img
|
||||
data-gallery-draggable-1="true"
|
||||
// NB: "loading=lazy" is necessary to prevent loading GIGABYTES of images!
|
||||
// It is a bit finnicky, too: you must include this attribute
|
||||
// BEFORE the `src` attribute, or it won't work.
|
||||
loading="lazy"
|
||||
ref={imgRef}
|
||||
src={imgSrc}
|
||||
alt={item.label}
|
||||
className={twMerge(
|
||||
"h-full w-full bg-black/30",
|
||||
imageFit === "contain" ? "object-contain" : "object-cover",
|
||||
isVideo && !videoLoaded ? "opacity-0" : "",
|
||||
)}
|
||||
draggable={false}
|
||||
onLoad={() => {
|
||||
if (isVideo) setVideoLoaded(true);
|
||||
}}
|
||||
onError={handleImgError}
|
||||
/>
|
||||
{isVideo && !videoLoaded && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/30">
|
||||
<LoadingSpinner className="h-6 w-6" />
|
||||
</div>
|
||||
)}
|
||||
draggable={false}
|
||||
onError={onImageError}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{selected && (
|
||||
<div className="absolute left-2 top-2 flex h-6 w-6 items-center justify-center rounded-full bg-primary">
|
||||
|
||||
@@ -70,17 +70,19 @@ interface GalleryCacheEntry {
|
||||
}
|
||||
|
||||
const galleryCacheMap = new Map<string, GalleryCacheEntry>();
|
||||
let cachedUsername: string | null = null;
|
||||
const usersApiSingleton = new UsersApi();
|
||||
|
||||
/** Get cached username or fetch it once. */
|
||||
async function getCachedUsername(): Promise<string | null> {
|
||||
if (cachedUsername) return cachedUsername;
|
||||
/** Clear the module-level gallery item cache. Call on logout so the next user doesn't see stale items. */
|
||||
export const clearGalleryCache = () => {
|
||||
galleryCacheMap.clear();
|
||||
};
|
||||
|
||||
/** Fetch the current session's username fresh every time — the session may have changed (logout/login). */
|
||||
async function fetchCurrentUsername(): Promise<string | null> {
|
||||
try {
|
||||
const session = await usersApiSingleton.GetSession();
|
||||
if (session.success && session.data?.user) {
|
||||
cachedUsername = session.data.user.username;
|
||||
return cachedUsername;
|
||||
return session.data.user.username;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
@@ -481,24 +483,52 @@ export const GalleryModal = React.memo(
|
||||
failedImageUrls.current.add(url);
|
||||
}, []);
|
||||
|
||||
// Fetch & cache username — uses module-level cache so subsequent opens are instant
|
||||
// Ref to the latest refreshGallery so the async session-fetch effect can
|
||||
// call it without taking a dependency on it. refreshGallery itself is
|
||||
// defined lower in the file (it needs loadItems); we wire the ref via a
|
||||
// layout-time assignment below.
|
||||
const refreshGalleryRef = useRef<() => void>(() => {});
|
||||
|
||||
// Fetch the current session's username every time the modal opens.
|
||||
// The modal is permanently mounted in TopBar, so this component survives
|
||||
// logout → login. We re-verify who the backend considers the current user
|
||||
// on every open and, if it's a different user, wipe all stale state before
|
||||
// letting refreshGallery fire. This effect is the SOLE driver for refresh
|
||||
// on modal-open — the separate filter/username effect below intentionally
|
||||
// does NOT depend on modal-open signals, so it can't race this one.
|
||||
useEffect(() => {
|
||||
const getUsername = async () => {
|
||||
if (username) return; // already have it
|
||||
const modalIsOpen =
|
||||
isOpen || (mode === "view" && galleryModalVisibleViewMode.value);
|
||||
if (!modalIsOpen) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
setUsernameError(false);
|
||||
const name = await getCachedUsername();
|
||||
if (name) {
|
||||
setInitialLoading(true); // keep skeleton alive until refreshGallery takes over
|
||||
setUsername(name);
|
||||
// refreshGallery (triggered by username state change) handles initialLoading
|
||||
} else {
|
||||
const name = await fetchCurrentUsername();
|
||||
if (cancelled) return;
|
||||
if (!name) {
|
||||
setUsernameError(true);
|
||||
setInitialLoading(false);
|
||||
return;
|
||||
}
|
||||
if (name !== usernameRef.current) {
|
||||
// Different user than before — wipe everything from the previous
|
||||
// session. setUsername will trigger the filter/username effect
|
||||
// below, which calls refreshGallery with the new identity.
|
||||
galleryCacheMap.clear();
|
||||
setAllItems([]);
|
||||
setPageIndex(0);
|
||||
setHasMore(true);
|
||||
setInitialLoading(true);
|
||||
setUsername(name);
|
||||
} else {
|
||||
// Same user reopening — just refresh (shows cache + background refetch).
|
||||
refreshGalleryRef.current();
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
if (isOpen || (mode === "view" && galleryModalVisibleViewMode.value)) {
|
||||
getUsername();
|
||||
}
|
||||
}, [mode, galleryModalVisibleViewMode.value, isOpen, username, usernameRetryCount]);
|
||||
}, [mode, galleryModalVisibleViewMode.value, isOpen, usernameRetryCount]);
|
||||
|
||||
// Helper to build the cache key for the current filter
|
||||
const getCacheKey = useCallback(
|
||||
@@ -651,6 +681,7 @@ export const GalleryModal = React.memo(
|
||||
);
|
||||
|
||||
// refresh logic — shows cached items immediately, then background-refreshes
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const refreshGallery = useCallback(() => {
|
||||
setItemsLoadError(null);
|
||||
const cacheKey = `gallery_${activeFilterRef.current}`;
|
||||
@@ -674,8 +705,17 @@ export const GalleryModal = React.memo(
|
||||
}
|
||||
}, [loadItems]);
|
||||
|
||||
// Keep the ref pointed at the latest refreshGallery so the session-fetch
|
||||
// effect can call it without taking it as a dep (avoids tearing).
|
||||
refreshGalleryRef.current = refreshGallery;
|
||||
|
||||
// Refresh on username change (new user logged in via the session-fetch
|
||||
// effect) or on activeFilter change. This effect deliberately does NOT
|
||||
// depend on modal-open signals — the session-fetch effect above is the
|
||||
// sole driver for modal-open refreshes, which prevents a race where this
|
||||
// effect would otherwise fire synchronously with a stale `username` and
|
||||
// take the loading lock before the new session resolves.
|
||||
useEffect(() => {
|
||||
// Refresh every time the modal is opened
|
||||
const modalIsOpen =
|
||||
mode === "view"
|
||||
? galleryModalVisibleViewMode.value
|
||||
@@ -686,13 +726,7 @@ export const GalleryModal = React.memo(
|
||||
refreshGallery();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
mode,
|
||||
isOpen,
|
||||
galleryModalVisibleViewMode.value,
|
||||
username,
|
||||
activeFilter,
|
||||
]);
|
||||
}, [username, activeFilter]);
|
||||
|
||||
// Auto-refresh when a generation completes while the gallery is open
|
||||
const modalIsOpenRef = useRef(false);
|
||||
|
||||
@@ -348,15 +348,26 @@ export const MentionTextarea = forwardRef<HTMLDivElement, MentionTextareaProps>(
|
||||
}, [mentionItems, mentionState.isOpen, mentionState.query]);
|
||||
|
||||
// Build a regex that matches any known mention label (supports spaces in names)
|
||||
// Sort longest-first so "@Pumpkin Head" matches before "@Pumpkin"
|
||||
// Sort longest-first so "@Pumpkin Head" matches before "@Pumpkin".
|
||||
// Case-insensitive so users can type "@image1" and still get it tagged
|
||||
// when the canonical label is "@Image1".
|
||||
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");
|
||||
return new RegExp(`(${pattern})`, "gi");
|
||||
}, [mentionItems]);
|
||||
|
||||
// Case-insensitive lookup table for colorMap so "@image1" finds "@Image1".
|
||||
const lowerColorMap = useMemo(() => {
|
||||
const m: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(colorMap)) {
|
||||
m[k.toLowerCase()] = v;
|
||||
}
|
||||
return m;
|
||||
}, [colorMap]);
|
||||
|
||||
// Build innerHTML with colored @mentions inline
|
||||
const buildHTML = useCallback(
|
||||
(text: string): string => {
|
||||
@@ -376,7 +387,7 @@ export const MentionTextarea = forwardRef<HTMLDivElement, MentionTextareaProps>(
|
||||
// biome-ignore lint/suspicious/noAssignInExpressions: --
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
const fullMatch = match[0];
|
||||
const color = colorMap[fullMatch];
|
||||
const color = lowerColorMap[fullMatch.toLowerCase()];
|
||||
|
||||
if (match.index > lastIndex) {
|
||||
html += escapeHTML(text.slice(lastIndex, match.index));
|
||||
@@ -402,7 +413,7 @@ export const MentionTextarea = forwardRef<HTMLDivElement, MentionTextareaProps>(
|
||||
|
||||
return html;
|
||||
},
|
||||
[colorMap, mentionRegex],
|
||||
[lowerColorMap, mentionRegex],
|
||||
);
|
||||
|
||||
// Sync DOM when value changes from parent (not from user input)
|
||||
@@ -464,13 +475,20 @@ export const MentionTextarea = forwardRef<HTMLDivElement, MentionTextareaProps>(
|
||||
}, []);
|
||||
|
||||
// Detect @mention trigger from cursor position
|
||||
// Supports multi-word names by scanning back to the nearest @
|
||||
// Supports multi-word names by scanning back to the nearest @.
|
||||
// A valid trigger requires the char before @ to not be an ASCII
|
||||
// identifier char (letter/digit/underscore) — this lets CJK characters,
|
||||
// quotes, and punctuation all count as word boundaries, so users can
|
||||
// type "@Image1" directly after Chinese text like 从@Image1.
|
||||
const detectMention = useCallback(
|
||||
(text: string, cursorPos: number) => {
|
||||
// Find the last @ before cursor
|
||||
const textBefore = text.slice(0, cursorPos);
|
||||
const lastAt = textBefore.lastIndexOf("@");
|
||||
if (lastAt !== -1 && (lastAt === 0 || /\s/.test(text[lastAt - 1]))) {
|
||||
if (
|
||||
lastAt !== -1 &&
|
||||
(lastAt === 0 || !/[A-Za-z0-9_]/.test(text[lastAt - 1]))
|
||||
) {
|
||||
const query = text.slice(lastAt, cursorPos); // includes @
|
||||
// Only open if there's no newline in the query
|
||||
if (!query.includes("\n")) {
|
||||
|
||||
@@ -10,22 +10,60 @@ declare -r chrome_dir="/tmp/chrome/${entropy}"
|
||||
# https://github.com/GoogleChrome/chrome-launcher/blob/main/docs/chrome-flags-for-tools.md
|
||||
# https://peter.sh/experiments/chromium-command-line-switches/
|
||||
|
||||
# NB: Creating the directory first seems to block process start.
|
||||
# Perhaps this is the wrong chmod ownership flags.
|
||||
#mkdir -p $CHROME_DIR
|
||||
# Seed the profile with DevTools preferences before Chrome starts.
|
||||
# This sets: dock position = bottom, default panel = network.
|
||||
# Chrome stores these in <user-data-dir>/Default/Preferences.
|
||||
seed_devtools_preferences() {
|
||||
local prefs='{"devtools":{"preferences":{"currentDockState":"\"bottom\"","panel-selected-tab":"\"network\""}}}'
|
||||
|
||||
#--auto-open-devtools-for-tabs="true" \
|
||||
#--devtools-flags="" \
|
||||
# Seed both Default and Guest Profile — Chrome uses Guest Profile when
|
||||
# launched with --guest, and Default otherwise.
|
||||
for profile_name in "Default" "Guest Profile"; do
|
||||
local prefs_dir="${chrome_dir}/${profile_name}"
|
||||
mkdir -p "${prefs_dir}"
|
||||
echo "${prefs}" > "${prefs_dir}/Preferences"
|
||||
done
|
||||
}
|
||||
|
||||
# TODO(bt): Math to determine better window placement.
|
||||
|
||||
# Flags reference:
|
||||
# --auto-open-devtools-for-tabs Open DevTools automatically for every tab on launch.
|
||||
# Dock position and default panel are set via seeded
|
||||
# preferences (see seed_devtools_preferences above).
|
||||
# --allow-running-insecure-content Allow HTTPS pages to load HTTP resources (mixed content).
|
||||
# --disable-features=ChromeLabs, Disable Chrome Labs experiments UI and the
|
||||
# DefaultBrowserPrompt feature-flag-based default browser prompt.
|
||||
# --disable-site-isolation-trials Disable site isolation (allows cross-origin iframes
|
||||
# to share process, useful for local dev).
|
||||
# --disable-web-security Disable same-origin policy. Required for local dev
|
||||
# where the frontend talks to a different-origin API.
|
||||
# --ignore-certificate-errors Accept self-signed / invalid TLS certs.
|
||||
# --no-default-browser-check Skip the "set as default browser" check on startup.
|
||||
# --no-first-run Suppress the "Welcome to Google Chrome" first-run
|
||||
# dialog (default browser checkbox, crash reporting, etc.).
|
||||
# --install-autogenerated-theme Set a custom window color so the dev browser is
|
||||
# visually distinct from your normal Chrome.
|
||||
# --unsafely-treat-insecure-origin-as-secure
|
||||
# Treat the given HTTP origin as secure. Required for
|
||||
# APIs like getUserMedia() / navigator.mediaDevices
|
||||
# which need a "secure context".
|
||||
# --user-data-dir Use a throwaway profile directory so dev sessions
|
||||
# don't pollute your real Chrome profile.
|
||||
# --guest (Mac only) Launch in guest mode — no Google login screen.
|
||||
# --window-position / --window-size Place the dev window predictably on screen.
|
||||
|
||||
launch_linux() {
|
||||
seed_devtools_preferences
|
||||
chromium \
|
||||
--auto-open-devtools-for-tabs \
|
||||
--allow-running-insecure-content \
|
||||
--disable-features=ChromeLabs \
|
||||
--disable-features=ChromeLabs,DefaultBrowserPrompt \
|
||||
--disable-site-isolation-trials \
|
||||
--disable-web-security \
|
||||
--ignore-certificate-errors \
|
||||
--no-default-browser-check \
|
||||
--no-first-run \
|
||||
--install-autogenerated-theme="100,150,255" \
|
||||
--unsafely-treat-insecure-origin-as-secure="http://localhost:5173" \
|
||||
--user-data-dir="${chrome_dir}" \
|
||||
@@ -35,16 +73,15 @@ launch_linux() {
|
||||
}
|
||||
|
||||
launch_mac() {
|
||||
# See https://stackoverflow.com/a/58658101
|
||||
# NB: '7001' port is being chosen because something on the system is hogging port 7000.
|
||||
# chrome://flags/#unsafely-treat-insecure-origin-as-secure - allow microphone recording
|
||||
# otherwise getUserMedia() / navigator.mediaDevices will not function
|
||||
# --guest: no Google login screen
|
||||
seed_devtools_preferences
|
||||
open -na "Google Chrome" --args \
|
||||
--disable-features=ChromeLabs \
|
||||
--auto-open-devtools-for-tabs \
|
||||
--disable-features=ChromeLabs,DefaultBrowserPrompt \
|
||||
--disable-site-isolation-trials \
|
||||
--disable-web-security \
|
||||
--ignore-certificate-errors \
|
||||
--no-default-browser-check \
|
||||
--no-first-run \
|
||||
--install-autogenerated-theme="100,150,255" \
|
||||
--unsafely-treat-insecure-origin-as-secure="http://localhost:5173" \
|
||||
--user-data-dir="${chrome_dir}" \
|
||||
|
||||
Reference in New Issue
Block a user