mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-03 21:10:58 -05:00
Compare commits
52
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
318a285b60 | ||
|
|
bccdd8df5b | ||
|
|
5ec73e362d | ||
|
|
89ac302392 | ||
|
|
554a76d35f | ||
|
|
1d091e2d75 | ||
|
|
e06abd36bd | ||
|
|
abf2e1d6c2 | ||
|
|
4c521ab55c | ||
|
|
c352edd38c | ||
|
|
2b5e509a29 | ||
|
|
28db87c1b2 | ||
|
|
292cf33f57 | ||
|
|
58b7cd0cd4 | ||
|
|
101b735c35 | ||
|
|
32291ce080 | ||
|
|
cd9c06d2ce | ||
|
|
a780779f7a | ||
|
|
076b7fe4a4 | ||
|
|
7eef728d22 | ||
|
|
d09c7320fd | ||
|
|
0ca91e29b7 | ||
|
|
8af4657393 | ||
|
|
f8ee94e538 | ||
|
|
01e0e27037 | ||
|
|
794f65b517 | ||
|
|
c53b407952 | ||
|
|
e0611e0147 | ||
|
|
8a03449128 | ||
|
|
e4af73c9ac | ||
|
|
efa9a54bee | ||
|
|
718b8261fd | ||
|
|
61c4936c29 | ||
|
|
c23e652499 | ||
|
|
a26ebce483 | ||
|
|
1d3da065a2 | ||
|
|
b4a3eb88bf | ||
|
|
48ab5f73ee | ||
|
|
76add858c9 | ||
|
|
e39e67195e | ||
|
|
ea49ddf60a | ||
|
|
e0c04efa8f | ||
|
|
dc140c0bc1 | ||
|
|
21d3617970 | ||
|
|
11a0ac82b6 | ||
|
|
4b0618e50b | ||
|
|
35aae54d49 | ||
|
|
088edd270f | ||
|
|
61311028e2 | ||
|
|
6d22d0d3c3 | ||
|
|
50e68b0721 | ||
|
|
1351a40b3d |
+22
-33
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nSELECT\n jobs.token as `job_token: tokens::tokens::generic_inference_jobs::InferenceJobToken`,\n\n jobs.status as `status: enums::common::job_status_plus::JobStatusPlus`,\n jobs.attempt_count,\n\n jobs.maybe_creator_user_token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,\n jobs.maybe_creator_anonymous_visitor_token as `maybe_creator_anonymous_visitor_token: tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken`,\n jobs.creator_ip_address,\n\n jobs.product_category as `product_category: enums::by_table::generic_inference_jobs::inference_job_product_category::InferenceJobProductCategory`,\n jobs.inference_category as `inference_category: enums::by_table::generic_inference_jobs::inference_category::InferenceCategory`,\n jobs.maybe_model_type,\n jobs.maybe_model_token,\n jobs.maybe_raw_inference_text,\n jobs.maybe_inference_args,\n jobs.maybe_prompt_token as `maybe_prompt_token: tokens::tokens::prompts::PromptToken`,\n\n jobs.frontend_failure_category as `maybe_frontend_failure_category: enums::by_table::generic_inference_jobs::frontend_failure_category::FrontendFailureCategory`,\n jobs.failure_reason,\n\n jobs.on_success_result_entity_type as maybe_result_entity_type,\n jobs.on_success_result_entity_token as maybe_result_entity_token,\n jobs.on_success_result_batch_token as maybe_result_batch_token,\n\n model_weights.title as maybe_model_weights_title,\n tts_models.title as maybe_tts_model_title,\n voice_conversion_models.title as maybe_voice_conversion_model_title,\n\n tts_results.public_bucket_wav_audio_path as maybe_tts_public_bucket_path,\n voice_conversion_results.public_bucket_hash as maybe_voice_conversion_public_bucket_hash,\n\n media_files.public_bucket_directory_hash as maybe_media_file_public_bucket_directory_hash,\n media_files.maybe_public_bucket_prefix as maybe_media_file_public_bucket_prefix,\n media_files.maybe_public_bucket_extension as maybe_media_file_public_bucket_extension,\n\n jobs.assigned_worker as maybe_assigned_worker,\n jobs.assigned_cluster as maybe_assigned_cluster,\n\n jobs.is_keepalive_required,\n\n jobs.created_at,\n jobs.updated_at,\n\n jobs.first_started_at as maybe_first_started_at,\n jobs.successfully_completed_at as maybe_successfully_completed_at,\n\n NOW() as database_clock\n\nFROM generic_inference_jobs as jobs\n\nLEFT OUTER JOIN model_weights ON jobs.maybe_model_token = model_weights.token\nLEFT OUTER JOIN tts_models ON jobs.maybe_model_token = tts_models.token\nLEFT OUTER JOIN voice_conversion_models ON jobs.maybe_model_token = voice_conversion_models.token\n\nLEFT OUTER JOIN tts_results ON jobs.on_success_result_entity_token = tts_results.token\nLEFT OUTER JOIN voice_conversion_results ON jobs.on_success_result_entity_token = voice_conversion_results.token\nLEFT OUTER JOIN media_files ON jobs.on_success_result_entity_token = media_files.token\n\nWHERE jobs.token = ?\n ",
|
||||
"query": "\nSELECT\n jobs.token as `job_token: tokens::tokens::generic_inference_jobs::InferenceJobToken`,\n\n jobs.status as `status: enums::common::job_status_plus::JobStatusPlus`,\n jobs.attempt_count,\n\n jobs.maybe_creator_user_token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,\n jobs.maybe_creator_anonymous_visitor_token as `maybe_creator_anonymous_visitor_token: tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken`,\n jobs.creator_ip_address,\n\n jobs.product_category as `product_category: enums::by_table::generic_inference_jobs::inference_job_product_category::InferenceJobProductCategory`,\n jobs.inference_category as `inference_category: enums::by_table::generic_inference_jobs::inference_category::InferenceCategory`,\n jobs.maybe_model_type,\n jobs.maybe_model_token,\n jobs.maybe_raw_inference_text,\n jobs.maybe_inference_args,\n\n jobs.frontend_failure_category as `maybe_frontend_failure_category: enums::by_table::generic_inference_jobs::frontend_failure_category::FrontendFailureCategory`,\n jobs.failure_reason,\n\n jobs.on_success_result_entity_type as maybe_result_entity_type,\n jobs.on_success_result_entity_token as maybe_result_entity_token,\n jobs.on_success_result_batch_token as maybe_result_batch_token,\n\n model_weights.title as maybe_model_weights_title,\n tts_models.title as maybe_tts_model_title,\n voice_conversion_models.title as maybe_voice_conversion_model_title,\n\n tts_results.public_bucket_wav_audio_path as maybe_tts_public_bucket_path,\n voice_conversion_results.public_bucket_hash as maybe_voice_conversion_public_bucket_hash,\n\n media_files.public_bucket_directory_hash as maybe_media_file_public_bucket_directory_hash,\n media_files.maybe_public_bucket_prefix as maybe_media_file_public_bucket_prefix,\n media_files.maybe_public_bucket_extension as maybe_media_file_public_bucket_extension,\n\n jobs.assigned_worker as maybe_assigned_worker,\n jobs.assigned_cluster as maybe_assigned_cluster,\n\n jobs.is_keepalive_required,\n\n jobs.created_at,\n jobs.updated_at,\n\n jobs.first_started_at as maybe_first_started_at,\n jobs.successfully_completed_at as maybe_successfully_completed_at,\n\n NOW() as database_clock\n\nFROM generic_inference_jobs as jobs\n\nLEFT OUTER JOIN model_weights ON jobs.maybe_model_token = model_weights.token\nLEFT OUTER JOIN tts_models ON jobs.maybe_model_token = tts_models.token\nLEFT OUTER JOIN voice_conversion_models ON jobs.maybe_model_token = voice_conversion_models.token\n\nLEFT OUTER JOIN tts_results ON jobs.on_success_result_entity_token = tts_results.token\nLEFT OUTER JOIN voice_conversion_results ON jobs.on_success_result_entity_token = voice_conversion_results.token\nLEFT OUTER JOIN media_files ON jobs.on_success_result_entity_token = media_files.token\n\nWHERE jobs.token = ?\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -125,16 +125,6 @@
|
||||
},
|
||||
{
|
||||
"ordinal": 12,
|
||||
"name": "maybe_prompt_token: tokens::tokens::prompts::PromptToken",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 13,
|
||||
"name": "maybe_frontend_failure_category: enums::by_table::generic_inference_jobs::frontend_failure_category::FrontendFailureCategory",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
@@ -144,7 +134,7 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 14,
|
||||
"ordinal": 13,
|
||||
"name": "failure_reason",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
@@ -154,7 +144,7 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 15,
|
||||
"ordinal": 14,
|
||||
"name": "maybe_result_entity_type",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
@@ -164,7 +154,7 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 16,
|
||||
"ordinal": 15,
|
||||
"name": "maybe_result_entity_token",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
@@ -174,7 +164,7 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 17,
|
||||
"ordinal": 16,
|
||||
"name": "maybe_result_batch_token",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
@@ -184,7 +174,7 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 18,
|
||||
"ordinal": 17,
|
||||
"name": "maybe_model_weights_title",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
@@ -194,7 +184,7 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 19,
|
||||
"ordinal": 18,
|
||||
"name": "maybe_tts_model_title",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
@@ -204,7 +194,7 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 20,
|
||||
"ordinal": 19,
|
||||
"name": "maybe_voice_conversion_model_title",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
@@ -214,7 +204,7 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 21,
|
||||
"ordinal": 20,
|
||||
"name": "maybe_tts_public_bucket_path",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
@@ -224,7 +214,7 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 22,
|
||||
"ordinal": 21,
|
||||
"name": "maybe_voice_conversion_public_bucket_hash",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
@@ -234,7 +224,7 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 23,
|
||||
"ordinal": 22,
|
||||
"name": "maybe_media_file_public_bucket_directory_hash",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
@@ -244,7 +234,7 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 24,
|
||||
"ordinal": 23,
|
||||
"name": "maybe_media_file_public_bucket_prefix",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
@@ -254,7 +244,7 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 25,
|
||||
"ordinal": 24,
|
||||
"name": "maybe_media_file_public_bucket_extension",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
@@ -264,7 +254,7 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 26,
|
||||
"ordinal": 25,
|
||||
"name": "maybe_assigned_worker",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
@@ -274,7 +264,7 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 27,
|
||||
"ordinal": 26,
|
||||
"name": "maybe_assigned_cluster",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
@@ -284,7 +274,7 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 28,
|
||||
"ordinal": 27,
|
||||
"name": "is_keepalive_required",
|
||||
"type_info": {
|
||||
"type": "Tiny",
|
||||
@@ -294,7 +284,7 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 29,
|
||||
"ordinal": 28,
|
||||
"name": "created_at",
|
||||
"type_info": {
|
||||
"type": "Timestamp",
|
||||
@@ -304,7 +294,7 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 30,
|
||||
"ordinal": 29,
|
||||
"name": "updated_at",
|
||||
"type_info": {
|
||||
"type": "Timestamp",
|
||||
@@ -314,7 +304,7 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 31,
|
||||
"ordinal": 30,
|
||||
"name": "maybe_first_started_at",
|
||||
"type_info": {
|
||||
"type": "Timestamp",
|
||||
@@ -324,7 +314,7 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 32,
|
||||
"ordinal": 31,
|
||||
"name": "maybe_successfully_completed_at",
|
||||
"type_info": {
|
||||
"type": "Timestamp",
|
||||
@@ -334,7 +324,7 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 33,
|
||||
"ordinal": 32,
|
||||
"name": "database_clock",
|
||||
"type_info": {
|
||||
"type": "Datetime",
|
||||
@@ -375,7 +365,6 @@
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
@@ -384,5 +373,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "6948edca3f06643605695665df51cc94f897e2cb9a2d6906910d58d4d56ee73e"
|
||||
"hash": "a2c9e2f61eba666254f8432b3c50a0a7738ee3b3faa77c5281bc8e4af96159b9"
|
||||
}
|
||||
Generated
-2
@@ -11469,14 +11469,12 @@ name = "support-tool"
|
||||
version = "0.0.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"artcraft_client",
|
||||
"clap 4.5.60",
|
||||
"easyenv",
|
||||
"errors",
|
||||
"log",
|
||||
"seedance2pro_client",
|
||||
"serde_json",
|
||||
"tokens",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
ALTER TABLE prompts
|
||||
DROP COLUMN maybe_quality;
|
||||
@@ -1,8 +0,0 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
-- Add quality parameter to prompts table.
|
||||
-- Represented by the enum `CommonQuality` (high, medium, low).
|
||||
ALTER TABLE prompts
|
||||
ADD COLUMN maybe_quality VARCHAR(16) DEFAULT NULL;
|
||||
@@ -109,7 +109,7 @@ CREATE TABLE generic_inference_jobs (
|
||||
-- If we pre-constructed a prompt, this will be copied to the resultant media generated.
|
||||
-- This is especially important for Fal-related jobs and endpoints. We need this for keeping
|
||||
-- track of all the prompt details, context images, etc.
|
||||
maybe_prompt_token VARCHAR(32) DEFAULT NULL,
|
||||
maybe_prompt_token VARCHAR(30) DEFAULT NULL,
|
||||
|
||||
-- A migration flag we can control from the backend API service which will tell
|
||||
-- the worker where to store the results. If false, GCP. If true, AWS.
|
||||
|
||||
@@ -68,10 +68,6 @@ CREATE TABLE prompts (
|
||||
-- The resolution for the prompt (eg. four_k, 720p, etc.)
|
||||
maybe_resolution VARCHAR(16) DEFAULT NULL,
|
||||
|
||||
-- Represented by the enum `CommonQuality`.
|
||||
-- The quality setting (eg. high, medium, low)
|
||||
maybe_quality VARCHAR(16) DEFAULT NULL,
|
||||
|
||||
-- The duration in seconds
|
||||
maybe_duration_seconds INT UNSIGNED DEFAULT NULL,
|
||||
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
$kind: http-request
|
||||
name: Batch Get Prompt
|
||||
url: "{{api_host}}/v1/prompts/prompt_8q8fwcmh93nn8pjb2szeqbxgz"
|
||||
method: GET
|
||||
headers:
|
||||
- key: Accept
|
||||
value: application/json
|
||||
- key: Session
|
||||
value: ""
|
||||
disabled: true
|
||||
order: 2171327660164107
|
||||
@@ -1,17 +1,16 @@
|
||||
use crate::common::responses::job_details::{JobDetailsLipsyncRequest, JobDetailsLivePortraitRequest};
|
||||
use crate::common::responses::media_links::MediaLinks;
|
||||
use chrono::{DateTime, Utc};
|
||||
use enums::api_safe::by_table::generic_inference_jobs::frontend_failure_category_for_api_clients::FrontendFailureCategoryForApiClients;
|
||||
use enums::api_safe::by_table::generic_inference_jobs::frontend_failure_category_for_old_clients::FrontendFailureCategoryForOldClients;
|
||||
use enums::by_table::generic_inference_jobs::inference_category::InferenceCategory;
|
||||
use enums::common::job_status_plus::JobStatusPlus;
|
||||
use enums::no_table::style_transfer::style_transfer_name::StyleTransferName;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use tokens::tokens::batch_generations::BatchGenerationToken;
|
||||
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
|
||||
use tokens::tokens::prompts::PromptToken;
|
||||
use utoipa::{IntoParams, ToSchema};
|
||||
use enums::api_safe::by_table::generic_inference_jobs::frontend_failure_category_for_api_clients::FrontendFailureCategoryForApiClients;
|
||||
use enums::api_safe::by_table::generic_inference_jobs::frontend_failure_category_for_old_clients::FrontendFailureCategoryForOldClients;
|
||||
use tokens::tokens::batch_generations::BatchGenerationToken;
|
||||
|
||||
pub const LIST_SESSION_JOBS_URL_PATH: &str = "/v1/jobs/session";
|
||||
|
||||
@@ -54,9 +53,6 @@ pub struct ListSessionJobsItem {
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct ListSessionRequestDetailsResponse {
|
||||
pub inference_category: InferenceCategory,
|
||||
|
||||
pub maybe_prompt_token: Option<PromptToken>,
|
||||
|
||||
pub maybe_model_type: Option<String>,
|
||||
pub maybe_model_token: Option<String>,
|
||||
|
||||
|
||||
-7
@@ -6,12 +6,5 @@ use utoipa::ToSchema;
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct OmniGenVideoGenerateResponse {
|
||||
pub success: bool,
|
||||
|
||||
pub inference_job_token: InferenceJobToken,
|
||||
|
||||
/// All job tokens created by this request (including the primary).
|
||||
/// For single-job requests this will contain just one element matching
|
||||
/// `inference_job_token`. For batch requests (eg. Seedance2Pro with
|
||||
/// multiple order IDs) this will contain all of them.
|
||||
pub all_job_tokens: Vec<InferenceJobToken>,
|
||||
}
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
use crate::prompts::get_prompt::GetPromptImageContextItem;
|
||||
use chrono::{DateTime, Utc};
|
||||
use enums::by_table::prompts::prompt_type::PromptType;
|
||||
use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
|
||||
use enums::common::generation::common_generation_mode::CommonGenerationMode;
|
||||
use enums::common::generation::common_model_class::CommonModelClass;
|
||||
use enums::common::generation::common_model_type::CommonModelType;
|
||||
use enums::common::generation::common_resolution::CommonResolution;
|
||||
use enums::common::generation_provider::GenerationProvider;
|
||||
use std::collections::HashSet;
|
||||
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use tokens::tokens::prompts::PromptToken;
|
||||
use utoipa::{IntoParams, ToSchema};
|
||||
|
||||
pub const BATCH_GET_PROMPTS_PATH: &str = "/v1/prompt/batch";
|
||||
|
||||
#[derive(Deserialize, ToSchema, IntoParams)]
|
||||
pub struct BatchGetPromptsQuery {
|
||||
/// Prompt tokens to look up.
|
||||
pub tokens: HashSet<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct BatchGetPromptsResponse {
|
||||
pub success: bool,
|
||||
pub prompts: Vec<BatchPromptInfo>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct BatchPromptInfo {
|
||||
pub token: PromptToken,
|
||||
|
||||
/// The type of model used
|
||||
pub maybe_model_type: Option<CommonModelType>,
|
||||
|
||||
/// The class of model used
|
||||
pub maybe_model_class: Option<CommonModelClass>,
|
||||
|
||||
/// The service provider used
|
||||
pub maybe_generation_provider: Option<GenerationProvider>,
|
||||
|
||||
/// Positive prompt (technically optional, but usually present)
|
||||
pub maybe_positive_prompt: Option<String>,
|
||||
|
||||
/// Negative prompt (optional)
|
||||
pub maybe_negative_prompt: Option<String>,
|
||||
|
||||
/// OPTIONAL. The generation mode (eg. keyframe, reference, inpaint, etc.)
|
||||
pub maybe_generation_mode: Option<CommonGenerationMode>,
|
||||
|
||||
/// OPTIONAL. The aspect ratio (eg. square, auto, wide_three_by_two, etc.)
|
||||
pub maybe_aspect_ratio: Option<CommonAspectRatio>,
|
||||
|
||||
/// OPTIONAL. The resolution (eg. one_k, two_k, four_k, etc.)
|
||||
pub maybe_resolution: Option<CommonResolution>,
|
||||
|
||||
/// OPTIONAL. The number of outputs to generate (0-255).
|
||||
pub maybe_batch_count: Option<u8>,
|
||||
|
||||
/// OPTIONAL. Whether to generate audio.
|
||||
pub maybe_generate_audio: Option<bool>,
|
||||
|
||||
/// OPTIONAL. The duration in seconds.
|
||||
pub maybe_duration_seconds: Option<u32>,
|
||||
|
||||
/// Context images (optional)
|
||||
pub maybe_context_images: Option<Vec<GetPromptImageContextItem>>,
|
||||
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
@@ -27,6 +27,13 @@ pub struct GetPromptSuccessResponse {
|
||||
pub prompt: PromptInfo,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct GetPromptImageContextItem {
|
||||
pub media_token: MediaFileToken,
|
||||
pub semantic: PromptContextSemanticType,
|
||||
pub media_links: MediaLinks,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct PromptInfo {
|
||||
pub token: PromptToken,
|
||||
@@ -123,13 +130,6 @@ pub struct PromptInfo {
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct GetPromptImageContextItem {
|
||||
pub media_token: MediaFileToken,
|
||||
pub semantic: PromptContextSemanticType,
|
||||
pub media_links: MediaLinks,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct PromptInfoModeratorFields {
|
||||
/// How many milliseconds it took to run generation.
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
pub mod batch_get_prompts;
|
||||
pub mod create_prompt;
|
||||
pub mod get_prompt;
|
||||
@@ -1,6 +1,5 @@
|
||||
use cloudflare_errors::cloudflare_error::CloudflareError;
|
||||
use errors::AnyhowError;
|
||||
use reqwest::StatusCode;
|
||||
use std::error::Error;
|
||||
use std::fmt::{Display, Formatter};
|
||||
use std::io;
|
||||
@@ -31,12 +30,6 @@ pub enum ApiError {
|
||||
backend_hostname: Option<String>,
|
||||
},
|
||||
|
||||
/// An uncategorized bad HTTP response with status code and body.
|
||||
UncategorizedBadResponseWithStatusAndBody {
|
||||
status_code: StatusCode,
|
||||
body: String,
|
||||
},
|
||||
|
||||
/// Cloudflare errors.
|
||||
CloudflareError(CloudflareError),
|
||||
|
||||
@@ -73,8 +66,6 @@ impl Display for ApiError {
|
||||
ApiError::TooManyRequests(msg) => write!(f, "Too many requests to Storyteller backend: {}", msg),
|
||||
ApiError::InternalServerError {body, backend_hostname} =>
|
||||
write!(f, "Internal Server Error; backend hostname: {:?} ; body: {}; ", backend_hostname, body),
|
||||
ApiError::UncategorizedBadResponseWithStatusAndBody { status_code, body } =>
|
||||
write!(f, "Uncategorized bad response with status code {} and body: {}", status_code, body),
|
||||
// Server response handling errors
|
||||
ApiError::DeserializationError(error) => write!(f, "Deserialization error: {}", error),
|
||||
// Network errors
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use log::{info, warn};
|
||||
use reqwest::Client;
|
||||
use url::Url;
|
||||
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
use crate::endpoints::media_files::get_media_file::{get_media_file, GetMediaFileSuccessResponse};
|
||||
use crate::error::api_error::ApiError;
|
||||
use crate::error::client_error::ClientError;
|
||||
use crate::error::storyteller_error::StorytellerError;
|
||||
use crate::utils::api_host::ApiHost;
|
||||
|
||||
pub struct DownloadMediaFileArgs<'a, P: AsRef<Path>> {
|
||||
pub media_token: &'a MediaFileToken,
|
||||
pub api_host: &'a ApiHost,
|
||||
pub download_path: DownloadPath<P>,
|
||||
}
|
||||
|
||||
pub enum DownloadPath<P: AsRef<Path>> {
|
||||
/// Write the file to this exact path.
|
||||
ExactFilename(P),
|
||||
/// Write the file into this directory, generating a filename from the CDN URL extension.
|
||||
Directory(P),
|
||||
}
|
||||
|
||||
pub struct DownloadMediaFileResult {
|
||||
/// The path the file was written to.
|
||||
pub downloaded_file_path: PathBuf,
|
||||
|
||||
/// The full media file response from the API.
|
||||
pub media_file_response: GetMediaFileSuccessResponse,
|
||||
|
||||
/// Size of downloaded file in bytes.
|
||||
pub filesize_bytes: usize,
|
||||
}
|
||||
|
||||
pub async fn download_media_file<P: AsRef<Path>>(
|
||||
args: DownloadMediaFileArgs<'_, P>,
|
||||
) -> Result<DownloadMediaFileResult, StorytellerError> {
|
||||
let DownloadMediaFileArgs {
|
||||
media_token,
|
||||
api_host,
|
||||
download_path
|
||||
} = args;
|
||||
|
||||
// 1. Fetch media file info from the API.
|
||||
let response = get_media_file(api_host, media_token).await?;
|
||||
let media_class = &response.media_file.media_class;
|
||||
let cdn_url = &response.media_file.media_links.cdn_url;
|
||||
|
||||
info!("Downloading media file {} of class {} from CDN: {}",
|
||||
media_token.as_str(), media_class.to_str(), cdn_url);
|
||||
|
||||
// 2. Determine the output file path.
|
||||
let output_path = match &download_path {
|
||||
DownloadPath::ExactFilename(path) => path.as_ref().to_path_buf(),
|
||||
DownloadPath::Directory(dir) => {
|
||||
let filename = derive_filename_from_url(cdn_url, &media_token);
|
||||
dir.as_ref().join(filename)
|
||||
}
|
||||
};
|
||||
|
||||
// 3. Download the bytes from the CDN.
|
||||
let bytes = download_bytes(cdn_url).await?;
|
||||
|
||||
// 4. Write to disk.
|
||||
let mut file = fs::File::create(&output_path)
|
||||
.map_err(|err| StorytellerError::Client(ClientError::IoError(err)))?;
|
||||
|
||||
file.write_all(&bytes)
|
||||
.map_err(|err| StorytellerError::Client(ClientError::IoError(err)))?;
|
||||
|
||||
file.flush()
|
||||
.map_err(|err| StorytellerError::Client(ClientError::IoError(err)))?;
|
||||
|
||||
info!("Downloaded {} bytes to {:?}", bytes.len(), output_path);
|
||||
|
||||
Ok(DownloadMediaFileResult {
|
||||
downloaded_file_path: output_path,
|
||||
media_file_response: response,
|
||||
filesize_bytes: bytes.len(),
|
||||
})
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
async fn download_bytes(url: &Url) -> Result<Vec<u8>, StorytellerError> {
|
||||
let client = Client::builder()
|
||||
.gzip(true)
|
||||
.build()
|
||||
.map_err(|err| StorytellerError::Client(ClientError::ReqwestError(err)))?;
|
||||
|
||||
let response = client.get(url.as_str())
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| StorytellerError::Api(ApiError::OtherReqwestError(err)))?;
|
||||
|
||||
let status_code = response.status();
|
||||
|
||||
if !status_code.is_success() {
|
||||
let body = response.text().await.unwrap_or_else(|err| {
|
||||
warn!("Failed to retrieve response body: {}", err);
|
||||
"".to_string()
|
||||
});
|
||||
return Err(StorytellerError::Api(ApiError::UncategorizedBadResponseWithStatusAndBody {
|
||||
status_code,
|
||||
body,
|
||||
}));
|
||||
}
|
||||
|
||||
let bytes = response.bytes()
|
||||
.await
|
||||
.map_err(|err| StorytellerError::Client(ClientError::ReqwestError(err)))?;
|
||||
|
||||
Ok(bytes.to_vec())
|
||||
}
|
||||
|
||||
/// Derive a filename from the CDN URL's path extension, falling back to the media token.
|
||||
fn derive_filename_from_url(url: &Url, media_token: &MediaFileToken) -> String {
|
||||
let path = url.path();
|
||||
let extension = Path::new(path)
|
||||
.extension() // NB: without dot '.'
|
||||
.and_then(|ext| ext.to_str())
|
||||
.unwrap_or("bin");
|
||||
|
||||
format!("{}.{}", media_token.as_str(), extension)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn derive_filename_from_url_extracts_extension() {
|
||||
let url = Url::parse("https://cdn.example.com/files/abc123.png").unwrap();
|
||||
let token = MediaFileToken::new_from_str("mf_test123");
|
||||
assert_eq!(derive_filename_from_url(&url, &token), "mf_test123.png");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derive_filename_from_url_handles_no_extension() {
|
||||
let url = Url::parse("https://cdn.example.com/files/abc123").unwrap();
|
||||
let token = MediaFileToken::new_from_str("mf_test123");
|
||||
assert_eq!(derive_filename_from_url(&url, &token), "mf_test123.bin");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derive_filename_from_url_handles_query_params() {
|
||||
let url = Url::parse("https://cdn.example.com/files/abc123.mp4?token=xyz").unwrap();
|
||||
let token = MediaFileToken::new_from_str("mf_test123");
|
||||
assert_eq!(derive_filename_from_url(&url, &token), "mf_test123.mp4");
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1 @@
|
||||
pub mod download_media_file;
|
||||
pub mod upload_media_file_from_file;
|
||||
+1
-1
@@ -80,7 +80,6 @@ fn plan_aspect_ratio(
|
||||
}
|
||||
Some(CommonAspectRatio::WideFourByThree) => Ok(Some(CommonAspectRatioEnum::WideFourByThree)),
|
||||
Some(CommonAspectRatio::TallThreeByFour) => Ok(Some(CommonAspectRatioEnum::TallThreeByFour)),
|
||||
Some(CommonAspectRatio::WideTwentyOneByNine) => Ok(Some(CommonAspectRatioEnum::WideTwentyOneByNine)),
|
||||
|
||||
Some(unsupported) => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
@@ -101,6 +100,7 @@ fn nearest_aspect_ratio(aspect_ratio: CommonAspectRatio) -> CommonAspectRatioEnu
|
||||
match aspect_ratio {
|
||||
CommonAspectRatio::WideFiveByFour => CommonAspectRatioEnum::WideFourByThree,
|
||||
CommonAspectRatio::WideThreeByTwo => CommonAspectRatioEnum::WideFourByThree,
|
||||
CommonAspectRatio::WideTwentyOneByNine => CommonAspectRatioEnum::WideSixteenByNine,
|
||||
CommonAspectRatio::TallFourByFive => CommonAspectRatioEnum::TallThreeByFour,
|
||||
CommonAspectRatio::TallTwoByThree => CommonAspectRatioEnum::TallThreeByFour,
|
||||
CommonAspectRatio::TallNineByTwentyOne => CommonAspectRatioEnum::TallNineBySixteen,
|
||||
|
||||
+2
-3
@@ -222,10 +222,9 @@ mod tests {
|
||||
fn artcraft_matches_kinovi_all_combos() {
|
||||
let resolutions = [
|
||||
Some(CommonResolution::FourEightyP),
|
||||
Some(CommonResolution::SevenTwentyP),
|
||||
None,
|
||||
Some(CommonResolution::TenEightyP),
|
||||
// NB: These are underpriced (for now)
|
||||
//Some(CommonResolution::SevenTwentyP),
|
||||
//None,
|
||||
];
|
||||
let durations: [u16; 4] = [4, 5, 10, 15];
|
||||
let batches: [u16; 3] = [1, 2, 4];
|
||||
|
||||
+1
-1
@@ -81,7 +81,6 @@ fn plan_aspect_ratio(
|
||||
}
|
||||
Some(CommonAspectRatio::WideFourByThree) => Ok(Some(CommonAspectRatioEnum::WideFourByThree)),
|
||||
Some(CommonAspectRatio::TallThreeByFour) => Ok(Some(CommonAspectRatioEnum::TallThreeByFour)),
|
||||
Some(CommonAspectRatio::WideTwentyOneByNine) => Ok(Some(CommonAspectRatioEnum::WideTwentyOneByNine)),
|
||||
|
||||
// Mismatches
|
||||
Some(unsupported) => match strategy {
|
||||
@@ -103,6 +102,7 @@ fn nearest_aspect_ratio(aspect_ratio: CommonAspectRatio) -> CommonAspectRatioEnu
|
||||
match aspect_ratio {
|
||||
CommonAspectRatio::WideFiveByFour => CommonAspectRatioEnum::WideFourByThree,
|
||||
CommonAspectRatio::WideThreeByTwo => CommonAspectRatioEnum::WideFourByThree,
|
||||
CommonAspectRatio::WideTwentyOneByNine => CommonAspectRatioEnum::WideSixteenByNine,
|
||||
CommonAspectRatio::TallFourByFive => CommonAspectRatioEnum::TallThreeByFour,
|
||||
CommonAspectRatio::TallTwoByThree => CommonAspectRatioEnum::TallThreeByFour,
|
||||
CommonAspectRatio::TallNineByTwentyOne => CommonAspectRatioEnum::TallNineBySixteen,
|
||||
|
||||
+41
-41
@@ -185,47 +185,47 @@ mod tests {
|
||||
|
||||
// -- Cross-check with Kinovi Fast via builder --
|
||||
|
||||
//mod cross_check_with_kinovi {
|
||||
// use super::*;
|
||||
//
|
||||
// #[test]
|
||||
// fn artcraft_fast_matches_kinovi_fast_all_combos() {
|
||||
// let resolutions = [
|
||||
// Some(CommonResolution::FourEightyP),
|
||||
// Some(CommonResolution::SevenTwentyP),
|
||||
// None,
|
||||
// ];
|
||||
// let durations: [u16; 4] = [4, 5, 10, 15];
|
||||
// let batches: [u16; 3] = [1, 2, 4];
|
||||
//
|
||||
// for res in &resolutions {
|
||||
// for dur in &durations {
|
||||
// for batch in &batches {
|
||||
// let artcraft_cost = build_cost(*res, *dur, *batch);
|
||||
//
|
||||
// let kinovi = GenerateVideoRequestBuilder {
|
||||
// model: CommonVideoModel::Seedance2p0Fast,
|
||||
// provider: Provider::Seedance2Pro,
|
||||
// resolution: *res,
|
||||
// duration_seconds: Some(*dur),
|
||||
// video_batch_count: Some(*batch),
|
||||
// ..Default::default()
|
||||
// };
|
||||
// let kinovi_cost = kinovi.build2()
|
||||
// .expect("kinovi build2")
|
||||
// .estimate_cost()
|
||||
// .expect("kinovi estimate_cost");
|
||||
//
|
||||
// assert_eq!(
|
||||
// artcraft_cost.cost_in_usd_cents, kinovi_cost.cost_in_usd_cents,
|
||||
// "USD cents mismatch: res={:?} dur={}s batch={}",
|
||||
// res, dur, batch,
|
||||
// );
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
mod cross_check_with_kinovi {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn artcraft_fast_matches_kinovi_fast_all_combos() {
|
||||
let resolutions = [
|
||||
Some(CommonResolution::FourEightyP),
|
||||
Some(CommonResolution::SevenTwentyP),
|
||||
None,
|
||||
];
|
||||
let durations: [u16; 4] = [4, 5, 10, 15];
|
||||
let batches: [u16; 3] = [1, 2, 4];
|
||||
|
||||
for res in &resolutions {
|
||||
for dur in &durations {
|
||||
for batch in &batches {
|
||||
let artcraft_cost = build_cost(*res, *dur, *batch);
|
||||
|
||||
let kinovi = GenerateVideoRequestBuilder {
|
||||
model: CommonVideoModel::Seedance2p0Fast,
|
||||
provider: Provider::Seedance2Pro,
|
||||
resolution: *res,
|
||||
duration_seconds: Some(*dur),
|
||||
video_batch_count: Some(*batch),
|
||||
..Default::default()
|
||||
};
|
||||
let kinovi_cost = kinovi.build2()
|
||||
.expect("kinovi build2")
|
||||
.estimate_cost()
|
||||
.expect("kinovi estimate_cost");
|
||||
|
||||
assert_eq!(
|
||||
artcraft_cost.cost_in_usd_cents, kinovi_cost.cost_in_usd_cents,
|
||||
"USD cents mismatch: res={:?} dur={}s batch={}",
|
||||
res, dur, batch,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -- Helpers --
|
||||
|
||||
|
||||
+3
-11
@@ -1,8 +1,4 @@
|
||||
use seedance2pro_client::generate::video::generate_seedance_2p0::{
|
||||
KinoviSeedance2p0AspectRatio as KinoviAspectRatio,
|
||||
KinoviSeedance2p0BatchCount as KinoviBatchCount,
|
||||
KinoviSeedance2p0OutputResolution as KinoviOutputResolution,
|
||||
};
|
||||
use seedance2pro_client::requests::generate_video::generate_video::{KinoviAspectRatio, KinoviBatchCount, KinoviOutputResolution};
|
||||
|
||||
use crate::api::common_aspect_ratio::CommonAspectRatio;
|
||||
use crate::api::common_resolution::CommonResolution;
|
||||
@@ -77,7 +73,6 @@ fn plan_aspect_ratio(
|
||||
}
|
||||
Some(CommonAspectRatio::WideFourByThree) => Ok(KinoviAspectRatio::Standard4x3),
|
||||
Some(CommonAspectRatio::TallThreeByFour) => Ok(KinoviAspectRatio::Portrait3x4),
|
||||
Some(CommonAspectRatio::WideTwentyOneByNine) => Ok(KinoviAspectRatio::UltraWide21x9),
|
||||
|
||||
// Mismatches — apply strategy
|
||||
Some(unsupported) => match strategy {
|
||||
@@ -100,6 +95,7 @@ fn nearest_aspect_ratio(aspect_ratio: CommonAspectRatio) -> KinoviAspectRatio {
|
||||
match aspect_ratio {
|
||||
CommonAspectRatio::WideFiveByFour => KinoviAspectRatio::Standard4x3, // 1.25, nearest 1.33
|
||||
CommonAspectRatio::WideThreeByTwo => KinoviAspectRatio::Standard4x3, // 1.50, nearest 1.33
|
||||
CommonAspectRatio::WideTwentyOneByNine => KinoviAspectRatio::Landscape16x9, // 2.33, nearest 1.78
|
||||
CommonAspectRatio::TallFourByFive => KinoviAspectRatio::Portrait3x4, // 0.80, nearest 0.75
|
||||
CommonAspectRatio::TallTwoByThree => KinoviAspectRatio::Portrait3x4, // 0.67, nearest 0.75
|
||||
CommonAspectRatio::TallNineByTwentyOne => KinoviAspectRatio::Portrait9x16, // 0.43, nearest 0.56
|
||||
@@ -197,11 +193,7 @@ fn plan_duration(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use seedance2pro_client::generate::video::generate_seedance_2p0::{
|
||||
KinoviSeedance2p0AspectRatio as KinoviAspectRatio,
|
||||
KinoviSeedance2p0BatchCount as KinoviBatchCount,
|
||||
KinoviSeedance2p0OutputResolution as KinoviOutputResolution,
|
||||
};
|
||||
use seedance2pro_client::requests::generate_video::generate_video::{KinoviAspectRatio, KinoviBatchCount, KinoviOutputResolution};
|
||||
use tokens::tokens::characters::CharacterToken;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
|
||||
+54
-60
@@ -1,23 +1,22 @@
|
||||
use seedance2pro_client::generate::video::generate_seedance_2p0::{
|
||||
GenerateSeedance2p0Request, KinoviSeedance2p0BatchCount,
|
||||
KinoviSeedance2p0OutputResolution,
|
||||
};
|
||||
|
||||
use seedance2pro_client::requests::generate_video::generate_video::{KinoviAspectRatio, KinoviBatchCount, KinoviGenerateVideoRequest, KinoviModelType, KinoviOutputResolution};
|
||||
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
|
||||
use crate::generate::generate_video_v2::providers::kinovi::seedance_2p0::draft::KinoviSeedance2p0DraftState;
|
||||
use crate::generate::generate_video_v2::providers::kinovi::seedance_2p0::request::KinoviSeedance2p0RequestState;
|
||||
|
||||
pub struct KinoviSeedance2p0CostState {
|
||||
pub resolution: Option<KinoviSeedance2p0OutputResolution>,
|
||||
pub resolution: KinoviOutputResolution,
|
||||
pub duration_seconds: u8,
|
||||
pub batch_count: Option<KinoviSeedance2p0BatchCount>,
|
||||
pub batch_count: KinoviBatchCount,
|
||||
|
||||
/// Forward-looking: Kinovi charges for this. We need to take it into
|
||||
/// account in the future.
|
||||
pub has_video_reference: bool,
|
||||
}
|
||||
|
||||
impl KinoviSeedance2p0CostState {
|
||||
pub fn from_request(request: &KinoviSeedance2p0RequestState) -> Self {
|
||||
Self {
|
||||
resolution: request.request.output_resolution,
|
||||
resolution: request.request.output_resolution.unwrap_or(KinoviOutputResolution::SevenTwentyP),
|
||||
duration_seconds: request.request.duration_seconds,
|
||||
batch_count: request.request.batch_count,
|
||||
has_video_reference: request.request.reference_video_urls
|
||||
@@ -33,22 +32,27 @@ impl KinoviSeedance2p0CostState {
|
||||
.is_some();
|
||||
|
||||
Self {
|
||||
resolution: draft.resolution,
|
||||
resolution: draft.resolution.unwrap_or(KinoviOutputResolution::SevenTwentyP),
|
||||
duration_seconds: draft.duration_seconds,
|
||||
batch_count: Some(draft.batch_count),
|
||||
batch_count: draft.batch_count,
|
||||
has_video_reference,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
|
||||
let pricing_request = GenerateSeedance2p0Request {
|
||||
output_resolution: self.resolution,
|
||||
let request = KinoviGenerateVideoRequest {
|
||||
// Pricing factors
|
||||
model_type: KinoviModelType::Seedance2Pro,
|
||||
output_resolution: Some(self.resolution),
|
||||
duration_seconds: self.duration_seconds,
|
||||
batch_count: self.batch_count,
|
||||
|
||||
// NB: has_video_reference is tracked but does not affect price yet.
|
||||
// When Kinovi starts charging for video references, add cost here.
|
||||
|
||||
// No impact on price
|
||||
prompt: String::new(),
|
||||
aspect_ratio: None,
|
||||
aspect_ratio: KinoviAspectRatio::Portrait9x16,
|
||||
start_frame_url: None,
|
||||
end_frame_url: None,
|
||||
reference_image_urls: None,
|
||||
@@ -58,8 +62,8 @@ impl KinoviSeedance2p0CostState {
|
||||
use_face_blur_hack: None,
|
||||
};
|
||||
|
||||
let cost_in_credits = pricing_request.estimate_credits();
|
||||
let cost_in_usd_cents = pricing_request.estimate_cost_in_usd_cents();
|
||||
let cost_in_credits = request.estimate_credits();
|
||||
let cost_in_usd_cents = request.estimate_cost_in_usd_cents();
|
||||
|
||||
VideoGenerationCostEstimate {
|
||||
cost_in_credits: Some(cost_in_credits as u64),
|
||||
@@ -75,11 +79,6 @@ impl KinoviSeedance2p0CostState {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use seedance2pro_client::generate::video::generate_seedance_2p0::{
|
||||
KinoviSeedance2p0OutputResolution as KinoviOutputResolution,
|
||||
KinoviSeedance2p0BatchCount as KinoviBatchCount,
|
||||
};
|
||||
|
||||
use crate::api::common_resolution::CommonResolution;
|
||||
use crate::api::provider::Provider;
|
||||
use crate::api::video_list_ref::VideoListRef;
|
||||
@@ -94,26 +93,26 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn cost_720p_batch_1() {
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::One), 83);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::One), 104);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 6, KinoviBatchCount::One), 124);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 7, KinoviBatchCount::One), 145);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 10, KinoviBatchCount::One), 207);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::One), 311);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::One), 64);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::One), 80);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 6, KinoviBatchCount::One), 96);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 7, KinoviBatchCount::One), 112);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 10, KinoviBatchCount::One), 160);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::One), 240);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_720p_batch_2() {
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::Two), 166);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Two), 207);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::Two), 622);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::Two), 128);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Two), 160);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::Two), 480);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_720p_batch_4() {
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::Four), 332);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Four), 415);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::Four), 1244);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::Four), 256);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Four), 320);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::Four), 960);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,9 +202,9 @@ mod tests {
|
||||
#[test]
|
||||
fn video_reference_does_not_affect_cost() {
|
||||
let base = KinoviSeedance2p0CostState {
|
||||
resolution: Some(KinoviOutputResolution::SevenTwentyP),
|
||||
resolution: KinoviOutputResolution::SevenTwentyP,
|
||||
duration_seconds: 5,
|
||||
batch_count: Some(KinoviBatchCount::One),
|
||||
batch_count: KinoviBatchCount::One,
|
||||
has_video_reference: false,
|
||||
};
|
||||
let without = base.estimate_cost();
|
||||
@@ -223,19 +222,19 @@ mod tests {
|
||||
fn from_request_720p() {
|
||||
let req = make_request_state(Some(KinoviOutputResolution::SevenTwentyP), 5, KinoviBatchCount::One, false);
|
||||
let cost = KinoviSeedance2p0CostState::from_request(&req);
|
||||
assert!(matches!(cost.resolution, Some(KinoviOutputResolution::SevenTwentyP)));
|
||||
assert!(matches!(cost.resolution, KinoviOutputResolution::SevenTwentyP));
|
||||
assert_eq!(cost.duration_seconds, 5);
|
||||
assert!(matches!(cost.batch_count, Some(KinoviBatchCount::One)));
|
||||
assert!(matches!(cost.batch_count, KinoviBatchCount::One));
|
||||
assert!(!cost.has_video_reference);
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(104));
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(80));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_request_none_defaults_to_720p() {
|
||||
let req = make_request_state(None, 5, KinoviBatchCount::One, false);
|
||||
let cost = KinoviSeedance2p0CostState::from_request(&req);
|
||||
assert!(cost.resolution.is_none()); // None defaults to 720p pricing
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(104));
|
||||
assert!(matches!(cost.resolution, KinoviOutputResolution::SevenTwentyP));
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(80));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -258,7 +257,7 @@ mod tests {
|
||||
let cost = KinoviSeedance2p0CostState::from_request(&req);
|
||||
assert!(cost.has_video_reference);
|
||||
// Video refs don't affect cost yet
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(104));
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(80));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -278,18 +277,18 @@ mod tests {
|
||||
fn from_draft_720p_default() {
|
||||
let draft = make_draft(5, 1, None, false);
|
||||
let cost = KinoviSeedance2p0CostState::from_draft(&draft);
|
||||
assert!(cost.resolution.is_none()); // None defaults to 720p pricing
|
||||
assert!(matches!(cost.resolution, KinoviOutputResolution::SevenTwentyP));
|
||||
assert_eq!(cost.duration_seconds, 5);
|
||||
assert!(matches!(cost.batch_count, Some(KinoviBatchCount::One)));
|
||||
assert!(matches!(cost.batch_count, KinoviBatchCount::One));
|
||||
assert!(!cost.has_video_reference);
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(104));
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(80));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_draft_480p() {
|
||||
let draft = make_draft(5, 1, Some(CommonResolution::FourEightyP), false);
|
||||
let cost = KinoviSeedance2p0CostState::from_draft(&draft);
|
||||
assert!(matches!(cost.resolution, Some(KinoviOutputResolution::FourEightyP)));
|
||||
assert!(matches!(cost.resolution, KinoviOutputResolution::FourEightyP));
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(39));
|
||||
}
|
||||
|
||||
@@ -297,8 +296,8 @@ mod tests {
|
||||
fn from_draft_1080p_batch_4() {
|
||||
let draft = make_draft(5, 4, Some(CommonResolution::TenEightyP), false);
|
||||
let cost = KinoviSeedance2p0CostState::from_draft(&draft);
|
||||
assert!(matches!(cost.resolution, Some(KinoviOutputResolution::TenEightyP)));
|
||||
assert!(matches!(cost.batch_count, Some(KinoviBatchCount::Four)));
|
||||
assert!(matches!(cost.resolution, KinoviOutputResolution::TenEightyP));
|
||||
assert!(matches!(cost.batch_count, KinoviBatchCount::Four));
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(933));
|
||||
}
|
||||
|
||||
@@ -308,7 +307,7 @@ mod tests {
|
||||
let cost = KinoviSeedance2p0CostState::from_draft(&draft);
|
||||
assert!(cost.has_video_reference);
|
||||
// Video refs don't affect cost yet
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(104));
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(80));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -323,8 +322,8 @@ mod tests {
|
||||
let draft = make_draft(15, 2, Some(CommonResolution::SevenTwentyP), false);
|
||||
let cost = KinoviSeedance2p0CostState::from_draft(&draft);
|
||||
assert_eq!(cost.duration_seconds, 15);
|
||||
assert!(matches!(cost.batch_count, Some(KinoviBatchCount::Two)));
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(622));
|
||||
assert!(matches!(cost.batch_count, KinoviBatchCount::Two));
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(480));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -383,10 +382,7 @@ mod tests {
|
||||
duration_seconds: u8,
|
||||
batch_count: KinoviBatchCount,
|
||||
) -> u64 {
|
||||
KinoviSeedance2p0CostState {
|
||||
resolution: Some(resolution), duration_seconds, batch_count: Some(batch_count),
|
||||
has_video_reference: false,
|
||||
}
|
||||
KinoviSeedance2p0CostState { resolution, duration_seconds, batch_count, has_video_reference: false }
|
||||
.estimate_cost()
|
||||
.cost_in_usd_cents
|
||||
.unwrap()
|
||||
@@ -397,10 +393,7 @@ mod tests {
|
||||
duration_seconds: u8,
|
||||
batch_count: KinoviBatchCount,
|
||||
) -> u64 {
|
||||
KinoviSeedance2p0CostState {
|
||||
resolution: Some(resolution), duration_seconds, batch_count: Some(batch_count),
|
||||
has_video_reference: false,
|
||||
}
|
||||
KinoviSeedance2p0CostState { resolution, duration_seconds, batch_count, has_video_reference: false }
|
||||
.estimate_cost()
|
||||
.cost_in_credits
|
||||
.unwrap()
|
||||
@@ -450,12 +443,13 @@ mod tests {
|
||||
};
|
||||
|
||||
KinoviSeedance2p0RequestState {
|
||||
request: GenerateSeedance2p0Request {
|
||||
request: KinoviGenerateVideoRequest {
|
||||
model_type: KinoviModelType::Seedance2Pro,
|
||||
prompt: "test".to_string(),
|
||||
aspect_ratio: None,
|
||||
aspect_ratio: KinoviAspectRatio::Landscape16x9,
|
||||
output_resolution: resolution,
|
||||
duration_seconds,
|
||||
batch_count: Some(batch_count),
|
||||
batch_count,
|
||||
start_frame_url: None,
|
||||
end_frame_url: None,
|
||||
reference_image_urls: None,
|
||||
|
||||
+8
-10
@@ -1,7 +1,4 @@
|
||||
use seedance2pro_client::generate::video::generate_seedance_2p0::{
|
||||
GenerateSeedance2p0Request, KinoviSeedance2p0AspectRatio,
|
||||
KinoviSeedance2p0BatchCount, KinoviSeedance2p0OutputResolution,
|
||||
};
|
||||
use seedance2pro_client::requests::generate_video::generate_video::{KinoviAspectRatio, KinoviBatchCount, KinoviGenerateVideoRequest, KinoviModelType, KinoviOutputResolution};
|
||||
|
||||
use crate::api::audio_list_ref::AudioListRef;
|
||||
use crate::api::character_list_ref::CharacterListRef;
|
||||
@@ -22,10 +19,10 @@ pub struct KinoviSeedance2p0DraftState {
|
||||
// Materialized / finalized types
|
||||
|
||||
pub prompt: String,
|
||||
pub aspect_ratio: KinoviSeedance2p0AspectRatio,
|
||||
pub resolution: Option<KinoviSeedance2p0OutputResolution>,
|
||||
pub aspect_ratio: KinoviAspectRatio,
|
||||
pub resolution: Option<KinoviOutputResolution>,
|
||||
pub duration_seconds: u8,
|
||||
pub batch_count: KinoviSeedance2p0BatchCount,
|
||||
pub batch_count: KinoviBatchCount,
|
||||
|
||||
// Pending types that need to be queried.
|
||||
pub unhandled_request_state: Option<KinoviSeedance2p0RemainingItems>,
|
||||
@@ -80,12 +77,13 @@ impl KinoviSeedance2p0DraftState {
|
||||
)?;
|
||||
}
|
||||
|
||||
let request = GenerateSeedance2p0Request {
|
||||
let request = KinoviGenerateVideoRequest {
|
||||
model_type: KinoviModelType::Seedance2Pro,
|
||||
prompt: self.prompt.clone(),
|
||||
aspect_ratio: Some(self.aspect_ratio),
|
||||
aspect_ratio: self.aspect_ratio,
|
||||
output_resolution: self.resolution,
|
||||
duration_seconds: self.duration_seconds,
|
||||
batch_count: Some(self.batch_count),
|
||||
batch_count: self.batch_count,
|
||||
start_frame_url,
|
||||
end_frame_url,
|
||||
reference_image_urls,
|
||||
|
||||
+324
-10
@@ -1,31 +1,28 @@
|
||||
use seedance2pro_client::generate::video::generate_seedance_2p0::{
|
||||
generate_seedance_2p0, GenerateSeedance2p0Args, GenerateSeedance2p0Request,
|
||||
};
|
||||
|
||||
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::errors::provider_error::ProviderError;
|
||||
use crate::generate::generate_video::generate_video_response::{GenerateVideoResponse, Seedance2proVideoResponsePayload};
|
||||
use seedance2pro_client::requests::generate_video::generate_video::{generate_video, GenerateVideoArgs, KinoviGenerateVideoRequest};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct KinoviSeedance2p0RequestState {
|
||||
/// Final materialized request; ready to fire.
|
||||
pub request: GenerateSeedance2p0Request,
|
||||
pub request: KinoviGenerateVideoRequest,
|
||||
}
|
||||
|
||||
impl KinoviSeedance2p0RequestState {
|
||||
pub async fn send(&self, client: &RouterSeedance2ProClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
|
||||
let session = &client.session;
|
||||
|
||||
let args = GenerateSeedance2p0Args {
|
||||
let args = GenerateVideoArgs {
|
||||
session,
|
||||
host_override: None,
|
||||
request: self.request.clone(),
|
||||
request: self.request.clone(), // TODO: Yuck.
|
||||
};
|
||||
|
||||
let response = generate_seedance_2p0(args)
|
||||
.await
|
||||
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Seedance2Pro(err)))?;
|
||||
let response = generate_video(args)
|
||||
.await
|
||||
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Seedance2Pro(err)))?;
|
||||
|
||||
Ok(GenerateVideoResponse::Seedance2Pro(Seedance2proVideoResponsePayload {
|
||||
order_id: response.order_id,
|
||||
@@ -35,3 +32,320 @@ impl KinoviSeedance2p0RequestState {
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashMap;
|
||||
|
||||
use tokens::tokens::characters::CharacterToken;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
use crate::api::character_list_ref::CharacterListRef;
|
||||
use crate::api::common_aspect_ratio::CommonAspectRatio;
|
||||
use crate::api::common_resolution::CommonResolution;
|
||||
use crate::api::image_list_ref::ImageListRef;
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::provider::Provider;
|
||||
use crate::api::video_list_ref::VideoListRef;
|
||||
use crate::client::router_client::RouterClient;
|
||||
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
|
||||
use crate::generate::generate_video_v2::video_generation_draft_context::VideoGenerationDraftContext;
|
||||
use crate::generate::generate_video_v2::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
|
||||
use test_data::web::image_urls::{FOREST_BACKDROP_IMAGE_URL, JUNO_AT_LAKE_IMAGE_URL, WHITE_HOUSE_SUNSET_IMAGE_URL};
|
||||
|
||||
const STEAMPUNK_CLOWN_KINOVI_ID: &str = "char_1775176566518_sik0te";
|
||||
const MOCHI_KINOVI_ID: &str = "char_1775177718294_g2pitx";
|
||||
|
||||
mod aspect_ratio_tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually run — fires a real API request and incurs cost
|
||||
async fn landscape() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("A corgi running through a field of wildflowers at sunset.".to_string()),
|
||||
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
|
||||
..seedance2pro_builder()
|
||||
}).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually run — fires a real API request and incurs cost
|
||||
async fn portrait() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("A cat sitting on a windowsill watching rain.".to_string()),
|
||||
aspect_ratio: Some(CommonAspectRatio::TallNineBySixteen),
|
||||
..seedance2pro_builder()
|
||||
}).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually run — fires a real API request and incurs cost
|
||||
async fn square() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("A hummingbird hovering near a flower.".to_string()),
|
||||
aspect_ratio: Some(CommonAspectRatio::Square),
|
||||
..seedance2pro_builder()
|
||||
}).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
}
|
||||
|
||||
mod resolution_tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually run — fires a real API request and incurs cost
|
||||
async fn res_480p() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("A shiba inu playing in autumn leaves.".to_string()),
|
||||
resolution: Some(CommonResolution::FourEightyP),
|
||||
..seedance2pro_builder()
|
||||
}).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually run — fires a real API request and incurs cost
|
||||
async fn res_720p() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("A golden retriever catching a frisbee on the beach.".to_string()),
|
||||
resolution: Some(CommonResolution::SevenTwentyP),
|
||||
..seedance2pro_builder()
|
||||
}).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually run — fires a real API request and incurs cost
|
||||
async fn res_1080p() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("A fox walking through a snowy forest.".to_string()),
|
||||
resolution: Some(CommonResolution::TenEightyP),
|
||||
..seedance2pro_builder()
|
||||
}).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
}
|
||||
|
||||
mod modality_tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually run — fires a real API request and incurs cost
|
||||
async fn text_to_video() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("A whale breaching in the open ocean at dawn, cinematic.".to_string()),
|
||||
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
|
||||
..seedance2pro_builder()
|
||||
}).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually run — fires a real API request and incurs cost
|
||||
async fn keyframe_start_and_end_frame() {
|
||||
let start_token = MediaFileToken::new("mf_start".to_string());
|
||||
let end_token = MediaFileToken::new("mf_end".to_string());
|
||||
|
||||
let mut media_map = HashMap::new();
|
||||
media_map.insert(start_token.clone(), JUNO_AT_LAKE_IMAGE_URL.to_string());
|
||||
media_map.insert(end_token.clone(), FOREST_BACKDROP_IMAGE_URL.to_string());
|
||||
|
||||
let response = run_pipeline_with_media_map(GenerateVideoRequestBuilder {
|
||||
prompt: Some("The dog walks from the lake to the forest.".to_string()),
|
||||
start_frame: Some(ImageRef::MediaFileToken(start_token)),
|
||||
end_frame: Some(ImageRef::MediaFileToken(end_token)),
|
||||
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
|
||||
..seedance2pro_builder()
|
||||
}, media_map).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually run — fires a real API request and incurs cost
|
||||
async fn image_references() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("The dog in @2 runs through the scenery in @1 towards the building in @3.".to_string()),
|
||||
reference_images: Some(ImageListRef::Urls(vec![
|
||||
FOREST_BACKDROP_IMAGE_URL.to_string(),
|
||||
JUNO_AT_LAKE_IMAGE_URL.to_string(),
|
||||
WHITE_HOUSE_SUNSET_IMAGE_URL.to_string(),
|
||||
])),
|
||||
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
|
||||
..seedance2pro_builder()
|
||||
}).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually run — fires a real API request and incurs cost
|
||||
async fn video_reference() {
|
||||
// NB: This URL is a pre-uploaded video on the Seedance2Pro CDN from prior tests.
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("Change @video1 to a nighttime scene with moonlight.".to_string()),
|
||||
reference_videos: Some(VideoListRef::Urls(vec![
|
||||
"https://static.seedance2-pro.com/materials/20260315/1773594284659-3a46d231.mp4".to_string(),
|
||||
])),
|
||||
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
|
||||
..seedance2pro_builder()
|
||||
}).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually run — fires a real API request and incurs cost
|
||||
async fn character_references() {
|
||||
let char_clown = CharacterToken::new("char_clown".to_string());
|
||||
let char_mochi = CharacterToken::new("char_mochi".to_string());
|
||||
|
||||
let mut character_map = HashMap::new();
|
||||
character_map.insert(char_clown.clone(), STEAMPUNK_CLOWN_KINOVI_ID.to_string());
|
||||
character_map.insert(char_mochi.clone(), MOCHI_KINOVI_ID.to_string());
|
||||
|
||||
let response = run_pipeline_with_maps(
|
||||
GenerateVideoRequestBuilder {
|
||||
prompt: Some("@Steampunk Clown and @Mochi are playing fetch in a sunny park.".to_string()),
|
||||
reference_character_tokens: Some(CharacterListRef::CharacterTokens(vec![
|
||||
char_clown,
|
||||
char_mochi,
|
||||
])),
|
||||
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
|
||||
..seedance2pro_builder()
|
||||
},
|
||||
HashMap::new(),
|
||||
character_map,
|
||||
).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
|
||||
// TODO: Add audio reference test once a web-accessible audio URL is available in test_data.
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
fn seedance2pro_builder() -> GenerateVideoRequestBuilder {
|
||||
GenerateVideoRequestBuilder {
|
||||
provider: Provider::Seedance2Pro,
|
||||
duration_seconds: Some(4),
|
||||
video_batch_count: Some(1),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn get_seedance2pro_client() -> RouterClient {
|
||||
let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/seedance2pro_cookies.txt")
|
||||
.expect("Failed to read seedance2pro cookies");
|
||||
let session = Seedance2ProSession::from_cookies_string(cookies.trim().to_string());
|
||||
RouterClient::Seedance2Pro(RouterSeedance2ProClient::new(session))
|
||||
}
|
||||
|
||||
/// Run the full pipeline: builder → build2 → finalize → send.
|
||||
async fn run_pipeline(builder: GenerateVideoRequestBuilder) -> GenerateVideoResponse {
|
||||
let client = get_seedance2pro_client();
|
||||
|
||||
let draft_or_request = builder.build2().expect("build2 should succeed");
|
||||
let draft = match draft_or_request {
|
||||
VideoGenerationDraftOrRequest::Draft(d) => d,
|
||||
_ => panic!("expected Draft variant"),
|
||||
};
|
||||
|
||||
let draft_context = VideoGenerationDraftContext {
|
||||
client: Some(&client),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let request = draft.finalize(draft_context).await.expect("finalize should succeed");
|
||||
let response = request.send_request(&client).await.expect("send_request should succeed");
|
||||
|
||||
match &response {
|
||||
GenerateVideoResponse::Seedance2Pro(p) => {
|
||||
println!("task_id={}, order_id={}", p.task_id, p.order_id);
|
||||
}
|
||||
other => println!("response: {:?}", other),
|
||||
}
|
||||
|
||||
response
|
||||
}
|
||||
|
||||
/// Run the full pipeline with a media file token map for resolving references.
|
||||
async fn run_pipeline_with_media_map(
|
||||
builder: GenerateVideoRequestBuilder,
|
||||
media_map: HashMap<MediaFileToken, String>,
|
||||
) -> GenerateVideoResponse {
|
||||
let client = get_seedance2pro_client();
|
||||
|
||||
let draft_or_request = builder.build2().expect("build2 should succeed");
|
||||
let draft = match draft_or_request {
|
||||
VideoGenerationDraftOrRequest::Draft(d) => d,
|
||||
_ => panic!("expected Draft variant"),
|
||||
};
|
||||
|
||||
let draft_context = VideoGenerationDraftContext {
|
||||
client: Some(&client),
|
||||
media_file_to_artcraft_url_map: Some(&media_map),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let request = draft.finalize(draft_context).await.expect("finalize should succeed");
|
||||
let response = request.send_request(&client).await.expect("send_request should succeed");
|
||||
|
||||
match &response {
|
||||
GenerateVideoResponse::Seedance2Pro(p) => {
|
||||
println!("task_id={}, order_id={}", p.task_id, p.order_id);
|
||||
}
|
||||
other => println!("response: {:?}", other),
|
||||
}
|
||||
|
||||
response
|
||||
}
|
||||
|
||||
/// Run the full pipeline with both a media map and character map.
|
||||
async fn run_pipeline_with_maps(
|
||||
builder: GenerateVideoRequestBuilder,
|
||||
media_map: HashMap<MediaFileToken, String>,
|
||||
character_map: HashMap<CharacterToken, String>,
|
||||
) -> GenerateVideoResponse {
|
||||
let client = get_seedance2pro_client();
|
||||
|
||||
let draft_or_request = builder.build2().expect("build2 should succeed");
|
||||
let draft = match draft_or_request {
|
||||
VideoGenerationDraftOrRequest::Draft(d) => d,
|
||||
_ => panic!("expected Draft variant"),
|
||||
};
|
||||
|
||||
let draft_context = VideoGenerationDraftContext {
|
||||
client: Some(&client),
|
||||
media_file_to_artcraft_url_map: Some(&media_map),
|
||||
character_token_to_kinovi_id_map: Some(&character_map),
|
||||
};
|
||||
|
||||
let request = draft.finalize(draft_context).await.expect("finalize should succeed");
|
||||
let response = request.send_request(&client).await.expect("send_request should succeed");
|
||||
|
||||
match &response {
|
||||
GenerateVideoResponse::Seedance2Pro(p) => {
|
||||
println!("task_id={}, order_id={}", p.task_id, p.order_id);
|
||||
}
|
||||
other => println!("response: {:?}", other),
|
||||
}
|
||||
|
||||
response
|
||||
}
|
||||
}
|
||||
|
||||
+3
-11
@@ -1,8 +1,4 @@
|
||||
use seedance2pro_client::generate::video::generate_seedance_2p0_fast::{
|
||||
KinoviSeedance2p0FastAspectRatio as KinoviAspectRatio,
|
||||
KinoviSeedance2p0FastBatchCount as KinoviBatchCount,
|
||||
KinoviSeedance2p0FastOutputResolution as KinoviOutputResolution,
|
||||
};
|
||||
use seedance2pro_client::requests::generate_video::generate_video::{KinoviAspectRatio, KinoviBatchCount, KinoviOutputResolution};
|
||||
|
||||
use crate::api::common_aspect_ratio::CommonAspectRatio;
|
||||
use crate::api::common_resolution::CommonResolution;
|
||||
@@ -77,7 +73,6 @@ fn plan_aspect_ratio(
|
||||
}
|
||||
Some(CommonAspectRatio::WideFourByThree) => Ok(KinoviAspectRatio::Standard4x3),
|
||||
Some(CommonAspectRatio::TallThreeByFour) => Ok(KinoviAspectRatio::Portrait3x4),
|
||||
Some(CommonAspectRatio::WideTwentyOneByNine) => Ok(KinoviAspectRatio::UltraWide21x9),
|
||||
|
||||
// Mismatches — apply strategy
|
||||
Some(unsupported) => match strategy {
|
||||
@@ -100,6 +95,7 @@ fn nearest_aspect_ratio(aspect_ratio: CommonAspectRatio) -> KinoviAspectRatio {
|
||||
match aspect_ratio {
|
||||
CommonAspectRatio::WideFiveByFour => KinoviAspectRatio::Standard4x3, // 1.25, nearest 1.33
|
||||
CommonAspectRatio::WideThreeByTwo => KinoviAspectRatio::Standard4x3, // 1.50, nearest 1.33
|
||||
CommonAspectRatio::WideTwentyOneByNine => KinoviAspectRatio::Landscape16x9, // 2.33, nearest 1.78
|
||||
CommonAspectRatio::TallFourByFive => KinoviAspectRatio::Portrait3x4, // 0.80, nearest 0.75
|
||||
CommonAspectRatio::TallTwoByThree => KinoviAspectRatio::Portrait3x4, // 0.67, nearest 0.75
|
||||
CommonAspectRatio::TallNineByTwentyOne => KinoviAspectRatio::Portrait9x16, // 0.43, nearest 0.56
|
||||
@@ -212,11 +208,7 @@ fn plan_duration(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use seedance2pro_client::generate::video::generate_seedance_2p0_fast::{
|
||||
KinoviSeedance2p0FastAspectRatio as KinoviAspectRatio,
|
||||
KinoviSeedance2p0FastBatchCount as KinoviBatchCount,
|
||||
KinoviSeedance2p0FastOutputResolution as KinoviOutputResolution,
|
||||
};
|
||||
use seedance2pro_client::requests::generate_video::generate_video::{KinoviAspectRatio, KinoviBatchCount, KinoviOutputResolution};
|
||||
use tokens::tokens::characters::CharacterToken;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
|
||||
+57
-63
@@ -1,23 +1,22 @@
|
||||
use seedance2pro_client::generate::video::generate_seedance_2p0_fast::{
|
||||
GenerateSeedance2p0FastRequest, KinoviSeedance2p0FastBatchCount,
|
||||
KinoviSeedance2p0FastOutputResolution,
|
||||
};
|
||||
|
||||
use seedance2pro_client::requests::generate_video::generate_video::{KinoviAspectRatio, KinoviBatchCount, KinoviGenerateVideoRequest, KinoviModelType, KinoviOutputResolution};
|
||||
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
|
||||
use crate::generate::generate_video_v2::providers::kinovi::seedance_2p0_fast::draft::KinoviSeedance2p0FastDraftState;
|
||||
use crate::generate::generate_video_v2::providers::kinovi::seedance_2p0_fast::request::KinoviSeedance2p0FastRequestState;
|
||||
|
||||
pub struct KinoviSeedance2p0FastCostState {
|
||||
pub resolution: Option<KinoviSeedance2p0FastOutputResolution>,
|
||||
pub resolution: KinoviOutputResolution,
|
||||
pub duration_seconds: u8,
|
||||
pub batch_count: Option<KinoviSeedance2p0FastBatchCount>,
|
||||
pub batch_count: KinoviBatchCount,
|
||||
|
||||
/// Forward-looking: Kinovi charges for this. We need to take it into
|
||||
/// account in the future.
|
||||
pub has_video_reference: bool,
|
||||
}
|
||||
|
||||
impl KinoviSeedance2p0FastCostState {
|
||||
pub fn from_request(request: &KinoviSeedance2p0FastRequestState) -> Self {
|
||||
Self {
|
||||
resolution: request.request.output_resolution,
|
||||
resolution: request.request.output_resolution.unwrap_or(KinoviOutputResolution::SevenTwentyP),
|
||||
duration_seconds: request.request.duration_seconds,
|
||||
batch_count: request.request.batch_count,
|
||||
has_video_reference: request.request.reference_video_urls
|
||||
@@ -33,22 +32,27 @@ impl KinoviSeedance2p0FastCostState {
|
||||
.is_some();
|
||||
|
||||
Self {
|
||||
resolution: draft.resolution,
|
||||
resolution: draft.resolution.unwrap_or(KinoviOutputResolution::SevenTwentyP),
|
||||
duration_seconds: draft.duration_seconds,
|
||||
batch_count: Some(draft.batch_count),
|
||||
batch_count: draft.batch_count,
|
||||
has_video_reference,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
|
||||
let pricing_request = GenerateSeedance2p0FastRequest {
|
||||
output_resolution: self.resolution,
|
||||
let request = KinoviGenerateVideoRequest {
|
||||
// Pricing factors
|
||||
model_type: KinoviModelType::Seedance2Fast,
|
||||
output_resolution: Some(self.resolution),
|
||||
duration_seconds: self.duration_seconds,
|
||||
batch_count: self.batch_count,
|
||||
|
||||
// NB: has_video_reference is tracked but does not affect price yet.
|
||||
// When Kinovi starts charging for video references, add cost here.
|
||||
|
||||
// No impact on price
|
||||
prompt: String::new(),
|
||||
aspect_ratio: None,
|
||||
aspect_ratio: KinoviAspectRatio::Portrait9x16,
|
||||
start_frame_url: None,
|
||||
end_frame_url: None,
|
||||
reference_image_urls: None,
|
||||
@@ -58,8 +62,8 @@ impl KinoviSeedance2p0FastCostState {
|
||||
use_face_blur_hack: None,
|
||||
};
|
||||
|
||||
let cost_in_credits = pricing_request.estimate_credits();
|
||||
let cost_in_usd_cents = pricing_request.estimate_cost_in_usd_cents();
|
||||
let cost_in_credits = request.estimate_credits();
|
||||
let cost_in_usd_cents = request.estimate_cost_in_usd_cents();
|
||||
|
||||
VideoGenerationCostEstimate {
|
||||
cost_in_credits: Some(cost_in_credits as u64),
|
||||
@@ -76,11 +80,6 @@ impl KinoviSeedance2p0FastCostState {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::api::common_resolution::CommonResolution;
|
||||
use seedance2pro_client::generate::video::generate_seedance_2p0_fast::{
|
||||
KinoviSeedance2p0FastOutputResolution as KinoviOutputResolution,
|
||||
KinoviSeedance2p0FastBatchCount as KinoviBatchCount,
|
||||
};
|
||||
|
||||
use crate::api::common_video_model::CommonVideoModel;
|
||||
use crate::api::provider::Provider;
|
||||
use crate::api::video_list_ref::VideoListRef;
|
||||
@@ -91,51 +90,51 @@ mod tests {
|
||||
// ── Direct estimate_cost() tests ──
|
||||
// These must match the reference implementation values exactly.
|
||||
|
||||
// -- 720p (28 credits/sec, 193 credits/$1) --
|
||||
// -- 720p (legacy pricing: 220 credits/$1, 28 credits/sec) --
|
||||
|
||||
mod pricing_720p {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn cost_720p_batch_1() {
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::One), 58);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::One), 73);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 10, KinoviBatchCount::One), 145);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::One), 218);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::One), 51);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::One), 64);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 10, KinoviBatchCount::One), 127);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::One), 191);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_720p_batch_2() {
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Two), 145);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Two), 127);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_720p_batch_4() {
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Four), 290);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Four), 255);
|
||||
}
|
||||
}
|
||||
|
||||
// -- 480p (14 credits/sec, 193 credits/$1) --
|
||||
// -- 480p (new pricing: 193 credits/$1, 10 credits/sec) --
|
||||
|
||||
mod pricing_480p {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn cost_480p_batch_1() {
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 4, KinoviBatchCount::One), 29);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::One), 36);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 10, KinoviBatchCount::One), 73);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 15, KinoviBatchCount::One), 109);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 4, KinoviBatchCount::One), 21);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::One), 26);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 10, KinoviBatchCount::One), 52);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 15, KinoviBatchCount::One), 78);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_480p_batch_2() {
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::Two), 73);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::Two), 52);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_480p_batch_4() {
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::Four), 145);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::Four), 104);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,9 +174,9 @@ mod tests {
|
||||
#[test]
|
||||
fn video_reference_does_not_affect_cost() {
|
||||
let base = KinoviSeedance2p0FastCostState {
|
||||
resolution: Some(KinoviOutputResolution::SevenTwentyP),
|
||||
resolution: KinoviOutputResolution::SevenTwentyP,
|
||||
duration_seconds: 5,
|
||||
batch_count: Some(KinoviBatchCount::One),
|
||||
batch_count: KinoviBatchCount::One,
|
||||
has_video_reference: false,
|
||||
};
|
||||
let without = base.estimate_cost();
|
||||
@@ -195,26 +194,26 @@ mod tests {
|
||||
fn from_request_720p() {
|
||||
let req = make_request_state(Some(KinoviOutputResolution::SevenTwentyP), 5, KinoviBatchCount::One, false);
|
||||
let cost = KinoviSeedance2p0FastCostState::from_request(&req);
|
||||
assert!(matches!(cost.resolution, Some(KinoviOutputResolution::SevenTwentyP)));
|
||||
assert!(matches!(cost.resolution, KinoviOutputResolution::SevenTwentyP));
|
||||
assert_eq!(cost.duration_seconds, 5);
|
||||
assert!(matches!(cost.batch_count, Some(KinoviBatchCount::One)));
|
||||
assert!(matches!(cost.batch_count, KinoviBatchCount::One));
|
||||
assert!(!cost.has_video_reference);
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(73));
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(64));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_request_none_defaults_to_720p() {
|
||||
let req = make_request_state(None, 5, KinoviBatchCount::One, false);
|
||||
let cost = KinoviSeedance2p0FastCostState::from_request(&req);
|
||||
assert!(cost.resolution.is_none());
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(73));
|
||||
assert!(matches!(cost.resolution, KinoviOutputResolution::SevenTwentyP));
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(64));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_request_480p() {
|
||||
let req = make_request_state(Some(KinoviOutputResolution::FourEightyP), 5, KinoviBatchCount::One, false);
|
||||
let cost = KinoviSeedance2p0FastCostState::from_request(&req);
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(36));
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(26));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -223,7 +222,7 @@ mod tests {
|
||||
let cost = KinoviSeedance2p0FastCostState::from_request(&req);
|
||||
assert!(cost.has_video_reference);
|
||||
// Video refs don't affect cost yet
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(73));
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(64));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -243,19 +242,19 @@ mod tests {
|
||||
fn from_draft_720p_default() {
|
||||
let draft = make_draft(5, 1, None, false);
|
||||
let cost = KinoviSeedance2p0FastCostState::from_draft(&draft);
|
||||
assert!(cost.resolution.is_none());
|
||||
assert!(matches!(cost.resolution, KinoviOutputResolution::SevenTwentyP));
|
||||
assert_eq!(cost.duration_seconds, 5);
|
||||
assert!(matches!(cost.batch_count, Some(KinoviBatchCount::One)));
|
||||
assert!(matches!(cost.batch_count, KinoviBatchCount::One));
|
||||
assert!(!cost.has_video_reference);
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(73));
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(64));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_draft_480p() {
|
||||
let draft = make_draft(5, 1, Some(CommonResolution::FourEightyP), false);
|
||||
let cost = KinoviSeedance2p0FastCostState::from_draft(&draft);
|
||||
assert!(matches!(cost.resolution, Some(KinoviOutputResolution::FourEightyP)));
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(36));
|
||||
assert!(matches!(cost.resolution, KinoviOutputResolution::FourEightyP));
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(26));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -264,7 +263,7 @@ mod tests {
|
||||
let cost = KinoviSeedance2p0FastCostState::from_draft(&draft);
|
||||
assert!(cost.has_video_reference);
|
||||
// Video refs don't affect cost yet
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(73));
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(64));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -279,8 +278,8 @@ mod tests {
|
||||
let draft = make_draft(15, 2, Some(CommonResolution::SevenTwentyP), false);
|
||||
let cost = KinoviSeedance2p0FastCostState::from_draft(&draft);
|
||||
assert_eq!(cost.duration_seconds, 15);
|
||||
assert!(matches!(cost.batch_count, Some(KinoviBatchCount::Two)));
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(435));
|
||||
assert!(matches!(cost.batch_count, KinoviBatchCount::Two));
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(382));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -323,7 +322,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn credits_480p() {
|
||||
assert_eq!(credits(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::One), 70);
|
||||
assert_eq!(credits(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::One), 50);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -334,10 +333,7 @@ mod tests {
|
||||
duration_seconds: u8,
|
||||
batch_count: KinoviBatchCount,
|
||||
) -> u64 {
|
||||
KinoviSeedance2p0FastCostState {
|
||||
resolution: Some(resolution), duration_seconds, batch_count: Some(batch_count),
|
||||
has_video_reference: false,
|
||||
}
|
||||
KinoviSeedance2p0FastCostState { resolution, duration_seconds, batch_count, has_video_reference: false }
|
||||
.estimate_cost()
|
||||
.cost_in_usd_cents
|
||||
.unwrap()
|
||||
@@ -348,10 +344,7 @@ mod tests {
|
||||
duration_seconds: u8,
|
||||
batch_count: KinoviBatchCount,
|
||||
) -> u64 {
|
||||
KinoviSeedance2p0FastCostState {
|
||||
resolution: Some(resolution), duration_seconds, batch_count: Some(batch_count),
|
||||
has_video_reference: false,
|
||||
}
|
||||
KinoviSeedance2p0FastCostState { resolution, duration_seconds, batch_count, has_video_reference: false }
|
||||
.estimate_cost()
|
||||
.cost_in_credits
|
||||
.unwrap()
|
||||
@@ -402,12 +395,13 @@ mod tests {
|
||||
};
|
||||
|
||||
KinoviSeedance2p0FastRequestState {
|
||||
request: GenerateSeedance2p0FastRequest {
|
||||
request: KinoviGenerateVideoRequest {
|
||||
model_type: KinoviModelType::Seedance2Fast,
|
||||
prompt: "test".to_string(),
|
||||
aspect_ratio: None,
|
||||
aspect_ratio: KinoviAspectRatio::Landscape16x9,
|
||||
output_resolution: resolution,
|
||||
duration_seconds,
|
||||
batch_count: Some(batch_count),
|
||||
batch_count,
|
||||
start_frame_url: None,
|
||||
end_frame_url: None,
|
||||
reference_image_urls: None,
|
||||
|
||||
+13
-12
@@ -1,7 +1,4 @@
|
||||
use seedance2pro_client::generate::video::generate_seedance_2p0_fast::{
|
||||
GenerateSeedance2p0FastRequest, KinoviSeedance2p0FastAspectRatio,
|
||||
KinoviSeedance2p0FastBatchCount, KinoviSeedance2p0FastOutputResolution,
|
||||
};
|
||||
use seedance2pro_client::requests::generate_video::generate_video::{KinoviAspectRatio, KinoviBatchCount, KinoviGenerateVideoRequest, KinoviModelType, KinoviOutputResolution};
|
||||
|
||||
use crate::api::audio_list_ref::AudioListRef;
|
||||
use crate::api::character_list_ref::CharacterListRef;
|
||||
@@ -19,12 +16,15 @@ use crate::generate::generate_video_v2::video_generation_draft_context::VideoGen
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct KinoviSeedance2p0FastDraftState {
|
||||
pub prompt: String,
|
||||
pub aspect_ratio: KinoviSeedance2p0FastAspectRatio,
|
||||
pub resolution: Option<KinoviSeedance2p0FastOutputResolution>,
|
||||
pub duration_seconds: u8,
|
||||
pub batch_count: KinoviSeedance2p0FastBatchCount,
|
||||
// Materialized / finalized types
|
||||
|
||||
pub prompt: String,
|
||||
pub aspect_ratio: KinoviAspectRatio,
|
||||
pub resolution: Option<KinoviOutputResolution>,
|
||||
pub duration_seconds: u8,
|
||||
pub batch_count: KinoviBatchCount,
|
||||
|
||||
// Pending types that need to be queried.
|
||||
pub unhandled_request_state: Option<KinoviSeedance2p0FastRemainingItems>,
|
||||
}
|
||||
|
||||
@@ -77,12 +77,13 @@ impl KinoviSeedance2p0FastDraftState {
|
||||
)?;
|
||||
}
|
||||
|
||||
let request = GenerateSeedance2p0FastRequest {
|
||||
let request = KinoviGenerateVideoRequest {
|
||||
model_type: KinoviModelType::Seedance2Fast,
|
||||
prompt: self.prompt.clone(),
|
||||
aspect_ratio: Some(self.aspect_ratio),
|
||||
aspect_ratio: self.aspect_ratio,
|
||||
output_resolution: self.resolution,
|
||||
duration_seconds: self.duration_seconds,
|
||||
batch_count: Some(self.batch_count),
|
||||
batch_count: self.batch_count,
|
||||
start_frame_url,
|
||||
end_frame_url,
|
||||
reference_image_urls,
|
||||
|
||||
+233
-10
@@ -1,31 +1,28 @@
|
||||
use seedance2pro_client::generate::video::generate_seedance_2p0_fast::{
|
||||
generate_seedance_2p0_fast, GenerateSeedance2p0FastArgs, GenerateSeedance2p0FastRequest,
|
||||
};
|
||||
|
||||
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::errors::provider_error::ProviderError;
|
||||
use crate::generate::generate_video::generate_video_response::{GenerateVideoResponse, Seedance2proVideoResponsePayload};
|
||||
use seedance2pro_client::requests::generate_video::generate_video::{generate_video, GenerateVideoArgs, KinoviGenerateVideoRequest};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct KinoviSeedance2p0FastRequestState {
|
||||
/// Final materialized request; ready to fire.
|
||||
pub request: GenerateSeedance2p0FastRequest,
|
||||
pub request: KinoviGenerateVideoRequest,
|
||||
}
|
||||
|
||||
impl KinoviSeedance2p0FastRequestState {
|
||||
pub async fn send(&self, client: &RouterSeedance2ProClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
|
||||
let session = &client.session;
|
||||
|
||||
let args = GenerateSeedance2p0FastArgs {
|
||||
let args = GenerateVideoArgs {
|
||||
session,
|
||||
host_override: None,
|
||||
request: self.request.clone(),
|
||||
request: self.request.clone(), // TODO: Yuck.
|
||||
};
|
||||
|
||||
let response = generate_seedance_2p0_fast(args)
|
||||
.await
|
||||
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Seedance2Pro(err)))?;
|
||||
let response = generate_video(args)
|
||||
.await
|
||||
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Seedance2Pro(err)))?;
|
||||
|
||||
Ok(GenerateVideoResponse::Seedance2Pro(Seedance2proVideoResponsePayload {
|
||||
order_id: response.order_id,
|
||||
@@ -35,3 +32,229 @@ impl KinoviSeedance2p0FastRequestState {
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashMap;
|
||||
|
||||
use tokens::tokens::characters::CharacterToken;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
use crate::api::character_list_ref::CharacterListRef;
|
||||
use crate::api::common_aspect_ratio::CommonAspectRatio;
|
||||
use crate::api::common_resolution::CommonResolution;
|
||||
use crate::api::common_video_model::CommonVideoModel;
|
||||
use crate::api::image_list_ref::ImageListRef;
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::provider::Provider;
|
||||
use crate::api::video_list_ref::VideoListRef;
|
||||
use crate::client::router_client::RouterClient;
|
||||
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
|
||||
use crate::generate::generate_video_v2::video_generation_draft_context::VideoGenerationDraftContext;
|
||||
use crate::generate::generate_video_v2::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
|
||||
use test_data::web::image_urls::{FOREST_BACKDROP_IMAGE_URL, JUNO_AT_LAKE_IMAGE_URL, WHITE_HOUSE_SUNSET_IMAGE_URL};
|
||||
|
||||
mod aspect_ratio_tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually run — fires a real API request and incurs cost
|
||||
async fn landscape() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("A corgi running through a field of wildflowers at sunset.".to_string()),
|
||||
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
|
||||
..seedance2pro_fast_builder()
|
||||
}).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually run — fires a real API request and incurs cost
|
||||
async fn portrait() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("A cat sitting on a windowsill watching rain.".to_string()),
|
||||
aspect_ratio: Some(CommonAspectRatio::TallNineBySixteen),
|
||||
..seedance2pro_fast_builder()
|
||||
}).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually run — fires a real API request and incurs cost
|
||||
async fn square() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("A hummingbird hovering near a flower.".to_string()),
|
||||
aspect_ratio: Some(CommonAspectRatio::Square),
|
||||
..seedance2pro_fast_builder()
|
||||
}).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
}
|
||||
|
||||
mod resolution_tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually run — fires a real API request and incurs cost
|
||||
async fn res_480p() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("A shiba inu playing in autumn leaves.".to_string()),
|
||||
resolution: Some(CommonResolution::FourEightyP),
|
||||
..seedance2pro_fast_builder()
|
||||
}).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually run — fires a real API request and incurs cost
|
||||
async fn res_720p() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("A golden retriever catching a frisbee on the beach.".to_string()),
|
||||
resolution: Some(CommonResolution::SevenTwentyP),
|
||||
..seedance2pro_fast_builder()
|
||||
}).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
}
|
||||
|
||||
mod modality_tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually run — fires a real API request and incurs cost
|
||||
async fn text_to_video() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("A whale breaching in the open ocean at dawn, cinematic.".to_string()),
|
||||
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
|
||||
..seedance2pro_fast_builder()
|
||||
}).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually run — fires a real API request and incurs cost
|
||||
async fn keyframe_start_and_end_frame() {
|
||||
let start_token = MediaFileToken::new("mf_start".to_string());
|
||||
let end_token = MediaFileToken::new("mf_end".to_string());
|
||||
|
||||
let mut media_map = HashMap::new();
|
||||
media_map.insert(start_token.clone(), JUNO_AT_LAKE_IMAGE_URL.to_string());
|
||||
media_map.insert(end_token.clone(), FOREST_BACKDROP_IMAGE_URL.to_string());
|
||||
|
||||
let response = run_pipeline_with_media_map(GenerateVideoRequestBuilder {
|
||||
prompt: Some("The dog walks from the lake to the forest.".to_string()),
|
||||
start_frame: Some(ImageRef::MediaFileToken(start_token)),
|
||||
end_frame: Some(ImageRef::MediaFileToken(end_token)),
|
||||
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
|
||||
..seedance2pro_fast_builder()
|
||||
}, media_map).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually run — fires a real API request and incurs cost
|
||||
async fn image_references() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("The dog in @2 runs through the scenery in @1 towards the building in @3.".to_string()),
|
||||
reference_images: Some(ImageListRef::Urls(vec![
|
||||
FOREST_BACKDROP_IMAGE_URL.to_string(),
|
||||
JUNO_AT_LAKE_IMAGE_URL.to_string(),
|
||||
WHITE_HOUSE_SUNSET_IMAGE_URL.to_string(),
|
||||
])),
|
||||
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
|
||||
..seedance2pro_fast_builder()
|
||||
}).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
fn seedance2pro_fast_builder() -> GenerateVideoRequestBuilder {
|
||||
GenerateVideoRequestBuilder {
|
||||
model: CommonVideoModel::Seedance2p0Fast,
|
||||
provider: Provider::Seedance2Pro,
|
||||
duration_seconds: Some(4),
|
||||
video_batch_count: Some(1),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn get_seedance2pro_client() -> RouterClient {
|
||||
let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/seedance2pro_cookies.txt")
|
||||
.expect("Failed to read seedance2pro cookies");
|
||||
let session = Seedance2ProSession::from_cookies_string(cookies.trim().to_string());
|
||||
RouterClient::Seedance2Pro(RouterSeedance2ProClient::new(session))
|
||||
}
|
||||
|
||||
/// Run the full pipeline: builder -> build2 -> finalize -> send.
|
||||
async fn run_pipeline(builder: GenerateVideoRequestBuilder) -> GenerateVideoResponse {
|
||||
let client = get_seedance2pro_client();
|
||||
|
||||
let draft_or_request = builder.build2().expect("build2 should succeed");
|
||||
let draft = match draft_or_request {
|
||||
VideoGenerationDraftOrRequest::Draft(d) => d,
|
||||
_ => panic!("expected Draft variant"),
|
||||
};
|
||||
|
||||
let draft_context = VideoGenerationDraftContext {
|
||||
client: Some(&client),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let request = draft.finalize(draft_context).await.expect("finalize should succeed");
|
||||
let response = request.send_request(&client).await.expect("send_request should succeed");
|
||||
|
||||
match &response {
|
||||
GenerateVideoResponse::Seedance2Pro(p) => {
|
||||
println!("task_id={}, order_id={}", p.task_id, p.order_id);
|
||||
}
|
||||
other => println!("response: {:?}", other),
|
||||
}
|
||||
|
||||
response
|
||||
}
|
||||
|
||||
/// Run the full pipeline with a media file token map for resolving references.
|
||||
async fn run_pipeline_with_media_map(
|
||||
builder: GenerateVideoRequestBuilder,
|
||||
media_map: HashMap<MediaFileToken, String>,
|
||||
) -> GenerateVideoResponse {
|
||||
let client = get_seedance2pro_client();
|
||||
|
||||
let draft_or_request = builder.build2().expect("build2 should succeed");
|
||||
let draft = match draft_or_request {
|
||||
VideoGenerationDraftOrRequest::Draft(d) => d,
|
||||
_ => panic!("expected Draft variant"),
|
||||
};
|
||||
|
||||
let draft_context = VideoGenerationDraftContext {
|
||||
client: Some(&client),
|
||||
media_file_to_artcraft_url_map: Some(&media_map),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let request = draft.finalize(draft_context).await.expect("finalize should succeed");
|
||||
let response = request.send_request(&client).await.expect("send_request should succeed");
|
||||
|
||||
match &response {
|
||||
GenerateVideoResponse::Seedance2Pro(p) => {
|
||||
println!("task_id={}, order_id={}", p.task_id, p.order_id);
|
||||
}
|
||||
other => println!("response: {:?}", other),
|
||||
}
|
||||
|
||||
response
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +38,6 @@ pub struct GenerateSeedance2p0Request {
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum KinoviSeedance2p0AspectRatio {
|
||||
Landscape16x9,
|
||||
UltraWide21x9,
|
||||
Portrait9x16,
|
||||
Square1x1,
|
||||
Standard4x3,
|
||||
@@ -156,7 +155,6 @@ pub async fn generate_seedance_2p0(
|
||||
fn map_aspect_ratio(ar: Option<KinoviSeedance2p0AspectRatio>) -> KinoviAspectRatioRaw {
|
||||
match ar {
|
||||
Some(KinoviSeedance2p0AspectRatio::Landscape16x9) => KinoviAspectRatioRaw::Landscape16x9,
|
||||
Some(KinoviSeedance2p0AspectRatio::UltraWide21x9) => KinoviAspectRatioRaw::UltraWide21x9,
|
||||
Some(KinoviSeedance2p0AspectRatio::Portrait9x16) => KinoviAspectRatioRaw::Portrait9x16,
|
||||
Some(KinoviSeedance2p0AspectRatio::Square1x1) => KinoviAspectRatioRaw::Square1x1,
|
||||
Some(KinoviSeedance2p0AspectRatio::Standard4x3) => KinoviAspectRatioRaw::Landscape4x3,
|
||||
@@ -432,7 +430,6 @@ mod tests {
|
||||
|
||||
let ratios = [
|
||||
KinoviSeedance2p0AspectRatio::Landscape16x9,
|
||||
KinoviSeedance2p0AspectRatio::UltraWide21x9,
|
||||
KinoviSeedance2p0AspectRatio::Portrait9x16,
|
||||
KinoviSeedance2p0AspectRatio::Square1x1,
|
||||
KinoviSeedance2p0AspectRatio::Standard4x3,
|
||||
@@ -531,70 +528,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
mod ultra_wide {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_text_to_video_21x9() -> AnyhowResult<()> {
|
||||
setup_test_logging(LevelFilter::Trace);
|
||||
let session = test_session()?;
|
||||
let result = generate_seedance_2p0(GenerateSeedance2p0Args {
|
||||
session: &session,
|
||||
host_override: None,
|
||||
request: GenerateSeedance2p0Request {
|
||||
prompt: "A corgi is riding on the back of a sauropod dinosaur".to_string(),
|
||||
aspect_ratio: Some(KinoviSeedance2p0AspectRatio::UltraWide21x9),
|
||||
output_resolution: None,
|
||||
batch_count: None,
|
||||
duration_seconds: 5,
|
||||
start_frame_url: None,
|
||||
end_frame_url: None,
|
||||
reference_image_urls: None,
|
||||
reference_video_urls: None,
|
||||
reference_audio_urls: None,
|
||||
character_ids: None,
|
||||
use_face_blur_hack: None,
|
||||
},
|
||||
}).await?;
|
||||
println!("t2v 21:9 — task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
assert!(!result.task_id.is_empty());
|
||||
assert_eq!(1, 2);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_keyframe_21x9() -> AnyhowResult<()> {
|
||||
setup_test_logging(LevelFilter::Trace);
|
||||
let session = test_session()?;
|
||||
let start_frame_url = upload_test_image(&session, test_data::web::image_urls::WIDE_CORGI_SHIBA_TREASURE_OCEAN_URL).await?;
|
||||
|
||||
let result = generate_seedance_2p0(GenerateSeedance2p0Args {
|
||||
session: &session,
|
||||
host_override: None,
|
||||
request: GenerateSeedance2p0Request {
|
||||
prompt: "The dogs sail across the ocean on a treasure ship.".to_string(),
|
||||
aspect_ratio: Some(KinoviSeedance2p0AspectRatio::UltraWide21x9),
|
||||
output_resolution: None,
|
||||
batch_count: None,
|
||||
duration_seconds: 5,
|
||||
start_frame_url: Some(start_frame_url),
|
||||
end_frame_url: None,
|
||||
reference_image_urls: None,
|
||||
reference_video_urls: None,
|
||||
reference_audio_urls: None,
|
||||
character_ids: None,
|
||||
use_face_blur_hack: None,
|
||||
},
|
||||
}).await?;
|
||||
println!("keyframe 21:9 — task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
assert!(!result.task_id.is_empty());
|
||||
assert_eq!(1, 2);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
mod keyframe {
|
||||
use super::*;
|
||||
|
||||
|
||||
-67
@@ -38,7 +38,6 @@ pub struct GenerateSeedance2p0FastRequest {
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum KinoviSeedance2p0FastAspectRatio {
|
||||
Landscape16x9,
|
||||
UltraWide21x9,
|
||||
Portrait9x16,
|
||||
Square1x1,
|
||||
Standard4x3,
|
||||
@@ -153,7 +152,6 @@ pub async fn generate_seedance_2p0_fast(
|
||||
fn map_aspect_ratio(ar: Option<KinoviSeedance2p0FastAspectRatio>) -> KinoviAspectRatioRaw {
|
||||
match ar {
|
||||
Some(KinoviSeedance2p0FastAspectRatio::Landscape16x9) => KinoviAspectRatioRaw::Landscape16x9,
|
||||
Some(KinoviSeedance2p0FastAspectRatio::UltraWide21x9) => KinoviAspectRatioRaw::UltraWide21x9,
|
||||
Some(KinoviSeedance2p0FastAspectRatio::Portrait9x16) => KinoviAspectRatioRaw::Portrait9x16,
|
||||
Some(KinoviSeedance2p0FastAspectRatio::Square1x1) => KinoviAspectRatioRaw::Square1x1,
|
||||
Some(KinoviSeedance2p0FastAspectRatio::Standard4x3) => KinoviAspectRatioRaw::Landscape4x3,
|
||||
@@ -384,7 +382,6 @@ mod tests {
|
||||
|
||||
let ratios = [
|
||||
KinoviSeedance2p0FastAspectRatio::Landscape16x9,
|
||||
KinoviSeedance2p0FastAspectRatio::UltraWide21x9,
|
||||
KinoviSeedance2p0FastAspectRatio::Portrait9x16,
|
||||
KinoviSeedance2p0FastAspectRatio::Square1x1,
|
||||
KinoviSeedance2p0FastAspectRatio::Standard4x3,
|
||||
@@ -483,70 +480,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
mod ultra_wide {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_text_to_video_21x9() -> AnyhowResult<()> {
|
||||
setup_test_logging(LevelFilter::Trace);
|
||||
let session = test_session()?;
|
||||
let result = generate_seedance_2p0_fast(GenerateSeedance2p0FastArgs {
|
||||
session: &session,
|
||||
host_override: None,
|
||||
request: GenerateSeedance2p0FastRequest {
|
||||
prompt: "A shiba is riding on the back of a sauropod dinosaur".to_string(),
|
||||
aspect_ratio: Some(KinoviSeedance2p0FastAspectRatio::UltraWide21x9),
|
||||
output_resolution: Some(KinoviSeedance2p0FastOutputResolution::FourEightyP),
|
||||
batch_count: None,
|
||||
duration_seconds: 5,
|
||||
start_frame_url: None,
|
||||
end_frame_url: None,
|
||||
reference_image_urls: None,
|
||||
reference_video_urls: None,
|
||||
reference_audio_urls: None,
|
||||
character_ids: None,
|
||||
use_face_blur_hack: None,
|
||||
},
|
||||
}).await?;
|
||||
println!("fast t2v 21:9 480p — task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
assert!(!result.task_id.is_empty());
|
||||
assert_eq!(1, 2);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_keyframe_21x9() -> AnyhowResult<()> {
|
||||
setup_test_logging(LevelFilter::Trace);
|
||||
let session = test_session()?;
|
||||
let start_frame_url = upload_test_image(&session, test_data::web::image_urls::WIDE_CORGI_SHIBA_TREASURE_OCEAN_URL).await?;
|
||||
|
||||
let result = generate_seedance_2p0_fast(GenerateSeedance2p0FastArgs {
|
||||
session: &session,
|
||||
host_override: None,
|
||||
request: GenerateSeedance2p0FastRequest {
|
||||
prompt: "The dogs in @1 set sail across the ocean on a treasure ship.".to_string(),
|
||||
aspect_ratio: Some(KinoviSeedance2p0FastAspectRatio::UltraWide21x9),
|
||||
output_resolution: None,
|
||||
batch_count: None,
|
||||
duration_seconds: 5,
|
||||
start_frame_url: None,
|
||||
end_frame_url: None,
|
||||
reference_image_urls: None,
|
||||
reference_video_urls: Some(vec![start_frame_url]),
|
||||
reference_audio_urls: None,
|
||||
character_ids: None,
|
||||
use_face_blur_hack: None,
|
||||
},
|
||||
}).await?;
|
||||
println!("fast keyframe 21:9 — task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
assert!(!result.task_id.is_empty());
|
||||
assert_eq!(1, 2);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
mod keyframe {
|
||||
use super::*;
|
||||
|
||||
|
||||
@@ -172,8 +172,6 @@ impl KinoviGenerateVideoRequest {
|
||||
pub enum KinoviAspectRatio {
|
||||
/// 16:9 landscape (1280x720)
|
||||
Landscape16x9,
|
||||
/// 21:9 ultra-wide (1280x540)
|
||||
UltraWide21x9,
|
||||
/// 9:16 portrait (720x1280)
|
||||
Portrait9x16,
|
||||
/// 1:1 square (720x720)
|
||||
@@ -188,7 +186,6 @@ impl KinoviAspectRatio {
|
||||
fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Landscape16x9 => "1280x720",
|
||||
Self::UltraWide21x9 => "1280x540",
|
||||
Self::Portrait9x16 => "720x1280",
|
||||
Self::Square1x1 => "720x720",
|
||||
Self::Standard4x3 => "960x720",
|
||||
|
||||
-3
@@ -181,8 +181,6 @@ impl WorkflowRunTaskRequest {
|
||||
pub enum KinoviAspectRatioRaw {
|
||||
/// 16:9 landscape (1280x720)
|
||||
Landscape16x9,
|
||||
/// 21:9 ultra-wide (1280x540)
|
||||
UltraWide21x9,
|
||||
/// 9:16 portrait (720x1280)
|
||||
Portrait9x16,
|
||||
/// 1:1 square (720x720)
|
||||
@@ -197,7 +195,6 @@ impl KinoviAspectRatioRaw {
|
||||
fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Landscape16x9 => "1280x720",
|
||||
Self::UltraWide21x9 => "1280x540",
|
||||
Self::Portrait9x16 => "720x1280",
|
||||
Self::Square1x1 => "720x720",
|
||||
Self::Landscape4x3 => "960x720",
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
# support-tool
|
||||
|
||||
Production support CLI for debugging and manual operations against external services.
|
||||
|
||||
## Building & Running
|
||||
|
||||
```bash
|
||||
cargo build -p support-tool
|
||||
cargo run -p support-tool -- seedance2pro generate_video --prompt "A corgi at the lake"
|
||||
```
|
||||
|
||||
## Environment
|
||||
|
||||
Requires a `.env-support-tool-secrets` file (or env vars) with:
|
||||
|
||||
- `SEEDANCE2PRO_COOKIES` — session cookies for Kinovi/Seedance2Pro API auth
|
||||
|
||||
## Architecture
|
||||
|
||||
- Entry point: `src/main.rs` → normalizes args (underscores optional) → dispatches
|
||||
- Top-level commands in `src/commands/run.rs` (`TopLevelCommand` enum)
|
||||
- Each top-level command has its own module with `dispatch.rs`, `state.rs`, `subcommands/`
|
||||
- Subcommands use `#[derive(Args)]` for their argument structs
|
||||
- All subcommand `run()` functions are `async fn` returning `anyhow::Result<()>`
|
||||
|
||||
## Adding a New Subcommand (to seedance2pro)
|
||||
|
||||
1. Create `src/commands/seedance2pro/subcommands/my_command.rs`
|
||||
2. Add `pub mod my_command;` to `subcommands/mod.rs`
|
||||
3. Add the name to `SUBCOMMAND_NAMES` in `dispatch.rs`
|
||||
4. Add a variant to `Seedance2proCommand` enum in `dispatch.rs`
|
||||
5. Add a match arm in `dispatch.rs::run()`
|
||||
|
||||
## Conventions
|
||||
|
||||
- Use `log::info!()` for status output, never `println!` (except for final results)
|
||||
- Use `anyhow::anyhow!()` for ad-hoc errors
|
||||
- Get `Seedance2ProSession` from `state.cookies` via `from_cookies_string()`
|
||||
- External crates used: `artcraft_client` (media file download), `seedance2pro_client` (video gen)
|
||||
|
||||
## Subcommands
|
||||
|
||||
- `seedance2pro find_job --token <order_id>` — search for a job across all pages
|
||||
- `seedance2pro failed_job_histogram` — histogram of failure reasons
|
||||
- `seedance2pro generate_video --prompt <text_or_file> [--model seedance2p0] [--reference_media_tokens "token1,token2"] [--localhost] [--download_path /tmp/media_files]`
|
||||
@@ -11,10 +11,8 @@ path = "src/main.rs"
|
||||
easyenv.workspace = true
|
||||
|
||||
# Internal
|
||||
artcraft_client.workspace = true
|
||||
errors.workspace = true
|
||||
seedance2pro_client.workspace = true
|
||||
tokens.workspace = true
|
||||
|
||||
# External
|
||||
anyhow.workspace = true
|
||||
|
||||
@@ -6,11 +6,7 @@ use super::subcommands;
|
||||
|
||||
/// All canonical subcommand names for this module.
|
||||
/// Used by the underscore-insensitive arg normalizer.
|
||||
pub const SUBCOMMAND_NAMES: &[&str] = &[
|
||||
"failed_job_histogram",
|
||||
"find_job",
|
||||
"generate_video",
|
||||
];
|
||||
pub const SUBCOMMAND_NAMES: &[&str] = &["find_job", "failed_job_histogram"];
|
||||
|
||||
#[derive(Subcommand)]
|
||||
#[command(rename_all = "snake_case")]
|
||||
@@ -20,9 +16,6 @@ pub enum Seedance2proCommand {
|
||||
|
||||
/// Scan all jobs and print a histogram of failure reasons
|
||||
FailedJobHistogram,
|
||||
|
||||
/// Generate a video via Seedance2Pro/Kinovi directly
|
||||
GenerateVideo(subcommands::generate_video::GenerateVideoArgs),
|
||||
}
|
||||
|
||||
pub async fn run(command: Seedance2proCommand) -> anyhow::Result<()> {
|
||||
@@ -34,6 +27,5 @@ pub async fn run(command: Seedance2proCommand) -> anyhow::Result<()> {
|
||||
match command {
|
||||
Seedance2proCommand::FindJob(args) => subcommands::find_job::run(&state, args).await,
|
||||
Seedance2proCommand::FailedJobHistogram => subcommands::failed_job_histogram::run(&state).await,
|
||||
Seedance2proCommand::GenerateVideo(args) => subcommands::generate_video::run(&state, args).await,
|
||||
}
|
||||
}
|
||||
|
||||
-475
@@ -1,475 +0,0 @@
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::anyhow;
|
||||
use clap::Args;
|
||||
use log::info;
|
||||
|
||||
use artcraft_client::recipes::download_media_file::{
|
||||
download_media_file, DownloadMediaFileArgs, DownloadPath,
|
||||
};
|
||||
use artcraft_client::utils::api_host::ApiHost;
|
||||
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
|
||||
use seedance2pro_client::generate::video::generate_happy_horse_1p0::{
|
||||
generate_happy_horse_1p0, GenerateHappyHorse1p0Args, GenerateHappyHorse1p0Request,
|
||||
};
|
||||
use seedance2pro_client::generate::video::generate_seedance_2p0::{
|
||||
generate_seedance_2p0, GenerateSeedance2p0Args, GenerateSeedance2p0Request,
|
||||
};
|
||||
use seedance2pro_client::generate::video::generate_seedance_2p0_fast::{
|
||||
generate_seedance_2p0_fast, GenerateSeedance2p0FastArgs, GenerateSeedance2p0FastRequest,
|
||||
};
|
||||
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 super::super::state::Seedance2ProState;
|
||||
|
||||
const DEFAULT_DOWNLOAD_PATH: &str = "/tmp/media_files";
|
||||
|
||||
// ── Args ──
|
||||
|
||||
#[derive(Args)]
|
||||
#[command(
|
||||
override_usage = "support-tool seedance2pro generate_video [OPTIONS] --prompt <PROMPT>",
|
||||
after_help = "\
|
||||
EXAMPLES:
|
||||
support-tool seedance2pro generate_video --prompt \"A corgi at the lake\"
|
||||
support-tool seedance2pro generate_video --prompt prompt.txt --model happyhorse
|
||||
support-tool seedance2pro generate_video --prompt \"Dancing\" --start_frame_media_token mf_abc123
|
||||
support-tool seedance2pro generate_video --prompt \"Cat\" --image_reference_tokens \"mf_abc,mf_def\"
|
||||
",
|
||||
)]
|
||||
pub struct GenerateVideoArgs {
|
||||
/// A prompt string or path to a file containing the prompt (.txt or .md).
|
||||
#[arg(long)]
|
||||
pub prompt: String,
|
||||
|
||||
/// A single media token for the start frame image.
|
||||
#[arg(long)]
|
||||
pub start_frame_media_token: Option<String>,
|
||||
|
||||
/// A single media token for the end frame image.
|
||||
#[arg(long)]
|
||||
pub end_frame_media_token: Option<String>,
|
||||
|
||||
/// Comma or space separated media tokens for image references.
|
||||
#[arg(long)]
|
||||
pub image_reference_tokens: Option<String>,
|
||||
|
||||
/// Comma or space separated media tokens for video references.
|
||||
#[arg(long)]
|
||||
pub video_reference_tokens: Option<String>,
|
||||
|
||||
/// Comma or space separated media tokens for audio references.
|
||||
#[arg(long)]
|
||||
pub audio_reference_tokens: Option<String>,
|
||||
|
||||
/// Use localhost:12345 instead of production API for artcraft_client lookups.
|
||||
#[arg(long)]
|
||||
pub localhost: bool,
|
||||
|
||||
/// Directory to cache downloaded media files. [default: /tmp/media_files]
|
||||
#[arg(long)]
|
||||
pub download_path: Option<String>,
|
||||
|
||||
/// Model: seedance2p0, seedance2p0fast, happyhorse (and underscore variants).
|
||||
/// [default: seedance2p0]
|
||||
#[arg(long)]
|
||||
pub model: Option<String>,
|
||||
}
|
||||
|
||||
// ── Model enum ──
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum VideoModel {
|
||||
Seedance2p0,
|
||||
Seedance2p0Fast,
|
||||
HappyHorse,
|
||||
}
|
||||
|
||||
fn parse_model(value: &str) -> anyhow::Result<VideoModel> {
|
||||
match value.to_lowercase().as_str() {
|
||||
"seedance2" | "seedance2p0" | "seedance_2p0" => Ok(VideoModel::Seedance2p0),
|
||||
"seedance2fast" | "seedance2p0fast" | "seedance_2p0_fast" => Ok(VideoModel::Seedance2p0Fast),
|
||||
"happyhorse" | "happy_horse" => Ok(VideoModel::HappyHorse),
|
||||
other => Err(anyhow!(
|
||||
"Unknown model '{}'. Valid: seedance2, seedance2p0, seedance_2p0, \
|
||||
seedance2fast, seedance2p0fast, seedance_2p0_fast, happyhorse, happy_horse",
|
||||
other
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Resolved media inputs ──
|
||||
|
||||
struct ResolvedMediaInputs {
|
||||
start_frame_url: Option<String>,
|
||||
end_frame_url: Option<String>,
|
||||
reference_image_urls: Option<Vec<String>>,
|
||||
reference_video_urls: Option<Vec<String>>,
|
||||
reference_audio_urls: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
// ── Entry point ──
|
||||
|
||||
pub async fn run(state: &Seedance2ProState, args: GenerateVideoArgs) -> anyhow::Result<()> {
|
||||
let session = Seedance2ProSession::from_cookies_string(state.cookies.clone());
|
||||
let model = parse_model(args.model.as_deref().unwrap_or("seedance2p0"))?;
|
||||
let prompt = resolve_prompt(&args.prompt)?;
|
||||
let api_host = if args.localhost { ApiHost::Localhost { port: 12345 } } else { ApiHost::Storyteller };
|
||||
|
||||
info!("Model: {:?}", model);
|
||||
info!("Prompt: {:?}", prompt);
|
||||
|
||||
// Resolve download path.
|
||||
let download_dir = resolve_download_dir(args.download_path.as_deref())?;
|
||||
info!("Download cache directory: {:?}", download_dir);
|
||||
|
||||
// Parse media tokens.
|
||||
let start_frame_token = args.start_frame_media_token.as_deref()
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| MediaFileToken::new_from_str(s));
|
||||
|
||||
let end_frame_token = args.end_frame_media_token.as_deref()
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| MediaFileToken::new_from_str(s));
|
||||
|
||||
let image_ref_tokens = parse_media_tokens(args.image_reference_tokens.as_deref());
|
||||
let video_ref_tokens = parse_media_tokens(args.video_reference_tokens.as_deref());
|
||||
let audio_ref_tokens = parse_media_tokens(args.audio_reference_tokens.as_deref());
|
||||
|
||||
// Collect all tokens that need downloading.
|
||||
let mut all_tokens: Vec<MediaFileToken> = Vec::new();
|
||||
if let Some(t) = &start_frame_token { all_tokens.push(t.clone()); }
|
||||
if let Some(t) = &end_frame_token { all_tokens.push(t.clone()); }
|
||||
all_tokens.extend(image_ref_tokens.iter().cloned());
|
||||
all_tokens.extend(video_ref_tokens.iter().cloned());
|
||||
all_tokens.extend(audio_ref_tokens.iter().cloned());
|
||||
|
||||
info!("Total media tokens to process: {}", all_tokens.len());
|
||||
|
||||
// Download media files (with caching).
|
||||
let downloaded_files = download_media_files(&all_tokens, &api_host, &download_dir).await?;
|
||||
|
||||
// Upload media files to Kinovi/Seedance2Pro.
|
||||
let uploaded_urls = upload_media_files_to_kinovi(&session, &downloaded_files).await?;
|
||||
|
||||
// Map uploaded URLs back to their roles.
|
||||
let mut url_iter = uploaded_urls.into_iter();
|
||||
|
||||
let start_frame_url = if start_frame_token.is_some() { url_iter.next() } else { None };
|
||||
let end_frame_url = if end_frame_token.is_some() { url_iter.next() } else { None };
|
||||
|
||||
let reference_image_urls: Vec<String> = url_iter.by_ref().take(image_ref_tokens.len()).collect();
|
||||
let reference_video_urls: Vec<String> = url_iter.by_ref().take(video_ref_tokens.len()).collect();
|
||||
let reference_audio_urls: Vec<String> = url_iter.by_ref().take(audio_ref_tokens.len()).collect();
|
||||
|
||||
let media_inputs = ResolvedMediaInputs {
|
||||
start_frame_url,
|
||||
end_frame_url,
|
||||
reference_image_urls: if reference_image_urls.is_empty() { None } else { Some(reference_image_urls) },
|
||||
reference_video_urls: if reference_video_urls.is_empty() { None } else { Some(reference_video_urls) },
|
||||
reference_audio_urls: if reference_audio_urls.is_empty() { None } else { Some(reference_audio_urls) },
|
||||
};
|
||||
|
||||
// Generate video.
|
||||
generate(model, &session, &prompt, media_inputs).await
|
||||
}
|
||||
|
||||
// ── Prompt resolution ──
|
||||
|
||||
fn resolve_prompt(input: &str) -> anyhow::Result<String> {
|
||||
let looks_like_file = input.ends_with(".txt") || input.ends_with(".md");
|
||||
|
||||
if looks_like_file {
|
||||
let path = Path::new(input);
|
||||
if !path.exists() {
|
||||
return Err(anyhow!("Prompt file does not exist: {:?}", path));
|
||||
}
|
||||
let content = fs::read_to_string(path)
|
||||
.map_err(|err| anyhow!("Failed to read prompt file {:?}: {}", path, err))?;
|
||||
let trimmed = content.trim().to_string();
|
||||
info!("Read prompt from file {:?} ({} chars)", path, trimmed.len());
|
||||
Ok(trimmed)
|
||||
} else {
|
||||
info!("Using prompt string directly ({} chars)", input.len());
|
||||
Ok(input.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
// ── Download path resolution ──
|
||||
|
||||
fn resolve_download_dir(maybe_path: Option<&str>) -> anyhow::Result<PathBuf> {
|
||||
let dir = match maybe_path {
|
||||
Some(path) => {
|
||||
let p = PathBuf::from(path);
|
||||
if p.exists() && !p.is_dir() {
|
||||
return Err(anyhow!("--download_path {:?} exists but is not a directory", p));
|
||||
}
|
||||
p
|
||||
}
|
||||
None => PathBuf::from(DEFAULT_DOWNLOAD_PATH),
|
||||
};
|
||||
|
||||
if !dir.exists() {
|
||||
info!("Creating download directory: {:?}", dir);
|
||||
fs::create_dir_all(&dir)
|
||||
.map_err(|err| anyhow!("Failed to create download directory {:?}: {}", dir, err))?;
|
||||
}
|
||||
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
// ── Media token parsing ──
|
||||
|
||||
fn parse_media_tokens(input: Option<&str>) -> Vec<MediaFileToken> {
|
||||
let input = match input {
|
||||
Some(s) if !s.trim().is_empty() => s,
|
||||
_ => return Vec::new(),
|
||||
};
|
||||
|
||||
input
|
||||
.split(|c: char| c == ',' || c.is_whitespace())
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| MediaFileToken::new_from_str(s))
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ── Download media files with caching ──
|
||||
|
||||
struct DownloadedFile {
|
||||
path: PathBuf,
|
||||
extension: String,
|
||||
}
|
||||
|
||||
async fn download_media_files(
|
||||
tokens: &[MediaFileToken],
|
||||
api_host: &ApiHost,
|
||||
download_dir: &Path,
|
||||
) -> anyhow::Result<Vec<DownloadedFile>> {
|
||||
let mut results = Vec::new();
|
||||
|
||||
for token in tokens {
|
||||
// Check if file already exists in cache.
|
||||
let maybe_cached = find_cached_file(download_dir, token);
|
||||
|
||||
if let Some(cached) = maybe_cached {
|
||||
info!(
|
||||
"Cache hit: {} already exists at {:?} ({} bytes)",
|
||||
token.as_str(),
|
||||
cached.path,
|
||||
fs::metadata(&cached.path).map(|m| m.len()).unwrap_or(0),
|
||||
);
|
||||
results.push(cached);
|
||||
continue;
|
||||
}
|
||||
|
||||
info!("Downloading {} from API...", token.as_str());
|
||||
|
||||
let result = download_media_file(DownloadMediaFileArgs {
|
||||
media_token: token,
|
||||
api_host,
|
||||
download_path: DownloadPath::Directory(download_dir),
|
||||
}).await.map_err(|err| anyhow!("Failed to download {}: {}", token.as_str(), err))?;
|
||||
|
||||
let extension = result.downloaded_file_path
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.unwrap_or("bin")
|
||||
.to_string();
|
||||
|
||||
info!(
|
||||
"Downloaded: {} -> {:?} ({} bytes, class: {})",
|
||||
token.as_str(),
|
||||
result.downloaded_file_path,
|
||||
result.filesize_bytes,
|
||||
result.media_file_response.media_file.media_class.to_str(),
|
||||
);
|
||||
|
||||
results.push(DownloadedFile {
|
||||
path: result.downloaded_file_path,
|
||||
extension,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// Look for an existing cached file matching the token (any extension).
|
||||
fn find_cached_file(download_dir: &Path, token: &MediaFileToken) -> Option<DownloadedFile> {
|
||||
let prefix = format!("{}.", token.as_str());
|
||||
|
||||
let entries = fs::read_dir(download_dir).ok()?;
|
||||
for entry in entries.flatten() {
|
||||
let filename = entry.file_name();
|
||||
let filename_str = filename.to_string_lossy();
|
||||
if filename_str.starts_with(&prefix) {
|
||||
let path = entry.path();
|
||||
// Skip zero-byte files — treat as incomplete downloads.
|
||||
let metadata = fs::metadata(&path).ok()?;
|
||||
if metadata.len() == 0 {
|
||||
continue;
|
||||
}
|
||||
let extension = path.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.unwrap_or("bin")
|
||||
.to_string();
|
||||
return Some(DownloadedFile { path, extension });
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
// ── Upload to Kinovi/Seedance2Pro ──
|
||||
|
||||
async fn upload_media_files_to_kinovi(
|
||||
session: &Seedance2ProSession,
|
||||
files: &[DownloadedFile],
|
||||
) -> anyhow::Result<Vec<String>> {
|
||||
let mut urls = Vec::new();
|
||||
|
||||
for file in files {
|
||||
info!("Uploading {:?} to Kinovi (extension: {})...", file.path, file.extension);
|
||||
|
||||
// 1. Prepare upload (get signed URL).
|
||||
let prepare_result = prepare_file_upload(PrepareFileUploadArgs {
|
||||
session,
|
||||
extension: file.extension.clone(),
|
||||
host_override: None,
|
||||
}).await.map_err(|err| anyhow!("Failed to prepare upload for {:?}: {:?}", file.path, err))?;
|
||||
|
||||
// 2. Read file bytes.
|
||||
let file_bytes = fs::read(&file.path)
|
||||
.map_err(|err| anyhow!("Failed to read {:?}: {}", file.path, err))?;
|
||||
|
||||
info!("Uploading {} bytes to signed URL...", file_bytes.len());
|
||||
|
||||
// 3. Upload to Cloudflare R2.
|
||||
let upload_result = upload_file(UploadFileArgs {
|
||||
upload_url: prepare_result.upload_url,
|
||||
file_bytes,
|
||||
host_override: None,
|
||||
}).await.map_err(|err| anyhow!("Failed to upload {:?}: {:?}", file.path, err))?;
|
||||
|
||||
info!("Uploaded: {:?} -> {}", file.path, upload_result.public_url);
|
||||
urls.push(upload_result.public_url);
|
||||
}
|
||||
|
||||
Ok(urls)
|
||||
}
|
||||
|
||||
// ── Generate video ──
|
||||
|
||||
async fn generate(
|
||||
model: VideoModel,
|
||||
session: &Seedance2ProSession,
|
||||
prompt: &str,
|
||||
media: ResolvedMediaInputs,
|
||||
) -> anyhow::Result<()> {
|
||||
match model {
|
||||
VideoModel::HappyHorse => {
|
||||
// Happy Horse only supports start_frame_url.
|
||||
if media.end_frame_url.is_some() {
|
||||
return Err(anyhow!("Happy Horse does not support --end_frame_media_token"));
|
||||
}
|
||||
if media.reference_image_urls.is_some() {
|
||||
return Err(anyhow!("Happy Horse does not support --image_reference_tokens"));
|
||||
}
|
||||
if media.reference_video_urls.is_some() {
|
||||
return Err(anyhow!("Happy Horse does not support --video_reference_tokens"));
|
||||
}
|
||||
if media.reference_audio_urls.is_some() {
|
||||
return Err(anyhow!("Happy Horse does not support --audio_reference_tokens"));
|
||||
}
|
||||
|
||||
info!("Generating video with Happy Horse 1.0...");
|
||||
let result = generate_happy_horse_1p0(GenerateHappyHorse1p0Args {
|
||||
request: GenerateHappyHorse1p0Request {
|
||||
prompt: prompt.to_string(),
|
||||
aspect_ratio: None,
|
||||
output_resolution: None,
|
||||
batch_count: None,
|
||||
duration_seconds: 5,
|
||||
start_frame_url: media.start_frame_url,
|
||||
},
|
||||
session,
|
||||
host_override: None,
|
||||
}).await.map_err(|err| anyhow!("Happy Horse generation failed: {:?}", err))?;
|
||||
|
||||
info!("Success! task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
if let Some(task_ids) = &result.task_ids {
|
||||
info!("All task_ids: {:?}", task_ids);
|
||||
}
|
||||
if let Some(order_ids) = &result.order_ids {
|
||||
info!("All order_ids: {:?}", order_ids);
|
||||
}
|
||||
}
|
||||
VideoModel::Seedance2p0 => {
|
||||
info!("Generating video with Seedance 2.0 Pro...");
|
||||
let result = generate_seedance_2p0(GenerateSeedance2p0Args {
|
||||
request: GenerateSeedance2p0Request {
|
||||
prompt: prompt.to_string(),
|
||||
aspect_ratio: None,
|
||||
output_resolution: None,
|
||||
duration_seconds: 5,
|
||||
batch_count: None,
|
||||
start_frame_url: media.start_frame_url,
|
||||
end_frame_url: media.end_frame_url,
|
||||
reference_image_urls: media.reference_image_urls,
|
||||
reference_video_urls: media.reference_video_urls,
|
||||
reference_audio_urls: media.reference_audio_urls,
|
||||
character_ids: None,
|
||||
use_face_blur_hack: None,
|
||||
},
|
||||
session,
|
||||
host_override: None,
|
||||
}).await.map_err(|err| anyhow!("Seedance 2.0 generation failed: {:?}", err))?;
|
||||
|
||||
info!("Success! task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
if let Some(task_ids) = &result.task_ids {
|
||||
info!("All task_ids: {:?}", task_ids);
|
||||
}
|
||||
if let Some(order_ids) = &result.order_ids {
|
||||
info!("All order_ids: {:?}", order_ids);
|
||||
}
|
||||
}
|
||||
VideoModel::Seedance2p0Fast => {
|
||||
info!("Generating video with Seedance 2.0 Fast...");
|
||||
let result = generate_seedance_2p0_fast(GenerateSeedance2p0FastArgs {
|
||||
request: GenerateSeedance2p0FastRequest {
|
||||
prompt: prompt.to_string(),
|
||||
aspect_ratio: None,
|
||||
output_resolution: None,
|
||||
duration_seconds: 5,
|
||||
batch_count: None,
|
||||
start_frame_url: media.start_frame_url,
|
||||
end_frame_url: media.end_frame_url,
|
||||
reference_image_urls: media.reference_image_urls,
|
||||
reference_video_urls: media.reference_video_urls,
|
||||
reference_audio_urls: media.reference_audio_urls,
|
||||
character_ids: None,
|
||||
use_face_blur_hack: None,
|
||||
},
|
||||
session,
|
||||
host_override: None,
|
||||
}).await.map_err(|err| anyhow!("Seedance 2.0 Fast generation failed: {:?}", err))?;
|
||||
|
||||
info!("Success! task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
if let Some(task_ids) = &result.task_ids {
|
||||
info!("All task_ids: {:?}", task_ids);
|
||||
}
|
||||
if let Some(order_ids) = &result.order_ids {
|
||||
info!("All order_ids: {:?}", order_ids);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,3 +1,2 @@
|
||||
pub mod failed_job_histogram;
|
||||
pub mod find_job;
|
||||
pub mod generate_video;
|
||||
|
||||
+4
-8
@@ -37,20 +37,16 @@ pub async fn handle_video_artcraft(
|
||||
None => {
|
||||
Err(GenerateError::no_model_specified())
|
||||
},
|
||||
// Handled via router
|
||||
Some(VideoModel::Kling3p0Pro) => handle_artcraft_video_via_router(request, app_env_configs, &creds, CommonVideoModel::Kling3p0Pro, GenerationModel::Kling3p0Pro).await,
|
||||
Some(VideoModel::Kling3p0Standard) => handle_artcraft_video_via_router(request, app_env_configs, &creds, CommonVideoModel::Kling3p0Standard, GenerationModel::Kling3p0Standard).await,
|
||||
Some(VideoModel::Seedance1p5Pro) => handle_artcraft_video_via_router(request, app_env_configs, &creds, CommonVideoModel::Seedance1p5Pro, GenerationModel::Seedance1p5Pro).await,
|
||||
Some(VideoModel::Seedance2p0) => handle_artcraft_video_via_router(request, app_env_configs, &creds, CommonVideoModel::Seedance2p0, GenerationModel::Seedance2p0).await,
|
||||
Some(VideoModel::Seedance2p0Fast) => handle_artcraft_video_via_router(request, app_env_configs, &creds, CommonVideoModel::Seedance2p0Fast, GenerationModel::Seedance2p0Fast).await,
|
||||
Some(VideoModel::HappyHorse1p0) => handle_artcraft_video_via_router(request, app_env_configs, &creds, CommonVideoModel::HappyHorse1p0, GenerationModel::HappyHorse1p0).await,
|
||||
// Legacy endpoints
|
||||
Some(VideoModel::Kling16Pro) => handle_artcraft_kling_1p6_pro(request, app_env_configs, &creds).await,
|
||||
Some(VideoModel::Kling21Pro) => handle_artcraft_kling_2p1_pro(request, app_env_configs, &creds).await,
|
||||
Some(VideoModel::Kling21Master) => handle_artcraft_kling_2p1_master(request, app_env_configs, &creds).await,
|
||||
Some(VideoModel::Kling2p5TurboPro) => handle_artcraft_kling_2p5_turbo_pro(request, app_env_configs, &creds).await,
|
||||
Some(VideoModel::Kling2p6Pro) => handle_artcraft_kling_2p6_pro(request, app_env_configs, &creds).await,
|
||||
Some(VideoModel::Kling3p0Standard) => handle_artcraft_video_via_router(request, app_env_configs, &creds, CommonVideoModel::Kling3p0Standard, GenerationModel::Kling3p0Standard).await,
|
||||
Some(VideoModel::Kling3p0Pro) => handle_artcraft_video_via_router(request, app_env_configs, &creds, CommonVideoModel::Kling3p0Pro, GenerationModel::Kling3p0Pro).await,
|
||||
Some(VideoModel::Seedance10Lite) => handle_artcraft_seedance_1_lite(request, app_env_configs, &creds).await,
|
||||
Some(VideoModel::Seedance1p5Pro) => handle_artcraft_video_via_router(request, app_env_configs, &creds, CommonVideoModel::Seedance1p5Pro, GenerationModel::Seedance1p5Pro).await,
|
||||
Some(VideoModel::Seedance2p0) => handle_artcraft_seedance_2p0(request, app_env_configs, &creds).await,
|
||||
Some(VideoModel::Sora2) => handle_artcraft_sora2(request, app_env_configs, &creds).await,
|
||||
Some(VideoModel::Sora2Pro) => handle_artcraft_sora2_pro(request, app_env_configs, &creds).await,
|
||||
Some(VideoModel::Veo2) => handle_artcraft_veo2(request, app_env_configs, &creds).await,
|
||||
|
||||
+22
-66
@@ -3,23 +3,18 @@ use crate::core::commands::enqueue::image_to_video::enqueue_image_to_video_comma
|
||||
use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess;
|
||||
use crate::core::events::generation_events::common::GenerationModel;
|
||||
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
|
||||
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
|
||||
use artcraft_router::api::audio_list_ref::AudioListRef;
|
||||
use artcraft_router::api::character_list_ref::CharacterListRef;
|
||||
use artcraft_router::api::common_video_model::CommonVideoModel;
|
||||
use artcraft_router::api::image_list_ref::ImageListRef;
|
||||
use artcraft_router::api::image_ref::ImageRef;
|
||||
use artcraft_router::api::provider::Provider;
|
||||
use artcraft_router::api::video_list_ref::VideoListRef;
|
||||
use artcraft_router::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
|
||||
use artcraft_router::client::router_artcraft_client::RouterArtcraftClient;
|
||||
use artcraft_router::client::router_client::RouterClient;
|
||||
use artcraft_router::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use artcraft_router::generate::generate_video::generate_video_response::GenerateVideoResponse;
|
||||
use artcraft_router::generate::generate_video_v2::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
use enums::common::generation_provider::GenerationProvider;
|
||||
use enums::tauri::tasks::task_type::TaskType;
|
||||
use log::{error, info};
|
||||
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
|
||||
|
||||
pub(super) async fn handle_artcraft_video_via_router(
|
||||
request: &EnqueueImageToVideoRequest,
|
||||
@@ -36,43 +31,44 @@ pub(super) async fn handle_artcraft_video_via_router(
|
||||
let start_frame = request.image_media_token.clone().map(ImageRef::MediaFileToken);
|
||||
let end_frame = request.end_frame_image_media_token.clone().map(ImageRef::MediaFileToken);
|
||||
|
||||
let reference_images = request.reference_image_media_tokens.clone().map(ImageListRef::MediaFileTokens);
|
||||
let reference_videos = request.reference_video_media_tokens.clone().map(VideoListRef::MediaFileTokens);
|
||||
let reference_audio = request.reference_audio_media_tokens.clone().map(AudioListRef::MediaFileTokens);
|
||||
|
||||
let reference_character_tokens = request.reference_character_tokens.clone().map(CharacterListRef::CharacterTokens);
|
||||
|
||||
let router_request = GenerateVideoRequestBuilder {
|
||||
model,
|
||||
provider: Provider::Artcraft,
|
||||
prompt: request.prompt.clone(),
|
||||
negative_prompt: None,
|
||||
start_frame,
|
||||
end_frame,
|
||||
reference_images,
|
||||
reference_videos,
|
||||
reference_audio,
|
||||
reference_character_tokens,
|
||||
resolution: request.resolution,
|
||||
reference_images: None,
|
||||
reference_videos: None,
|
||||
reference_audio: None,
|
||||
reference_character_tokens: request.reference_character_tokens.clone().map(CharacterListRef::CharacterTokens),
|
||||
resolution: None,
|
||||
aspect_ratio: request.aspect_ratio,
|
||||
duration_seconds: request.duration_seconds,
|
||||
video_batch_count: request.video_batch_count,
|
||||
generate_audio: request.generate_audio,
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
|
||||
idempotency_token: None,
|
||||
negative_prompt: None,
|
||||
};
|
||||
|
||||
let response = if router_request.use_new_builder() {
|
||||
info!("Building request for artcraft_router (v2 pipeline)...");
|
||||
generate_via_v2(router_request, &client).await?
|
||||
} else {
|
||||
info!("Building request for artcraft_router (v1 pipeline)...");
|
||||
generate_via_v1(router_request, &client).await?
|
||||
let plan = router_request.build()?;
|
||||
|
||||
info!("Video Generation Plan: {:?}", plan);
|
||||
|
||||
let response = match plan.generate_video(&client).await {
|
||||
Ok(resp) => {
|
||||
info!("Successfully enqueued.");
|
||||
resp
|
||||
}
|
||||
Err(err) => {
|
||||
error!("Failed to enqueue: {:?}", err);
|
||||
return Err(GenerateError::from(err));
|
||||
}
|
||||
};
|
||||
|
||||
let job_id = response.get_artcraft_payload()
|
||||
.map(|p| p.inference_job_token.to_string())
|
||||
.ok_or(GenerateError::ResponseHadNoJobTokens)?;
|
||||
.map(|p| p.inference_job_token.to_string())
|
||||
.ok_or(GenerateError::ResponseHadNoJobTokens)?;
|
||||
|
||||
Ok(TaskEnqueueSuccess {
|
||||
task_type: TaskType::VideoGeneration,
|
||||
@@ -81,43 +77,3 @@ pub(super) async fn handle_artcraft_video_via_router(
|
||||
provider_job_id: Some(job_id),
|
||||
})
|
||||
}
|
||||
|
||||
/// V1 pipeline: build → plan → generate_video.
|
||||
async fn generate_via_v1(
|
||||
router_request: GenerateVideoRequestBuilder,
|
||||
client: &RouterClient,
|
||||
) -> Result<GenerateVideoResponse, GenerateError> {
|
||||
let plan = router_request.build()?;
|
||||
|
||||
let response = plan.generate_video(client).await.map_err(|err| {
|
||||
error!("V1 failed to enqueue: {:?}", err);
|
||||
GenerateError::from(err)
|
||||
})?;
|
||||
|
||||
info!("V1 successfully enqueued.");
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
/// V2 pipeline: build2 → send_request (Artcraft skips draft phase).
|
||||
async fn generate_via_v2(
|
||||
router_request: GenerateVideoRequestBuilder,
|
||||
client: &RouterClient,
|
||||
) -> Result<GenerateVideoResponse, GenerateError> {
|
||||
let draft_or_request = router_request.build2()?;
|
||||
|
||||
let request = match draft_or_request {
|
||||
VideoGenerationDraftOrRequest::Request(r) => r,
|
||||
VideoGenerationDraftOrRequest::Draft(_) => {
|
||||
error!("Unexpected Draft variant for Artcraft provider");
|
||||
return Err(GenerateError::NotYetImplemented("Artcraft provider should not produce a draft request".to_string()));
|
||||
}
|
||||
};
|
||||
|
||||
let response = request.send_request(client).await.map_err(|err| {
|
||||
error!("V2 failed to enqueue: {:?}", err);
|
||||
GenerateError::from(err)
|
||||
})?;
|
||||
|
||||
info!("V2 successfully enqueued.");
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
+4
-12
@@ -1,14 +1,17 @@
|
||||
use crate::core::commands::enqueue::common::notify_frontend_of_errors::notify_frontend_of_errors;
|
||||
use crate::core::commands::enqueue::generate_error::{BadInputReason, GenerateError, MissingCredentialsReason};
|
||||
use crate::core::commands::enqueue::generate_error::{BadInputReason, GenerateError, MissingCredentialsReason, ProviderFailureReason};
|
||||
use crate::core::commands::enqueue::image_to_video::artcraft::handle_artcraft_video::handle_video_artcraft;
|
||||
use crate::core::commands::enqueue::image_to_video::grok::handle_grok_video::handle_grok_video;
|
||||
use crate::core::commands::enqueue::image_to_video::sora2::handle_sora_sora2::handle_sora_sora2;
|
||||
use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess;
|
||||
use crate::core::commands::enqueue::text_to_image::enqueue_text_to_image_command::TextToImageModel;
|
||||
use crate::core::commands::response::failure_response_wrapper::{CommandErrorResponseWrapper, CommandErrorStatus};
|
||||
use crate::core::commands::response::shorthand::Response;
|
||||
use crate::core::commands::response::success_response_wrapper::SerializeMarker;
|
||||
use crate::core::events::basic_sendable_event_trait::BasicSendableEvent;
|
||||
use crate::core::events::functional_events::credits_balance_changed_event::CreditsBalanceChangedEvent;
|
||||
use crate::core::events::generation_events::common::GenerationAction;
|
||||
use crate::core::events::generation_events::generation_enqueue_failure_event::GenerationEnqueueFailureEvent;
|
||||
use crate::core::events::generation_events::generation_enqueue_success_event::GenerationEnqueueSuccessEvent;
|
||||
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
|
||||
use crate::core::state::artcraft_usage_tracker::artcraft_usage_tracker::ArtcraftUsageTracker;
|
||||
@@ -21,7 +24,6 @@ use crate::services::sora::state::sora_credential_manager::SoraCredentialManager
|
||||
use crate::services::sora::state::sora_task_queue::SoraTaskQueue;
|
||||
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
|
||||
use artcraft_router::api::common_aspect_ratio::CommonAspectRatio;
|
||||
use artcraft_router::api::common_resolution::CommonResolution;
|
||||
use enums::common::generation_provider::GenerationProvider;
|
||||
use enums::tauri::ux::tauri_command_caller::TauriCommandCaller;
|
||||
use log::{error, info, warn};
|
||||
@@ -59,9 +61,6 @@ pub enum VideoModel {
|
||||
#[serde(rename = "kling_3p0_pro")]
|
||||
Kling3p0Pro,
|
||||
|
||||
#[serde(rename = "happy_horse_1p0")]
|
||||
HappyHorse1p0,
|
||||
|
||||
#[serde(rename = "seedance_1.0_lite")]
|
||||
Seedance10Lite,
|
||||
|
||||
@@ -70,9 +69,6 @@ pub enum VideoModel {
|
||||
|
||||
#[serde(rename = "seedance_2p0")]
|
||||
Seedance2p0,
|
||||
|
||||
#[serde(rename = "seedance_2p0_fast")]
|
||||
Seedance2p0Fast,
|
||||
|
||||
#[serde(rename = "sora_2")]
|
||||
Sora2,
|
||||
@@ -145,10 +141,6 @@ pub struct EnqueueImageToVideoRequest {
|
||||
/// Aspect ratio for the generated video.
|
||||
pub aspect_ratio: Option<CommonAspectRatio>,
|
||||
|
||||
/// OPTIONAL.
|
||||
/// Resolution for the generated video.
|
||||
pub resolution: Option<CommonResolution>,
|
||||
|
||||
/// OPTIONAL.
|
||||
/// Duration of the generated video in seconds.
|
||||
pub duration_seconds: Option<u16>,
|
||||
|
||||
@@ -88,11 +88,9 @@ impl TaskEnqueueSuccess{
|
||||
Some(GenerationModel::Kling2p6Pro) => Some(TaskModelType::Kling2p6Pro),
|
||||
Some(GenerationModel::Kling3p0Standard) => Some(TaskModelType::Kling3p0Standard),
|
||||
Some(GenerationModel::Kling3p0Pro) => Some(TaskModelType::Kling3p0Pro),
|
||||
Some(GenerationModel::HappyHorse1p0) => Some(TaskModelType::HappyHorse1p0),
|
||||
Some(GenerationModel::Seedance10Lite) => Some(TaskModelType::Seedance10Lite),
|
||||
Some(GenerationModel::Seedance1p5Pro) => Some(TaskModelType::Seedance1p5Pro),
|
||||
Some(GenerationModel::Seedance2p0) => Some(TaskModelType::Seedance2p0),
|
||||
Some(GenerationModel::Seedance2p0Fast) => Some(TaskModelType::Seedance2p0Fast),
|
||||
Some(GenerationModel::Sora2) => Some(TaskModelType::Sora2),
|
||||
Some(GenerationModel::Sora2Pro) => Some(TaskModelType::Sora2Pro),
|
||||
Some(GenerationModel::Veo2) => Some(TaskModelType::Veo2),
|
||||
|
||||
@@ -95,8 +95,6 @@ pub enum GenerationModel {
|
||||
Kling3p0Standard,
|
||||
#[serde(rename = "kling_3p0_pro")]
|
||||
Kling3p0Pro,
|
||||
#[serde(rename = "happy_horse_1p0")]
|
||||
HappyHorse1p0,
|
||||
#[serde(rename = "recraft_3")]
|
||||
Recraft3,
|
||||
#[serde(rename = "sora")]
|
||||
@@ -111,8 +109,6 @@ pub enum GenerationModel {
|
||||
Seedance1p5Pro,
|
||||
#[serde(rename = "seedance_2p0")]
|
||||
Seedance2p0,
|
||||
#[serde(rename = "seedance_2p0_fast")]
|
||||
Seedance2p0Fast,
|
||||
#[serde(rename = "veo_2")]
|
||||
Veo2,
|
||||
#[serde(rename = "veo_3")]
|
||||
|
||||
@@ -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.31.0";
|
||||
pub const ARTCRAFT_VERSION: &str = "0.30.0";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2.0.0-rc",
|
||||
"productName": "ArtCraft",
|
||||
"version": "0.31.0",
|
||||
"version": "0.30.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.31.0",
|
||||
"version": "0.30.0",
|
||||
"identifier": "ai.artcraft.app",
|
||||
"build": {
|
||||
"frontendDist": "../../../frontend/apps/artcraft/dist",
|
||||
|
||||
+1
-6
@@ -5,15 +5,14 @@ use sqlx::pool::PoolConnection;
|
||||
use sqlx::{FromRow, MySql, MySqlPool, QueryBuilder, Row};
|
||||
|
||||
use enums::by_table::generic_inference_jobs::frontend_failure_category::FrontendFailureCategory;
|
||||
use enums::traits::mysql_from_row::MySqlFromRow as _;
|
||||
use enums::by_table::generic_inference_jobs::inference_category::InferenceCategory;
|
||||
use enums::by_table::generic_inference_jobs::inference_job_product_category::InferenceJobProductCategory;
|
||||
use enums::common::job_status_plus::JobStatusPlus;
|
||||
use enums::traits::mysql_from_row::MySqlFromRow as _;
|
||||
use errors::AnyhowResult;
|
||||
use tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken;
|
||||
use tokens::tokens::batch_generations::BatchGenerationToken;
|
||||
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
|
||||
use tokens::tokens::prompts::PromptToken;
|
||||
use tokens::tokens::users::UserToken;
|
||||
use tokens::traits::mysql_token_from_row::MySqlTokenFromRow;
|
||||
|
||||
@@ -98,7 +97,6 @@ SELECT
|
||||
jobs.maybe_model_token,
|
||||
jobs.maybe_raw_inference_text,
|
||||
jobs.maybe_inference_args,
|
||||
jobs.maybe_prompt_token,
|
||||
|
||||
jobs.frontend_failure_category as maybe_frontend_failure_category,
|
||||
jobs.failure_reason,
|
||||
@@ -268,7 +266,6 @@ fn raw_records_to_public_result(records: Vec<RawGenericInferenceJobStatus>) -> V
|
||||
maybe_model_title: maybe_model_title.map(|title| title.to_string()),
|
||||
maybe_raw_inference_text: record.maybe_raw_inference_text,
|
||||
maybe_inference_args: record.maybe_inference_args,
|
||||
maybe_prompt_token: record.maybe_prompt_token,
|
||||
maybe_style_name,
|
||||
},
|
||||
maybe_result_details,
|
||||
@@ -303,7 +300,6 @@ struct RawGenericInferenceJobStatus {
|
||||
pub maybe_model_token: Option<String>,
|
||||
pub maybe_raw_inference_text: Option<String>,
|
||||
pub maybe_inference_args: Option<String>,
|
||||
pub maybe_prompt_token: Option<PromptToken>,
|
||||
|
||||
pub maybe_result_entity_type: Option<String>,
|
||||
pub maybe_result_entity_token: Option<String>,
|
||||
@@ -363,7 +359,6 @@ impl FromRow<'_, MySqlRow> for RawGenericInferenceJobStatus {
|
||||
maybe_model_token: row.try_get("maybe_model_token")?,
|
||||
maybe_raw_inference_text: row.try_get("maybe_raw_inference_text")?,
|
||||
maybe_inference_args: row.try_get("maybe_inference_args")?,
|
||||
maybe_prompt_token: PromptToken::try_from_mysql_row_nullable(row, "maybe_prompt_token")?,
|
||||
maybe_result_entity_type: row.try_get("maybe_result_entity_type")?,
|
||||
maybe_result_entity_token: row.try_get("maybe_result_entity_token")?,
|
||||
maybe_result_batch_token: row.try_get("maybe_result_batch_token")?,
|
||||
|
||||
-4
@@ -12,7 +12,6 @@ use errors::AnyhowResult;
|
||||
use tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken;
|
||||
use tokens::tokens::batch_generations::BatchGenerationToken;
|
||||
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
|
||||
use tokens::tokens::prompts::PromptToken;
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
use crate::helpers::boolean_converters::i8_to_bool;
|
||||
@@ -56,7 +55,6 @@ SELECT
|
||||
jobs.maybe_model_token,
|
||||
jobs.maybe_raw_inference_text,
|
||||
jobs.maybe_inference_args,
|
||||
jobs.maybe_prompt_token as `maybe_prompt_token: tokens::tokens::prompts::PromptToken`,
|
||||
|
||||
jobs.frontend_failure_category as `maybe_frontend_failure_category: enums::by_table::generic_inference_jobs::frontend_failure_category::FrontendFailureCategory`,
|
||||
jobs.failure_reason,
|
||||
@@ -243,7 +241,6 @@ fn raw_record_to_public_result(record: RawGenericInferenceJobStatus) -> GenericI
|
||||
maybe_model_title: maybe_model_title.map(|title| title.to_string()),
|
||||
maybe_raw_inference_text: record.maybe_raw_inference_text,
|
||||
maybe_inference_args: record.maybe_inference_args,
|
||||
maybe_prompt_token: record.maybe_prompt_token,
|
||||
maybe_style_name,
|
||||
},
|
||||
maybe_result_details,
|
||||
@@ -276,7 +273,6 @@ struct RawGenericInferenceJobStatus {
|
||||
pub maybe_model_token: Option<String>,
|
||||
pub maybe_raw_inference_text: Option<String>,
|
||||
pub maybe_inference_args: Option<String>,
|
||||
pub maybe_prompt_token: Option<PromptToken>,
|
||||
|
||||
pub maybe_result_entity_type: Option<String>,
|
||||
pub maybe_result_entity_token: Option<String>,
|
||||
|
||||
@@ -8,7 +8,6 @@ use enums::no_table::style_transfer::style_transfer_name::StyleTransferName;
|
||||
use tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken;
|
||||
use tokens::tokens::batch_generations::BatchGenerationToken;
|
||||
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
|
||||
use tokens::tokens::prompts::PromptToken;
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
/// Shared inference job status struct for several queries (list, get, batch get)
|
||||
@@ -58,9 +57,6 @@ pub struct RequestDetails {
|
||||
/// Raw JSON payload of polymorphic inference args
|
||||
pub maybe_inference_args: Option<String>,
|
||||
|
||||
/// Prompt token linking this job to its prompt record.
|
||||
pub maybe_prompt_token: Option<PromptToken>,
|
||||
|
||||
/// For Comfy / Video Style Transfer jobs, this might include
|
||||
/// the name of the selected style.
|
||||
pub maybe_style_name: Option<StyleTransferName>,
|
||||
|
||||
-5
@@ -15,7 +15,6 @@ use errors::AnyhowResult;
|
||||
use tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken;
|
||||
use tokens::tokens::batch_generations::BatchGenerationToken;
|
||||
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
|
||||
use tokens::tokens::prompts::PromptToken;
|
||||
use tokens::tokens::users::UserToken;
|
||||
use tokens::traits::mysql_token_from_row::MySqlTokenFromRow;
|
||||
|
||||
@@ -164,7 +163,6 @@ SELECT
|
||||
jobs.maybe_model_token,
|
||||
jobs.maybe_raw_inference_text,
|
||||
jobs.maybe_inference_args,
|
||||
jobs.maybe_prompt_token,
|
||||
|
||||
jobs.frontend_failure_category as maybe_frontend_failure_category,
|
||||
jobs.failure_reason,
|
||||
@@ -334,7 +332,6 @@ fn raw_records_to_public_result(records: Vec<RawGenericInferenceJobStatus>) -> V
|
||||
maybe_model_title: maybe_model_title.map(|title| title.to_string()),
|
||||
maybe_raw_inference_text: record.maybe_raw_inference_text,
|
||||
maybe_inference_args: record.maybe_inference_args,
|
||||
maybe_prompt_token: record.maybe_prompt_token,
|
||||
maybe_style_name,
|
||||
},
|
||||
maybe_result_details,
|
||||
@@ -369,7 +366,6 @@ struct RawGenericInferenceJobStatus {
|
||||
pub maybe_model_token: Option<String>,
|
||||
pub maybe_raw_inference_text: Option<String>,
|
||||
pub maybe_inference_args: Option<String>,
|
||||
pub maybe_prompt_token: Option<PromptToken>,
|
||||
|
||||
pub maybe_result_entity_type: Option<String>,
|
||||
pub maybe_result_entity_token: Option<String>,
|
||||
@@ -429,7 +425,6 @@ impl FromRow<'_, MySqlRow> for RawGenericInferenceJobStatus {
|
||||
maybe_model_token: row.try_get("maybe_model_token")?,
|
||||
maybe_raw_inference_text: row.try_get("maybe_raw_inference_text")?,
|
||||
maybe_inference_args: row.try_get("maybe_inference_args")?,
|
||||
maybe_prompt_token: PromptToken::try_from_mysql_row_nullable(row, "maybe_prompt_token")?,
|
||||
maybe_result_entity_type: row.try_get("maybe_result_entity_type")?,
|
||||
maybe_result_entity_token: row.try_get("maybe_result_entity_token")?,
|
||||
maybe_result_batch_token: row.try_get("maybe_result_batch_token")?,
|
||||
|
||||
-70
@@ -1,70 +0,0 @@
|
||||
use sqlx::mysql::MySqlRow;
|
||||
use sqlx::pool::PoolConnection;
|
||||
use sqlx::{FromRow, MySql, QueryBuilder, Row};
|
||||
|
||||
use enums::by_table::prompt_context_items::prompt_context_semantic_type::PromptContextSemanticType;
|
||||
use enums::traits::mysql_from_row::MySqlFromRow;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
use tokens::tokens::prompts::PromptToken;
|
||||
use tokens::traits::mysql_token_from_row::MySqlTokenFromRow;
|
||||
|
||||
/// A prompt context item with its owning prompt token, for batch queries.
|
||||
pub struct BatchPromptContextItem {
|
||||
pub prompt_token: PromptToken,
|
||||
pub media_token: MediaFileToken,
|
||||
pub context_semantic_type: PromptContextSemanticType,
|
||||
pub public_bucket_directory_hash: String,
|
||||
pub maybe_public_bucket_prefix: Option<String>,
|
||||
pub maybe_public_bucket_extension: Option<String>,
|
||||
}
|
||||
|
||||
impl FromRow<'_, MySqlRow> for BatchPromptContextItem {
|
||||
fn from_row(row: &MySqlRow) -> Result<Self, sqlx::Error> {
|
||||
Ok(Self {
|
||||
prompt_token: PromptToken::try_from_mysql_row(row, "prompt_token")?,
|
||||
media_token: MediaFileToken::try_from_mysql_row(row, "media_token")?,
|
||||
context_semantic_type: PromptContextSemanticType::try_from_mysql_row(row, "context_semantic_type")?,
|
||||
public_bucket_directory_hash: row.try_get("public_bucket_directory_hash")?,
|
||||
maybe_public_bucket_prefix: row.try_get("maybe_public_bucket_prefix")?,
|
||||
maybe_public_bucket_extension: row.try_get("maybe_public_bucket_extension")?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Batch fetch all context items for multiple prompts in a single query.
|
||||
/// Returns items grouped by their prompt_token (via the prompt_token field).
|
||||
pub async fn batch_list_prompt_context_items(
|
||||
prompt_tokens: &[PromptToken],
|
||||
mysql_connection: &mut PoolConnection<MySql>,
|
||||
) -> Result<Vec<BatchPromptContextItem>, sqlx::Error> {
|
||||
if prompt_tokens.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut query_builder: QueryBuilder<MySql> = QueryBuilder::new(r#"
|
||||
SELECT
|
||||
pci.prompt_token,
|
||||
pci.media_token,
|
||||
pci.context_semantic_type,
|
||||
|
||||
m.public_bucket_directory_hash,
|
||||
m.maybe_public_bucket_prefix,
|
||||
m.maybe_public_bucket_extension
|
||||
|
||||
FROM prompt_context_items pci
|
||||
JOIN media_files m ON pci.media_token = m.token
|
||||
WHERE pci.prompt_token IN (
|
||||
"#);
|
||||
|
||||
let mut separated = query_builder.separated(", ");
|
||||
for token in prompt_tokens {
|
||||
separated.push_bind(token.to_string());
|
||||
}
|
||||
separated.push_unseparated(") ");
|
||||
|
||||
let query = query_builder.build_query_as::<BatchPromptContextItem>();
|
||||
|
||||
let results = query.fetch_all(&mut **mysql_connection).await?;
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
@@ -1,3 +1,2 @@
|
||||
pub mod batch_list_prompt_context_items;
|
||||
pub mod insert_batch_prompt_context_items;
|
||||
pub mod list_prompt_context_items;
|
||||
@@ -1,95 +0,0 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use sqlx::mysql::MySqlRow;
|
||||
use sqlx::pool::PoolConnection;
|
||||
use sqlx::{FromRow, MySql, QueryBuilder, Row};
|
||||
|
||||
use enums::by_table::prompts::prompt_type::PromptType;
|
||||
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::common_resolution::CommonResolution;
|
||||
use enums::common::generation_provider::GenerationProvider;
|
||||
use enums::traits::mysql_from_row::MySqlFromRow;
|
||||
use tokens::tokens::prompts::PromptToken;
|
||||
use tokens::traits::mysql_token_from_row::MySqlTokenFromRow;
|
||||
|
||||
pub struct BatchPromptResult {
|
||||
pub token: PromptToken,
|
||||
pub prompt_type: PromptType,
|
||||
pub maybe_model_type: Option<CommonModelType>,
|
||||
pub maybe_generation_provider: Option<GenerationProvider>,
|
||||
pub maybe_positive_prompt: Option<String>,
|
||||
pub maybe_negative_prompt: Option<String>,
|
||||
pub maybe_generation_mode: Option<CommonGenerationMode>,
|
||||
pub maybe_aspect_ratio: Option<CommonAspectRatio>,
|
||||
pub maybe_resolution: Option<CommonResolution>,
|
||||
pub maybe_batch_count: Option<u8>,
|
||||
pub maybe_generate_audio: Option<bool>,
|
||||
pub maybe_duration_seconds: Option<u32>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl FromRow<'_, MySqlRow> for BatchPromptResult {
|
||||
fn from_row(row: &MySqlRow) -> Result<Self, sqlx::Error> {
|
||||
Ok(Self {
|
||||
token: PromptToken::try_from_mysql_row(row, "token")?,
|
||||
prompt_type: PromptType::try_from_mysql_row(row, "prompt_type")?,
|
||||
maybe_model_type: row.try_get::<Option<String>, _>("maybe_model_type")?
|
||||
.and_then(|s| CommonModelType::from_str(&s).ok()),
|
||||
maybe_generation_provider: row.try_get::<Option<String>, _>("maybe_generation_provider")?
|
||||
.and_then(|s| GenerationProvider::from_str(&s).ok()),
|
||||
maybe_positive_prompt: row.try_get("maybe_positive_prompt")?,
|
||||
maybe_negative_prompt: row.try_get("maybe_negative_prompt")?,
|
||||
maybe_generation_mode: row.try_get::<Option<String>, _>("maybe_generation_mode")?
|
||||
.and_then(|s| CommonGenerationMode::from_str(&s).ok()),
|
||||
maybe_aspect_ratio: row.try_get::<Option<String>, _>("maybe_aspect_ratio")?
|
||||
.and_then(|s| CommonAspectRatio::from_str(&s).ok()),
|
||||
maybe_resolution: row.try_get::<Option<String>, _>("maybe_resolution")?
|
||||
.and_then(|s| CommonResolution::from_str(&s).ok()),
|
||||
maybe_batch_count: row.try_get("maybe_batch_count")?,
|
||||
maybe_generate_audio: row.try_get("maybe_generate_audio")?,
|
||||
maybe_duration_seconds: row.try_get("maybe_duration_seconds")?,
|
||||
created_at: row.try_get("created_at")?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn batch_get_prompts(
|
||||
tokens: &[PromptToken],
|
||||
mysql_connection: &mut PoolConnection<MySql>,
|
||||
) -> Result<Vec<BatchPromptResult>, sqlx::Error> {
|
||||
if tokens.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut query_builder: QueryBuilder<MySql> = QueryBuilder::new(r#"
|
||||
SELECT
|
||||
p.token,
|
||||
p.prompt_type,
|
||||
p.maybe_model_type,
|
||||
p.maybe_generation_provider,
|
||||
p.maybe_positive_prompt,
|
||||
p.maybe_negative_prompt,
|
||||
p.maybe_generation_mode,
|
||||
p.maybe_aspect_ratio,
|
||||
p.maybe_resolution,
|
||||
p.maybe_batch_count,
|
||||
p.maybe_generate_audio,
|
||||
p.maybe_duration_seconds,
|
||||
p.created_at
|
||||
FROM prompts as p
|
||||
WHERE p.token IN (
|
||||
"#);
|
||||
|
||||
let mut separated = query_builder.separated(", ");
|
||||
|
||||
for token in tokens {
|
||||
separated.push_bind(token.to_string());
|
||||
}
|
||||
|
||||
separated.push_unseparated(") ");
|
||||
|
||||
let query = query_builder.build_query_as::<BatchPromptResult>();
|
||||
|
||||
query.fetch_all(&mut **mysql_connection).await
|
||||
}
|
||||
@@ -1,3 +1,2 @@
|
||||
pub mod batch_get_prompts;
|
||||
pub mod get_prompt;
|
||||
pub mod insert_prompt;
|
||||
@@ -77,16 +77,12 @@ pub enum TaskModelType {
|
||||
Kling3p0Standard,
|
||||
#[serde(rename = "kling_3p0_pro")]
|
||||
Kling3p0Pro,
|
||||
#[serde(rename = "happy_horse_1p0")]
|
||||
HappyHorse1p0,
|
||||
#[serde(rename = "seedance_1.0_lite")]
|
||||
Seedance10Lite,
|
||||
#[serde(rename = "seedance_1p5_pro")]
|
||||
Seedance1p5Pro,
|
||||
#[serde(rename = "seedance_2p0")]
|
||||
Seedance2p0,
|
||||
#[serde(rename = "seedance_2p0_fast")]
|
||||
Seedance2p0Fast,
|
||||
#[serde(rename = "sora_2")]
|
||||
Sora2,
|
||||
#[serde(rename = "sora_2_pro")]
|
||||
@@ -158,11 +154,9 @@ impl TaskModelType {
|
||||
Self::Kling2p6Pro => "kling_2p6_pro",
|
||||
Self::Kling3p0Standard => "kling_3p0_standard",
|
||||
Self::Kling3p0Pro => "kling_3p0_pro",
|
||||
Self::HappyHorse1p0 => "happy_horse_1p0",
|
||||
Self::Seedance10Lite => "seedance_1.0_lite",
|
||||
Self::Seedance1p5Pro => "seedance_1p5_pro",
|
||||
Self::Seedance2p0 => "seedance_2p0",
|
||||
Self::Seedance2p0Fast => "seedance_2p0_fast",
|
||||
Self::Sora2 => "sora_2",
|
||||
Self::Sora2Pro => "sora_2_pro",
|
||||
Self::Veo2 => "veo_2",
|
||||
@@ -214,11 +208,9 @@ impl TaskModelType {
|
||||
"kling_2p6_pro" => Ok(Self::Kling2p6Pro),
|
||||
"kling_3p0_standard" => Ok(Self::Kling3p0Standard),
|
||||
"kling_3p0_pro" => Ok(Self::Kling3p0Pro),
|
||||
"happy_horse_1p0" => Ok(Self::HappyHorse1p0),
|
||||
"seedance_1.0_lite" => Ok(Self::Seedance10Lite),
|
||||
"seedance_1p5_pro" => Ok(Self::Seedance1p5Pro),
|
||||
"seedance_2p0" => Ok(Self::Seedance2p0),
|
||||
"seedance_2p0_fast" => Ok(Self::Seedance2p0Fast),
|
||||
"sora_2" => Ok(Self::Sora2),
|
||||
"sora_2_pro" => Ok(Self::Sora2Pro),
|
||||
"veo_2" => Ok(Self::Veo2),
|
||||
@@ -273,11 +265,9 @@ impl TaskModelType {
|
||||
Self::Kling2p6Pro,
|
||||
Self::Kling3p0Standard,
|
||||
Self::Kling3p0Pro,
|
||||
Self::HappyHorse1p0,
|
||||
Self::Seedance10Lite,
|
||||
Self::Seedance1p5Pro,
|
||||
Self::Seedance2p0,
|
||||
Self::Seedance2p0Fast,
|
||||
Self::Sora2,
|
||||
Self::Sora2Pro,
|
||||
Self::Veo2,
|
||||
@@ -339,11 +329,9 @@ mod tests {
|
||||
assert_serialization(TaskModelType::Kling2p6Pro, "kling_2p6_pro");
|
||||
assert_serialization(TaskModelType::Kling3p0Standard, "kling_3p0_standard");
|
||||
assert_serialization(TaskModelType::Kling3p0Pro, "kling_3p0_pro");
|
||||
assert_serialization(TaskModelType::HappyHorse1p0, "happy_horse_1p0");
|
||||
assert_serialization(TaskModelType::Seedance10Lite, "seedance_1.0_lite");
|
||||
assert_serialization(TaskModelType::Seedance1p5Pro, "seedance_1p5_pro");
|
||||
assert_serialization(TaskModelType::Seedance2p0, "seedance_2p0");
|
||||
assert_serialization(TaskModelType::Seedance2p0Fast, "seedance_2p0_fast");
|
||||
assert_serialization(TaskModelType::Sora2, "sora_2");
|
||||
assert_serialization(TaskModelType::Sora2Pro, "sora_2_pro");
|
||||
assert_serialization(TaskModelType::Veo2, "veo_2");
|
||||
@@ -394,11 +382,9 @@ mod tests {
|
||||
assert_eq!(TaskModelType::Kling2p6Pro.to_str(), "kling_2p6_pro");
|
||||
assert_eq!(TaskModelType::Kling3p0Standard.to_str(), "kling_3p0_standard");
|
||||
assert_eq!(TaskModelType::Kling3p0Pro.to_str(), "kling_3p0_pro");
|
||||
assert_eq!(TaskModelType::HappyHorse1p0.to_str(), "happy_horse_1p0");
|
||||
assert_eq!(TaskModelType::Seedance10Lite.to_str(), "seedance_1.0_lite");
|
||||
assert_eq!(TaskModelType::Seedance1p5Pro.to_str(), "seedance_1p5_pro");
|
||||
assert_eq!(TaskModelType::Seedance2p0.to_str(), "seedance_2p0");
|
||||
assert_eq!(TaskModelType::Seedance2p0Fast.to_str(), "seedance_2p0_fast");
|
||||
assert_eq!(TaskModelType::Sora2.to_str(), "sora_2");
|
||||
assert_eq!(TaskModelType::Sora2Pro.to_str(), "sora_2_pro");
|
||||
assert_eq!(TaskModelType::Veo2.to_str(), "veo_2");
|
||||
@@ -449,11 +435,9 @@ mod tests {
|
||||
assert_eq!(TaskModelType::from_str("kling_2p6_pro").unwrap(), TaskModelType::Kling2p6Pro);
|
||||
assert_eq!(TaskModelType::from_str("kling_3p0_standard").unwrap(), TaskModelType::Kling3p0Standard);
|
||||
assert_eq!(TaskModelType::from_str("kling_3p0_pro").unwrap(), TaskModelType::Kling3p0Pro);
|
||||
assert_eq!(TaskModelType::from_str("happy_horse_1p0").unwrap(), TaskModelType::HappyHorse1p0);
|
||||
assert_eq!(TaskModelType::from_str("seedance_1.0_lite").unwrap(), TaskModelType::Seedance10Lite);
|
||||
assert_eq!(TaskModelType::from_str("seedance_1p5_pro").unwrap(), TaskModelType::Seedance1p5Pro);
|
||||
assert_eq!(TaskModelType::from_str("seedance_2p0").unwrap(), TaskModelType::Seedance2p0);
|
||||
assert_eq!(TaskModelType::from_str("seedance_2p0_fast").unwrap(), TaskModelType::Seedance2p0Fast);
|
||||
assert_eq!(TaskModelType::from_str("sora_2").unwrap(), TaskModelType::Sora2);
|
||||
assert_eq!(TaskModelType::from_str("sora_2_pro").unwrap(), TaskModelType::Sora2Pro);
|
||||
assert_eq!(TaskModelType::from_str("veo_2").unwrap(), TaskModelType::Veo2);
|
||||
@@ -484,7 +468,7 @@ mod tests {
|
||||
#[test]
|
||||
fn all_variants() {
|
||||
let mut variants = TaskModelType::all_variants();
|
||||
assert_eq!(variants.len(), 48);
|
||||
assert_eq!(variants.len(), 46);
|
||||
// Image models
|
||||
assert_eq!(variants.pop_first(), Some(TaskModelType::Flux1Dev));
|
||||
assert_eq!(variants.pop_first(), Some(TaskModelType::Flux1Schnell));
|
||||
@@ -517,11 +501,9 @@ mod tests {
|
||||
assert_eq!(variants.pop_first(), Some(TaskModelType::Kling2p6Pro));
|
||||
assert_eq!(variants.pop_first(), Some(TaskModelType::Kling3p0Standard));
|
||||
assert_eq!(variants.pop_first(), Some(TaskModelType::Kling3p0Pro));
|
||||
assert_eq!(variants.pop_first(), Some(TaskModelType::HappyHorse1p0));
|
||||
assert_eq!(variants.pop_first(), Some(TaskModelType::Seedance10Lite));
|
||||
assert_eq!(variants.pop_first(), Some(TaskModelType::Seedance1p5Pro));
|
||||
assert_eq!(variants.pop_first(), Some(TaskModelType::Seedance2p0));
|
||||
assert_eq!(variants.pop_first(), Some(TaskModelType::Seedance2p0Fast));
|
||||
assert_eq!(variants.pop_first(), Some(TaskModelType::Sora2));
|
||||
assert_eq!(variants.pop_first(), Some(TaskModelType::Sora2Pro));
|
||||
assert_eq!(variants.pop_first(), Some(TaskModelType::Veo2));
|
||||
|
||||
@@ -94,7 +94,6 @@ fn build_omni_gen_video_models() -> Vec<OmniGenVideoModelDetails> {
|
||||
character_references_supported: Some(true),
|
||||
character_references_max: Some(9),
|
||||
aspect_ratio_options: Some(vec![
|
||||
CommonAspectRatio::WideTwentyOneByNine,
|
||||
CommonAspectRatio::WideSixteenByNine,
|
||||
CommonAspectRatio::WideFourByThree,
|
||||
CommonAspectRatio::Square,
|
||||
@@ -134,7 +133,6 @@ fn build_omni_gen_video_models() -> Vec<OmniGenVideoModelDetails> {
|
||||
character_references_supported: Some(true),
|
||||
character_references_max: Some(9),
|
||||
aspect_ratio_options: Some(vec![
|
||||
CommonAspectRatio::WideTwentyOneByNine,
|
||||
CommonAspectRatio::WideSixteenByNine,
|
||||
CommonAspectRatio::WideFourByThree,
|
||||
CommonAspectRatio::Square,
|
||||
|
||||
@@ -155,7 +155,6 @@ use artcraft_api_defs::media_file::delete_media_file::DeleteMediaFilePathInfo;
|
||||
use artcraft_api_defs::media_file::delete_media_file::DeleteMediaFileRequest;
|
||||
use artcraft_api_defs::prompts::create_prompt::CreatePromptRequest;
|
||||
use artcraft_api_defs::prompts::create_prompt::CreatePromptResponse;
|
||||
use artcraft_api_defs::prompts::batch_get_prompts::*;
|
||||
use artcraft_api_defs::prompts::get_prompt::*;
|
||||
use artcraft_api_defs::users::change_password::{ChangePasswordRequest, ChangePasswordResponse};
|
||||
use artcraft_api_defs::users::edit_email::{EditEmailRequest, EditEmailResponse};
|
||||
@@ -221,7 +220,6 @@ use enums::by_table::user_bookmarks::user_bookmark_entity_type::UserBookmarkEnti
|
||||
use enums::by_table::user_ratings::entity_type::UserRatingEntityType;
|
||||
use enums::by_table::user_ratings::rating_value::UserRatingValue;
|
||||
use enums::by_table::users::user_feature_flag::UserFeatureFlag;
|
||||
use enums::common::generation::common_model_class::CommonModelClass;
|
||||
use enums::common::generation::common_model_type::CommonModelType;
|
||||
use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
|
||||
use enums::common::generation::common_generation_mode::CommonGenerationMode;
|
||||
@@ -404,7 +402,6 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
|
||||
crate::http_server::endpoints::moderation::user_feature_flags::moderator_edit_user_feature_flags_handler::moderator_edit_user_feature_flags_handler,
|
||||
crate::http_server::endpoints::moderation::user_feature_flags::moderator_list_all_available_user_feature_flags_handler::moderator_list_all_available_user_feature_flags_handler,
|
||||
crate::http_server::endpoints::moderation::user_feature_flags::moderator_list_user_feature_flags_handler::moderator_list_user_feature_flags_handler,
|
||||
crate::http_server::endpoints::prompts::batch_get_prompts_handler::batch_get_prompts_handler,
|
||||
crate::http_server::endpoints::prompts::create_prompt_handler::create_prompt_handler,
|
||||
crate::http_server::endpoints::prompts::get_prompt_handler::get_prompt_handler,
|
||||
crate::http_server::endpoints::service::status_alert_handler::status_alert_handler,
|
||||
@@ -562,7 +559,6 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
|
||||
MediaFileOriginProductCategory,
|
||||
MediaFileSubtype,
|
||||
MediaFileType,
|
||||
CommonModelClass,
|
||||
CommonModelType,
|
||||
CommonAspectRatio,
|
||||
CommonGenerationMode,
|
||||
@@ -625,9 +621,6 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
|
||||
BatchGetInferenceJobStatusQueryParams,
|
||||
BatchGetInferenceJobStatusSuccessResponse,
|
||||
BatchGetMediaFilesModelInfo,
|
||||
BatchGetPromptsQuery,
|
||||
BatchGetPromptsResponse,
|
||||
BatchPromptInfo,
|
||||
CreatePromptRequest,
|
||||
CreatePromptResponse,
|
||||
BatchGetMediaFilesQueryParams,
|
||||
@@ -869,6 +862,7 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
|
||||
GetMediaFilePathInfo,
|
||||
GetMediaFileSuccessResponse,
|
||||
GetProfilePathInfo,
|
||||
GetPromptError,
|
||||
GetPromptPathInfo,
|
||||
GetPromptSuccessResponse,
|
||||
GetUnifiedQueueStatsError,
|
||||
|
||||
-5
@@ -30,7 +30,6 @@ use redis::Commands;
|
||||
use redis_common::redis_keys::RedisKeys;
|
||||
use server_environment::ServerEnvironment;
|
||||
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
|
||||
use tokens::tokens::prompts::PromptToken;
|
||||
use utoipa::{IntoParams, ToSchema};
|
||||
|
||||
/// For certain jobs or job classes (eg. non-premium), we kill the jobs if the user hasn't
|
||||
@@ -74,9 +73,6 @@ pub struct BatchInferenceJobStatusResponsePayload {
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct BatchRequestDetailsResponse {
|
||||
pub inference_category: InferenceCategory,
|
||||
|
||||
pub maybe_prompt_token: Option<PromptToken>,
|
||||
|
||||
pub maybe_model_type: Option<String>,
|
||||
pub maybe_model_token: Option<String>,
|
||||
|
||||
@@ -257,7 +253,6 @@ fn db_record_to_response_payload(
|
||||
job_token: record.job_token,
|
||||
request: BatchRequestDetailsResponse {
|
||||
inference_category: record.request_details.inference_category,
|
||||
maybe_prompt_token: record.request_details.maybe_prompt_token,
|
||||
maybe_model_type: maybe_filter_model_name(record.request_details.maybe_model_type.as_deref()),
|
||||
maybe_model_token: record.request_details.maybe_model_token,
|
||||
maybe_model_title: record.request_details.maybe_model_title,
|
||||
|
||||
-5
@@ -28,7 +28,6 @@ use redis::{Commands, RedisResult};
|
||||
use redis_common::redis_keys::RedisKeys;
|
||||
use server_environment::ServerEnvironment;
|
||||
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
|
||||
use tokens::tokens::prompts::PromptToken;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
/// For certain jobs or job classes (eg. non-premium), we kill the jobs if the user hasn't
|
||||
@@ -65,9 +64,6 @@ pub struct InferenceJobStatusResponsePayload {
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct RequestDetailsResponse {
|
||||
pub inference_category: InferenceCategory,
|
||||
|
||||
pub maybe_prompt_token: Option<PromptToken>,
|
||||
|
||||
pub maybe_model_type: Option<String>,
|
||||
pub maybe_model_token: Option<String>,
|
||||
|
||||
@@ -252,7 +248,6 @@ fn record_to_payload(
|
||||
job_token: record.job_token,
|
||||
request: RequestDetailsResponse {
|
||||
inference_category: record.request_details.inference_category,
|
||||
maybe_prompt_token: record.request_details.maybe_prompt_token,
|
||||
maybe_model_type: maybe_filter_model_name(record.request_details.maybe_model_type.as_deref()),
|
||||
maybe_model_token: record.request_details.maybe_model_token,
|
||||
maybe_model_title: record.request_details.maybe_model_title,
|
||||
|
||||
-1
@@ -207,7 +207,6 @@ fn db_record_to_response_payload(
|
||||
job_token: record.job_token,
|
||||
request: ListSessionRequestDetailsResponse {
|
||||
inference_category: record.request_details.inference_category,
|
||||
maybe_prompt_token: record.request_details.maybe_prompt_token,
|
||||
maybe_model_type: maybe_filter_model_name(record.request_details.maybe_model_type.as_deref()),
|
||||
maybe_model_token: record.request_details.maybe_model_token,
|
||||
maybe_model_title: record.request_details.maybe_model_title,
|
||||
|
||||
+2
-2
@@ -29,13 +29,13 @@ use super::distill_helper::hydrate_to_router_request::hydrate_to_router_request;
|
||||
/// plan, all in one place.
|
||||
pub struct DistilledImageRequest {
|
||||
/// The fully-built router request.
|
||||
pub request: GenerateImageRequest,
|
||||
request: GenerateImageRequest,
|
||||
|
||||
/// Cost estimate as computed by the Artcraft provider — this is what we bill on.
|
||||
pub cost: ImageGenerationCostEstimate,
|
||||
|
||||
/// Execution plan as computed by the Fal provider — what we hand to the router.
|
||||
pub plan: ImageGenerationPlan,
|
||||
plan: ImageGenerationPlan,
|
||||
}
|
||||
|
||||
impl DistilledImageRequest {
|
||||
|
||||
+9
-12
@@ -9,9 +9,6 @@ use url::Url;
|
||||
|
||||
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_image_cost_and_generate_request::OmniGenImageCostAndGenerateRequest;
|
||||
use artcraft_api_defs::omni_gen::generate_response::omni_gen_image_generate_response::OmniGenImageGenerateResponse;
|
||||
use artcraft_router::client::router_client::RouterClient;
|
||||
use artcraft_router::client::router_fal_client::RouterFalClient;
|
||||
use artcraft_router::generate::generate_image::generate_image_response::GenerateImageResponse;
|
||||
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;
|
||||
@@ -19,11 +16,11 @@ use enums::common::generation::common_model_type::CommonModelType;
|
||||
use enums::common::generation_provider::GenerationProvider;
|
||||
use enums::common::visibility::Visibility;
|
||||
use http_server_common::request::get_request_ip::get_request_ip;
|
||||
use mysql_queries::queries::generic_inference::fal::insert_generic_inference_job_for_fal_queue::FalCategory;
|
||||
use mysql_queries::queries::generic_inference::fal::insert_generic_inference_job_for_fal_queue_with_apriori_job_token::{
|
||||
insert_generic_inference_job_for_fal_queue_with_apriori_job_token,
|
||||
InsertGenericInferenceForFalWithAprioriJobTokenArgs,
|
||||
};
|
||||
use mysql_queries::queries::generic_inference::fal::insert_generic_inference_job_for_fal_queue::FalCategory;
|
||||
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,
|
||||
@@ -156,12 +153,12 @@ pub async fn omni_gen_image_generate_handler(
|
||||
|
||||
// ==================== EXECUTE GENERATION ==================== //
|
||||
|
||||
let fal_client = RouterFalClient::new(
|
||||
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 = RouterClient::Fal(fal_client);
|
||||
let router_client = artcraft_router::client::router_client::RouterClient::Fal(fal_client);
|
||||
|
||||
let generation_response = distilled.plan().generate_image(&router_client)
|
||||
.await
|
||||
@@ -171,10 +168,10 @@ pub async fn omni_gen_image_generate_handler(
|
||||
})?;
|
||||
|
||||
let external_job_id = match &generation_response {
|
||||
GenerateImageResponse::Artcraft(p) => {
|
||||
artcraft_router::generate::generate_image::generate_image_response::GenerateImageResponse::Artcraft(p) => {
|
||||
p.inference_job_token.as_str().to_string()
|
||||
}
|
||||
GenerateImageResponse::Fal(p) => {
|
||||
artcraft_router::generate::generate_image::generate_image_response::GenerateImageResponse::Fal(p) => {
|
||||
p.request_id.clone().unwrap_or_default()
|
||||
}
|
||||
};
|
||||
@@ -209,11 +206,11 @@ pub async fn omni_gen_image_generate_handler(
|
||||
maybe_negative_prompt: None,
|
||||
maybe_other_args: None,
|
||||
maybe_generation_mode: Some(generation_mode),
|
||||
maybe_aspect_ratio: request.aspect_ratio, // TODO: should be saved from router's decision as it could have changed
|
||||
maybe_resolution: request.resolution,// TODO: should be saved from router's decision as it could have changed
|
||||
maybe_aspect_ratio: None,
|
||||
maybe_resolution: None,
|
||||
maybe_batch_count: request.image_batch_count.map(|c| c as u8),
|
||||
maybe_generate_audio: None, // NB: Images, not video
|
||||
maybe_duration_seconds: None, // NB: Images, not video
|
||||
maybe_generate_audio: None,
|
||||
maybe_duration_seconds: None,
|
||||
creator_ip_address: &ip_address,
|
||||
mysql_executor: &mut *transaction,
|
||||
phantom: Default::default(),
|
||||
|
||||
+5
-20
@@ -1,6 +1,5 @@
|
||||
use log::{error, warn};
|
||||
|
||||
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
|
||||
use enums::common::visibility::Visibility;
|
||||
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,
|
||||
@@ -8,7 +7,8 @@ use mysql_queries::queries::generic_inference::seedance2pro::insert_generic_infe
|
||||
};
|
||||
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
|
||||
use tokens::tokens::wallet_ledger_entries::WalletLedgerEntryToken;
|
||||
use uuid_utils::uuid::generate_random_uuid;
|
||||
|
||||
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
|
||||
|
||||
use super::shared_job_args::SharedJobArgs;
|
||||
|
||||
@@ -19,12 +19,7 @@ pub struct InsertSeedance2proJobsArgs<'a, 'tx> {
|
||||
pub shared: SharedJobArgs<'a, 'tx>,
|
||||
}
|
||||
|
||||
pub struct InsertSeedance2proJobsResult {
|
||||
pub primary_job_token: InferenceJobToken,
|
||||
pub all_job_tokens: Vec<InferenceJobToken>,
|
||||
}
|
||||
|
||||
pub async fn insert_seedance2pro_jobs(args: InsertSeedance2proJobsArgs<'_, '_>) -> Result<InsertSeedance2proJobsResult, AdvancedCommonWebError> {
|
||||
pub async fn insert_seedance2pro_jobs(args: InsertSeedance2proJobsArgs<'_, '_>) -> Result<InferenceJobToken, AdvancedCommonWebError> {
|
||||
let InsertSeedance2proJobsArgs {
|
||||
primary_order_id,
|
||||
maybe_additional_order_ids,
|
||||
@@ -34,7 +29,6 @@ pub async fn insert_seedance2pro_jobs(args: InsertSeedance2proJobsArgs<'_, '_>)
|
||||
|
||||
// Build a deduplicated list with primary_order_id first.
|
||||
let mut all_order_ids = vec![primary_order_id.to_string()];
|
||||
|
||||
if let Some(additional) = maybe_additional_order_ids {
|
||||
for id in additional {
|
||||
if id != primary_order_id {
|
||||
@@ -47,11 +41,7 @@ pub async fn insert_seedance2pro_jobs(args: InsertSeedance2proJobsArgs<'_, '_>)
|
||||
|
||||
for (i, order_id) in all_order_ids.iter().enumerate() {
|
||||
let job_token = if i == 0 { shared.apriori_job_token.clone() } else { InferenceJobToken::generate() };
|
||||
|
||||
// TODO: These strings are too long for the database:
|
||||
//let idempotency_str = if i == 0 { shared.idempotency_token.to_string() } else { format!("{}-batch-{}", shared.idempotency_token, i) };
|
||||
|
||||
let idempotency_str = generate_random_uuid();
|
||||
let idempotency_str = if i == 0 { shared.idempotency_token.to_string() } else { format!("{}-batch-{}", shared.idempotency_token, i) };
|
||||
|
||||
let db_result = insert_generic_inference_job_for_seedance2pro_queue_with_apriori_job_token(
|
||||
InsertGenericInferenceForSeedance2ProWithAprioriJobTokenArgs {
|
||||
@@ -79,13 +69,8 @@ pub async fn insert_seedance2pro_jobs(args: InsertSeedance2proJobsArgs<'_, '_>)
|
||||
}
|
||||
}
|
||||
|
||||
let primary_job_token = all_job_tokens.first().cloned().ok_or_else(|| {
|
||||
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")
|
||||
})?;
|
||||
|
||||
Ok(InsertSeedance2proJobsResult {
|
||||
primary_job_token,
|
||||
all_job_tokens,
|
||||
})
|
||||
}
|
||||
|
||||
+7
-18
@@ -23,7 +23,6 @@ use mysql_queries::queries::prompt_context_items::insert_batch_prompt_context_it
|
||||
};
|
||||
use mysql_queries::queries::prompts::insert_prompt::{insert_prompt, InsertPromptArgs};
|
||||
use tokens::tokens::characters::CharacterToken;
|
||||
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
|
||||
@@ -291,10 +290,10 @@ pub async fn omni_gen_video_generate_handler(
|
||||
|
||||
// -- Inference job --
|
||||
|
||||
let (primary_job_token, all_job_tokens) = match &pipeline_result.response {
|
||||
let job_token = match &pipeline_result.response {
|
||||
GenerateVideoResponse::Seedance2Pro(payload) => {
|
||||
info!("Inserting seedance2pro job(s) with token: {:?}", pipeline_result.billing.apriori_job_token);
|
||||
let result = insert_seedance2pro_jobs(InsertSeedance2proJobsArgs {
|
||||
insert_seedance2pro_jobs(InsertSeedance2proJobsArgs {
|
||||
primary_order_id: &payload.order_id,
|
||||
maybe_additional_order_ids: payload.maybe_order_ids.as_deref(),
|
||||
maybe_wallet_ledger_entry_token: pipeline_result.billing.maybe_wallet_ledger_entry_token.as_ref(),
|
||||
@@ -307,8 +306,7 @@ pub async fn omni_gen_video_generate_handler(
|
||||
ip_address: &ip_address,
|
||||
transaction: &mut transaction,
|
||||
},
|
||||
}).await?;
|
||||
(result.primary_job_token, result.all_job_tokens)
|
||||
}).await?
|
||||
}
|
||||
GenerateVideoResponse::Fal(payload) => {
|
||||
let external_id = payload.request_id.as_deref().ok_or_else(|| {
|
||||
@@ -316,7 +314,7 @@ pub async fn omni_gen_video_generate_handler(
|
||||
AdvancedCommonWebError::server_error_with_message("Fal generation response missing request_id")
|
||||
})?;
|
||||
info!("Inserting fal job with token: {:?}", pipeline_result.billing.apriori_job_token);
|
||||
let token = insert_fal_job(InsertFalJobArgs {
|
||||
insert_fal_job(InsertFalJobArgs {
|
||||
external_job_id: external_id,
|
||||
shared: SharedJobArgs {
|
||||
apriori_job_token: &pipeline_result.billing.apriori_job_token,
|
||||
@@ -327,18 +325,10 @@ pub async fn omni_gen_video_generate_handler(
|
||||
ip_address: &ip_address,
|
||||
transaction: &mut transaction,
|
||||
},
|
||||
}).await?;
|
||||
|
||||
(
|
||||
token.clone(),
|
||||
vec![token],
|
||||
)
|
||||
}).await?
|
||||
}
|
||||
GenerateVideoResponse::Artcraft(payload) => {
|
||||
(
|
||||
payload.inference_job_token.clone(),
|
||||
vec![payload.inference_job_token.clone()],
|
||||
)
|
||||
payload.inference_job_token.clone()
|
||||
}
|
||||
other => {
|
||||
error!("Unexpected generation response variant: {:?}", other);
|
||||
@@ -353,8 +343,7 @@ pub async fn omni_gen_video_generate_handler(
|
||||
|
||||
Ok(Json(OmniGenVideoGenerateResponse {
|
||||
success: true,
|
||||
inference_job_token: primary_job_token,
|
||||
all_job_tokens,
|
||||
inference_job_token: job_token,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
-155
@@ -1,155 +0,0 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_web::web::Json;
|
||||
use actix_web::{web, HttpRequest};
|
||||
use actix_web_lab::extract::Query;
|
||||
use artcraft_api_defs::prompts::batch_get_prompts::{
|
||||
BatchGetPromptsQuery, BatchGetPromptsResponse, BatchPromptInfo,
|
||||
};
|
||||
use artcraft_api_defs::prompts::get_prompt::GetPromptImageContextItem;
|
||||
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
|
||||
use enums::by_table::prompt_context_items::prompt_context_semantic_type::PromptContextSemanticType;
|
||||
use log::warn;
|
||||
use mysql_queries::queries::prompt_context_items::batch_list_prompt_context_items::batch_list_prompt_context_items;
|
||||
use mysql_queries::queries::prompts::batch_get_prompts::batch_get_prompts;
|
||||
use tokens::tokens::prompts::PromptToken;
|
||||
|
||||
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
|
||||
use crate::http_server::common_responses::media::media_links_builder::MediaLinksBuilder;
|
||||
use crate::http_server::endpoints::media_files::helpers::get_media_domain::get_media_domain;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
const MAX_BATCH_SIZE: usize = 100;
|
||||
|
||||
/// Batch get details on multiple prompts.
|
||||
///
|
||||
/// Called, eg. {{api_host}}/v1/prompt/batch?tokens={val}&tokens={val}&tokens=...
|
||||
#[utoipa::path(
|
||||
get,
|
||||
tag = "Prompts",
|
||||
path = "/v1/prompt/batch",
|
||||
params(BatchGetPromptsQuery),
|
||||
responses(
|
||||
(status = 200, description = "Found", body = BatchGetPromptsResponse),
|
||||
(status = 400, description = "Bad request"),
|
||||
(status = 500, description = "Server error"),
|
||||
),
|
||||
)]
|
||||
pub async fn batch_get_prompts_handler(
|
||||
http_request: HttpRequest,
|
||||
query: Query<BatchGetPromptsQuery>,
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<Json<BatchGetPromptsResponse>, AdvancedCommonWebError> {
|
||||
if query.tokens.len() > MAX_BATCH_SIZE {
|
||||
return Err(AdvancedCommonWebError::BadInputWithSimpleMessage(
|
||||
format!("tokens must contain at most {} items", MAX_BATCH_SIZE),
|
||||
));
|
||||
}
|
||||
|
||||
// Deduplicate and trim whitespace
|
||||
let unique_tokens: Vec<PromptToken> = query.tokens
|
||||
.iter()
|
||||
.map(|t| PromptToken::new_from_str(t.trim()))
|
||||
.filter(|t| !t.as_str().is_empty())
|
||||
.collect::<HashSet<_>>()
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
if unique_tokens.is_empty() {
|
||||
return Ok(Json(BatchGetPromptsResponse {
|
||||
success: true,
|
||||
prompts: Vec::new(),
|
||||
}));
|
||||
}
|
||||
|
||||
let mut mysql_connection = server_state.mysql_pool.acquire().await?;
|
||||
|
||||
// Batch query: fetch all prompts in one query
|
||||
let results = batch_get_prompts(&unique_tokens, &mut mysql_connection)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
warn!("Batch get prompts query error: {:?}", err);
|
||||
AdvancedCommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
// Batch query: fetch all context items for all prompts in one query
|
||||
let all_context_items = batch_list_prompt_context_items(&unique_tokens, &mut mysql_connection)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
warn!("Batch list prompt context items query error: {:?}", err);
|
||||
AdvancedCommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let media_domain = get_media_domain(&http_request);
|
||||
|
||||
// Group context items by prompt token
|
||||
let mut context_items_map: HashMap<String, Vec<GetPromptImageContextItem>> = HashMap::new();
|
||||
|
||||
for item in all_context_items {
|
||||
// Filter to only image-like context types
|
||||
match item.context_semantic_type {
|
||||
PromptContextSemanticType::VidStartFrame
|
||||
| PromptContextSemanticType::VidEndFrame
|
||||
| PromptContextSemanticType::Imgref
|
||||
| PromptContextSemanticType::ImgrefCharacter
|
||||
| PromptContextSemanticType::ImgrefStyle
|
||||
| PromptContextSemanticType::ImgrefBg
|
||||
| PromptContextSemanticType::VidRef => {} // Include
|
||||
_ => continue, // Ignore
|
||||
}
|
||||
|
||||
let bucket_path = MediaFileBucketPath::from_object_hash(
|
||||
&item.public_bucket_directory_hash,
|
||||
item.maybe_public_bucket_prefix.as_deref(),
|
||||
item.maybe_public_bucket_extension.as_deref(),
|
||||
);
|
||||
|
||||
let context_item = GetPromptImageContextItem {
|
||||
media_token: item.media_token,
|
||||
semantic: item.context_semantic_type,
|
||||
media_links: MediaLinksBuilder::from_media_path_and_env(
|
||||
media_domain,
|
||||
server_state.server_environment,
|
||||
&bucket_path,
|
||||
),
|
||||
};
|
||||
|
||||
context_items_map
|
||||
.entry(item.prompt_token.as_str().to_string())
|
||||
.or_default()
|
||||
.push(context_item);
|
||||
}
|
||||
|
||||
// Assemble final response
|
||||
let prompts: Vec<BatchPromptInfo> = results
|
||||
.into_iter()
|
||||
.map(|result| {
|
||||
let maybe_context_images = context_items_map
|
||||
.remove(result.token.as_str())
|
||||
.filter(|items| !items.is_empty());
|
||||
|
||||
BatchPromptInfo {
|
||||
token: result.token,
|
||||
maybe_model_class: result.maybe_model_type.map(|ty| ty.get_model_class()),
|
||||
maybe_model_type: result.maybe_model_type,
|
||||
maybe_generation_provider: result.maybe_generation_provider,
|
||||
maybe_positive_prompt: result.maybe_positive_prompt,
|
||||
maybe_negative_prompt: result.maybe_negative_prompt,
|
||||
maybe_generation_mode: result.maybe_generation_mode,
|
||||
maybe_aspect_ratio: result.maybe_aspect_ratio,
|
||||
maybe_resolution: result.maybe_resolution,
|
||||
maybe_batch_count: result.maybe_batch_count,
|
||||
maybe_generate_audio: result.maybe_generate_audio,
|
||||
maybe_duration_seconds: result.maybe_duration_seconds,
|
||||
maybe_context_images,
|
||||
created_at: result.created_at,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(BatchGetPromptsResponse {
|
||||
success: true,
|
||||
prompts,
|
||||
}))
|
||||
}
|
||||
+88
-32
@@ -1,7 +1,14 @@
|
||||
use std::fmt;
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_web::web::{Json, Path};
|
||||
use actix_web::{web, HttpMessage, HttpRequest};
|
||||
use crate::http_server::common_responses::media::media_links_builder::MediaLinksBuilder;
|
||||
use crate::http_server::endpoints::media_files::helpers::get_media_domain::get_media_domain;
|
||||
use crate::http_server::web_utils::response_error_helpers::to_simple_json_error;
|
||||
use crate::state::server_state::ServerState;
|
||||
use actix_web::error::ResponseError;
|
||||
use actix_web::http::StatusCode;
|
||||
use actix_web::web::Path;
|
||||
use actix_web::{web, HttpMessage, HttpRequest, HttpResponse};
|
||||
use artcraft_api_defs::prompts::get_prompt::{
|
||||
GetPromptImageContextItem, GetPromptPathInfo, GetPromptSuccessResponse,
|
||||
PromptInfo, PromptInfoModeratorFields,
|
||||
@@ -11,11 +18,38 @@ use enums::by_table::prompt_context_items::prompt_context_semantic_type::PromptC
|
||||
use log::{error, warn};
|
||||
use mysql_queries::queries::prompt_context_items::list_prompt_context_items::list_prompt_context_items;
|
||||
use mysql_queries::queries::prompts::get_prompt::get_prompt_from_connection;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
|
||||
use crate::http_server::common_responses::media::media_links_builder::MediaLinksBuilder;
|
||||
use crate::http_server::endpoints::media_files::helpers::get_media_domain::get_media_domain;
|
||||
use crate::state::server_state::ServerState;
|
||||
#[derive(Debug, ToSchema)]
|
||||
pub enum GetPromptError {
|
||||
ServerError,
|
||||
NotFound,
|
||||
}
|
||||
|
||||
impl ResponseError for GetPromptError {
|
||||
fn status_code(&self) -> StatusCode {
|
||||
match *self {
|
||||
GetPromptError::ServerError => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
GetPromptError::NotFound => StatusCode::NOT_FOUND,
|
||||
}
|
||||
}
|
||||
|
||||
fn error_response(&self) -> HttpResponse {
|
||||
let error_reason = match self {
|
||||
GetPromptError::ServerError => "server error".to_string(),
|
||||
GetPromptError::NotFound => "not found".to_string(),
|
||||
};
|
||||
|
||||
to_simple_json_error(&error_reason, self.status_code())
|
||||
}
|
||||
}
|
||||
|
||||
// NB: Not using derive_more::Display since Clion doesn't understand it.
|
||||
impl fmt::Display for GetPromptError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{:?}", self)
|
||||
}
|
||||
}
|
||||
|
||||
/// Get details on a prompt.
|
||||
#[utoipa::path(
|
||||
@@ -24,8 +58,8 @@ use crate::state::server_state::ServerState;
|
||||
path = "/v1/prompts/{token}",
|
||||
responses(
|
||||
(status = 200, description = "Found", body = GetPromptSuccessResponse),
|
||||
(status = 404, description = "Not found"),
|
||||
(status = 500, description = "Server error"),
|
||||
(status = 404, description = "Not found", body = GetPromptError),
|
||||
(status = 500, description = "Server error", body = GetPromptError),
|
||||
),
|
||||
params(
|
||||
("path" = GetPromptPathInfo, description = "Path for Request")
|
||||
@@ -34,32 +68,44 @@ use crate::state::server_state::ServerState;
|
||||
pub async fn get_prompt_handler(
|
||||
http_request: HttpRequest,
|
||||
path: Path<GetPromptPathInfo>,
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<Json<GetPromptSuccessResponse>, AdvancedCommonWebError> {
|
||||
let mut mysql_connection = server_state.mysql_pool.acquire().await?;
|
||||
server_state: web::Data<Arc<ServerState>>) -> Result<HttpResponse, GetPromptError>
|
||||
{
|
||||
let mut mysql_connection = server_state.mysql_pool
|
||||
.acquire()
|
||||
.await
|
||||
.map_err(|err| {
|
||||
error!("Error acquiring MySQL connection: {:?}", err);
|
||||
GetPromptError::ServerError
|
||||
})?;
|
||||
|
||||
let maybe_user_session = server_state
|
||||
.session_checker
|
||||
.maybe_get_user_session_from_connection(&http_request, &mut mysql_connection)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
warn!("Session checker error: {:?}", e);
|
||||
AdvancedCommonWebError::from(e)
|
||||
})?;
|
||||
.session_checker
|
||||
.maybe_get_user_session_from_connection(&http_request, &mut mysql_connection)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
warn!("Session checker error: {:?}", e);
|
||||
GetPromptError::ServerError
|
||||
})?;
|
||||
|
||||
let is_moderator = maybe_user_session
|
||||
.map(|session| session.can_ban_users)
|
||||
.unwrap_or(false);
|
||||
.map(|session| session.can_ban_users)
|
||||
.unwrap_or(false);
|
||||
|
||||
let prompt_token = path.into_inner().token;
|
||||
|
||||
let result = get_prompt_from_connection(&prompt_token, &mut mysql_connection)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
warn!("query error: {:?}", err);
|
||||
AdvancedCommonWebError::from(err)
|
||||
})?
|
||||
.ok_or(AdvancedCommonWebError::NotFound)?;
|
||||
let result = get_prompt_from_connection(
|
||||
&prompt_token,
|
||||
&mut mysql_connection
|
||||
).await;
|
||||
|
||||
let result = match result {
|
||||
Err(e) => {
|
||||
warn!("query error: {:?}", e);
|
||||
return Err(GetPromptError::ServerError);
|
||||
}
|
||||
Ok(None) => return Err(GetPromptError::NotFound),
|
||||
Ok(Some(result)) => result,
|
||||
};
|
||||
|
||||
let mut maybe_style_name = None;
|
||||
let mut maybe_strength = None;
|
||||
@@ -68,12 +114,14 @@ pub async fn get_prompt_handler(
|
||||
let mut maybe_travel_prompt = None;
|
||||
let mut maybe_frame_skip = None;
|
||||
|
||||
// Flags
|
||||
let mut used_face_detailer = false;
|
||||
let mut used_upscaler = false;
|
||||
let mut lipsync_enabled = false;
|
||||
let mut lcm_disabled = false;
|
||||
let mut use_cinematic = false;
|
||||
|
||||
// Moderator fields
|
||||
let mut main_ipa_workflow = None;
|
||||
let mut face_detailer_workflow = None;
|
||||
let mut upscaler_workflow = None;
|
||||
@@ -88,12 +136,14 @@ pub async fn get_prompt_handler(
|
||||
maybe_travel_prompt = inner_payload.travel_prompt.clone();
|
||||
maybe_frame_skip = inner_payload.frame_skip;
|
||||
|
||||
// Flags
|
||||
used_face_detailer = inner_payload.used_face_detailer.unwrap_or(false);
|
||||
used_upscaler = inner_payload.used_upscaler.unwrap_or(false);
|
||||
lipsync_enabled = inner_payload.lipsync_enabled.unwrap_or(false);
|
||||
lcm_disabled = inner_payload.disable_lcm.unwrap_or(false);
|
||||
use_cinematic = inner_payload.use_cinematic.unwrap_or(false);
|
||||
|
||||
// Moderator fields
|
||||
main_ipa_workflow = inner_payload.main_ipa_workflow.clone();
|
||||
face_detailer_workflow = inner_payload.face_detailer_workflow.clone();
|
||||
upscaler_workflow = inner_payload.upscaler_workflow.clone();
|
||||
@@ -114,7 +164,7 @@ pub async fn get_prompt_handler(
|
||||
|
||||
let items_result = list_prompt_context_items(
|
||||
&result.token,
|
||||
&mut mysql_connection,
|
||||
&mut mysql_connection
|
||||
).await;
|
||||
|
||||
let items = items_result.unwrap_or_else(|e| {
|
||||
@@ -139,8 +189,7 @@ pub async fn get_prompt_handler(
|
||||
let bucket_path = MediaFileBucketPath::from_object_hash(
|
||||
&item.public_bucket_directory_hash,
|
||||
item.maybe_public_bucket_prefix.as_deref(),
|
||||
item.maybe_public_bucket_extension.as_deref(),
|
||||
);
|
||||
item.maybe_public_bucket_extension.as_deref());
|
||||
|
||||
Some(GetPromptImageContextItem {
|
||||
media_token: item.media_token.clone(),
|
||||
@@ -159,7 +208,7 @@ pub async fn get_prompt_handler(
|
||||
Some(items)
|
||||
};
|
||||
|
||||
Ok(Json(GetPromptSuccessResponse {
|
||||
let response = GetPromptSuccessResponse {
|
||||
success: true,
|
||||
prompt: PromptInfo {
|
||||
token: result.token,
|
||||
@@ -190,5 +239,12 @@ pub async fn get_prompt_handler(
|
||||
maybe_global_ipa_image_token,
|
||||
maybe_frame_skip,
|
||||
},
|
||||
}))
|
||||
};
|
||||
|
||||
let body = serde_json::to_string(&response)
|
||||
.map_err(|e| GetPromptError::ServerError)?;
|
||||
|
||||
Ok(HttpResponse::Ok()
|
||||
.content_type("application/json")
|
||||
.body(body))
|
||||
}
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
pub mod batch_get_prompts_handler;
|
||||
pub mod create_prompt_handler;
|
||||
pub mod get_prompt_handler;
|
||||
-2
@@ -1,4 +1,3 @@
|
||||
use crate::http_server::endpoints::prompts::batch_get_prompts_handler::batch_get_prompts_handler;
|
||||
use crate::http_server::endpoints::prompts::create_prompt_handler::create_prompt_handler;
|
||||
use crate::http_server::endpoints::prompts::get_prompt_handler::get_prompt_handler;
|
||||
use actix_helpers::route_builder::RouteBuilder;
|
||||
@@ -22,7 +21,6 @@ where
|
||||
RouteBuilder::from_app(app)
|
||||
// NB: This poor RouteBuilder utility requires that POST comes first, otherwise the GET glob
|
||||
// will capture it and force 504 Method Not Allowed for all POSTs.
|
||||
.add_get("/v1/prompt/batch", batch_get_prompts_handler)
|
||||
.add_post("/v1/prompts/create", create_prompt_handler)
|
||||
.add_get("/v1/prompts/{token}", get_prompt_handler)
|
||||
.into_app()
|
||||
|
||||
@@ -343,10 +343,10 @@
|
||||
"markdownDescription": "Denies the fetch_send command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures what kind of\nfetch operations are available from the http plugin.\n\nThis enables all fetch operations but does not\nallow explicitly any origins to be fetched. This needs to\nbe manually configured before usage.\n\n#### Granted Permissions\n\nAll fetch operations are enabled.\n\n\n#### This default permission set includes:\n\n- `allow-fetch`\n- `allow-fetch-cancel`\n- `allow-fetch-read-body`\n- `allow-fetch-send`",
|
||||
"description": "This permission set configures what kind of\r\nfetch operations are available from the http plugin.\r\n\r\nThis enables all fetch operations but does not\r\nallow explicitly any origins to be fetched. This needs to\r\nbe manually configured before usage.\r\n\r\n#### Granted Permissions\r\n\r\nAll fetch operations are enabled.\r\n\r\n\n#### This default permission set includes:\n\n- `allow-fetch`\n- `allow-fetch-cancel`\n- `allow-fetch-read-body`\n- `allow-fetch-send`",
|
||||
"type": "string",
|
||||
"const": "default",
|
||||
"markdownDescription": "This permission set configures what kind of\nfetch operations are available from the http plugin.\n\nThis enables all fetch operations but does not\nallow explicitly any origins to be fetched. This needs to\nbe manually configured before usage.\n\n#### Granted Permissions\n\nAll fetch operations are enabled.\n\n\n#### This default permission set includes:\n\n- `allow-fetch`\n- `allow-fetch-cancel`\n- `allow-fetch-read-body`\n- `allow-fetch-send`"
|
||||
"markdownDescription": "This permission set configures what kind of\r\nfetch operations are available from the http plugin.\r\n\r\nThis enables all fetch operations but does not\r\nallow explicitly any origins to be fetched. This needs to\r\nbe manually configured before usage.\r\n\r\n#### Granted Permissions\r\n\r\nAll fetch operations are enabled.\r\n\r\n\n#### This default permission set includes:\n\n- `allow-fetch`\n- `allow-fetch-cancel`\n- `allow-fetch-read-body`\n- `allow-fetch-send`"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Outfit:wght@100..900&family=Source+Sans+3:ital,wght@0,200..900;1,200..900&family=Instrument+Sans:ital,wght@0,400..700;1,400..700&family=Instrument+Serif:ital@0;1&family=Figtree:ital,wght@0,300..900;1,300..900&display=swap"
|
||||
href="https://fonts.googleapis.com/css2?family=Outfit:wght@100..900&family=Source+Sans+3:ital,wght@0,200..900;1,200..900&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -7,7 +7,6 @@ import { ToastContainer } from "../components/toast/toast";
|
||||
import CreateImage from "../pages/create-image";
|
||||
import CreateVideo from "../pages/create-video";
|
||||
import Landing2 from "../pages/landing2";
|
||||
import Landing3 from "../pages/landing3";
|
||||
import LandingSD2 from "../pages/landing-sd2";
|
||||
import TutorialsPage from "../pages/tutorials";
|
||||
import TutorialsArticle from "../pages/tutorials/article";
|
||||
@@ -30,8 +29,7 @@ export function App() {
|
||||
<Navbar />
|
||||
|
||||
<Routes>
|
||||
<Route path="/" element={<Landing3 />} />
|
||||
<Route path="/landing3" element={<Landing3 />} />
|
||||
<Route path="/" element={<Landing2 />} />
|
||||
<Route path="/create-image" element={<CreateImage />} />
|
||||
<Route path="/create-video" element={<CreateVideo />} />
|
||||
<Route path="/seedance-2" element={<LandingSD2 />} />
|
||||
|
||||
@@ -117,7 +117,7 @@ export const SignupForm = ({
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-semibold text-white/70 ml-1">
|
||||
<label className="text-xs font-bold text-white/60 uppercase tracking-wide ml-1">
|
||||
Email
|
||||
</label>
|
||||
<Input
|
||||
@@ -131,7 +131,7 @@ export const SignupForm = ({
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-semibold text-white/70 ml-1">
|
||||
<label className="text-xs font-bold text-white/60 uppercase tracking-wide ml-1">
|
||||
Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
@@ -155,7 +155,7 @@ export const SignupForm = ({
|
||||
|
||||
<div className="pt-2">
|
||||
<Button
|
||||
className="rounded-full w-full bg-primary hover:bg-primary-600 text-white border-none justify-center font-bold h-10"
|
||||
className="w-full bg-primary hover:bg-primary-600 text-white border-none justify-center font-bold h-10"
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Button } from "@storyteller/ui-button";
|
||||
import { faCoins } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { BillingApi } from "@storyteller/api";
|
||||
import { toast } from "./toast/toast";
|
||||
import { toast } from "@storyteller/ui-toaster";
|
||||
|
||||
interface CreditPack {
|
||||
id: string;
|
||||
@@ -101,7 +101,7 @@ export function CreditsModal({ isOpen, onClose }: CreditsModalProps) {
|
||||
</span>
|
||||
<Button
|
||||
variant="primary"
|
||||
className="rounded-full pointer-events-none rounded-lg px-5 py-2 text-sm font-semibold"
|
||||
className="pointer-events-none rounded-lg px-5 py-2 text-sm font-semibold"
|
||||
disabled={purchasingId !== null}
|
||||
>
|
||||
{purchasingId === pack.id ? "Loading..." : "Purchase"}
|
||||
|
||||
@@ -17,7 +17,7 @@ export const DiscordButton = ({
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={`rounded-full relative z-10 ${sizeClasses} font-semibold transition-all duration-300 shadow-lg bg-white text-black hover:bg-gray-200 ${className}`}
|
||||
className={`relative z-10 ${sizeClasses} font-semibold transition-all duration-300 shadow-lg bg-white text-black hover:bg-gray-200 ${className}`}
|
||||
icon={faDiscord}
|
||||
as="link"
|
||||
href={SOCIAL_LINKS.DISCORD}
|
||||
|
||||
@@ -37,7 +37,7 @@ export const DownloadButton = ({ className = "" }: DownloadButtonProps) => {
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={`rounded-full rounded-xl px-8 py-4 text-md transition-all duration-300 shadow-lg hover:shadow-blue-500/25 ${className}`}
|
||||
className={`rounded-xl px-8 py-4 text-md transition-all duration-300 shadow-lg hover:shadow-blue-500/25 ${className}`}
|
||||
disabled={isMobile || !downloadLink}
|
||||
icon={getIcon()}
|
||||
as="link"
|
||||
|
||||
@@ -111,7 +111,7 @@ export const DownloadModal = ({ isOpen, onClose }: DownloadModalProps) => {
|
||||
{view === "menu" ? (
|
||||
<div className="space-y-3">
|
||||
{/* <Button
|
||||
className="rounded-full w-full bg-white text-black hover:bg-gray-100 border-none justify-center gap-3 font-medium h-12"
|
||||
className="w-full bg-white text-black hover:bg-gray-100 border-none justify-center gap-3 font-medium h-12"
|
||||
onClick={() => {}} // Dummy op
|
||||
>
|
||||
<FontAwesomeIcon icon={faGoogle} className="text-lg" />
|
||||
@@ -119,7 +119,7 @@ export const DownloadModal = ({ isOpen, onClose }: DownloadModalProps) => {
|
||||
</Button> */}
|
||||
|
||||
<Button
|
||||
className="rounded-full w-full bg-white/5 hover:bg-white/10 text-white border-white/10 justify-center gap-3 font-medium h-12"
|
||||
className="w-full bg-white/5 hover:bg-white/10 text-white border-white/10 justify-center gap-3 font-medium h-12"
|
||||
onClick={() => setView("signup")}
|
||||
>
|
||||
<FontAwesomeIcon icon={faEnvelope} className="text-lg" />
|
||||
|
||||
@@ -108,24 +108,6 @@ export default function Example() {
|
||||
<Link to="/" className="text-gray-400 hover:text-white">
|
||||
Home
|
||||
</Link>
|
||||
<Link
|
||||
to="/create-image"
|
||||
className="text-gray-400 hover:text-white"
|
||||
>
|
||||
Image
|
||||
</Link>
|
||||
<Link
|
||||
to="/create-video"
|
||||
className="text-gray-400 hover:text-white"
|
||||
>
|
||||
Video
|
||||
</Link>
|
||||
<Link to="/download" className="text-gray-400 hover:text-white">
|
||||
Download
|
||||
</Link>
|
||||
<Link to="/pricing" className="text-gray-400 hover:text-white">
|
||||
Pricing
|
||||
</Link>
|
||||
<Link to="/news" className="text-gray-400 hover:text-white">
|
||||
News
|
||||
</Link>
|
||||
|
||||
-37
@@ -9,7 +9,6 @@ import { Button } from "@storyteller/ui-button";
|
||||
import { type PopoverItem } from "@storyteller/ui-popover";
|
||||
import Seo from "../../components/seo";
|
||||
import Footer from "../../components/footer";
|
||||
import { TruchetPattern } from "../truchet-pattern";
|
||||
|
||||
interface CreateMediaPageShellProps {
|
||||
// SEO
|
||||
@@ -70,22 +69,6 @@ export function CreateMediaPageShell({
|
||||
return (
|
||||
<div className="relative min-h-screen overflow-x-hidden bg-[#101014] text-white">
|
||||
<Seo title={title} description={description} />
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none fixed inset-0 z-0"
|
||||
style={{
|
||||
maskImage:
|
||||
"radial-gradient(ellipse 70% 60% at 50% 50%, black 20%, transparent 80%)",
|
||||
WebkitMaskImage:
|
||||
"radial-gradient(ellipse 70% 60% at 50% 50%, black 20%, transparent 80%)",
|
||||
}}
|
||||
>
|
||||
<TruchetPattern
|
||||
variant="auth"
|
||||
intensity={0.5}
|
||||
className="absolute inset-0 h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
<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>
|
||||
@@ -136,26 +119,6 @@ export function CreateMediaPageShell({
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Subtle truchet pattern — only on empty state */}
|
||||
{!hasContent && (
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none fixed inset-0 z-0"
|
||||
style={{
|
||||
maskImage:
|
||||
"radial-gradient(ellipse 70% 60% at 50% 50%, black 20%, transparent 80%)",
|
||||
WebkitMaskImage:
|
||||
"radial-gradient(ellipse 70% 60% at 50% 50%, black 20%, transparent 80%)",
|
||||
}}
|
||||
>
|
||||
<TruchetPattern
|
||||
variant="content"
|
||||
intensity={0.5}
|
||||
className="absolute inset-0 h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative z-[1] h-full w-full">
|
||||
<div className="flex h-full w-full flex-col">
|
||||
{!hasContent && (
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import { memo } from "react";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faCircleExclamation, faXmark } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { getModelCreatorIconPath } from "../../lib/omni-gen-hooks";
|
||||
|
||||
export interface FailedCardProps {
|
||||
id: string;
|
||||
failureReason?: string;
|
||||
failureMessage?: string;
|
||||
prompt: string;
|
||||
modelId: string;
|
||||
modelLabel: string;
|
||||
onDismiss: (id: string) => void;
|
||||
}
|
||||
@@ -18,11 +16,9 @@ export const FailedCard = memo(function FailedCard({
|
||||
failureReason,
|
||||
failureMessage,
|
||||
prompt,
|
||||
modelId,
|
||||
modelLabel,
|
||||
onDismiss,
|
||||
}: FailedCardProps) {
|
||||
const iconPath = modelId ? getModelCreatorIconPath(modelId) : null;
|
||||
return (
|
||||
<div className="relative aspect-square w-full overflow-hidden rounded-lg bg-red-500/10">
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2 px-4 sm:px-6">
|
||||
@@ -48,16 +44,7 @@ export const FailedCard = memo(function FailedCard({
|
||||
</div>
|
||||
<div className="absolute inset-x-0 bottom-0 hidden bg-gradient-to-t from-black/60 to-transparent px-3 pb-2.5 pt-6 sm:block">
|
||||
<p className="truncate text-xs text-white/70">{prompt}</p>
|
||||
<div className="mt-1 flex items-center gap-1.5">
|
||||
{iconPath && (
|
||||
<img
|
||||
src={iconPath}
|
||||
alt=""
|
||||
className="h-3 w-3 flex-shrink-0 icon-auto-contrast"
|
||||
/>
|
||||
)}
|
||||
<p className="truncate text-[10px] text-white/40">{modelLabel}</p>
|
||||
</div>
|
||||
<p className="truncate text-[10px] text-white/40">{modelLabel}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -11,10 +11,9 @@ import {
|
||||
faSpinnerThird,
|
||||
faVideo,
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import { PLACEHOLDER_IMAGES } from "@storyteller/common";
|
||||
import { addCorsParam, PLACEHOLDER_IMAGES } from "@storyteller/common";
|
||||
import { Tooltip } from "@storyteller/ui-tooltip";
|
||||
import { toast } from "../toast/toast";
|
||||
import { downloadMediaFile } from "../../lib/download-media";
|
||||
import { toast } from "@storyteller/ui-toaster";
|
||||
import {
|
||||
getModelCreatorIconPath,
|
||||
getModelDisplayName,
|
||||
@@ -120,7 +119,6 @@ export const GalleryCard = memo(function GalleryCard({
|
||||
const [ratio, setRatio] = useState<number | undefined>(cached);
|
||||
const [shareCopied, setShareCopied] = useState(false);
|
||||
const [isRecreating, setIsRecreating] = useState(false);
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
const isVideo = item.mediaClass === "video";
|
||||
const is3D = item.mediaClass === "dimensional";
|
||||
const recreateMediaClass: RecreateMediaClass | null = isVideo
|
||||
@@ -279,23 +277,9 @@ export const GalleryCard = memo(function GalleryCard({
|
||||
[item, onClick],
|
||||
);
|
||||
|
||||
const handleDownload = useCallback(
|
||||
async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (!item.fullImage || isDownloading) return;
|
||||
setIsDownloading(true);
|
||||
try {
|
||||
await downloadMediaFile({
|
||||
url: item.fullImage,
|
||||
filename: `artcraft-${item.id}`,
|
||||
mediaClass: item.mediaClass,
|
||||
});
|
||||
} finally {
|
||||
setIsDownloading(false);
|
||||
}
|
||||
},
|
||||
[item.fullImage, item.id, item.mediaClass, isDownloading],
|
||||
);
|
||||
const downloadHref = item.fullImage
|
||||
? addCorsParam(item.fullImage) || item.fullImage
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -401,20 +385,19 @@ export const GalleryCard = memo(function GalleryCard({
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
{item.fullImage && (
|
||||
{downloadHref && (
|
||||
<Tooltip content="Download" position="top">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDownload}
|
||||
disabled={isDownloading}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md text-white/85 transition-colors hover:bg-white/15 hover:text-white disabled:opacity-60"
|
||||
<a
|
||||
href={downloadHref}
|
||||
download={`artcraft-${item.id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md text-white/85 transition-colors hover:bg-white/15 hover:text-white"
|
||||
aria-label="Download"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={isDownloading ? faSpinnerThird : faArrowDownToLine}
|
||||
className={`text-sm ${isDownloading ? "animate-spin" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
<FontAwesomeIcon icon={faArrowDownToLine} className="text-sm" />
|
||||
</a>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
-2
@@ -144,14 +144,12 @@ export function GenerationGalleryGrid({
|
||||
modelLabel={entry.job.modelLabel}
|
||||
progress={entry.job.progress}
|
||||
estimatedTimeLeftMs={entry.job.estimatedTimeLeftMs}
|
||||
batchCount={entry.job.batchCount}
|
||||
/>
|
||||
)}
|
||||
{entry.kind === "failed" && (
|
||||
<FailedCard
|
||||
id={entry.job.id}
|
||||
prompt={entry.job.prompt}
|
||||
modelId={entry.job.modelId}
|
||||
modelLabel={entry.job.modelLabel}
|
||||
failureReason={entry.job.failureReason}
|
||||
failureMessage={entry.job.failureMessage}
|
||||
|
||||
@@ -10,7 +10,6 @@ export interface PendingCardProps {
|
||||
prompt: string;
|
||||
progress?: number;
|
||||
estimatedTimeLeftMs?: number;
|
||||
batchCount?: number;
|
||||
}
|
||||
|
||||
const formatTimeLeft = (ms: number): string => {
|
||||
@@ -30,7 +29,6 @@ export const PendingCard = memo(function PendingCard({
|
||||
prompt,
|
||||
progress,
|
||||
estimatedTimeLeftMs,
|
||||
batchCount,
|
||||
}: PendingCardProps) {
|
||||
const progressPercent =
|
||||
progress != null ? Math.max(0, Math.min(100, Math.round(progress))) : null;
|
||||
@@ -46,11 +44,6 @@ export const PendingCard = memo(function PendingCard({
|
||||
return (
|
||||
<div className="relative aspect-square w-full overflow-hidden rounded-lg bg-white/[0.03]">
|
||||
<div className="animate-shimmer h-full w-full" />
|
||||
{batchCount != null && batchCount > 1 && (
|
||||
<div className="absolute left-2 right-2 top-2 z-10 rounded-md bg-black/60 px-2.5 py-1.5 text-center text-[10px] leading-snug text-white/70 backdrop-blur-sm">
|
||||
Generating {batchCount} videos · Results may appear one at a time
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2">
|
||||
<FontAwesomeIcon
|
||||
icon={faSpinnerThird}
|
||||
|
||||
+46
-103
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { JobsApi, JobStatus, MediaFilesApi } from "@storyteller/api";
|
||||
import type { Job, Prompts } from "@storyteller/api";
|
||||
import type { Job } from "@storyteller/api";
|
||||
import { getMediaThumbnail, THUMBNAIL_SIZES } from "@storyteller/common";
|
||||
import {
|
||||
getModelDisplayName,
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
ALL_MODELS_LIST,
|
||||
} from "@storyteller/model-list";
|
||||
import type { GalleryItem } from "./useGalleryData";
|
||||
import { getCachedPrompt, usePrompts } from "../../lib/prompts-cache";
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -20,7 +19,6 @@ export interface InProgressJob {
|
||||
progress: number;
|
||||
estimatedTimeLeftMs?: number;
|
||||
createdAt: string;
|
||||
batchCount?: number;
|
||||
}
|
||||
|
||||
export interface FailedJob {
|
||||
@@ -70,48 +68,21 @@ function getJobMediaType(job: Job): "image" | "video" | "other" {
|
||||
return "other";
|
||||
}
|
||||
|
||||
function getModelLabel(job: Job, promptsMap?: Map<string, Prompts>): string {
|
||||
const promptToken = job.request.maybe_prompt_token;
|
||||
const cachedPrompt = promptToken
|
||||
? (promptsMap?.get(promptToken) ?? getCachedPrompt(promptToken))
|
||||
: undefined;
|
||||
|
||||
const modelType =
|
||||
cachedPrompt?.maybe_model_type ?? job.request.maybe_model_type ?? "";
|
||||
const providerKey = cachedPrompt?.maybe_generation_provider ?? modelType;
|
||||
|
||||
function getModelLabel(job: Job): string {
|
||||
const modelType = job.request.maybe_model_type ?? "";
|
||||
const modelDisplay = modelType ? getModelDisplayName(modelType) : undefined;
|
||||
const provider = providerKey
|
||||
? getProviderDisplayName(providerKey.toLowerCase())
|
||||
const provider = modelType
|
||||
? getProviderDisplayName(modelType.toLowerCase())
|
||||
: undefined;
|
||||
|
||||
if (modelDisplay && provider) return `${modelDisplay} · ${provider}`;
|
||||
return modelDisplay || provider || "Unknown model";
|
||||
if (modelDisplay && provider) return `${modelDisplay} — ${provider}`;
|
||||
return modelDisplay || job.request.maybe_model_title || "Unknown model";
|
||||
}
|
||||
|
||||
function getPrompt(job: Job, promptsMap?: Map<string, Prompts>): string {
|
||||
const promptToken = job.request.maybe_prompt_token;
|
||||
const cached = promptToken
|
||||
? (promptsMap?.get(promptToken) ?? getCachedPrompt(promptToken))
|
||||
: undefined;
|
||||
return (
|
||||
cached?.maybe_positive_prompt ||
|
||||
job.request.maybe_raw_inference_text ||
|
||||
""
|
||||
);
|
||||
function getPrompt(job: Job): string {
|
||||
return job.request.maybe_raw_inference_text || "";
|
||||
}
|
||||
|
||||
function getModelId(job: Job, promptsMap?: Map<string, Prompts>): string {
|
||||
const promptToken = job.request.maybe_prompt_token;
|
||||
const cachedPrompt = promptToken
|
||||
? (promptsMap?.get(promptToken) ?? getCachedPrompt(promptToken))
|
||||
: undefined;
|
||||
return (
|
||||
cachedPrompt?.maybe_model_type ?? job.request.maybe_model_type ?? ""
|
||||
);
|
||||
}
|
||||
|
||||
function jobToInProgress(job: Job, promptsMap: Map<string, Prompts>): InProgressJob {
|
||||
function jobToInProgress(job: Job): InProgressJob {
|
||||
const now = Date.now();
|
||||
const createdMs = new Date(job.created_at).getTime();
|
||||
const modelType = job.request.maybe_model_type;
|
||||
@@ -135,16 +106,16 @@ function jobToInProgress(job: Job, promptsMap: Map<string, Prompts>): InProgress
|
||||
|
||||
return {
|
||||
id: job.job_token,
|
||||
prompt: getPrompt(job, promptsMap),
|
||||
modelId: getModelId(job, promptsMap),
|
||||
modelLabel: getModelLabel(job, promptsMap),
|
||||
prompt: getPrompt(job),
|
||||
modelId: job.request.maybe_model_type ?? "",
|
||||
modelLabel: getModelLabel(job),
|
||||
progress,
|
||||
estimatedTimeLeftMs,
|
||||
createdAt: job.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
function jobToFailed(job: Job, promptsMap: Map<string, Prompts>): FailedJob {
|
||||
function jobToFailed(job: Job): FailedJob {
|
||||
const failureCategory =
|
||||
job.status.maybe_failure_category_updated ||
|
||||
job.status.maybe_failure_category;
|
||||
@@ -159,9 +130,9 @@ function jobToFailed(job: Job, promptsMap: Map<string, Prompts>): FailedJob {
|
||||
|
||||
return {
|
||||
id: job.job_token,
|
||||
prompt: getPrompt(job, promptsMap),
|
||||
modelId: getModelId(job, promptsMap),
|
||||
modelLabel: getModelLabel(job, promptsMap),
|
||||
prompt: getPrompt(job),
|
||||
modelId: job.request.maybe_model_type ?? "",
|
||||
modelLabel: getModelLabel(job),
|
||||
failureReason,
|
||||
failureMessage,
|
||||
status: job.status.status,
|
||||
@@ -169,10 +140,7 @@ function jobToFailed(job: Job, promptsMap: Map<string, Prompts>): FailedJob {
|
||||
};
|
||||
}
|
||||
|
||||
function jobToGalleryItem(
|
||||
job: Job,
|
||||
promptsMap?: Map<string, Prompts>,
|
||||
): GalleryItem | null {
|
||||
function jobToGalleryItem(job: Job): GalleryItem | null {
|
||||
const result = job.maybe_result;
|
||||
if (!result?.entity_token) return null;
|
||||
|
||||
@@ -184,7 +152,7 @@ function jobToGalleryItem(
|
||||
|
||||
return {
|
||||
id: result.entity_token,
|
||||
label: getPrompt(job, promptsMap) || "Generation",
|
||||
label: getPrompt(job) || "Generation",
|
||||
thumbnail,
|
||||
fullImage: result.media_links?.cdn_url || null,
|
||||
// Sort by job creation time (not completion time) so the completed card
|
||||
@@ -245,51 +213,13 @@ export function useGenerationJobs(options: {
|
||||
const apiRef = useRef(new JobsApi());
|
||||
const mediaApiRef = useRef(new MediaFilesApi());
|
||||
|
||||
const [inProgressJobs, setInProgressJobs] = useState<Job[]>([]);
|
||||
const [failedJobsRaw, setFailedJobsRaw] = useState<Job[]>([]);
|
||||
const [inProgress, setInProgress] = useState<InProgressJob[]>([]);
|
||||
const [failed, setFailed] = useState<FailedJob[]>([]);
|
||||
const [newlyCompleted, setNewlyCompleted] = useState<GalleryItem[]>([]);
|
||||
|
||||
const prevCompletedIdsRef = useRef<Set<string>>(new Set());
|
||||
const initialLoadDoneRef = useRef(false);
|
||||
|
||||
const promptTokens = useMemo(() => {
|
||||
const tokens: string[] = [];
|
||||
for (const j of inProgressJobs) {
|
||||
if (j.request.maybe_prompt_token) tokens.push(j.request.maybe_prompt_token);
|
||||
}
|
||||
for (const j of failedJobsRaw) {
|
||||
if (j.request.maybe_prompt_token) tokens.push(j.request.maybe_prompt_token);
|
||||
}
|
||||
return tokens;
|
||||
}, [inProgressJobs, failedJobsRaw]);
|
||||
const promptsMap = usePrompts(promptTokens);
|
||||
|
||||
const inProgress = useMemo(
|
||||
() =>
|
||||
inProgressJobs
|
||||
.slice()
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(b.created_at).getTime() -
|
||||
new Date(a.created_at).getTime(),
|
||||
)
|
||||
.map((j) => jobToInProgress(j, promptsMap)),
|
||||
[inProgressJobs, promptsMap],
|
||||
);
|
||||
|
||||
const failed = useMemo(
|
||||
() =>
|
||||
failedJobsRaw
|
||||
.slice()
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(b.updated_at).getTime() -
|
||||
new Date(a.updated_at).getTime(),
|
||||
)
|
||||
.map((j) => jobToFailed(j, promptsMap)),
|
||||
[failedJobsRaw, promptsMap],
|
||||
);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const response = await apiRef.current.ListRecentJobs();
|
||||
@@ -300,12 +230,25 @@ export function useGenerationJobs(options: {
|
||||
// Filter by media type
|
||||
const filtered = jobs.filter((j) => getJobMediaType(j) === mediaType);
|
||||
|
||||
const newInProgress = filtered.filter((j) =>
|
||||
IN_PROGRESS_STATUSES.has(j.status.status),
|
||||
);
|
||||
const newFailed = filtered.filter((j) =>
|
||||
FAILED_STATUSES.has(j.status.status),
|
||||
);
|
||||
// In-progress
|
||||
const inProg = filtered
|
||||
.filter((j) => IN_PROGRESS_STATUSES.has(j.status.status))
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(b.created_at).getTime() -
|
||||
new Date(a.created_at).getTime(),
|
||||
)
|
||||
.map(jobToInProgress);
|
||||
|
||||
// Failed
|
||||
const failedJobs = filtered
|
||||
.filter((j) => FAILED_STATUSES.has(j.status.status))
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(b.updated_at).getTime() -
|
||||
new Date(a.updated_at).getTime(),
|
||||
)
|
||||
.map(jobToFailed);
|
||||
|
||||
// Completed
|
||||
const completedJobs = filtered.filter((j) =>
|
||||
@@ -321,7 +264,7 @@ export function useGenerationJobs(options: {
|
||||
);
|
||||
if (newOnes.length > 0) {
|
||||
const items = newOnes
|
||||
.map((j) => jobToGalleryItem(j))
|
||||
.map(jobToGalleryItem)
|
||||
.filter((item): item is GalleryItem => item !== null);
|
||||
if (items.length > 0) {
|
||||
// Await expansion so the pending card and its completed replacement
|
||||
@@ -337,7 +280,7 @@ export function useGenerationJobs(options: {
|
||||
prevCompletedIdsRef.current = completedIdSet;
|
||||
|
||||
// Prune duration cache
|
||||
const activeIds = new Set(newInProgress.map((j) => j.job_token));
|
||||
const activeIds = new Set(inProg.map((t) => t.id));
|
||||
for (const id of taskDurationCache.keys()) {
|
||||
if (!activeIds.has(id)) taskDurationCache.delete(id);
|
||||
}
|
||||
@@ -349,8 +292,8 @@ export function useGenerationJobs(options: {
|
||||
return [...fresh, ...prev];
|
||||
});
|
||||
}
|
||||
setInProgressJobs(newInProgress);
|
||||
setFailedJobsRaw(newFailed);
|
||||
setInProgress(inProg);
|
||||
setFailed(failedJobs);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
@@ -374,7 +317,7 @@ export function useGenerationJobs(options: {
|
||||
async (jobToken: string) => {
|
||||
try {
|
||||
await apiRef.current.DeleteJobByToken(jobToken);
|
||||
setFailedJobsRaw((prev) => prev.filter((f) => f.job_token !== jobToken));
|
||||
setFailed((prev) => prev.filter((f) => f.id !== jobToken));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
@@ -1,242 +0,0 @@
|
||||
import { forwardRef, useImperativeHandle, useRef } from "react";
|
||||
import gsap from "gsap";
|
||||
|
||||
export interface KnightCinemaHandle {
|
||||
cinemaEl: HTMLDivElement | null;
|
||||
videoEl: HTMLVideoElement | null;
|
||||
topBarEl: HTMLDivElement | null;
|
||||
bottomBarEl: HTMLDivElement | null;
|
||||
progressBarEl: HTMLDivElement | null;
|
||||
timecodeEl: HTMLSpanElement | null;
|
||||
}
|
||||
|
||||
interface KnightCinemaProps {
|
||||
src: string;
|
||||
topLabel?: string;
|
||||
bottomLabel?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Letterbox cinema for the manifesto section: full-bleed scroll-scrubbed
|
||||
* video framed by black bars carrying meta (brand label, scene label, live
|
||||
* timecode + progress line). The element refs are exposed via an imperative
|
||||
* handle so the parent can drive entrance/exit/scrub animations on its own
|
||||
* GSAP timeline (see `setupKnightCinemaTimeline` below).
|
||||
*/
|
||||
export const KnightCinema = forwardRef<KnightCinemaHandle, KnightCinemaProps>(
|
||||
({ src, topLabel = "SEEDANCE 2.0", bottomLabel = "Knight Sneaking IN THE CASTLE" }, ref) => {
|
||||
const cinemaRef = useRef<HTMLDivElement>(null);
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const topBarRef = useRef<HTMLDivElement>(null);
|
||||
const bottomBarRef = useRef<HTMLDivElement>(null);
|
||||
const progressBarRef = useRef<HTMLDivElement>(null);
|
||||
const timecodeRef = useRef<HTMLSpanElement>(null);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
get cinemaEl() {
|
||||
return cinemaRef.current;
|
||||
},
|
||||
get videoEl() {
|
||||
return videoRef.current;
|
||||
},
|
||||
get topBarEl() {
|
||||
return topBarRef.current;
|
||||
},
|
||||
get bottomBarEl() {
|
||||
return bottomBarRef.current;
|
||||
},
|
||||
get progressBarEl() {
|
||||
return progressBarRef.current;
|
||||
},
|
||||
get timecodeEl() {
|
||||
return timecodeRef.current;
|
||||
},
|
||||
}));
|
||||
|
||||
return (
|
||||
<div aria-hidden className="absolute inset-0 z-20 pointer-events-none">
|
||||
{/* Full-bleed video, wrapped so we can scale + round-corner it during
|
||||
the phase 5 exit. overflow-hidden + GPU-promoted transform layer
|
||||
= smooth resize. */}
|
||||
<div
|
||||
ref={cinemaRef}
|
||||
className="absolute inset-0 overflow-hidden bg-black"
|
||||
style={{
|
||||
transformOrigin: "center center",
|
||||
willChange: "transform, border-radius",
|
||||
}}
|
||||
>
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={src}
|
||||
muted
|
||||
playsInline
|
||||
preload="auto"
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Top letterbox bar */}
|
||||
<div
|
||||
ref={topBarRef}
|
||||
className="absolute top-0 inset-x-0 h-[12vh] bg-black flex items-end px-6 sm:px-10 pb-3 sm:pb-4 will-change-transform"
|
||||
>
|
||||
<span className="inline-flex items-center gap-2 text-[10px] sm:text-[11px] uppercase tracking-[0.32em] font-semibold text-white/55">
|
||||
<span className="inline-block h-1 w-1 rounded-full bg-primary" />
|
||||
{topLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Bottom letterbox bar */}
|
||||
<div
|
||||
ref={bottomBarRef}
|
||||
className="absolute bottom-0 inset-x-0 h-[12vh] bg-black flex items-start justify-between gap-4 px-6 sm:px-10 pt-3 sm:pt-4 will-change-transform"
|
||||
>
|
||||
<span className="hidden sm:inline-flex items-center text-[10px] sm:text-[11px] uppercase tracking-[0.32em] font-semibold text-white/55">
|
||||
{bottomLabel}
|
||||
</span>
|
||||
<div className="ml-auto flex items-center gap-3 sm:gap-4 shrink-0">
|
||||
<div className="relative w-28 sm:w-48 h-px bg-white/15">
|
||||
<div
|
||||
ref={progressBarRef}
|
||||
className="absolute inset-y-0 left-0 bg-white/70"
|
||||
style={{ width: "0%" }}
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
ref={timecodeRef}
|
||||
className="text-[10px] sm:text-[11px] tabular-nums text-white/65 min-w-[68px] sm:min-w-[80px] text-right"
|
||||
>
|
||||
00:00 / 00:00
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
KnightCinema.displayName = "KnightCinema";
|
||||
|
||||
/**
|
||||
* Appends the cinema's three animation phases to a GSAP timeline:
|
||||
* 3. Letterbox bars slide in + cinema fades in
|
||||
* 4. Scroll-scrubbed video (with live timecode + progress line)
|
||||
* 5. Bars retract + cinema scales down to navbar width with rounded corners
|
||||
*
|
||||
* Call this AFTER you've added the manifesto words / hold / text-fade phases
|
||||
* to your timeline. The phases are appended at the end (`>`).
|
||||
*
|
||||
* Note: the parent timeline's ScrollTrigger should set `invalidateOnRefresh: true`
|
||||
* so the function-based scale in phase 5 re-evaluates on viewport resize.
|
||||
*/
|
||||
export const setupKnightCinemaTimeline = (
|
||||
tl: gsap.core.Timeline,
|
||||
knight: KnightCinemaHandle,
|
||||
) => {
|
||||
const {
|
||||
cinemaEl,
|
||||
videoEl,
|
||||
topBarEl,
|
||||
bottomBarEl,
|
||||
progressBarEl,
|
||||
timecodeEl,
|
||||
} = knight;
|
||||
|
||||
// Initial state — bars off-screen, cinema invisible. Set on the *element*
|
||||
// (not in CSS) so a hot-reload during dev doesn't leave residual transforms.
|
||||
if (topBarEl) gsap.set(topBarEl, { yPercent: -100 });
|
||||
if (bottomBarEl) gsap.set(bottomBarEl, { yPercent: 100 });
|
||||
if (cinemaEl)
|
||||
gsap.set(cinemaEl, { opacity: 0, scale: 1, borderRadius: 0 });
|
||||
|
||||
// Phase 3 — bars slide in from off-screen edges, framing the cinema.
|
||||
// Cinema fades in slightly after the bars start moving so the bars
|
||||
// register as the framing element first.
|
||||
if (topBarEl && bottomBarEl) {
|
||||
tl.to(
|
||||
[topBarEl, bottomBarEl],
|
||||
{ yPercent: 0, duration: 4, ease: "power3.out" },
|
||||
">",
|
||||
);
|
||||
}
|
||||
if (cinemaEl) {
|
||||
tl.to(
|
||||
cinemaEl,
|
||||
{ opacity: 1, duration: 3, ease: "power2.out" },
|
||||
"<+=0.5",
|
||||
);
|
||||
}
|
||||
|
||||
// Phase 4 — scroll-scrub the video. GSAP can't tween a video element
|
||||
// directly, so we tween a proxy `t` from 0→1 and write currentTime each
|
||||
// frame. Same onUpdate keeps the bottom-bar timecode + progress line in
|
||||
// sync. Seek calls are throttled to ~30Hz to avoid saturating the decoder
|
||||
// on larger paint surfaces (1440p+).
|
||||
if (videoEl) {
|
||||
const videoProxy = { t: 0 };
|
||||
const formatTC = (s: number) => {
|
||||
const safe = Number.isFinite(s) ? Math.max(0, s) : 0;
|
||||
const m = Math.floor(safe / 60);
|
||||
const sec = Math.floor(safe % 60);
|
||||
return `${String(m).padStart(2, "0")}:${String(sec).padStart(2, "0")}`;
|
||||
};
|
||||
const SEEK_INTERVAL_MS = 15;
|
||||
let lastSeekAt = 0;
|
||||
tl.to(
|
||||
videoProxy,
|
||||
{
|
||||
t: 0.999,
|
||||
duration: 22,
|
||||
ease: "none",
|
||||
onUpdate: () => {
|
||||
const now = performance.now();
|
||||
if (now - lastSeekAt < SEEK_INTERVAL_MS) return;
|
||||
lastSeekAt = now;
|
||||
if (!videoEl.duration) return;
|
||||
const time = videoProxy.t * videoEl.duration;
|
||||
videoEl.currentTime = time;
|
||||
if (timecodeEl) {
|
||||
timecodeEl.textContent = `${formatTC(time)} / ${formatTC(videoEl.duration)}`;
|
||||
}
|
||||
if (progressBarEl) {
|
||||
progressBarEl.style.width = `${videoProxy.t * 100}%`;
|
||||
}
|
||||
},
|
||||
},
|
||||
">",
|
||||
);
|
||||
}
|
||||
|
||||
// Phase 5 — exit. Bars retract off-screen, cinema scales down to navbar
|
||||
// width (Tailwind max-w-6xl = 1152px) and rounds its corners. The scale
|
||||
// is computed from window.innerWidth so the final card is the same
|
||||
// absolute pixel width on every screen — paired with the parent
|
||||
// ScrollTrigger's `invalidateOnRefresh: true` it stays exact on resize.
|
||||
if (topBarEl && bottomBarEl) {
|
||||
tl.to(
|
||||
topBarEl,
|
||||
{ yPercent: -100, opacity: 0, duration: 2, ease: "power2.in" },
|
||||
">",
|
||||
);
|
||||
tl.to(
|
||||
bottomBarEl,
|
||||
{ yPercent: 100, opacity: 0, duration: 2, ease: "power2.in" },
|
||||
"<",
|
||||
);
|
||||
}
|
||||
if (cinemaEl) {
|
||||
const NAVBAR_WIDTH_PX = 1152;
|
||||
tl.to(
|
||||
cinemaEl,
|
||||
{
|
||||
scale: () => Math.min(1, NAVBAR_WIDTH_PX / window.innerWidth),
|
||||
// Border radius is overspecified relative to the post-scale visual
|
||||
// size (40 × scale ≈ pillowy 20-25px corners on typical desktops).
|
||||
borderRadius: 40,
|
||||
duration: 6,
|
||||
ease: "power2.inOut",
|
||||
},
|
||||
"<+=0.3",
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import dayjs from "dayjs";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import { toast } from "../toast/toast";
|
||||
import { toast } from "@storyteller/ui-toaster";
|
||||
import { Gravatar } from "@storyteller/ui-gravatar";
|
||||
import type { UserInfo } from "@storyteller/api";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
@@ -14,12 +14,12 @@ import {
|
||||
faImage,
|
||||
faLink,
|
||||
faPencil,
|
||||
faSpinnerThird,
|
||||
faTrashCan,
|
||||
faUser,
|
||||
faXmark,
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import {
|
||||
addCorsParam,
|
||||
getContextImageThumbnail,
|
||||
THUMBNAIL_SIZES,
|
||||
} from "@storyteller/common";
|
||||
@@ -29,7 +29,6 @@ import {
|
||||
getProviderDisplayName,
|
||||
getProviderIconByName,
|
||||
} from "@storyteller/model-list";
|
||||
import { downloadMediaFile } from "../../lib/download-media";
|
||||
import {
|
||||
formatAspectRatio,
|
||||
formatDuration,
|
||||
@@ -89,7 +88,6 @@ export function LightboxDetails({
|
||||
}: LightboxDetailsProps) {
|
||||
const promptCopy = useCopyFeedback();
|
||||
const shareCopy = useCopyFeedback();
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
|
||||
const [isPromptExpanded, setIsPromptExpanded] = useState(false);
|
||||
const [isPromptClamped, setIsPromptClamped] = useState(false);
|
||||
@@ -97,7 +95,6 @@ export function LightboxDetails({
|
||||
|
||||
useEffect(() => {
|
||||
setIsPromptExpanded(false);
|
||||
setIsDownloading(false);
|
||||
}, [mediaToken]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -351,7 +348,7 @@ export function LightboxDetails({
|
||||
{createdAt && (
|
||||
<InfoRow
|
||||
label="Created"
|
||||
value={dayjs(createdAt).format("MMMM D, YYYY h:mm:ss A")}
|
||||
value={dayjs(createdAt).format("MMMM D, YYYY")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -371,33 +368,23 @@ export function LightboxDetails({
|
||||
>
|
||||
{shareCopy.copied ? "Copied" : "Share"}
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!mediaUrl || isDownloading}
|
||||
onClick={async () => {
|
||||
if (!mediaUrl || isDownloading) return;
|
||||
setIsDownloading(true);
|
||||
try {
|
||||
await downloadMediaFile({
|
||||
url: mediaUrl,
|
||||
filename: `artcraft-${mediaToken || "media"}`,
|
||||
});
|
||||
} finally {
|
||||
setIsDownloading(false);
|
||||
}
|
||||
}}
|
||||
<a
|
||||
className={`w-full inline-flex items-center justify-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors border border-ui-panel-border ${
|
||||
mediaUrl
|
||||
? "bg-ui-controls/40 hover:bg-ui-controls/60 text-white disabled:opacity-60"
|
||||
: "bg-ui-controls/20 text-white/60 cursor-not-allowed"
|
||||
? "bg-ui-controls/40 hover:bg-ui-controls/60 text-white"
|
||||
: "bg-ui-controls/20 text-white/60 cursor-not-allowed pointer-events-none"
|
||||
}`}
|
||||
href={mediaUrl ? addCorsParam(mediaUrl) || mediaUrl : undefined}
|
||||
download={
|
||||
mediaUrl ? `artcraft-${mediaToken || "media"}` : undefined
|
||||
}
|
||||
aria-disabled={!mediaUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={isDownloading ? faSpinnerThird : faArrowDownToLine}
|
||||
className={isDownloading ? "animate-spin" : ""}
|
||||
/>
|
||||
{isDownloading ? "Downloading…" : "Download"}
|
||||
</button>
|
||||
<FontAwesomeIcon icon={faArrowDownToLine} />
|
||||
Download
|
||||
</a>
|
||||
</div>
|
||||
{onRecreate && promptData.hasToken && (
|
||||
<Button
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Modal } from "@storyteller/ui-modal";
|
||||
import { LoadingSpinner } from "@storyteller/ui-loading-spinner";
|
||||
import { toast } from "../toast/toast";
|
||||
import { toast } from "@storyteller/ui-toaster";
|
||||
import { useEffect, useState, useMemo, useCallback } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { MediaFilesApi, PromptsApi, type UserInfo } from "@storyteller/api";
|
||||
|
||||
@@ -1,319 +0,0 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import * as THREE from "three";
|
||||
import { RoomEnvironment } from "three/examples/jsm/environments/RoomEnvironment.js";
|
||||
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
|
||||
|
||||
interface ManifestoThreeBackgroundProps {
|
||||
progressRef: React.RefObject<number>;
|
||||
/**
|
||||
* When `true`, the render loop skips the expensive `renderer.render` +
|
||||
* `mixer.update` calls. Used to halt GPU work once the character has walked
|
||||
* off screen and the canvas is fully covered by other UI — saves significant
|
||||
* paint cost on high-DPI / large displays.
|
||||
*/
|
||||
pausedRef?: React.RefObject<boolean>;
|
||||
}
|
||||
|
||||
export const ManifestoThreeBackground = ({
|
||||
progressRef,
|
||||
pausedRef,
|
||||
}: ManifestoThreeBackgroundProps) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
const container = containerRef.current;
|
||||
|
||||
// CAPTURE MODE — disabled. Uncomment this block + the matching sections
|
||||
// below (search "CAPTURE MODE") to record a self-driven walk video via
|
||||
// `?capture=manifesto`. Used to produce a motion-reference clip for
|
||||
// Seedance without depending on smooth manual scrolling.
|
||||
// const captureMode =
|
||||
// typeof window !== "undefined" &&
|
||||
// new URLSearchParams(window.location.search).get("capture") ===
|
||||
// "manifesto";
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
// if (captureMode) {
|
||||
// // Solid background so the recorded video is self-contained (no alpha).
|
||||
// scene.background = new THREE.Color(0x101014);
|
||||
// }
|
||||
// Long-lens (telephoto) feel — narrow FOV + camera pulled further back.
|
||||
// Flattens perspective distortion so the scene reads as a 2D-ish plane
|
||||
// instead of a wide-angle bowl.
|
||||
const camera = new THREE.PerspectiveCamera(
|
||||
22,
|
||||
container.clientWidth / container.clientHeight,
|
||||
0.1,
|
||||
100,
|
||||
);
|
||||
camera.position.z = 20;
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
renderer.setSize(container.clientWidth, container.clientHeight);
|
||||
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
||||
renderer.toneMappingExposure = 0.7;
|
||||
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||
renderer.shadowMap.enabled = true;
|
||||
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
||||
container.appendChild(renderer.domElement);
|
||||
|
||||
// Pre-baked environment for PBR reflections — what makes metals/clearcoat
|
||||
// actually look like materials instead of flat-shaded primitives.
|
||||
const pmrem = new THREE.PMREMGenerator(renderer);
|
||||
pmrem.compileEquirectangularShader();
|
||||
const envScene = new RoomEnvironment();
|
||||
const envTexture = pmrem.fromScene(envScene, 0.04).texture;
|
||||
scene.environment = envTexture;
|
||||
|
||||
// Cinematic three-point lighting — softer ambient so shadows read deeper,
|
||||
// strong key (with shadow casting) for grounding, cool blue rim behind
|
||||
// for silhouette definition against the dark scene.
|
||||
scene.add(new THREE.HemisphereLight(0x3d6cb0, 0x06060c, 0.12));
|
||||
const keyLight = new THREE.DirectionalLight(0xffffff, 0.85);
|
||||
keyLight.position.set(6, 8, 5);
|
||||
keyLight.castShadow = true;
|
||||
keyLight.shadow.mapSize.set(1024, 1024);
|
||||
keyLight.shadow.camera.near = 1;
|
||||
keyLight.shadow.camera.far = 40;
|
||||
keyLight.shadow.camera.left = -8;
|
||||
keyLight.shadow.camera.right = 8;
|
||||
keyLight.shadow.camera.top = 6;
|
||||
keyLight.shadow.camera.bottom = -6;
|
||||
keyLight.shadow.bias = -0.0008;
|
||||
keyLight.shadow.radius = 4;
|
||||
scene.add(keyLight);
|
||||
const rimLight = new THREE.DirectionalLight(0x6ba6ff, 0.8);
|
||||
rimLight.position.set(-6, 3, -4);
|
||||
scene.add(rimLight);
|
||||
const fillLight = new THREE.PointLight(0x3a78d6, 0.3, 20);
|
||||
fillLight.position.set(-3, -2, 5);
|
||||
scene.add(fillLight);
|
||||
|
||||
// Invisible ground plane — only shows the cast shadow. Sits at the
|
||||
// character's feet so the contact point reads.
|
||||
const ground = new THREE.Mesh(
|
||||
new THREE.PlaneGeometry(40, 20),
|
||||
new THREE.ShadowMaterial({ opacity: 0.25 }),
|
||||
);
|
||||
ground.rotation.x = -Math.PI / 2;
|
||||
ground.position.y = -2.5;
|
||||
ground.receiveShadow = true;
|
||||
scene.add(ground);
|
||||
|
||||
// Character loaded async from FBX. The mixer is driven by scroll progress
|
||||
// (not a real-time clock) so the animation scrubs with the user's scroll.
|
||||
let character: THREE.Group | null = null;
|
||||
let mixer: THREE.AnimationMixer | null = null;
|
||||
let action: THREE.AnimationAction | null = null;
|
||||
let clipDuration = 0;
|
||||
let cancelled = false;
|
||||
|
||||
// Number of walk cycles played across the full section scroll. >1 keeps
|
||||
// the legs cycling visibly rather than crawling through a single step.
|
||||
const WALK_CYCLES = 2;
|
||||
|
||||
const characterMaterial = new THREE.MeshPhysicalMaterial({
|
||||
color: 0x07142a,
|
||||
metalness: 0.35,
|
||||
roughness: 0.55,
|
||||
clearcoat: 0.4,
|
||||
clearcoatRoughness: 0.35,
|
||||
envMapIntensity: 0.45,
|
||||
});
|
||||
|
||||
new GLTFLoader().load(
|
||||
"/3d-models/sneaking-forward.glb",
|
||||
(gltf) => {
|
||||
if (cancelled) return;
|
||||
|
||||
const root = gltf.scene;
|
||||
|
||||
// Mixamo defaults: ~100 unit tall, faces -Z. FBX → GLB conversions
|
||||
// typically apply a 0.01 cm→m scale, so the GLB lands roughly 100×
|
||||
// smaller than the FBX. Scale up to match the previous on-screen
|
||||
// size and rotate so the character faces the direction of travel (+X).
|
||||
root.scale.setScalar(3.5);
|
||||
root.rotation.y = Math.PI / 2;
|
||||
|
||||
// Override default materials with the scene's PBR look, and enable
|
||||
// shadow casting so the figure grounds visually.
|
||||
root.traverse((child) => {
|
||||
const mesh = child as THREE.Mesh;
|
||||
if (mesh.isMesh) {
|
||||
mesh.material = characterMaterial;
|
||||
mesh.castShadow = true;
|
||||
}
|
||||
});
|
||||
|
||||
scene.add(root);
|
||||
character = root;
|
||||
|
||||
if (gltf.animations.length > 0) {
|
||||
mixer = new THREE.AnimationMixer(root);
|
||||
const clip = gltf.animations[0];
|
||||
clipDuration = clip.duration;
|
||||
action = mixer.clipAction(clip);
|
||||
action.play();
|
||||
// Pause real-time playback — we'll set action.time manually in tick.
|
||||
// (For CAPTURE MODE: set this to `!captureMode` instead.)
|
||||
action.paused = true;
|
||||
}
|
||||
|
||||
// CAPTURE MODE: if (captureMode) startCaptureRecording();
|
||||
},
|
||||
undefined,
|
||||
(err: unknown) => console.error("manifesto character load failed", err),
|
||||
);
|
||||
|
||||
const clamp01 = (v: number) => THREE.MathUtils.clamp(v, 0, 1);
|
||||
|
||||
// CAPTURE MODE — disabled. Records the canvas in real time and downloads
|
||||
// the walk as a video. To re-enable: uncomment, restore the call in the
|
||||
// GLTF load callback above, and the capture branch in the tick below.
|
||||
// let recorder: MediaRecorder | null = null;
|
||||
// let captureStart = 0;
|
||||
// const captureChunks: Blob[] = [];
|
||||
// const captureDuration = 6; // seconds of recorded walk
|
||||
// const startCaptureRecording = () => {
|
||||
// const stream = (
|
||||
// renderer.domElement as HTMLCanvasElement
|
||||
// ).captureStream(60);
|
||||
// // Prefer MP4 — MediaRecorder's webm output has a known broken duration
|
||||
// // header (file contains all frames but most players report ~1s). MP4
|
||||
// // writes duration correctly. Fall back to webm where MP4 isn't supported.
|
||||
// const mimeCandidates = [
|
||||
// "video/mp4;codecs=h264",
|
||||
// "video/mp4;codecs=avc1",
|
||||
// "video/mp4",
|
||||
// "video/webm;codecs=vp9",
|
||||
// "video/webm",
|
||||
// ];
|
||||
// const mimeType =
|
||||
// mimeCandidates.find((m) => MediaRecorder.isTypeSupported(m)) ??
|
||||
// "video/webm";
|
||||
// const ext = mimeType.startsWith("video/mp4") ? "mp4" : "webm";
|
||||
// recorder = new MediaRecorder(stream, {
|
||||
// mimeType,
|
||||
// videoBitsPerSecond: 12_000_000,
|
||||
// });
|
||||
// recorder.ondataavailable = (e) => {
|
||||
// if (e.data.size > 0) captureChunks.push(e.data);
|
||||
// };
|
||||
// recorder.onstop = () => {
|
||||
// const blob = new Blob(captureChunks, { type: mimeType });
|
||||
// const url = URL.createObjectURL(blob);
|
||||
// const a = document.createElement("a");
|
||||
// a.href = url;
|
||||
// a.download = `manifesto-walk.${ext}`;
|
||||
// document.body.appendChild(a);
|
||||
// a.click();
|
||||
// a.remove();
|
||||
// setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
// };
|
||||
// // Timeslice forces periodic chunk emission instead of buffering
|
||||
// // everything until stop() — more reliable across browsers.
|
||||
// recorder.start(100);
|
||||
// captureStart = performance.now();
|
||||
// };
|
||||
|
||||
let rafId = 0;
|
||||
// CAPTURE MODE: also track `let lastFrame = performance.now();` and
|
||||
// `const dt = (now - lastFrame) / 1000; lastFrame = now;` for mixer.update.
|
||||
const start = performance.now();
|
||||
const tick = () => {
|
||||
// Skip the entire render pass when the parent flags us as paused.
|
||||
// We still loop rAF (cheap) so resuming is instantaneous, but the
|
||||
// expensive WebGL render + mixer update are gated. Saves 5-15ms per
|
||||
// frame on high-DPI / large displays once the character is off-screen.
|
||||
if (pausedRef?.current) {
|
||||
rafId = requestAnimationFrame(tick);
|
||||
return;
|
||||
}
|
||||
const now = performance.now();
|
||||
const t = (now - start) / 1000;
|
||||
|
||||
if (character && mixer && action && clipDuration > 0) {
|
||||
// CAPTURE MODE branch (disabled):
|
||||
// if (captureMode && captureStart > 0) {
|
||||
// const elapsed = (now - captureStart) / 1000;
|
||||
// const cp = clamp01(elapsed / captureDuration);
|
||||
// mixer.update(dt);
|
||||
// character.position.x = -11 + 25 * cp;
|
||||
// character.position.y = -2.5 + Math.sin(t * 1.6) * 0.04;
|
||||
// if (elapsed >= captureDuration && recorder?.state === "recording") {
|
||||
// recorder.stop();
|
||||
// }
|
||||
// } else {
|
||||
const cp = clamp01(progressRef.current);
|
||||
|
||||
// Drive animation time from scroll. Multiplier sets how many walk
|
||||
// cycles play across the full section so the legs cycle visibly.
|
||||
// Modulo wraps the time so the loop chains together cleanly.
|
||||
const animTime = (cp * clipDuration * WALK_CYCLES) % clipDuration;
|
||||
action.time = animTime;
|
||||
mixer.update(0);
|
||||
|
||||
// Linear scroll-linked traversal. Progress spans the full visible
|
||||
// duration of the section (top entering viewport → bottom exiting),
|
||||
// so the text-reveal end falls at ~p=0.55 (sticky duration / total
|
||||
// visible duration). At that point the character is around mid-frame.
|
||||
// From p=0.55 → 1.0 the character continues walking and exits to
|
||||
// the right while the section scrolls away.
|
||||
character.position.x = -11 + 25 * cp;
|
||||
character.position.y = -2.5 + Math.sin(t * 1.6) * 0.04;
|
||||
// }
|
||||
}
|
||||
|
||||
renderer.render(scene, camera);
|
||||
rafId = requestAnimationFrame(tick);
|
||||
};
|
||||
rafId = requestAnimationFrame(tick);
|
||||
|
||||
const handleResize = () => {
|
||||
const w = container.clientWidth;
|
||||
const h = container.clientHeight;
|
||||
camera.aspect = w / h;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(w, h);
|
||||
};
|
||||
window.addEventListener("resize", handleResize);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
cancelAnimationFrame(rafId);
|
||||
// CAPTURE MODE: if (recorder && recorder.state === "recording") recorder.stop();
|
||||
window.removeEventListener("resize", handleResize);
|
||||
if (renderer.domElement.parentNode === container) {
|
||||
container.removeChild(renderer.domElement);
|
||||
}
|
||||
if (mixer) mixer.stopAllAction();
|
||||
if (character) {
|
||||
scene.remove(character);
|
||||
character.traverse((child) => {
|
||||
const mesh = child as THREE.Mesh;
|
||||
if (mesh.isMesh) {
|
||||
mesh.geometry?.dispose();
|
||||
}
|
||||
});
|
||||
}
|
||||
characterMaterial.dispose();
|
||||
ground.geometry.dispose();
|
||||
(ground.material as THREE.Material).dispose();
|
||||
envTexture.dispose();
|
||||
pmrem.dispose();
|
||||
renderer.dispose();
|
||||
};
|
||||
}, [progressRef]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0 z-[1]"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ManifestoThreeBackground;
|
||||
@@ -14,7 +14,7 @@ interface ModelBadgeGridProps {
|
||||
}
|
||||
|
||||
const baseBadgeClasses =
|
||||
"rounded-2xl px-6 py-3 text-2xl font-normal flex-shrink-0 bg-[#121212] text-white/90 text-center h-[58px] flex items-center justify-center gap-1.5";
|
||||
"rounded-2xl px-6 py-3 text-2xl font-normal flex-shrink-0 bg-white/10 text-white/90 text-center h-[58px] flex items-center justify-center gap-1.5";
|
||||
const highlightClasses =
|
||||
"bg-primary/30 text-white font-semibold shadow-lg border-2 border-primary/60";
|
||||
|
||||
@@ -103,16 +103,16 @@ export default function ModelBadgeGrid({
|
||||
className={`select-none relative z-10 h-full overflow-hidden ${className}`}
|
||||
>
|
||||
{/* Gradient fade overlays */}
|
||||
<div className="absolute left-0 top-0 w-32 xl:w-96 h-full bg-gradient-to-r from-[#080808] to-transparent z-10 pointer-events-none" />
|
||||
<div className="absolute right-0 top-0 w-32 xl:w-96 h-full bg-gradient-to-l from-[#080808] to-transparent z-10 pointer-events-none" />
|
||||
<div className="absolute left-0 top-0 w-32 xl:w-96 h-full bg-gradient-to-r from-[#28282C] to-transparent z-10 pointer-events-none" />
|
||||
<div className="absolute right-0 top-0 w-32 xl:w-96 h-full bg-gradient-to-l from-[#28282C] to-transparent z-10 pointer-events-none" />
|
||||
|
||||
<div className="flex flex-col gap-4 h-full -mx-[280px] xl:-mx-8">
|
||||
<div className="flex flex-col gap-5 h-full -mx-[280px] xl:-mx-8">
|
||||
{rows.map((row, rowIdx) => {
|
||||
const offset = rowOffsets[rowIdx] ?? 0;
|
||||
return (
|
||||
<div
|
||||
key={rowIdx}
|
||||
className="flex gap-4 whitespace-nowrap"
|
||||
className="flex gap-5 whitespace-nowrap"
|
||||
style={{ marginLeft: offset }}
|
||||
>
|
||||
{row.map(({ id, label, svg }, idx) => {
|
||||
@@ -123,7 +123,7 @@ export default function ModelBadgeGrid({
|
||||
className={twMerge(
|
||||
baseBadgeClasses,
|
||||
label ? "" : "min-w-[180px]",
|
||||
isHighlight ? highlightClasses : "",
|
||||
isHighlight ? highlightClasses : ""
|
||||
)}
|
||||
>
|
||||
{svg ? (
|
||||
|
||||
@@ -8,14 +8,12 @@ import {
|
||||
MenuItems,
|
||||
Transition,
|
||||
} from "@headlessui/react";
|
||||
import * as NavigationMenu from "@radix-ui/react-navigation-menu";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { useEffect, useState, Fragment } from "react";
|
||||
import { Link, useLocation, useNavigate } from "react-router-dom";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import { PopoverMenu } from "@storyteller/ui-popover";
|
||||
import { UsersApi, UserInfo, CreditsApi, BillingApi } from "@storyteller/api";
|
||||
import { getSession, invalidateSession } from "../../lib/session";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
faCoins,
|
||||
@@ -23,51 +21,21 @@ import {
|
||||
faBars,
|
||||
faXmark,
|
||||
faGem,
|
||||
faCog,
|
||||
faArrowRight,
|
||||
faChevronDown,
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import { TaskQueue } from "./task-queue";
|
||||
import { CreditsModal } from "../credits-modal";
|
||||
import { SettingsModal } from "../settings-modal/SettingsModal";
|
||||
|
||||
type NavLeaf = { name: string; href: string };
|
||||
type NavGroup = { name: string; href?: string; children: NavLeaf[] };
|
||||
type NavEntry = NavLeaf | NavGroup;
|
||||
|
||||
const NAV_ITEMS: NavEntry[] = [
|
||||
const NAV_ITEMS = [
|
||||
{ name: "Home", href: "/" },
|
||||
{ name: "Image", href: "/create-image" },
|
||||
{ name: "Video", href: "/create-video" },
|
||||
{
|
||||
name: "Resources",
|
||||
children: [
|
||||
{ name: "Tutorials", href: "/tutorials" },
|
||||
{ name: "News", href: "/news" },
|
||||
{ name: "FAQ", href: "/faq" },
|
||||
{ name: "Press Kit", href: "/press-kit" },
|
||||
],
|
||||
},
|
||||
{ name: "Tutorials", href: "/tutorials" },
|
||||
{ name: "News", href: "/news" },
|
||||
{ name: "FAQ", href: "/faq" },
|
||||
{ name: "Press Kit", href: "/press-kit" },
|
||||
{ name: "Download", href: "/download" },
|
||||
];
|
||||
|
||||
function isGroup(entry: NavEntry): entry is NavGroup {
|
||||
return "children" in entry && Array.isArray(entry.children);
|
||||
}
|
||||
|
||||
function isPathActive(pathname: string, href: string): boolean {
|
||||
if (href === "/") return pathname === "/";
|
||||
return pathname === href || pathname.startsWith(href + "/");
|
||||
}
|
||||
|
||||
function isEntryActive(pathname: string, entry: NavEntry): boolean {
|
||||
if (isGroup(entry)) {
|
||||
if (entry.href && isPathActive(pathname, entry.href)) return true;
|
||||
return entry.children.some((c) => isPathActive(pathname, c.href));
|
||||
}
|
||||
return isPathActive(pathname, entry.href);
|
||||
}
|
||||
|
||||
async function fetchCredits(): Promise<number | null> {
|
||||
try {
|
||||
const api = new CreditsApi();
|
||||
@@ -97,18 +65,27 @@ async function fetchHasPaidPlan(): Promise<boolean> {
|
||||
}
|
||||
|
||||
export default function Navbar() {
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Pages with content flush to top need a solid navbar background always
|
||||
const alwaysSolid =
|
||||
location.pathname === "/create-image" ||
|
||||
location.pathname === "/create-video" ||
|
||||
location.pathname === "/library";
|
||||
const [user, setUser] = useState<UserInfo | undefined>(undefined);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [credits, setCredits] = useState<number | null>(null);
|
||||
// null = unknown (still loading) so we don't flash the upgrade button
|
||||
const [hasPaidPlan, setHasPaidPlan] = useState<boolean | null>(null);
|
||||
const [creditsModalOpen, setCreditsModalOpen] = useState(false);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
|
||||
// Check session on mount and when auth changes or location changes
|
||||
useEffect(() => {
|
||||
const checkSession = async (force = false) => {
|
||||
const response = await getSession(force);
|
||||
const checkSession = async () => {
|
||||
const api = new UsersApi();
|
||||
const response = await api.GetSession();
|
||||
if (
|
||||
response.success &&
|
||||
response.data &&
|
||||
@@ -130,8 +107,7 @@ export default function Navbar() {
|
||||
|
||||
const handleAuthChange = () => {
|
||||
setIsLoading(true);
|
||||
invalidateSession();
|
||||
checkSession(true);
|
||||
checkSession();
|
||||
};
|
||||
|
||||
const handleCreditsChange = () => {
|
||||
@@ -149,519 +125,405 @@ export default function Navbar() {
|
||||
const handleLogout = async () => {
|
||||
const api = new UsersApi();
|
||||
await api.Logout();
|
||||
invalidateSession();
|
||||
window.location.href = "/";
|
||||
};
|
||||
|
||||
return (
|
||||
<Disclosure
|
||||
as="nav"
|
||||
className="z-50 fixed top-0 left-0 w-full font-display"
|
||||
>
|
||||
{({ open }) => {
|
||||
const isFullWidthRoute =
|
||||
location.pathname.startsWith("/create-image") ||
|
||||
location.pathname.startsWith("/create-video");
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
setScrolled(window.scrollY > 0);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="px-3 sm:px-5 pt-3">
|
||||
<div
|
||||
className={twMerge(
|
||||
"liquid-glass mx-auto rounded-3xl transition-[max-width] duration-300",
|
||||
isFullWidthRoute ? "max-w-none" : "max-w-6xl",
|
||||
)}
|
||||
>
|
||||
<div className="flex h-11 sm:h-12 items-center justify-between pl-4 pr-2 sm:pl-5 sm:pr-2.5">
|
||||
{/* Left: Logo + nav items */}
|
||||
<div className="flex items-center gap-5 min-w-0">
|
||||
<Link to="/" className="flex items-center shrink-0">
|
||||
window.addEventListener("scroll", handleScroll);
|
||||
return () => window.removeEventListener("scroll", handleScroll);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<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-5 w-auto"
|
||||
className="h-5 sm:h-6 w-auto"
|
||||
/>
|
||||
</Link>
|
||||
|
||||
<NavigationMenu.Root
|
||||
delayDuration={120}
|
||||
className="hidden lg:flex items-center min-w-0"
|
||||
>
|
||||
<NavigationMenu.List className="flex items-center">
|
||||
{NAV_ITEMS.map((entry) => {
|
||||
const active = isEntryActive(location.pathname, entry);
|
||||
const baseClasses =
|
||||
"px-3 py-1.5 text-[13px] font-medium rounded-lg transition-all whitespace-nowrap flex items-center gap-1.5";
|
||||
const stateClasses = active
|
||||
? "text-white bg-white/[0.08]"
|
||||
: "text-white/60 hover:text-white hover:bg-white/[0.04]";
|
||||
|
||||
if (!isGroup(entry)) {
|
||||
return (
|
||||
<NavigationMenu.Item key={entry.name}>
|
||||
<NavigationMenu.Link asChild>
|
||||
<Link
|
||||
to={entry.href}
|
||||
aria-current={active ? "page" : undefined}
|
||||
className={twMerge(baseClasses, stateClasses)}
|
||||
>
|
||||
{entry.name}
|
||||
</Link>
|
||||
</NavigationMenu.Link>
|
||||
</NavigationMenu.Item>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<NavigationMenu.Item
|
||||
key={entry.name}
|
||||
className="relative"
|
||||
>
|
||||
{entry.href ? (
|
||||
<NavigationMenu.Trigger asChild>
|
||||
<Link
|
||||
to={entry.href}
|
||||
aria-current={active ? "page" : undefined}
|
||||
className={twMerge(
|
||||
baseClasses,
|
||||
stateClasses,
|
||||
"group",
|
||||
)}
|
||||
>
|
||||
{entry.name}
|
||||
<FontAwesomeIcon
|
||||
icon={faChevronDown}
|
||||
className="text-[9px] transition-transform duration-200 group-data-[state=open]:rotate-180"
|
||||
/>
|
||||
</Link>
|
||||
</NavigationMenu.Trigger>
|
||||
) : (
|
||||
<NavigationMenu.Trigger
|
||||
className={twMerge(
|
||||
baseClasses,
|
||||
stateClasses,
|
||||
"group focus:outline-none",
|
||||
)}
|
||||
>
|
||||
{entry.name}
|
||||
<FontAwesomeIcon
|
||||
icon={faChevronDown}
|
||||
className="text-[9px] transition-transform duration-200 group-data-[state=open]:rotate-180"
|
||||
/>
|
||||
</NavigationMenu.Trigger>
|
||||
)}
|
||||
<NavigationMenu.Content
|
||||
className="absolute top-full left-0 mt-2 rounded-xl border border-white/[0.08] bg-[#1a1a1a] shadow-xl overflow-hidden"
|
||||
>
|
||||
<ul className="flex flex-col p-1.5 min-w-[180px]">
|
||||
{entry.children.map((child) => {
|
||||
const childActive = isPathActive(
|
||||
location.pathname,
|
||||
child.href,
|
||||
);
|
||||
return (
|
||||
<li key={child.name}>
|
||||
<NavigationMenu.Link asChild>
|
||||
<Link
|
||||
to={child.href}
|
||||
aria-current={
|
||||
childActive ? "page" : undefined
|
||||
}
|
||||
className={twMerge(
|
||||
"block px-3 py-2 text-[13px] font-medium rounded-lg transition-colors whitespace-nowrap",
|
||||
childActive
|
||||
? "text-white bg-white/[0.08]"
|
||||
: "text-white/70 hover:text-white hover:bg-white/[0.06]",
|
||||
)}
|
||||
>
|
||||
{child.name}
|
||||
</Link>
|
||||
</NavigationMenu.Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</NavigationMenu.Content>
|
||||
</NavigationMenu.Item>
|
||||
);
|
||||
})}
|
||||
</NavigationMenu.List>
|
||||
</NavigationMenu.Root>
|
||||
</div>
|
||||
|
||||
{/* Right: Auth/credits/library */}
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{isLoading ? (
|
||||
<div className="hidden md:flex items-center gap-2 opacity-0" />
|
||||
) : user ? (
|
||||
<div className="hidden md:flex items-center gap-2">
|
||||
<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
|
||||
to="/pricing"
|
||||
className="hidden xl:flex h-8 items-center gap-1.5 px-3 rounded-lg text-[13px] font-medium text-white/90 hover:text-white hover:bg-white/[0.04] transition-all"
|
||||
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",
|
||||
)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faGem} className="text-[11px]" />
|
||||
Pricing
|
||||
</Link>
|
||||
|
||||
{credits !== null && (
|
||||
<PopoverMenu
|
||||
position="bottom"
|
||||
align="center"
|
||||
triggerIcon={
|
||||
<FontAwesomeIcon
|
||||
icon={faCoins}
|
||||
className="text-primary text-[11px]"
|
||||
/>
|
||||
}
|
||||
triggerLabel={
|
||||
<span className="whitespace-nowrap text-[13px] font-medium">
|
||||
{credits.toLocaleString()}
|
||||
</span>
|
||||
}
|
||||
buttonClassName="h-8 px-3 ps-2.5 bg-white/[0.04] hover:bg-white/[0.08] border border-white/[0.08] shadow-none text-white/80 rounded-lg gap-1.5"
|
||||
panelClassName="mt-2 bg-[#1a1a1a] border border-white/[0.08] text-white rounded-xl font-display"
|
||||
>
|
||||
{(close) => (
|
||||
<div className="w-72 p-3 text-white">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-white/70">
|
||||
Your credit balance
|
||||
</span>
|
||||
<button
|
||||
className="text-sm font-medium text-primary hover:text-primary-300 transition-colors"
|
||||
onClick={() => {
|
||||
close();
|
||||
setCreditsModalOpen(true);
|
||||
}}
|
||||
>
|
||||
Buy credits
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-3xl font-medium text-white tracking-tight">
|
||||
<FontAwesomeIcon
|
||||
icon={faCoins}
|
||||
className="text-xl text-primary"
|
||||
/>
|
||||
{credits.toLocaleString()}
|
||||
</div>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
className="h-9 grow"
|
||||
onClick={() => {
|
||||
close();
|
||||
navigate("/pricing");
|
||||
}}
|
||||
icon={faGem}
|
||||
>
|
||||
Support
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<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",
|
||||
)}
|
||||
</PopoverMenu>
|
||||
)}
|
||||
|
||||
{hasPaidPlan === false && (
|
||||
<Button
|
||||
variant="primary"
|
||||
icon={faGem}
|
||||
onClick={() => navigate("/pricing")}
|
||||
className="h-8 px-3 text-[13px] font-semibold rounded-lg transition-all"
|
||||
aria-hidden="true"
|
||||
>
|
||||
Support
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Link
|
||||
to="/library"
|
||||
className="flex h-8 items-center gap-1.5 rounded-lg px-3 text-[13px] font-medium text-white/80 bg-white/[0.04] hover:bg-white/[0.08] border border-white/[0.08] transition-all"
|
||||
>
|
||||
<FontAwesomeIcon icon={faGrid2} className="text-[11px]" />
|
||||
Library
|
||||
<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 opacity-0"></div>
|
||||
) : user ? (
|
||||
// ── Desktop: Logged In ─────────────────────────
|
||||
<div className="hidden md:ml-4 md:flex items-center gap-2">
|
||||
<Link
|
||||
to="/pricing"
|
||||
className="text-[15px] font-semibold text-white/60 hover:text-white transition-colors"
|
||||
>
|
||||
Pricing
|
||||
</Link>
|
||||
{credits !== null && (
|
||||
<PopoverMenu
|
||||
position="bottom"
|
||||
align="center"
|
||||
triggerIcon={
|
||||
<FontAwesomeIcon
|
||||
icon={faCoins}
|
||||
className="text-primary"
|
||||
/>
|
||||
}
|
||||
triggerLabel={
|
||||
<span className="whitespace-nowrap text-[15px] font-semibold">
|
||||
{credits.toLocaleString()} Credits
|
||||
</span>
|
||||
}
|
||||
buttonClassName="h-[34px] px-2 ps-1.5 bg-transparent hover:bg-white/10 border-0 shadow-none text-white/90 ms-2.5 me-1.5"
|
||||
panelClassName="mt-3 bg-[#1C1C20] border border-white/10 text-white"
|
||||
>
|
||||
{(close) => (
|
||||
<div className="w-72 p-2.5 text-white">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-white/80">
|
||||
Your credit balance
|
||||
</span>
|
||||
<button
|
||||
className="text-sm font-medium text-primary-400 transition-all hover:text-primary-300"
|
||||
onClick={() => {
|
||||
close();
|
||||
setCreditsModalOpen(true);
|
||||
}}
|
||||
>
|
||||
Buy credits
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-4xl font-bold text-white">
|
||||
<FontAwesomeIcon
|
||||
icon={faCoins}
|
||||
className="text-2xl text-primary"
|
||||
/>
|
||||
{credits.toLocaleString()}
|
||||
</div>
|
||||
|
||||
<TaskQueue />
|
||||
<div className="mt-3 flex gap-2">
|
||||
{/* <Button
|
||||
variant="action"
|
||||
className="h-9 grow"
|
||||
onClick={() => {
|
||||
close();
|
||||
navigate("/pricing");
|
||||
}}
|
||||
>
|
||||
See details
|
||||
</Button> */}
|
||||
<Button
|
||||
variant="primary"
|
||||
className="h-9 grow"
|
||||
onClick={() => {
|
||||
close();
|
||||
navigate("/pricing");
|
||||
}}
|
||||
icon={faGem}
|
||||
>
|
||||
Support
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</PopoverMenu>
|
||||
)}
|
||||
|
||||
<Menu as="div" className="relative ml-1">
|
||||
<MenuButton className="flex h-8 w-8 rounded-full overflow-hidden focus:outline-none focus:ring-2 focus:ring-primary/40 ring-offset-2 ring-offset-[#121212]">
|
||||
{hasPaidPlan === false && (
|
||||
<Button
|
||||
variant="primary"
|
||||
icon={faGem}
|
||||
onClick={() => navigate("/pricing")}
|
||||
className="h-[38px] transition-all duration-300 hover:shadow-md hover:shadow-primary-600/75"
|
||||
>
|
||||
Support
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<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-full w-full object-cover"
|
||||
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>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
</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"
|
||||
>
|
||||
<MenuItems
|
||||
modal={false}
|
||||
className="absolute right-0 z-50 mt-2 w-48 origin-top-right rounded-xl bg-[#1a1a1a] border border-white/[0.08] shadow-xl focus:outline-none overflow-hidden"
|
||||
>
|
||||
<div className="px-4 py-3 border-b border-white/[0.06]">
|
||||
<p className="text-sm font-medium text-white truncate">
|
||||
{user.display_name || user.username}
|
||||
</p>
|
||||
</div>
|
||||
<MenuItem>
|
||||
{({ active }) => (
|
||||
<button
|
||||
onClick={() => setSettingsOpen(true)}
|
||||
className={twMerge(
|
||||
active ? "bg-white/[0.04]" : "",
|
||||
"flex w-full items-center gap-2 px-4 py-2 text-sm text-white/70 transition-colors",
|
||||
)}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={faCog}
|
||||
className="text-[11px] text-white/50"
|
||||
/>
|
||||
Settings
|
||||
</button>
|
||||
)}
|
||||
</MenuItem>
|
||||
<MenuItem>
|
||||
{({ active }) => (
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className={twMerge(
|
||||
active ? "bg-white/[0.04]" : "",
|
||||
"block w-full text-left px-4 py-2 text-sm text-white/70 transition-colors",
|
||||
)}
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
)}
|
||||
</MenuItem>
|
||||
</MenuItems>
|
||||
</Transition>
|
||||
</Menu>
|
||||
</div>
|
||||
) : (
|
||||
<div className="hidden md:flex items-center gap-2">
|
||||
<Link
|
||||
to="/pricing"
|
||||
className="h-8 flex items-center gap-1.5 px-3 rounded-lg text-[13px] font-medium text-white/90 hover:text-white hover:bg-white/[0.04] transition-all"
|
||||
>
|
||||
<FontAwesomeIcon icon={faGem} className="text-[11px]" />
|
||||
Pricing
|
||||
</Link>
|
||||
<Link
|
||||
to="/login"
|
||||
className="h-8 flex items-center px-3 rounded-lg text-[13px] font-medium text-white/80 hover:text-white hover:bg-white/[0.04] transition-all"
|
||||
>
|
||||
Login
|
||||
</Link>
|
||||
<Link
|
||||
to="/signup"
|
||||
className="group h-8 flex items-center gap-1.5 px-3.5 rounded-full text-[13px] font-semibold text-black bg-white hover:bg-white/90 transition-all shadow-sm"
|
||||
>
|
||||
Sign up
|
||||
<FontAwesomeIcon
|
||||
icon={faArrowRight}
|
||||
className="text-[10px] transition-transform group-hover:translate-x-0.5"
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mobile: hamburger + task queue */}
|
||||
<div className="flex items-center gap-2 lg:hidden">
|
||||
{user && <TaskQueue />}
|
||||
<DisclosureButton className="flex h-8 w-8 items-center justify-center rounded-lg text-white/70 hover:text-white hover:bg-white/[0.06] transition-colors">
|
||||
<span className="sr-only">Open main menu</span>
|
||||
<FontAwesomeIcon
|
||||
icon={open ? faXmark : faBars}
|
||||
className="text-base"
|
||||
/>
|
||||
</DisclosureButton>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* ── 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>
|
||||
|
||||
{/* Mobile slide-down panel */}
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition duration-150 ease-out"
|
||||
enterFrom="opacity-0 -translate-y-1"
|
||||
enterTo="opacity-100 translate-y-0"
|
||||
leave="transition duration-100 ease-in"
|
||||
leaveFrom="opacity-100 translate-y-0"
|
||||
leaveTo="opacity-0 -translate-y-1"
|
||||
>
|
||||
<DisclosurePanel className="lg:hidden border-t border-white/[0.06] px-3 pb-3 pt-2">
|
||||
<div className="flex flex-col">
|
||||
{NAV_ITEMS.map((entry) => {
|
||||
if (!isGroup(entry)) {
|
||||
const isCurrent = isPathActive(
|
||||
location.pathname,
|
||||
entry.href,
|
||||
);
|
||||
return (
|
||||
<DisclosureButton
|
||||
key={entry.name}
|
||||
as={Link}
|
||||
to={entry.href}
|
||||
className={twMerge(
|
||||
"rounded-lg px-3 py-2 text-[13px] font-medium transition-colors",
|
||||
isCurrent
|
||||
? "bg-white/[0.08] text-white"
|
||||
: "text-white/60 active:bg-white/[0.04]",
|
||||
)}
|
||||
>
|
||||
{entry.name}
|
||||
</DisclosureButton>
|
||||
);
|
||||
}
|
||||
|
||||
const headerActive =
|
||||
entry.href !== undefined &&
|
||||
isPathActive(location.pathname, entry.href);
|
||||
|
||||
return (
|
||||
<div key={entry.name} className="flex flex-col">
|
||||
{entry.href ? (
|
||||
<DisclosureButton
|
||||
as={Link}
|
||||
to={entry.href}
|
||||
className={twMerge(
|
||||
"rounded-lg px-3 py-2 text-[13px] font-medium transition-colors",
|
||||
headerActive
|
||||
? "bg-white/[0.08] text-white"
|
||||
: "text-white/60 active:bg-white/[0.04]",
|
||||
)}
|
||||
>
|
||||
{entry.name}
|
||||
</DisclosureButton>
|
||||
) : (
|
||||
<div className="px-3 pt-2 pb-1 text-[11px] font-semibold uppercase tracking-wider text-white/40">
|
||||
{entry.name}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col pl-3">
|
||||
{entry.children.map((child) => {
|
||||
const childActive = isPathActive(
|
||||
location.pathname,
|
||||
child.href,
|
||||
);
|
||||
return (
|
||||
<DisclosureButton
|
||||
key={child.name}
|
||||
as={Link}
|
||||
to={child.href}
|
||||
className={twMerge(
|
||||
"rounded-lg px-3 py-2 text-[13px] font-medium transition-colors",
|
||||
childActive
|
||||
? "bg-white/[0.08] text-white"
|
||||
: "text-white/55 active:bg-white/[0.04]",
|
||||
)}
|
||||
>
|
||||
{child.name}
|
||||
</DisclosureButton>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<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-lg px-2.5 text-[12px] font-medium text-white/70 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 gap-1.5 rounded-lg px-2.5 text-[12px] font-medium text-white/70 bg-white/[0.06] active:bg-white/10 transition-colors"
|
||||
>
|
||||
<FontAwesomeIcon icon={faGem} className="text-[10px]" />
|
||||
Pricing
|
||||
</DisclosureButton>
|
||||
<DisclosureButton
|
||||
as="button"
|
||||
onClick={() => setSettingsOpen(true)}
|
||||
className="flex h-8 items-center gap-1.5 rounded-lg px-2.5 text-[12px] font-medium text-white/70 bg-white/[0.06] active:bg-white/10 transition-colors"
|
||||
>
|
||||
<FontAwesomeIcon icon={faCog} className="text-[10px]" />
|
||||
Settings
|
||||
</DisclosureButton>
|
||||
{credits !== null && (
|
||||
<span className="flex items-center gap-1.5 ml-auto text-[12px] font-medium text-white/80">
|
||||
<FontAwesomeIcon
|
||||
icon={faCoins}
|
||||
className="text-primary text-[10px]"
|
||||
/>
|
||||
{credits.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<img
|
||||
className="h-7 w-7 rounded-full border border-white/[0.08] shrink-0"
|
||||
src={`https://www.gravatar.com/avatar/${user.email_gravatar_hash}?d=mp`}
|
||||
alt=""
|
||||
/>
|
||||
<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-lg px-2.5 text-[12px] font-medium text-red-400/80 active:bg-red-500/10 transition-colors shrink-0"
|
||||
>
|
||||
Sign out
|
||||
</DisclosureButton>
|
||||
</div>
|
||||
</div>
|
||||
) : !isLoading ? (
|
||||
<div className="flex gap-2">
|
||||
<DisclosureButton as={Link} to="/login" className="flex-1">
|
||||
<button className="w-full h-9 rounded-lg text-[13px] font-semibold text-white/80 bg-white/[0.06] active:bg-white/10 transition-colors">
|
||||
Login
|
||||
</button>
|
||||
</DisclosureButton>
|
||||
<DisclosureButton as={Link} to="/signup" className="flex-1">
|
||||
<button className="w-full h-9 rounded-lg text-[13px] font-semibold text-black bg-white active:bg-white/90 transition-colors">
|
||||
Sign up
|
||||
</button>
|
||||
</DisclosureButton>
|
||||
</div>
|
||||
) : null}
|
||||
</DisclosurePanel>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<CreditsModal
|
||||
isOpen={creditsModalOpen}
|
||||
onClose={() => setCreditsModalOpen(false)}
|
||||
/>
|
||||
|
||||
<SettingsModal
|
||||
isOpen={settingsOpen}
|
||||
onClose={() => setSettingsOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
|
||||
{/* ── 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>
|
||||
|
||||
<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-7 w-7 rounded-full border border-white/10 shrink-0"
|
||||
src={`https://www.gravatar.com/avatar/${user.email_gravatar_hash}?d=mp`}
|
||||
alt=""
|
||||
/>
|
||||
<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>
|
||||
) : !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>
|
||||
</DisclosurePanel>
|
||||
</Transition>
|
||||
|
||||
<CreditsModal
|
||||
isOpen={creditsModalOpen}
|
||||
onClose={() => setCreditsModalOpen(false)}
|
||||
/>
|
||||
|
||||
<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>
|
||||
)}
|
||||
</Disclosure>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,8 +17,7 @@ import {
|
||||
import { Modal } from "@storyteller/ui-modal";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { JobsApi, JobStatus } from "@storyteller/api";
|
||||
import type { Job, Prompts } from "@storyteller/api";
|
||||
import { getCachedPrompt, usePrompts } from "../../lib/prompts-cache";
|
||||
import type { Job } from "@storyteller/api";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import {
|
||||
getProviderDisplayName,
|
||||
@@ -31,7 +30,6 @@ 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 { getMediaThumbnail, THUMBNAIL_SIZES } from "@storyteller/common";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────────
|
||||
@@ -157,10 +155,10 @@ const PromptLine = ({
|
||||
style={
|
||||
marqueePlaying
|
||||
? {
|
||||
animation: "marquee 6.5s linear infinite",
|
||||
animationDelay: "0.5s",
|
||||
animationFillMode: "both",
|
||||
}
|
||||
animation: "marquee 6.5s linear infinite",
|
||||
animationDelay: "0.5s",
|
||||
animationFillMode: "both",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
@@ -462,29 +460,13 @@ const FailedCard = ({
|
||||
|
||||
// ── Job → Task transformers ────────────────────────────────────────────────
|
||||
|
||||
function getPrompt(job: Job, promptsMap?: Map<string, Prompts>): string {
|
||||
const promptToken = job.request.maybe_prompt_token;
|
||||
const cached = promptToken
|
||||
? (promptsMap?.get(promptToken) ?? getCachedPrompt(promptToken))
|
||||
: undefined;
|
||||
return (
|
||||
cached?.maybe_positive_prompt ||
|
||||
job.request.maybe_raw_inference_text ||
|
||||
""
|
||||
);
|
||||
function getPrompt(job: Job): string {
|
||||
return job.request.maybe_raw_inference_text || "";
|
||||
}
|
||||
|
||||
function formatTitleParts(job: Job, promptsMap?: Map<string, Prompts>) {
|
||||
const promptToken = job.request.maybe_prompt_token;
|
||||
const cachedPrompt = promptToken
|
||||
? (promptsMap?.get(promptToken) ?? getCachedPrompt(promptToken))
|
||||
: undefined;
|
||||
|
||||
function formatTitleParts(job: Job) {
|
||||
const taskTypeStr = job.request.inference_category?.toLowerCase() ?? "";
|
||||
const modelTypeStr =
|
||||
cachedPrompt?.maybe_model_type ?? job.request.maybe_model_type ?? "";
|
||||
const providerKey =
|
||||
cachedPrompt?.maybe_generation_provider ?? modelTypeStr;
|
||||
const modelTypeStr = job.request.maybe_model_type ?? "";
|
||||
|
||||
let kind: string | undefined;
|
||||
if (taskTypeStr.includes("video")) {
|
||||
@@ -498,25 +480,22 @@ function formatTitleParts(job: Job, promptsMap?: Map<string, Prompts>) {
|
||||
const modelDisplay = modelTypeStr
|
||||
? getModelDisplayName(modelTypeStr)
|
||||
: undefined;
|
||||
const provider = providerKey
|
||||
? getProviderDisplayName(providerKey.toLowerCase())
|
||||
const provider = modelTypeStr
|
||||
? getProviderDisplayName(modelTypeStr.toLowerCase())
|
||||
: undefined;
|
||||
|
||||
const title = kind || "Task";
|
||||
const subtitle =
|
||||
modelDisplay && provider
|
||||
? `${modelDisplay} · ${provider}`
|
||||
: modelDisplay || provider || undefined;
|
||||
? `${modelDisplay} — ${provider}`
|
||||
: modelDisplay || job.request.maybe_model_title || undefined;
|
||||
return { title, subtitle, kind };
|
||||
}
|
||||
|
||||
// Cache per-task durations so model changes don't affect existing progress bars
|
||||
const taskDurationCache = new Map<string, number>();
|
||||
|
||||
function jobsToInProgress(
|
||||
jobs: Job[],
|
||||
promptsMap: Map<string, Prompts>,
|
||||
): InProgressTask[] {
|
||||
function jobsToInProgress(jobs: Job[]): InProgressTask[] {
|
||||
const now = Date.now();
|
||||
const activeIds = new Set<string>();
|
||||
|
||||
@@ -529,7 +508,7 @@ function jobsToInProgress(
|
||||
.map((j): InProgressTask => {
|
||||
activeIds.add(j.job_token);
|
||||
const createdMs = new Date(j.created_at).getTime();
|
||||
const parts = formatTitleParts(j, promptsMap);
|
||||
const parts = formatTitleParts(j);
|
||||
const isVideo =
|
||||
j.request.inference_category?.toLowerCase().includes("video") ?? false;
|
||||
const modelType = j.request.maybe_model_type;
|
||||
@@ -539,8 +518,8 @@ function jobsToInProgress(
|
||||
if (!duration) {
|
||||
const model = modelType
|
||||
? ALL_MODELS_LIST.find(
|
||||
(m) => m.tauriId === modelType || m.id === modelType,
|
||||
)
|
||||
(m) => m.tauriId === modelType || m.id === modelType,
|
||||
)
|
||||
: undefined;
|
||||
duration = model?.progressBarTime ?? (isVideo ? 900000 : 30000);
|
||||
taskDurationCache.set(j.job_token, duration);
|
||||
@@ -562,7 +541,7 @@ function jobsToInProgress(
|
||||
canDismiss,
|
||||
estimatedTimeLeftMs,
|
||||
modelType: modelType ?? undefined,
|
||||
prompt: getPrompt(j, promptsMap) || undefined,
|
||||
prompt: getPrompt(j) || undefined,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -574,10 +553,7 @@ function jobsToInProgress(
|
||||
return result;
|
||||
}
|
||||
|
||||
function jobsToCompleted(
|
||||
jobs: Job[],
|
||||
promptsMap: Map<string, Prompts>,
|
||||
): CompletedTask[] {
|
||||
function jobsToCompleted(jobs: Job[]): CompletedTask[] {
|
||||
return jobs
|
||||
.filter((j) => COMPLETED_STATUSES.has(j.status.status))
|
||||
.sort(
|
||||
@@ -586,22 +562,13 @@ function jobsToCompleted(
|
||||
)
|
||||
.map((j): CompletedTask => {
|
||||
const cdnUrl = j.maybe_result?.media_links?.cdn_url;
|
||||
const mediaClass = j.request.inference_category
|
||||
?.toLowerCase()
|
||||
.includes("video")
|
||||
? "video"
|
||||
: "image";
|
||||
const thumbnailUrl =
|
||||
getMediaThumbnail(j.maybe_result?.media_links, mediaClass, {
|
||||
size: THUMBNAIL_SIZES.MEDIUM,
|
||||
}) ?? cdnUrl;
|
||||
const parts = formatTitleParts(j, promptsMap);
|
||||
const parts = formatTitleParts(j);
|
||||
|
||||
return {
|
||||
id: j.job_token,
|
||||
title: parts.title,
|
||||
subtitle: parts.subtitle,
|
||||
thumbnailUrl,
|
||||
thumbnailUrl: cdnUrl,
|
||||
completedAt: j.maybe_result?.maybe_successfully_completed_at
|
||||
? new Date(j.maybe_result.maybe_successfully_completed_at)
|
||||
: new Date(j.updated_at),
|
||||
@@ -610,15 +577,12 @@ function jobsToCompleted(
|
||||
mediaTokens: j.maybe_result?.entity_token
|
||||
? [j.maybe_result.entity_token]
|
||||
: [],
|
||||
prompt: getPrompt(j, promptsMap) || undefined,
|
||||
prompt: getPrompt(j) || undefined,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function jobsToFailed(
|
||||
jobs: Job[],
|
||||
promptsMap: Map<string, Prompts>,
|
||||
): FailedTask[] {
|
||||
function jobsToFailed(jobs: Job[]): FailedTask[] {
|
||||
return jobs
|
||||
.filter((j) => FAILED_STATUSES.has(j.status.status))
|
||||
.sort(
|
||||
@@ -626,7 +590,7 @@ function jobsToFailed(
|
||||
new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime(),
|
||||
)
|
||||
.map((j): FailedTask => {
|
||||
const parts = formatTitleParts(j, promptsMap);
|
||||
const parts = formatTitleParts(j);
|
||||
const failureCategory =
|
||||
j.status.maybe_failure_category_updated ||
|
||||
j.status.maybe_failure_category;
|
||||
@@ -647,7 +611,7 @@ function jobsToFailed(
|
||||
status: j.status.status,
|
||||
failureReason,
|
||||
failureMessage,
|
||||
prompt: getPrompt(j, promptsMap) || undefined,
|
||||
prompt: getPrompt(j) || undefined,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -656,32 +620,12 @@ function jobsToFailed(
|
||||
|
||||
export const TaskQueue = () => {
|
||||
const [isModalOpen, setModalOpen] = useState(false);
|
||||
const [jobs, setJobs] = useState<Job[]>([]);
|
||||
const [inProgress, setInProgress] = useState<InProgressTask[]>([]);
|
||||
const [completed, setCompleted] = useState<CompletedTask[]>([]);
|
||||
const [failed, setFailed] = useState<FailedTask[]>([]);
|
||||
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
||||
const [unreadCompletedIds, setUnreadCompletedIds] = useState<string[]>([]);
|
||||
|
||||
const promptTokens = useMemo(() => {
|
||||
const tokens: string[] = [];
|
||||
for (const j of jobs) {
|
||||
if (j.request.maybe_prompt_token) tokens.push(j.request.maybe_prompt_token);
|
||||
}
|
||||
return tokens;
|
||||
}, [jobs]);
|
||||
const promptsMap = usePrompts(promptTokens);
|
||||
|
||||
const inProgress = useMemo(
|
||||
() => jobsToInProgress(jobs, promptsMap),
|
||||
[jobs, promptsMap],
|
||||
);
|
||||
const completed = useMemo(
|
||||
() => jobsToCompleted(jobs, promptsMap),
|
||||
[jobs, promptsMap],
|
||||
);
|
||||
const failed = useMemo(
|
||||
() => jobsToFailed(jobs, promptsMap),
|
||||
[jobs, promptsMap],
|
||||
);
|
||||
|
||||
// Lightbox state
|
||||
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||||
const [lightboxMediaToken, setLightboxMediaToken] = useState<
|
||||
@@ -710,7 +654,7 @@ export const TaskQueue = () => {
|
||||
primaryActionText: "",
|
||||
primaryActionIcon: faTrashAlt,
|
||||
primaryActionBtnClassName: "",
|
||||
onConfirm: async () => { },
|
||||
onConfirm: async () => {},
|
||||
});
|
||||
|
||||
// ── Confirmation handlers ──────────────────────────────────────────
|
||||
@@ -803,19 +747,19 @@ export const TaskQueue = () => {
|
||||
const response = await api.ListRecentJobs();
|
||||
if (cancelled || !response.success || !response.data) return;
|
||||
|
||||
const fetchedJobs: Job[] = response.data;
|
||||
setJobs(fetchedJobs);
|
||||
const jobs: Job[] = response.data;
|
||||
|
||||
// Toast titles use formatTitleParts directly so we don't depend on
|
||||
// the prompt cache being populated here.
|
||||
const completedJobs = fetchedJobs.filter((j) =>
|
||||
COMPLETED_STATUSES.has(j.status.status),
|
||||
);
|
||||
const failedJobs = fetchedJobs.filter((j) =>
|
||||
FAILED_STATUSES.has(j.status.status),
|
||||
);
|
||||
const newCompletedIdSet = new Set(completedJobs.map((j) => j.job_token));
|
||||
const newFailedIdSet = new Set(failedJobs.map((j) => j.job_token));
|
||||
const inProg = jobsToInProgress(jobs);
|
||||
const done = jobsToCompleted(jobs);
|
||||
const failedTasks = jobsToFailed(jobs);
|
||||
|
||||
setInProgress(inProg);
|
||||
setCompleted(done);
|
||||
setFailed(failedTasks);
|
||||
|
||||
// Track newly completed IDs
|
||||
const newCompletedIdSet = new Set(done.map((d) => d.id));
|
||||
const newFailedIdSet = new Set(failedTasks.map((f) => f.id));
|
||||
|
||||
if (!initialLoadDoneRef.current) {
|
||||
// First load: seed the "seen" sets without toasting.
|
||||
@@ -823,47 +767,36 @@ export const TaskQueue = () => {
|
||||
prevFailedIdsRef.current = newFailedIdSet;
|
||||
initialLoadDoneRef.current = true;
|
||||
} else {
|
||||
const newlyCompletedJobs = completedJobs.filter(
|
||||
(j) => !prevCompletedIdsRef.current.has(j.job_token),
|
||||
const newlyCompleted = done.filter(
|
||||
(d) => !prevCompletedIdsRef.current.has(d.id),
|
||||
);
|
||||
prevCompletedIdsRef.current = newCompletedIdSet;
|
||||
|
||||
if (newlyCompletedJobs.length > 0) {
|
||||
for (const job of newlyCompletedJobs) {
|
||||
const parts = formatTitleParts(job);
|
||||
showToast("success", `${parts.title} creation complete`);
|
||||
if (newlyCompleted.length > 0) {
|
||||
for (const task of newlyCompleted) {
|
||||
showToast("success", `${task.title} creation complete`);
|
||||
}
|
||||
if (!isPopoverOpen) {
|
||||
setUnreadCompletedIds((prev) =>
|
||||
Array.from(
|
||||
new Set([
|
||||
...(prev ?? []),
|
||||
...newlyCompletedJobs.map((j) => j.job_token),
|
||||
...newlyCompleted.map((d) => d.id),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const newlyFailedJobs = failedJobs.filter(
|
||||
(j) => !prevFailedIdsRef.current.has(j.job_token),
|
||||
const newlyFailed = failedTasks.filter(
|
||||
(f) => !prevFailedIdsRef.current.has(f.id),
|
||||
);
|
||||
prevFailedIdsRef.current = newFailedIdSet;
|
||||
|
||||
for (const job of newlyFailedJobs) {
|
||||
const parts = formatTitleParts(job);
|
||||
const failureCategory =
|
||||
job.status.maybe_failure_category_updated ||
|
||||
job.status.maybe_failure_category;
|
||||
const rawMessage =
|
||||
job.status.maybe_failure_message ||
|
||||
job.status.maybe_extra_status_description;
|
||||
const failureReason = failureCategory
|
||||
? FAILURE_REASON_LABEL[failureCategory] || rawMessage || undefined
|
||||
: rawMessage || undefined;
|
||||
for (const task of newlyFailed) {
|
||||
showToast(
|
||||
"error",
|
||||
`${parts.title} creation failed${failureReason ? ` — ${failureReason}` : ""}`,
|
||||
`${task.title} creation failed${task.failureReason ? ` — ${task.failureReason}` : ""}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -911,7 +844,9 @@ export const TaskQueue = () => {
|
||||
try {
|
||||
const api = new JobsApi();
|
||||
await api.DeleteJobByToken(id);
|
||||
setJobs((prev) => prev.filter((j) => j.job_token !== id));
|
||||
setInProgress((prev) => prev.filter((t) => t.id !== id));
|
||||
setCompleted((prev) => prev.filter((t) => t.id !== id));
|
||||
setFailed((prev) => prev.filter((t) => t.id !== id));
|
||||
setUnreadCompletedIds((prev) => (prev ?? []).filter((x) => x !== id));
|
||||
} catch {
|
||||
// ignore
|
||||
@@ -927,8 +862,7 @@ export const TaskQueue = () => {
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
const idSet = new Set(ids);
|
||||
setJobs((prev) => prev.filter((j) => !idSet.has(j.job_token)));
|
||||
setCompleted([]);
|
||||
setUnreadCompletedIds([]);
|
||||
}
|
||||
};
|
||||
@@ -942,8 +876,7 @@ export const TaskQueue = () => {
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
const idSet = new Set(ids);
|
||||
setJobs((prev) => prev.filter((j) => !idSet.has(j.job_token)));
|
||||
setFailed([]);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -953,8 +886,7 @@ export const TaskQueue = () => {
|
||||
await Promise.allSettled(
|
||||
staleIds.map((id) => new JobsApi().DeleteJobByToken(id)),
|
||||
);
|
||||
const idSet = new Set(staleIds);
|
||||
setJobs((prev) => prev.filter((j) => !idSet.has(j.job_token)));
|
||||
setInProgress((prev) => prev.filter((t) => !staleIds.includes(t.id)));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
@@ -973,7 +905,9 @@ export const TaskQueue = () => {
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setJobs([]);
|
||||
setInProgress([]);
|
||||
setCompleted([]);
|
||||
setFailed([]);
|
||||
setUnreadCompletedIds([]);
|
||||
}
|
||||
};
|
||||
@@ -1072,24 +1006,24 @@ export const TaskQueue = () => {
|
||||
<Tooltip content="Task Queue" position="bottom" closeOnClick={true}>
|
||||
<div className="relative task-queue-trigger">
|
||||
{badgeCount > 0 && (
|
||||
<div className="absolute -right-1 -top-1 z-20 flex h-[15px] min-w-[15px] items-center justify-center rounded-full bg-primary-400 px-1 text-[10px] font-semibold text-white ring-2 ring-[#121212]">
|
||||
<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}
|
||||
</div>
|
||||
)}
|
||||
<PopoverMenu
|
||||
mode="default"
|
||||
buttonClassName="h-8 w-8 !p-0 relative bg-white/[0.04] hover:bg-white/[0.08] border border-white/[0.08] text-white/80 rounded-lg shadow-none"
|
||||
panelClassName="w-[calc(100vw-5rem)] sm:w-[400px] p-2 bg-[#1a1a1a] border border-white/[0.08] mt-2 rounded-xl font-display"
|
||||
buttonClassName="h-[38px] w-[38px] !p-0 relative"
|
||||
panelClassName="w-[calc(100vw-5rem)] sm:w-[400px] p-2 bg-ui-panel mt-2.5"
|
||||
position="bottom"
|
||||
align="end"
|
||||
triggerIcon={
|
||||
inProgressCount > 0 ? (
|
||||
<FontAwesomeIcon
|
||||
icon={faSpinnerThird}
|
||||
className="animate-spin text-[11px]"
|
||||
className="animate-spin"
|
||||
/>
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faListCheck} className="text-[11px]" />
|
||||
<FontAwesomeIcon icon={faListCheck} />
|
||||
)
|
||||
}
|
||||
onOpenChange={handleOpenChange}
|
||||
@@ -1131,28 +1065,28 @@ export const TaskQueue = () => {
|
||||
<h2 className="text-lg font-semibold">Task Queue</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
className="rounded-full flex h-9 items-center justify-center bg-green-500/10 px-3 text-green-500 hover:bg-green-500/20"
|
||||
className="flex h-9 items-center justify-center bg-green-500/10 px-3 text-green-500 hover:bg-green-500/20"
|
||||
onClick={() => handleClearCompleted()}
|
||||
>
|
||||
<FontAwesomeIcon icon={faBroom} className="mr-1.5" />
|
||||
Clear completed
|
||||
</Button>
|
||||
<Button
|
||||
className="rounded-full flex h-9 items-center justify-center bg-orange-500/10 px-3 text-orange-500 hover:bg-orange-500/20"
|
||||
className="flex h-9 items-center justify-center bg-orange-500/10 px-3 text-orange-500 hover:bg-orange-500/20"
|
||||
onClick={() => handleClearStale()}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrashAlt} className="mr-1.5" />
|
||||
Clear stale
|
||||
</Button>
|
||||
<Button
|
||||
className="rounded-full flex h-9 items-center justify-center bg-red-500/10 px-3 text-red-400 hover:bg-red-500/20"
|
||||
className="flex h-9 items-center justify-center bg-red-500/10 px-3 text-red-400 hover:bg-red-500/20"
|
||||
onClick={() => handleClearFailed()}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrashAlt} className="mr-1.5" />
|
||||
Clear failed
|
||||
</Button>
|
||||
<Button
|
||||
className="rounded-full flex h-9 items-center justify-center bg-red-500/10 px-3 text-red-500 hover:bg-red-500/20"
|
||||
className="flex h-9 items-center justify-center bg-red-500/10 px-3 text-red-500 hover:bg-red-500/20"
|
||||
onClick={() => handleRemoveAll()}
|
||||
>
|
||||
<FontAwesomeIcon icon={faBomb} className="mr-1.5" />
|
||||
|
||||
@@ -39,9 +39,6 @@ interface PricingTableProps {
|
||||
compact?: boolean;
|
||||
showSeedanceFeatures?: boolean;
|
||||
showEnterprise?: boolean;
|
||||
/** Use the unified landing3-style monochrome theme instead of per-plan
|
||||
* green/purple/orange color schemes. */
|
||||
unifiedTheme?: boolean;
|
||||
}
|
||||
|
||||
const PricingTable = ({
|
||||
@@ -53,7 +50,6 @@ const PricingTable = ({
|
||||
compact = false,
|
||||
showSeedanceFeatures = false,
|
||||
showEnterprise = false,
|
||||
unifiedTheme = false,
|
||||
}: PricingTableProps) => {
|
||||
const navigate = useNavigate();
|
||||
const [billingType, setBillingType] = useState("yearly");
|
||||
@@ -116,17 +112,14 @@ const PricingTable = ({
|
||||
const getColorSchemeClasses = (
|
||||
colorScheme: SubscriptionPlanDetails["colorScheme"],
|
||||
) => {
|
||||
const baseClasses = unifiedTheme
|
||||
? "relative rounded-2xl sm:rounded-[28px] p-5 md:p-6 border flex flex-col transition-all duration-300"
|
||||
: "relative rounded-3xl p-6 md:p-8 border flex flex-col transition-all duration-300 backdrop-blur-md";
|
||||
const baseClasses =
|
||||
"relative rounded-3xl p-6 md:p-8 border flex flex-col transition-all duration-300 backdrop-blur-md";
|
||||
|
||||
switch (colorScheme) {
|
||||
case "dark":
|
||||
return twMerge(
|
||||
baseClasses,
|
||||
unifiedTheme
|
||||
? "bg-[#080808] border-white/[0.08] hover:border-white/15"
|
||||
: "bg-[#1C1C20] border-white/10 hover:border-white/20",
|
||||
"bg-[#1C1C20] border-white/10 hover:border-white/20",
|
||||
);
|
||||
case "green":
|
||||
return twMerge(
|
||||
@@ -339,9 +332,7 @@ const PricingTable = ({
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`${unifiedTheme ? "max-w-6xl" : "max-w-7xl"} mx-auto grid ${gridCols} gap-4 md:gap-6`}
|
||||
>
|
||||
<div className={`max-w-7xl mx-auto grid ${gridCols} gap-4 md:gap-6`}>
|
||||
{plans.map((plan) => {
|
||||
const isPopular = plan.slug === "artcraft_pro";
|
||||
const isCurrent = isCurrentPlan(plan.slug);
|
||||
@@ -358,14 +349,8 @@ const PricingTable = ({
|
||||
}
|
||||
>
|
||||
{isPopular && !isCurrent && (
|
||||
<div
|
||||
className={
|
||||
unifiedTheme
|
||||
? "absolute top-0 left-1/2 -translate-x-1/2 -translate-y-1/2 inline-flex items-center text-xs font-semibold text-primary-200 bg-primary/[0.18] border border-primary/30 rounded-full px-3 py-1 backdrop-blur whitespace-nowrap"
|
||||
: "absolute top-0 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-primary px-4 py-1 rounded-full text-sm font-bold shadow-lg whitespace-nowrap"
|
||||
}
|
||||
>
|
||||
Most Popular
|
||||
<div className="absolute top-0 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-primary px-4 py-1 rounded-full text-sm font-bold shadow-lg whitespace-nowrap">
|
||||
MOST POPULAR
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -451,13 +436,7 @@ const PricingTable = ({
|
||||
|
||||
{/* Enterprise card - inline in grid for normal view */}
|
||||
{showEnterprise && !compact && (
|
||||
<div
|
||||
className={
|
||||
unifiedTheme
|
||||
? "relative rounded-2xl sm:rounded-[28px] p-5 md:p-6 border flex flex-col transition-all duration-300 bg-gradient-to-b from-[#0d1f4a]/90 via-[#183878]/60 to-[#2456b8]/15 border-[#3568c9]/40 hover:border-[#3568c9] hover:shadow-[0_0_30px_rgba(53,104,201,0.25)]"
|
||||
: "relative rounded-3xl p-6 md:p-8 border flex flex-col transition-all duration-300 backdrop-blur-md bg-gradient-to-b from-[#0d1f4a]/90 via-[#183878]/60 to-[#2456b8]/15 border-[#3568c9]/40 hover:border-[#3568c9] hover:shadow-[0_0_30px_rgba(53,104,201,0.25)]"
|
||||
}
|
||||
>
|
||||
<div className="relative rounded-3xl p-6 md:p-8 border flex flex-col transition-all duration-300 backdrop-blur-md bg-gradient-to-b from-[#0d1f4a]/90 via-[#183878]/60 to-[#2456b8]/15 border-[#3568c9]/40 hover:border-[#3568c9] hover:shadow-[0_0_30px_rgba(53,104,201,0.25)]">
|
||||
<h3 className="text-xl md:text-2xl font-bold mb-2 text-white">
|
||||
Enterprise
|
||||
</h3>
|
||||
@@ -489,13 +468,7 @@ const PricingTable = ({
|
||||
|
||||
{/* Enterprise card - horizontal bar below grid for compact/sd2 view */}
|
||||
{showEnterprise && compact && (
|
||||
<div
|
||||
className={
|
||||
unifiedTheme
|
||||
? "mt-4 relative rounded-2xl sm:rounded-[28px] p-6 border flex flex-col md:flex-row md:items-center gap-4 md:gap-8 transition-all duration-300 bg-gradient-to-b from-[#0d1f4a]/90 via-[#183878]/60 to-[#2456b8]/15 border-[#3568c9]/40 hover:border-[#3568c9] hover:shadow-[0_0_30px_rgba(53,104,201,0.25)]"
|
||||
: "mt-4 relative rounded-3xl p-6 border flex flex-col md:flex-row md:items-center gap-4 md:gap-8 transition-all duration-300 backdrop-blur-md bg-gradient-to-b from-[#0d1f4a]/90 via-[#183878]/60 to-[#2456b8]/15 border-[#3568c9]/40 hover:border-[#3568c9] hover:shadow-[0_0_30px_rgba(53,104,201,0.25)]"
|
||||
}
|
||||
>
|
||||
<div className="mt-4 relative rounded-3xl p-6 border flex flex-col md:flex-row md:items-center gap-4 md:gap-8 transition-all duration-300 backdrop-blur-md bg-gradient-to-b from-[#0d1f4a]/90 via-[#183878]/60 to-[#2456b8]/15 border-[#3568c9]/40 hover:border-[#3568c9] hover:shadow-[0_0_30px_rgba(53,104,201,0.25)]">
|
||||
<div className="flex-shrink-0">
|
||||
<h3 className="text-lg font-bold text-white">Enterprise</h3>
|
||||
<div className="text-2xl font-bold mt-1">Custom</div>
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
Character,
|
||||
MediaUploadApi,
|
||||
} from "@storyteller/api";
|
||||
import { toast } from "../toast/toast";
|
||||
import { toast } from "@storyteller/ui-toaster";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import { useCharactersStore } from "./characters-store";
|
||||
@@ -39,11 +39,9 @@ interface UploadedImage {
|
||||
interface PendingCharacter {
|
||||
name: string;
|
||||
previewUrl?: string;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
const POLL_INTERVAL_MS = 5000;
|
||||
const PENDING_STALE_MS = 3 * 60 * 1000;
|
||||
|
||||
export const CharactersModal = ({
|
||||
isOpen,
|
||||
@@ -76,11 +74,8 @@ export const CharactersModal = ({
|
||||
setRefreshKey((k) => k + 1);
|
||||
};
|
||||
|
||||
const handleCreated = (pending: { name: string; previewUrl?: string }) => {
|
||||
setPendingCharacters((prev) => [
|
||||
{ ...pending, createdAt: Date.now() },
|
||||
...prev,
|
||||
]);
|
||||
const handleCreated = (pending: PendingCharacter) => {
|
||||
setPendingCharacters((prev) => [pending, ...prev]);
|
||||
setView("list");
|
||||
setRefreshKey((k) => k + 1);
|
||||
};
|
||||
@@ -89,58 +84,6 @@ export const CharactersModal = ({
|
||||
setPendingCharacters((prev) => prev.filter((p) => p.name !== name));
|
||||
}, []);
|
||||
|
||||
// Poll the server while the modal is open and any creation is pending, so
|
||||
// a creation that finishes while the user is on the create/edit view still
|
||||
// gets cleaned up — preventing the duplicate (real + pending) card.
|
||||
useEffect(() => {
|
||||
if (!isOpen || pendingCharacters.length === 0) return;
|
||||
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const api = new CharactersApi();
|
||||
const res = await api.ListAllCharacters();
|
||||
if (!res.success || !res.data) return;
|
||||
|
||||
const serverNames = new Set(res.data.map((c) => c.name));
|
||||
let resolved = false;
|
||||
setPendingCharacters((prev) =>
|
||||
prev.filter((p) => {
|
||||
if (serverNames.has(p.name)) {
|
||||
resolved = true;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
);
|
||||
if (resolved) setRefreshKey((k) => k + 1);
|
||||
} catch {
|
||||
// retry next tick
|
||||
}
|
||||
}, POLL_INTERVAL_MS);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [isOpen, pendingCharacters.length]);
|
||||
|
||||
// Time out failed creations so the "Creating..." card never sticks forever.
|
||||
useEffect(() => {
|
||||
if (pendingCharacters.length === 0) return;
|
||||
|
||||
const timers = pendingCharacters.map((pending) =>
|
||||
setTimeout(
|
||||
() => {
|
||||
setPendingCharacters((prev) => {
|
||||
if (!prev.some((p) => p.name === pending.name)) return prev;
|
||||
toast.error(`Character "${pending.name}" failed to create`);
|
||||
return prev.filter((p) => p.name !== pending.name);
|
||||
});
|
||||
},
|
||||
Math.max(0, PENDING_STALE_MS - (Date.now() - pending.createdAt)),
|
||||
),
|
||||
);
|
||||
|
||||
return () => timers.forEach(clearTimeout);
|
||||
}, [pendingCharacters]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
@@ -205,9 +148,13 @@ const CharacterListView = ({
|
||||
}) => {
|
||||
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);
|
||||
@@ -226,33 +173,79 @@ const CharacterListView = ({
|
||||
[storeSetCharacters, storeSetLoaded],
|
||||
);
|
||||
|
||||
const fetchCharacters = useCallback(async () => {
|
||||
try {
|
||||
const api = new CharactersApi();
|
||||
const res = await api.ListAllCharacters();
|
||||
if (res.success && res.data) {
|
||||
setCharacters(res.data);
|
||||
syncToStore(res.data);
|
||||
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;
|
||||
}
|
||||
} catch {
|
||||
storeSetLoaded(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [syncToStore, storeSetLoaded]);
|
||||
},
|
||||
[syncToStore, storeSetLoaded],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCharacters();
|
||||
}, [fetchCharacters]);
|
||||
|
||||
// Drop pendings already represented in the fetched list (no duplicate cards)
|
||||
// Poll for pending characters becoming active
|
||||
useEffect(() => {
|
||||
if (pendingCharacters.length === 0) return;
|
||||
const names = new Set(characters.map((c) => c.name));
|
||||
for (const pending of pendingCharacters) {
|
||||
if (names.has(pending.name)) onPendingResolved(pending.name);
|
||||
}
|
||||
}, [characters, pendingCharacters, onPendingResolved]);
|
||||
|
||||
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);
|
||||
@@ -430,6 +423,16 @@ const CharacterListView = ({
|
||||
</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">
|
||||
@@ -608,7 +611,7 @@ const NewCharacterView = ({
|
||||
onCreated,
|
||||
}: {
|
||||
onBack: () => void;
|
||||
onCreated: (pending: { name: string; previewUrl?: string }) => void;
|
||||
onCreated: (pending: PendingCharacter) => void;
|
||||
}) => {
|
||||
const addCharacterToStore = useCharactersStore((s) => s.addCharacter);
|
||||
const [name, setName] = useState("");
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { UploaderStates } from "@storyteller/common";
|
||||
import { toast } from "../toast/toast";
|
||||
import type { RefVideo, RefAudio } from "./types";
|
||||
import {
|
||||
uploadVideo,
|
||||
@@ -48,6 +47,11 @@ export const MediaReferenceRow = ({
|
||||
const [uploadingVideo, setUploadingVideo] = useState(false);
|
||||
const [uploadingAudio, setUploadingAudio] = useState(false);
|
||||
|
||||
const referenceVideosRef = useRef(referenceVideos);
|
||||
referenceVideosRef.current = referenceVideos;
|
||||
const referenceAudiosRef = useRef(referenceAudios);
|
||||
referenceAudiosRef.current = referenceAudios;
|
||||
|
||||
const totalVideoDuration = referenceVideos.reduce(
|
||||
(sum, v) => sum + v.duration,
|
||||
0,
|
||||
@@ -57,42 +61,24 @@ export const MediaReferenceRow = ({
|
||||
0,
|
||||
);
|
||||
|
||||
const canAddVideo =
|
||||
referenceVideos.length < maxVideoCount &&
|
||||
totalVideoDuration < maxVideoRefDuration &&
|
||||
!uploadingVideo;
|
||||
const canAddAudio =
|
||||
referenceAudios.length < maxAudioCount &&
|
||||
totalAudioDuration < maxAudioRefDuration &&
|
||||
!uploadingAudio;
|
||||
const canAddVideo = referenceVideos.length < maxVideoCount && !uploadingVideo;
|
||||
const canAddAudio = referenceAudios.length < maxAudioCount && !uploadingAudio;
|
||||
|
||||
const handleVideoUpload = async (
|
||||
e: React.ChangeEvent<HTMLInputElement>,
|
||||
) => {
|
||||
const files = Array.from(e.target.files || []);
|
||||
if (videoInputRef.current) videoInputRef.current.value = "";
|
||||
if (files.length === 0) return;
|
||||
const handleVideoUpload = useCallback(
|
||||
async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files || []);
|
||||
if (files.length === 0) return;
|
||||
|
||||
// Snapshot at call time so a stale read inside the async callback can't overwrite a remove.
|
||||
const baseVideos = [...referenceVideos];
|
||||
const file = files[0];
|
||||
const duration = await getVideoDuration(file);
|
||||
|
||||
const file = files[0];
|
||||
const duration = await getVideoDuration(file);
|
||||
if (totalVideoDuration + duration > maxVideoRefDuration) {
|
||||
if (videoInputRef.current) videoInputRef.current.value = "";
|
||||
return;
|
||||
}
|
||||
|
||||
if (duration <= 0) {
|
||||
toast.error("Could not read video file");
|
||||
return;
|
||||
}
|
||||
const currentTotal = baseVideos.reduce((sum, v) => sum + v.duration, 0);
|
||||
if (currentTotal + duration > maxVideoRefDuration) {
|
||||
toast.error(
|
||||
`Video too long — max ${maxVideoRefDuration}s total (${maxVideoRefDuration - currentTotal}s remaining)`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
setUploadingVideo(true);
|
||||
|
||||
setUploadingVideo(true);
|
||||
try {
|
||||
await uploadVideo({
|
||||
title: `reference-video-${Math.random().toString(36).substring(2, 15)}`,
|
||||
assetFile: file,
|
||||
@@ -105,50 +91,37 @@ export const MediaReferenceRow = ({
|
||||
mediaToken: newState.data,
|
||||
duration,
|
||||
};
|
||||
onReferenceVideosChange([...baseVideos, refVideo]);
|
||||
onReferenceVideosChange([...referenceVideosRef.current, refVideo]);
|
||||
setUploadingVideo(false);
|
||||
} else if (
|
||||
newState.status === UploaderStates.assetError ||
|
||||
newState.status === UploaderStates.imageCreateError
|
||||
) {
|
||||
toast.error(newState.errorMessage || "Could not upload video");
|
||||
setUploadingVideo(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : "Could not upload video",
|
||||
);
|
||||
} finally {
|
||||
setUploadingVideo(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAudioUpload = async (
|
||||
e: React.ChangeEvent<HTMLInputElement>,
|
||||
) => {
|
||||
const files = Array.from(e.target.files || []);
|
||||
if (audioInputRef.current) audioInputRef.current.value = "";
|
||||
if (files.length === 0) return;
|
||||
if (videoInputRef.current) videoInputRef.current.value = "";
|
||||
},
|
||||
[totalVideoDuration, maxVideoRefDuration, onReferenceVideosChange],
|
||||
);
|
||||
|
||||
const baseAudios = [...referenceAudios];
|
||||
const handleAudioUpload = useCallback(
|
||||
async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files || []);
|
||||
if (files.length === 0) return;
|
||||
|
||||
const file = files[0];
|
||||
const duration = await getAudioDuration(file);
|
||||
const file = files[0];
|
||||
const duration = await getAudioDuration(file);
|
||||
|
||||
if (duration <= 0) {
|
||||
toast.error("Could not read audio file");
|
||||
return;
|
||||
}
|
||||
const currentTotal = baseAudios.reduce((sum, a) => sum + a.duration, 0);
|
||||
if (currentTotal + duration > maxAudioRefDuration) {
|
||||
toast.error(
|
||||
`Audio too long — max ${maxAudioRefDuration}s total (${maxAudioRefDuration - currentTotal}s remaining)`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (totalAudioDuration + duration > maxAudioRefDuration) {
|
||||
if (audioInputRef.current) audioInputRef.current.value = "";
|
||||
return;
|
||||
}
|
||||
|
||||
setUploadingAudio(true);
|
||||
|
||||
setUploadingAudio(true);
|
||||
try {
|
||||
await uploadAudio({
|
||||
title: `reference-audio-${Math.random().toString(36).substring(2, 15)}`,
|
||||
assetFile: file,
|
||||
@@ -161,23 +134,21 @@ export const MediaReferenceRow = ({
|
||||
mediaToken: newState.data,
|
||||
duration,
|
||||
};
|
||||
onReferenceAudiosChange([...baseAudios, refAudio]);
|
||||
onReferenceAudiosChange([...referenceAudiosRef.current, refAudio]);
|
||||
setUploadingAudio(false);
|
||||
} else if (
|
||||
newState.status === UploaderStates.assetError ||
|
||||
newState.status === UploaderStates.imageCreateError
|
||||
) {
|
||||
toast.error(newState.errorMessage || "Could not upload audio");
|
||||
setUploadingAudio(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : "Could not upload audio",
|
||||
);
|
||||
} finally {
|
||||
setUploadingAudio(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (audioInputRef.current) audioInputRef.current.value = "";
|
||||
},
|
||||
[totalAudioDuration, maxAudioRefDuration, onReferenceAudiosChange],
|
||||
);
|
||||
|
||||
const removeVideo = (id: string) => {
|
||||
const video = referenceVideos.find((v) => v.id === id);
|
||||
|
||||
@@ -529,13 +529,9 @@ export const MentionTextarea = forwardRef<HTMLDivElement, MentionTextareaProps>(
|
||||
const el = editorRef.current;
|
||||
if (!el) return;
|
||||
|
||||
// Use the known mention bounds — `getCaretOffset` can return 0 when the
|
||||
// dropdown click collapses the selection out of the editor, which would
|
||||
// leave the typed query in the prompt alongside the inserted label.
|
||||
const queryEnd =
|
||||
mentionState.triggerIndex + mentionState.query.length;
|
||||
const caretPos = getCaretOffset(el);
|
||||
const before = value.slice(0, mentionState.triggerIndex);
|
||||
const after = value.slice(queryEnd);
|
||||
const after = value.slice(caretPos);
|
||||
const mention = `${item.label} `;
|
||||
const newValue = before + mention + after;
|
||||
|
||||
@@ -554,7 +550,7 @@ export const MentionTextarea = forwardRef<HTMLDivElement, MentionTextareaProps>(
|
||||
el.focus();
|
||||
});
|
||||
},
|
||||
[value, mentionState.triggerIndex, mentionState.query, onChange],
|
||||
[value, mentionState.triggerIndex, onChange],
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
@@ -591,7 +587,10 @@ export const MentionTextarea = forwardRef<HTMLDivElement, MentionTextareaProps>(
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key === "Enter") {
|
||||
// Default: plain Enter inserts a newline (instead of letting the
|
||||
// contentEditable create a <div>). Shift/Cmd+Enter falls through to
|
||||
// externalOnKeyDown so the host can submit.
|
||||
if (e.key === "Enter" && !e.shiftKey && !e.metaKey) {
|
||||
e.preventDefault();
|
||||
document.execCommand("insertLineBreak");
|
||||
handleInput();
|
||||
|
||||
@@ -21,7 +21,6 @@ import { Tooltip } from "@storyteller/ui-tooltip";
|
||||
import { ImagePromptRow } from "./ImagePromptRow";
|
||||
import { MentionTextarea } from "./MentionTextarea";
|
||||
import type { RefImage, MentionItem } from "./types";
|
||||
import { useEnterToGenerateStore } from "../../lib/enter-to-generate-store";
|
||||
|
||||
// ── @-mention color palette ─────────────────────────────────────────────
|
||||
|
||||
@@ -145,7 +144,6 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const highlightRef = useRef<HTMLDivElement>(null);
|
||||
const mentionEditorRef = useRef<HTMLDivElement>(null);
|
||||
const enterToGenerate = useEnterToGenerateStore((s) => s.enabled);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [showImagePrompts, setShowImagePrompts] = useState(false);
|
||||
@@ -293,7 +291,8 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key === "Enter" && enterToGenerate && !e.shiftKey) {
|
||||
// Default: Enter inserts a newline; Shift+Enter submits.
|
||||
if (e.key === "Enter" && e.shiftKey) {
|
||||
e.preventDefault();
|
||||
onSubmit();
|
||||
}
|
||||
@@ -363,7 +362,7 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
|
||||
}, [prompt, hasMentionItems, mentionRegex, mentionLabelMap, mentionItems]);
|
||||
|
||||
return (
|
||||
<div ref={ref} className="prompt-box-root font-display">
|
||||
<div ref={ref}>
|
||||
<div className="relative flex flex-col">
|
||||
{isImageRowVisible && (
|
||||
<ImagePromptRow
|
||||
@@ -437,7 +436,8 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
|
||||
)}
|
||||
colorMap={mentionColorMap}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && enterToGenerate && !e.shiftKey && !e.metaKey) {
|
||||
// Default: Enter inserts a newline; Shift+Enter (or Cmd+Enter) submits.
|
||||
if (e.key === "Enter" && (e.shiftKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
onSubmit();
|
||||
}
|
||||
|
||||
@@ -15,29 +15,22 @@ export const uploadVideo: UploadMediaFn = async ({
|
||||
const api = new MediaUploadApi();
|
||||
progressCallback({ status: UploaderStates.uploadingImage });
|
||||
|
||||
try {
|
||||
const response = await api.UploadNewVideo({
|
||||
uuid: crypto.randomUUID(),
|
||||
blob: assetFile,
|
||||
fileName: assetFile.name || `reference-video-${Date.now()}`,
|
||||
maybe_title: `ref_video_${title}`,
|
||||
});
|
||||
const response = await api.UploadNewVideo({
|
||||
uuid: crypto.randomUUID(),
|
||||
blob: assetFile,
|
||||
fileName: assetFile.name || `reference-video-${Date.now()}`,
|
||||
maybe_title: `ref_video_${title}`,
|
||||
});
|
||||
|
||||
if (!response?.success || !response.data) {
|
||||
progressCallback({
|
||||
status: UploaderStates.imageCreateError,
|
||||
errorMessage: response?.errorMessage ?? "Could not upload video",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
progressCallback({ status: UploaderStates.success, data: response.data });
|
||||
} catch (err) {
|
||||
if (!response?.success || !response.data) {
|
||||
progressCallback({
|
||||
status: UploaderStates.imageCreateError,
|
||||
errorMessage: err instanceof Error ? err.message : "Could not upload video",
|
||||
errorMessage: response?.errorMessage ?? "Could not upload video",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
progressCallback({ status: UploaderStates.success, data: response.data });
|
||||
};
|
||||
|
||||
export const uploadAudio: UploadMediaFn = async ({
|
||||
@@ -48,29 +41,22 @@ export const uploadAudio: UploadMediaFn = async ({
|
||||
const api = new MediaUploadApi();
|
||||
progressCallback({ status: UploaderStates.uploadingImage });
|
||||
|
||||
try {
|
||||
const response = await api.UploadAudio({
|
||||
uuid: crypto.randomUUID(),
|
||||
blob: assetFile,
|
||||
fileName: assetFile.name || `reference-audio-${Date.now()}`,
|
||||
maybe_title: `ref_audio_${title}`,
|
||||
});
|
||||
const response = await api.UploadAudio({
|
||||
uuid: crypto.randomUUID(),
|
||||
blob: assetFile,
|
||||
fileName: assetFile.name || `reference-audio-${Date.now()}`,
|
||||
maybe_title: `ref_audio_${title}`,
|
||||
});
|
||||
|
||||
if (!response?.success || !response.data) {
|
||||
progressCallback({
|
||||
status: UploaderStates.imageCreateError,
|
||||
errorMessage: response?.errorMessage ?? "Could not upload audio",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
progressCallback({ status: UploaderStates.success, data: response.data });
|
||||
} catch (err) {
|
||||
if (!response?.success || !response.data) {
|
||||
progressCallback({
|
||||
status: UploaderStates.imageCreateError,
|
||||
errorMessage: err instanceof Error ? err.message : "Could not upload audio",
|
||||
errorMessage: response?.errorMessage ?? "Could not upload audio",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
progressCallback({ status: UploaderStates.success, data: response.data });
|
||||
};
|
||||
|
||||
export const getVideoDuration = (file: File): Promise<number> =>
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
import { Modal } from "@storyteller/ui-modal";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faCog } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { Switch } from "@storyteller/ui-switch";
|
||||
import { useEnterToGenerateStore } from "../../lib/enter-to-generate-store";
|
||||
|
||||
interface SettingsModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
|
||||
const enterToGenerate = useEnterToGenerateStore((s) => s.enabled);
|
||||
const setEnterToGenerate = useEnterToGenerateStore((s) => s.setEnabled);
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} className="max-w-3xl" childPadding={false}>
|
||||
<div className="h-[560px]">
|
||||
<div className="grid h-full grid-cols-12 gap-3">
|
||||
<div className="relative col-span-4 p-3 pt-2 after:absolute after:right-0 after:top-0 after:h-full after:w-px after:bg-ui-panel-border">
|
||||
<div className="flex items-center gap-2.5 py-0.5">
|
||||
<h2 className="text-[18px] font-semibold opacity-80">Settings</h2>
|
||||
</div>
|
||||
<hr className="my-2 w-full border-ui-panel-border" />
|
||||
<div className="space-y-1">
|
||||
<button className="h-9 w-full rounded-lg p-2 text-left bg-[#63636B]/20">
|
||||
<div className="flex items-center gap-2.5 text-sm">
|
||||
<FontAwesomeIcon icon={faCog} />
|
||||
General
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-span-8 flex h-full flex-col overflow-y-auto relative">
|
||||
<div className="w-full border-b border-ui-panel-border py-2.5 ps-0">
|
||||
<h2 className="text-[18px] font-semibold">General</h2>
|
||||
</div>
|
||||
<div className="p-3 ps-0 text-sm h-full">
|
||||
<div className="space-y-4 text-base-fg">
|
||||
<div className="flex flex-col gap-2 pt-3">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<p className="text-sm font-medium">Enter to generate</p>
|
||||
<p className="text-xs opacity-70">
|
||||
When on, pressing Enter submits the prompt and Shift+Enter
|
||||
adds a new line. When off (default), both Enter and
|
||||
Shift+Enter add a new line — use the button to submit.
|
||||
</p>
|
||||
</div>
|
||||
<Switch enabled={enterToGenerate} setEnabled={setEnterToGenerate} offClassName="bg-white/20" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -35,13 +35,6 @@ export function showToast(type: ToastType, message: string, duration?: number) {
|
||||
);
|
||||
}
|
||||
|
||||
export const toast = {
|
||||
success: (message: string, opts?: { duration?: number }) =>
|
||||
showToast("success", message, opts?.duration),
|
||||
error: (message: string, opts?: { duration?: number }) =>
|
||||
showToast("error", message, opts?.duration),
|
||||
};
|
||||
|
||||
// ── Component ──────────────────────────────────────────────────────────────
|
||||
|
||||
const DEFAULT_DURATION = 6000;
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
export { TruchetPattern, PagePatternBackdrop } from "./truchet-pattern";
|
||||
export type { TruchetVariant } from "./truchet-pattern";
|
||||
@@ -1,167 +0,0 @@
|
||||
/**
|
||||
* Subtle blue-tinted Truchet (random-diagonal) tile pattern used as a
|
||||
* background flourish on landing, pricing, and content pages. Each variant
|
||||
* has its own deterministic 6x4 grid so different pages feel related but
|
||||
* not identical.
|
||||
*/
|
||||
|
||||
type TruchetCell = "/" | "\\";
|
||||
type TruchetGrid = readonly (readonly TruchetCell[])[];
|
||||
|
||||
export type TruchetVariant = "landing" | "pricing" | "content" | "auth";
|
||||
|
||||
const GRIDS: Record<TruchetVariant, TruchetGrid> = {
|
||||
landing: [
|
||||
["/", "\\", "/", "/", "\\", "/"],
|
||||
["\\", "/", "/", "\\", "\\", "/"],
|
||||
["/", "/", "\\", "/", "/", "\\"],
|
||||
["\\", "/", "/", "\\", "/", "/"],
|
||||
],
|
||||
pricing: [
|
||||
["\\", "\\", "/", "\\", "/", "\\"],
|
||||
["/", "\\", "\\", "/", "\\", "/"],
|
||||
["\\", "/", "\\", "\\", "/", "\\"],
|
||||
["/", "\\", "/", "\\", "\\", "/"],
|
||||
],
|
||||
content: [
|
||||
["/", "/", "\\", "/", "\\", "\\"],
|
||||
["\\", "\\", "/", "\\", "/", "/"],
|
||||
["/", "\\", "\\", "/", "\\", "/"],
|
||||
["\\", "/", "/", "\\", "/", "\\"],
|
||||
],
|
||||
auth: [
|
||||
["\\", "/", "\\", "/", "\\", "/"],
|
||||
["/", "\\", "/", "\\", "/", "\\"],
|
||||
["\\", "/", "/", "\\", "\\", "/"],
|
||||
["/", "\\", "\\", "/", "/", "\\"],
|
||||
],
|
||||
};
|
||||
|
||||
interface TruchetPatternProps {
|
||||
className?: string;
|
||||
/** Multiplier on the default fill/stroke alpha. Defaults to 1. */
|
||||
intensity?: number;
|
||||
/** Which deterministic 6x4 grid to render. Defaults to "landing". */
|
||||
variant?: TruchetVariant;
|
||||
}
|
||||
|
||||
export function TruchetPattern({
|
||||
className,
|
||||
intensity = 1,
|
||||
variant = "landing",
|
||||
}: TruchetPatternProps) {
|
||||
// Slightly reduced base opacity for a quieter overall feel.
|
||||
const fillA = `rgba(120,170,255,${0.036 * intensity})`;
|
||||
const fillB = `rgba(120,170,255,${0.0095 * intensity})`;
|
||||
const diagStroke = `rgba(120,170,255,${0.16 * intensity})`;
|
||||
const gridStroke = `rgba(120,170,255,${0.065 * intensity})`;
|
||||
|
||||
const grid = GRIDS[variant];
|
||||
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 1200 800"
|
||||
preserveAspectRatio="xMidYMid slice"
|
||||
className={className}
|
||||
>
|
||||
{grid.flatMap((row, ri) =>
|
||||
row.map((dir, ci) => {
|
||||
const x = ci * 200;
|
||||
const y = ri * 200;
|
||||
const isSlash = dir === "/";
|
||||
const triA = isSlash
|
||||
? `${x},${y} ${x + 200},${y} ${x},${y + 200}`
|
||||
: `${x},${y} ${x + 200},${y} ${x + 200},${y + 200}`;
|
||||
const triB = isSlash
|
||||
? `${x + 200},${y} ${x + 200},${y + 200} ${x},${y + 200}`
|
||||
: `${x},${y} ${x + 200},${y + 200} ${x},${y + 200}`;
|
||||
const diag = isSlash
|
||||
? `M${x},${y + 200}L${x + 200},${y}`
|
||||
: `M${x},${y}L${x + 200},${y + 200}`;
|
||||
return (
|
||||
<g key={`${ri}-${ci}`}>
|
||||
<polygon points={triA} fill={fillA} />
|
||||
<polygon points={triB} fill={fillB} />
|
||||
<path d={diag} stroke={diagStroke} strokeWidth="0.6" fill="none" />
|
||||
</g>
|
||||
);
|
||||
}),
|
||||
)}
|
||||
{[1, 2, 3].map((i) => (
|
||||
<line
|
||||
key={`h${i}`}
|
||||
x1="0"
|
||||
y1={i * 200}
|
||||
x2="1200"
|
||||
y2={i * 200}
|
||||
stroke={gridStroke}
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
))}
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<line
|
||||
key={`v${i}`}
|
||||
x1={i * 200}
|
||||
y1="0"
|
||||
x2={i * 200}
|
||||
y2="800"
|
||||
stroke={gridStroke}
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default TruchetPattern;
|
||||
|
||||
/**
|
||||
* Drop-in subtle backdrop combining a top + bottom Truchet pattern with
|
||||
* radial fades. Place once at the top of a page's root <div> (which must be
|
||||
* `relative`, as most marketing pages already are).
|
||||
*/
|
||||
interface PagePatternBackdropProps {
|
||||
variant?: TruchetVariant;
|
||||
}
|
||||
|
||||
export function PagePatternBackdrop({
|
||||
variant = "content",
|
||||
}: PagePatternBackdropProps) {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-x-0 top-0 h-[1100px] z-0"
|
||||
style={{
|
||||
maskImage:
|
||||
"radial-gradient(ellipse 80% 60% at 50% 35%, black 25%, transparent 80%)",
|
||||
WebkitMaskImage:
|
||||
"radial-gradient(ellipse 80% 60% at 50% 35%, black 25%, transparent 80%)",
|
||||
}}
|
||||
>
|
||||
<TruchetPattern
|
||||
variant={variant}
|
||||
intensity={0.5}
|
||||
className="absolute inset-0 w-full h-full"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-x-0 bottom-0 h-[700px] z-0"
|
||||
style={{
|
||||
maskImage:
|
||||
"radial-gradient(ellipse 75% 60% at 50% 60%, black 20%, transparent 80%)",
|
||||
WebkitMaskImage:
|
||||
"radial-gradient(ellipse 75% 60% at 50% 60%, black 20%, transparent 80%)",
|
||||
}}
|
||||
>
|
||||
<TruchetPattern
|
||||
variant={variant}
|
||||
intensity={0.5}
|
||||
className="absolute inset-0 w-full h-full"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user