mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-14 11:11:18 -05:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b8db60365b |
+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,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;
|
||||
|
||||
+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;
|
||||
@@ -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()
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
-1
@@ -151,7 +151,6 @@ export function GenerationGalleryGrid({
|
||||
<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}
|
||||
|
||||
+46
-102
@@ -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 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -70,48 +69,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 +107,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 +131,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 +141,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 +153,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 +214,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 +231,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 +265,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 +281,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 +293,8 @@ export function useGenerationJobs(options: {
|
||||
return [...fresh, ...prev];
|
||||
});
|
||||
}
|
||||
setInProgressJobs(newInProgress);
|
||||
setFailedJobsRaw(newFailed);
|
||||
setInProgress(inProg);
|
||||
setFailed(failedJobs);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
@@ -374,7 +318,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,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 ? (
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
|
||||
@@ -363,7 +363,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
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { PromptsApi } from "@storyteller/api";
|
||||
import type { Prompts } from "@storyteller/api";
|
||||
|
||||
const promptCache = new Map<string, Prompts>();
|
||||
const inFlight = new Set<string>();
|
||||
const subscribers = new Set<() => void>();
|
||||
let api: PromptsApi | null = null;
|
||||
|
||||
function getApi(): PromptsApi {
|
||||
if (!api) api = new PromptsApi();
|
||||
return api;
|
||||
}
|
||||
|
||||
function notifySubscribers() {
|
||||
for (const cb of subscribers) cb();
|
||||
}
|
||||
|
||||
async function fetchMissing(tokens: readonly string[]): Promise<void> {
|
||||
const missing = tokens.filter(
|
||||
(t) => t && !promptCache.has(t) && !inFlight.has(t),
|
||||
);
|
||||
if (missing.length === 0) return;
|
||||
for (const t of missing) inFlight.add(t);
|
||||
try {
|
||||
const response = await getApi().BatchGetPrompts({ tokens: missing });
|
||||
if (response.success && response.data) {
|
||||
for (const p of response.data) {
|
||||
promptCache.set(p.token, p);
|
||||
}
|
||||
notifySubscribers();
|
||||
}
|
||||
} catch {
|
||||
// Failures will be retried when usePrompts re-runs with the same tokens.
|
||||
} finally {
|
||||
for (const t of missing) inFlight.delete(t);
|
||||
}
|
||||
}
|
||||
|
||||
export function getCachedPrompt(token: string): Prompts | undefined {
|
||||
return promptCache.get(token);
|
||||
}
|
||||
|
||||
export function usePrompts(tokens: readonly (string | null | undefined)[]): Map<string, Prompts> {
|
||||
const [, forceUpdate] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const cb = () => forceUpdate((n) => n + 1);
|
||||
subscribers.add(cb);
|
||||
return () => {
|
||||
subscribers.delete(cb);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const cleaned: string[] = [];
|
||||
for (const t of tokens) {
|
||||
if (t) cleaned.push(t);
|
||||
}
|
||||
// Stable cache key for the effect's dep list. Sorting keeps it stable when
|
||||
// a poll returns the same set in a different order.
|
||||
const key = [...new Set(cleaned)].sort().join(",");
|
||||
|
||||
useEffect(() => {
|
||||
if (key.length > 0) fetchMissing(key.split(","));
|
||||
}, [key]);
|
||||
|
||||
const result = new Map<string, Prompts>();
|
||||
for (const t of cleaned) {
|
||||
const p = promptCache.get(t);
|
||||
if (p) result.set(t, p);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import { UsersApi } from "@storyteller/api";
|
||||
|
||||
type SessionResponse = Awaited<ReturnType<UsersApi["GetSession"]>>;
|
||||
|
||||
// Coalesce concurrent GetSession calls into a single in-flight request, and
|
||||
// cap the wait so an unreachable backend (e.g. localhost desktop API not
|
||||
// running) doesn't tie up connection slots for ~2.35s × N callers waiting on
|
||||
// the OS-level TCP timeout. Callers who need a fresh result after auth state
|
||||
// changes pass force=true to bypass the cache.
|
||||
let inflight: Promise<SessionResponse> | null = null;
|
||||
let cachedAt = 0;
|
||||
const CACHE_TTL_MS = 30_000;
|
||||
const TIMEOUT_MS = 1500;
|
||||
|
||||
export function getSession(force = false): Promise<SessionResponse> {
|
||||
const now = Date.now();
|
||||
if (!force && inflight && now - cachedAt < CACHE_TTL_MS) {
|
||||
return inflight;
|
||||
}
|
||||
cachedAt = now;
|
||||
inflight = (async () => {
|
||||
try {
|
||||
return await Promise.race([
|
||||
new UsersApi().GetSession(),
|
||||
new Promise<SessionResponse>((_, reject) => {
|
||||
setTimeout(() => reject(new Error("session-timeout")), TIMEOUT_MS);
|
||||
}),
|
||||
]);
|
||||
} catch {
|
||||
// Backend unreachable or timed out — return a not-logged-in shape so
|
||||
// callers don't need to special-case errors. Clear the cache so the
|
||||
// next call retries instead of serving the failure forever.
|
||||
inflight = null;
|
||||
cachedAt = 0;
|
||||
return {
|
||||
success: false,
|
||||
data: { loggedIn: false },
|
||||
} as SessionResponse;
|
||||
}
|
||||
})();
|
||||
return inflight;
|
||||
}
|
||||
|
||||
export function invalidateSession() {
|
||||
inflight = null;
|
||||
cachedAt = 0;
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import { Button } from "@storyteller/ui-button";
|
||||
import { Link, useSearchParams } from "react-router-dom";
|
||||
import { SOCIAL_LINKS } from "../../config/links";
|
||||
import Seo from "../../components/seo";
|
||||
import { PagePatternBackdrop } from "../../components/truchet-pattern";
|
||||
import { useEffect, useState } from "react";
|
||||
import { UsersApi } from "@storyteller/api";
|
||||
|
||||
@@ -68,8 +67,6 @@ const CheckoutCancel = () => {
|
||||
description="Your checkout was cancelled. No payment was made."
|
||||
/>
|
||||
|
||||
<PagePatternBackdrop variant="auth" />
|
||||
|
||||
{/* Background gradient - subtle/neutral */}
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-0">
|
||||
<div className="w-[900px] h-[900px] rounded-full bg-gradient-to-br from-gray-500/20 via-gray-600/10 to-gray-700/5 opacity-40 blur-[120px]"></div>
|
||||
@@ -114,7 +111,7 @@ const CheckoutCancel = () => {
|
||||
<Button
|
||||
as="link"
|
||||
href="/pricing"
|
||||
className="rounded-full bg-primary hover:bg-primary-600 px-8 py-3 text-sm font-bold rounded-xl justify-center"
|
||||
className="bg-primary hover:bg-primary-600 px-8 py-3 text-sm font-bold rounded-xl justify-center"
|
||||
>
|
||||
View Plans Again
|
||||
</Button>
|
||||
@@ -131,7 +128,7 @@ const CheckoutCancel = () => {
|
||||
as="link"
|
||||
href={SOCIAL_LINKS.DISCORD}
|
||||
target="_blank"
|
||||
className="rounded-full bg-white text-black hover:bg-white/80 px-4 py-2 text-sm font-bold rounded-xl justify-center border-transparent"
|
||||
className="bg-white text-black hover:bg-white/80 px-4 py-2 text-sm font-bold rounded-xl justify-center border-transparent"
|
||||
>
|
||||
<FontAwesomeIcon icon={faDiscord} />
|
||||
Join Discord
|
||||
|
||||
@@ -12,7 +12,6 @@ import { isMobile, isMacOs } from "react-device-detect";
|
||||
import { DOWNLOAD_LINKS } from "../../config/github_download_links";
|
||||
import { SOCIAL_LINKS } from "../../config/links";
|
||||
import Seo from "../../components/seo";
|
||||
import { PagePatternBackdrop } from "../../components/truchet-pattern";
|
||||
import { UsersApi } from "@storyteller/api";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
@@ -79,8 +78,6 @@ const CheckoutSuccess = () => {
|
||||
description="Your ArtCraft subscription is now active. Download and start creating!"
|
||||
/>
|
||||
|
||||
<PagePatternBackdrop variant="auth" />
|
||||
|
||||
{/* Background gradient - celebratory green/purple */}
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-0">
|
||||
<div className="w-[900px] h-[900px] rounded-full bg-gradient-to-br from-green-500/40 via-primary/30 to-purple-600/20 opacity-40 blur-[120px]"></div>
|
||||
@@ -145,7 +142,7 @@ const CheckoutSuccess = () => {
|
||||
<Button
|
||||
as="link"
|
||||
href={downloadUrl}
|
||||
className="rounded-full bg-primary hover:bg-primary-600 px-8 py-3 text-sm font-bold rounded-xl justify-center"
|
||||
className="bg-primary hover:bg-primary-600 px-8 py-3 text-sm font-bold rounded-xl justify-center"
|
||||
>
|
||||
<FontAwesomeIcon icon={faDownload} className="mr-2" />
|
||||
Download ArtCraft
|
||||
@@ -192,7 +189,7 @@ const CheckoutSuccess = () => {
|
||||
as="link"
|
||||
href={SOCIAL_LINKS.DISCORD}
|
||||
target="_blank"
|
||||
className="rounded-full bg-white text-black hover:bg-white/80 px-4 py-2 text-sm font-bold rounded-xl justify-center border-transparent"
|
||||
className="bg-white text-black hover:bg-white/80 px-4 py-2 text-sm font-bold rounded-xl justify-center border-transparent"
|
||||
>
|
||||
<FontAwesomeIcon icon={faDiscord} />
|
||||
Join Discord
|
||||
|
||||
@@ -10,12 +10,10 @@ import { Button } from "@storyteller/ui-button";
|
||||
import { DOWNLOAD_LINKS } from "../../config/github_download_links";
|
||||
import { isMobile, isMacOs } from "react-device-detect";
|
||||
import { useState, useEffect } from "react";
|
||||
import Lenis from "lenis";
|
||||
import { UsersApi } from "@storyteller/api";
|
||||
import { DownloadModal } from "../../components/download-modal";
|
||||
import Seo from "../../components/seo";
|
||||
import Footer from "../../components/footer";
|
||||
import { PagePatternBackdrop } from "../../components/truchet-pattern";
|
||||
|
||||
const SYSTEMS = [
|
||||
{
|
||||
@@ -56,26 +54,6 @@ const Download = () => {
|
||||
checkSession();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isMobile) return;
|
||||
const lenis = new Lenis({
|
||||
duration: 1.2,
|
||||
easing: (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t)),
|
||||
smoothWheel: true,
|
||||
lerp: 0.1,
|
||||
});
|
||||
let rafId: number;
|
||||
const raf = (time: number) => {
|
||||
lenis.raf(time);
|
||||
rafId = requestAnimationFrame(raf);
|
||||
};
|
||||
rafId = requestAnimationFrame(raf);
|
||||
return () => {
|
||||
cancelAnimationFrame(rafId);
|
||||
lenis.destroy();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const onDownloadClick = (e: React.MouseEvent) => {
|
||||
if (isLoggedIn) return;
|
||||
setShowDownloadModal(true);
|
||||
@@ -83,22 +61,17 @@ const Download = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative min-h-screen bg-[#101014] text-white overflow-hidden">
|
||||
<div className="relative min-h-screen bg-[#101014] text-white bg-dots">
|
||||
<Seo
|
||||
title="Download ArtCraft - Windows and macOS"
|
||||
description="Download ArtCraft for Windows and macOS. Start creating AI artwork today."
|
||||
/>
|
||||
|
||||
<PagePatternBackdrop variant="content" />
|
||||
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-x-0 top-0 h-[700px] z-0"
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(ellipse 60% 50% at 50% 0%, rgba(45,129,255,0.18) 0%, transparent 70%)",
|
||||
}}
|
||||
/>
|
||||
{/* Background effects */}
|
||||
<div className="dotted-pattern absolute inset-0 z-[0] opacity-50 pointer-events-none" />
|
||||
<div className="absolute top-0 inset-x-0 flex justify-center pointer-events-none z-0">
|
||||
<div className="w-[900px] h-[900px] -mt-[200px] rounded-full bg-gradient-to-br from-blue-700 via-blue-500 to-[#00AABA] opacity-20 blur-[120px]" />
|
||||
</div>
|
||||
|
||||
<div className="relative w-full max-w-6xl mx-auto px-6 sm:px-10 pt-36 sm:pt-40 pb-24">
|
||||
{/* Hero */}
|
||||
@@ -115,14 +88,14 @@ const Download = () => {
|
||||
<div className="mt-10 flex items-center justify-center">
|
||||
{isMobile ? (
|
||||
<Button
|
||||
className="rounded-full text-lg font-semibold rounded-xl shadow-lg"
|
||||
className="text-lg font-semibold rounded-xl shadow-lg"
|
||||
disabled
|
||||
>
|
||||
Download on a desktop
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
className="rounded-full glow-border-animated text-md px-8 py-4 text-lg font-semibold rounded-xl shadow-lg gap-3 transition-all duration-300 hover:scale-105 hover:shadow-primary/25 bg-white text-black hover:bg-white/90"
|
||||
className="glow-border-animated text-md px-8 py-4 text-lg font-semibold rounded-xl shadow-lg gap-3 transition-all duration-300 hover:scale-105 hover:shadow-primary/25 bg-white text-black hover:bg-white/90"
|
||||
as="link"
|
||||
href={detectedLink}
|
||||
onClick={onDownloadClick}
|
||||
@@ -198,14 +171,14 @@ const Download = () => {
|
||||
|
||||
{isMobile ? (
|
||||
<Button
|
||||
className="rounded-full w-full justify-center font-semibold"
|
||||
className="w-full justify-center font-semibold"
|
||||
disabled
|
||||
>
|
||||
Desktop only
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
className="rounded-full w-full justify-center font-semibold gap-2"
|
||||
className="w-full justify-center font-semibold gap-2"
|
||||
as="link"
|
||||
href={system.link}
|
||||
icon={faArrowDownToLine}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import Seo from "../../components/seo";
|
||||
import Footer from "../../components/footer";
|
||||
import { PagePatternBackdrop } from "../../components/truchet-pattern";
|
||||
import { getFaqItemBySlug, markdownToHtml } from "@storyteller/markdown-content";
|
||||
import { faChevronLeft } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
@@ -39,19 +38,12 @@ const FaqArticle = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative min-h-screen bg-[#101014] text-white overflow-hidden">
|
||||
<div className="relative min-h-screen bg-[#101014] text-white overflow-x-hidden bg-dots">
|
||||
<Seo title={title} description={description} jsonLd={jsonLd} />
|
||||
|
||||
<PagePatternBackdrop variant="content" />
|
||||
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-x-0 top-0 h-[700px] z-0"
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(ellipse 60% 50% at 50% 0%, rgba(45,129,255,0.18) 0%, transparent 70%)",
|
||||
}}
|
||||
/>
|
||||
<div className="absolute top-0 inset-x-0 flex justify-center pointer-events-none z-0">
|
||||
<div className="w-[900px] h-[900px] -mt-[200px] rounded-full bg-gradient-to-br from-blue-700 via-blue-500 to-[#00AABA] opacity-20 blur-[120px]"></div>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 mx-auto w-full max-w-3xl px-4 sm:px-6 pt-24 sm:pt-32 pb-32">
|
||||
<div className="mb-6">
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import Seo from "../../components/seo";
|
||||
import Footer from "../../components/footer";
|
||||
import { PagePatternBackdrop } from "../../components/truchet-pattern";
|
||||
import { getFaqItems } from "@storyteller/markdown-content";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faArrowRight } from "@fortawesome/pro-solid-svg-icons";
|
||||
@@ -27,19 +26,12 @@ const FaqIndex = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative min-h-screen bg-[#101014] text-white overflow-hidden">
|
||||
<div className="relative min-h-screen bg-[#101014] text-white overflow-x-hidden bg-dots">
|
||||
<Seo title={title} description={description} jsonLd={jsonLd} />
|
||||
|
||||
<PagePatternBackdrop variant="content" />
|
||||
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-x-0 top-0 h-[700px] z-0"
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(ellipse 60% 50% at 50% 0%, rgba(45,129,255,0.18) 0%, transparent 70%)",
|
||||
}}
|
||||
/>
|
||||
<div className="absolute top-0 inset-x-0 flex justify-center pointer-events-none z-0">
|
||||
<div className="w-[900px] h-[900px] -mt-[200px] rounded-full bg-gradient-to-br from-blue-700 via-blue-500 to-[#00AABA] opacity-20 blur-[120px]"></div>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 mx-auto w-full max-w-4xl px-4 sm:px-8 pt-28 sm:pt-36 pb-12">
|
||||
<div className="text-center mb-10">
|
||||
|
||||
@@ -11,7 +11,6 @@ import { Link } from "react-router-dom";
|
||||
import { PasswordResetApi } from "@storyteller/api";
|
||||
|
||||
import Seo from "../../components/seo";
|
||||
import { PagePatternBackdrop } from "../../components/truchet-pattern";
|
||||
|
||||
const ForgotPassword = () => {
|
||||
const [email, setEmail] = useState("");
|
||||
@@ -47,20 +46,12 @@ const ForgotPassword = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative min-h-screen bg-[#101014] text-white overflow-hidden flex flex-col">
|
||||
<div className="relative min-h-screen bg-[#101014] text-white bg-dots flex flex-col">
|
||||
<Seo
|
||||
title="Reset Password - ArtCraft"
|
||||
description="Reset your ArtCraft password."
|
||||
/>
|
||||
<PagePatternBackdrop variant="auth" />
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-x-0 top-0 h-[700px] z-0"
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(ellipse 60% 50% at 50% 0%, rgba(45,129,255,0.18) 0%, transparent 70%)",
|
||||
}}
|
||||
/>
|
||||
<div className="dotted-pattern absolute inset-0 z-[0] opacity-30" />
|
||||
|
||||
<main className="relative z-10 flex-1 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md bg-[#1C1C20] border border-white/10 rounded-2xl p-6 py-8 shadow-2xl">
|
||||
@@ -103,7 +94,7 @@ const ForgotPassword = () => {
|
||||
<div className="pt-2">
|
||||
<Button
|
||||
id="send-reset-btn"
|
||||
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}
|
||||
>
|
||||
@@ -130,12 +121,12 @@ const ForgotPassword = () => {
|
||||
<span className="text-white font-medium">{email}</span>
|
||||
</p>
|
||||
<Link to="/forgot-password/verify">
|
||||
<Button className="rounded-full w-full bg-primary hover:bg-primary-600 text-white border-none justify-center font-bold h-10 mb-3">
|
||||
<Button className="w-full bg-primary hover:bg-primary-600 text-white border-none justify-center font-bold h-10 mb-3">
|
||||
Enter Verification Code
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
className="rounded-full w-full bg-white/10 hover:bg-white/20 text-white border-none justify-center font-bold h-10"
|
||||
className="w-full bg-white/10 hover:bg-white/20 text-white border-none justify-center font-bold h-10"
|
||||
onClick={() => setSubmitted(false)}
|
||||
>
|
||||
Try another email
|
||||
|
||||
@@ -13,7 +13,6 @@ import { Link, useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { PasswordResetApi, BillingApi } from "@storyteller/api";
|
||||
|
||||
import Seo from "../../components/seo";
|
||||
import { PagePatternBackdrop } from "../../components/truchet-pattern";
|
||||
|
||||
const VerifyReset = () => {
|
||||
const navigate = useNavigate();
|
||||
@@ -103,20 +102,12 @@ const VerifyReset = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative min-h-screen bg-[#101014] text-white overflow-hidden flex flex-col">
|
||||
<div className="relative min-h-screen bg-[#101014] text-white bg-dots flex flex-col">
|
||||
<Seo
|
||||
title="Verify Password Reset - ArtCraft"
|
||||
description="Enter your verification code and new password."
|
||||
/>
|
||||
<PagePatternBackdrop variant="auth" />
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-x-0 top-0 h-[700px] z-0"
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(ellipse 60% 50% at 50% 0%, rgba(45,129,255,0.18) 0%, transparent 70%)",
|
||||
}}
|
||||
/>
|
||||
<div className="dotted-pattern absolute inset-0 z-[0] opacity-30" />
|
||||
|
||||
<main className="relative z-10 flex-1 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md bg-[#1C1C20] border border-white/10 rounded-2xl p-6 py-8 shadow-2xl">
|
||||
@@ -234,7 +225,7 @@ const VerifyReset = () => {
|
||||
<div className="pt-2">
|
||||
<Button
|
||||
id="change-password-btn"
|
||||
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}
|
||||
>
|
||||
@@ -274,7 +265,7 @@ const VerifyReset = () => {
|
||||
</p>
|
||||
<Button
|
||||
id="back-to-homepage-btn"
|
||||
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"
|
||||
onClick={() => navigate(redirectTo)}
|
||||
>
|
||||
{redirectLabel}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { default } from "./landing3";
|
||||
File diff suppressed because it is too large
Load Diff
@@ -278,7 +278,7 @@ export default function Library() {
|
||||
<Link to="/login">
|
||||
<Button
|
||||
variant="primary"
|
||||
className="rounded-full bg-white text-black hover:bg-white/90 text-sm font-semibold px-6 py-2.5 rounded-lg"
|
||||
className="bg-white text-black hover:bg-white/90 text-sm font-semibold px-6 py-2.5 rounded-lg"
|
||||
>
|
||||
Login
|
||||
</Button>
|
||||
@@ -286,7 +286,7 @@ export default function Library() {
|
||||
<Link to="/signup">
|
||||
<Button
|
||||
variant="primary"
|
||||
className="rounded-full text-sm font-semibold px-6 py-2.5 rounded-lg"
|
||||
className="text-sm font-semibold px-6 py-2.5 rounded-lg"
|
||||
>
|
||||
Sign up
|
||||
</Button>
|
||||
@@ -390,14 +390,14 @@ export default function Library() {
|
||||
<p className="text-white/40 text-sm mb-4">No items yet.</p>
|
||||
<div className="flex gap-3">
|
||||
<Link to="/create-image">
|
||||
<Button variant="primary" className="rounded-full text-sm px-4 py-2">
|
||||
<Button variant="primary" className="text-sm px-4 py-2">
|
||||
Create Image
|
||||
</Button>
|
||||
</Link>
|
||||
<Link to="/create-video">
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="rounded-full text-sm px-4 py-2 border border-ui-panel-border"
|
||||
className="text-sm px-4 py-2 border border-ui-panel-border"
|
||||
>
|
||||
Create Video
|
||||
</Button>
|
||||
|
||||
@@ -54,19 +54,12 @@ const Login = () => {
|
||||
//};
|
||||
|
||||
return (
|
||||
<div className="relative min-h-screen bg-[#101014] text-white overflow-hidden flex flex-col">
|
||||
<div className="relative min-h-screen bg-[#101014] text-white bg-dots flex flex-col">
|
||||
<Seo
|
||||
title="Login - ArtCraft"
|
||||
description="Login to your ArtCraft account."
|
||||
/>
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-x-0 top-0 h-[700px] z-0"
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(ellipse 60% 50% at 50% 0%, rgba(45,129,255,0.18) 0%, transparent 70%)",
|
||||
}}
|
||||
/>
|
||||
<div className="dotted-pattern absolute inset-0 z-[0] opacity-30" />
|
||||
|
||||
<main className="relative z-10 flex-1 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md bg-[#1C1C20] border border-white/10 rounded-2xl p-6 py-8 shadow-2xl">
|
||||
@@ -106,7 +99,7 @@ const Login = () => {
|
||||
)}
|
||||
|
||||
<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 or Username
|
||||
</label>
|
||||
<Input
|
||||
@@ -119,7 +112,7 @@ const Login = () => {
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center ml-1">
|
||||
<label className="text-xs font-semibold text-white/70">
|
||||
<label className="text-xs font-bold text-white/60 uppercase tracking-wide">
|
||||
Password
|
||||
</label>
|
||||
<Link
|
||||
|
||||
@@ -1,28 +1,16 @@
|
||||
import { NewsIndex as LibNewsIndex } from "@storyteller/markdown-content";
|
||||
import Seo from "../../components/seo";
|
||||
import Footer from "../../components/footer";
|
||||
import { PagePatternBackdrop } from "../../components/truchet-pattern";
|
||||
|
||||
const NewsIndex = ({ basePath }: { basePath: string }) => {
|
||||
return (
|
||||
<div className="relative min-h-screen bg-[#101014] overflow-hidden">
|
||||
<div className="bg-[#101014] bg-dots">
|
||||
<Seo
|
||||
title="News & Updates - ArtCraft"
|
||||
description="Latest updates, features, and announcements from the ArtCraft team."
|
||||
/>
|
||||
<PagePatternBackdrop variant="content" />
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-x-0 top-0 h-[700px] z-0"
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(ellipse 60% 50% at 50% 0%, rgba(45,129,255,0.18) 0%, transparent 70%)",
|
||||
}}
|
||||
/>
|
||||
<div className="relative z-10">
|
||||
<LibNewsIndex basePath={basePath} />
|
||||
<Footer />
|
||||
</div>
|
||||
<LibNewsIndex basePath={basePath} />
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
} from "@storyteller/markdown-content";
|
||||
import Seo from "../../components/seo";
|
||||
import Footer from "../../components/footer";
|
||||
import { PagePatternBackdrop } from "../../components/truchet-pattern";
|
||||
import { useParams } from "react-router-dom";
|
||||
|
||||
const NewsPost = ({ basePath }: { basePath: string }) => {
|
||||
@@ -17,21 +16,10 @@ const NewsPost = ({ basePath }: { basePath: string }) => {
|
||||
const desc = post ? post.description : "";
|
||||
|
||||
return (
|
||||
<div className="relative min-h-screen bg-[#101014] overflow-hidden">
|
||||
<div className="bg-[#101014] bg-dots">
|
||||
<Seo title={title} description={desc} />
|
||||
<PagePatternBackdrop variant="content" />
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-x-0 top-0 h-[700px] z-0"
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(ellipse 60% 50% at 50% 0%, rgba(45,129,255,0.18) 0%, transparent 70%)",
|
||||
}}
|
||||
/>
|
||||
<div className="relative z-10">
|
||||
<LibNewsPost basePath={basePath} />
|
||||
<Footer />
|
||||
</div>
|
||||
<LibNewsPost basePath={basePath} />
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import Footer from "../../components/footer";
|
||||
import Seo from "../../components/seo";
|
||||
import { PagePatternBackdrop } from "../../components/truchet-pattern";
|
||||
import { PRESS_KIT_CATEGORIES, type PressKitAsset } from "./press-kit-data";
|
||||
|
||||
// ============================================================================
|
||||
@@ -210,22 +209,16 @@ export default function PressKitPage() {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative min-h-screen bg-[#101014] text-white overflow-hidden">
|
||||
<div className="relative min-h-screen bg-[#101014] text-white overflow-x-hidden bg-dots">
|
||||
<Seo
|
||||
title="Press Kit | ArtCraft"
|
||||
description="Download ArtCraft press assets including logos, promotional videos, screenshots, and branding materials for media coverage."
|
||||
/>
|
||||
|
||||
<PagePatternBackdrop variant="content" />
|
||||
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-x-0 top-0 h-[700px] z-0"
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(ellipse 60% 50% at 50% 0%, rgba(45,129,255,0.18) 0%, transparent 70%)",
|
||||
}}
|
||||
/>
|
||||
{/* Background glow */}
|
||||
<div className="absolute top-0 inset-x-0 flex justify-center pointer-events-none z-0">
|
||||
<div className="w-[900px] h-[900px] -mt-[200px] rounded-full bg-gradient-to-br from-blue-700 via-blue-500 to-[#00AABA] opacity-20 blur-[120px]" />
|
||||
</div>
|
||||
|
||||
{/* Hero Section */}
|
||||
<main className="relative pt-24 sm:pt-32 pb-12 sm:pb-16 px-4 sm:px-6 lg:px-8">
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { isMobile } from "react-device-detect";
|
||||
import Lenis from "lenis";
|
||||
import { faCoins } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
@@ -10,32 +8,32 @@ import Footer from "../../components/footer";
|
||||
import Seo from "../../components/seo";
|
||||
import { PricingTable } from "../../components/pricing-table";
|
||||
import { CreditsModal } from "../../components/credits-modal";
|
||||
import { TruchetPattern } from "../../components/truchet-pattern";
|
||||
|
||||
const SeedanceBanner = () => (
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="inline-flex items-center text-[11px] font-semibold uppercase tracking-[0.18em] text-primary-200 bg-primary/[0.12] border border-primary/25 rounded-full px-3 py-1">
|
||||
Early access
|
||||
<span className="bg-primary/20 border border-primary/40 text-primary px-3 py-1 rounded-full text-xs font-semibold tracking-wide uppercase">
|
||||
Early Access
|
||||
</span>
|
||||
<span className="inline-flex items-center text-[11px] font-semibold uppercase tracking-[0.18em] text-white/65 bg-white/[0.04] border border-white/[0.08] rounded-full px-3 py-1">
|
||||
Available today in ArtCraft
|
||||
<span className="bg-white/10 border border-white/15 text-white/70 px-3 py-1 rounded-full text-xs font-medium">
|
||||
Available Today in ArtCraft
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h1 className="text-3xl md:text-4xl lg:text-5xl tracking-[-0.035em] font-semibold leading-[1.05] mb-3">
|
||||
Seedance 2.0 is{" "}
|
||||
<span className="font-serif-italic">here</span>
|
||||
<h1 className="text-2xl md:text-3xl lg:text-4xl font-bold leading-tight mb-2">
|
||||
Seedance 2.0 is here -{" "}
|
||||
<span className="text-primary">now in ArtCraft</span>
|
||||
</h1>
|
||||
<p className="text-white/55 text-base md:text-lg leading-relaxed">
|
||||
<p className="text-white/65 text-sm md:text-base leading-relaxed">
|
||||
Generate jaw-dropping AI videos with Seedance 2.0 before it's available
|
||||
anywhere else. Every paid plan includes video credits, so you can start
|
||||
creating right now.
|
||||
anywhere else. Every paid plan includes video credits - start creating
|
||||
right now.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="relative w-full rounded-2xl sm:rounded-[24px] overflow-hidden bg-[#080808] border border-white/[0.08]">
|
||||
{/* Video embed */}
|
||||
<div className="relative w-full rounded-2xl overflow-hidden shadow-[0_0_40px_rgba(0,170,186,0.15)] border border-white/10">
|
||||
<div style={{ paddingTop: "56.25%" }} className="relative">
|
||||
<iframe
|
||||
src="https://player.vimeo.com/video/1169289718?autoplay=1&muted=1&loop=1&background=0&byline=0&portrait=0&title=0"
|
||||
@@ -47,20 +45,21 @@ const SeedanceBanner = () => (
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="rounded-xl bg-gradient-to-br from-primary/15 via-white/[0.03] to-white/[0.02] border border-primary/25 p-4">
|
||||
<div className="text-[12px] font-semibold uppercase tracking-wider text-primary mb-1">
|
||||
Seedance video credits
|
||||
{/* Feature callouts */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="bg-primary/5 border border-primary/20 rounded-xl p-3">
|
||||
<div className="text-xs font-semibold text-primary mb-0.5">
|
||||
Seedance Video Credits
|
||||
</div>
|
||||
<div className="text-white/55 text-sm leading-snug">
|
||||
<div className="text-white/55 text-xs leading-snug">
|
||||
Included with every paid ArtCraft plan
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-[#080808] border border-white/[0.08] p-4">
|
||||
<div className="text-[12px] font-semibold uppercase tracking-wider text-white/70 mb-1">
|
||||
First in the world
|
||||
<div className="bg-white/5 border border-white/10 rounded-xl p-3">
|
||||
<div className="text-xs font-semibold text-white mb-0.5">
|
||||
First in the World
|
||||
</div>
|
||||
<div className="text-white/55 text-sm leading-snug">
|
||||
<div className="text-white/55 text-xs leading-snug">
|
||||
Seedance launches in ArtCraft ahead of anywhere else
|
||||
</div>
|
||||
</div>
|
||||
@@ -89,141 +88,67 @@ const Pricing = () => {
|
||||
check();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isMobile) return;
|
||||
const lenis = new Lenis({
|
||||
duration: 1.2,
|
||||
easing: (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t)),
|
||||
smoothWheel: true,
|
||||
lerp: 0.1,
|
||||
});
|
||||
let rafId: number;
|
||||
const raf = (time: number) => {
|
||||
lenis.raf(time);
|
||||
rafId = requestAnimationFrame(raf);
|
||||
};
|
||||
rafId = requestAnimationFrame(raf);
|
||||
return () => {
|
||||
cancelAnimationFrame(rafId);
|
||||
lenis.destroy();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative min-h-screen bg-[#101014] text-white overflow-hidden">
|
||||
<div className="relative min-h-screen bg-[#101014] text-white bg-dots">
|
||||
<Seo
|
||||
title="Pricing - ArtCraft"
|
||||
description="Simple, transparent pricing for ArtCraft. Start for free and scale as you grow."
|
||||
/>
|
||||
|
||||
{/* Subtle radial accent at top, matches landing3 */}
|
||||
<div
|
||||
className="absolute inset-x-0 top-0 h-[700px] pointer-events-none z-0"
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(ellipse 60% 50% at 50% 0%, rgba(45,129,255,0.18) 0%, transparent 70%)",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Truchet pattern flourish, top of page */}
|
||||
<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="pricing"
|
||||
intensity={0.5}
|
||||
className="absolute inset-0 w-full h-full"
|
||||
/>
|
||||
<div className="dotted-pattern absolute inset-0 z-[0] opacity-50 pointer-events-none" />
|
||||
<div className="absolute top-0 inset-x-0 flex justify-center pointer-events-none z-0">
|
||||
<div className="w-[900px] h-[900px] -mt-[200px] rounded-full bg-gradient-to-br from-blue-700 via-blue-500 to-[#00AABA] opacity-20 blur-[120px]" />
|
||||
</div>
|
||||
|
||||
{/* Truchet pattern flourish, lower section */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-x-0 bottom-0 h-[800px] 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="pricing"
|
||||
intensity={0.5}
|
||||
className="absolute inset-0 w-full h-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<main className="relative z-10 px-4 sm:px-8 pt-28 sm:pt-32 pb-16">
|
||||
{isSeedanceRef ? (
|
||||
<div className="max-w-6xl mx-auto grid grid-cols-1 lg:grid-cols-[5fr_7fr] gap-8 xl:gap-12 items-start">
|
||||
{isSeedanceRef ? (
|
||||
<main className="relative z-10 pt-32 pb-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-[1700px] mx-auto grid grid-cols-1 lg:grid-cols-[5fr_7fr] gap-8 xl:gap-12 items-start">
|
||||
<SeedanceBanner />
|
||||
<div className="w-full">
|
||||
<PricingTable
|
||||
showHeader={false}
|
||||
unifiedTheme
|
||||
title="Get Early Access"
|
||||
subtitle="Every plan includes Seedance video credits. Invest in a tool you'll always own."
|
||||
compact
|
||||
showSeedanceFeatures
|
||||
showEnterprise
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="text-center mb-14" data-reveal>
|
||||
<span className="inline-block text-xs font-semibold uppercase tracking-[0.18em] text-primary mb-5">
|
||||
Plans
|
||||
</span>
|
||||
<h1 className="text-4xl sm:text-5xl md:text-6xl tracking-[-0.035em] font-semibold leading-[1.02] mb-5">
|
||||
Invest in <span className="font-serif-italic">yourself</span>.
|
||||
</h1>
|
||||
<p className="max-w-xl mx-auto text-base sm:text-lg text-white/55 leading-relaxed">
|
||||
Get a ton of generations and invest in a tool you'll always
|
||||
own. Your subscription helps keep ArtCraft free and open for
|
||||
everyone.
|
||||
</p>
|
||||
</div>
|
||||
<PricingTable
|
||||
showHeader={false}
|
||||
unifiedTheme
|
||||
showSeedanceFeatures
|
||||
showEnterprise
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</main>
|
||||
) : (
|
||||
<main className="relative z-10 pt-28 sm:pt-32 pb-16 px-4 sm:px-6 lg:px-8">
|
||||
<PricingTable
|
||||
title="Invest in Yourself"
|
||||
subtitle="You'll get a ton of generations and you'll be investing in a tool that you'll always own."
|
||||
showSeedanceFeatures
|
||||
showEnterprise
|
||||
/>
|
||||
</main>
|
||||
)}
|
||||
|
||||
{isLoggedIn && (
|
||||
<div className="relative z-10 flex flex-col items-center px-4 pb-12 sm:px-8">
|
||||
<div className="relative z-10 flex flex-col items-center px-4 pb-12 sm:px-6 lg:px-8">
|
||||
<div className="inline-flex items-center gap-2 text-white/40">
|
||||
<div className="h-px w-8 bg-white/20" />
|
||||
<span className="text-sm">Or</span>
|
||||
<div className="h-px w-8 bg-white/20" />
|
||||
</div>
|
||||
<p className="mt-3 text-base text-white/65">
|
||||
<p className="mt-3 text-lg text-white/70">
|
||||
Purchase one-time credit packs
|
||||
</p>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="mt-4 gap-2 rounded-full border border-white/[0.1] bg-white/[0.06] hover:bg-white/[0.1] px-5 py-2 h-11 text-[14px] font-semibold text-white"
|
||||
className="mt-4 gap-2 rounded-xl border border-white/15 bg-white/5 px-6 py-2.5 font-semibold text-white backdrop-blur-sm hover:bg-white/10"
|
||||
onClick={() => setCreditsModalOpen(true)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faCoins} className="text-primary text-[13px]" />
|
||||
<FontAwesomeIcon icon={faCoins} className="text-primary" />
|
||||
Buy Credits
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footnote */}
|
||||
<div className="relative z-10 max-w-3xl mx-auto px-4 sm:px-8 pb-16 text-center">
|
||||
<p className="text-sm text-white/45 leading-relaxed">
|
||||
{/* Footnotes */}
|
||||
<div className="relative z-10 max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 pb-12 text-center">
|
||||
<p className="text-md text-white/50 leading-relaxed">
|
||||
† ArtCraft can be used without paying for a subscription. You
|
||||
can bring your own compute and third party subscriptions. We hope
|
||||
you'll subscribe, though, as that helps accelerate our development.
|
||||
|
||||
@@ -6,19 +6,12 @@ const Signup = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className="relative min-h-screen bg-[#101014] text-white overflow-hidden flex flex-col">
|
||||
<div className="relative min-h-screen bg-[#101014] text-white bg-dots flex flex-col">
|
||||
<Seo
|
||||
title="Sign Up - ArtCraft"
|
||||
description="Create your ArtCraft account."
|
||||
/>
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-x-0 top-0 h-[700px] z-0"
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(ellipse 60% 50% at 50% 0%, rgba(45,129,255,0.18) 0%, transparent 70%)",
|
||||
}}
|
||||
/>
|
||||
<div className="dotted-pattern absolute inset-0 z-[0] opacity-30" />
|
||||
|
||||
<main className="relative z-10 flex-1 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md bg-[#1C1C20] border border-white/10 rounded-2xl p-6 py-8 shadow-2xl">
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useMemo } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import Seo from "../../components/seo";
|
||||
import Footer from "../../components/footer";
|
||||
import { PagePatternBackdrop } from "../../components/truchet-pattern";
|
||||
import { getTutorialItemBySlug, markdownToHtml } from "@storyteller/markdown-content";
|
||||
import { faChevronLeft } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
@@ -51,19 +50,12 @@ const TutorialsArticle = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative min-h-screen bg-[#101014] text-white overflow-hidden">
|
||||
<div className="relative min-h-screen bg-[#101014] text-white overflow-x-hidden bg-dots">
|
||||
<Seo title={title} description={description} jsonLd={jsonLd} />
|
||||
|
||||
<PagePatternBackdrop variant="content" />
|
||||
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-x-0 top-0 h-[700px] z-0"
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(ellipse 60% 50% at 50% 0%, rgba(45,129,255,0.18) 0%, transparent 70%)",
|
||||
}}
|
||||
/>
|
||||
<div className="absolute top-0 inset-x-0 flex justify-center pointer-events-none z-0">
|
||||
<div className="w-[900px] h-[900px] -mt-[200px] rounded-full bg-gradient-to-br from-blue-700 via-blue-500 to-[#00AABA] opacity-20 blur-[120px]"></div>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 mx-auto w-full max-w-4xl px-4 sm:px-6 pt-24 sm:pt-32 pb-32">
|
||||
<div className="mb-6">
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import Seo from "../../components/seo";
|
||||
import Footer from "../../components/footer";
|
||||
import { PagePatternBackdrop } from "../../components/truchet-pattern";
|
||||
import { Link } from "react-router-dom";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
@@ -40,17 +39,11 @@ export const TutorialsPage = () => {
|
||||
const pageDescription = "Learn tips, tricks, and workflows for ArtCraft.";
|
||||
|
||||
return (
|
||||
<div className="relative min-h-screen bg-[#101014] text-white overflow-hidden">
|
||||
<div className="relative min-h-screen bg-[#101014] text-white overflow-x-hidden bg-dots">
|
||||
<Seo title={pageTitle} description={pageDescription} />
|
||||
<PagePatternBackdrop variant="content" />
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-x-0 top-0 h-[700px] z-0"
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(ellipse 60% 50% at 50% 0%, rgba(45,129,255,0.18) 0%, transparent 70%)",
|
||||
}}
|
||||
/>
|
||||
<div className="absolute top-0 inset-x-0 flex justify-center pointer-events-none z-0">
|
||||
<div className="w-[900px] h-[900px] -mt-[200px] rounded-full bg-gradient-to-br from-blue-700 via-blue-500 to-[#00AABA] opacity-20 blur-[120px]"></div>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 mx-auto w-full max-w-[1200px] px-4 sm:px-8 pt-28 sm:pt-36 pb-12 min-h-screen">
|
||||
{/* Hero */}
|
||||
|
||||
@@ -11,7 +11,6 @@ import { isMobile, isMacOs } from "react-device-detect";
|
||||
import { DOWNLOAD_LINKS } from "../../config/github_download_links";
|
||||
import Seo from "../../components/seo";
|
||||
import { PricingTable } from "../../components/pricing-table";
|
||||
import { PagePatternBackdrop } from "../../components/truchet-pattern";
|
||||
|
||||
const Welcome = () => {
|
||||
const downloadUrl = isMacOs ? DOWNLOAD_LINKS.MACOS : DOWNLOAD_LINKS.WINDOWS;
|
||||
@@ -23,8 +22,6 @@ const Welcome = () => {
|
||||
description="Welcome to ArtCraft. Get started with your subscription."
|
||||
/>
|
||||
|
||||
<PagePatternBackdrop variant="landing" />
|
||||
|
||||
{/* Background gradient */}
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-0">
|
||||
<div className="w-[900px] h-[900px] rounded-full bg-gradient-to-br from-primary/40 via-purple-600/30 to-blue-500/20 opacity-30 blur-[120px]"></div>
|
||||
@@ -109,7 +106,7 @@ const Welcome = () => {
|
||||
<Button
|
||||
as="link"
|
||||
href={downloadUrl}
|
||||
className="rounded-full bg-white text-black hover:bg-gray-100 dark:bg-white dark:text-black dark:hover:bg-gray-200 px-6 py-2.5 text-sm font-bold rounded-xl"
|
||||
className="bg-white text-black hover:bg-gray-100 dark:bg-white dark:text-black dark:hover:bg-gray-200 px-6 py-2.5 text-sm font-bold rounded-xl"
|
||||
>
|
||||
<FontAwesomeIcon icon={faDownload} className="mr-2" />
|
||||
Download for {isMacOs ? "Mac" : "Windows"}
|
||||
|
||||
@@ -31,28 +31,19 @@
|
||||
}
|
||||
|
||||
@layer base {
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-family:
|
||||
"Figtree",
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
Roboto,
|
||||
"Helvetica Neue",
|
||||
Arial,
|
||||
sans-serif;
|
||||
letter-spacing: -0.02em;
|
||||
font-family: "Outfit", sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family:
|
||||
"Instrument Sans",
|
||||
"Source Sans 3",
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
@@ -62,34 +53,7 @@
|
||||
Arial,
|
||||
sans-serif;
|
||||
color: #ffffff;
|
||||
background-color: #121212;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.font-serif-italic {
|
||||
font-family: "Instrument Serif", Georgia, "Times New Roman", serif;
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
/* Buttons and prompt boxes use Figtree (display font), not Instrument Sans. */
|
||||
button,
|
||||
.prompt-box-root,
|
||||
.prompt-box-root input,
|
||||
.prompt-box-root textarea,
|
||||
.prompt-box-root button {
|
||||
font-family:
|
||||
"Figtree",
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
Roboto,
|
||||
"Helvetica Neue",
|
||||
Arial,
|
||||
sans-serif;
|
||||
background-color: #242424;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,14 +71,6 @@
|
||||
@apply bg-ui-controls/85 !important;
|
||||
}
|
||||
|
||||
.liquid-glass {
|
||||
background: #232323c0;
|
||||
border: 1px solid #ffffff14;
|
||||
backdrop-filter: blur(18px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(12px) saturate(180%);
|
||||
box-shadow: 0 8px 32px -8px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
/* Minimal thin scrollbar for prompt textareas */
|
||||
.promptbox-scrollbar {
|
||||
scrollbar-width: thin;
|
||||
@@ -138,6 +94,7 @@
|
||||
background-color: rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
|
||||
@keyframes fade-in-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
@@ -192,12 +149,10 @@
|
||||
}
|
||||
|
||||
.animate-shimmer {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
rgba(255, 255, 255, 0.08) 50%,
|
||||
transparent 100%
|
||||
);
|
||||
background: linear-gradient(90deg,
|
||||
transparent 0%,
|
||||
rgba(255, 255, 255, 0.08) 50%,
|
||||
transparent 100%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s ease-in-out infinite;
|
||||
}
|
||||
@@ -234,17 +189,15 @@
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -3px;
|
||||
background: conic-gradient(
|
||||
from var(--angle),
|
||||
transparent 0%,
|
||||
transparent 80%,
|
||||
rgba(147, 197, 253, 0.4) 85%,
|
||||
rgba(191, 219, 254, 0.9) 90%,
|
||||
rgba(255, 255, 255, 1) 92.5%,
|
||||
rgba(191, 219, 254, 0.9) 95%,
|
||||
rgba(147, 197, 253, 0.4) 100%,
|
||||
transparent 100%
|
||||
);
|
||||
background: conic-gradient(from var(--angle),
|
||||
transparent 0%,
|
||||
transparent 80%,
|
||||
rgba(147, 197, 253, 0.4) 85%,
|
||||
rgba(191, 219, 254, 0.9) 90%,
|
||||
rgba(255, 255, 255, 1) 92.5%,
|
||||
rgba(191, 219, 254, 0.9) 95%,
|
||||
rgba(147, 197, 253, 0.4) 100%,
|
||||
transparent 100%);
|
||||
border-radius: inherit;
|
||||
animation: rotate 2.2s linear infinite;
|
||||
z-index: -1;
|
||||
@@ -257,4 +210,4 @@
|
||||
background: inherit;
|
||||
border-radius: inherit;
|
||||
z-index: -1;
|
||||
}
|
||||
}
|
||||
@@ -16,21 +16,7 @@ module.exports = {
|
||||
"../../libs/components/**/*.{js,jsx,ts,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
display: [
|
||||
"Figtree",
|
||||
"system-ui",
|
||||
"-apple-system",
|
||||
"BlinkMacSystemFont",
|
||||
"Segoe UI",
|
||||
"Roboto",
|
||||
"Helvetica Neue",
|
||||
"Arial",
|
||||
"sans-serif",
|
||||
],
|
||||
},
|
||||
},
|
||||
extend: {},
|
||||
colors: {
|
||||
transparent: "transparent",
|
||||
current: "currentColor",
|
||||
|
||||
@@ -43,9 +43,6 @@
|
||||
{
|
||||
"path": "../../libs/components/gallery-modal/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/switch/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/tab-selector/tsconfig.lib.json"
|
||||
},
|
||||
@@ -82,6 +79,9 @@
|
||||
{
|
||||
"path": "../../libs/markdown-content/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/toaster/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/modal/tsconfig.lib.json"
|
||||
},
|
||||
|
||||
@@ -8,9 +8,6 @@
|
||||
{
|
||||
"path": "../../libs/components/gallery-modal"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/switch"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/tab-selector"
|
||||
},
|
||||
@@ -47,6 +44,9 @@
|
||||
{
|
||||
"path": "../../libs/markdown-content"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/toaster"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/modal"
|
||||
},
|
||||
|
||||
@@ -22,30 +22,4 @@ export class PromptsApi extends ApiManager {
|
||||
return { success: false, errorMessage: err.message };
|
||||
});
|
||||
}
|
||||
|
||||
public BatchGetPrompts({
|
||||
tokens,
|
||||
}: {
|
||||
tokens: string[];
|
||||
}): Promise<ApiResponse<Prompts[]>> {
|
||||
if (tokens.length === 0) {
|
||||
return Promise.resolve({ success: true, data: [] });
|
||||
}
|
||||
const query = tokens
|
||||
.map((t) => `tokens=${encodeURIComponent(t)}`)
|
||||
.join("&");
|
||||
const endpoint = `${this.getApiSchemeAndHost()}/v1/prompt/batch?${query}`;
|
||||
|
||||
return this.get<{
|
||||
success: boolean;
|
||||
prompts: Prompts[];
|
||||
}>({ endpoint })
|
||||
.then((response) => ({
|
||||
success: response.success,
|
||||
data: response.prompts,
|
||||
}))
|
||||
.catch((err) => {
|
||||
return { success: false, errorMessage: err.message };
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,6 @@ export interface JobRequest {
|
||||
maybe_model_title: string;
|
||||
maybe_model_token: string;
|
||||
maybe_model_type: string;
|
||||
maybe_prompt_token: string | null;
|
||||
maybe_raw_inference_text: string;
|
||||
maybe_style_name: string;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ interface Request {
|
||||
maybe_model_title?: string;
|
||||
maybe_model_token: string;
|
||||
maybe_model_type: string;
|
||||
maybe_prompt_token?: string | null;
|
||||
maybe_raw_inference_text: string;
|
||||
maybe_style_name: string;
|
||||
}
|
||||
|
||||
@@ -190,32 +190,6 @@ export class PromptsApi extends ApiManager {
|
||||
});
|
||||
}
|
||||
|
||||
public BatchGetPrompts({
|
||||
tokens,
|
||||
}: {
|
||||
tokens: string[];
|
||||
}): Promise<ApiResponse<Prompts[]>> {
|
||||
if (tokens.length === 0) {
|
||||
return Promise.resolve({ success: true, data: [] });
|
||||
}
|
||||
const query = tokens
|
||||
.map((t) => `tokens=${encodeURIComponent(t)}`)
|
||||
.join("&");
|
||||
const endpoint = `${this.getApiSchemeAndHost()}/v1/prompt/batch?${query}`;
|
||||
|
||||
return this.get<{
|
||||
success: boolean;
|
||||
prompts: Prompts[];
|
||||
}>({ endpoint })
|
||||
.then((response) => ({
|
||||
success: response.success,
|
||||
data: response.prompts,
|
||||
}))
|
||||
.catch((err) => {
|
||||
return { success: false, errorMessage: err.message };
|
||||
});
|
||||
}
|
||||
|
||||
public async uploadSceneSnapshot({
|
||||
screenshot,
|
||||
sceneMediaToken,
|
||||
|
||||
@@ -14,27 +14,15 @@ export interface JobMaybeResult {
|
||||
entity_type: string;
|
||||
// maybe_public_bucket_media_path: string;
|
||||
maybe_successfully_completed_at: string;
|
||||
media_links: JobMediaLinks;
|
||||
}
|
||||
|
||||
export interface JobMediaLinks {
|
||||
cdn_url: string;
|
||||
maybe_thumbnail_template?: string | null;
|
||||
maybe_video_previews?: JobMediaLinksVideoPreviews | null;
|
||||
}
|
||||
|
||||
export interface JobMediaLinksVideoPreviews {
|
||||
still: string;
|
||||
animated: string;
|
||||
still_thumbnail_template: string;
|
||||
animated_thumbnail_template: string;
|
||||
media_links: {
|
||||
cdn_url: string;
|
||||
};
|
||||
}
|
||||
export interface JobRequest {
|
||||
inference_category: string;
|
||||
maybe_model_title: string;
|
||||
maybe_model_token: string;
|
||||
maybe_model_type: string;
|
||||
maybe_prompt_token: string | null;
|
||||
maybe_raw_inference_text: string;
|
||||
maybe_style_name: string;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ interface Request {
|
||||
maybe_model_title?: string;
|
||||
maybe_model_token: string;
|
||||
maybe_model_type: string;
|
||||
maybe_prompt_token?: string | null;
|
||||
maybe_raw_inference_text: string;
|
||||
maybe_style_name: string;
|
||||
}
|
||||
|
||||
Generated
-32
@@ -24,7 +24,6 @@
|
||||
"@headlessui/react": "^2.2.2",
|
||||
"@preact/signals-react": "^3.0.1",
|
||||
"@radix-ui/react-dialog": "^1.1.14",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.14",
|
||||
"@react-oauth/google": "^0.13.4",
|
||||
"@remix-run/serve": "^2.15.0",
|
||||
"@sparkjsdev/spark": "^0.1.10",
|
||||
@@ -34,7 +33,6 @@
|
||||
"framer-motion": "^12.11.0",
|
||||
"gsap": "^3.14.2",
|
||||
"konva": "^9.3.20",
|
||||
"lenis": "^1.3.23",
|
||||
"lodash": "^4.17.21",
|
||||
"lucide-react": "^0.512.0",
|
||||
"pnpm": "^10.27.0",
|
||||
@@ -22444,36 +22442,6 @@
|
||||
"node": ">=0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/lenis": {
|
||||
"version": "1.3.23",
|
||||
"resolved": "https://registry.npmjs.org/lenis/-/lenis-1.3.23.tgz",
|
||||
"integrity": "sha512-YxYq3TJqj9sJNv0V9SkyQHejt14xwyIwgDaaMK89Uf9SxQfIszu+gTQSSphh6BWlLTNVKvvXAGkg+Zf+oFIevg==",
|
||||
"workspaces": [
|
||||
"packages/*",
|
||||
"playground",
|
||||
"playground/*"
|
||||
],
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/darkroomengineering"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@nuxt/kit": ">=3.0.0",
|
||||
"react": ">=17.0.0",
|
||||
"vue": ">=3.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@nuxt/kit": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"vue": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/leven": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
"@headlessui/react": "^2.2.2",
|
||||
"@preact/signals-react": "^3.0.1",
|
||||
"@radix-ui/react-dialog": "^1.1.14",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.14",
|
||||
"@react-oauth/google": "^0.13.4",
|
||||
"@remix-run/serve": "^2.15.0",
|
||||
"@sparkjsdev/spark": "^0.1.10",
|
||||
@@ -26,7 +25,6 @@
|
||||
"framer-motion": "^12.11.0",
|
||||
"gsap": "^3.14.2",
|
||||
"konva": "^9.3.20",
|
||||
"lenis": "^1.3.23",
|
||||
"lodash": "^4.17.21",
|
||||
"lucide-react": "^0.512.0",
|
||||
"pnpm": "^10.27.0",
|
||||
|
||||
Reference in New Issue
Block a user