mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-22 09:39:31 -04:00
Compare commits
35
Commits
test
..
bt-fix-api-2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d961d88459 | ||
|
|
b3ae7e44cc | ||
|
|
c466c68c33 | ||
|
|
74e1150f3c | ||
|
|
3e0bf8ebfd | ||
|
|
f36a5d5e02 | ||
|
|
9545dba684 | ||
|
|
9506885529 | ||
|
|
ed6dd542d3 | ||
|
|
21aba384a6 | ||
|
|
895a38cd04 | ||
|
|
f947c78bf5 | ||
|
|
94ff298cfa | ||
|
|
c98aca330b | ||
|
|
f9da20315e | ||
|
|
4134c648ab | ||
|
|
56d40c7800 | ||
|
|
69adc0d95a | ||
|
|
0613e6d3ca | ||
|
|
4b77e589e8 | ||
|
|
71ba9e7286 | ||
|
|
e219c4728b | ||
|
|
0b3cce75f3 | ||
|
|
339fb8263c | ||
|
|
8cd0f543fa | ||
|
|
a25c4005a9 | ||
|
|
dbd89cdd10 | ||
|
|
9c1136710d | ||
|
|
7054e3a381 | ||
|
|
bbc4b98b50 | ||
|
|
6c2f60801e | ||
|
|
68624e81d8 | ||
|
|
5bbb0fdb06 | ||
|
|
8273822a5c | ||
|
|
e1427094c3 |
Generated
+20
-2
@@ -677,10 +677,11 @@ checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
|
||||
|
||||
[[package]]
|
||||
name = "artcraft"
|
||||
version = "0.6.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"artcraft_api_defs",
|
||||
"artcraft_router",
|
||||
"base64 0.22.1",
|
||||
"build_metadata",
|
||||
"bytes",
|
||||
@@ -737,6 +738,7 @@ dependencies = [
|
||||
name = "artcraft_api_defs"
|
||||
version = "0.0.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
"enums",
|
||||
"enums_public",
|
||||
@@ -749,6 +751,22 @@ dependencies = [
|
||||
"workspace-build-acceleration",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "artcraft_router"
|
||||
version = "0.0.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"artcraft_api_defs",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"storyteller_client",
|
||||
"tokens",
|
||||
"tokio",
|
||||
"uuid 1.18.1",
|
||||
"workspace-build-acceleration",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ashpd"
|
||||
version = "0.9.2"
|
||||
@@ -11520,7 +11538,6 @@ dependencies = [
|
||||
"redis_common",
|
||||
"redis_schema",
|
||||
"regex",
|
||||
"reqwest 0.12.23",
|
||||
"resend-rs",
|
||||
"reusable_types",
|
||||
"rust-s3",
|
||||
@@ -11552,6 +11569,7 @@ dependencies = [
|
||||
"utoipa-swagger-ui",
|
||||
"videos",
|
||||
"workspace-build-acceleration",
|
||||
"wreq",
|
||||
"zip 0.6.6",
|
||||
]
|
||||
|
||||
|
||||
+4
-2
@@ -72,6 +72,7 @@ members = [
|
||||
"crates/lib/files/videos",
|
||||
"crates/lib/http_headers",
|
||||
"crates/lib/idempotency",
|
||||
"crates/lib/artcraft_router",
|
||||
"crates/lib/image_algorithms",
|
||||
"crates/lib/jobs_common",
|
||||
"crates/lib/jwt_light",
|
||||
@@ -207,6 +208,7 @@ workspace-build-acceleration = { path = "crates/workspace-build-acceleration" }
|
||||
# Setting paths here ensures we don't need to do so in every crate's Cargo.toml
|
||||
# It makes refactoring much easier.
|
||||
actix_artcraft = { path = "crates/lib/actix_artcraft" }
|
||||
artcraft_router = { path = "crates/lib/artcraft_router" }
|
||||
actix_helpers = { path = "crates/lib/actix_helpers" }
|
||||
artcraft_api_defs = { path = "crates/api_clients/artcraft/artcraft_api_defs" }
|
||||
build_metadata = { path = "crates/lib/build_metadata" }
|
||||
@@ -300,7 +302,7 @@ toml = "0.8.23"
|
||||
url = { version = "2.5.4", features = ["serde"] }
|
||||
uuid = "1.18.1"
|
||||
|
||||
# NB(1): wreq gets past fingerprinting, but we have to deal with platform-specific build issues.
|
||||
# NB: wreq gets past fingerprinting, but we have to deal with platform-specific build issues.
|
||||
#
|
||||
# Mac:
|
||||
# brew install --formula cmake pkg-config llvm
|
||||
@@ -311,5 +313,5 @@ uuid = "1.18.1"
|
||||
# choco install llvm -y
|
||||
# choco install nasm -y
|
||||
wreq-util = "3.0.0-rc.10"
|
||||
wreq = { version = "6.0.0-rc.28", features = ["json", "query", "multipart", "cookies", "ws", "stream", "gzip", "form"] }
|
||||
wreq = { version = "6.0.0-rc.28", features = ["json", "query", "form", "multipart", "cookies", "ws", "stream", "gzip", "brotli", "zstd", "deflate"] }
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ We haven't abandoned text-to-asset generation for quick prototyping and ideation
|
||||
|
||||
| Provider | Features |
|
||||
|------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| Artcraft | Nano Banana, Nano Banana Pro, GPT-Image-1 / 1.5, Seedream 4 / 4.5, Flux 1.1 / Kontext, Veo 2 / 3 / 3.1, Kling 1.6 / 2.1 / 2.5 / 2.6, Seedance, Sora 2 / Pro, Hunyuan 3d 2 / 3 |
|
||||
| Artcraft | Nano Banana, Nano Banana Pro, GPT-Image-1 / 1.5, Seedance 2 / Seedream 4 / 4.5, Flux 1.1 / Kontext, Veo 2 / 3 / 3.1, Kling 1.6 / 2.1 / 2.5 / 2.6, Seedance, Sora 2 / Pro, Hunyuan 3d 2 / 3 |
|
||||
| Grok | Grok Imagine, Grok Video |
|
||||
| Midjourney | Image Gen (all versions) |
|
||||
| Sora | Sora 1, Sora 2, GPT-Image-1 |
|
||||
|
||||
@@ -202,6 +202,7 @@ RUN echo -n ${GIT_SHA} > GIT_SHA
|
||||
|
||||
# Copy all the binaries (except those that need a GPU):
|
||||
COPY --from=builder /tmp/target/release/storyteller-web /
|
||||
COPY --from=builder /tmp/target/release/seedance2-pro-job /
|
||||
COPY --from=builder /tmp/target/release/dummy-service /
|
||||
COPY --from=builder /tmp/target/release/analytics-job /
|
||||
COPY --from=builder /tmp/target/release/email-sender-job /
|
||||
|
||||
@@ -36,3 +36,4 @@ serde_json.workspace = true
|
||||
utoipa = { version = "4", features = ["chrono", "actix_extras"] }
|
||||
|
||||
[dev-dependencies]
|
||||
anyhow.workspace = true
|
||||
+19
-2
@@ -33,7 +33,7 @@ pub struct Seedance2p0MultiFunctionVideoGenRequest {
|
||||
pub batch_count: Option<Seedance2p0BatchCount>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Seedance2p0AspectRatio {
|
||||
/// 16:9 landscape (1280x720)
|
||||
@@ -48,7 +48,7 @@ pub enum Seedance2p0AspectRatio {
|
||||
Portrait3x4,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Seedance2p0BatchCount {
|
||||
One,
|
||||
@@ -66,3 +66,20 @@ pub struct Seedance2p0MultiFunctionVideoGenResponse {
|
||||
/// All inference job tokens for the batch (includes `inference_job_token` as the first entry).
|
||||
pub all_inference_job_tokens: Vec<InferenceJobToken>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn aspect_to_string(input: Seedance2p0AspectRatio) -> anyhow::Result<String> {
|
||||
let val = serde_json::to_string(&input)?;
|
||||
Ok(val.replace("\"", ""))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn just_testing_snake_case() -> anyhow::Result<()> {
|
||||
// Ugh, not sure what these are...
|
||||
assert_eq!(aspect_to_string(Seedance2p0AspectRatio::Landscape16x9)?, "landscape16x9");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod parse_multi_cookie_header;
|
||||
pub mod storyteller_avt_cookie;
|
||||
pub mod storyteller_credential_set;
|
||||
pub mod storyteller_session_cookie;
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
use crate::credentials::storyteller_avt_cookie::StorytellerAvtCookie;
|
||||
use crate::credentials::storyteller_credential_set::StorytellerCredentialSet;
|
||||
use crate::credentials::storyteller_session_cookie::StorytellerSessionCookie;
|
||||
use cookie::Cookie;
|
||||
|
||||
/// Parses a multi-cookie header string (e.g. `"visitor=abc; session=xyz"`) and extracts
|
||||
/// the known Storyteller cookies by name.
|
||||
///
|
||||
/// Returns `Ok(Some(...))` if at least one recognised cookie is present,
|
||||
/// `Ok(None)` if the input is empty or contains no recognised cookies,
|
||||
/// and `Err` if any individual cookie segment fails to parse.
|
||||
pub fn parse_multi_cookie_header(header: &str) -> Result<Option<StorytellerCredentialSet>, cookie::ParseError> {
|
||||
let mut avt: Option<StorytellerAvtCookie> = None;
|
||||
let mut session: Option<StorytellerSessionCookie> = None;
|
||||
|
||||
for segment in header.split(';') {
|
||||
let segment = segment.trim();
|
||||
if segment.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let cookie = Cookie::parse(segment.to_owned())?;
|
||||
if avt.is_none() {
|
||||
avt = StorytellerAvtCookie::maybe_from_cookie(&cookie);
|
||||
}
|
||||
if session.is_none() {
|
||||
session = StorytellerSessionCookie::maybe_from_cookie(&cookie);
|
||||
}
|
||||
}
|
||||
|
||||
if avt.is_none() && session.is_none() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
Ok(Some(StorytellerCredentialSet::initialize(avt, session)))
|
||||
}
|
||||
+5
@@ -1,3 +1,4 @@
|
||||
use crate::credentials::parse_multi_cookie_header::parse_multi_cookie_header;
|
||||
use crate::credentials::storyteller_avt_cookie::StorytellerAvtCookie;
|
||||
use crate::credentials::storyteller_session_cookie::StorytellerSessionCookie;
|
||||
|
||||
@@ -25,6 +26,10 @@ impl StorytellerCredentialSet {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_multi_cookie_header(header: &str) -> Result<Option<Self>, cookie::ParseError> {
|
||||
parse_multi_cookie_header(header)
|
||||
}
|
||||
|
||||
pub fn initialize_with_just_cookie(session: StorytellerSessionCookie) -> Self {
|
||||
Self {
|
||||
avt: None,
|
||||
|
||||
+6
-6
@@ -18,11 +18,9 @@ use enums::no_table::style_transfer::style_transfer_name::StyleTransferName;
|
||||
use enums_public::by_table::model_weights::public_weights_types::PublicWeightsType;
|
||||
use idempotency::uuid::generate_random_uuid;
|
||||
use log::debug;
|
||||
use reqwest::multipart::Form;
|
||||
use reqwest::multipart::{Form, Part};
|
||||
use reqwest::Client;
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use std::fs::File;
|
||||
use std::io::Read;
|
||||
use std::path::Path;
|
||||
use tokens::tokens::batch_generations::BatchGenerationToken;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
@@ -73,11 +71,13 @@ pub async fn legacy_upload_media_file_from_file<P: AsRef<Path>>(
|
||||
.build()
|
||||
.map_err(|err| StorytellerError::Client(ClientError::from(err)))?;
|
||||
|
||||
let file_bytes = std::fs::read(args.path.as_ref())
|
||||
.map_err(|err| StorytellerError::Client(ClientError::from(err)))?;
|
||||
let file_name = args.path.as_ref().file_name()
|
||||
.and_then(|n| n.to_str()).unwrap_or("file").to_string();
|
||||
let mut form = Form::new()
|
||||
.text("uuid_idempotency_token", generate_random_uuid())
|
||||
.file("file", args.path)
|
||||
.await
|
||||
.map_err(|err| StorytellerError::Client(ClientError::from(err)))?;
|
||||
.part("file", Part::bytes(file_bytes).file_name(file_name));
|
||||
|
||||
//if args.is_intermediate_system_file {
|
||||
// form = form.text("is_intermediate_system_file", "true");
|
||||
|
||||
+6
-6
@@ -18,11 +18,9 @@ use enums::no_table::style_transfer::style_transfer_name::StyleTransferName;
|
||||
use enums_public::by_table::model_weights::public_weights_types::PublicWeightsType;
|
||||
use idempotency::uuid::generate_random_uuid;
|
||||
use log::debug;
|
||||
use reqwest::multipart::Form;
|
||||
use reqwest::multipart::{Form, Part};
|
||||
use reqwest::Client;
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use std::fs::File;
|
||||
use std::io::Read;
|
||||
use std::path::Path;
|
||||
use tokens::tokens::batch_generations::BatchGenerationToken;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
@@ -73,11 +71,13 @@ pub async fn upload_image_media_file_from_file<P: AsRef<Path>>(
|
||||
.build()
|
||||
.map_err(|err| StorytellerError::Client(ClientError::from(err)))?;
|
||||
|
||||
let file_bytes = std::fs::read(args.path.as_ref())
|
||||
.map_err(|err| StorytellerError::Client(ClientError::from(err)))?;
|
||||
let file_name = args.path.as_ref().file_name()
|
||||
.and_then(|n| n.to_str()).unwrap_or("file").to_string();
|
||||
let mut form = Form::new()
|
||||
.text("uuid_idempotency_token", generate_random_uuid())
|
||||
.file("file", args.path)
|
||||
.await
|
||||
.map_err(|err| StorytellerError::Client(ClientError::from(err)))?;
|
||||
.part("file", Part::bytes(file_bytes).file_name(file_name));
|
||||
|
||||
if args.is_intermediate_system_file {
|
||||
form = form.text("is_intermediate_system_file", "true");
|
||||
|
||||
+6
-6
@@ -15,11 +15,9 @@ use enums::no_table::style_transfer::style_transfer_name::StyleTransferName;
|
||||
use enums_public::by_table::model_weights::public_weights_types::PublicWeightsType;
|
||||
use idempotency::uuid::generate_random_uuid;
|
||||
use log::debug;
|
||||
use reqwest::multipart::Form;
|
||||
use reqwest::multipart::{Form, Part};
|
||||
use reqwest::Client;
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use std::fs::File;
|
||||
use std::io::Read;
|
||||
use std::path::Path;
|
||||
use tokens::tokens::batch_generations::BatchGenerationToken;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
@@ -44,11 +42,13 @@ pub async fn upload_new_engine_asset_from_file<P: AsRef<Path>>(
|
||||
.gzip(true)
|
||||
.build()?;
|
||||
|
||||
let file_bytes = std::fs::read(path.as_ref())?;
|
||||
let file_name = path.as_ref().file_name()
|
||||
.and_then(|n| n.to_str()).unwrap_or("file").to_string();
|
||||
let form = Form::new()
|
||||
.text("uuid_idempotency_token", generate_random_uuid())
|
||||
.text("engine_category", "object") // TODO: Which type should we use?
|
||||
.file("file", path)
|
||||
.await?;
|
||||
.text("engine_category", "object") // TODO: Which type should we use?
|
||||
.part("file", Part::bytes(file_bytes).file_name(file_name));
|
||||
|
||||
let mut request_builder = client.post(url)
|
||||
.header("User-Agent", USER_AGENT)
|
||||
|
||||
+5
-5
@@ -15,11 +15,9 @@ use enums::no_table::style_transfer::style_transfer_name::StyleTransferName;
|
||||
use enums_public::by_table::model_weights::public_weights_types::PublicWeightsType;
|
||||
use idempotency::uuid::generate_random_uuid;
|
||||
use log::debug;
|
||||
use reqwest::multipart::Form;
|
||||
use reqwest::multipart::{Form, Part};
|
||||
use reqwest::Client;
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use std::fs::File;
|
||||
use std::io::Read;
|
||||
use std::path::Path;
|
||||
use tokens::tokens::batch_generations::BatchGenerationToken;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
@@ -74,10 +72,12 @@ pub async fn upload_video_media_file_from_file<P: AsRef<Path>>(
|
||||
.gzip(true)
|
||||
.build()?;
|
||||
|
||||
let file_bytes = std::fs::read(args.path.as_ref())?;
|
||||
let file_name = args.path.as_ref().file_name()
|
||||
.and_then(|n| n.to_str()).unwrap_or("file").to_string();
|
||||
let mut form = Form::new()
|
||||
.text("uuid_idempotency_token", generate_random_uuid())
|
||||
.file("file", args.path)
|
||||
.await?;
|
||||
.part("file", Part::bytes(file_bytes).file_name(file_name));
|
||||
|
||||
if let Some(prompt_token) = &args.maybe_prompt_token {
|
||||
form = form.text("maybe_prompt_token", prompt_token.to_string());
|
||||
|
||||
@@ -48,6 +48,16 @@ pub async fn basic_json_delete_request<Req: Serialize, Res: DeserializeOwned>(
|
||||
.await
|
||||
.map_err(|err| StorytellerError::Api(ApiError::from(err)))?;
|
||||
|
||||
let hostname = response.headers().get("x-backend-hostname")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_owned());
|
||||
|
||||
let build_sha = response.headers().get("x-build-sha")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_owned());
|
||||
|
||||
debug!("[server] x-backend-hostname: {:?}, x-build-sha: {:?}", hostname, build_sha);
|
||||
|
||||
let response = filter_bad_response(response)
|
||||
.await
|
||||
.map_err(|err| StorytellerError::Api(ApiError::from(err)))?;
|
||||
|
||||
@@ -5,7 +5,7 @@ use crate::error::storyteller_error::StorytellerError;
|
||||
use crate::utils::api_host::ApiHost;
|
||||
use crate::utils::constants::{APPLICATION_JSON, USER_AGENT};
|
||||
use crate::utils::filter_bad_response::filter_bad_response;
|
||||
use log::debug;
|
||||
use log::{debug, info};
|
||||
use reqwest::Client;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -40,6 +40,16 @@ pub async fn basic_json_get_request<Res: DeserializeOwned>(
|
||||
.await
|
||||
.map_err(|err| StorytellerError::Api(ApiError::from(err)))?;
|
||||
|
||||
let hostname = response.headers().get("x-backend-hostname")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_owned());
|
||||
|
||||
let build_sha = response.headers().get("x-build-sha")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_owned());
|
||||
|
||||
debug!("[server] x-backend-hostname: {:?}, x-build-sha: {:?}", hostname, build_sha);
|
||||
|
||||
let response = filter_bad_response(response)
|
||||
.await
|
||||
.map_err(|err| StorytellerError::Api(ApiError::from(err)))?;
|
||||
|
||||
@@ -48,6 +48,16 @@ pub async fn basic_json_post_request<Req: Serialize, Res: DeserializeOwned>(
|
||||
.await
|
||||
.map_err(|err| StorytellerError::Api(ApiError::from(err)))?;
|
||||
|
||||
let hostname = response.headers().get("x-backend-hostname")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_owned());
|
||||
|
||||
let build_sha = response.headers().get("x-build-sha")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_owned());
|
||||
|
||||
debug!("[server] x-backend-hostname: {:?}, x-build-sha: {:?}", hostname, build_sha);
|
||||
|
||||
let response = filter_bad_response(response)
|
||||
.await
|
||||
.map_err(|err| StorytellerError::Api(ApiError::from(err)))?;
|
||||
|
||||
+10
@@ -46,6 +46,16 @@ pub async fn basic_query_string_post_request<Res: DeserializeOwned>(
|
||||
.await
|
||||
.map_err(|err| StorytellerError::Api(ApiError::from(err)))?;
|
||||
|
||||
let hostname = response.headers().get("x-backend-hostname")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_owned());
|
||||
|
||||
let build_sha = response.headers().get("x-build-sha")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_owned());
|
||||
|
||||
debug!("[server] x-backend-hostname: {:?}, x-build-sha: {:?}", hostname, build_sha);
|
||||
|
||||
let response = filter_bad_response(response)
|
||||
.await
|
||||
.map_err(|err| StorytellerError::Api(ApiError::from(err)))?;
|
||||
|
||||
@@ -365,7 +365,7 @@ mod tests {
|
||||
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(),
|
||||
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 walks in front of the desk and barks.".to_string(),
|
||||
resolution: Resolution::Standard4x3,
|
||||
duration_seconds: 10,
|
||||
batch_count: BatchCount::One,
|
||||
|
||||
@@ -79,8 +79,9 @@ 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,
|
||||
// NB: We don't need these.
|
||||
// /// Width / height ratio (e.g. 1.777… for 16:9). `None` when the server returns null (e.g. width/height are 0).
|
||||
// pub ratio: Option<f64>,
|
||||
}
|
||||
|
||||
/// The status of one order (one video generation task).
|
||||
@@ -179,7 +180,7 @@ pub async fn poll_orders(args: PollOrdersArgs<'_>) -> Result<PollOrdersResponse,
|
||||
url: r.url,
|
||||
width: r.width,
|
||||
height: r.height,
|
||||
ratio: r.ratio,
|
||||
// ratio: r.ratio,
|
||||
}).collect(),
|
||||
fail_reason: o.fail_reason,
|
||||
created_at: o.created_at,
|
||||
|
||||
@@ -38,5 +38,5 @@ pub(super) struct RawVideoResult {
|
||||
pub url: String,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub ratio: f64,
|
||||
// pub ratio: Option<f64>,
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "artcraft"
|
||||
version = "0.6.0"
|
||||
version = "0.8.0"
|
||||
description = "Artcraft"
|
||||
authors = ["you"]
|
||||
license = ""
|
||||
@@ -27,6 +27,7 @@ workspace-build-acceleration.workspace = true
|
||||
|
||||
# Internal
|
||||
artcraft_api_defs.workspace = true
|
||||
artcraft_router.workspace = true
|
||||
build_metadata.workspace = true
|
||||
cookie_store.workspace = true
|
||||
enums.workspace = true
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use crate::core::artcraft_error::ArtcraftError;
|
||||
use anyhow::anyhow;
|
||||
use artcraft_router::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use artcraft_router::errors::provider_error::ProviderError;
|
||||
use base64::DecodeError;
|
||||
use enums::common::generation_provider::GenerationProvider;
|
||||
use enums::common::model_type::ModelType;
|
||||
@@ -211,3 +213,16 @@ impl From<WorldLabsError> for GenerateError {
|
||||
Self::ProviderFailure(ProviderFailureReason::WorldLabsError(value))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ArtcraftRouterError> for GenerateError {
|
||||
fn from(value: ArtcraftRouterError) -> Self {
|
||||
match value {
|
||||
ArtcraftRouterError::Client(e) => Self::BadInput(BadInputReason::WrongImageArguments(e.to_string())),
|
||||
ArtcraftRouterError::UnsupportedModel(model) => Self::NotYetImplemented(format!("Unsupported model: {}", model)),
|
||||
ArtcraftRouterError::InvalidInput(msg) => Self::BadInput(BadInputReason::WrongImageArguments(msg)),
|
||||
ArtcraftRouterError::Provider(ProviderError::Storyteller(e)) => {
|
||||
Self::ProviderFailure(ProviderFailureReason::StorytellerError(e))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+39
-31
@@ -1,51 +1,59 @@
|
||||
use crate::core::commands::enqueue::generate_error::GenerateError;
|
||||
use crate::core::commands::enqueue::image_to_video::enqueue_image_to_video_command::{EnqueueImageToVideoRequest, SoraOrientation};
|
||||
use crate::core::commands::enqueue::image_to_video::enqueue_image_to_video_command::EnqueueImageToVideoRequest;
|
||||
use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess;
|
||||
use crate::core::events::generation_events::common::{GenerationModel};
|
||||
use crate::core::events::generation_events::common::GenerationModel;
|
||||
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
|
||||
use artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::{Seedance2p0AspectRatio, Seedance2p0MultiFunctionVideoGenRequest};
|
||||
use artcraft_router::api::common_video_model::CommonVideoModel;
|
||||
use artcraft_router::api::image_list_ref::ImageListRef;
|
||||
use artcraft_router::api::image_ref::ImageRef;
|
||||
use artcraft_router::api::provider::Provider;
|
||||
use artcraft_router::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
|
||||
use artcraft_router::client::router_artcraft_client::RouterArtcraftClient;
|
||||
use artcraft_router::client::router_client::RouterClient;
|
||||
use artcraft_router::generate::generate_video::begin_video_generation::begin_video_generation;
|
||||
use artcraft_router::generate::generate_video::generate_video_request::GenerateVideoRequest;
|
||||
use enums::common::generation_provider::GenerationProvider;
|
||||
use enums::tauri::tasks::task_type::TaskType;
|
||||
use idempotency::uuid::generate_random_uuid;
|
||||
use log::{error, info};
|
||||
use storyteller_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
|
||||
use storyteller_client::endpoints::generate::video::multi_function::seedance_2p0_multi_function_video_gen::seedance_2p0_multi_function_video_gen;
|
||||
|
||||
pub (super) async fn handle_artcraft_seedance_2p0(
|
||||
pub(super) async fn handle_artcraft_seedance_2p0(
|
||||
request: &EnqueueImageToVideoRequest,
|
||||
app_env_configs: &AppEnvConfigs,
|
||||
creds: &StorytellerCredentialSet,
|
||||
) -> Result<TaskEnqueueSuccess, GenerateError> {
|
||||
let client = RouterClient::Artcraft(RouterArtcraftClient::new(
|
||||
app_env_configs.storyteller_host.clone(),
|
||||
creds.clone(),
|
||||
));
|
||||
|
||||
let uuid_idempotency_token = generate_random_uuid();
|
||||
let start_frame = request.image_media_token.as_ref().map(ImageRef::MediaFileToken);
|
||||
let end_frame = request.end_frame_image_media_token.as_ref().map(ImageRef::MediaFileToken);
|
||||
let reference_images = request.reference_image_media_tokens.as_ref().map(ImageListRef::MediaFileTokens);
|
||||
|
||||
let aspect_ratio = match request.sora_orientation {
|
||||
Some(SoraOrientation::Portrait) => Seedance2p0AspectRatio::Portrait9x16,
|
||||
Some(SoraOrientation::Landscape) => Seedance2p0AspectRatio::Landscape16x9,
|
||||
None => Seedance2p0AspectRatio::Landscape16x9,
|
||||
let router_request = GenerateVideoRequest {
|
||||
model: CommonVideoModel::Seedance2p0,
|
||||
provider: Provider::Artcraft,
|
||||
prompt: request.prompt.as_deref(),
|
||||
start_frame,
|
||||
end_frame,
|
||||
reference_images,
|
||||
resolution: None,
|
||||
aspect_ratio: request.aspect_ratio,
|
||||
duration_seconds: request.duration_seconds,
|
||||
video_batch_count: request.video_batch_count,
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
|
||||
idempotency_token: None,
|
||||
};
|
||||
|
||||
let request = Seedance2p0MultiFunctionVideoGenRequest {
|
||||
uuid_idempotency_token,
|
||||
prompt: request.prompt.clone(),
|
||||
start_frame_media_token: request.image_media_token.clone(),
|
||||
end_frame_media_token: request.end_frame_image_media_token.clone(),
|
||||
reference_image_media_tokens: None,
|
||||
aspect_ratio: Some(aspect_ratio),
|
||||
duration_seconds: None, // TODO: Parameterize
|
||||
batch_count: None,
|
||||
};
|
||||
let plan = begin_video_generation(&router_request)?;
|
||||
|
||||
info!("Video Generation Plan: {:?}", plan);
|
||||
|
||||
let result = seedance_2p0_multi_function_video_gen(
|
||||
&app_env_configs.storyteller_host,
|
||||
Some(&creds),
|
||||
request,
|
||||
).await;
|
||||
|
||||
let job_token = match result {
|
||||
Ok(enqueued) => {
|
||||
let response = match plan.generate_video(&client).await {
|
||||
Ok(resp) => {
|
||||
info!("Successfully enqueued.");
|
||||
enqueued.inference_job_token
|
||||
resp
|
||||
}
|
||||
Err(err) => {
|
||||
error!("Failed to enqueue: {:?}", err);
|
||||
@@ -57,6 +65,6 @@ pub (super) async fn handle_artcraft_seedance_2p0(
|
||||
task_type: TaskType::VideoGeneration,
|
||||
model: Some(GenerationModel::Seedance2p0),
|
||||
provider: GenerationProvider::Artcraft,
|
||||
provider_job_id: Some(job_token.to_string()),
|
||||
provider_job_id: Some(response.inference_job_token.to_string()),
|
||||
})
|
||||
}
|
||||
|
||||
+17
@@ -22,6 +22,7 @@ use crate::services::grok::state::grok_credential_manager::GrokCredentialManager
|
||||
use crate::services::sora::state::sora_credential_manager::SoraCredentialManager;
|
||||
use crate::services::sora::state::sora_task_queue::SoraTaskQueue;
|
||||
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
|
||||
use artcraft_router::api::common_aspect_ratio::CommonAspectRatio;
|
||||
use enums::common::generation_provider::GenerationProvider;
|
||||
use enums::tauri::ux::tauri_command_caller::TauriCommandCaller;
|
||||
use log::{error, info, warn};
|
||||
@@ -101,6 +102,10 @@ pub struct EnqueueImageToVideoRequest {
|
||||
/// TODO: In the future we may support base64 images, URLs, or file paths here.
|
||||
pub end_frame_image_media_token: Option<MediaFileToken>,
|
||||
|
||||
/// OPTIONAL.
|
||||
/// Reference images for the video generation (e.g. character consistency, props, etc.)
|
||||
pub reference_image_media_tokens: Option<Vec<MediaFileToken>>,
|
||||
|
||||
/// Optional.
|
||||
/// Text prompt used to direct the video.
|
||||
pub prompt: Option<String>,
|
||||
@@ -110,6 +115,18 @@ pub struct EnqueueImageToVideoRequest {
|
||||
/// Typically, this costs money when presented as an option, so we default to off.
|
||||
pub generate_audio: Option<bool>,
|
||||
|
||||
/// OPTIONAL.
|
||||
/// Aspect ratio for the generated video.
|
||||
pub aspect_ratio: Option<CommonAspectRatio>,
|
||||
|
||||
/// OPTIONAL.
|
||||
/// Duration of the generated video in seconds.
|
||||
pub duration_seconds: Option<u16>,
|
||||
|
||||
/// OPTIONAL.
|
||||
/// Number of videos to generate in one batch.
|
||||
pub video_batch_count: Option<u16>,
|
||||
|
||||
/// OPTIONAL.
|
||||
/// Only for Sora2 model currently.
|
||||
pub sora_orientation: Option<SoraOrientation>,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
|
||||
/// The version of the ArtCraft desktop app reported in the UI and via Analytics.
|
||||
/// This should match the CI builds.
|
||||
pub const ARTCRAFT_VERSION: &str = "0.6.0";
|
||||
pub const ARTCRAFT_VERSION: &str = "0.8.0";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2.0.0-rc",
|
||||
"productName": "ArtCraft",
|
||||
"version": "0.6.0",
|
||||
"version": "0.8.0",
|
||||
"identifier": "ai.artcraft.app",
|
||||
"build": {
|
||||
"frontendDist": "../../../frontend/apps/artcraft/dist",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2.0.0-rc",
|
||||
"productName": "ArtCraft",
|
||||
"version": "0.6.0",
|
||||
"version": "0.8.0",
|
||||
"identifier": "ai.artcraft.app",
|
||||
"build": {
|
||||
"frontendDist": "../../../frontend/apps/artcraft/dist",
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
[package]
|
||||
name = "artcraft_router"
|
||||
edition = "2024"
|
||||
version = "0.0.1"
|
||||
publish = false
|
||||
|
||||
[package.metadata.cargo-udeps.ignore]
|
||||
normal = ["workspace-build-acceleration"]
|
||||
|
||||
[lib]
|
||||
name = "artcraft_router"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
# cargo-hakari workspace hack (faster compile times)
|
||||
workspace-build-acceleration.workspace = true
|
||||
|
||||
# Internal
|
||||
artcraft_api_defs.workspace = true
|
||||
storyteller_client.workspace = true
|
||||
tokens.workspace = true
|
||||
|
||||
# External
|
||||
anyhow.workspace = true
|
||||
log.workspace = true
|
||||
serde.workspace = true
|
||||
serde_derive.workspace = true
|
||||
tokio.workspace = true
|
||||
uuid.workspace = true
|
||||
@@ -0,0 +1,42 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
|
||||
/// This is a comprehensive list of common aspect ratios you can specify when enqueuing a generation.
|
||||
/// Not every model will support every aspect ratio.
|
||||
/// In the case a model doesn't support the aspect ratio, gracefully pick the nearest option.
|
||||
#[derive(Copy, Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CommonAspectRatio {
|
||||
// Auto (eg. for image editing to use the source; used by Nano Banana Pro edit image, but not text-to-image)
|
||||
Auto,
|
||||
|
||||
// Square
|
||||
Square,
|
||||
|
||||
// Wide
|
||||
WideThreeByTwo,
|
||||
WideFourByThree,
|
||||
WideFiveByFour,
|
||||
WideSixteenByNine,
|
||||
WideTwentyOneByNine,
|
||||
|
||||
// Tall
|
||||
TallTwoByThree,
|
||||
TallThreeByFour,
|
||||
TallFourByFive,
|
||||
TallNineBySixteen,
|
||||
TallNineByTwentyOne,
|
||||
|
||||
// Imprecise semantic values that we probably remap to other meanings
|
||||
// on a model-by-model basis.
|
||||
Wide,
|
||||
Tall,
|
||||
|
||||
// Auto values that bake in resolution
|
||||
// These are from the Seedream models
|
||||
Auto2k,
|
||||
Auto4k,
|
||||
|
||||
// Defined aspect ratios that bake in resolution
|
||||
// These are from the Seedream models
|
||||
SquareHd,
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
|
||||
/// This is a comprehensive list of common resolutions you can specify when enqueuing a generation.
|
||||
/// Not every model will support every resolution.
|
||||
/// In the case a model doesn't support the resolution, gracefully pick the nearest option.
|
||||
#[derive(Copy, Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CommonVideoResolution {
|
||||
/// Models: Nano Banana Pro
|
||||
OneK,
|
||||
/// Models: Nano Banana Pro
|
||||
TwoK,
|
||||
/// Models: (None)
|
||||
ThreeK,
|
||||
/// Models: Nano Banana Pro
|
||||
FourK,
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
|
||||
/// Common video models supported by the router.
|
||||
/// Not all models are available through all providers.
|
||||
#[derive(Copy, Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CommonVideoModel {
|
||||
#[serde(rename = "grok_video")]
|
||||
GrokVideo,
|
||||
|
||||
#[serde(rename = "kling_1p6_pro")]
|
||||
Kling16Pro,
|
||||
|
||||
#[serde(rename = "kling_2p1_pro")]
|
||||
Kling21Pro,
|
||||
|
||||
#[serde(rename = "kling_2p1_master")]
|
||||
Kling21Master,
|
||||
|
||||
#[serde(rename = "kling_2p5_turbo_pro")]
|
||||
Kling2p5TurboPro,
|
||||
|
||||
#[serde(rename = "kling_2p6_pro")]
|
||||
Kling2p6Pro,
|
||||
|
||||
#[serde(rename = "seedance_1p0_lite")]
|
||||
Seedance10Lite,
|
||||
|
||||
#[serde(rename = "seedance_2p0")]
|
||||
Seedance2p0,
|
||||
|
||||
#[serde(rename = "sora_2")]
|
||||
Sora2,
|
||||
|
||||
#[serde(rename = "sora_2_pro")]
|
||||
Sora2Pro,
|
||||
|
||||
#[serde(rename = "veo_2")]
|
||||
Veo2,
|
||||
|
||||
#[serde(rename = "veo_3")]
|
||||
Veo3,
|
||||
|
||||
#[serde(rename = "veo_3_fast")]
|
||||
Veo3Fast,
|
||||
|
||||
#[serde(rename = "veo_3p1")]
|
||||
Veo3p1,
|
||||
|
||||
#[serde(rename = "veo_3p1_fast")]
|
||||
Veo3p1Fast,
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
#[derive(Copy, Clone)]
|
||||
pub enum ImageListRef<'a> {
|
||||
MediaFileTokens(&'a Vec<MediaFileToken>),
|
||||
Urls(&'a Vec<String>),
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
#[derive(Copy, Clone)]
|
||||
pub enum ImageRef<'a> {
|
||||
MediaFileToken(&'a MediaFileToken),
|
||||
Url(&'a str),
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
pub mod common_aspect_ratio;
|
||||
pub mod common_resolution;
|
||||
pub mod common_video_model;
|
||||
pub mod image_list_ref;
|
||||
pub mod image_ref;
|
||||
pub mod provider;
|
||||
@@ -0,0 +1,8 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
|
||||
/// The provider to route a generation request to.
|
||||
#[derive(Copy, Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Provider {
|
||||
Artcraft,
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
pub mod multi_router_client;
|
||||
pub mod multi_router_client_builder;
|
||||
pub mod request_mismatch_mitigation_strategy;
|
||||
pub mod router_artcraft_client;
|
||||
pub mod router_client;
|
||||
@@ -0,0 +1,13 @@
|
||||
use crate::client::router_artcraft_client::RouterArtcraftClient;
|
||||
use crate::errors::client_error::{ClientError, ClientType};
|
||||
|
||||
pub struct MultiRouterClient {
|
||||
pub(crate) artcraft_client: Option<RouterArtcraftClient>,
|
||||
}
|
||||
|
||||
impl MultiRouterClient {
|
||||
pub fn get_artcraft_client_ref(&self) -> Result<&RouterArtcraftClient, ClientError> {
|
||||
self.artcraft_client.as_ref()
|
||||
.ok_or(ClientError::ClientNotConfigured(ClientType::Artcraft))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
use crate::client::router_artcraft_client::RouterArtcraftClient;
|
||||
use crate::client::multi_router_client::MultiRouterClient;
|
||||
|
||||
pub struct MultiRouterClientBuilder {
|
||||
artcraft_client: Option<RouterArtcraftClient>,
|
||||
}
|
||||
|
||||
impl MultiRouterClientBuilder {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
artcraft_client: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_artcraft_client(mut self, client: RouterArtcraftClient) -> Self {
|
||||
self.artcraft_client = Some(client);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn build(self) -> MultiRouterClient {
|
||||
MultiRouterClient {
|
||||
artcraft_client: self.artcraft_client,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
pub enum RequestMismatchMitigationStrategy {
|
||||
/// Upgrade the request to pay more.
|
||||
PayMoreUpgrade,
|
||||
|
||||
/// Downgrade the request to pay less
|
||||
PayLessDowngrade,
|
||||
|
||||
/// Fail the request.
|
||||
ErrorOut,
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
use storyteller_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
|
||||
use storyteller_client::utils::api_host::ApiHost;
|
||||
|
||||
pub struct RouterArtcraftClient {
|
||||
pub(crate) api_host: ApiHost,
|
||||
pub(crate) credentials: StorytellerCredentialSet,
|
||||
}
|
||||
|
||||
impl RouterArtcraftClient {
|
||||
pub fn new(api_host: ApiHost, credentials: StorytellerCredentialSet) -> Self {
|
||||
Self { api_host, credentials }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
use crate::client::multi_router_client::MultiRouterClient;
|
||||
use crate::client::router_artcraft_client::RouterArtcraftClient;
|
||||
use crate::errors::client_error::ClientError;
|
||||
|
||||
pub enum RouterClient {
|
||||
Multi(MultiRouterClient),
|
||||
Artcraft(RouterArtcraftClient),
|
||||
}
|
||||
|
||||
impl RouterClient {
|
||||
pub fn get_artcraft_client_ref(&self) -> Result<&RouterArtcraftClient, ClientError> {
|
||||
match self {
|
||||
RouterClient::Artcraft(client) => Ok(client),
|
||||
RouterClient::Multi(multi) => multi.get_artcraft_client_ref(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
use crate::errors::client_error::ClientError;
|
||||
use crate::errors::provider_error::ProviderError;
|
||||
use std::error::Error;
|
||||
use std::fmt::{Display, Formatter};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ArtcraftRouterError {
|
||||
/// A client configuration error.
|
||||
Client(ClientError),
|
||||
|
||||
/// The requested model is not yet supported by the router.
|
||||
UnsupportedModel(String),
|
||||
|
||||
/// Invalid or missing input arguments.
|
||||
InvalidInput(String),
|
||||
|
||||
/// An error from an underlying provider.
|
||||
Provider(ProviderError),
|
||||
}
|
||||
|
||||
impl Error for ArtcraftRouterError {}
|
||||
|
||||
impl Display for ArtcraftRouterError {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Client(e) => write!(f, "Client error: {}", e),
|
||||
Self::UnsupportedModel(model) => write!(f, "Unsupported model: {}", model),
|
||||
Self::InvalidInput(msg) => write!(f, "Invalid input: {}", msg),
|
||||
Self::Provider(e) => write!(f, "Provider error: {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ClientError> for ArtcraftRouterError {
|
||||
fn from(error: ClientError) -> Self {
|
||||
Self::Client(error)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ProviderError> for ArtcraftRouterError {
|
||||
fn from(error: ProviderError) -> Self {
|
||||
Self::Provider(error)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
use std::error::Error;
|
||||
use std::fmt::{Display, Formatter};
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum ClientType {
|
||||
Artcraft,
|
||||
}
|
||||
|
||||
impl Display for ClientType {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Artcraft => write!(f, "Artcraft"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ClientError {
|
||||
/// The requested client is not configured on the RouterClient.
|
||||
ClientNotConfigured(ClientType),
|
||||
|
||||
/// The model does not support the given option value.
|
||||
/// `field` is the request field name, `value` is what was requested.
|
||||
ModelDoesNotSupportOption { field: &'static str, value: String },
|
||||
|
||||
/// The caller requested zero generations, which is never valid.
|
||||
UserRequestedZeroGenerations,
|
||||
|
||||
/// ArtCraft only accepts media tokens for image inputs, not raw URLs.
|
||||
ArtcraftOnlySupportsMediaTokens,
|
||||
}
|
||||
|
||||
impl Error for ClientError {}
|
||||
|
||||
impl Display for ClientError {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::ClientNotConfigured(client_type) => {
|
||||
write!(f, "{} client is not configured on the RouterClient", client_type)
|
||||
}
|
||||
Self::ModelDoesNotSupportOption { field, value } => {
|
||||
write!(f, "Model does not support '{}' for field '{}'", value, field)
|
||||
}
|
||||
Self::UserRequestedZeroGenerations => {
|
||||
write!(f, "Cannot request zero generations")
|
||||
}
|
||||
Self::ArtcraftOnlySupportsMediaTokens => {
|
||||
write!(f, "ArtCraft only supports media tokens for image inputs; upload the image first to obtain a media token")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod artcraft_router_error;
|
||||
pub mod client_error;
|
||||
pub mod provider_error;
|
||||
@@ -0,0 +1,24 @@
|
||||
use std::error::Error;
|
||||
use std::fmt::{Display, Formatter};
|
||||
use storyteller_client::error::storyteller_error::StorytellerError;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ProviderError {
|
||||
Storyteller(StorytellerError),
|
||||
}
|
||||
|
||||
impl Error for ProviderError {}
|
||||
|
||||
impl Display for ProviderError {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Storyteller(e) => write!(f, "Storyteller provider error: {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<StorytellerError> for ProviderError {
|
||||
fn from(error: StorytellerError) -> Self {
|
||||
Self::Storyteller(error)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
use crate::api::common_video_model::CommonVideoModel;
|
||||
use crate::api::provider::Provider;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
|
||||
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_seedance2p0::plan_generate_video_artcraft_seedance2p0;
|
||||
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
|
||||
|
||||
/// Read the video generation request, construct a plan, then yield a means to execute it.
|
||||
pub fn begin_video_generation<'a>(
|
||||
request: &'a GenerateVideoRequest<'a>,
|
||||
) -> Result<VideoGenerationPlan<'a>, ArtcraftRouterError> {
|
||||
match request.provider {
|
||||
Provider::Artcraft => match request.model {
|
||||
CommonVideoModel::Seedance2p0 => {
|
||||
plan_generate_video_artcraft_seedance2p0(request).map(VideoGenerationPlan::ArtcraftSeedance2p0)
|
||||
}
|
||||
_ => Err(ArtcraftRouterError::UnsupportedModel(format!("{:?}", request.model))),
|
||||
},
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
use crate::client::router_artcraft_client::RouterArtcraftClient;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::errors::provider_error::ProviderError;
|
||||
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_seedance2p0::PlanArtcraftSeedance2p0;
|
||||
use crate::generate::generate_video::video_generation_plan::GenerateVideoResponse;
|
||||
use artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::Seedance2p0MultiFunctionVideoGenRequest;
|
||||
use storyteller_client::endpoints::generate::video::multi_function::seedance_2p0_multi_function_video_gen::seedance_2p0_multi_function_video_gen;
|
||||
|
||||
pub async fn execute_artcraft_seedance2p0(
|
||||
plan: &PlanArtcraftSeedance2p0<'_>,
|
||||
artcraft_client: &RouterArtcraftClient,
|
||||
) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
|
||||
let request = Seedance2p0MultiFunctionVideoGenRequest {
|
||||
uuid_idempotency_token: plan.idempotency_token.clone(),
|
||||
prompt: plan.prompt.map(|p| p.to_string()),
|
||||
start_frame_media_token: plan.start_frame.map(|t| t.to_owned()),
|
||||
end_frame_media_token: plan.end_frame.map(|t| t.to_owned()),
|
||||
reference_image_media_tokens: plan.reference_images.map(|tokens| tokens.to_owned()),
|
||||
aspect_ratio: plan.aspect_ratio,
|
||||
duration_seconds: plan.duration_seconds,
|
||||
batch_count: Some(plan.batch_count),
|
||||
};
|
||||
|
||||
let response = seedance_2p0_multi_function_video_gen(
|
||||
&artcraft_client.api_host,
|
||||
Some(&artcraft_client.credentials),
|
||||
request,
|
||||
)
|
||||
.await
|
||||
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Storyteller(err)))?;
|
||||
|
||||
Ok(GenerateVideoResponse {
|
||||
inference_job_token: response.inference_job_token,
|
||||
all_inference_job_tokens: response.all_inference_job_tokens,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod generate_video_artcraft_seedance2p0;
|
||||
@@ -0,0 +1 @@
|
||||
pub mod artcraft;
|
||||
@@ -0,0 +1,60 @@
|
||||
use crate::api::common_aspect_ratio::CommonAspectRatio;
|
||||
use crate::api::common_resolution::CommonVideoResolution;
|
||||
use crate::api::common_video_model::CommonVideoModel;
|
||||
use crate::api::image_list_ref::ImageListRef;
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::provider::Provider;
|
||||
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
|
||||
|
||||
pub struct GenerateVideoRequest<'a> {
|
||||
/// Which model to use.
|
||||
pub model: CommonVideoModel,
|
||||
|
||||
/// Which provider to use.
|
||||
pub provider: Provider,
|
||||
|
||||
/// The prompt for the video generation
|
||||
pub prompt: Option<&'a str>,
|
||||
|
||||
// /// Some models support negative prompts
|
||||
// pub negative_prompt: Option<String>,
|
||||
|
||||
/// Starting keyframe (optional).
|
||||
pub start_frame: Option<ImageRef<'a>>,
|
||||
|
||||
/// Ending keyframe (optional).
|
||||
pub end_frame: Option<ImageRef<'a>>,
|
||||
|
||||
/// Reference images (optional).
|
||||
pub reference_images: Option<ImageListRef<'a>>,
|
||||
|
||||
/// The resolution to use
|
||||
pub resolution: Option<CommonVideoResolution>,
|
||||
|
||||
/// The aspect ratio to use
|
||||
pub aspect_ratio: Option<CommonAspectRatio>,
|
||||
|
||||
/// How many seconds to generate.
|
||||
pub duration_seconds: Option<u16>,
|
||||
|
||||
/// How many videos to generate.
|
||||
pub video_batch_count: Option<u16>,
|
||||
|
||||
// /// Whether to turn on/off audio.
|
||||
// /// Not all models support audio, not all models have a choice.
|
||||
// pub generate_audio: Option<bool>,
|
||||
|
||||
/// If the request is a mismatch with the (model/provider), how to mitigate it.
|
||||
pub request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy,
|
||||
|
||||
/// Some providers support idempotency.
|
||||
/// If not supplied, we'll generate one for the required providers.
|
||||
pub idempotency_token: Option<&'a str>,
|
||||
}
|
||||
|
||||
impl <'a> GenerateVideoRequest<'a> {
|
||||
pub fn get_or_generate_idempotency_token(&self) -> String {
|
||||
self.idempotency_token.map(|t| t.to_string())
|
||||
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
pub mod execute;
|
||||
pub mod generate_video_request;
|
||||
pub mod plan;
|
||||
pub mod begin_video_generation;
|
||||
pub mod video_generation_plan;
|
||||
@@ -0,0 +1 @@
|
||||
pub mod plan_generate_video_artcraft_seedance2p0;
|
||||
+183
@@ -0,0 +1,183 @@
|
||||
use crate::api::common_aspect_ratio::CommonAspectRatio;
|
||||
use crate::api::image_list_ref::ImageListRef;
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::errors::client_error::ClientError;
|
||||
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
|
||||
use artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::{
|
||||
Seedance2p0AspectRatio, Seedance2p0BatchCount,
|
||||
};
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PlanArtcraftSeedance2p0<'a> {
|
||||
pub prompt: Option<&'a str>,
|
||||
pub start_frame: Option<&'a MediaFileToken>,
|
||||
pub end_frame: Option<&'a MediaFileToken>,
|
||||
pub reference_images: Option<&'a Vec<MediaFileToken>>,
|
||||
pub aspect_ratio: Option<Seedance2p0AspectRatio>,
|
||||
pub duration_seconds: Option<u8>,
|
||||
pub batch_count: Seedance2p0BatchCount,
|
||||
pub idempotency_token: String,
|
||||
}
|
||||
|
||||
pub fn plan_generate_video_artcraft_seedance2p0<'a>(
|
||||
request: &'a GenerateVideoRequest<'a>,
|
||||
) -> Result<PlanArtcraftSeedance2p0<'a>, ArtcraftRouterError> {
|
||||
let strategy = request.request_mismatch_mitigation_strategy;
|
||||
|
||||
let start_frame = resolve_image_ref(request.start_frame)?;
|
||||
let end_frame = resolve_image_ref(request.end_frame)?;
|
||||
let reference_images = resolve_image_list_ref(request.reference_images)?;
|
||||
|
||||
let aspect_ratio = plan_aspect_ratio(request.aspect_ratio, strategy)?;
|
||||
let batch_count = plan_batch_count(request.video_batch_count, strategy)?;
|
||||
let duration_seconds = plan_duration(request.duration_seconds, strategy)?;
|
||||
|
||||
Ok(PlanArtcraftSeedance2p0 {
|
||||
prompt: request.prompt,
|
||||
start_frame,
|
||||
end_frame,
|
||||
reference_images,
|
||||
aspect_ratio,
|
||||
duration_seconds,
|
||||
batch_count,
|
||||
idempotency_token: request.get_or_generate_idempotency_token(),
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_image_ref<'a>(
|
||||
image_ref: Option<ImageRef<'a>>,
|
||||
) -> Result<Option<&'a MediaFileToken>, ArtcraftRouterError> {
|
||||
match image_ref {
|
||||
None => Ok(None),
|
||||
Some(ImageRef::MediaFileToken(t)) => Ok(Some(t)),
|
||||
Some(ImageRef::Url(_)) => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_image_list_ref<'a>(
|
||||
image_list_ref: Option<ImageListRef<'a>>,
|
||||
) -> Result<Option<&'a Vec<MediaFileToken>>, ArtcraftRouterError> {
|
||||
match image_list_ref {
|
||||
None => Ok(None),
|
||||
Some(ImageListRef::MediaFileTokens(tokens)) => Ok(Some(tokens)),
|
||||
Some(ImageListRef::Urls(_)) => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Supported aspect ratios and their AR values (width / height):
|
||||
// Portrait9x16 = 0.5625, Portrait3x4 = 0.75, Square1x1 = 1.0, Standard4x3 = 1.33, Landscape16x9 = 1.78
|
||||
//
|
||||
// All supported ratios cost the same, so PayMoreUpgrade and PayLessDowngrade both
|
||||
// select the nearest match rather than rounding in a specific direction.
|
||||
fn plan_aspect_ratio(
|
||||
aspect_ratio: Option<CommonAspectRatio>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<Option<Seedance2p0AspectRatio>, ArtcraftRouterError> {
|
||||
match aspect_ratio {
|
||||
// No preference or auto — let the model decide
|
||||
None
|
||||
| Some(CommonAspectRatio::Auto)
|
||||
| Some(CommonAspectRatio::Auto2k)
|
||||
| Some(CommonAspectRatio::Auto4k) => Ok(None),
|
||||
|
||||
// Direct mappings
|
||||
Some(CommonAspectRatio::WideSixteenByNine) | Some(CommonAspectRatio::Wide) => {
|
||||
Ok(Some(Seedance2p0AspectRatio::Landscape16x9))
|
||||
}
|
||||
Some(CommonAspectRatio::TallNineBySixteen) | Some(CommonAspectRatio::Tall) => {
|
||||
Ok(Some(Seedance2p0AspectRatio::Portrait9x16))
|
||||
}
|
||||
Some(CommonAspectRatio::Square) | Some(CommonAspectRatio::SquareHd) => {
|
||||
Ok(Some(Seedance2p0AspectRatio::Square1x1))
|
||||
}
|
||||
Some(CommonAspectRatio::WideFourByThree) => Ok(Some(Seedance2p0AspectRatio::Standard4x3)),
|
||||
Some(CommonAspectRatio::TallThreeByFour) => Ok(Some(Seedance2p0AspectRatio::Portrait3x4)),
|
||||
|
||||
// Mismatches — apply strategy
|
||||
Some(unsupported) => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "aspect_ratio",
|
||||
value: format!("{:?}", unsupported),
|
||||
}))
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade
|
||||
| RequestMismatchMitigationStrategy::PayLessDowngrade => {
|
||||
Ok(Some(nearest_aspect_ratio(unsupported)))
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Pick the nearest supported aspect ratio by AR value (width / height).
|
||||
/// All Seedance2p0 aspect ratios cost the same, so this is used for both upgrade and downgrade.
|
||||
fn nearest_aspect_ratio(aspect_ratio: CommonAspectRatio) -> Seedance2p0AspectRatio {
|
||||
match aspect_ratio {
|
||||
CommonAspectRatio::WideFiveByFour => Seedance2p0AspectRatio::Standard4x3, // 1.25, nearest 1.33
|
||||
CommonAspectRatio::WideThreeByTwo => Seedance2p0AspectRatio::Standard4x3, // 1.50, nearest 1.33
|
||||
CommonAspectRatio::WideTwentyOneByNine => Seedance2p0AspectRatio::Landscape16x9, // 2.33, nearest 1.78
|
||||
CommonAspectRatio::TallFourByFive => Seedance2p0AspectRatio::Portrait3x4, // 0.80, nearest 0.75
|
||||
CommonAspectRatio::TallTwoByThree => Seedance2p0AspectRatio::Portrait3x4, // 0.67, nearest 0.75
|
||||
CommonAspectRatio::TallNineByTwentyOne => Seedance2p0AspectRatio::Portrait9x16, // 0.43, nearest 0.56
|
||||
_ => Seedance2p0AspectRatio::Square1x1,
|
||||
}
|
||||
}
|
||||
|
||||
// Seedance2p0 supports batch counts of 1, 2, and 4 only.
|
||||
fn plan_batch_count(
|
||||
video_batch_count: Option<u16>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<Seedance2p0BatchCount, ArtcraftRouterError> {
|
||||
let count = video_batch_count.unwrap_or(1);
|
||||
match count {
|
||||
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
|
||||
1 => Ok(Seedance2p0BatchCount::One),
|
||||
2 => Ok(Seedance2p0BatchCount::Two),
|
||||
4 => Ok(Seedance2p0BatchCount::Four),
|
||||
_ => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "video_batch_count",
|
||||
value: format!("{}", count),
|
||||
}))
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade => {
|
||||
// Round up: 3 → Four; anything > 4 clamps to Four
|
||||
Ok(if count < 4 { Seedance2p0BatchCount::Four } else { Seedance2p0BatchCount::Four })
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayLessDowngrade => {
|
||||
// Round down: 3 → Two; anything > 4 clamps to Four (max available)
|
||||
Ok(if count < 4 { Seedance2p0BatchCount::Two } else { Seedance2p0BatchCount::Four })
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Seedance2p0 supports duration of 4–15 seconds.
|
||||
fn plan_duration(
|
||||
duration_seconds: Option<u16>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<Option<u8>, ArtcraftRouterError> {
|
||||
const MIN: u16 = 4;
|
||||
const MAX: u16 = 15;
|
||||
match duration_seconds {
|
||||
None => Ok(None),
|
||||
Some(d) if d >= MIN && d <= MAX => Ok(Some(d as u8)),
|
||||
Some(d) => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "duration_seconds",
|
||||
value: format!("{}", d),
|
||||
}))
|
||||
}
|
||||
_ => Ok(Some(d.clamp(MIN, MAX) as u8)),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod artcraft;
|
||||
@@ -0,0 +1,30 @@
|
||||
use crate::client::router_client::RouterClient;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::generate::generate_video::execute::artcraft::generate_video_artcraft_seedance2p0::execute_artcraft_seedance2p0;
|
||||
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_seedance2p0::PlanArtcraftSeedance2p0;
|
||||
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GenerateVideoResponse {
|
||||
pub inference_job_token: InferenceJobToken,
|
||||
pub all_inference_job_tokens: Vec<InferenceJobToken>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum VideoGenerationPlan<'a> {
|
||||
ArtcraftSeedance2p0(PlanArtcraftSeedance2p0<'a>),
|
||||
}
|
||||
|
||||
impl<'a> VideoGenerationPlan<'a> {
|
||||
pub async fn generate_video(
|
||||
&self,
|
||||
client: &RouterClient,
|
||||
) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
|
||||
match self {
|
||||
VideoGenerationPlan::ArtcraftSeedance2p0(plan) => {
|
||||
let artcraft_client = client.get_artcraft_client_ref()?;
|
||||
execute_artcraft_seedance2p0(plan, artcraft_client).await
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod generate_video;
|
||||
@@ -0,0 +1,4 @@
|
||||
pub mod api;
|
||||
pub mod client;
|
||||
pub mod errors;
|
||||
pub mod generate;
|
||||
@@ -0,0 +1,233 @@
|
||||
use artcraft_router::api::common_aspect_ratio::CommonAspectRatio;
|
||||
use artcraft_router::api::common_video_model::CommonVideoModel;
|
||||
use artcraft_router::api::image_ref::ImageRef;
|
||||
use artcraft_router::api::provider::Provider;
|
||||
use artcraft_router::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
|
||||
use artcraft_router::client::router_artcraft_client::RouterArtcraftClient;
|
||||
use artcraft_router::client::router_client::RouterClient;
|
||||
use artcraft_router::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use artcraft_router::errors::client_error::ClientError;
|
||||
use artcraft_router::generate::generate_video::begin_video_generation::begin_video_generation;
|
||||
use artcraft_router::generate::generate_video::generate_video_request::GenerateVideoRequest;
|
||||
use artcraft_router::generate::generate_video::video_generation_plan::VideoGenerationPlan;
|
||||
use artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::{
|
||||
Seedance2p0AspectRatio, Seedance2p0BatchCount,
|
||||
};
|
||||
use storyteller_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
|
||||
use storyteller_client::credentials::storyteller_session_cookie::StorytellerSessionCookie;
|
||||
use storyteller_client::utils::api_host::ApiHost;
|
||||
|
||||
fn get_artcraft_client() -> RouterClient {
|
||||
let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/artcraft_cookies.txt")
|
||||
.expect("Failed to read /Users/bt/Artcraft/credentials/artcraft_cookies.txt");
|
||||
let cookies = cookies.trim().to_string();
|
||||
let credentials = StorytellerCredentialSet::parse_multi_cookie_header(&cookies)
|
||||
.expect("Failed to parse cookies")
|
||||
.expect("No credentials found");
|
||||
RouterClient::Artcraft(RouterArtcraftClient::new(ApiHost::Storyteller, credentials))
|
||||
}
|
||||
|
||||
fn base_request() -> GenerateVideoRequest<'static> {
|
||||
GenerateVideoRequest {
|
||||
model: CommonVideoModel::Seedance2p0,
|
||||
provider: Provider::Artcraft,
|
||||
prompt: Some("a cat in space"),
|
||||
start_frame: None,
|
||||
end_frame: None,
|
||||
reference_images: None,
|
||||
resolution: None,
|
||||
aspect_ratio: None,
|
||||
duration_seconds: None,
|
||||
video_batch_count: None,
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
|
||||
idempotency_token: None,
|
||||
}
|
||||
}
|
||||
|
||||
mod plan_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn aspect_ratio_direct_16x9() {
|
||||
let request = GenerateVideoRequest {
|
||||
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
|
||||
..base_request()
|
||||
};
|
||||
let VideoGenerationPlan::ArtcraftSeedance2p0(plan) = begin_video_generation(&request).unwrap();
|
||||
assert!(matches!(plan.aspect_ratio, Some(Seedance2p0AspectRatio::Landscape16x9)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aspect_ratio_direct_9x16() {
|
||||
let request = GenerateVideoRequest {
|
||||
aspect_ratio: Some(CommonAspectRatio::TallNineBySixteen),
|
||||
..base_request()
|
||||
};
|
||||
let VideoGenerationPlan::ArtcraftSeedance2p0(plan) = begin_video_generation(&request).unwrap();
|
||||
assert!(matches!(plan.aspect_ratio, Some(Seedance2p0AspectRatio::Portrait9x16)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aspect_ratio_direct_square() {
|
||||
let request = GenerateVideoRequest {
|
||||
aspect_ratio: Some(CommonAspectRatio::Square),
|
||||
..base_request()
|
||||
};
|
||||
let VideoGenerationPlan::ArtcraftSeedance2p0(plan) = begin_video_generation(&request).unwrap();
|
||||
assert!(matches!(plan.aspect_ratio, Some(Seedance2p0AspectRatio::Square1x1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aspect_ratio_nearest_match_both_strategies() {
|
||||
// WideThreeByTwo (AR 1.5) is nearest to Standard4x3 (1.33), not Landscape16x9 (1.78)
|
||||
for strategy in [
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade,
|
||||
RequestMismatchMitigationStrategy::PayLessDowngrade,
|
||||
] {
|
||||
let request = GenerateVideoRequest {
|
||||
aspect_ratio: Some(CommonAspectRatio::WideThreeByTwo),
|
||||
request_mismatch_mitigation_strategy: strategy,
|
||||
..base_request()
|
||||
};
|
||||
let VideoGenerationPlan::ArtcraftSeedance2p0(plan) = begin_video_generation(&request).unwrap();
|
||||
assert!(
|
||||
matches!(plan.aspect_ratio, Some(Seedance2p0AspectRatio::Standard4x3)),
|
||||
"Expected Standard4x3 for WideThreeByTwo with {:?}", strategy,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aspect_ratio_error_out_on_unsupported() {
|
||||
let request = GenerateVideoRequest {
|
||||
aspect_ratio: Some(CommonAspectRatio::WideThreeByTwo),
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
|
||||
..base_request()
|
||||
};
|
||||
let result = begin_video_generation(&request);
|
||||
assert!(matches!(
|
||||
result,
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { .. }))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_count_zero_is_always_error() {
|
||||
for strategy in [
|
||||
RequestMismatchMitigationStrategy::ErrorOut,
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade,
|
||||
RequestMismatchMitigationStrategy::PayLessDowngrade,
|
||||
] {
|
||||
let request = GenerateVideoRequest {
|
||||
video_batch_count: Some(0),
|
||||
request_mismatch_mitigation_strategy: strategy,
|
||||
..base_request()
|
||||
};
|
||||
let result = begin_video_generation(&request);
|
||||
assert!(
|
||||
matches!(result, Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations))),
|
||||
"Expected UserRequestedZeroGenerations with {:?}", strategy,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_count_direct_mapping() {
|
||||
let req = GenerateVideoRequest { video_batch_count: Some(1), ..base_request() };
|
||||
let VideoGenerationPlan::ArtcraftSeedance2p0(plan) = begin_video_generation(&req).unwrap();
|
||||
assert!(matches!(plan.batch_count, Seedance2p0BatchCount::One));
|
||||
|
||||
let req = GenerateVideoRequest { video_batch_count: Some(2), ..base_request() };
|
||||
let VideoGenerationPlan::ArtcraftSeedance2p0(plan) = begin_video_generation(&req).unwrap();
|
||||
assert!(matches!(plan.batch_count, Seedance2p0BatchCount::Two));
|
||||
|
||||
let req = GenerateVideoRequest { video_batch_count: Some(4), ..base_request() };
|
||||
let VideoGenerationPlan::ArtcraftSeedance2p0(plan) = begin_video_generation(&req).unwrap();
|
||||
assert!(matches!(plan.batch_count, Seedance2p0BatchCount::Four));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_count_three_upgrade_rounds_to_four() {
|
||||
let request = GenerateVideoRequest {
|
||||
video_batch_count: Some(3),
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
|
||||
..base_request()
|
||||
};
|
||||
let VideoGenerationPlan::ArtcraftSeedance2p0(plan) = begin_video_generation(&request).unwrap();
|
||||
assert!(matches!(plan.batch_count, Seedance2p0BatchCount::Four));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_count_three_downgrade_rounds_to_two() {
|
||||
let request = GenerateVideoRequest {
|
||||
video_batch_count: Some(3),
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayLessDowngrade,
|
||||
..base_request()
|
||||
};
|
||||
let VideoGenerationPlan::ArtcraftSeedance2p0(plan) = begin_video_generation(&request).unwrap();
|
||||
assert!(matches!(plan.batch_count, Seedance2p0BatchCount::Two));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_image_ref_returns_error() {
|
||||
let request = GenerateVideoRequest {
|
||||
start_frame: Some(ImageRef::Url("https://example.com/image.jpg")),
|
||||
..base_request()
|
||||
};
|
||||
let result = begin_video_generation(&request);
|
||||
assert!(matches!(
|
||||
result,
|
||||
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
mod real_requests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually run — fires a real API request and incurs cost
|
||||
async fn test_text_to_video_seedance_2p0() {
|
||||
let client = get_artcraft_client();
|
||||
let request = GenerateVideoRequest {
|
||||
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
|
||||
video_batch_count: Some(1),
|
||||
prompt: Some("a cat walking through a cyberpunk city at night"),
|
||||
..base_request()
|
||||
};
|
||||
|
||||
let plan = begin_video_generation(&request).unwrap();
|
||||
let result = plan.generate_video(&client).await;
|
||||
|
||||
println!("Result: {:?}", result);
|
||||
let response = result.expect("generate_video request failed");
|
||||
println!("Job token: {:?}", response.inference_job_token);
|
||||
println!("All job tokens: {:?}", response.all_inference_job_tokens);
|
||||
|
||||
assert_eq!(1, 2); // NB: Intentional failure to inspect the response above.
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually run — fires a real API request and incurs cost
|
||||
async fn test_text_to_video_seedance_2p0_batch_two() {
|
||||
let client = get_artcraft_client();
|
||||
let request = GenerateVideoRequest {
|
||||
aspect_ratio: Some(CommonAspectRatio::Square),
|
||||
video_batch_count: Some(2),
|
||||
prompt: Some("a dog surfing a wave, cinematic"),
|
||||
..base_request()
|
||||
};
|
||||
|
||||
let plan = begin_video_generation(&request).unwrap();
|
||||
let result = plan.generate_video(&client).await;
|
||||
|
||||
println!("Result: {:?}", result);
|
||||
let response = result.expect("generate_video request failed");
|
||||
println!("Job tokens ({} total):", response.all_inference_job_tokens.len());
|
||||
for token in &response.all_inference_job_tokens {
|
||||
println!(" {:?}", token);
|
||||
}
|
||||
|
||||
assert_eq!(1, 2); // NB: Intentional failure to inspect the response above.
|
||||
}
|
||||
}
|
||||
@@ -22,8 +22,5 @@ workspace-build-acceleration.workspace = true
|
||||
once_cell.workspace = true
|
||||
wreq.workspace = true
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
wreq = { workspace = true, features = ["prefix-symbols"] }
|
||||
|
||||
[dev-dependencies]
|
||||
# (None)
|
||||
|
||||
@@ -16,11 +16,11 @@ path = "src/lib.rs"
|
||||
normal = ["workspace-build-acceleration"]
|
||||
|
||||
[dependencies]
|
||||
# Internal
|
||||
errors = { path = "../errors" }
|
||||
# cargo-hakari workspace hack (faster compile times)
|
||||
workspace-build-acceleration.workspace = true
|
||||
|
||||
# Workspace hack (faster compile times)
|
||||
workspace-build-acceleration = { version = "0.1", path = "../../workspace-build-acceleration" }
|
||||
# Internal
|
||||
errors.workspace = true
|
||||
|
||||
# External
|
||||
# (None yet)
|
||||
|
||||
@@ -52,7 +52,6 @@ email_sender= { path = "../../../lib/email_sender" }
|
||||
enums.workspace = true
|
||||
enums_public.workspace = true
|
||||
errors.workspace = true
|
||||
seedance2pro.workspace = true
|
||||
fal_client.workspace = true
|
||||
filesys.workspace = true
|
||||
google_sign_in = { path = "../../../api_clients/google_sign_in" }
|
||||
@@ -66,7 +65,6 @@ memory_caching = { path = "../../../lib/caching/memory_caching" }
|
||||
migration = { path = "../../../lib/migration" }
|
||||
mimetypes.workspace = true
|
||||
mysql_queries = { path = "../../../schema/database/mysql_queries" }
|
||||
#openai_sora_client = { path = "../../../api_clients/openai_sora_client" }
|
||||
password = { path = "../../../lib/password" }
|
||||
primitives = { path = "../../../lib/data/primitives" }
|
||||
prompt_engineering = { path = "../../../lib/machine_learning/prompt_engineering" }
|
||||
@@ -74,6 +72,7 @@ redis_caching = { path = "../../../lib/caching/redis_caching" }
|
||||
redis_common = { path = "../../../schema/database/redis_common" }
|
||||
redis_schema = { path = "../../../schema/database/redis_schema" }
|
||||
reusable_types = { path = "../../../schema/public/reusable_types" }
|
||||
seedance2pro.workspace = true
|
||||
server_environment = { path = "../../../lib/server_environment" }
|
||||
thumbnail_generator = { path = "../../../api_clients/thumbnail_generator" }
|
||||
tokens.workspace = true
|
||||
@@ -112,10 +111,12 @@ language-tags = "0.3.2" # Parsing single tags richly
|
||||
|
||||
# HTTP
|
||||
# TODO/NB: This is just for Twitch, but we might need it later.
|
||||
reqwest.workspace = true
|
||||
# reqwest.workspace = true
|
||||
wreq.workspace = true
|
||||
|
||||
# Email
|
||||
resend-rs = { version = "0.21.0", default-features = false, features = ["rustls-tls"] }
|
||||
|
||||
# NB: Both ring (via reqwest 0.12/hyper-rustls) and aws-lc-rs (via quinn/resend-rs)
|
||||
# are features of rustls 0.23 in this binary. rustls requires an explicit default
|
||||
# provider when multiple crypto providers are compiled in.
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
use bytes::Bytes;
|
||||
use errors::AnyhowResult;
|
||||
use reqwest::{Client, IntoUrl};
|
||||
use wreq::Client;
|
||||
|
||||
const USER_AGENT: &str = "storyteller-web/1.0";
|
||||
|
||||
// TODO(bt, 2025-06-03): Don't load the entire file into memory!
|
||||
|
||||
/// Downloads a (binary) file to memory. Good for images, etc. Not great for large files.
|
||||
pub async fn http_download_url_to_bytes<U: IntoUrl>(url: U) -> AnyhowResult<Bytes> {
|
||||
pub async fn http_download_url_to_bytes(url: &str) -> AnyhowResult<Bytes> {
|
||||
let client = Client::builder()
|
||||
.gzip(true)
|
||||
.build()?;
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
use crate::util::http_download_url_to_bytes::http_download_url_to_bytes;
|
||||
use errors::AnyhowResult;
|
||||
use reqwest::IntoUrl;
|
||||
use std::io::Write;
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
const USER_AGENT: &str = "storyteller-web/1.0";
|
||||
|
||||
/// Downloads a (binary) file to a filesystem path. Good for images, etc. Not great for large files.
|
||||
pub async fn http_download_url_to_tempfile<U: IntoUrl>(url: U, temp_file: &mut NamedTempFile) -> AnyhowResult<()> {
|
||||
pub async fn http_download_url_to_tempfile(url: &str, temp_file: &mut NamedTempFile) -> AnyhowResult<()> {
|
||||
let bytes = http_download_url_to_bytes(url).await?;
|
||||
temp_file.write_all(&bytes)?;
|
||||
Ok(())
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
faLinux,
|
||||
} from "@fortawesome/free-brands-svg-icons";
|
||||
import { isMobile, isWindows, isMacOs } from "react-device-detect";
|
||||
import { DOWNLOAD_LINKS } from "../../config/downloads";
|
||||
import { DOWNLOAD_LINKS } from "../../config/github_download_links";
|
||||
|
||||
interface DownloadButtonProps {
|
||||
className?: string;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { isMobile, isMacOs } from "react-device-detect";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faWindows, faApple } from "@fortawesome/free-brands-svg-icons";
|
||||
import { faRocket } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { DOWNLOAD_LINKS } from "../config/downloads";
|
||||
import { DOWNLOAD_LINKS } from "../config/github_download_links";
|
||||
|
||||
interface LandingActionButtonsProps {
|
||||
onDownloadClick?: (e: React.MouseEvent) => void;
|
||||
|
||||
+4
-2
@@ -35,8 +35,10 @@
|
||||
export const DOWNLOAD_LINKS = {
|
||||
//WINDOWS: 'https://github.com/storytold/artcraft/releases/download/artcraft-windows-v0.2.0/ArtCraft_0.2.0_x64-setup.exe',
|
||||
//WINDOWS: 'https://github.com/storytold/artcraft/releases/download/artcraft-windows-v0.4.0/ArtCraft_0.4.0_x64-setup.exe', // BROKEN (NX)
|
||||
WINDOWS: 'https://github.com/storytold/artcraft/releases/download/artcraft-windows-v0.5.0/ArtCraft_0.5.0_x64-setup.exe',
|
||||
//WINDOWS: 'https://github.com/storytold/artcraft/releases/download/artcraft-windows-v0.5.0/ArtCraft_0.5.0_x64-setup.exe',
|
||||
WINDOWS: 'https://github.com/storytold/artcraft/releases/download/artcraft-v0.7.0/ArtCraft_0.7.0_x64-setup.exe', // 2026-02-23 Seedance
|
||||
//MACOS: 'https://github.com/storytold/artcraft/releases/download/artcraft-v0.2.0/ArtCraft_0.2.0_universal.dmg',
|
||||
//MACOS: 'https://github.com/storytold/artcraft/releases/download/artcraft-v0.4.0/ArtCraft_0.4.0_universal.dmg', // BROKEN (NX)
|
||||
MACOS: 'https://github.com/storytold/artcraft/releases/download/artcraft-v0.5.0/ArtCraft_0.5.0_universal.dmg',
|
||||
//MACOS: 'https://github.com/storytold/artcraft/releases/download/artcraft-v0.5.0/ArtCraft_0.5.0_universal.dmg',
|
||||
MACOS: 'https://github.com/storytold/artcraft/releases/download/artcraft-v0.7.0/ArtCraft_0.7.0_universal.dmg', // 2026-02-23 Seedance
|
||||
} as const;
|
||||
@@ -9,7 +9,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import { Link, useSearchParams } from "react-router-dom";
|
||||
import { isMobile, isMacOs } from "react-device-detect";
|
||||
import { DOWNLOAD_LINKS } from "../../config/downloads";
|
||||
import { DOWNLOAD_LINKS } from "../../config/github_download_links";
|
||||
import { SOCIAL_LINKS } from "../../config/links";
|
||||
import Seo from "../../components/seo";
|
||||
import { UsersApi } from "@storyteller/api";
|
||||
|
||||
@@ -2,7 +2,7 @@ import { faApple, faWindows } from "@fortawesome/free-brands-svg-icons";
|
||||
import { faArrowDownToLine } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import { DOWNLOAD_LINKS } from "../../config/downloads";
|
||||
import { DOWNLOAD_LINKS } from "../../config/github_download_links";
|
||||
import { isMobile } from "react-device-detect";
|
||||
import Seo from "../../components/seo";
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import { Link } from "react-router-dom";
|
||||
import { isMobile, isMacOs } from "react-device-detect";
|
||||
import { DOWNLOAD_LINKS } from "../../config/downloads";
|
||||
import { DOWNLOAD_LINKS } from "../../config/github_download_links";
|
||||
import Seo from "../../components/seo";
|
||||
import { PricingTable } from "../../components/pricing-table";
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ export const defaultModelForPage = (models: Model[], page: ModelPage): Model =>
|
||||
imageModel = IMAGE_MODELS_BY_ID.get("nano_banana_pro");
|
||||
break;
|
||||
case ModelPage.ImageToVideo:
|
||||
imageModel = VIDEO_MODELS_BY_ID.get("seedance_1_0_lite");
|
||||
imageModel = VIDEO_MODELS_BY_ID.get("seedance_2p0");
|
||||
break;
|
||||
case ModelPage.Canvas2D:
|
||||
imageModel = IMAGE_MODELS_BY_ID.get("gpt_image_1p5");
|
||||
|
||||
@@ -60,6 +60,7 @@ interface ImagePromptRowProps {
|
||||
uploadImage?: UploadImageFn;
|
||||
onImageClick?: (image: RefImage) => void;
|
||||
isVideo?: boolean;
|
||||
isReferenceMode?: boolean;
|
||||
endFrameImage?: RefImage;
|
||||
setEndFrameImage?: (image?: RefImage) => void;
|
||||
allowUploadEnd?: boolean;
|
||||
@@ -77,6 +78,7 @@ export const ImagePromptRow = ({
|
||||
uploadImage,
|
||||
onImageClick,
|
||||
isVideo,
|
||||
isReferenceMode,
|
||||
endFrameImage,
|
||||
setEndFrameImage,
|
||||
allowUploadEnd,
|
||||
@@ -116,7 +118,7 @@ export const ImagePromptRow = ({
|
||||
})
|
||||
);
|
||||
|
||||
const SortableImage = ({ image }: { image: RefImage }) => {
|
||||
const SortableImage = ({ image, index }: { image: RefImage; index: number }) => {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
@@ -151,6 +153,11 @@ export const ImagePromptRow = ({
|
||||
alt="Reference"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
{isReferenceMode && (
|
||||
<div className="absolute bottom-0 left-0 right-0 flex items-center justify-center bg-black/60 py-0.5 text-[11px] font-bold text-white">
|
||||
{index + 1}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
@@ -413,8 +420,12 @@ export const ImagePromptRow = ({
|
||||
<div className="flex items-center gap-2 opacity-90 text-base-fg">
|
||||
<FontAwesomeIcon icon={faImage} className="h-3.5 w-3.5" />
|
||||
<span className="text-sm font-medium flex items-center gap-1.5">
|
||||
{isVideo ? "Starting Frame" : "Image Prompts"}
|
||||
{!isVideo && (
|
||||
{isVideo
|
||||
? isReferenceMode
|
||||
? "Reference Images"
|
||||
: "Starting Frame"
|
||||
: "Image Prompts"}
|
||||
{(!isVideo || isReferenceMode) && (
|
||||
<span className="text-base-fg/60 font-semibold">
|
||||
({usedSlotsRender}/{maxImagePromptCount})
|
||||
</span>
|
||||
@@ -422,7 +433,11 @@ export const ImagePromptRow = ({
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[13px] text-base-fg/60">
|
||||
{isVideo ? "Animate an image" : "Use the elements of an image"}
|
||||
{isVideo
|
||||
? isReferenceMode
|
||||
? "Upload images"
|
||||
: "Animate an image"
|
||||
: "Use the elements of an image"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -441,15 +456,15 @@ export const ImagePromptRow = ({
|
||||
>
|
||||
{referenceImages
|
||||
.slice(0, Math.max(0, maxImagePromptCount))
|
||||
.map((image) => (
|
||||
<SortableImage key={image.id} image={image} />
|
||||
.map((image, index) => (
|
||||
<SortableImage key={image.id} image={image} index={index} />
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
) : (
|
||||
referenceImages
|
||||
.slice(0, Math.max(0, maxImagePromptCount))
|
||||
.map((image) => (
|
||||
.map((image, index) => (
|
||||
<div
|
||||
key={image.id}
|
||||
className="glass relative aspect-square overflow-hidden rounded-lg w-14 border-2 border-white/30 hover:border-white/80 transition-all group cursor-pointer hover:cursor-zoom-in"
|
||||
@@ -460,6 +475,11 @@ export const ImagePromptRow = ({
|
||||
alt="Reference"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
{isReferenceMode && (
|
||||
<div className="absolute bottom-0 left-0 right-0 flex items-center justify-center bg-black/60 py-0.5 text-[11px] font-bold text-white">
|
||||
{index + 1}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
@@ -19,13 +19,7 @@ import {
|
||||
faSpinnerThird,
|
||||
faExpand,
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import {
|
||||
faRectangle,
|
||||
faSquare,
|
||||
faRectangleVertical,
|
||||
} from "@fortawesome/pro-regular-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { IconProp } from "@fortawesome/fontawesome-svg-core";
|
||||
import { CommonAspectRatio, ImageModel } from "@storyteller/model-list";
|
||||
import { usePromptImageStore, RefImage } from "./promptStore";
|
||||
import { gtagEvent } from "@storyteller/google-analytics";
|
||||
@@ -33,6 +27,7 @@ import { twMerge } from "tailwind-merge";
|
||||
import { ImagePromptRow } from "./ImagePromptRow";
|
||||
import { GenerationProvider } from "@storyteller/api-enums";
|
||||
import { AspectRatioPicker } from "./common/AspectRatioPicker";
|
||||
import { AspectRatioIcon } from "./common/AspectRatioIcon";
|
||||
import { GenerationCountPicker } from "./common/GenerationCountPicker";
|
||||
import { ResolutionPicker } from "./common/ResolutionPicker";
|
||||
import { CommonResolution } from "@storyteller/model-list";
|
||||
@@ -134,17 +129,17 @@ export const PromptBoxImage = ({
|
||||
{
|
||||
label: "Wide",
|
||||
selected: aspectRatio === "wide",
|
||||
icon: <FontAwesomeIcon icon={faRectangle} className="h-4 w-4" />,
|
||||
icon: <AspectRatioIcon ratio={[16, 10]} />,
|
||||
},
|
||||
{
|
||||
label: "Tall",
|
||||
selected: aspectRatio === "tall",
|
||||
icon: <FontAwesomeIcon icon={faRectangleVertical} className="h-4 w-4" />,
|
||||
icon: <AspectRatioIcon ratio={[10, 16]} />,
|
||||
},
|
||||
{
|
||||
label: "Square",
|
||||
selected: aspectRatio === "square",
|
||||
icon: <FontAwesomeIcon icon={faSquare} className="h-4 w-4" />,
|
||||
icon: <AspectRatioIcon ratio={[1, 1]} />,
|
||||
},
|
||||
]);
|
||||
const [resolutionList, setResolutionList] = useState<PopoverItem[]>([
|
||||
@@ -319,11 +314,18 @@ export const PromptBoxImage = ({
|
||||
}
|
||||
};
|
||||
|
||||
const getCurrentResolutionIcon = () => {
|
||||
const getCurrentLegacyAspectRatioIcon = () => {
|
||||
const selected = aspectRatioList.find((item) => item.selected);
|
||||
if (!selected || !selected.icon) return faRectangle;
|
||||
const iconElement = selected.icon as React.ReactElement<{ icon: IconProp }>;
|
||||
return iconElement.props.icon;
|
||||
switch (selected?.label?.toLowerCase()) {
|
||||
case "wide":
|
||||
return <AspectRatioIcon ratio={[16, 10]} />;
|
||||
case "tall":
|
||||
return <AspectRatioIcon ratio={[10, 16]} />;
|
||||
case "square":
|
||||
return <AspectRatioIcon ratio={[1, 1]} />;
|
||||
default:
|
||||
return <AspectRatioIcon ratio={[16, 10]} />;
|
||||
}
|
||||
};
|
||||
|
||||
const getCurrentLegacyAspectRatio = (): EnqueueTextToImageSize => {
|
||||
@@ -481,12 +483,7 @@ export const PromptBoxImage = ({
|
||||
mode="toggle"
|
||||
panelTitle="Aspect Ratio (Legacy)"
|
||||
showIconsInList
|
||||
triggerIcon={
|
||||
<FontAwesomeIcon
|
||||
icon={getCurrentResolutionIcon()}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
}
|
||||
triggerIcon={getCurrentLegacyAspectRatioIcon()}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
@@ -16,25 +16,21 @@ import {
|
||||
faSparkles,
|
||||
faSpinnerThird,
|
||||
faWaveformLines,
|
||||
faClock,
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import {faCircleInfo} from "@fortawesome/pro-regular-svg-icons";
|
||||
import {
|
||||
faRectangle,
|
||||
faSquare,
|
||||
faRectangleVertical,
|
||||
} from "@fortawesome/pro-regular-svg-icons";
|
||||
import { faCircleInfo } from "@fortawesome/pro-regular-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { IconProp } from "@fortawesome/fontawesome-svg-core";
|
||||
import { GalleryItem, GalleryModal } from "@storyteller/ui-gallery-modal";
|
||||
import {
|
||||
SizeIconOption,
|
||||
SizeOption,
|
||||
VideoModel,
|
||||
} from "@storyteller/model-list";
|
||||
import { usePromptVideoStore, RefImage } from "./promptStore";
|
||||
import { usePromptVideoStore, RefImage, VideoInputMode } from "./promptStore";
|
||||
import { gtagEvent } from "@storyteller/google-analytics";
|
||||
import { ImagePromptRow } from "./ImagePromptRow";
|
||||
import type { UploadImageFn } from "./ImagePromptRow";
|
||||
import { AspectRatioIcon } from "./common/AspectRatioIcon";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { toast } from "@storyteller/ui-toaster";
|
||||
import { GenerationProvider } from "@storyteller/api-enums";
|
||||
@@ -105,6 +101,12 @@ export const PromptBoxVideo = ({
|
||||
);
|
||||
const resolution = usePromptVideoStore((s) => s.resolution);
|
||||
const setResolution = usePromptVideoStore((s) => s.setResolution);
|
||||
const aspectRatio = usePromptVideoStore((s) => s.aspectRatio);
|
||||
const setAspectRatio = usePromptVideoStore((s) => s.setAspectRatio);
|
||||
const duration = usePromptVideoStore((s) => s.duration);
|
||||
const setDuration = usePromptVideoStore((s) => s.setDuration);
|
||||
const inputMode = usePromptVideoStore((s) => s.inputMode);
|
||||
const setInputMode = usePromptVideoStore((s) => s.setInputMode);
|
||||
const [isEnqueueing, setIsEnqueueing] = useState(false);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const [selectedGalleryImages, setSelectedGalleryImages] = useState<string[]>(
|
||||
@@ -124,68 +126,30 @@ export const PromptBoxVideo = ({
|
||||
uploadingImages.length > 0;
|
||||
|
||||
// TODO: Get rid of default resolutions. Just disable it if not present.
|
||||
let resolutionOptions: PopoverItem[];
|
||||
let aspectRatioOptions: PopoverItem[];
|
||||
|
||||
const buildAspectRatioOptions = (options: SizeOption[]): PopoverItem[] => {
|
||||
const currentExists = options.some(
|
||||
(option) => option.textLabel === aspectRatio,
|
||||
);
|
||||
const useFirstOption = !currentExists;
|
||||
|
||||
return options.map((option, index) => ({
|
||||
label: option.textLabel,
|
||||
selected:
|
||||
option.textLabel === aspectRatio || (useFirstOption && index === 0),
|
||||
icon: <AspectRatioIcon sizeIcon={option.icon} />,
|
||||
}));
|
||||
};
|
||||
|
||||
if (!!selectedModel?.sizeOptions && selectedModel.sizeOptions.length > 0) {
|
||||
// When switching to a new model, the existing resolution might not be correct.
|
||||
// This is a gross and nasty hack to handle the case where the resolution is not found.
|
||||
const resolutionExists = selectedModel.sizeOptions.some(
|
||||
(option) => option.textLabel === resolution,
|
||||
);
|
||||
const useFirstOption = !resolutionExists;
|
||||
|
||||
resolutionOptions = selectedModel.sizeOptions.map((option, index) => {
|
||||
let faIcon = faRectangle;
|
||||
switch (option.icon) {
|
||||
case SizeIconOption.Landscape:
|
||||
faIcon = faRectangle;
|
||||
break;
|
||||
case SizeIconOption.Portrait:
|
||||
faIcon = faRectangleVertical;
|
||||
break;
|
||||
case SizeIconOption.Square:
|
||||
faIcon = faSquare;
|
||||
break;
|
||||
}
|
||||
const icon = <FontAwesomeIcon icon={faIcon} className="h-4 w-4" />;
|
||||
return {
|
||||
label: option.textLabel,
|
||||
selected:
|
||||
option.textLabel === resolution || (useFirstOption && index === 0),
|
||||
icon: icon,
|
||||
};
|
||||
});
|
||||
aspectRatioOptions = buildAspectRatioOptions(selectedModel.sizeOptions);
|
||||
} else {
|
||||
const resolutionExists = DEFAULT_RESOLUTIONS.some(
|
||||
(option) => option.textLabel === resolution,
|
||||
);
|
||||
const useFirstOption = !resolutionExists;
|
||||
|
||||
resolutionOptions = DEFAULT_RESOLUTIONS.map((option, index) => {
|
||||
let faIcon = faRectangle;
|
||||
switch (option.icon) {
|
||||
case SizeIconOption.Landscape:
|
||||
faIcon = faRectangle;
|
||||
break;
|
||||
case SizeIconOption.Portrait:
|
||||
faIcon = faRectangleVertical;
|
||||
break;
|
||||
case SizeIconOption.Square:
|
||||
faIcon = faSquare;
|
||||
break;
|
||||
}
|
||||
const icon = <FontAwesomeIcon icon={faIcon} className="h-4 w-4" />;
|
||||
return {
|
||||
label: option.textLabel,
|
||||
selected:
|
||||
option.textLabel === resolution || (useFirstOption && index === 0),
|
||||
icon: icon,
|
||||
};
|
||||
});
|
||||
aspectRatioOptions = buildAspectRatioOptions(DEFAULT_RESOLUTIONS);
|
||||
}
|
||||
|
||||
const [resolutionList, setResolutionList] =
|
||||
useState<PopoverItem[]>(resolutionOptions);
|
||||
const [aspectRatioList, setAspectRatioList] =
|
||||
useState<PopoverItem[]>(aspectRatioOptions);
|
||||
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
@@ -212,17 +176,174 @@ export const PromptBoxVideo = ({
|
||||
onImageRowVisibilityChange?.(isImageRowVisible);
|
||||
}, [isImageRowVisible, onImageRowVisibilityChange]);
|
||||
|
||||
const handleResolutionSelect = (selectedItem: PopoverItem) => {
|
||||
console.log(">>>> handleResolutionSelect", selectedItem);
|
||||
setResolution(selectedItem.label as any);
|
||||
setResolutionList((prev) =>
|
||||
resolutionOptions.map((item) => ({
|
||||
const handleAspectRatioSelect = (selectedItem: PopoverItem) => {
|
||||
setAspectRatio(selectedItem.label);
|
||||
setAspectRatioList((prev) =>
|
||||
aspectRatioOptions.map((item) => ({
|
||||
...item,
|
||||
selected: item.label === selectedItem.label,
|
||||
})),
|
||||
);
|
||||
};
|
||||
|
||||
// Sync duration with model default when switching models
|
||||
useEffect(() => {
|
||||
if (selectedModel?.durationOptions && selectedModel.defaultDuration) {
|
||||
if (
|
||||
duration === null ||
|
||||
!selectedModel.durationOptions.includes(duration)
|
||||
) {
|
||||
setDuration(selectedModel.defaultDuration);
|
||||
}
|
||||
} else if (duration !== null) {
|
||||
setDuration(null);
|
||||
}
|
||||
}, [selectedModel]);
|
||||
|
||||
// Sync resolution with model default when switching models
|
||||
useEffect(() => {
|
||||
if (selectedModel?.resolutionOptions && selectedModel.defaultResolution) {
|
||||
if (!selectedModel.resolutionOptions.includes(resolution as string)) {
|
||||
setResolution(selectedModel.defaultResolution);
|
||||
}
|
||||
}
|
||||
}, [selectedModel]);
|
||||
|
||||
// Reset input mode when switching to a model that doesn't support reference
|
||||
useEffect(() => {
|
||||
if (!selectedModel?.supportsReferenceMode && inputMode === "reference") {
|
||||
setInputMode("keyframe");
|
||||
}
|
||||
}, [selectedModel]);
|
||||
|
||||
const durationOptions: PopoverItem[] | null = selectedModel?.durationOptions
|
||||
? selectedModel.durationOptions.map((d) => ({
|
||||
label: `${d}s`,
|
||||
selected: d === (duration ?? selectedModel.defaultDuration),
|
||||
}))
|
||||
: null;
|
||||
|
||||
const resolutionPickerOptions: PopoverItem[] | null =
|
||||
selectedModel?.resolutionOptions
|
||||
? selectedModel.resolutionOptions.map((r) => ({
|
||||
label: r,
|
||||
selected: r === resolution,
|
||||
}))
|
||||
: null;
|
||||
|
||||
const handleResolutionSelect = (selectedItem: PopoverItem) => {
|
||||
setResolution(selectedItem.label);
|
||||
};
|
||||
|
||||
const handleDurationSelect = (selectedItem: PopoverItem) => {
|
||||
const seconds = parseInt(selectedItem.label);
|
||||
setDuration(seconds);
|
||||
};
|
||||
|
||||
const inputModeOptions: PopoverItem[] | null =
|
||||
selectedModel?.supportsReferenceMode
|
||||
? [
|
||||
{
|
||||
label: "Keyframe",
|
||||
description: "First/Last frame",
|
||||
selected: inputMode === "keyframe",
|
||||
},
|
||||
{
|
||||
label: "Reference",
|
||||
description: "Multi-media ref",
|
||||
selected: inputMode === "reference",
|
||||
},
|
||||
]
|
||||
: null;
|
||||
|
||||
const handleInputModeSelect = (selectedItem: PopoverItem) => {
|
||||
const mode: VideoInputMode =
|
||||
selectedItem.label === "Reference" ? "reference" : "keyframe";
|
||||
setInputMode(mode);
|
||||
// Clear images when switching modes to avoid stale state
|
||||
if (mode === "reference") {
|
||||
setEndFrameImage(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
const isReferenceMode =
|
||||
inputMode === "reference" && !!selectedModel?.supportsReferenceMode;
|
||||
const maxImageCount = isReferenceMode
|
||||
? (selectedModel?.maxReferenceImages ?? 3)
|
||||
: 1;
|
||||
|
||||
const highlightRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Sync scroll between textarea and highlight overlay
|
||||
const handleScroll = () => {
|
||||
if (highlightRef.current && textareaRef.current) {
|
||||
highlightRef.current.scrollTop = textareaRef.current.scrollTop;
|
||||
}
|
||||
};
|
||||
|
||||
// Color palette for @Image mentions
|
||||
const MENTION_COLORS = [
|
||||
"rgb(96, 165, 250)", // blue
|
||||
"rgb(251, 146, 60)", // orange
|
||||
"rgb(167, 139, 250)", // purple
|
||||
"rgb(52, 211, 153)", // green
|
||||
"rgb(251, 113, 133)", // pink
|
||||
];
|
||||
|
||||
const renderHighlightedPrompt = () => {
|
||||
if (!isReferenceMode || referenceImages.length === 0) return null;
|
||||
const parts = prompt.split(/(@Image\d+)/g);
|
||||
return parts.map((part, i) => {
|
||||
const match = part.match(/^@Image(\d+)$/);
|
||||
if (match) {
|
||||
const imgIndex = parseInt(match[1]) - 1;
|
||||
const color = MENTION_COLORS[imgIndex % MENTION_COLORS.length];
|
||||
return (
|
||||
<span key={i} style={{ color, fontWeight: 600 }}>
|
||||
{part}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return <span key={i}>{part}</span>;
|
||||
});
|
||||
};
|
||||
|
||||
// @-mention autocomplete state
|
||||
const [mentionOpen, setMentionOpen] = useState(false);
|
||||
const [mentionFilter, setMentionFilter] = useState("");
|
||||
const [mentionIndex, setMentionIndex] = useState(0);
|
||||
const mentionAnchorRef = useRef<number | null>(null);
|
||||
|
||||
const mentionItems = isReferenceMode
|
||||
? referenceImages
|
||||
.map((img, i) => ({
|
||||
label: `@Image${i + 1}`,
|
||||
image: img,
|
||||
}))
|
||||
.filter((item) =>
|
||||
mentionFilter
|
||||
? item.label.toLowerCase().includes(mentionFilter.toLowerCase())
|
||||
: true,
|
||||
)
|
||||
: [];
|
||||
|
||||
const insertMention = (label: string) => {
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea || mentionAnchorRef.current === null) return;
|
||||
const before = prompt.slice(0, mentionAnchorRef.current);
|
||||
const after = prompt.slice(textarea.selectionStart);
|
||||
const next = before + label + " " + after;
|
||||
setPrompt(next);
|
||||
setMentionOpen(false);
|
||||
setMentionFilter("");
|
||||
mentionAnchorRef.current = null;
|
||||
requestAnimationFrame(() => {
|
||||
const pos = before.length + label.length + 1;
|
||||
textarea.setSelectionRange(pos, pos);
|
||||
textarea.focus();
|
||||
});
|
||||
};
|
||||
|
||||
const handlePaste = (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
||||
e.preventDefault();
|
||||
const pastedText = e.clipboardData.getData("text");
|
||||
@@ -238,7 +359,31 @@ export const PromptBoxVideo = ({
|
||||
};
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setPrompt(e.target.value);
|
||||
const value = e.target.value;
|
||||
const cursorPos = e.target.selectionStart;
|
||||
setPrompt(value);
|
||||
|
||||
if (isReferenceMode && referenceImages.length > 0) {
|
||||
// Find the last '@' before cursor that could be a mention trigger
|
||||
const textBeforeCursor = value.slice(0, cursorPos);
|
||||
const lastAtIndex = textBeforeCursor.lastIndexOf("@");
|
||||
|
||||
if (lastAtIndex !== -1) {
|
||||
const textAfterAt = textBeforeCursor.slice(lastAtIndex + 1);
|
||||
// Only trigger if no space after @ (still typing the mention)
|
||||
if (!textAfterAt.includes(" ")) {
|
||||
mentionAnchorRef.current = lastAtIndex;
|
||||
setMentionFilter("@" + textAfterAt);
|
||||
setMentionOpen(true);
|
||||
setMentionIndex(0);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setMentionOpen(false);
|
||||
setMentionFilter("");
|
||||
mentionAnchorRef.current = null;
|
||||
};
|
||||
|
||||
const handleEnqueue = async () => {
|
||||
@@ -268,9 +413,12 @@ export const PromptBoxVideo = ({
|
||||
? crypto.randomUUID()
|
||||
: Math.random().toString(36).slice(2);
|
||||
|
||||
const isRefMode =
|
||||
inputMode === "reference" && !!selectedModel.supportsReferenceMode;
|
||||
|
||||
let imageMediaToken = undefined;
|
||||
|
||||
if (referenceImages.length > 0) {
|
||||
if (!isRefMode && referenceImages.length > 0) {
|
||||
imageMediaToken = referenceImages[0].mediaToken;
|
||||
}
|
||||
|
||||
@@ -285,7 +433,9 @@ export const PromptBoxVideo = ({
|
||||
model: selectedModel,
|
||||
image_media_token: imageMediaToken,
|
||||
prompt: prompt,
|
||||
end_frame_image_media_token: endFrameImage?.mediaToken,
|
||||
end_frame_image_media_token: isRefMode
|
||||
? undefined
|
||||
: endFrameImage?.mediaToken,
|
||||
frontend_caller: "image_to_video",
|
||||
frontend_subscriber_id: subscriberId,
|
||||
};
|
||||
@@ -298,6 +448,18 @@ export const PromptBoxVideo = ({
|
||||
request.generate_audio = !!generateWithSound;
|
||||
}
|
||||
|
||||
// Pass reference image tokens in reference mode
|
||||
if (isRefMode && referenceImages.length > 0) {
|
||||
request.reference_image_media_tokens = referenceImages.map(
|
||||
(img) => img.mediaToken,
|
||||
);
|
||||
}
|
||||
|
||||
// Pass duration if model supports it
|
||||
if (selectedModel.durationOptions && duration !== null) {
|
||||
request.duration_seconds = duration;
|
||||
}
|
||||
|
||||
switch (selectedModel?.tauriId) {
|
||||
case "grok_video":
|
||||
request.grok_aspect_ratio = getGrokAspectRatio();
|
||||
@@ -307,6 +469,17 @@ export const PromptBoxVideo = ({
|
||||
request.sora_orientation =
|
||||
resolution === "720p" ? "landscape" : "portrait";
|
||||
break;
|
||||
|
||||
case "seedance_2p0": {
|
||||
const selectedOption = selectedModel.sizeOptions?.find(
|
||||
(option) => option.textLabel === aspectRatio,
|
||||
);
|
||||
if (selectedOption) {
|
||||
request.aspect_ratio =
|
||||
selectedOption.tauriValue as typeof request.aspect_ratio;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
await EnqueueImageToVideo(request);
|
||||
@@ -316,14 +489,40 @@ export const PromptBoxVideo = ({
|
||||
setIsEnqueueing(false);
|
||||
};
|
||||
|
||||
const getCurrentResolutionIcon = () => {
|
||||
const selected = resolutionList.find((item) => item.selected);
|
||||
if (!selected || !selected.icon) return faRectangle;
|
||||
const iconElement = selected.icon as React.ReactElement<{ icon: IconProp }>;
|
||||
return iconElement.props.icon;
|
||||
const getCurrentAspectRatioIcon = (): SizeIconOption => {
|
||||
const selectedLabel = aspectRatioList.find((item) => item.selected)?.label;
|
||||
const allOptions = selectedModel?.sizeOptions ?? DEFAULT_RESOLUTIONS;
|
||||
const match = allOptions.find((o) => o.textLabel === selectedLabel);
|
||||
return match?.icon ?? SizeIconOption.Landscape;
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
// Handle mention dropdown navigation
|
||||
if (mentionOpen && mentionItems.length > 0) {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setMentionIndex((prev) => (prev + 1) % mentionItems.length);
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setMentionIndex((prev) =>
|
||||
prev <= 0 ? mentionItems.length - 1 : prev - 1,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (e.key === "Enter" || e.key === "Tab") {
|
||||
e.preventDefault();
|
||||
insertMention(mentionItems[mentionIndex].label);
|
||||
return;
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
setMentionOpen(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -343,7 +542,7 @@ export const PromptBoxVideo = ({
|
||||
// NB: This function was just written to give us better type safety.
|
||||
// There has to be a cleaner appraoach.
|
||||
const maybeAspectRatio = selectedModel?.sizeOptions?.find(
|
||||
(option) => option.textLabel === resolution,
|
||||
(option) => option.textLabel === aspectRatio,
|
||||
)?.tauriValue;
|
||||
|
||||
switch (maybeAspectRatio) {
|
||||
@@ -386,7 +585,8 @@ export const PromptBoxVideo = ({
|
||||
<ImagePromptRow
|
||||
visible={true}
|
||||
isVideo={true}
|
||||
maxImagePromptCount={1}
|
||||
isReferenceMode={isReferenceMode}
|
||||
maxImagePromptCount={maxImageCount}
|
||||
allowUpload={true}
|
||||
referenceImages={referenceImages}
|
||||
setReferenceImages={setReferenceImages}
|
||||
@@ -401,10 +601,10 @@ export const PromptBoxVideo = ({
|
||||
setIsModalOpen(true);
|
||||
}}
|
||||
uploadImage={uploadImage}
|
||||
endFrameImage={endFrameImage}
|
||||
setEndFrameImage={setEndFrameImage}
|
||||
allowUploadEnd={!!selectedModel?.endFrame}
|
||||
showEndFrameSection={!!selectedModel?.endFrame}
|
||||
endFrameImage={isReferenceMode ? undefined : endFrameImage}
|
||||
setEndFrameImage={isReferenceMode ? undefined : setEndFrameImage}
|
||||
allowUploadEnd={!isReferenceMode && !!selectedModel?.endFrame}
|
||||
showEndFrameSection={!isReferenceMode && !!selectedModel?.endFrame}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
@@ -416,7 +616,38 @@ export const PromptBoxVideo = ({
|
||||
: "ring-1 ring-transparent",
|
||||
)}
|
||||
>
|
||||
<div className="flex justify-center gap-2">
|
||||
<div className="relative flex justify-center gap-2">
|
||||
{/* @-mention autocomplete dropdown */}
|
||||
{mentionOpen && mentionItems.length > 0 && (
|
||||
<div className="absolute bottom-full left-0 z-50 mb-1 w-64 overflow-hidden rounded-lg border border-white/10 bg-ui-controls shadow-lg backdrop-blur-xl">
|
||||
<div className="px-3 py-1.5 text-[11px] font-semibold uppercase tracking-wider text-base-fg/50">
|
||||
Reference Files
|
||||
</div>
|
||||
{mentionItems.map((item, i) => (
|
||||
<button
|
||||
key={item.label}
|
||||
className={twMerge(
|
||||
"flex w-full items-center gap-2.5 px-3 py-2 text-sm text-base-fg transition-colors cursor-pointer",
|
||||
i === mentionIndex ? "bg-white/10" : "hover:bg-white/5",
|
||||
)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
insertMention(item.label);
|
||||
}}
|
||||
onMouseEnter={() => setMentionIndex(i)}
|
||||
>
|
||||
<div className="h-8 w-8 flex-shrink-0 overflow-hidden rounded-md border border-white/20">
|
||||
<img
|
||||
src={item.image.url}
|
||||
alt={item.label}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<span className="font-medium">{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* Hide the Add image button for video for now */}
|
||||
{/* <Tooltip
|
||||
content="Add Image"
|
||||
@@ -448,59 +679,114 @@ export const PromptBoxVideo = ({
|
||||
</Button>
|
||||
</Tooltip> */}
|
||||
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
rows={1}
|
||||
placeholder="Describe what you want to happen in the video..."
|
||||
className="text-md mb-2 max-h-[5.5em] flex-1 resize-none overflow-y-auto rounded bg-transparent pb-2 pr-2 pt-1 text-base-fg placeholder-base-fg/60 focus:outline-none"
|
||||
value={prompt}
|
||||
onChange={handleChange}
|
||||
onPaste={handlePaste}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
/>
|
||||
<div className="relative flex-1">
|
||||
{isReferenceMode && referenceImages.length > 0 && (
|
||||
<div
|
||||
ref={highlightRef}
|
||||
aria-hidden
|
||||
className="text-md pointer-events-none absolute inset-0 max-h-[5.5em] overflow-y-auto whitespace-pre-wrap break-words rounded pb-2 pr-2 pt-1 text-base-fg"
|
||||
>
|
||||
{renderHighlightedPrompt()}
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
rows={1}
|
||||
placeholder={
|
||||
isReferenceMode
|
||||
? "Use @Image1, @Image2... to reference your uploaded images in the prompt..."
|
||||
: "Describe what you want to happen in the video..."
|
||||
}
|
||||
className={twMerge(
|
||||
"text-md relative mb-2 max-h-[5.5em] w-full resize-none overflow-y-auto rounded bg-transparent pb-2 pr-2 pt-1 placeholder-base-fg/60 focus:outline-none",
|
||||
isReferenceMode && referenceImages.length > 0
|
||||
? "text-transparent caret-base-fg"
|
||||
: "text-base-fg",
|
||||
)}
|
||||
value={prompt}
|
||||
onChange={handleChange}
|
||||
onPaste={handlePaste}
|
||||
onKeyDown={handleKeyDown}
|
||||
onScroll={handleScroll}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Tooltip
|
||||
content="Resolution"
|
||||
content="Aspect Ratio"
|
||||
position="top"
|
||||
className="z-50"
|
||||
closeOnClick={true}
|
||||
>
|
||||
<PopoverMenu
|
||||
items={resolutionOptions}
|
||||
onSelect={handleResolutionSelect}
|
||||
items={aspectRatioOptions}
|
||||
onSelect={handleAspectRatioSelect}
|
||||
mode="toggle"
|
||||
panelTitle="Resolution"
|
||||
panelTitle="Aspect Ratio"
|
||||
showIconsInList
|
||||
triggerIcon={
|
||||
<FontAwesomeIcon
|
||||
icon={getCurrentResolutionIcon()}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
<AspectRatioIcon sizeIcon={getCurrentAspectRatioIcon()} />
|
||||
}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip
|
||||
content={
|
||||
useSystemPrompt
|
||||
? "Use system prompt: ON"
|
||||
: "Use system prompt: OFF"
|
||||
}
|
||||
position="top"
|
||||
className="z-50"
|
||||
delay={200}
|
||||
>
|
||||
<ToggleButton
|
||||
isActive={useSystemPrompt}
|
||||
icon={faMessageXmark}
|
||||
activeIcon={faMessageCheck}
|
||||
onClick={() => setUseSystemPrompt(!useSystemPrompt)}
|
||||
/>
|
||||
</Tooltip>
|
||||
{resolutionPickerOptions && (
|
||||
<Tooltip
|
||||
content="Resolution"
|
||||
position="top"
|
||||
className="z-50"
|
||||
closeOnClick={true}
|
||||
>
|
||||
<PopoverMenu
|
||||
items={resolutionPickerOptions}
|
||||
onSelect={handleResolutionSelect}
|
||||
mode="toggle"
|
||||
panelTitle="Resolution"
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{durationOptions && (
|
||||
<Tooltip
|
||||
content="Duration"
|
||||
position="top"
|
||||
className="z-50"
|
||||
closeOnClick={true}
|
||||
>
|
||||
<PopoverMenu
|
||||
items={durationOptions}
|
||||
onSelect={handleDurationSelect}
|
||||
mode="toggle"
|
||||
panelTitle="Duration"
|
||||
triggerIcon={
|
||||
<FontAwesomeIcon icon={faClock} className="h-3.5 w-3.5" />
|
||||
}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{selectedModel?.supportsSystemPrompt !== false && (
|
||||
<Tooltip
|
||||
content={
|
||||
useSystemPrompt
|
||||
? "Use system prompt: ON"
|
||||
: "Use system prompt: OFF"
|
||||
}
|
||||
position="top"
|
||||
className="z-50"
|
||||
delay={200}
|
||||
>
|
||||
<ToggleButton
|
||||
isActive={useSystemPrompt}
|
||||
icon={faMessageXmark}
|
||||
activeIcon={faMessageCheck}
|
||||
onClick={() => setUseSystemPrompt(!useSystemPrompt)}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{selectedModel?.generateWithSound && (
|
||||
<Tooltip
|
||||
@@ -517,6 +803,22 @@ export const PromptBoxVideo = ({
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{inputModeOptions && (
|
||||
<Tooltip
|
||||
content="Input Mode"
|
||||
position="top"
|
||||
className="z-50"
|
||||
closeOnClick={true}
|
||||
>
|
||||
<PopoverMenu
|
||||
items={inputModeOptions}
|
||||
onSelect={handleInputModeSelect}
|
||||
mode="toggle"
|
||||
panelTitle="Input Mode"
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{modelNeedsAnImageButNoneAreSelected && (
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { CommonAspectRatio } from "@storyteller/model-list";
|
||||
import { SizeIconOption } from "@storyteller/model-list";
|
||||
|
||||
/**
|
||||
* Renders a proportionally-sized rounded rectangle SVG icon
|
||||
* that visually represents the actual shape of an aspect ratio.
|
||||
*
|
||||
* Supports three input modes:
|
||||
* - `ratio` — raw [width, height] tuple
|
||||
* - `commonAspectRatio` — CommonAspectRatio enum (image models)
|
||||
* - `sizeIcon` — SizeIconOption enum (video models)
|
||||
*/
|
||||
export const AspectRatioIcon = ({
|
||||
ratio,
|
||||
commonAspectRatio,
|
||||
sizeIcon,
|
||||
size = 16,
|
||||
}: {
|
||||
ratio?: [number, number];
|
||||
commonAspectRatio?: CommonAspectRatio;
|
||||
sizeIcon?: SizeIconOption;
|
||||
size?: number;
|
||||
}) => {
|
||||
let rw: number;
|
||||
let rh: number;
|
||||
|
||||
if (ratio) {
|
||||
[rw, rh] = ratio;
|
||||
} else if (commonAspectRatio !== undefined) {
|
||||
[rw, rh] = commonAspectRatioToProportions(commonAspectRatio);
|
||||
} else if (sizeIcon !== undefined) {
|
||||
[rw, rh] = sizeIconToProportions(sizeIcon);
|
||||
} else {
|
||||
[rw, rh] = [16, 10];
|
||||
}
|
||||
|
||||
const scale = (size - 2) / Math.max(rw, rh);
|
||||
const w = Math.round(rw * scale);
|
||||
const h = Math.round(rh * scale);
|
||||
const x = (size - w) / 2;
|
||||
const y = (size - h) / 2;
|
||||
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox={`0 0 ${size} ${size}`}
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<rect
|
||||
x={x}
|
||||
y={y}
|
||||
width={w}
|
||||
height={h}
|
||||
rx={1.5}
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
// "Auto" icon — magic wand sparkle, kept as SVG to avoid FontAwesome dependency
|
||||
export const AutoIcon = ({ size = 16 }: { size?: number }) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M8 1l1.2 3.3L12.5 5.5l-3.3 1.2L8 10l-1.2-3.3L3.5 5.5l3.3-1.2L8 1z"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.2}
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M12 9l.6 1.7 1.7.6-1.7.6-.6 1.7-.6-1.7L9.7 11.3l1.7-.6L12 9z"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1}
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
function commonAspectRatioToProportions(
|
||||
ratio: CommonAspectRatio,
|
||||
): [number, number] {
|
||||
switch (ratio) {
|
||||
// Square
|
||||
case CommonAspectRatio.Square:
|
||||
case CommonAspectRatio.SquareHd:
|
||||
return [1, 1];
|
||||
|
||||
// Wide
|
||||
case CommonAspectRatio.Wide:
|
||||
return [16, 10];
|
||||
case CommonAspectRatio.WideFiveByFour:
|
||||
return [5, 4];
|
||||
case CommonAspectRatio.WideFourByThree:
|
||||
return [4, 3];
|
||||
case CommonAspectRatio.WideThreeByTwo:
|
||||
return [3, 2];
|
||||
case CommonAspectRatio.WideSixteenByNine:
|
||||
return [16, 9];
|
||||
case CommonAspectRatio.WideTwentyOneByNine:
|
||||
return [21, 9];
|
||||
|
||||
// Tall
|
||||
case CommonAspectRatio.Tall:
|
||||
return [10, 16];
|
||||
case CommonAspectRatio.TallFourByFive:
|
||||
return [4, 5];
|
||||
case CommonAspectRatio.TallThreeByFour:
|
||||
return [3, 4];
|
||||
case CommonAspectRatio.TallTwoByThree:
|
||||
return [2, 3];
|
||||
case CommonAspectRatio.TallNineBySixteen:
|
||||
return [9, 16];
|
||||
case CommonAspectRatio.TallNineByTwentyOne:
|
||||
return [9, 21];
|
||||
|
||||
// Auto
|
||||
case CommonAspectRatio.Auto:
|
||||
case CommonAspectRatio.Auto2k:
|
||||
case CommonAspectRatio.Auto4k:
|
||||
return [1, 1]; // fallback, Auto uses the sparkle icon instead
|
||||
|
||||
default:
|
||||
return [1, 1];
|
||||
}
|
||||
}
|
||||
|
||||
function sizeIconToProportions(icon: SizeIconOption): [number, number] {
|
||||
switch (icon) {
|
||||
case SizeIconOption.Landscape16x9:
|
||||
return [16, 9];
|
||||
case SizeIconOption.Landscape:
|
||||
return [16, 10];
|
||||
case SizeIconOption.Standard4x3:
|
||||
return [4, 3];
|
||||
case SizeIconOption.Square:
|
||||
return [1, 1];
|
||||
case SizeIconOption.Portrait3x4:
|
||||
return [3, 4];
|
||||
case SizeIconOption.Portrait:
|
||||
return [10, 16];
|
||||
case SizeIconOption.Portrait9x16:
|
||||
return [9, 16];
|
||||
default:
|
||||
return [16, 10];
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,23 @@
|
||||
import { faMagicWandSparkles, faRectangle, faRectangleVertical, faRectangleWide, faSquare, IconDefinition } from "@fortawesome/pro-regular-svg-icons"
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"
|
||||
import { PopoverItem, PopoverMenu } from "@storyteller/ui-popover"
|
||||
import { Tooltip } from "@storyteller/ui-tooltip"
|
||||
import { ImageModel } from "@storyteller/model-list"
|
||||
import { CommonAspectRatio } from "@storyteller/model-list"
|
||||
|
||||
import { CommonAspectRatio } from "@storyteller/model-list";
|
||||
import { PopoverItem, PopoverMenu } from "@storyteller/ui-popover";
|
||||
import { Tooltip } from "@storyteller/ui-tooltip";
|
||||
import { ImageModel } from "@storyteller/model-list";
|
||||
import { AspectRatioIcon, AutoIcon } from "./AspectRatioIcon";
|
||||
|
||||
interface AspectRatioPickerProps {
|
||||
model: ImageModel,
|
||||
currentAspectRatio?: CommonAspectRatio,
|
||||
model: ImageModel;
|
||||
currentAspectRatio?: CommonAspectRatio;
|
||||
handleCommonAspectRatioSelect: (selected: CommonAspectRatio) => void;
|
||||
//model: ImageModel | VideoModel
|
||||
}
|
||||
|
||||
/**
|
||||
* Stateless component.
|
||||
*
|
||||
* Picker for "common aspect ratios", the new data structure Tauri accepts for
|
||||
* all image and video models (Note: this is not fully rolled out yet. Some/most
|
||||
*
|
||||
* Picker for "common aspect ratios", the new data structure Tauri accepts for
|
||||
* all image and video models (Note: this is not fully rolled out yet. Some/most
|
||||
* models may still use the old format.)
|
||||
*
|
||||
*
|
||||
* @param model - currently selected model
|
||||
* @param currentAspectRatio - currently selected aspect ratio
|
||||
* @param handleCommonAspectRatioSelect - callback when an aspect ratio is selected
|
||||
@@ -29,32 +27,38 @@ export const AspectRatioPicker = ({
|
||||
currentAspectRatio,
|
||||
handleCommonAspectRatioSelect,
|
||||
}: AspectRatioPickerProps) => {
|
||||
const useAspectRatio =
|
||||
currentAspectRatio ?? model.defaultAspectRatio ?? undefined;
|
||||
|
||||
const useAspectRatio = currentAspectRatio ?? model.defaultAspectRatio ?? undefined;
|
||||
|
||||
console.log("aspect - currentAspectRatio:", currentAspectRatio);
|
||||
console.log("aspect - useAspectRatio:", useAspectRatio);
|
||||
|
||||
const getCurrentResolutionIcon = () : IconDefinition => {
|
||||
if (!useAspectRatio) {
|
||||
return faSquare;
|
||||
}
|
||||
return getAspectRatioIcon(useAspectRatio);
|
||||
}
|
||||
const isAutoRatio =
|
||||
useAspectRatio === CommonAspectRatio.Auto ||
|
||||
useAspectRatio === CommonAspectRatio.Auto2k ||
|
||||
useAspectRatio === CommonAspectRatio.Auto4k;
|
||||
|
||||
const handleSelectAdapter = (item: PopoverItem) => {
|
||||
const ratio = popOverLabelToAspectRatio(item.label, model);
|
||||
handleCommonAspectRatioSelect(ratio);
|
||||
}
|
||||
};
|
||||
|
||||
let aspectRatioList : PopoverItem[] = [];
|
||||
const isAutoAspectRatio = (ratio: CommonAspectRatio): boolean => {
|
||||
return (
|
||||
ratio === CommonAspectRatio.Auto ||
|
||||
ratio === CommonAspectRatio.Auto2k ||
|
||||
ratio === CommonAspectRatio.Auto4k
|
||||
);
|
||||
};
|
||||
|
||||
let aspectRatioList: PopoverItem[] = [];
|
||||
|
||||
model.aspectRatios?.forEach((ratio: CommonAspectRatio) => {
|
||||
aspectRatioList.push({
|
||||
label: getAspectRatioTextLabel(ratio),
|
||||
selected: useAspectRatio === ratio,
|
||||
description: `foo ${ratio}`,
|
||||
icon: <FontAwesomeIcon icon={getAspectRatioIcon(ratio)} className="h-4 w-4" />,
|
||||
icon: isAutoAspectRatio(ratio) ? (
|
||||
<AutoIcon />
|
||||
) : (
|
||||
<AspectRatioIcon commonAspectRatio={ratio} />
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -73,60 +77,19 @@ export const AspectRatioPicker = ({
|
||||
panelTitle="Aspect Ratio"
|
||||
showIconsInList
|
||||
triggerIcon={
|
||||
<FontAwesomeIcon
|
||||
icon={getCurrentResolutionIcon()}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
isAutoRatio || !useAspectRatio ? (
|
||||
<AutoIcon />
|
||||
) : (
|
||||
<AspectRatioIcon commonAspectRatio={useAspectRatio} />
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Tooltip>
|
||||
</>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
const getAspectRatioIcon = (aspectRatio: CommonAspectRatio) : IconDefinition => {
|
||||
switch (aspectRatio) {
|
||||
case CommonAspectRatio.Auto:
|
||||
return faMagicWandSparkles;
|
||||
case CommonAspectRatio.Square:
|
||||
return faSquare;
|
||||
|
||||
// Wide
|
||||
case CommonAspectRatio.Wide:
|
||||
case CommonAspectRatio.WideFiveByFour:
|
||||
case CommonAspectRatio.WideFourByThree:
|
||||
case CommonAspectRatio.WideThreeByTwo:
|
||||
return faRectangle;
|
||||
case CommonAspectRatio.WideSixteenByNine:
|
||||
case CommonAspectRatio.WideTwentyOneByNine:
|
||||
return faRectangleWide;
|
||||
|
||||
// Tall
|
||||
case CommonAspectRatio.Tall:
|
||||
case CommonAspectRatio.TallFourByFive:
|
||||
case CommonAspectRatio.TallThreeByFour:
|
||||
case CommonAspectRatio.TallTwoByThree:
|
||||
return faRectangleVertical;
|
||||
case CommonAspectRatio.TallNineBySixteen:
|
||||
case CommonAspectRatio.TallNineByTwentyOne:
|
||||
return faRectangleVertical; // TODO: New FontAwesome 7 has "Tall".
|
||||
|
||||
// With resolution baked in
|
||||
case CommonAspectRatio.Auto2k:
|
||||
return faMagicWandSparkles;
|
||||
case CommonAspectRatio.Auto4k:
|
||||
return faMagicWandSparkles;
|
||||
case CommonAspectRatio.SquareHd:
|
||||
return faSquare;
|
||||
|
||||
default:
|
||||
console.error("Unknown aspect ratio in icon mapping:", aspectRatio);
|
||||
return faSquare; // Fail open-ish
|
||||
}
|
||||
}
|
||||
|
||||
const getAspectRatioTextLabel = (aspectRatio: CommonAspectRatio) : string => {
|
||||
const getAspectRatioTextLabel = (aspectRatio: CommonAspectRatio): string => {
|
||||
switch (aspectRatio) {
|
||||
case CommonAspectRatio.Auto:
|
||||
return "Auto";
|
||||
@@ -175,28 +138,46 @@ const getAspectRatioTextLabel = (aspectRatio: CommonAspectRatio) : string => {
|
||||
console.error("Unknown aspect ratio:", aspectRatio);
|
||||
return "Square"; // Fail open-ish
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Note: We only need this to deal with turning PopOverItems back into typesafe aspect ratios
|
||||
const popOverLabelToAspectRatio = (label: string, model: ImageModel): CommonAspectRatio => {
|
||||
const popOverLabelToAspectRatio = (
|
||||
label: string,
|
||||
model: ImageModel,
|
||||
): CommonAspectRatio => {
|
||||
switch (label) {
|
||||
case "Auto": return CommonAspectRatio.Auto;
|
||||
case "Square": return CommonAspectRatio.Square;
|
||||
case "5:4 (Wide)": return CommonAspectRatio.WideFiveByFour;
|
||||
case "4:3 (Wide)": return CommonAspectRatio.WideFourByThree;
|
||||
case "3:2 (Wide)": return CommonAspectRatio.WideThreeByTwo;
|
||||
case "16:9 (Wide)": return CommonAspectRatio.WideSixteenByNine;
|
||||
case "21:9 (Wide)": return CommonAspectRatio.WideTwentyOneByNine;
|
||||
case "4:5 (Tall)": return CommonAspectRatio.TallFourByFive;
|
||||
case "3:4 (Tall)": return CommonAspectRatio.TallThreeByFour;
|
||||
case "2:3 (Tall)": return CommonAspectRatio.TallTwoByThree;
|
||||
case "9:16 (Tall)": return CommonAspectRatio.TallNineBySixteen;
|
||||
case "9:21 (Tall)": return CommonAspectRatio.TallNineByTwentyOne;
|
||||
case "Auto (2K)": return CommonAspectRatio.Auto2k;
|
||||
case "Auto (4K)": return CommonAspectRatio.Auto4k;
|
||||
case "Square (HD)": return CommonAspectRatio.SquareHd;
|
||||
case "Auto":
|
||||
return CommonAspectRatio.Auto;
|
||||
case "Square":
|
||||
return CommonAspectRatio.Square;
|
||||
case "5:4 (Wide)":
|
||||
return CommonAspectRatio.WideFiveByFour;
|
||||
case "4:3 (Wide)":
|
||||
return CommonAspectRatio.WideFourByThree;
|
||||
case "3:2 (Wide)":
|
||||
return CommonAspectRatio.WideThreeByTwo;
|
||||
case "16:9 (Wide)":
|
||||
return CommonAspectRatio.WideSixteenByNine;
|
||||
case "21:9 (Wide)":
|
||||
return CommonAspectRatio.WideTwentyOneByNine;
|
||||
case "4:5 (Tall)":
|
||||
return CommonAspectRatio.TallFourByFive;
|
||||
case "3:4 (Tall)":
|
||||
return CommonAspectRatio.TallThreeByFour;
|
||||
case "2:3 (Tall)":
|
||||
return CommonAspectRatio.TallTwoByThree;
|
||||
case "9:16 (Tall)":
|
||||
return CommonAspectRatio.TallNineBySixteen;
|
||||
case "9:21 (Tall)":
|
||||
return CommonAspectRatio.TallNineByTwentyOne;
|
||||
case "Auto (2K)":
|
||||
return CommonAspectRatio.Auto2k;
|
||||
case "Auto (4K)":
|
||||
return CommonAspectRatio.Auto4k;
|
||||
case "Square (HD)":
|
||||
return CommonAspectRatio.SquareHd;
|
||||
}
|
||||
console.error("Unknown aspect ratio label:", label, "for model:", model.id);
|
||||
// If we can't find it, return the model's default aspect ratio or Square as fallback
|
||||
return model.defaultAspectRatio || CommonAspectRatio.Square;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -98,34 +98,48 @@ export const usePromptImageStore = create<PromptImageStore>()((set) => ({
|
||||
}));
|
||||
|
||||
// ----- Video Prompt Box Store -----
|
||||
export type VideoInputMode = "keyframe" | "reference";
|
||||
|
||||
interface PromptVideoStore {
|
||||
prompt: string;
|
||||
resolution: Resolution | string;
|
||||
aspectRatio: string | null;
|
||||
useSystemPrompt: boolean;
|
||||
referenceImages: RefImage[];
|
||||
endFrameImage?: RefImage;
|
||||
generateWithSound: boolean;
|
||||
duration: number | null;
|
||||
inputMode: VideoInputMode;
|
||||
setPrompt: (prompt: string) => void;
|
||||
setResolution: (resolution: Resolution | string) => void;
|
||||
setAspectRatio: (aspectRatio: string | null) => void;
|
||||
setUseSystemPrompt: (value: boolean) => void;
|
||||
setReferenceImages: (images: RefImage[]) => void;
|
||||
setEndFrameImage: (image?: RefImage) => void;
|
||||
setGenerateWithSound: (value: boolean) => void;
|
||||
setDuration: (duration: number | null) => void;
|
||||
setInputMode: (mode: VideoInputMode) => void;
|
||||
}
|
||||
|
||||
export const usePromptVideoStore = create<PromptVideoStore>()((set) => ({
|
||||
prompt: "",
|
||||
resolution: "720p",
|
||||
aspectRatio: null,
|
||||
useSystemPrompt: true,
|
||||
referenceImages: [],
|
||||
endFrameImage: undefined,
|
||||
generateWithSound: true,
|
||||
duration: null,
|
||||
inputMode: "keyframe",
|
||||
setPrompt: (prompt) => set({ prompt }),
|
||||
setResolution: (resolution) => set({ resolution }),
|
||||
setAspectRatio: (aspectRatio) => set({ aspectRatio }),
|
||||
setUseSystemPrompt: (useSystemPrompt) => set({ useSystemPrompt }),
|
||||
setReferenceImages: (referenceImages) => set({ referenceImages }),
|
||||
setEndFrameImage: (endFrameImage) => set({ endFrameImage }),
|
||||
setGenerateWithSound: (generateWithSound) => set({ generateWithSound }),
|
||||
setDuration: (duration) => set({ duration }),
|
||||
setInputMode: (inputMode) => set({ inputMode }),
|
||||
}));
|
||||
|
||||
// ----- Edit Prompt Box Store -----
|
||||
|
||||
@@ -26,6 +26,27 @@ export class VideoModel extends Model {
|
||||
// Whether this model supports toggling generation with sound
|
||||
readonly generateWithSound?: boolean;
|
||||
|
||||
// Available duration options in seconds (e.g. [4, 5, 6, 7, 8, 9])
|
||||
readonly durationOptions?: number[];
|
||||
|
||||
// Default duration in seconds
|
||||
readonly defaultDuration?: number;
|
||||
|
||||
// Whether the model supports multi-image reference mode
|
||||
readonly supportsReferenceMode?: boolean;
|
||||
|
||||
// Maximum number of reference images in reference mode
|
||||
readonly maxReferenceImages?: number;
|
||||
|
||||
// Available resolution options (e.g. ["480p", "720p"])
|
||||
readonly resolutionOptions?: string[];
|
||||
|
||||
// Default resolution
|
||||
readonly defaultResolution?: string;
|
||||
|
||||
// Whether the model supports the system prompt toggle (default true)
|
||||
readonly supportsSystemPrompt: boolean;
|
||||
|
||||
constructor(args: {
|
||||
id: string;
|
||||
tauriId: string;
|
||||
@@ -43,6 +64,13 @@ export class VideoModel extends Model {
|
||||
progressBarTime?: number;
|
||||
generateWithSound?: boolean;
|
||||
providers?: GenerationProvider[];
|
||||
durationOptions?: number[];
|
||||
defaultDuration?: number;
|
||||
supportsReferenceMode?: boolean;
|
||||
maxReferenceImages?: number;
|
||||
resolutionOptions?: string[];
|
||||
defaultResolution?: string;
|
||||
supportsSystemPrompt?: boolean;
|
||||
}) {
|
||||
super(args);
|
||||
this.startFrame = args.startFrame;
|
||||
@@ -50,5 +78,12 @@ export class VideoModel extends Model {
|
||||
this.requiresImage = args.requiresImage;
|
||||
this.sizeOptions = args.sizeOptions ?? [];
|
||||
this.generateWithSound = args.generateWithSound || false;
|
||||
this.durationOptions = args.durationOptions;
|
||||
this.defaultDuration = args.defaultDuration;
|
||||
this.supportsReferenceMode = args.supportsReferenceMode;
|
||||
this.maxReferenceImages = args.maxReferenceImages;
|
||||
this.resolutionOptions = args.resolutionOptions;
|
||||
this.defaultResolution = args.defaultResolution;
|
||||
this.supportsSystemPrompt = args.supportsSystemPrompt ?? true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ export const MODEL_TYPE_TO_CREATOR: Record<string, ModelCreator> = {
|
||||
kling_2p1_pro: ModelCreator.Kling,
|
||||
kling_2p1_master: ModelCreator.Kling,
|
||||
seedance_1p0_lite: ModelCreator.Bytedance,
|
||||
seedance_2p0: ModelCreator.Bytedance,
|
||||
sora_2: ModelCreator.OpenAi,
|
||||
veo_2: ModelCreator.Google,
|
||||
gemini_25_flash: ModelCreator.Google,
|
||||
@@ -117,6 +118,7 @@ export const getModelDisplayName = (modelType: string): string => {
|
||||
kling_2p1_pro: "Kling 2.1 Pro",
|
||||
kling_2p1_master: "Kling 2.1 Master",
|
||||
seedance_1p0_lite: "Seedance 1.0 Lite",
|
||||
seedance_2p0: "Seedance 2.0",
|
||||
veo_2: "Veo 2",
|
||||
sora_2: "Sora 2",
|
||||
recraft_3: "Recraft 3",
|
||||
|
||||
@@ -3,6 +3,10 @@ export enum SizeIconOption {
|
||||
Landscape,
|
||||
Portrait,
|
||||
Square,
|
||||
Landscape16x9,
|
||||
Portrait9x16,
|
||||
Standard4x3,
|
||||
Portrait3x4,
|
||||
}
|
||||
|
||||
export interface SizeOption {
|
||||
|
||||
@@ -10,9 +10,7 @@ export const VIDEO_MODELS: VideoModel[] = [
|
||||
fullName: "Grok Video",
|
||||
category: "video",
|
||||
creator: ModelCreator.Grok,
|
||||
providers: [
|
||||
GenerationProvider.Grok
|
||||
],
|
||||
providers: [GenerationProvider.Grok],
|
||||
selectorName: "Grok Video",
|
||||
selectorDescription: "Fastest video model",
|
||||
selectorBadges: ["20 sec."],
|
||||
@@ -141,22 +139,39 @@ export const VIDEO_MODELS: VideoModel[] = [
|
||||
requiresImage: false,
|
||||
sizeOptions: [
|
||||
{
|
||||
tauriValue: "landscape",
|
||||
textLabel: "Landscape",
|
||||
icon: SizeIconOption.Landscape,
|
||||
},
|
||||
{
|
||||
tauriValue: "portrait",
|
||||
textLabel: "Portrait",
|
||||
icon: SizeIconOption.Portrait,
|
||||
tauriValue: "wide_sixteen_by_nine",
|
||||
textLabel: "16:9",
|
||||
icon: SizeIconOption.Landscape16x9,
|
||||
},
|
||||
{
|
||||
tauriValue: "square",
|
||||
textLabel: "Square",
|
||||
textLabel: "1:1",
|
||||
icon: SizeIconOption.Square,
|
||||
},
|
||||
{
|
||||
tauriValue: "tall_three_by_four",
|
||||
textLabel: "3:4",
|
||||
icon: SizeIconOption.Portrait3x4,
|
||||
},
|
||||
{
|
||||
tauriValue: "wide_four_by_three",
|
||||
textLabel: "4:3",
|
||||
icon: SizeIconOption.Standard4x3,
|
||||
},
|
||||
{
|
||||
tauriValue: "tall_nine_by_sixteen",
|
||||
textLabel: "9:16",
|
||||
icon: SizeIconOption.Portrait9x16,
|
||||
},
|
||||
],
|
||||
progressBarTime: 100000,
|
||||
durationOptions: [4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15],
|
||||
defaultDuration: 5,
|
||||
supportsReferenceMode: true,
|
||||
maxReferenceImages: 3,
|
||||
resolutionOptions: ["480p", "720p"],
|
||||
defaultResolution: "720p",
|
||||
supportsSystemPrompt: false,
|
||||
progressBarTime: 360000,
|
||||
}),
|
||||
new VideoModel({
|
||||
id: "sora_2",
|
||||
@@ -164,10 +179,7 @@ export const VIDEO_MODELS: VideoModel[] = [
|
||||
fullName: "Sora 2",
|
||||
category: "video",
|
||||
creator: ModelCreator.OpenAi,
|
||||
providers: [
|
||||
GenerationProvider.Artcraft,
|
||||
GenerationProvider.Sora,
|
||||
],
|
||||
providers: [GenerationProvider.Artcraft, GenerationProvider.Sora],
|
||||
selectorName: "Sora 2",
|
||||
selectorDescription: "Smart video model",
|
||||
selectorBadges: ["2 min."],
|
||||
|
||||
@@ -44,6 +44,16 @@ export interface EnqueueImageToVideoRequest {
|
||||
|
||||
// Optional. Whether to generate audio alongside the video (used by some models like Veo2)
|
||||
generate_audio?: boolean;
|
||||
|
||||
// Optional. Common aspect ratio for other models.
|
||||
// Not all are supported by all models, but Artcraft will compensate.
|
||||
aspect_ratio?: string; // TODO: Typesafety.
|
||||
|
||||
// Optional. Duration in seconds.
|
||||
duration_seconds?: number;
|
||||
|
||||
// Optional. Reference image media tokens (for reference mode).
|
||||
reference_image_media_tokens?: string[];
|
||||
}
|
||||
|
||||
interface RawEnqueueImageToVideoRequest {
|
||||
@@ -55,8 +65,11 @@ interface RawEnqueueImageToVideoRequest {
|
||||
frontend_caller?: string;
|
||||
frontend_subscriber_id?: string;
|
||||
sora_orientation?: "portrait" | "landscape";
|
||||
grok_aspect_ratio?: "portrait" | "landscape" | "square";
|
||||
grok_aspect_ratio?: "portrait" | "landscape" | "square";
|
||||
generate_audio?: boolean;
|
||||
aspect_ratio?: string; // TODO: Typesafety.
|
||||
duration_seconds?: number;
|
||||
reference_image_media_tokens?: string[];
|
||||
}
|
||||
|
||||
export interface EnqueueImageToVideoError extends CommandResult {
|
||||
@@ -75,7 +88,7 @@ export type EnqueueImageToVideoResult =
|
||||
| EnqueueImageToVideoError;
|
||||
|
||||
export const EnqueueImageToVideo = async (
|
||||
request: EnqueueImageToVideoRequest
|
||||
request: EnqueueImageToVideoRequest,
|
||||
): Promise<EnqueueImageToVideoResult> => {
|
||||
const mutableRequest: RawEnqueueImageToVideoRequest = {
|
||||
model: request.model?.tauriId,
|
||||
@@ -115,6 +128,22 @@ export const EnqueueImageToVideo = async (
|
||||
mutableRequest.generate_audio = request.generate_audio;
|
||||
}
|
||||
|
||||
if (request.aspect_ratio) {
|
||||
mutableRequest.aspect_ratio = request.aspect_ratio;
|
||||
}
|
||||
|
||||
if (typeof request.duration_seconds === "number") {
|
||||
mutableRequest.duration_seconds = request.duration_seconds;
|
||||
}
|
||||
|
||||
if (
|
||||
request.reference_image_media_tokens &&
|
||||
request.reference_image_media_tokens.length > 0
|
||||
) {
|
||||
mutableRequest.reference_image_media_tokens =
|
||||
request.reference_image_media_tokens;
|
||||
}
|
||||
|
||||
const result = await invoke("enqueue_image_to_video_command", {
|
||||
request: mutableRequest,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user