Compare commits

..
65 changed files with 1081 additions and 3050 deletions
-3
View File
@@ -35,9 +35,6 @@ script/socat_dev_database_connect.sh
# Python virtual envs (named by convention)
venv/
# Testing
/requests/
# Scratch data
scratch/
test_data/temp/
Generated
-23
View File
@@ -10334,28 +10334,6 @@ dependencies = [
"libc",
]
[[package]]
name = "seedance2pro"
version = "0.0.1"
dependencies = [
"chrono",
"cloudflare_errors",
"env",
"env_logger 0.11.8",
"errors",
"log",
"rand 0.9.2",
"serde",
"serde_derive",
"serde_json",
"testing",
"tokio",
"url",
"workspace-build-acceleration",
"wreq",
"wreq-util",
]
[[package]]
name = "selectors"
version = "0.24.0"
@@ -11452,7 +11430,6 @@ dependencies = [
"resend-rs",
"reusable_types",
"rust-s3",
"seedance2pro",
"serde",
"serde_derive",
"serde_json",
-2
View File
@@ -16,7 +16,6 @@ members = [
"crates/api_clients/midjourney_client",
"crates/api_clients/openai_sora_client",
"crates/api_clients/openart_client",
"crates/api_clients/seedance2pro",
"crates/api_clients/runwayml_client",
"crates/api_clients/thumbnail_generator",
"crates/api_clients/world_labs_client",
@@ -233,7 +232,6 @@ memory_store = { path = "crates/lib/caching/memory_store" }
midjourney_client = { path = "crates/api_clients/midjourney_client" }
mimetypes = { path = "crates/lib/files/mimetypes" }
openai_sora_client = { path = "crates/api_clients/openai_sora_client" }
seedance2pro = { path = "crates/api_clients/seedance2pro" }
primitives = { path = "crates/lib/data/primitives" }
shared_service_components = { path = "crates/lib/shared_service_components" }
sqlite_tasks = { path = "crates/schema/database/sqlite_tasks" }
@@ -3,7 +3,7 @@
-- noinspection SqlResolveForFile
ALTER TABLE media_files
ADD COLUMN origin_product_category VARCHAR(16) NOT NULL DEFAULT 'unknown'
ADD COLUMN origin_product_category VARCHAR(16) NOT NULL DEFAULT "unknown"
AFTER origin_category;
ALTER TABLE media_files
@@ -3,7 +3,7 @@
-- noinspection SqlResolveForFile
ALTER TABLE media_files
ADD COLUMN media_class VARCHAR(16) NOT NULL DEFAULT 'unknown'
ADD COLUMN media_class VARCHAR(16) NOT NULL DEFAULT "unknown"
AFTER media_type;
ALTER TABLE media_files
@@ -3,7 +3,7 @@
-- noinspection SqlResolveForFile
ALTER TABLE media_files
ADD COLUMN nsfw_status VARCHAR(32) NOT NULL DEFAULT 'unknown'
ADD COLUMN nsfw_status VARCHAR(32) NOT NULL DEFAULT "unknown"
AFTER maybe_scene_source_media_file_token;
ALTER TABLE media_files
@@ -1,6 +1,5 @@
pub mod kling_2_5_turbo_multi_function_video_gen;
pub mod kling_2_6_multi_function_video_gen;
pub mod seedance_2p0_multi_function_video_gen;
pub mod sora_2_multi_function_video_gen;
pub mod sora_2_pro_multi_function_video_gen;
pub mod veo_3p1_fast_multi_function_video_gen;
@@ -1,68 +0,0 @@
use serde::{Deserialize, Serialize};
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use tokens::tokens::media_files::MediaFileToken;
use utoipa::ToSchema;
pub const SEEDANCE_2P0_MULTI_FUNCTION_VIDEO_GEN_PATH: &str = "/v1/generate/video/multi_function/seedance_2p0";
/// Seedance 2.0 multi-function video generation (text-to-video, image-to-video, and reference-based).
#[derive(Serialize, Deserialize, ToSchema)]
pub struct Seedance2p0MultiFunctionVideoGenRequest {
/// Idempotency token to prevent duplicate requests.
pub uuid_idempotency_token: String,
/// Text prompt.
pub prompt: Option<String>,
/// Optional start frame image.
pub start_frame_media_token: Option<MediaFileToken>,
/// Optional end frame image.
pub end_frame_media_token: Option<MediaFileToken>,
/// Optional reference images (reference mode). When present, takes priority over start/end frames.
pub reference_image_media_tokens: Option<Vec<MediaFileToken>>,
/// Video aspect ratio / resolution.
pub aspect_ratio: Option<Seedance2p0AspectRatio>,
/// Duration in seconds (4–15).
pub duration_seconds: Option<u8>,
/// Number of videos to generate.
pub batch_count: Option<Seedance2p0BatchCount>,
}
#[derive(Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum Seedance2p0AspectRatio {
/// 16:9 landscape (1280x720)
Landscape16x9,
/// 9:16 portrait (720x1280)
Portrait9x16,
/// 1:1 square (720x720)
Square1x1,
/// 4:3 standard (960x720)
Standard4x3,
/// 3:4 portrait (720x960)
Portrait3x4,
}
#[derive(Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum Seedance2p0BatchCount {
One,
Two,
Four,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct Seedance2p0MultiFunctionVideoGenResponse {
pub success: bool,
/// The first inference job token (always present).
pub inference_job_token: InferenceJobToken,
/// All inference job tokens for the batch (includes `inference_job_token` as the first entry).
pub all_inference_job_tokens: Vec<InferenceJobToken>,
}
@@ -1,38 +0,0 @@
[package]
name = "seedance2pro"
edition = "2024"
version = "0.0.1"
publish = false
[package.metadata.cargo-udeps.ignore]
normal = ["workspace-build-acceleration"]
[lib]
name = "seedance2pro"
path = "src/lib.rs"
[dependencies]
# Workspace hack (faster compile times)
workspace-build-acceleration.workspace = true
# Internal
errors.workspace = true
# External
chrono.workspace = true
cloudflare_errors.workspace = true
log.workspace = true
rand.workspace = true
serde.workspace = true
serde_derive.workspace = true
serde_json.workspace = true
wreq.workspace = true
wreq-util.workspace = true
url.workspace = true
[dev-dependencies]
chrono.workspace = true
env.workspace = true
env_logger.workspace = true
testing = { path = "../../lib/testing" }
tokio = { version = "1", features = ["full"] }
@@ -1,2 +0,0 @@
pub mod seedance2pro_cookies;
pub mod seedance2pro_session;
@@ -1,24 +0,0 @@
/// Cookies are the credential required to interact with the Seedance2 Pro API.
#[derive(Clone)]
pub struct Seedance2ProCookies {
cookies: String,
}
impl Seedance2ProCookies {
pub fn new(cookies: String) -> Self {
Seedance2ProCookies { cookies }
}
pub fn as_str(&self) -> &str {
&self.cookies
}
pub fn as_bytes(&self) -> &[u8] {
self.cookies.as_bytes()
}
pub fn to_string(&self) -> String {
self.cookies.clone()
}
}
@@ -1,19 +0,0 @@
use crate::creds::seedance2pro_cookies::Seedance2ProCookies;
/// Holds the full session data needed to make authenticated requests to Seedance2 Pro.
#[derive(Clone)]
pub struct Seedance2ProSession {
pub cookies: Seedance2ProCookies,
}
impl Seedance2ProSession {
pub fn new(cookies: Seedance2ProCookies) -> Self {
Seedance2ProSession { cookies }
}
pub fn from_cookies_string(cookies: String) -> Self {
Seedance2ProSession {
cookies: Seedance2ProCookies::new(cookies),
}
}
}
@@ -1,4 +0,0 @@
pub mod seedance2pro_client_error;
pub mod seedance2pro_error;
pub mod seedance2pro_generic_api_error;
pub mod seedance2pro_specific_api_error;
@@ -1,26 +0,0 @@
use std::error::Error;
use std::fmt::{Display, Formatter};
#[derive(Debug)]
pub enum Seedance2ProClientError {
/// No cookies are present in the session.
NoCookiesPresent,
/// Error parsing a URL.
UrlParseError(url::ParseError),
/// An error was encountered in building the Wreq client.
WreqClientError(wreq::Error),
}
impl Error for Seedance2ProClientError {}
impl Display for Seedance2ProClientError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::NoCookiesPresent => write!(f, "No cookies present in the session."),
Self::UrlParseError(err) => write!(f, "URL parse error: {}", err),
Self::WreqClientError(err) => write!(f, "Wreq client error (during client creation): {}", err),
}
}
}
@@ -1,54 +0,0 @@
use crate::error::seedance2pro_client_error::Seedance2ProClientError;
use crate::error::seedance2pro_generic_api_error::Seedance2ProGenericApiError;
use crate::error::seedance2pro_specific_api_error::Seedance2ProSpecificApiError;
use cloudflare_errors::cloudflare_error::CloudflareError;
use std::error::Error;
#[derive(Debug)]
pub enum Seedance2ProError {
Client(Seedance2ProClientError),
ApiSpecific(Seedance2ProSpecificApiError),
ApiGeneric(Seedance2ProGenericApiError),
}
impl Seedance2ProError {
pub fn is_having_downtime_issues(&self) -> bool {
match self {
Self::ApiGeneric(Seedance2ProGenericApiError::Http502ErrorBadGateway(_)) => true,
Self::ApiGeneric(Seedance2ProGenericApiError::CloudflareError(CloudflareError::BadGateway502)) => true,
Self::ApiGeneric(Seedance2ProGenericApiError::CloudflareError(CloudflareError::GatewayTimeout504)) => true,
Self::ApiGeneric(Seedance2ProGenericApiError::CloudflareError(CloudflareError::TimeoutOccurred524)) => true,
_ => false,
}
}
}
impl Error for Seedance2ProError {}
impl std::fmt::Display for Seedance2ProError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Client(e) => write!(f, "Seedance2ProClientError: {:?}", e),
Self::ApiSpecific(e) => write!(f, "Seedance2ProSpecificApiError: {:?}", e),
Self::ApiGeneric(e) => write!(f, "Seedance2ProGenericApiError: {:?}", e),
}
}
}
impl From<Seedance2ProClientError> for Seedance2ProError {
fn from(error: Seedance2ProClientError) -> Self {
Self::Client(error)
}
}
impl From<Seedance2ProSpecificApiError> for Seedance2ProError {
fn from(error: Seedance2ProSpecificApiError) -> Self {
Self::ApiSpecific(error)
}
}
impl From<Seedance2ProGenericApiError> for Seedance2ProError {
fn from(error: Seedance2ProGenericApiError) -> Self {
Self::ApiGeneric(error)
}
}
@@ -1,50 +0,0 @@
use cloudflare_errors::cloudflare_error::CloudflareError;
use std::error::Error;
use std::fmt::{Display, Formatter};
use wreq::StatusCode;
#[derive(Debug)]
pub enum Seedance2ProGenericApiError {
/// Specific Cloudflare errors.
CloudflareError(CloudflareError),
/// HTTP 502 Bad Gateway.
/// We preserve the message for debugging.
Http502ErrorBadGateway(String),
/// serde_json::Error, likely from JSON deserialization schema mismatch.
/// Includes the original body.
SerdeResponseParseErrorWithBody(serde_json::Error, String),
/// serde_json::Error on a non-200 (error) response.
/// Includes the original body.
SerdeParseErrorWithBodyOnNon200(serde_json::Error, String),
/// An uncategorized bad HTTP response.
UncategorizedBadResponse(String),
/// An uncategorized bad HTTP response with status code and body.
UncategorizedBadResponseWithStatusAndBody {
status_code: StatusCode,
body: String,
},
/// An uncaught error from the HTTP client.
WreqError(wreq::Error),
}
impl Error for Seedance2ProGenericApiError {}
impl Display for Seedance2ProGenericApiError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::CloudflareError(err) => write!(f, "Cloudflare error: {}", err),
Self::Http502ErrorBadGateway(msg) => write!(f, "HTTP 502 Bad Gateway: {}", msg),
Self::SerdeResponseParseErrorWithBody(err, body) => write!(f, "Failed to parse response body: {:?}. Body: {}", err, body),
Self::SerdeParseErrorWithBodyOnNon200(err, body) => write!(f, "Failed to parse non-200 response body: {:?}. Body: {}", err, body),
Self::UncategorizedBadResponse(msg) => write!(f, "Uncategorized bad response: {}", msg),
Self::UncategorizedBadResponseWithStatusAndBody { status_code, body } => write!(f, "Uncategorized bad response: status code {}, body: {}", status_code, body),
Self::WreqError(err) => write!(f, "Wreq client error: {}", err),
}
}
}
@@ -1,22 +0,0 @@
use std::error::Error;
use std::fmt::{Display, Formatter};
#[derive(Debug)]
pub enum Seedance2ProSpecificApiError {
/// The session cookies are expired or invalid.
UnauthorizedSessionExpired,
/// The video generation request was flagged as a content violation.
VideoGenerationViolation(String),
}
impl Error for Seedance2ProSpecificApiError {}
impl Display for Seedance2ProSpecificApiError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::UnauthorizedSessionExpired => write!(f, "Unauthorized: session cookies expired or invalid."),
Self::VideoGenerationViolation(body) => write!(f, "Video generation violation: {}", body),
}
}
}
@@ -1,8 +0,0 @@
#[cfg(test)]
pub (crate) mod test_utils;
// User API
pub mod creds;
pub mod error;
pub mod requests;
pub mod utils;
@@ -1,386 +0,0 @@
use crate::creds::seedance2pro_session::Seedance2ProSession;
use crate::error::seedance2pro_client_error::Seedance2ProClientError;
use crate::error::seedance2pro_error::Seedance2ProError;
use crate::error::seedance2pro_generic_api_error::Seedance2ProGenericApiError;
use crate::error::seedance2pro_specific_api_error::Seedance2ProSpecificApiError;
use crate::requests::generate_video::request_types::*;
use crate::utils::common_headers::FIREFOX_USER_AGENT;
use log::info;
use wreq::Client;
use wreq_util::Emulation;
const RUN_TASK_URL: &str = "https://seedance2-pro.com/api/trpc/workflow.runTask?batch=1";
// --- Request args ---
pub struct GenerateVideoArgs<'a> {
pub session: &'a Seedance2ProSession,
pub prompt: String,
pub resolution: Resolution,
/// Duration in seconds (4–15).
pub duration_seconds: u8,
pub batch_count: BatchCount,
/// Optional start frame image URL (keyframe mode).
pub start_frame_url: Option<String>,
/// Optional end frame image URL (keyframe mode).
pub end_frame_url: Option<String>,
/// Optional reference image URLs (reference mode). When present, takes priority over start/end frames.
pub reference_image_urls: Option<Vec<String>>,
}
impl GenerateVideoArgs<'_> {
/// Estimates the credit cost for this generation request.
///
/// Pricing rules:
/// - 40 credits per second of video (4s = 160, 15s = 600)
/// - Resolution has no effect on cost
/// - Input mode (text, keyframe, reference) has no effect on cost
/// - Batch 1 = 1×, Batch 2 = 2×, Batch 4 = 3× (not 4×)
pub fn estimate_credits(&self) -> u32 {
let per_video = u32::from(self.duration_seconds) * 40;
let batch_multiplier = match self.batch_count {
BatchCount::One => 1,
BatchCount::Two => 2,
BatchCount::Four => 3, // NB: 3x, not 4x.
};
per_video * batch_multiplier
}
pub fn estimate_cost_in_usd_cents(&self) -> u64 {
// 25000 Credits costs $99.99 as of 2026-02-20
// 250 for $1.
let credits = self.estimate_credits() as f64;
let cost = credits / 250.0 * 100.0;
cost.round() as u64
}
}
// --- Public enums ---
/// Video resolution / aspect ratio.
#[derive(Debug, Clone, Copy)]
pub enum Resolution {
/// 16:9 landscape (1280x720)
Landscape16x9,
/// 9:16 portrait (720x1280)
Portrait9x16,
/// 1:1 square (720x720)
Square1x1,
/// 4:3 standard (960x720)
Standard4x3,
/// 3:4 portrait (720x960)
Portrait3x4,
}
impl Resolution {
fn as_str(&self) -> &'static str {
match self {
Self::Landscape16x9 => "1280x720",
Self::Portrait9x16 => "720x1280",
Self::Square1x1 => "720x720",
Self::Standard4x3 => "960x720",
Self::Portrait3x4 => "720x960",
}
}
}
/// Number of videos to generate in a single request.
#[derive(Debug, Clone, Copy)]
pub enum BatchCount {
One,
Two,
Four,
}
impl BatchCount {
fn as_u8(&self) -> u8 {
match self {
Self::One => 1,
Self::Two => 2,
Self::Four => 4,
}
}
}
// --- Response ---
pub struct GenerateVideoResponse {
pub task_id: String,
pub order_id: String,
/// Present when batch_count > 1.
pub task_ids: Option<Vec<String>>,
/// Present when batch_count > 1.
pub order_ids: Option<Vec<String>>,
}
// --- Implementation ---
pub async fn generate_video(args: GenerateVideoArgs<'_>) -> Result<GenerateVideoResponse, Seedance2ProError> {
let is_reference_mode = args.reference_image_urls.as_ref().is_some_and(|urls| !urls.is_empty());
let video_input_mode = if is_reference_mode { "reference" } else { "keyframe" };
let uploaded_urls: Option<Vec<String>> = if is_reference_mode {
args.reference_image_urls
} else {
let mut urls = Vec::new();
if let Some(url) = args.start_frame_url {
urls.push(url);
}
if let Some(url) = args.end_frame_url {
urls.push(url);
}
if urls.is_empty() { None } else { Some(urls) }
};
let batch_count_value = args.batch_count.as_u8();
let batch_count = if batch_count_value > 1 { Some(batch_count_value) } else { None };
let duration = format!("{}s", args.duration_seconds);
info!(
"Generating video: mode={}, resolution={}, duration={}, batch={}",
video_input_mode, args.resolution.as_str(), duration, batch_count_value
);
let request_body = BatchRequest {
zero: BatchRequestInner {
json: BatchRequestJson {
business_type: "wan22-video-generation",
api_params: ApiParams {
prompt: args.prompt,
resolution: args.resolution.as_str().to_string(),
mode: "normal",
model: "seedance-20",
duration,
video_input_mode,
uploaded_urls,
batch_count,
},
},
},
};
let cookie = args.session.cookies.as_str();
let client = Client::builder()
.emulation(Emulation::Firefox143)
.build()
.map_err(|err| Seedance2ProClientError::WreqClientError(err))?;
let response = client.post(RUN_TASK_URL)
.header("User-Agent", FIREFOX_USER_AGENT)
.header("Accept", "*/*")
.header("Accept-Language", "en-US,en;q=0.9")
.header("Accept-Encoding", "gzip, deflate, br, zstd")
.header("Referer", "https://seedance2-pro.com/")
.header("Content-Type", "application/json")
.header("x-trpc-source", "client")
.header("Origin", "https://seedance2-pro.com")
.header("Connection", "keep-alive")
.header("Cookie", cookie)
.header("Sec-Fetch-Dest", "empty")
.header("Sec-Fetch-Mode", "cors")
.header("Sec-Fetch-Site", "same-origin")
.header("Priority", "u=4")
.header("TE", "trailers")
.json(&request_body)
.send()
.await
.map_err(|err| Seedance2ProGenericApiError::WreqError(err))?;
let status = response.status();
let response_body = response.text()
.await
.map_err(|err| Seedance2ProGenericApiError::WreqError(err))?;
info!("Response status: {}, body: {}", status, response_body);
if !status.is_success() {
return Err(Seedance2ProGenericApiError::UncategorizedBadResponseWithStatusAndBody {
status_code: status,
body: response_body,
}.into());
}
let batch_response: Vec<BatchResponseItem> = serde_json::from_str(&response_body)
.map_err(|err| Seedance2ProGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.clone()))?;
let task_data = batch_response
.into_iter()
.next()
.ok_or_else(|| Seedance2ProGenericApiError::UncategorizedBadResponse(
"Empty batch response array".to_string()
))?
.result
.data
.json;
if task_data.violation_warning {
return Err(Seedance2ProSpecificApiError::VideoGenerationViolation(response_body).into());
}
Ok(GenerateVideoResponse {
task_id: task_data.task_id,
order_id: task_data.order_id,
task_ids: task_data.task_ids,
order_ids: task_data.order_ids,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::creds::seedance2pro_session::Seedance2ProSession;
use crate::test_utils::get_test_cookies::get_test_cookies;
use crate::test_utils::setup_test_logging::setup_test_logging;
use errors::AnyhowResult;
use log::LevelFilter;
fn dummy_session() -> Seedance2ProSession {
Seedance2ProSession::from_cookies_string(String::new())
}
fn args_with(duration_seconds: u8, batch_count: BatchCount) -> GenerateVideoArgs<'static> {
// Safety: the dummy session is leaked so the reference is 'static for test purposes.
let session = Box::leak(Box::new(dummy_session()));
GenerateVideoArgs {
session,
prompt: String::new(),
resolution: Resolution::Square1x1,
duration_seconds,
batch_count,
start_frame_url: None,
end_frame_url: None,
reference_image_urls: None,
}
}
#[test]
fn test_estimate_credits() {
// 40 credits per second, batch 1
assert_eq!(args_with(4, BatchCount::One).estimate_credits(), 160);
assert_eq!(args_with(5, BatchCount::One).estimate_credits(), 200);
assert_eq!(args_with(6, BatchCount::One).estimate_credits(), 240);
assert_eq!(args_with(7, BatchCount::One).estimate_credits(), 280);
assert_eq!(args_with(15, BatchCount::One).estimate_credits(), 600);
// Batch 2 = 2×
assert_eq!(args_with(4, BatchCount::Two).estimate_credits(), 320);
assert_eq!(args_with(5, BatchCount::Two).estimate_credits(), 400);
assert_eq!(args_with(15, BatchCount::Two).estimate_credits(), 1200);
// Batch 4 = 3× (not 4×)
assert_eq!(args_with(4, BatchCount::Four).estimate_credits(), 480);
assert_eq!(args_with(5, BatchCount::Four).estimate_credits(), 600);
assert_eq!(args_with(15, BatchCount::Four).estimate_credits(), 1800);
}
#[test]
fn test_estimate_cost_usd_cents() {
// 40 credits per second, batch 1
assert_eq!(args_with(4, BatchCount::One).estimate_cost_in_usd_cents(), 64);
assert_eq!(args_with(5, BatchCount::One).estimate_cost_in_usd_cents(), 80);
assert_eq!(args_with(6, BatchCount::One).estimate_cost_in_usd_cents(), 96);
assert_eq!(args_with(7, BatchCount::One).estimate_cost_in_usd_cents(), 112);
assert_eq!(args_with(15, BatchCount::One).estimate_cost_in_usd_cents(), 240);
// Batch 2 = 2×
assert_eq!(args_with(4, BatchCount::Two).estimate_cost_in_usd_cents(), 128);
assert_eq!(args_with(5, BatchCount::Two).estimate_cost_in_usd_cents(), 160);
assert_eq!(args_with(15, BatchCount::Two).estimate_cost_in_usd_cents(), 480);
// Batch 4 = 3× (not 4×)
assert_eq!(args_with(4, BatchCount::Four).estimate_cost_in_usd_cents(), 192);
assert_eq!(args_with(5, BatchCount::Four).estimate_cost_in_usd_cents(), 240);
assert_eq!(args_with(15, BatchCount::Four).estimate_cost_in_usd_cents(), 720);
}
fn test_session() -> AnyhowResult<Seedance2ProSession> {
let cookies = get_test_cookies()?;
Ok(Seedance2ProSession::from_cookies_string(cookies))
}
#[tokio::test]
#[ignore] // manually test — requires real cookies
async fn test_generate_text_to_video() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
let args = GenerateVideoArgs {
session: &session,
prompt: "A corgi eating a cake in a fancy kitchen.".to_string(),
resolution: Resolution::Square1x1,
duration_seconds: 5,
batch_count: BatchCount::One,
start_frame_url: None,
end_frame_url: None,
reference_image_urls: None,
};
let result = generate_video(args).await?;
println!("Task ID: {}", result.task_id);
println!("Order ID: {}", result.order_id);
assert!(!result.task_id.is_empty());
assert!(!result.order_id.is_empty());
assert_eq!(1, 2); // NB: Intentional failure to inspect output.
Ok(())
}
#[tokio::test]
#[ignore] // manually test — requires real cookies
async fn test_generate_keyframe_video() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
let args = GenerateVideoArgs {
session: &session,
prompt: "A dog shakes the glasses off its head. The camera pans out as the shiba shakes. The shiba barks.".to_string(),
resolution: Resolution::Landscape16x9,
duration_seconds: 15,
batch_count: BatchCount::One,
start_frame_url: Some("https://static.seedance2-pro.com/materials/20260219/1771496300184-fb32e08c.jpg".to_string()),
end_frame_url: None,
reference_image_urls: None,
};
let result = generate_video(args).await?;
println!("Task ID: {}", result.task_id);
println!("Order ID: {}", result.order_id);
assert!(!result.task_id.is_empty());
assert_eq!(1, 2); // NB: Intentional failure to inspect output.
Ok(())
}
#[tokio::test]
#[ignore] // manually test — requires real cookies
async fn test_generate_reference_video() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
let args = GenerateVideoArgs {
session: &session,
prompt: "The dog in @2 is in the office at @1 without the man. The office is dark and moonlight streams in through the windows. Particles of dust gleam in the moon beams. Suddenly, the dog jumps on the desk and barks.".to_string(),
resolution: Resolution::Standard4x3,
duration_seconds: 10,
batch_count: BatchCount::One,
start_frame_url: None,
end_frame_url: None,
reference_image_urls: Some(vec![
"https://static.seedance2-pro.com/materials/20260219/1771463564512-b14bfe90.png".to_string(),
"https://static.seedance2-pro.com/materials/20260219/1771496300184-fb32e08c.jpg".to_string(),
]),
};
let result = generate_video(args).await?;
println!("Task ID: {}", result.task_id);
println!("Order ID: {}", result.order_id);
assert!(!result.task_id.is_empty());
assert_eq!(1, 2); // NB: Intentional failure to inspect output.
Ok(())
}
}
@@ -1,2 +0,0 @@
pub mod generate_video;
mod request_types;
@@ -1,60 +0,0 @@
use serde_derive::{Deserialize, Serialize};
#[derive(Serialize)]
pub (super) struct BatchRequest {
#[serde(rename = "0")]
pub zero: BatchRequestInner,
}
#[derive(Serialize)]
pub (super) struct BatchRequestInner {
pub json: BatchRequestJson,
}
#[derive(Serialize)]
pub (super) struct BatchRequestJson {
#[serde(rename = "businessType")]
pub business_type: &'static str,
#[serde(rename = "apiParams")]
pub api_params: ApiParams,
}
#[derive(Serialize)]
pub (super) struct ApiParams {
pub prompt: String,
pub resolution: String,
pub mode: &'static str,
pub model: &'static str,
pub duration: String,
#[serde(rename = "videoInputMode")]
pub video_input_mode: &'static str,
#[serde(rename = "uploadedUrls", skip_serializing_if = "Option::is_none")]
pub uploaded_urls: Option<Vec<String>>,
#[serde(rename = "batchCount", skip_serializing_if = "Option::is_none")]
pub batch_count: Option<u8>,
}
#[derive(Deserialize, Debug)]
pub (super) struct BatchResponseItem {
pub result: BatchResponseResult,
}
#[derive(Deserialize, Debug)]
pub (super) struct BatchResponseResult {
pub data: BatchResponseData,
}
#[derive(Deserialize, Debug)]
pub (super) struct BatchResponseData {
pub json: TaskResponseJson,
}
#[derive(Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub (super) struct TaskResponseJson {
pub task_id: String,
pub order_id: String,
pub task_ids: Option<Vec<String>>,
pub order_ids: Option<Vec<String>>,
pub violation_warning: bool,
}
@@ -1,4 +0,0 @@
pub mod generate_video;
pub mod poll_orders;
pub mod prepare_image_upload;
pub mod upload_image;
@@ -1,2 +0,0 @@
pub mod poll_orders;
mod request_types;
@@ -1,270 +0,0 @@
use crate::creds::seedance2pro_session::Seedance2ProSession;
use crate::error::seedance2pro_client_error::Seedance2ProClientError;
use crate::error::seedance2pro_error::Seedance2ProError;
use crate::error::seedance2pro_generic_api_error::Seedance2ProGenericApiError;
use crate::requests::poll_orders::request_types::*;
use crate::utils::common_headers::FIREFOX_USER_AGENT;
use log::info;
use wreq::Client;
use wreq_util::Emulation;
const GET_ORDERS_BASE_URL: &str = "https://seedance2-pro.com/api/trpc/userOrder.getOrders";
/// Builds the tRPC `input` JSON for the getOrders endpoint.
/// When `cursor` is `Some`, it is included in the JSON payload.
fn build_input_json(cursor: Option<u64>) -> String {
match cursor {
None => r#"{"0":{"json":{"limit":30,"format":null,"direction":"forward"},"meta":{"values":{"format":["undefined"]},"v":1}}}"#.to_string(),
Some(c) => format!(
r#"{{"0":{{"json":{{"limit":30,"format":null,"cursor":{cursor},"direction":"forward"}},"meta":{{"values":{{"format":["undefined"]}},"v":1}}}}}}"#,
cursor = c
),
}
}
// --- Args & response ---
pub struct PollOrdersArgs<'a> {
pub session: &'a Seedance2ProSession,
/// Optional cursor from a previous `PollOrdersResponse::next_cursor`.
/// When `None`, the most recent orders are returned.
pub cursor: Option<u64>,
}
pub struct PollOrdersResponse {
pub orders: Vec<OrderStatus>,
/// Present when there are more orders to fetch.
/// Pass this value as `PollOrdersArgs::cursor` in the next call.
pub next_cursor: Option<u64>,
}
// --- Public types ---
/// The lifecycle status of a video generation task.
#[derive(Debug, Clone, PartialEq)]
pub enum TaskStatus {
/// The task is queued and has not started yet.
Pending,
/// The task is actively being processed.
Processing,
/// The task finished successfully. `result_url` and `results` will be populated.
Completed,
/// The task failed. `fail_reason` will contain the reason.
Failed,
/// An unrecognised status string was returned by the server.
Unknown(String),
}
impl TaskStatus {
fn from_str(s: &str) -> Self {
match s {
"PENDING" => Self::Pending,
"PROCESSING" => Self::Processing,
"COMPLETED" => Self::Completed,
"FAILED" => Self::Failed,
other => Self::Unknown(other.to_string()),
}
}
pub fn is_terminal(&self) -> bool {
matches!(self, Self::Completed | Self::Failed)
}
}
/// A single generated video result attached to an order.
#[derive(Debug, Clone)]
pub struct VideoResult {
pub url: String,
pub width: u32,
pub height: u32,
/// Width / height ratio (e.g. 1.777… for 16:9).
pub ratio: f64,
}
/// The status of one order (one video generation task).
#[derive(Debug, Clone)]
pub struct OrderStatus {
pub order_id: String,
pub task_status: TaskStatus,
/// Top-level result video URL. Populated when `task_status` is `Completed`.
pub result_url: Option<String>,
/// Detailed result entries. Typically one entry per video.
pub results: Vec<VideoResult>,
/// Failure reason. Populated when `task_status` is `Failed`.
pub fail_reason: Option<String>,
/// ISO 8601 creation timestamp (e.g. `"2026-02-19T01:20:50.398Z"`).
pub created_at: String,
}
// --- Implementation ---
pub async fn poll_orders(args: PollOrdersArgs<'_>) -> Result<PollOrdersResponse, Seedance2ProError> {
info!("Polling orders (cursor: {:?})...", args.cursor);
let input_json = build_input_json(args.cursor);
let client = Client::builder()
.emulation(Emulation::Firefox143)
.build()
.map_err(|err| Seedance2ProClientError::WreqClientError(err))?;
let cookie = args.session.cookies.as_str();
let response = client.get(GET_ORDERS_BASE_URL)
.query(&[("batch", "1"), ("input", input_json.as_str())])
.header("User-Agent", FIREFOX_USER_AGENT)
.header("Accept", "*/*")
.header("Accept-Language", "en-US,en;q=0.9")
.header("Accept-Encoding", "gzip, deflate, br, zstd")
.header("Referer", "https://seedance2-pro.com/app/gallery")
.header("content-type", "application/json")
.header("x-trpc-source", "client")
.header("Connection", "keep-alive")
.header("Cookie", cookie)
.header("Sec-Fetch-Dest", "empty")
.header("Sec-Fetch-Mode", "cors")
.header("Sec-Fetch-Site", "same-origin")
.header("Priority", "u=4")
.header("TE", "trailers")
.send()
.await
.map_err(|err| Seedance2ProGenericApiError::WreqError(err))?;
let status = response.status();
let response_body = response.text()
.await
.map_err(|err| Seedance2ProGenericApiError::WreqError(err))?;
info!("Poll orders response status: {}", status);
if !status.is_success() {
return Err(Seedance2ProGenericApiError::UncategorizedBadResponseWithStatusAndBody {
status_code: status,
body: response_body,
}.into());
}
let batch_response: Vec<BatchResponseItem> = serde_json::from_str(&response_body)
.map_err(|err| Seedance2ProGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.clone()))?;
let json = batch_response
.into_iter()
.next()
.ok_or_else(|| Seedance2ProGenericApiError::UncategorizedBadResponse(
"Empty batch response array".to_string()
))?
.result
.data
.json;
let next_cursor = json.next_cursor;
let orders: Vec<OrderStatus> = json.orders
.into_iter()
.map(|o| OrderStatus {
order_id: o.order_id,
task_status: TaskStatus::from_str(&o.task_status),
result_url: o.result_url,
results: o.results.into_iter().map(|r| VideoResult {
url: r.url,
width: r.width,
height: r.height,
ratio: r.ratio,
}).collect(),
fail_reason: o.fail_reason,
created_at: o.created_at,
})
.collect();
info!("Polled {} orders, next_cursor: {:?}", orders.len(), next_cursor);
Ok(PollOrdersResponse { orders, next_cursor })
}
#[cfg(test)]
mod tests {
use super::*;
use crate::creds::seedance2pro_session::Seedance2ProSession;
use crate::test_utils::get_test_cookies::get_test_cookies;
use crate::test_utils::setup_test_logging::setup_test_logging;
use errors::AnyhowResult;
use log::LevelFilter;
fn test_session() -> AnyhowResult<Seedance2ProSession> {
let cookies = get_test_cookies()?;
Ok(Seedance2ProSession::from_cookies_string(cookies))
}
#[tokio::test]
#[ignore] // manually test — requires real cookies
async fn test_poll_all_orders() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
let result = poll_orders(PollOrdersArgs { session: &session, cursor: None }).await?;
println!("Orders returned: {}", result.orders.len());
println!("Next cursor: {:?}", result.next_cursor);
for order in &result.orders {
println!(" {} | {:?} | result_url={:?} | fail={:?}",
order.order_id, order.task_status, order.result_url, order.fail_reason);
}
assert_eq!(1, 2); // NB: Intentional failure to inspect output.
Ok(())
}
#[tokio::test]
#[ignore] // manually test — requires real cookies and a known cursor value
async fn test_poll_with_cursor() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
// Use the cursor value returned from a prior call (e.g. 394062 from the example responses).
let cursor: u64 = 394062;
let result = poll_orders(PollOrdersArgs { session: &session, cursor: Some(cursor) }).await?;
println!("Orders returned (with cursor {}): {}", cursor, result.orders.len());
println!("Next cursor: {:?}", result.next_cursor);
for order in &result.orders {
println!(" {} | {:?} | result_url={:?} | fail={:?}",
order.order_id, order.task_status, order.result_url, order.fail_reason);
}
assert_eq!(1, 2); // NB: Intentional failure to inspect output.
Ok(())
}
#[tokio::test]
#[ignore] // manually test — requires real cookies; exhausts all pages
async fn test_poll_all_pages() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
let mut cursor: Option<u64> = None;
let mut page = 0usize;
let mut total_orders = 0usize;
loop {
page += 1;
let result = poll_orders(PollOrdersArgs { session: &session, cursor }).await?;
let page_count = result.orders.len();
total_orders += page_count;
println!("Page {}: {} orders, next_cursor: {:?}", page, page_count, result.next_cursor);
for order in &result.orders {
println!(" {} | {:?}", order.order_id, order.task_status);
}
cursor = result.next_cursor;
if cursor.is_none() {
break;
}
}
println!("Total orders across {} pages: {}", page, total_orders);
assert_eq!(1, 2); // NB: Intentional failure to inspect output.
Ok(())
}
}
@@ -1,42 +0,0 @@
use serde_derive::Deserialize;
#[derive(Deserialize, Debug)]
pub(super) struct BatchResponseItem {
pub result: BatchResponseResult,
}
#[derive(Deserialize, Debug)]
pub(super) struct BatchResponseResult {
pub data: BatchResponseData,
}
#[derive(Deserialize, Debug)]
pub(super) struct BatchResponseData {
pub json: OrdersResponseJson,
}
#[derive(Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub(super) struct OrdersResponseJson {
pub orders: Vec<RawOrder>,
pub next_cursor: Option<u64>,
}
#[derive(Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub(super) struct RawOrder {
pub order_id: String,
pub result_url: Option<String>,
pub task_status: String,
pub results: Vec<RawVideoResult>,
pub fail_reason: Option<String>,
pub created_at: String,
}
#[derive(Deserialize, Debug)]
pub(super) struct RawVideoResult {
pub url: String,
pub width: u32,
pub height: u32,
pub ratio: f64,
}
@@ -1,2 +0,0 @@
pub mod prepare_image_upload;
mod request_types;
@@ -1,140 +0,0 @@
use crate::creds::seedance2pro_session::Seedance2ProSession;
use crate::error::seedance2pro_client_error::Seedance2ProClientError;
use crate::error::seedance2pro_error::Seedance2ProError;
use crate::error::seedance2pro_generic_api_error::Seedance2ProGenericApiError;
use crate::requests::prepare_image_upload::request_types::*;
use crate::utils::common_headers::FIREFOX_USER_AGENT;
use chrono::Utc;
use log::info;
use rand::Rng;
use wreq::Client;
use wreq_util::Emulation;
const SIGNED_UPLOAD_URL: &str = "https://seedance2-pro.com/api/trpc/uploads.signedUploadUrl?batch=1";
/// Generates a material path based on the current time.
/// Format: `materials/YYYYMMDD/<unix_millis>-<random_hex>.png`
fn generate_material_path() -> String {
let now = Utc::now();
let date_part = now.format("%Y%m%d").to_string();
let timestamp_millis = now.timestamp_millis();
let random_hex: u32 = rand::rng().random();
let hex_part = format!("{:08x}", random_hex);
format!("materials/{}/{}-{}.png", date_part, timestamp_millis, hex_part)
}
pub struct PrepareImageUploadArgs<'a> {
pub session: &'a Seedance2ProSession,
}
pub struct PrepareImageUploadResponse {
/// The signed URL to upload the image to (Cloudflare R2 / S3-compatible).
pub upload_url: String,
/// The material path that was generated for this upload.
pub material_path: String,
}
pub async fn prepare_image_upload(args: PrepareImageUploadArgs<'_>) -> Result<PrepareImageUploadResponse, Seedance2ProError> {
let material_path = generate_material_path();
info!("Preparing image upload with path: {}", material_path);
let client = Client::builder()
.emulation(Emulation::Firefox143)
.build()
.map_err(|err| Seedance2ProClientError::WreqClientError(err))?;
let request_body = BatchRequest {
zero: BatchRequestInner {
json: BatchRequestJson {
path: material_path.clone(),
},
},
};
let cookie = args.session.cookies.as_str();
let response = client.post(SIGNED_UPLOAD_URL)
.header("User-Agent", FIREFOX_USER_AGENT)
.header("Accept", "*/*")
.header("Accept-Language", "en-US,en;q=0.9")
.header("Accept-Encoding", "gzip, deflate, br, zstd")
.header("Referer", "https://seedance2-pro.com/")
.header("Content-Type", "application/json")
.header("x-trpc-source", "client")
.header("Origin", "https://seedance2-pro.com")
.header("Connection", "keep-alive")
.header("Cookie", cookie)
.header("Sec-Fetch-Dest", "empty")
.header("Sec-Fetch-Mode", "cors")
.header("Sec-Fetch-Site", "same-origin")
.header("Priority", "u=4")
.header("TE", "trailers")
.json(&request_body)
.send()
.await
.map_err(|err| Seedance2ProGenericApiError::WreqError(err))?;
let status = response.status();
let response_body = response.text()
.await
.map_err(|err| Seedance2ProGenericApiError::WreqError(err))?;
info!("Response status: {}, body: {}", status, response_body);
if !status.is_success() {
return Err(Seedance2ProGenericApiError::UncategorizedBadResponseWithStatusAndBody {
status_code: status,
body: response_body,
}.into());
}
let batch_response: Vec<BatchResponseItem> = serde_json::from_str(&response_body)
.map_err(|err| Seedance2ProGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.clone()))?;
let upload_url = batch_response
.into_iter()
.next()
.ok_or_else(|| Seedance2ProGenericApiError::UncategorizedBadResponse(
"Empty batch response array".to_string()
))?
.result
.data
.json;
Ok(PrepareImageUploadResponse {
upload_url,
material_path,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::creds::seedance2pro_session::Seedance2ProSession;
use crate::test_utils::get_test_cookies::get_test_cookies;
use crate::test_utils::setup_test_logging::setup_test_logging;
use errors::AnyhowResult;
use log::LevelFilter;
#[tokio::test]
#[ignore] // manually test — requires real cookies
async fn test_prepare_image_upload() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let cookies = get_test_cookies()?;
let session = Seedance2ProSession::from_cookies_string(cookies);
let args = PrepareImageUploadArgs {
session: &session,
};
let result = prepare_image_upload(args).await?;
println!("Upload URL: {}", result.upload_url);
println!("Material path: {}", result.material_path);
assert!(!result.upload_url.is_empty());
assert!(result.upload_url.contains("cloudflarestorage.com"));
assert!(result.material_path.starts_with("materials/"));
assert_eq!(1, 2); // NB: Intentional failure to check the response.
Ok(())
}
}
@@ -1,32 +0,0 @@
use serde_derive::{Deserialize, Serialize};
#[derive(Serialize)]
pub(super) struct BatchRequest {
#[serde(rename = "0")]
pub zero: BatchRequestInner,
}
#[derive(Serialize)]
pub(super) struct BatchRequestInner {
pub json: BatchRequestJson,
}
#[derive(Serialize)]
pub(super) struct BatchRequestJson {
pub path: String,
}
#[derive(Deserialize, Debug)]
pub(super) struct BatchResponseItem {
pub result: BatchResponseResult,
}
#[derive(Deserialize, Debug)]
pub(super) struct BatchResponseResult {
pub data: BatchResponseData,
}
#[derive(Deserialize, Debug)]
pub(super) struct BatchResponseData {
pub json: String,
}
@@ -1 +0,0 @@
pub mod upload_image;
@@ -1,127 +0,0 @@
use crate::error::seedance2pro_client_error::Seedance2ProClientError;
use crate::error::seedance2pro_error::Seedance2ProError;
use crate::error::seedance2pro_generic_api_error::Seedance2ProGenericApiError;
use crate::utils::common_headers::FIREFOX_USER_AGENT;
use log::info;
use url::Url;
use wreq::Client;
use wreq_util::Emulation;
const STATIC_BASE_URL: &str = "https://static.seedance2-pro.com";
pub struct UploadImageArgs {
/// The signed upload URL returned by `prepare_image_upload`.
pub upload_url: String,
/// The raw image bytes to upload.
pub image_bytes: Vec<u8>,
}
pub struct UploadImageResponse {
/// The public-facing URL for the uploaded image.
/// e.g. `https://static.seedance2-pro.com/materials/20260219/1771463564512-b14bfe90.png`
pub public_url: String,
}
/// Extracts the path from the R2 upload URL and builds the static public URL.
/// e.g. `https://comm.….r2.cloudflarestorage.com/materials/20260219/…?X-Amz-…`
/// -> `https://static.seedance2-pro.com/materials/20260219/…`
fn build_public_url(upload_url: &str) -> Result<String, Seedance2ProError> {
let parsed = Url::parse(upload_url)
.map_err(|err| Seedance2ProClientError::UrlParseError(err))?;
let path = parsed.path(); // e.g. "/materials/20260219/1771463564512-b14bfe90.png"
Ok(format!("{}{}", STATIC_BASE_URL, path))
}
pub async fn upload_image(args: UploadImageArgs) -> Result<UploadImageResponse, Seedance2ProError> {
info!("Uploading image to: {}", args.upload_url);
let client = Client::builder()
.emulation(Emulation::Firefox143)
.build()
.map_err(|err| Seedance2ProClientError::WreqClientError(err))?;
let response = client.put(&args.upload_url)
.header("User-Agent", FIREFOX_USER_AGENT)
.header("Accept", "*/*")
.header("Accept-Language", "en-US,en;q=0.9")
.header("Accept-Encoding", "gzip, deflate, br, zstd")
.header("Referer", "https://seedance2-pro.com/")
.header("Origin", "https://seedance2-pro.com")
.header("Connection", "keep-alive")
.header("Sec-Fetch-Dest", "empty")
.header("Sec-Fetch-Mode", "cors")
.header("Sec-Fetch-Site", "cross-site")
.header("Priority", "u=4")
.body(args.image_bytes)
.send()
.await
.map_err(|err| Seedance2ProGenericApiError::WreqError(err))?;
let status = response.status();
info!("Upload response status: {}", status);
if !status.is_success() {
let body = response.text()
.await
.map_err(|err| Seedance2ProGenericApiError::WreqError(err))?;
return Err(Seedance2ProGenericApiError::UncategorizedBadResponseWithStatusAndBody {
status_code: status,
body,
}.into());
}
let public_url = build_public_url(&args.upload_url)?;
info!("Public URL: {}", public_url);
Ok(UploadImageResponse { public_url })
}
#[cfg(test)]
mod tests {
use super::*;
use crate::creds::seedance2pro_session::Seedance2ProSession;
use crate::test_utils::get_test_cookies::get_test_cookies;
use crate::test_utils::setup_test_logging::setup_test_logging;
use crate::requests::prepare_image_upload::prepare_image_upload::{
prepare_image_upload, PrepareImageUploadArgs,
};
use errors::AnyhowResult;
use log::LevelFilter;
use std::fs;
#[tokio::test]
#[ignore] // manually test — requires real cookies and a test image
async fn test_upload_image() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
// Step 1: Get a signed upload URL
let cookies = get_test_cookies()?;
let session = Seedance2ProSession::from_cookies_string(cookies);
let prepare_args = PrepareImageUploadArgs {
session: &session,
};
let prepare_result = prepare_image_upload(prepare_args).await?;
println!("Upload URL: {}", prepare_result.upload_url);
// Step 2: Read a test image
let image_bytes = fs::read("/Users/bt/dev/storyteller/artcraft/test_data/image/juno.jpg")?;
println!("Image size: {} bytes", image_bytes.len());
// Step 3: Upload
let upload_args = UploadImageArgs {
upload_url: prepare_result.upload_url,
image_bytes,
};
let result = upload_image(upload_args).await?;
println!("Public URL: {}", result.public_url);
assert!(result.public_url.starts_with("https://static.seedance2-pro.com/materials/"));
assert_eq!(1, 2); // NB: Intentional failure to check the response.
Ok(())
}
}
@@ -1,9 +0,0 @@
use errors::AnyhowResult;
use std::fs::read_to_string;
#[cfg(test)]
pub fn get_test_cookies() -> AnyhowResult<String> {
let cookies = read_to_string("/Users/bt/Artcraft/credentials/seedance2pro_cookies.txt")?;
let cookies = cookies.trim().to_string();
Ok(cookies)
}
@@ -1,5 +0,0 @@
#[cfg(test)]
pub (crate) mod get_test_cookies;
#[cfg(test)]
pub (crate) mod setup_test_logging;
@@ -1,52 +0,0 @@
use chrono::Local;
use env_logger::Builder;
use log::{debug, error, info, trace, warn, LevelFilter};
use std::io::Write;
const RUST_LOG: &str = "RUST_LOG";
pub fn setup_test_logging(level: LevelFilter) {
let level_str = get_level_str(level);
println!("Previous log level: {:?}", env::var(RUST_LOG));
let is_unsafe = env::set_var(RUST_LOG, level_str);
if is_unsafe.is_none() {
println!("Unsafely setting Rust Log Level...");
unsafe { std::env::set_var(RUST_LOG, level_str) }
}
println!("Log level: {:?}", env::var(RUST_LOG));
Builder::new()
.is_test(true)
.format(|buf, record| {
writeln!(buf,
"{} [{}] - {}",
Local::now().format("%H:%M:%S%.6f"),
record.level(),
record.args()
)
})
.filter(None, level)
.filter_level(level)
.init();
trace!("Test trace log");
debug!("Test debug log");
info!("Test info log");
warn!("Test warn log");
error!("Test error log");
}
fn get_level_str(level: LevelFilter) -> &'static str {
match level {
LevelFilter::Error => "error",
LevelFilter::Warn => "warn",
LevelFilter::Info => "info",
LevelFilter::Debug => "debug",
LevelFilter::Trace => "trace",
LevelFilter::Off => "off",
}
}
@@ -1,2 +0,0 @@
pub const FIREFOX_USER_AGENT: &str =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:147.0) Gecko/20100101 Firefox/147.0";
@@ -1 +0,0 @@
pub mod common_headers;
@@ -86,6 +86,7 @@ pub async fn insert_generic_inference_job_for_fal_queue_with_apriori_job_token<'
let maybe_external_third_party = InferenceJobExternalThirdParty::Fal;
let maybe_external_third_party_id = "";
const STATUS : JobStatusPlus = JobStatusPlus::Pending;
let query = sqlx::query!(
@@ -1,4 +1,3 @@
pub mod fal;
pub mod job;
pub mod seedance2pro;
pub mod web;
@@ -1,137 +0,0 @@
use anyhow::anyhow;
use log::info;
use sqlx::{Executor, MySql};
use std::marker::PhantomData;
use enums::by_table::generic_inference_jobs::inference_category::InferenceCategory;
use enums::by_table::generic_inference_jobs::inference_job_external_third_party::InferenceJobExternalThirdParty;
use enums::by_table::generic_inference_jobs::inference_job_product_category::InferenceJobProductCategory;
use enums::by_table::generic_inference_jobs::inference_job_type::InferenceJobType;
use enums::common::job_status_plus::JobStatusPlus;
use enums::common::visibility::Visibility;
use tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken;
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use tokens::tokens::prompts::PromptToken;
use tokens::tokens::users::UserToken;
use crate::errors::database_query_error::DatabaseQueryError;
use crate::payloads::generic_inference_args::generic_inference_args::GenericInferenceArgs;
pub struct InsertGenericInferenceForSeedance2ProWithAprioriJobTokenArgs<'e, 'c, E>
where E: 'e + Executor<'c, Database = MySql>
{
pub uuid_idempotency_token: &'e str,
// NOTE: We'll generate this ahead of time so we can save it with billing info!
pub apriori_job_token: &'e InferenceJobToken,
/// The external order_id returned by the seedance2pro generate_video call.
pub maybe_external_third_party_id: &'e str,
pub maybe_inference_args: Option<GenericInferenceArgs>,
pub maybe_prompt_token: Option<&'e PromptToken>,
pub maybe_creator_user_token: Option<&'e UserToken>,
pub maybe_avt_token: Option<&'e AnonymousVisitorTrackingToken>,
pub creator_ip_address: &'e str,
pub creator_set_visibility: Visibility,
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
}
pub async fn insert_generic_inference_job_for_seedance2pro_queue_with_apriori_job_token<'e, 'c : 'e, E>(
args: InsertGenericInferenceForSeedance2ProWithAprioriJobTokenArgs<'e, 'c, E>
) -> Result<InferenceJobToken, DatabaseQueryError>
where E: 'e + Executor<'c, Database = MySql>
{
let serialized_args_payload = serde_json::ser::to_string(&args.maybe_inference_args)
.map_err(|_e| anyhow!("could not encode inference args"))?;
const JOB_TYPE: InferenceJobType = InferenceJobType::Seedance2ProQueue;
const INFERENCE_CATEGORY: InferenceCategory = InferenceCategory::VideoGeneration;
const PRODUCT_CATEGORY: InferenceJobProductCategory = InferenceJobProductCategory::Seedance2ProVideo;
const EXTERNAL_THIRD_PARTY: InferenceJobExternalThirdParty = InferenceJobExternalThirdParty::Seedance2Pro;
const STATUS: JobStatusPlus = JobStatusPlus::Pending;
let query = sqlx::query!(
r#"
INSERT INTO generic_inference_jobs
SET
token = ?,
uuid_idempotency_token = ?,
job_type = ?,
maybe_external_third_party = ?,
maybe_external_third_party_id = ?,
product_category = ?,
inference_category = ?,
maybe_model_type = NULL,
maybe_model_token = NULL,
maybe_input_source_token = NULL,
maybe_input_source_token_type = NULL,
maybe_download_url = NULL,
maybe_cover_image_media_file_token = NULL,
maybe_prompt_token = ?,
maybe_raw_inference_text = NULL,
maybe_inference_args = ?,
maybe_creator_user_token = ?,
maybe_creator_anonymous_visitor_token = ?,
creator_ip_address = ?,
creator_set_visibility = ?,
priority_level = 0,
is_keepalive_required = FALSE,
max_duration_seconds = 0,
is_debug_request = FALSE,
maybe_routing_tag = NULL,
status = ?
"#,
args.apriori_job_token.as_str(),
args.uuid_idempotency_token,
JOB_TYPE.to_str(),
EXTERNAL_THIRD_PARTY.to_str(),
args.maybe_external_third_party_id,
PRODUCT_CATEGORY.to_str(),
INFERENCE_CATEGORY.to_str(),
args.maybe_prompt_token.map(|t| t.to_string()),
serialized_args_payload,
args.maybe_creator_user_token.map(|t| t.to_string()),
args.maybe_avt_token.map(|t| t.to_string()),
args.creator_ip_address,
args.creator_set_visibility.to_str(),
STATUS.to_str(),
);
let query_result = query.execute(args.mysql_executor)
.await;
let record_id = match query_result {
Err(err) => return Err(DatabaseQueryError::from(err)),
Ok(res) => res.last_insert_id(),
};
info!("Insert generic inference job for Seedance 2 Pro queue: {} with record ID {}", args.apriori_job_token, record_id);
Ok(args.apriori_job_token.clone())
}
@@ -1 +0,0 @@
pub mod insert_generic_inference_job_for_seedance2pro_queue_with_apriori_job_token;
@@ -40,7 +40,6 @@ pub enum InferenceCategory {
ImageGeneration,
/// FAL-powered video generation
/// (Also Seedance2-Pro.com)
#[serde(rename = "video_generation")]
VideoGeneration,
@@ -16,10 +16,6 @@ pub enum InferenceJobExternalThirdParty {
#[serde(rename = "fal")]
#[default]
Fal,
/// Seedance 2 Pro jobs
#[serde(rename = "seedance2pro")]
Seedance2Pro,
}
// TODO(bt, 2022-12-21): This desperately needs MySQL integration tests!
@@ -31,14 +27,12 @@ impl InferenceJobExternalThirdParty {
pub fn to_str(&self) -> &'static str {
match self {
Self::Fal => "fal",
Self::Seedance2Pro => "seedance2pro",
}
}
pub fn from_str(value: &str) -> Result<Self, String> {
match value {
"fal" => Ok(Self::Fal),
"seedance2pro" => Ok(Self::Seedance2Pro),
_ => Err(format!("invalid value: {:?}", value)),
}
}
@@ -48,7 +42,6 @@ impl InferenceJobExternalThirdParty {
// NB: BTreeSet::from() isn't const, but not worth using LazyStatic, etc.
BTreeSet::from([
Self::Fal,
Self::Seedance2Pro,
])
}
}
@@ -64,25 +57,22 @@ mod tests {
#[test]
fn test_serialization() {
assert_serialization(InferenceJobExternalThirdParty::Fal, "fal");
assert_serialization(InferenceJobExternalThirdParty::Seedance2Pro, "seedance2pro");
}
#[test]
fn to_str() {
assert_eq!(InferenceJobExternalThirdParty::Fal.to_str(), "fal");
assert_eq!(InferenceJobExternalThirdParty::Seedance2Pro.to_str(), "seedance2pro");
}
#[test]
fn from_str() {
assert_eq!(InferenceJobExternalThirdParty::from_str("fal").unwrap(), InferenceJobExternalThirdParty::Fal);
assert_eq!(InferenceJobExternalThirdParty::from_str("seedance2pro").unwrap(), InferenceJobExternalThirdParty::Seedance2Pro);
}
#[test]
fn all_variants() {
// Static check
const EXPECTED_COUNT : usize = 2;
const EXPECTED_COUNT : usize = 1;
assert_eq!(InferenceJobExternalThirdParty::all_variants().len(), EXPECTED_COUNT);
assert_eq!(InferenceJobExternalThirdParty::iter().len(), EXPECTED_COUNT);
@@ -113,7 +103,7 @@ mod tests {
#[test]
fn serialized_length_ok_for_database() {
const MAX_LENGTH : usize = 16;
const MAX_LENGTH : usize = 32;
for variant in InferenceJobExternalThirdParty::all_variants() {
let serialized = variant.to_str();
assert!(serialized.len() > 0, "variant {:?} is too short", variant);
@@ -30,11 +30,6 @@ pub enum InferenceJobProductCategory {
FalObject,
FalBgRemoval,
// =============== SEEDANCE 2 PRO ===============
#[serde(rename = "seedance2pro_video")]
Seedance2ProVideo,
// =============== TEXT TO SPEECH ===============
/// TTS: GptSoVits
@@ -130,7 +125,6 @@ impl InferenceJobProductCategory {
Self::FalVideo => "fal_video",
Self::FalObject => "fal_object",
Self::FalBgRemoval => "fal_bg_removal",
Self::Seedance2ProVideo => "seedance2pro_video",
Self::TtsGptSoVits => "tts_gpt_so_vits",
Self::TtsStyleTts2 => "tts_style_tts2",
Self::TtsTacotron2 => "tts_tacotron2",
@@ -163,7 +157,6 @@ impl InferenceJobProductCategory {
"fal_video" => Ok(Self::FalVideo),
"fal_object" => Ok(Self::FalObject),
"fal_bg_removal" => Ok(Self::FalBgRemoval),
"seedance2pro_video" => Ok(Self::Seedance2ProVideo),
"tts_gpt_so_vits" => Ok(Self::TtsGptSoVits),
"tts_style_tts2" => Ok(Self::TtsStyleTts2),
"tts_tacotron2" => Ok(Self::TtsTacotron2),
@@ -199,7 +192,6 @@ impl InferenceJobProductCategory {
Self::FalVideo,
Self::FalObject,
Self::FalBgRemoval,
Self::Seedance2ProVideo,
Self::TtsGptSoVits,
Self::TtsStyleTts2,
Self::TtsTacotron2,
@@ -241,7 +233,6 @@ mod tests {
assert_serialization(InferenceJobProductCategory::FalVideo, "fal_video");
assert_serialization(InferenceJobProductCategory::FalObject, "fal_object");
assert_serialization(InferenceJobProductCategory::FalBgRemoval, "fal_bg_removal");
assert_serialization(InferenceJobProductCategory::Seedance2ProVideo, "seedance2pro_video");
assert_serialization(InferenceJobProductCategory::TtsGptSoVits, "tts_gpt_so_vits");
assert_serialization(InferenceJobProductCategory::TtsStyleTts2, "tts_style_tts2");
assert_serialization(InferenceJobProductCategory::TtsTacotron2, "tts_tacotron2");
@@ -273,7 +264,6 @@ mod tests {
assert_eq!(InferenceJobProductCategory::FalVideo.to_str(), "fal_video");
assert_eq!(InferenceJobProductCategory::FalObject.to_str(), "fal_object");
assert_eq!(InferenceJobProductCategory::FalBgRemoval.to_str(), "fal_bg_removal");
assert_eq!(InferenceJobProductCategory::Seedance2ProVideo.to_str(), "seedance2pro_video");
assert_eq!(InferenceJobProductCategory::TtsGptSoVits.to_str(), "tts_gpt_so_vits");
assert_eq!(InferenceJobProductCategory::TtsStyleTts2.to_str(), "tts_style_tts2");
assert_eq!(InferenceJobProductCategory::TtsTacotron2.to_str(), "tts_tacotron2");
@@ -304,7 +294,6 @@ mod tests {
assert_eq!(InferenceJobProductCategory::from_str("fal_video").unwrap(), InferenceJobProductCategory::FalVideo);
assert_eq!(InferenceJobProductCategory::from_str("fal_object").unwrap(), InferenceJobProductCategory::FalObject);
assert_eq!(InferenceJobProductCategory::from_str("fal_bg_removal").unwrap(), InferenceJobProductCategory::FalBgRemoval);
assert_eq!(InferenceJobProductCategory::from_str("seedance2pro_video").unwrap(), InferenceJobProductCategory::Seedance2ProVideo);
assert_eq!(InferenceJobProductCategory::from_str("tts_gpt_so_vits").unwrap(), InferenceJobProductCategory::TtsGptSoVits);
assert_eq!(InferenceJobProductCategory::from_str("tts_style_tts2").unwrap(), InferenceJobProductCategory::TtsStyleTts2);
assert_eq!(InferenceJobProductCategory::from_str("tts_tacotron2").unwrap(), InferenceJobProductCategory::TtsTacotron2);
@@ -331,7 +320,7 @@ mod tests {
#[test]
fn all_variants() {
// Static check
const EXPECTED_COUNT : usize = 28;
const EXPECTED_COUNT : usize = 27;
assert_eq!(InferenceJobProductCategory::all_variants().len(), EXPECTED_COUNT);
assert_eq!(InferenceJobProductCategory::iter().len(), EXPECTED_COUNT);
@@ -18,11 +18,7 @@ pub enum InferenceJobType {
/// Fal jobs do not run on our job runners.
/// Instead, we receive web hook updates from Fal.
FalQueue,
/// Seedance 2 Pro jobs. We poll for results.
#[serde(rename = "seedance2pro_queue")]
Seedance2ProQueue,
/// Storyteller Studio and Video Style Transfer Jobs (which we may want to split).
/// These run in Comfy.
/// TODO(bt,2024-07-15): We may segregate these two job types in the future
@@ -114,7 +110,6 @@ impl InferenceJobType {
pub fn to_str(&self) -> &'static str {
match self {
Self::FalQueue => "fal_queue",
Self::Seedance2ProQueue => "seedance2pro_queue",
Self::VideoRender => "video_render",
Self::LivePortrait => "live_portrait",
Self::FaceFusion => "face_fusion",
@@ -141,7 +136,6 @@ impl InferenceJobType {
pub fn from_str(value: &str) -> Result<Self, String> {
match value {
"fal_queue" => Ok(Self::FalQueue),
"seedance2pro_queue" => Ok(Self::Seedance2ProQueue),
"video_render" => Ok(Self::VideoRender),
"live_portrait" => Ok(Self::LivePortrait),
"face_fusion" => Ok(Self::FaceFusion),
@@ -171,7 +165,6 @@ impl InferenceJobType {
// NB: BTreeSet::from() isn't const, but not worth using LazyStatic, etc.
BTreeSet::from([
Self::FalQueue,
Self::Seedance2ProQueue,
Self::VideoRender,
Self::LivePortrait,
Self::FaceFusion,
@@ -212,7 +205,6 @@ mod tests {
#[test]
fn test_serialization() {
assert_serialization(InferenceJobType::FalQueue, "fal_queue");
assert_serialization(InferenceJobType::Seedance2ProQueue, "seedance2pro_queue");
assert_serialization(InferenceJobType::VideoRender, "video_render");
assert_serialization(InferenceJobType::LivePortrait, "live_portrait");
assert_serialization(InferenceJobType::FaceFusion, "face_fusion");
@@ -238,7 +230,6 @@ mod tests {
#[test]
fn to_str() {
assert_eq!(InferenceJobType::FalQueue.to_str(), "fal_queue");
assert_eq!(InferenceJobType::Seedance2ProQueue.to_str(), "seedance2pro_queue");
assert_eq!(InferenceJobType::VideoRender.to_str(), "video_render");
assert_eq!(InferenceJobType::LivePortrait.to_str(), "live_portrait");
assert_eq!(InferenceJobType::FaceFusion.to_str(), "face_fusion");
@@ -264,7 +255,6 @@ mod tests {
#[test]
fn from_str() {
assert_eq!(InferenceJobType::from_str("fal_queue").unwrap(), InferenceJobType::FalQueue);
assert_eq!(InferenceJobType::from_str("seedance2pro_queue").unwrap(), InferenceJobType::Seedance2ProQueue);
assert_eq!(InferenceJobType::from_str("video_render").unwrap(), InferenceJobType::VideoRender);
assert_eq!(InferenceJobType::from_str("live_portrait").unwrap(), InferenceJobType::LivePortrait);
assert_eq!(InferenceJobType::from_str("face_fusion").unwrap(), InferenceJobType::FaceFusion);
@@ -290,7 +280,7 @@ mod tests {
#[test]
fn all_variants() {
// Static check
const EXPECTED_COUNT : usize = 22;
const EXPECTED_COUNT : usize = 21;
assert_eq!(InferenceJobType::all_variants().len(), EXPECTED_COUNT);
assert_eq!(InferenceJobType::iter().len(), EXPECTED_COUNT);
@@ -216,7 +216,7 @@ mod tests {
#[test]
fn all_variants() {
let mut variants = MediaFileOriginModelType::all_variants();
assert_eq!(variants.len(), 18);
assert_eq!(variants.len(), 15);
assert_eq!(variants.pop_first(), Some(MediaFileOriginModelType::FaceFusion));
assert_eq!(variants.pop_first(), Some(MediaFileOriginModelType::F5TTS));
assert_eq!(variants.pop_first(), Some(MediaFileOriginModelType::LivePortrait));
@@ -93,8 +93,6 @@ pub enum ModelType {
Seedance10Lite,
#[serde(rename = "seedance_1p0_pro")]
Seedance10Pro,
#[serde(rename = "seedance_2p0")]
Seedance2p0,
#[serde(rename = "sora_2")]
Sora2,
#[serde(rename = "sora_2_pro")]
@@ -165,7 +163,6 @@ impl ModelType {
Self::Kling2p6Pro => "kling_2p6_pro",
Self::Seedance10Lite => "seedance_1p0_lite",
Self::Seedance10Pro => "seedance_1p0_pro",
Self::Seedance2p0 => "seedance_2p0",
Self::Sora2 => "sora_2",
Self::Sora2Pro => "sora_2_pro",
Self::Veo2 => "veo_2",
@@ -220,7 +217,6 @@ impl ModelType {
"kling_2p6_pro" => Ok(Self::Kling2p6Pro),
"seedance_1p0_lite" => Ok(Self::Seedance10Lite),
"seedance_1p0_pro" => Ok(Self::Seedance10Pro),
"seedance_2p0" => Ok(Self::Seedance2p0),
"sora_2" => Ok(Self::Sora2),
"sora_2_pro" => Ok(Self::Sora2Pro),
"veo_2" => Ok(Self::Veo2),
@@ -279,7 +275,6 @@ impl ModelType {
Self::Kling2p6Pro,
Self::Seedance10Lite,
Self::Seedance10Pro,
Self::Seedance2p0,
Self::Sora2,
Self::Sora2Pro,
Self::Veo2,
@@ -342,7 +337,6 @@ mod tests {
assert_serialization(ModelType::Kling2p6Pro, "kling_2p6_pro");
assert_serialization(ModelType::Seedance10Lite, "seedance_1p0_lite");
assert_serialization(ModelType::Seedance10Pro, "seedance_1p0_pro");
assert_serialization(ModelType::Seedance2p0, "seedance_2p0");
assert_serialization(ModelType::Sora2, "sora_2");
assert_serialization(ModelType::Sora2Pro, "sora_2_pro");
assert_serialization(ModelType::Veo2, "veo_2");
@@ -395,7 +389,6 @@ mod tests {
assert_eq!(ModelType::Kling2p6Pro.to_str(), "kling_2p6_pro");
assert_eq!(ModelType::Seedance10Lite.to_str(), "seedance_1p0_lite");
assert_eq!(ModelType::Seedance10Pro.to_str(), "seedance_1p0_pro");
assert_eq!(ModelType::Seedance2p0.to_str(), "seedance_2p0");
assert_eq!(ModelType::Sora2.to_str(), "sora_2");
assert_eq!(ModelType::Sora2Pro.to_str(), "sora_2_pro");
assert_eq!(ModelType::Veo2.to_str(), "veo_2");
@@ -448,7 +441,6 @@ mod tests {
assert_eq!(ModelType::from_str("kling_2p6_pro").unwrap(), ModelType::Kling2p6Pro);
assert_eq!(ModelType::from_str("seedance_1p0_lite").unwrap(), ModelType::Seedance10Lite);
assert_eq!(ModelType::from_str("seedance_1p0_pro").unwrap(), ModelType::Seedance10Pro);
assert_eq!(ModelType::from_str("seedance_2p0").unwrap(), ModelType::Seedance2p0);
assert_eq!(ModelType::from_str("sora_2").unwrap(), ModelType::Sora2);
assert_eq!(ModelType::from_str("sora_2_pro").unwrap(), ModelType::Sora2Pro);
assert_eq!(ModelType::from_str("veo_2").unwrap(), ModelType::Veo2);
@@ -466,7 +458,7 @@ mod tests {
#[test]
fn all_variants() {
let mut variants = ModelType::all_variants();
assert_eq!(variants.len(), 45);
assert_eq!(variants.len(), 44);
// Image models
assert_eq!(variants.pop_first(), Some(ModelType::Flux1Dev));
assert_eq!(variants.pop_first(), Some(ModelType::Flux1Schnell));
@@ -503,7 +495,6 @@ mod tests {
assert_eq!(variants.pop_first(), Some(ModelType::Kling2p6Pro));
assert_eq!(variants.pop_first(), Some(ModelType::Seedance10Lite));
assert_eq!(variants.pop_first(), Some(ModelType::Seedance10Pro));
assert_eq!(variants.pop_first(), Some(ModelType::Seedance2p0));
assert_eq!(variants.pop_first(), Some(ModelType::Sora2));
assert_eq!(variants.pop_first(), Some(ModelType::Sora2Pro));
assert_eq!(variants.pop_first(), Some(ModelType::Veo2));
@@ -100,10 +100,6 @@ pub enum PublicInferenceJobType {
/// Sora GPT 4o image gen
#[serde(rename = "image_gen_api")]
ImageGenApi,
/// Seedance 2 Pro video generation jobs. We poll for results.
#[serde(rename = "seedance2pro_queue")]
Seedance2ProQueue,
}
/// NB: Legacy API for older code.
@@ -134,7 +130,6 @@ impl PublicInferenceJobType {
InferenceJobType::BevyToWorkflow => Self::BevyToWorkflow,
InferenceJobType::RerenderAVideo => Self::RerenderAVideo,
InferenceJobType::ImageGenApi => Self::ImageGenApi,
InferenceJobType::Seedance2ProQueue => Self::Seedance2ProQueue,
}
}
@@ -158,7 +153,6 @@ impl PublicInferenceJobType {
Self::SoVitsSvc => InferenceJobType::SoVitsSvc,
Self::StableDiffusion => InferenceJobType::StableDiffusion,
Self::ImageGenApi => InferenceJobType::ImageGenApi,
Self::Seedance2ProQueue => InferenceJobType::Seedance2ProQueue,
Self::StyleTTS2 => InferenceJobType::StyleTTS2,
Self::Tacotron2 => InferenceJobType::Tacotron2,
Self::Unknown => InferenceJobType::Unknown,
@@ -54,7 +54,6 @@ enums_public.workspace = true
errors.workspace = true
fal_client.workspace = true
filesys.workspace = true
seedance2pro.workspace = true
google_sign_in = { path = "../../../api_clients/google_sign_in" }
hashing = { path = "../../../lib/files/hashing" }
http_server_common = { path = "../../../lib/deprecated/http_server_common" }
@@ -1,6 +1,5 @@
pub mod kling_2p5_turbo_pro_multi_function_video_gen_handler;
pub mod kling_2p6_pro_multi_function_video_gen_handler;
pub mod seedance_2p0_multi_function_video_gen_handler;
pub mod sora_2_multi_function_video_gen_handler;
pub mod sora_2_pro_multi_function_video_gen_handler;
pub mod veo_3p1_fast_multi_function_video_gen_handler;
@@ -1,390 +0,0 @@
use std::sync::Arc;
use crate::billing::wallets::attempt_wallet_deduction::attempt_wallet_deduction_else_common_web_error;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::endpoints::generate::common::payments_error_test::payments_error_test;
use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format;
use crate::state::server_state::ServerState;
use crate::util::http_download_url_to_bytes::http_download_url_to_bytes;
use crate::util::lookup::lookup_image_urls_as_map::lookup_image_urls_as_map;
use actix_web::web::Json;
use actix_web::{web, HttpRequest};
use artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::{
Seedance2p0AspectRatio, Seedance2p0BatchCount, Seedance2p0MultiFunctionVideoGenRequest,
Seedance2p0MultiFunctionVideoGenResponse,
};
use enums::by_table::prompt_context_items::prompt_context_semantic_type::PromptContextSemanticType;
use enums::by_table::prompts::prompt_type::PromptType;
use enums::common::generation_provider::GenerationProvider;
use enums::common::model_type::ModelType;
use enums::common::visibility::Visibility;
use http_server_common::request::get_request_ip::get_request_ip;
use log::{error, info, warn};
use mysql_queries::queries::generic_inference::seedance2pro::insert_generic_inference_job_for_seedance2pro_queue_with_apriori_job_token::{
insert_generic_inference_job_for_seedance2pro_queue_with_apriori_job_token,
InsertGenericInferenceForSeedance2ProWithAprioriJobTokenArgs,
};
use mysql_queries::queries::idepotency_tokens::insert_idempotency_token::insert_idempotency_token;
use mysql_queries::queries::prompt_context_items::insert_batch_prompt_context_items::{
insert_batch_prompt_context_items, InsertBatchArgs, PromptContextItem,
};
use mysql_queries::queries::prompts::insert_prompt::{insert_prompt, InsertPromptArgs};
use seedance2pro::creds::seedance2pro_session::Seedance2ProSession;
use seedance2pro::requests::generate_video::generate_video::{
generate_video, BatchCount, GenerateVideoArgs, Resolution,
};
use seedance2pro::requests::prepare_image_upload::prepare_image_upload::{
prepare_image_upload, PrepareImageUploadArgs,
};
use seedance2pro::requests::upload_image::upload_image::{upload_image, UploadImageArgs};
use sqlx::Acquire;
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
/// Seedance 2.0 Multi-Function video generation (text-to-video, keyframe, and reference).
#[utoipa::path(
post,
tag = "Generate Video (Multi-Function)",
path = "/v1/generate/video/multi_function/seedance_2p0",
responses(
(status = 200, description = "Success", body = Seedance2p0MultiFunctionVideoGenResponse),
),
params(
("request" = Seedance2p0MultiFunctionVideoGenRequest, description = "Payload for Request"),
)
)]
pub async fn seedance_2p0_multi_function_video_gen_handler(
http_request: HttpRequest,
request: Json<Seedance2p0MultiFunctionVideoGenRequest>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<Seedance2p0MultiFunctionVideoGenResponse>, CommonWebError> {
payments_error_test(&request.prompt.as_deref().unwrap_or(""))?;
let mut mysql_connection = server_state.mysql_pool
.acquire()
.await?;
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);
CommonWebError::ServerError
})?;
let maybe_avt_token = server_state
.avt_cookie_manager
.get_avt_token_from_request(&http_request);
let user_token = match maybe_user_session.as_ref() {
Some(session) => &session.user_token,
None => {
return Err(CommonWebError::NotAuthorized);
}
};
if let Err(reason) = validate_idempotency_token_format(&request.uuid_idempotency_token) {
return Err(CommonWebError::BadInputWithSimpleMessage(reason));
}
// --- Collect all media tokens to look up ---
let mut all_media_tokens = Vec::new();
if let Some(token) = request.start_frame_media_token.as_ref() {
all_media_tokens.push(token.clone());
}
if let Some(token) = request.end_frame_media_token.as_ref() {
all_media_tokens.push(token.clone());
}
if let Some(tokens) = request.reference_image_media_tokens.as_ref() {
all_media_tokens.extend(tokens.iter().cloned());
}
let image_urls_by_token = if all_media_tokens.is_empty() {
std::collections::HashMap::new()
} else {
info!("Looking up image media tokens: {:?}", all_media_tokens);
lookup_image_urls_as_map(
&http_request,
&mut mysql_connection,
server_state.server_environment,
&all_media_tokens,
).await?
};
// --- Insert idempotency token ---
insert_idempotency_token(&request.uuid_idempotency_token, &mut *mysql_connection)
.await
.map_err(|err| {
error!("Error inserting idempotency token: {:?}", err);
CommonWebError::BadInputWithSimpleMessage("repeated idempotency token".to_string())
})?;
// --- Build seedance2pro session ---
let session = Seedance2ProSession::from_cookies_string(
server_state.seedance2pro.cookies.clone()
);
// --- Upload images to seedance2pro CDN ---
let start_frame_url = match request.start_frame_media_token.as_ref() {
None => None,
Some(token) => match image_urls_by_token.get(token) {
None => return Err(CommonWebError::BadInputWithSimpleMessage("Start frame media not found.".to_string())),
Some(url) => Some(upload_to_seedance2pro(&session, url).await?),
}
};
let end_frame_url = match request.end_frame_media_token.as_ref() {
None => None,
Some(token) => match image_urls_by_token.get(token) {
None => return Err(CommonWebError::BadInputWithSimpleMessage("End frame media not found.".to_string())),
Some(url) => Some(upload_to_seedance2pro(&session, url).await?),
}
};
let reference_image_urls: Option<Vec<String>> = match request.reference_image_media_tokens.as_ref() {
None => None,
Some(tokens) if tokens.is_empty() => None,
Some(tokens) => {
let mut urls = Vec::with_capacity(tokens.len());
for token in tokens {
match image_urls_by_token.get(token) {
None => return Err(CommonWebError::BadInputWithSimpleMessage("Reference image media not found.".to_string())),
Some(url) => {
let seedance_url = upload_to_seedance2pro(&session, url).await?;
urls.push(seedance_url);
}
}
}
Some(urls)
}
};
// --- Map request params to seedance2pro types ---
let resolution = match request.aspect_ratio {
Some(Seedance2p0AspectRatio::Landscape16x9) => Resolution::Landscape16x9,
Some(Seedance2p0AspectRatio::Portrait9x16) => Resolution::Portrait9x16,
Some(Seedance2p0AspectRatio::Square1x1) => Resolution::Square1x1,
Some(Seedance2p0AspectRatio::Standard4x3) => Resolution::Standard4x3,
Some(Seedance2p0AspectRatio::Portrait3x4) => Resolution::Portrait3x4,
None => Resolution::Landscape16x9,
};
let batch_count = match request.batch_count {
Some(Seedance2p0BatchCount::One) | None => BatchCount::One,
Some(Seedance2p0BatchCount::Two) => BatchCount::Two,
Some(Seedance2p0BatchCount::Four) => BatchCount::Four,
};
let duration_seconds = request.duration_seconds.unwrap_or(5).clamp(4, 15);
let prompt = request.prompt.clone().unwrap_or_else(|| "".to_string());
let video_gen_args = GenerateVideoArgs {
session: &session,
prompt,
resolution,
duration_seconds,
batch_count,
start_frame_url,
end_frame_url,
reference_image_urls,
};
// --- Calculate cost and charge wallet ---
let cost_in_cents = video_gen_args.estimate_cost_in_usd_cents(); // NB: ArtCraft credits are 1:1 with USD cents.
let apriori_job_token = InferenceJobToken::generate();
info!("Charging wallet: {} cents ({} credits)", cost_in_cents, cost_in_cents);
attempt_wallet_deduction_else_common_web_error(
user_token,
Some(apriori_job_token.as_str()),
cost_in_cents,
&mut mysql_connection,
).await?;
// --- Call seedance2pro API ---
let gen_response = generate_video(video_gen_args)
.await
.map_err(|err| {
warn!("Error calling seedance2pro generate_video: {:?}", err);
CommonWebError::ServerError
})?;
info!(
"Seedance2pro task_id={}, order_id={}",
gen_response.task_id, gen_response.order_id
);
// --- DB writes in a transaction ---
let ip_address = get_request_ip(&http_request);
let mut transaction = mysql_connection
.begin()
.await
.map_err(|err| {
error!("Error starting MySQL transaction: {:?}", err);
CommonWebError::ServerError
})?;
// NB: Don't fail the job if the prompt insert fails.
let prompt_result = insert_prompt(InsertPromptArgs {
maybe_apriori_prompt_token: None,
prompt_type: PromptType::ArtcraftApp,
maybe_creator_user_token: maybe_user_session.as_ref().map(|s| &s.user_token),
maybe_model_type: Some(ModelType::Seedance2p0),
maybe_generation_provider: Some(GenerationProvider::Artcraft),
maybe_positive_prompt: request.prompt.as_deref(),
maybe_negative_prompt: None,
maybe_other_args: None,
creator_ip_address: &ip_address,
mysql_executor: &mut *transaction,
phantom: Default::default(),
}).await;
let prompt_token = match prompt_result {
Ok(token) => Some(token),
Err(err) => {
warn!("Error inserting prompt: {:?}", err);
None
}
};
if let Some(token) = prompt_token.as_ref() {
let mut context_items: Vec<PromptContextItem> = Vec::new();
if let Some(media_token) = &request.start_frame_media_token {
context_items.push(PromptContextItem {
media_token: media_token.clone(),
context_semantic_type: PromptContextSemanticType::VidStartFrame,
});
}
if let Some(media_token) = &request.end_frame_media_token {
context_items.push(PromptContextItem {
media_token: media_token.clone(),
context_semantic_type: PromptContextSemanticType::VidEndFrame,
});
}
if !context_items.is_empty() {
if let Err(err) = insert_batch_prompt_context_items(InsertBatchArgs {
prompt_token: token.clone(),
items: context_items,
transaction: &mut transaction,
}).await {
warn!("Error inserting batch prompt context items: {:?}", err);
}
}
}
// Insert one DB job per order_id in the batch.
let order_ids: Vec<String> = match gen_response.order_ids {
Some(ids) if !ids.is_empty() => ids,
_ => vec![gen_response.order_id],
};
let mut all_job_tokens: Vec<InferenceJobToken> = Vec::with_capacity(order_ids.len());
for (i, order_id) in order_ids.iter().enumerate() {
let job_token = if i == 0 {
apriori_job_token.clone()
} else {
InferenceJobToken::generate()
};
// For batch jobs beyond the first, generate a unique idempotency key.
let idempotency_str = if i == 0 {
request.uuid_idempotency_token.clone()
} else {
format!("{}-batch-{}", request.uuid_idempotency_token, i)
};
let db_result = insert_generic_inference_job_for_seedance2pro_queue_with_apriori_job_token(
InsertGenericInferenceForSeedance2ProWithAprioriJobTokenArgs {
apriori_job_token: &job_token,
uuid_idempotency_token: &idempotency_str,
maybe_external_third_party_id: order_id,
maybe_inference_args: None,
maybe_prompt_token: prompt_token.as_ref(),
maybe_creator_user_token: maybe_user_session.as_ref().map(|s| &s.user_token),
maybe_avt_token: maybe_avt_token.as_ref(),
creator_ip_address: &ip_address,
creator_set_visibility: Visibility::Public,
mysql_executor: &mut *transaction,
phantom: Default::default(),
}
).await;
match db_result {
Ok(token) => {
all_job_tokens.push(token);
}
Err(err) => {
warn!("Error inserting seedance2pro inference job (order_id={}): {:?}", order_id, err);
if i == 0 {
return Err(CommonWebError::ServerError);
}
}
}
}
let first_job_token = all_job_tokens.first().cloned().ok_or_else(|| {
error!("No inference job token was created");
CommonWebError::ServerError
})?;
transaction
.commit()
.await
.map_err(|err| {
error!("Error committing MySQL transaction: {:?}", err);
CommonWebError::ServerError
})?;
Ok(Json(Seedance2p0MultiFunctionVideoGenResponse {
success: true,
inference_job_token: first_job_token,
all_inference_job_tokens: all_job_tokens,
}))
}
async fn upload_to_seedance2pro(
session: &Seedance2ProSession,
our_cdn_url: &str,
) -> Result<String, CommonWebError> {
let image_bytes = http_download_url_to_bytes(our_cdn_url)
.await
.map_err(|err| {
warn!("Error downloading media file from CDN: {:?}", err);
CommonWebError::ServerError
})?
.to_vec();
let prepare_result = prepare_image_upload(PrepareImageUploadArgs { session })
.await
.map_err(|err| {
warn!("Error preparing seedance2pro image upload: {:?}", err);
CommonWebError::ServerError
})?;
let upload_result = upload_image(UploadImageArgs {
upload_url: prepare_result.upload_url,
image_bytes,
})
.await
.map_err(|err| {
warn!("Error uploading image to seedance2pro: {:?}", err);
CommonWebError::ServerError
})?;
Ok(upload_result.public_url)
}
@@ -22,7 +22,6 @@ pub mod trending;
pub mod tts;
pub mod user_bookmarks;
pub mod user_ratings;
pub mod user_wallets;
pub mod users;
pub mod voice_clone_requests;
pub mod voice_conversion;
@@ -1,103 +0,0 @@
use std::sync::Arc;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::state::server_state::ServerState;
use actix_web::web::Json;
use actix_web::{web, HttpRequest};
use enums::common::payments_namespace::PaymentsNamespace;
use log::{error, warn};
use mysql_queries::queries::wallets::create_new_artcraft_wallet_for_owner_user::create_new_artcraft_wallet_for_owner_user;
use mysql_queries::queries::wallets::find_primary_wallet_token_for_owner::find_primary_wallet_token_for_owner_using_connection;
use serde::{Deserialize, Serialize};
use sqlx::Acquire;
use tokens::tokens::wallets::WalletToken;
use utoipa::ToSchema;
#[derive(Serialize, Deserialize, ToSchema)]
pub struct GetOrCreateSessionArtcraftWalletResponse {
pub success: bool,
pub wallet_token: WalletToken,
}
/// NB: This endpoint was created primarily for local testing with new dev accounts
/// It should be harmless to have in production as wallet creation is idempotent
/// and (should be) side effect free.
#[utoipa::path(
get,
tag = "Wallets",
path = "/v1/wallets/session_artcraft_wallet",
responses(
(status = 200, description = "Success", body = GetOrCreateSessionArtcraftWalletResponse),
),
)]
pub async fn get_or_create_session_artcraft_wallet_handler(
http_request: HttpRequest,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<GetOrCreateSessionArtcraftWalletResponse>, CommonWebError> {
let mut mysql_connection = server_state.mysql_pool
.acquire()
.await
.map_err(|err| {
error!("Error acquiring MySQL connection: {:?}", err);
CommonWebError::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);
CommonWebError::ServerError
})?;
let user_token = match maybe_user_session {
Some(session) => session.user_token,
None => return Err(CommonWebError::NotAuthorized),
};
let maybe_wallet_token = find_primary_wallet_token_for_owner_using_connection(
&user_token,
PaymentsNamespace::Artcraft,
&mut mysql_connection,
).await.map_err(|err| {
error!("Error finding primary artcraft wallet for user {:?}: {:?}", user_token, err);
CommonWebError::ServerError
})?;
if let Some(wallet_token) = maybe_wallet_token {
return Ok(Json(GetOrCreateSessionArtcraftWalletResponse {
success: true,
wallet_token,
}));
}
let mut transaction = mysql_connection
.begin()
.await
.map_err(|err| {
error!("Error starting MySQL transaction: {:?}", err);
CommonWebError::ServerError
})?;
let wallet_token = create_new_artcraft_wallet_for_owner_user(
&user_token,
&mut transaction,
).await.map_err(|err| {
error!("Error creating artcraft wallet for user {:?}: {:?}", user_token, err);
CommonWebError::ServerError
})?;
transaction
.commit()
.await
.map_err(|err| {
error!("Error committing MySQL transaction: {:?}", err);
CommonWebError::ServerError
})?;
Ok(Json(GetOrCreateSessionArtcraftWalletResponse {
success: true,
wallet_token,
}))
}
@@ -1 +0,0 @@
pub mod get_or_create_session_artcraft_wallet_handler;
@@ -13,7 +13,6 @@ use crate::http_server::routes::application_routes::tag_routes::add_tag_routes;
use crate::http_server::routes::application_routes::user_bookmarks_routes::add_user_bookmarks_routes;
use crate::http_server::routes::application_routes::user_rating_routes::add_user_rating_routes;
use crate::http_server::routes::application_routes::user_routes::add_user_routes;
use crate::http_server::routes::application_routes::wallet_routes::add_wallet_routes;
use crate::http_server::routes::application_routes::webhook_routes::add_webhook_routes;
use crate::http_server::routes::application_routes::weights_routes::add_weights_routes;
use actix_http::body::MessageBody;
@@ -63,7 +62,6 @@ where
app = add_user_routes(app); // /create_account, /session, /login, /logout, etc.
// Artcraft Billing pieces
app = add_wallet_routes(app); // /v1/wallets/...
app = add_credits_routes(app); // /v1/credits/...
app = add_stripe_artcraft_routes(app); // /v1/stripe_artcraft/...
app = add_subscription_routes(app); // /v1/subscriptions/...
@@ -29,7 +29,6 @@ use crate::http_server::endpoints::generate::video::generate_veo_3_fast_image_to
use crate::http_server::endpoints::generate::video::generate_veo_3_image_to_video_handler::generate_veo_3_image_to_video_handler;
use crate::http_server::endpoints::generate::video::multi_function::kling_2p5_turbo_pro_multi_function_video_gen_handler::kling_2p5_turbo_pro_multi_function_video_gen_handler;
use crate::http_server::endpoints::generate::video::multi_function::kling_2p6_pro_multi_function_video_gen_handler::kling_2p6_pro_multi_function_video_gen_handler;
use crate::http_server::endpoints::generate::video::multi_function::seedance_2p0_multi_function_video_gen_handler::seedance_2p0_multi_function_video_gen_handler;
use crate::http_server::endpoints::generate::video::multi_function::sora_2_multi_function_video_gen_handler::sora_2_multi_function_video_gen_handler;
use crate::http_server::endpoints::generate::video::multi_function::sora_2_pro_multi_function_video_gen_handler::sora_2_pro_multi_function_video_gen_handler;
use crate::http_server::endpoints::generate::video::multi_function::veo_3p1_fast_multi_function_video_gen_handler::veo_3p1_fast_multi_function_video_gen_handler;
@@ -141,10 +140,6 @@ where
.route(web::post().to(kling_2p6_pro_multi_function_video_gen_handler))
.route(web::head().to(|| HttpResponse::Ok()))
)
.service(web::resource("/seedance_2p0")
.route(web::post().to(seedance_2p0_multi_function_video_gen_handler))
.route(web::head().to(|| HttpResponse::Ok()))
)
.service(web::resource("/sora_2")
.route(web::post().to(sora_2_multi_function_video_gen_handler))
.route(web::head().to(|| HttpResponse::Ok()))
@@ -1,4 +1,3 @@
mod analytics_routes;
mod comments_routes;
mod credits_routes;
mod featured_item_routes;
@@ -6,14 +5,14 @@ mod generate_routes;
mod job_routes;
mod media_files_routes;
mod moderation_routes;
mod prompts_routes;
mod stripe_artcraft_routes;
mod subscription_routes;
mod tag_routes;
mod user_bookmarks_routes;
mod user_rating_routes;
mod user_routes;
mod wallet_routes;
mod webhook_routes;
mod weights_routes;
pub (super) mod add_application_routes;
pub (super) mod add_application_routes;
mod prompts_routes;
mod analytics_routes;
@@ -1,24 +0,0 @@
use crate::http_server::endpoints::user_wallets::get_or_create_session_artcraft_wallet_handler::get_or_create_session_artcraft_wallet_handler;
use actix_http::body::MessageBody;
use actix_service::ServiceFactory;
use actix_web::dev::{ServiceRequest, ServiceResponse};
use actix_web::{web, App, Error, HttpResponse};
pub fn add_wallet_routes<T, B>(app: App<T>) -> App<T>
where
B: MessageBody,
T: ServiceFactory<
ServiceRequest,
Config = (),
Response = ServiceResponse<B>,
Error = Error,
InitError = (),
>,
{
app.service(web::scope("/v1/wallets")
.service(web::resource("/session_artcraft_wallet")
.route(web::get().to(get_or_create_session_artcraft_wallet_handler))
.route(web::head().to(|| HttpResponse::Ok()))
)
)
}
@@ -80,7 +80,7 @@ use crate::http_server::web_utils::handle_multipart_error::handle_multipart_erro
use crate::http_server::web_utils::scoped_temp_dir_creator::ScopedTempDirCreator;
use crate::state::certs::google_sign_in_cert::GoogleSignInCert;
use crate::state::memory_cache::model_token_to_info_cache::ModelTokenToInfoCache;
use crate::state::server_state::{DurableInMemoryCaches, EnvConfig, EphemeralInMemoryCaches, FalData, InMemoryCaches, OpenAiData, ResendData, Seedance2ProData, ServerInfo, ServerState, StaticFeatureFlags, StripeSettings, TrollBans};
use crate::state::server_state::{DurableInMemoryCaches, EnvConfig, EphemeralInMemoryCaches, FalData, InMemoryCaches, OpenAiData, ResendData, ServerInfo, ServerState, StaticFeatureFlags, StripeSettings, TrollBans};
use crate::threads::db_health_checker_thread::db_health_check_status::HealthCheckStatus;
use crate::threads::db_health_checker_thread::db_health_checker_thread::db_health_checker_thread;
use crate::threads::poll_ip_banlist_thread::poll_ip_bans;
@@ -400,8 +400,6 @@ async fn main() -> AnyhowResult<()> {
let fal_api_key = FalApiKey::new(easyenv::get_env_string_required("FAL_API_KEY")?);
let fal_webhook_url = easyenv::get_env_string_required("FAL_WEBHOOK_URL")?;
let seedance2pro_cookies = easyenv::get_env_string_required("SEEDANCE2PRO_COOKIES")?;
let openai_api_key= easyenv::get_env_string_required("OPENAI_API_KEY")?;
let resend_api_key = easyenv::get_env_string_required("RESEND_API_KEY")?;
@@ -460,10 +458,7 @@ async fn main() -> AnyhowResult<()> {
fal: FalData {
api_key: fal_api_key,
webhook_url: fal_webhook_url,
},
seedance2pro: Seedance2ProData {
cookies: seedance2pro_cookies,
},
},
openai: OpenAiData {
api_key: openai_api_key,
},
@@ -92,9 +92,7 @@ pub struct ServerState {
pub auto_gc_bucket_client: BucketClient,
pub fal: FalData,
pub seedance2pro: Seedance2ProData,
pub openai: OpenAiData,
pub resend: ResendData,
@@ -313,13 +311,6 @@ pub struct FalData {
pub webhook_url: String,
}
/// Seedance 2 Pro integration
#[derive(Clone)]
pub struct Seedance2ProData {
/// Cookie string for seedance2-pro.com
pub cookies: String,
}
/// OpenAI integration
#[derive(Clone)]
pub struct OpenAiData {
@@ -1,9 +1,14 @@
import { useState, useRef, useEffect, useCallback } from "react";
import { useState, useRef, useEffect, useCallback, useMemo } from "react";
import { useShallow } from "zustand/react/shallow";
import { DRAW_LAYER_ID, INPAINT_LAYER_ID, PaintSurface } from "./PaintSurface";
import "./App.css";
import PromptEditor from "./PromptEditor/PromptEditor";
import SideToolbar from "./components/ui/SideToolbar";
import { AspectRatioType, useSceneStore } from "./stores/SceneState";
import {
AspectRatioType,
type SceneState,
useSceneStore,
} from "./stores/SceneState";
import { useUndoRedoHotkeys } from "./hooks/useUndoRedoHotkeys";
import { useDeleteHotkeys } from "./hooks/useDeleteHotkeys";
import { useCopyPasteHotkeys } from "./hooks/useCopyPasteHotkeys";
@@ -18,16 +23,26 @@ import {
useSelectedImageModel,
useSelectedProviderForModel,
} from "@storyteller/ui-model-selector";
import { EnqueueEditImage, EnqueueEditImageRequest, EnqueueEditImageResolution, EnqueueEditImageSize, EnqueueImageInpaint, EnqueueImageInpaintRequest, useCanvasBgRemovedEvent } from "@storyteller/tauri-api";
import {
EnqueueEditImage,
EnqueueEditImageRequest,
EnqueueEditImageResolution,
EnqueueEditImageSize,
EnqueueImageInpaint,
EnqueueImageInpaintRequest,
useCanvasBgRemovedEvent,
} from "@storyteller/tauri-api";
import { HelpMenuButton } from "@storyteller/ui-help-menu";
import { GenerationProvider } from "@storyteller/api-enums";
import { HistoryStack, ImageBundle } from "../PageEdit/HistoryStack";
import { BaseImageSelector, BaseSelectorImage } from "../PageEdit/BaseImageSelector";
import {
BaseImageSelector,
BaseSelectorImage,
} from "../PageEdit/BaseImageSelector";
import { normalizeCanvas } from "~/Helpers/CanvasHelpers";
import { EncodeImageBitmapToBase64 } from "./utilities/EncodeImageBitmapToBase64";
import { RefImage, usePrompt2DStore } from "@storyteller/ui-promptbox";
import { PromptsApi } from "@storyteller/api";
import toast from "react-hot-toast";
const PAGE_ID: ModelPage = ModelPage.Canvas2D;
@@ -62,26 +77,123 @@ const PageDraw = () => {
const [isSelecting, setIsSelecting] = useState<boolean>(false);
const stageRef = useRef<Konva.Stage>({} as Konva.Stage);
const transformerRefs = useRef<{ [key: string]: Konva.Transformer }>({});
const store = useSceneStore();
/*
* Scene store: use a selector + useShallow to avoid re-rendering on every store change.
* Without this, useSceneStore() subscribes to the whole store (e.g. moveNode during
* drag updates state constantly) and PageDraw would re-render every frame.
*
* useShallow compares the selected object by top-level keys: we only re-render when
* one of these values actually changes. Do NOT add:
* - cursorPosition / cursorVisible: updated every mouse move → would re-render constantly.
* - historyImageNodeMap: mutated in place in the store (reference never changes),
* so shallow would never see updates; fix the store to replace the Map if you need it.
* When adding new store fields here, prefer primitive/array/object refs that the store
* replaces (e.g. new array from .map()), not in-place mutations.
*
* IMPORTANT: The selector function is memoized to prevent infinite loops. If you modify
* this selector, ensure the function reference stays stable (use useMemo if needed).
*/
const selector = useMemo(
() => (state: SceneState) => ({
baseImageInfo: state.baseImageInfo,
baseImageBitmap: state.baseImageBitmap,
nodes: state.nodes,
selectedNodeIds: state.selectedNodeIds,
lineNodes: state.lineNodes,
activeTool: state.activeTool,
currentShape: state.currentShape,
fillColor: state.fillColor,
brushColor: state.brushColor,
brushSize: state.brushSize,
historyImageBundles: state.historyImageBundles,
getAspectRatioDimensions: state.getAspectRatioDimensions,
finishRemoveBackground: state.finishRemoveBackground,
createImageFromUrl: state.createImageFromUrl,
createImageFromFile: state.createImageFromFile,
setBaseImageInfo: state.setBaseImageInfo,
RESET: state.RESET,
clearLineNodes: state.clearLineNodes,
setNodes: state.setNodes,
removeHistoryImage: state.removeHistoryImage,
addHistoryImageBundle: state.addHistoryImageBundle,
setAspectRatioType: state.setAspectRatioType,
setActiveTool: state.setActiveTool,
selectNode: state.selectNode,
setCurrentShape: state.setCurrentShape,
setBrushColor: state.setBrushColor,
setBrushSize: state.setBrushSize,
setBrushOpacity: state.setBrushOpacity,
setFillColor: state.setFillColor,
toggleLock: state.toggleLock,
beginRemoveBackground: state.beginRemoveBackground,
bringToFront: state.bringToFront,
bringForward: state.bringForward,
sendBackward: state.sendBackward,
sendToBack: state.sendToBack,
copySelectedItems: state.copySelectedItems,
pasteItems: state.pasteItems,
deleteSelectedItems: state.deleteSelectedItems,
undo: state.undo,
redo: state.redo,
}),
[],
);
const {
baseImageInfo,
baseImageBitmap,
nodes,
selectedNodeIds,
lineNodes,
activeTool,
currentShape,
fillColor,
brushColor,
brushSize,
historyImageBundles,
getAspectRatioDimensions,
finishRemoveBackground,
createImageFromUrl,
createImageFromFile,
setBaseImageInfo,
RESET,
clearLineNodes,
setNodes,
removeHistoryImage,
addHistoryImageBundle,
setAspectRatioType,
setActiveTool,
selectNode,
setCurrentShape,
setBrushColor,
setBrushSize,
setBrushOpacity,
setFillColor,
toggleLock,
beginRemoveBackground,
bringToFront,
bringForward,
sendBackward,
sendToBack,
copySelectedItems,
pasteItems,
deleteSelectedItems,
undo,
redo,
} = useSceneStore(useShallow(selector));
const promptStoreProvider = usePrompt2DStore;
const generationCount = promptStoreProvider((state) => state.generationCount);
const setGenerationCount = promptStoreProvider((state) => state.setGenerationCount);
const useSystemPrompt = promptStoreProvider((state) => state.useSystemPrompt);
const referenceImages = promptStoreProvider((state) => state.referenceImages);
const prompt = promptStoreProvider((state) => state.prompt);
const setGenerationCount = promptStoreProvider(
(state) => state.setGenerationCount,
);
const baseImageKonvaRef = useRef<Konva.Image>({} as Konva.Image);
const baseImageUrl = store.baseImageInfo?.url;
const baseImageUrl = baseImageInfo?.url;
const [pendingGenerations, setPendingGenerations] = useState<
{ id: string; count: number }[]
>([]);
const addHistoryImageBundle = useSceneStore(
(state) => state.addHistoryImageBundle,
);
const removeHistoryImage = useSceneStore((state) => state.removeHistoryImage);
const historyImageBundles = useSceneStore(
(state) => state.historyImageBundles,
);
const selectedImageModel: ImageModel | undefined =
useSelectedImageModel(PAGE_ID);
@@ -92,11 +204,11 @@ const PageDraw = () => {
const supportsMaskedInpainting =
selectedImageModel?.usesInpaintingMask ?? false;
useDeleteHotkeys({ onDelete: store.deleteSelectedItems });
useUndoRedoHotkeys({ undo: store.undo, redo: store.redo });
useDeleteHotkeys({ onDelete: deleteSelectedItems });
useUndoRedoHotkeys({ undo, redo });
useCopyPasteHotkeys({
onCopy: store.copySelectedItems,
onPaste: store.pasteItems,
onCopy: copySelectedItems,
onPaste: pasteItems,
});
useCanvasBgRemovedEvent(async (event) => {
@@ -113,24 +225,19 @@ const PageDraw = () => {
// const file = new File([blob], "generated_image.png", {
// type: blob.type,
// });
store.finishRemoveBackground(
nodeId,
event.media_token,
event.image_cdn_url,
);
finishRemoveBackground(nodeId, event.media_token, event.image_cdn_url);
});
// Create a function to use the left layer ref and download the bitmap from it
const getMaskArrayBuffer = async (): Promise<Uint8Array> => {
if (
!stageRef.current ||
!baseImageKonvaRef.current
) {
if (!stageRef.current || !baseImageKonvaRef.current) {
console.error("Stage or left panel ref is not available");
throw new Error("Stage or left panel or base image ref is not available");
}
const layer = stageRef.current.getLayers().find((l) => l.id() === INPAINT_LAYER_ID)!;
const layer = stageRef.current
.getLayers()
.find((l) => l.id() === INPAINT_LAYER_ID)!;
// Get the canvas area that's covered by the image/rectangle
const rect = baseImageKonvaRef.current;
@@ -195,7 +302,7 @@ const PageDraw = () => {
console.log("Creating image from URL:", imageUrl);
store.createImageFromUrl(stagePoint.x, stagePoint.y, imageUrl);
createImageFromUrl(stagePoint.x, stagePoint.y, imageUrl);
console.log(
`Created image "${item.label}" at stage position:`,
@@ -214,12 +321,12 @@ const PageDraw = () => {
handleGallery2DDrop as EventListener,
);
};
}, [store]);
// Stable action ref; no need to depend on full store.
}, [createImageFromUrl]);
const handleImageUpload = async (files: File[]): Promise<void> => {
// Determine current canvas dimensions from the store (according to aspect-ratio)
const { width: canvasW, height: canvasH } =
store.getAspectRatioDimensions();
const { width: canvasW, height: canvasH } = getAspectRatioDimensions();
// Target maximum size – 85 % of the canvas in each direction
const maxW = canvasW * 0.85;
@@ -240,80 +347,20 @@ const PageDraw = () => {
const x = (canvasW - finalW) / 2;
const y = (canvasH - finalH) / 2;
store.createImageFromFile(x, y, file, finalW, finalH);
createImageFromFile(x, y, file, finalW, finalH);
};
img.src = URL.createObjectURL(file);
}
};
const handleTauriEnqueue = async (image: ImageBitmap, aspectRatio: EnqueueEditImageSize | undefined, resolution: EnqueueEditImageResolution | undefined, subscriberId: string) => {
if (image === undefined) {
console.log("image is undefined");
return;
}
const api = new PromptsApi();
const base64Bitmap = await EncodeImageBitmapToBase64(image);
const byteString = atob(base64Bitmap);
const mimeString = "image/png";
const ab = new ArrayBuffer(byteString.length);
const ia = new Uint8Array(ab);
for (let i = 0; i < byteString.length; i++) {
ia[i] = byteString.charCodeAt(i);
}
const uuid = crypto.randomUUID(); // Generate a new UUID
const file = new File([ab], `${uuid}.png`, { type: mimeString });
const snapshotMediaToken = await api.uploadSceneSnapshot({
screenshot: file,
});
if (snapshotMediaToken.data === undefined) {
toast.error("Error: Unable to upload scene snapshot Please try again.");
return;
}
console.log("useSystemPrompt", useSystemPrompt);
console.log("Snapshot media token:", snapshotMediaToken.data);
const request: EnqueueEditImageRequest = {
model: selectedImageModel,
scene_image_media_token: snapshotMediaToken.data!,
image_media_tokens: referenceImages
.map((image) => image.mediaToken)
.filter((t) => t.length > 0),
disable_system_prompt: !useSystemPrompt,
prompt: prompt,
image_count: generationCount,
aspect_ratio: aspectRatio,
image_resolution: resolution,
frontend_caller: "image_editor",
frontend_subscriber_id: subscriberId,
};
if (selectedProvider) {
request.provider = selectedProvider;
}
const generateResponse = await EnqueueEditImage(request);
console.log("generateResponse", generateResponse);
return generateResponse;
}
const getCompositeCanvasFile = useCallback(async (): Promise<File | null> => {
if (
!stageRef.current ||
!baseImageKonvaRef.current ||
!store.baseImageBitmap
) {
if (!stageRef.current || !baseImageKonvaRef.current || !baseImageBitmap) {
return null;
}
const editsLayer = stageRef.current.getLayers().find((l) => l.id() === DRAW_LAYER_ID);
const editsLayer = stageRef.current
.getLayers()
.find((l) => l.id() === DRAW_LAYER_ID);
if (!editsLayer) {
console.error("Edits layer not found");
@@ -328,7 +375,7 @@ const PageDraw = () => {
const ctx = canvas.getContext("2d");
if (!ctx) return null;
ctx.drawImage(store.baseImageBitmap, 0, 0, width, height);
ctx.drawImage(baseImageBitmap, 0, 0, width, height);
const markerLayerCanvas = editsLayer.toCanvas({
x: stageRef.current.x(),
@@ -347,7 +394,7 @@ const PageDraw = () => {
const blob = await canvas.convertToBlob({ type: "image/png" });
const uuid = crypto.randomUUID();
return new File([blob], `${uuid}.png`, { type: "image/png" });
}, [store.baseImageBitmap]);
}, [baseImageBitmap]);
const handleGenerate = useCallback(
async (
@@ -359,7 +406,7 @@ const PageDraw = () => {
selectedProvider?: GenerationProvider;
},
) => {
const editedImageToken = store.baseImageInfo?.mediaToken;
const editedImageToken = baseImageInfo?.mediaToken;
if (!editedImageToken) {
console.error("Base image is not available");
@@ -400,7 +447,7 @@ const PageDraw = () => {
}
};
const { width, height } = store.getAspectRatioDimensions();
const { width, height } = getAspectRatioDimensions();
const subscriberId: string =
crypto?.randomUUID?.() ??
`inpaint-${Date.now()}-${Math.random().toString(36).slice(2)}`;
@@ -518,7 +565,9 @@ const PageDraw = () => {
);
throw error;
}
}, [generationCount, getCompositeCanvasFile, selectedImageModel, store]);
},
[generationCount, getCompositeCanvasFile, selectedImageModel],
);
const onFitPressed = async () => {
// Get the stage and its container dimensions
@@ -530,8 +579,8 @@ const PageDraw = () => {
const containerHeight = stage.container().offsetHeight;
// Get canvas dimensions from store aspect ratio
const canvasW = store.getAspectRatioDimensions().width;
const canvasH = store.getAspectRatioDimensions().height;
const canvasW = getAspectRatioDimensions().width;
const canvasH = getAspectRatioDimensions().height;
// Add padding to ensure canvas doesn't touch the edges
const padding = 40;
@@ -560,15 +609,17 @@ const PageDraw = () => {
};
// When the model inpainting support changes, we need to auto-change the tool so it's not set to inpainting
// Note: setActiveTool is a stable Zustand action, so we don't need it in deps
useEffect(() => {
if (!supportsMaskedInpainting && store.activeTool === "inpaint") {
store.setActiveTool("select");
if (!supportsMaskedInpainting && activeTool === "inpaint") {
setActiveTool("select");
}
}, [store, supportsMaskedInpainting]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeTool, supportsMaskedInpainting]);
// Auto-fit canvas to screen on initial load
useEffect(() => {
if (!store.baseImageBitmap) {
if (!baseImageBitmap) {
return;
}
@@ -597,11 +648,11 @@ const PageDraw = () => {
autoFitCanvas();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [store.baseImageBitmap]);
}, [baseImageBitmap]);
// Display image selector on launch, otherwise hide it
// Also show loading state if info is set but image is loading
if (!store.baseImageInfo || !store.baseImageBitmap) {
if (!baseImageInfo || !baseImageBitmap) {
return (
<div
className={
@@ -613,11 +664,9 @@ const PageDraw = () => {
<BaseImageSelector
onImageSelect={(image: BaseSelectorImage) => {
addHistoryImageBundle({ images: [image] });
store.setBaseImageInfo(image);
setBaseImageInfo(image);
}}
showLoading={
store.baseImageInfo !== null && store.baseImageInfo === null
}
showLoading={baseImageInfo !== null && baseImageBitmap === null}
/>
</div>
</div>
@@ -629,29 +678,30 @@ const PageDraw = () => {
<>
<div className="fixed inset-0 -z-10 bg-ui-background" />
<div
className={`preserve-aspect-ratio fixed right-4 top-1/2 z-10 -translate-y-1/2 transform ${isSelecting ? "pointer-events-none" : "pointer-events-auto"
}`}
className={`preserve-aspect-ratio fixed right-4 top-1/2 z-10 -translate-y-1/2 transform ${
isSelecting ? "pointer-events-none" : "pointer-events-auto"
}`}
>
<HistoryStack
onClear={() => {
store.RESET();
RESET();
setPendingGenerations([]);
}}
imageBundles={historyImageBundles}
pendingPlaceholders={pendingGenerations}
blurredBackgroundUrl={baseImageUrl}
onImageSelect={(baseImage) => {
store.clearLineNodes();
store.setNodes([]);
store.setBaseImageInfo(baseImage);
clearLineNodes();
setNodes([]);
setBaseImageInfo(baseImage);
}}
onImageRemove={(baseImage) => {
if (
pendingGenerations.length === 0 &&
store.historyImageBundles.length === 1 &&
store.historyImageBundles[0].images.length <= 1
historyImageBundles.length === 1 &&
historyImageBundles[0].images.length <= 1
) {
store.RESET();
RESET();
} else {
removeHistoryImage(baseImage);
}
@@ -662,12 +712,13 @@ const PageDraw = () => {
onResolvePending={(id: string) =>
setPendingGenerations((prev) => prev.filter((p) => p.id !== id))
}
selectedImageToken={store.baseImageInfo?.mediaToken}
selectedImageToken={baseImageInfo?.mediaToken}
/>
</div>
<div
className={`preserve-aspect-ratio fixed bottom-0 left-1/2 z-10 -translate-x-1/2 transform ${isSelecting ? "pointer-events-none" : "pointer-events-auto"
}`}
className={`preserve-aspect-ratio fixed bottom-0 left-1/2 z-10 -translate-x-1/2 transform ${
isSelecting ? "pointer-events-none" : "pointer-events-auto"
}`}
>
<PromptEditor
onAspectRatioChange={async (ratio: string) => {
@@ -685,7 +736,7 @@ const PageDraw = () => {
};
const aspectRatioType = ratioToType(ratio);
store.setAspectRatioType(aspectRatioType);
setAspectRatioType(aspectRatioType);
await new Promise((resolve) => requestAnimationFrame(resolve));
onFitPressed();
@@ -705,28 +756,28 @@ const PageDraw = () => {
<SideToolbar
className="fixed left-0 top-1/2 z-10 -translate-y-1/2 transform"
onSelect={(): void => {
store.setActiveTool("select");
setActiveTool("select");
}}
onActivateShapeTool={(
shape: "rectangle" | "circle" | "triangle",
): void => {
store.selectNode(null);
store.setCurrentShape(shape);
store.setActiveTool("shape");
store.selectNode(null);
selectNode(null);
setCurrentShape(shape);
setActiveTool("shape");
selectNode(null);
}}
onPaintBrush={(hex: string, size: number, opacity: number): void => {
store.setActiveTool("draw");
store.setBrushColor(hex);
store.setBrushSize(size);
store.setBrushOpacity(opacity);
setActiveTool("draw");
setBrushColor(hex);
setBrushSize(size);
setBrushOpacity(opacity);
}}
onCanvasBackground={(hex: string): void => {
console.log("Canvas background activated", { color: hex });
// Add background change logic here
// TODO: minor bug needs to update the preview panel
// Debounce also causes issues with real time color change.
store.setFillColor(hex);
setFillColor(hex);
}}
onUploadImage={(): void => {
// Create input element dynamically like in PromptEditor
@@ -765,14 +816,14 @@ const PageDraw = () => {
input.click();
}}
supportsMaskTool={supportsMaskedInpainting}
activeToolId={store.activeTool}
currentShape={store.currentShape}
activeToolId={activeTool}
currentShape={currentShape}
/>
<div className="relative z-0">
<ContextMenuContainer
onAction={(e, action) => {
if (action === "contextMenu") {
const hasSelection = store.selectedNodeIds.length > 0;
const hasSelection = selectedNodeIds.length > 0;
if (hasSelection) {
console.log("An item is selected.");
return true;
@@ -786,51 +837,54 @@ const PageDraw = () => {
onMenuAction={async (action) => {
switch (action) {
case "LOCK":
store.toggleLock(store.selectedNodeIds);
toggleLock(selectedNodeIds);
break;
case "REMOVE_BACKGROUND":
await store.beginRemoveBackground(store.selectedNodeIds);
await beginRemoveBackground(selectedNodeIds);
break;
case "BRING_TO_FRONT":
store.bringToFront(store.selectedNodeIds);
bringToFront(selectedNodeIds);
break;
case "BRING_FORWARD":
store.bringForward(store.selectedNodeIds);
bringForward(selectedNodeIds);
break;
case "SEND_BACKWARD":
store.sendBackward(store.selectedNodeIds);
sendBackward(selectedNodeIds);
break;
case "SEND_TO_BACK":
store.sendToBack(store.selectedNodeIds);
sendToBack(selectedNodeIds);
break;
case "DUPLICATE":
store.copySelectedItems();
store.pasteItems();
copySelectedItems();
pasteItems();
break;
case "DELETE":
store.deleteSelectedItems();
deleteSelectedItems();
break;
default:
// No action
}
}}
isLocked={store.selectedNodeIds.some((id) => {
const node = store.nodes.find((n) => n.id === id);
const lineNode = store.lineNodes.find((n) => n.id === id);
isLocked={selectedNodeIds.some((id: string) => {
const node = nodes.find((n: (typeof nodes)[number]) => n.id === id);
const lineNode = lineNodes.find(
(n: (typeof lineNodes)[number]) => n.id === id,
);
return (node?.locked || lineNode?.locked) ?? false;
})}
>
<PaintSurface
nodes={store.nodes}
selectedNodeIds={store.selectedNodeIds}
nodes={nodes}
lineNodes={lineNodes}
selectedNodeIds={selectedNodeIds}
onCanvasSizeChange={(width: number, height: number): void => {
canvasWidth.current = width;
canvasHeight.current = height;
}}
fillColor={store.fillColor}
activeTool={store.activeTool}
brushColor={store.brushColor}
brushSize={store.brushSize}
fillColor={fillColor}
activeTool={activeTool}
brushColor={brushColor}
brushSize={brushSize}
onSelectionChange={setIsSelecting}
stageRef={stageRef}
transformerRefs={transformerRefs}
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,6 @@
import { Vector2d } from 'konva/lib/types';
import { useEffect } from 'react';
import { DragState } from '~/pages/PageEdit/EditPaintSurface';
import { Vector2d } from "konva/lib/types";
import { useEffect } from "react";
import { DragState } from "~/pages/PageEdit/EditPaintSurface";
// This piece of code ensures that when you are selecting or dragging and go off the stage,
// it releases the controls mouse drag.
export const useGlobalMouseUp = (
@@ -10,11 +10,12 @@ export const useGlobalMouseUp = (
setIsSelecting: (value: boolean) => void,
isSelectingRef: React.MutableRefObject<boolean>,
setSelectionRect: (value: any) => void,
setLastPoint: (value: { x: number, y: number } | null) => void,
onSelectionChange?: (isSelecting: boolean) => void,
onBeforeReset?: () => void,
) => {
useEffect(() => {
const handleGlobalMouseUp = () => {
onBeforeReset?.();
// Always reset all states when mouse is released anywhere
setIsDragging(undefined);
setIsDrawing(false);
@@ -23,16 +24,15 @@ export const useGlobalMouseUp = (
isSelectingRef.current = false;
onSelectionChange?.(false);
setSelectionRect(null);
setLastPoint(null);
};
// Listen to mouseup on the entire window
window.addEventListener('mouseup', handleGlobalMouseUp);
window.addEventListener('touchend', handleGlobalMouseUp);
window.addEventListener("mouseup", handleGlobalMouseUp);
window.addEventListener("touchend", handleGlobalMouseUp);
return () => {
window.removeEventListener('mouseup', handleGlobalMouseUp);
window.removeEventListener('touchend', handleGlobalMouseUp);
window.removeEventListener("mouseup", handleGlobalMouseUp);
window.removeEventListener("touchend", handleGlobalMouseUp);
};
}, [onSelectionChange]);
}
}, [onSelectionChange, onBeforeReset]);
};
@@ -86,11 +86,17 @@ type SerializedNodeData = {
locked: boolean;
};
interface SceneState {
interface HistoryNodeData {
nodes: Node[];
lineNodes: LineNode[];
}
export interface SceneState {
// Nodes
nodes: Node[];
selectedNodeIds: string[];
lineNodes: LineNode[]; // Add lineNodes to state
historyImageNodeMap: Map<BaseSelectorImage, HistoryNodeData>;
// Clipboard
clipboard: (Node | LineNode)[]; // To store copied items
@@ -292,6 +298,7 @@ export const useSceneStore = create<SceneState>((set, get, store) => ({
nodes: [],
lineNodes: [],
selectedNodeIds: [],
historyImageNodeMap: new Map<BaseSelectorImage, HistoryNodeData>(),
clipboard: [],
history: [],
historyIndex: -1,
@@ -1587,15 +1594,40 @@ export const useSceneStore = create<SceneState>((set, get, store) => ({
},
setBaseImageInfo: (image: BaseSelectorImage | null) => {
set({ baseImageInfo: image });
// If the new base image is null, return early because nothing needs to be loaded.
if (!image) {
// Set the new base image info - setting null is allowed.
set({ baseImageInfo: null });
return;
}
// Load the image bitmap as well
// Save the current node data to the history image node map
const currentBaseImage = get().baseImageInfo;
if (currentBaseImage) {
get().historyImageNodeMap.set(currentBaseImage, {
nodes: get().nodes,
lineNodes: get().lineNodes,
});
}
// Set the new base image info
set({ baseImageInfo: image });
// Load the image bitmap now
const imgBitmap = new Image();
imgBitmap.onload = () => {
// Once the base image is set, check previous node data - or create new empty one
const previousNodeData = get().historyImageNodeMap.get(image);
if (previousNodeData) {
set({
nodes: previousNodeData.nodes,
lineNodes: previousNodeData.lineNodes,
});
} else {
set({ nodes: [], lineNodes: [] });
}
// Finally, set the base image bitmap
set({ baseImageBitmap: imgBitmap });
};
imgBitmap.onerror = (event) => {
@@ -1615,7 +1647,10 @@ export const useSceneStore = create<SceneState>((set, get, store) => ({
},
clearHistoryImages: () => {
set({ historyImageBundles: [] });
set({
historyImageBundles: [],
historyImageNodeMap: new Map<BaseSelectorImage, HistoryNodeData>(),
});
},
addHistoryImageBundle(bundle) {
@@ -1627,6 +1662,10 @@ export const useSceneStore = create<SceneState>((set, get, store) => ({
removeHistoryImage(image) {
console.log("Removing history image:", image);
set((state) => {
// Remove the history image node data
state.historyImageNodeMap.delete(image);
// Remove the image from the history image bundles
const updatedBundles = state.historyImageBundles
.map((bundle) => {
return {
@@ -90,7 +90,6 @@ export const EditPaintSurface = ({
/* 1️⃣ Track SplitPane percent so we can re-measure */
const [leftPct, setLeftPct] = useState(singlePaneMode ? 100 : 50);
const [isDrawing, setIsDrawing] = useState(false);
const [, setLastPoint] = useState<{ x: number; y: number } | null>(null);
const selectionRectRef = React.useRef<Konva.Rect>(null);
const [selectionRect, setSelectionRect] = useState<{
@@ -144,7 +143,6 @@ export const EditPaintSurface = ({
setIsSelecting,
isSelectingRef,
setSelectionRect,
setLastPoint,
onSelectionChange,
);
@@ -282,7 +280,6 @@ export const EditPaintSurface = ({
store.addLineNode(newLineNode, false);
setCurrentLineId(lineId);
setIsDrawing(true);
setLastPoint(stagePoint);
return;
}
@@ -451,8 +448,6 @@ export const EditPaintSurface = ({
lineNode.points(currentPoints);
lineNode.getLayer()?.batchDraw();
}
setLastPoint(stagePoint);
}
// Handle selection rectangle - Update directly through Konva
@@ -481,11 +476,11 @@ export const EditPaintSurface = ({
setSelectionRect((prev) =>
prev
? {
// Ensure prev is not null
...prev,
endX: clampedPoint.x,
endY: clampedPoint.y,
}
// Ensure prev is not null
...prev,
endX: clampedPoint.x,
endY: clampedPoint.y,
}
: null,
);
}
@@ -562,7 +557,6 @@ export const EditPaintSurface = ({
isSelectingRef.current = false;
onSelectionChange?.(false);
setSelectionRect(null);
setLastPoint(null);
setIsMiddleMousePressed(false); // Reset middle mouse state
};
@@ -638,8 +632,8 @@ export const EditPaintSurface = ({
if (!container) return;
const defaultCursor =
activeTool === "edit" ||
activeTool === "marker" ||
activeTool === "eraser"
activeTool === "marker" ||
activeTool === "eraser"
? "none"
: activeTool === "select"
? "grab"
@@ -693,7 +687,7 @@ export const EditPaintSurface = ({
const touch2 = touches[1];
return Math.sqrt(
Math.pow(touch2.clientX - touch1.clientX, 2) +
Math.pow(touch2.clientY - touch1.clientY, 2),
Math.pow(touch2.clientY - touch1.clientY, 2),
);
};
@@ -1473,11 +1467,11 @@ export const EditPaintSurface = ({
scaleY={previewScale}
x={0}
y={0}
// style={{
// background: '#f5f5f5',
// border: '1px solid #ddd',
// borderRadius: '8px',
// }}
// style={{
// background: '#f5f5f5',
// border: '1px solid #ddd',
// borderRadius: '8px',
// }}
>
<Layer>
<Image
+4 -11
View File
@@ -29,18 +29,11 @@ SECRETS_PATH = "secrets.toml"
# -- Migration query to run in batches --
# Tweak this as needed. Use LIMIT to control batch size.
# QUERY = """
# delete
# from media_files
# where maybe_creator_user_token is null
# limit 10000
# """
QUERY = """
delete from media_files
where media_type in ("audio", "wav")
and created_at < NOW() - INTERVAL 6 MONTH
limit 10000
delete
from media_files
where maybe_creator_user_token is null
limit 10000
"""
BATCH_PAUSE_SECONDS = 0.5
+1 -8
View File
@@ -24,17 +24,10 @@ except ImportError:
SECRETS_PATH = "secrets.toml"
# -- Count query to poll --
# QUERY = """
# select count(*)
# from media_files
# where maybe_creator_user_token is null
# """
QUERY = """
select count(*)
from media_files
where media_type in ("audio", "wav")
and created_at < NOW() - INTERVAL 6 MONTH
where maybe_creator_user_token is null
"""
POLL_INTERVAL_SECONDS = 60