Compare commits

..
Author SHA1 Message Date
Tanish Manku b8db60365b artcraft: surface credits-fetch state on top bar coin + 60s poll
Customers occasionally saw "0 Credits" with no signal that the fetch
had failed silently. Add a status badge on the coin (slow/failed/
recovered, hidden in the happy path), a 60s background refresh, and
gate the initial fetch on AUTH_STATUS.LOGGED_IN to remove the 1s
setTimeout band-aid that was racing intermediate auth states.
2026-04-30 16:22:50 -07:00
96 changed files with 839 additions and 4845 deletions
@@ -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
View File
@@ -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>,
@@ -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,
}
}
@@ -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;
@@ -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")?,
@@ -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>,
@@ -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")?,
@@ -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,
@@ -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,
@@ -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,
@@ -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,
@@ -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,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(),
@@ -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,
})
}
@@ -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,
}))
}
@@ -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,
}))
}
@@ -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;
@@ -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()
+1 -1
View File
@@ -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>
@@ -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>
@@ -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>
);
@@ -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}
@@ -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">
&dagger; 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"}
+19 -66
View File
@@ -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"
},
+3 -3
View File
@@ -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;
}
-26
View File
@@ -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,
+3 -15
View File
@@ -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;
}
-32
View File
@@ -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",
-2
View File
@@ -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",