MAJOR change to support local files as refs
This commit is contained in:
parent
28c2605aab
commit
5d9192914e
|
|
@ -2,6 +2,7 @@ pub mod delete_media_file;
|
|||
pub mod get_media_file;
|
||||
pub mod list_batch_generated_redux_media_files;
|
||||
pub mod list_media_files_by_job;
|
||||
pub mod upload_audio_media_file_from_file;
|
||||
pub mod upload_image_media_file_from_bytes;
|
||||
pub mod upload_image_media_file_from_file;
|
||||
pub mod upload_new_engine_asset_from_file;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,90 @@
|
|||
use crate::credentials::storyteller_credential_set::StorytellerCredentialSet;
|
||||
use crate::error::api_error::ApiError;
|
||||
use crate::utils::api_host::ApiHost;
|
||||
use crate::utils::constants::{APPLICATION_JSON, USER_AGENT};
|
||||
use crate::utils::filter_bad_response::filter_bad_response;
|
||||
use core_types::enums::generation_source::GenerationSource;
|
||||
use uuid_utils::uuid::generate_random_uuid;
|
||||
use log::debug;
|
||||
use reqwest::multipart::{Form, Part};
|
||||
use reqwest::Client;
|
||||
use serde_derive::Deserialize;
|
||||
use std::path::Path;
|
||||
use sqlite_identifiers::ids::media_file_token::MediaFileToken;
|
||||
use sqlite_identifiers::ids::prompt_token::PromptToken;
|
||||
|
||||
pub struct UploadAudioFromFileArgs<'a, P: AsRef<Path>> {
|
||||
pub api_host: &'a ApiHost,
|
||||
pub maybe_creds: Option<&'a StorytellerCredentialSet>,
|
||||
|
||||
// NB: Path needs to be owned for the request.
|
||||
pub path: P,
|
||||
|
||||
/// If provided, this is the prompt that this audio is associated with.
|
||||
pub maybe_prompt_token: Option<&'a PromptToken>,
|
||||
|
||||
/// If provided, the third-party provider that generated this file.
|
||||
pub maybe_generation_provider: Option<GenerationSource>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct UploadAudioMediaFileSuccessResponse {
|
||||
pub success: bool,
|
||||
pub media_file_token: MediaFileToken,
|
||||
}
|
||||
|
||||
/// Upload an audio media file from a file.
|
||||
pub async fn upload_audio_media_file_from_file<P: AsRef<Path>>(
|
||||
args: UploadAudioFromFileArgs<'_, P>,
|
||||
) -> Result<UploadAudioMediaFileSuccessResponse, ApiError> {
|
||||
|
||||
let url = get_route(args.api_host);
|
||||
|
||||
debug!("Requesting {:?}", &url);
|
||||
|
||||
let client = Client::builder()
|
||||
.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())
|
||||
.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());
|
||||
}
|
||||
|
||||
if let Some(provider) = &args.maybe_generation_provider {
|
||||
form = form.text("maybe_generation_provider", provider.to_str().to_string());
|
||||
}
|
||||
|
||||
let mut request_builder = client.post(url)
|
||||
.header("User-Agent", USER_AGENT)
|
||||
.header("Accept", APPLICATION_JSON);
|
||||
|
||||
if let Some(creds) = args.maybe_creds {
|
||||
if let Some(header) = &creds.maybe_as_cookie_header() {
|
||||
request_builder = request_builder.header("Cookie", header);
|
||||
}
|
||||
}
|
||||
|
||||
let response = request_builder
|
||||
.multipart(form)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let response = filter_bad_response(response).await?;
|
||||
let response_body = &response.text().await?;
|
||||
|
||||
let media_file = serde_json::from_str(&response_body)?;
|
||||
|
||||
Ok(media_file)
|
||||
}
|
||||
|
||||
fn get_route(api_host: &ApiHost) -> String {
|
||||
let api_hostname_and_scheme = api_host.to_api_hostname_and_scheme();
|
||||
format!("{}/v1/media_files/upload/audio", api_hostname_and_scheme)
|
||||
}
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
//! Shared pieces of the first-party (cookie-session) Higgsfield enqueue path
|
||||
//! for images and videos: the router client for a stored credential, the
|
||||
//! media-token → CDN URL map the router needs to re-upload references to
|
||||
//! Higgsfield, and running a built request through the draft phase.
|
||||
//! media-token → CDN URL map for cloud-library references (local files and
|
||||
//! bytes go straight to Higgsfield without touching ArtCraft), and running a
|
||||
//! built request through the draft phase.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
|
|
@ -34,9 +35,11 @@ pub fn higgsfield_router_client(credential: &AuthCredential) -> Result<RouterCli
|
|||
Ok(RouterClient::Higgsfield(RouterHiggsfieldClient::new(session)))
|
||||
}
|
||||
|
||||
/// Resolve reference media tokens to their ArtCraft CDN URLs. Higgsfield
|
||||
/// can't fetch ArtCraft media itself, so the router downloads each URL and
|
||||
/// re-uploads the bytes as Higgsfield reference media.
|
||||
/// Resolve reference media tokens (cloud-library picks only) to their
|
||||
/// ArtCraft CDN URLs. Higgsfield can't fetch ArtCraft media itself, so the
|
||||
/// router downloads each URL and re-uploads the bytes as Higgsfield
|
||||
/// reference media. Empty when every reference is a local file or bytes —
|
||||
/// then ArtCraft is never contacted at all.
|
||||
pub async fn higgsfield_media_url_map(tokens: &[MediaFileToken]) -> Result<HashMap<MediaFileToken, String>, GenerateError> {
|
||||
if tokens.is_empty() {
|
||||
return Ok(HashMap::new());
|
||||
|
|
|
|||
|
|
@ -0,0 +1,254 @@
|
|||
//! Per-provider conversion of [`TauriMediaSource`]s.
|
||||
//!
|
||||
//! - Higgsfield / Kinovi consume sources natively (`sources_to_*_list`); the
|
||||
//! router reads local files / bytes and uploads them straight to the
|
||||
//! provider. Only tokens (cloud-library picks) resolve through ArtCraft.
|
||||
//! - The ArtCraft provider is token-native: local files / bytes upload to
|
||||
//! ArtCraft at generate time (`sources_to_artcraft_tokens`) — the media
|
||||
//! must reach ArtCraft anyway.
|
||||
//! - FAL needs public URLs and has no storage of its own: local files /
|
||||
//! bytes upload to ArtCraft and resolve to CDN URLs
|
||||
//! (`image_sources_to_fal_urls`). FAL is the ONLY non-ArtCraft provider
|
||||
//! allowed to route local media through the ArtCraft cloud.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
|
||||
use artcraft_client::endpoints::media_files::get_media_file::get_media_file;
|
||||
use artcraft_client::endpoints::media_files::upload_audio_media_file_from_file::{upload_audio_media_file_from_file, UploadAudioFromFileArgs};
|
||||
use artcraft_client::endpoints::media_files::upload_image_media_file_from_file::{upload_image_media_file_from_file, UploadImageFromFileArgs};
|
||||
use artcraft_client::endpoints::media_files::upload_video_media_file_from_file::{upload_video_media_file_from_file, UploadVideoFromFileArgs};
|
||||
use artcraft_client::error::storyteller_error::StorytellerError;
|
||||
use artcraft_client::utils::api_host::ApiHost;
|
||||
use log::info;
|
||||
use router::api::audio_list_ref::AudioListRef;
|
||||
use router::api::image_list_ref::ImageListRef;
|
||||
use router::api::image_ref::ImageRef;
|
||||
use router::api::video_list_ref::VideoListRef;
|
||||
use router::utils::higgsfield_media::sniff_mime_type;
|
||||
use sqlite_identifiers::ids::media_file_token::MediaFileToken;
|
||||
use uuid_utils::uuid::generate_random_uuid;
|
||||
|
||||
use crate::commands::generate::common::tauri_media_source::TauriMediaSource;
|
||||
use crate::commands::generate::generate_error::GenerateError;
|
||||
|
||||
/// Which ArtCraft upload endpoint a source goes through.
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||
pub enum ArtcraftMediaKind {
|
||||
Image,
|
||||
Video,
|
||||
Audio,
|
||||
}
|
||||
|
||||
// ── Router-native providers (Higgsfield, Kinovi) ──
|
||||
|
||||
pub fn source_to_image_ref(maybe_source: Option<TauriMediaSource>) -> Option<ImageRef> {
|
||||
maybe_source.map(TauriMediaSource::into_image_ref)
|
||||
}
|
||||
|
||||
pub fn sources_to_image_list(maybe_sources: Option<Vec<TauriMediaSource>>) -> Option<ImageListRef> {
|
||||
maybe_sources.map(|sources| {
|
||||
ImageListRef::Sources(sources.into_iter().map(TauriMediaSource::into_image_ref).collect())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn sources_to_video_list(maybe_sources: Option<Vec<TauriMediaSource>>) -> Option<VideoListRef> {
|
||||
maybe_sources.map(|sources| {
|
||||
VideoListRef::Sources(sources.into_iter().map(TauriMediaSource::into_video_ref).collect())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn sources_to_audio_list(maybe_sources: Option<Vec<TauriMediaSource>>) -> Option<AudioListRef> {
|
||||
maybe_sources.map(|sources| {
|
||||
AudioListRef::Sources(sources.into_iter().map(TauriMediaSource::into_audio_ref).collect())
|
||||
})
|
||||
}
|
||||
|
||||
/// The ArtCraft media tokens among `sources`, deduplicated, in order — the
|
||||
/// only sources that need the token→CDN-URL map.
|
||||
pub fn collect_source_tokens<'a>(sources: impl IntoIterator<Item = &'a TauriMediaSource>) -> Vec<MediaFileToken> {
|
||||
let mut tokens: Vec<MediaFileToken> = sources.into_iter()
|
||||
.filter_map(TauriMediaSource::maybe_media_file_token)
|
||||
.cloned()
|
||||
.collect();
|
||||
tokens.dedup();
|
||||
tokens
|
||||
}
|
||||
|
||||
// ── ArtCraft provider (token-native) ──
|
||||
|
||||
/// Resolve one source to an ArtCraft media token: tokens pass through;
|
||||
/// local files and bytes upload to ArtCraft at generate time.
|
||||
pub async fn source_to_artcraft_token(
|
||||
source: TauriMediaSource,
|
||||
kind: ArtcraftMediaKind,
|
||||
maybe_creds: Option<&StorytellerCredentialSet>,
|
||||
api_host: &ApiHost,
|
||||
) -> Result<MediaFileToken, GenerateError> {
|
||||
match source {
|
||||
TauriMediaSource::MediaFileToken { token } => Ok(token),
|
||||
TauriMediaSource::LocalPath { path } => upload_path_to_artcraft(&path, kind, maybe_creds, api_host).await,
|
||||
TauriMediaSource::Bytes { bytes, file_name } => {
|
||||
// The upload endpoints are multipart-from-file; stage the bytes.
|
||||
let staged = stage_bytes_to_temp_file(&bytes, file_name.as_deref())?;
|
||||
let result = upload_path_to_artcraft(&staged, kind, maybe_creds, api_host).await;
|
||||
std::fs::remove_file(&staged).ok();
|
||||
result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// List twin of [`source_to_artcraft_token`]; order is preserved.
|
||||
pub async fn sources_to_artcraft_tokens(
|
||||
maybe_sources: Option<Vec<TauriMediaSource>>,
|
||||
kind: ArtcraftMediaKind,
|
||||
maybe_creds: Option<&StorytellerCredentialSet>,
|
||||
api_host: &ApiHost,
|
||||
) -> Result<Option<Vec<MediaFileToken>>, GenerateError> {
|
||||
let sources = match maybe_sources {
|
||||
None => return Ok(None),
|
||||
Some(sources) if sources.is_empty() => return Ok(None),
|
||||
Some(sources) => sources,
|
||||
};
|
||||
let mut tokens = Vec::with_capacity(sources.len());
|
||||
for source in sources {
|
||||
tokens.push(source_to_artcraft_token(source, kind, maybe_creds, api_host).await?);
|
||||
}
|
||||
Ok(Some(tokens))
|
||||
}
|
||||
|
||||
/// Single-optional twin of [`source_to_artcraft_token`].
|
||||
pub async fn maybe_source_to_artcraft_token(
|
||||
maybe_source: Option<TauriMediaSource>,
|
||||
kind: ArtcraftMediaKind,
|
||||
maybe_creds: Option<&StorytellerCredentialSet>,
|
||||
api_host: &ApiHost,
|
||||
) -> Result<Option<MediaFileToken>, GenerateError> {
|
||||
match maybe_source {
|
||||
None => Ok(None),
|
||||
Some(source) => Ok(Some(source_to_artcraft_token(source, kind, maybe_creds, api_host).await?)),
|
||||
}
|
||||
}
|
||||
|
||||
// ── FAL (URL-native; the ONLY provider allowed to route through ArtCraft) ──
|
||||
|
||||
/// Resolve image sources to publicly-reachable CDN URLs for FAL: tokens
|
||||
/// resolve to their CDN URL; local files / bytes upload to ArtCraft first.
|
||||
pub async fn image_sources_to_fal_urls(
|
||||
maybe_sources: Option<Vec<TauriMediaSource>>,
|
||||
maybe_creds: Option<&StorytellerCredentialSet>,
|
||||
api_host: &ApiHost,
|
||||
) -> Result<Option<Vec<String>>, GenerateError> {
|
||||
let sources = match maybe_sources {
|
||||
None => return Ok(None),
|
||||
Some(sources) if sources.is_empty() => return Ok(None),
|
||||
Some(sources) => sources,
|
||||
};
|
||||
let mut urls = Vec::with_capacity(sources.len());
|
||||
for source in sources {
|
||||
let token = source_to_artcraft_token(source, ArtcraftMediaKind::Image, maybe_creds, api_host).await?;
|
||||
let response = get_media_file(api_host, &token).await?;
|
||||
urls.push(response.media_file.media_links.cdn_url.to_string());
|
||||
}
|
||||
Ok(Some(urls))
|
||||
}
|
||||
|
||||
// ── Private ──
|
||||
|
||||
async fn upload_path_to_artcraft(
|
||||
path: &Path,
|
||||
kind: ArtcraftMediaKind,
|
||||
maybe_creds: Option<&StorytellerCredentialSet>,
|
||||
api_host: &ApiHost,
|
||||
) -> Result<MediaFileToken, GenerateError> {
|
||||
info!("Uploading local {:?} file to ArtCraft at generate time: {}", kind, path.display());
|
||||
let token = match kind {
|
||||
ArtcraftMediaKind::Image => {
|
||||
upload_image_media_file_from_file(UploadImageFromFileArgs {
|
||||
api_host,
|
||||
maybe_creds,
|
||||
path,
|
||||
is_intermediate_system_file: false,
|
||||
maybe_prompt_token: None,
|
||||
maybe_generation_provider: None,
|
||||
maybe_batch_token: None,
|
||||
}).await?.media_file_token
|
||||
}
|
||||
ArtcraftMediaKind::Video => {
|
||||
upload_video_media_file_from_file(UploadVideoFromFileArgs {
|
||||
api_host,
|
||||
maybe_creds,
|
||||
path,
|
||||
maybe_prompt_token: None,
|
||||
maybe_generation_provider: None,
|
||||
}).await.map_err(StorytellerError::from)?.media_file_token
|
||||
}
|
||||
ArtcraftMediaKind::Audio => {
|
||||
upload_audio_media_file_from_file(UploadAudioFromFileArgs {
|
||||
api_host,
|
||||
maybe_creds,
|
||||
path,
|
||||
maybe_prompt_token: None,
|
||||
maybe_generation_provider: None,
|
||||
}).await.map_err(StorytellerError::from)?.media_file_token
|
||||
}
|
||||
};
|
||||
info!("Uploaded local {:?} file as {}", kind, token.as_str());
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
/// Stage in-memory bytes as a temp file for the multipart-from-file upload
|
||||
/// endpoints. The file name's extension survives so the server can tell the
|
||||
/// type; unknown types get one sniffed from the bytes.
|
||||
fn stage_bytes_to_temp_file(bytes: &[u8], maybe_file_name: Option<&str>) -> Result<PathBuf, GenerateError> {
|
||||
let extension = maybe_file_name
|
||||
.and_then(|name| Path::new(name).extension()?.to_str().map(str::to_string))
|
||||
.or_else(|| sniff_mime_type(bytes).map(|mime| mime.file_extension().to_string()))
|
||||
.unwrap_or_else(|| "bin".to_string());
|
||||
let path = std::env::temp_dir().join(format!("artcraftx_media_{}.{}", generate_random_uuid(), extension));
|
||||
std::fs::write(&path, bytes).map_err(GenerateError::IoError)?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sources_map_to_router_lists() {
|
||||
let list = sources_to_image_list(Some(vec![
|
||||
TauriMediaSource::MediaFileToken { token: MediaFileToken::new_from_str("m_a") },
|
||||
TauriMediaSource::LocalPath { path: PathBuf::from("/tmp/a.png") },
|
||||
])).unwrap();
|
||||
assert_eq!(list.len(), 2);
|
||||
assert!(matches!(&list, ImageListRef::Sources(refs) if matches!(refs[1], ImageRef::LocalPath(_))));
|
||||
assert!(sources_to_image_list(None).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_tokens_are_collected_for_the_url_map() {
|
||||
let sources = vec![
|
||||
TauriMediaSource::MediaFileToken { token: MediaFileToken::new_from_str("m_a") },
|
||||
TauriMediaSource::LocalPath { path: PathBuf::from("/tmp/a.png") },
|
||||
TauriMediaSource::Bytes { bytes: vec![1], file_name: None },
|
||||
TauriMediaSource::MediaFileToken { token: MediaFileToken::new_from_str("m_b") },
|
||||
];
|
||||
let tokens = collect_source_tokens(&sources);
|
||||
assert_eq!(tokens.len(), 2);
|
||||
assert_eq!(tokens[0].as_str(), "m_a");
|
||||
assert_eq!(tokens[1].as_str(), "m_b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn byte_staging_names_by_extension() {
|
||||
const PNG: &[u8] = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR";
|
||||
let staged = stage_bytes_to_temp_file(PNG, None).unwrap();
|
||||
assert_eq!(staged.extension().and_then(|e| e.to_str()), Some("png"));
|
||||
assert_eq!(std::fs::read(&staged).unwrap(), PNG);
|
||||
std::fs::remove_file(&staged).ok();
|
||||
|
||||
let staged = stage_bytes_to_temp_file(b"????", Some("track.mp3")).unwrap();
|
||||
assert_eq!(staged.extension().and_then(|e| e.to_str()), Some("mp3"));
|
||||
std::fs::remove_file(&staged).ok();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +1,10 @@
|
|||
pub mod maybe_notify_frontend_of_billing_errors;
|
||||
pub mod maybe_notify_frontend_of_credential_errors;
|
||||
pub mod maybe_notify_frontend_of_grok_errors;
|
||||
pub mod media_source_conversion;
|
||||
pub mod notify_frontend_of_errors;
|
||||
pub mod generation_credential;
|
||||
pub mod higgsfield_generation;
|
||||
pub mod router_image_request_to_artcraft_prompt;
|
||||
pub mod router_video_request_to_artcraft_prompt;
|
||||
pub mod tauri_media_source;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,205 @@
|
|||
//! The Tauri bridge's three-way media source: reference media arrives from
|
||||
//! the frontend as raw bytes, a local filesystem path, or an ArtCraft media
|
||||
//! file token (a cloud-library pick). Local files and bytes never touch the
|
||||
//! ArtCraft cloud unless the target provider itself requires it (ArtCraft
|
||||
//! generations, and FAL — the one provider allowed to route through ArtCraft
|
||||
//! uploads).
|
||||
|
||||
use std::fmt::{Debug, Formatter};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use router::api::audio_ref::AudioRef;
|
||||
use router::api::image_ref::ImageRef;
|
||||
use router::api::media_bytes::MediaBytes;
|
||||
use router::api::video_ref::VideoRef;
|
||||
use serde_derive::Deserialize;
|
||||
use sqlite_identifiers::ids::media_file_token::MediaFileToken;
|
||||
|
||||
use crate::commands::generate::generate_error::{BadInputReason, GenerateError};
|
||||
|
||||
/// One media reference from the frontend.
|
||||
///
|
||||
/// Wire format (don't change without coordinating with the frontend):
|
||||
/// `{"kind": "media_file_token", "token": "m_..."}`,
|
||||
/// `{"kind": "local_path", "path": "/Users/..."}`, or
|
||||
/// `{"kind": "bytes", "bytes": [...], "file_name": "photo.png"}`.
|
||||
#[derive(Deserialize, Clone)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum TauriMediaSource {
|
||||
MediaFileToken { token: MediaFileToken },
|
||||
LocalPath { path: PathBuf },
|
||||
Bytes {
|
||||
bytes: Vec<u8>,
|
||||
#[serde(default)]
|
||||
file_name: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
impl TauriMediaSource {
|
||||
/// Reject unusable sources up front, before any provider work: a local
|
||||
/// path must point at an existing regular file, and bytes must be
|
||||
/// non-empty.
|
||||
pub fn validate(&self) -> Result<(), GenerateError> {
|
||||
match self {
|
||||
Self::MediaFileToken { .. } => Ok(()),
|
||||
Self::LocalPath { path } => {
|
||||
if path.is_file() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(GenerateError::BadInput(BadInputReason::LocalMediaFileNotFound { path: path.clone() }))
|
||||
}
|
||||
}
|
||||
Self::Bytes { bytes, .. } => {
|
||||
if bytes.is_empty() {
|
||||
Err(GenerateError::BadInput(BadInputReason::EmptyMediaBytes))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn maybe_media_file_token(&self) -> Option<&MediaFileToken> {
|
||||
match self {
|
||||
Self::MediaFileToken { token } => Some(token),
|
||||
Self::LocalPath { .. } | Self::Bytes { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn into_image_ref(self) -> ImageRef {
|
||||
match self {
|
||||
Self::MediaFileToken { token } => ImageRef::MediaFileToken(token),
|
||||
Self::LocalPath { path } => ImageRef::LocalPath(path),
|
||||
Self::Bytes { bytes, file_name } => ImageRef::Bytes(media_bytes(bytes, file_name)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn into_video_ref(self) -> VideoRef {
|
||||
match self {
|
||||
Self::MediaFileToken { token } => VideoRef::MediaFileToken(token),
|
||||
Self::LocalPath { path } => VideoRef::LocalPath(path),
|
||||
Self::Bytes { bytes, file_name } => VideoRef::Bytes(media_bytes(bytes, file_name)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn into_audio_ref(self) -> AudioRef {
|
||||
match self {
|
||||
Self::MediaFileToken { token } => AudioRef::MediaFileToken(token),
|
||||
Self::LocalPath { path } => AudioRef::LocalPath(path),
|
||||
Self::Bytes { bytes, file_name } => AudioRef::Bytes(media_bytes(bytes, file_name)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Manual impl: generate requests are logged at INFO; never dump the bytes.
|
||||
impl Debug for TauriMediaSource {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::MediaFileToken { token } => f.debug_struct("MediaFileToken").field("token", token).finish(),
|
||||
Self::LocalPath { path } => f.debug_struct("LocalPath").field("path", path).finish(),
|
||||
Self::Bytes { bytes, file_name } => f.debug_struct("Bytes")
|
||||
.field("bytes", &format_args!("<{} bytes>", bytes.len()))
|
||||
.field("file_name", file_name)
|
||||
.finish(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A source field wins over its legacy token twin; legacy tokens are folded
|
||||
/// into sources so handlers only ever see [`TauriMediaSource`].
|
||||
pub fn merge_source_with_legacy_token(
|
||||
maybe_source: Option<TauriMediaSource>,
|
||||
maybe_legacy_token: Option<MediaFileToken>,
|
||||
) -> Option<TauriMediaSource> {
|
||||
maybe_source.or_else(|| maybe_legacy_token.map(|token| TauriMediaSource::MediaFileToken { token }))
|
||||
}
|
||||
|
||||
/// List twin of [`merge_source_with_legacy_token`]. An empty legacy list is
|
||||
/// treated like an absent one.
|
||||
pub fn merge_sources_with_legacy_tokens(
|
||||
maybe_sources: Option<Vec<TauriMediaSource>>,
|
||||
maybe_legacy_tokens: Option<Vec<MediaFileToken>>,
|
||||
) -> Option<Vec<TauriMediaSource>> {
|
||||
maybe_sources.or_else(|| {
|
||||
let tokens = maybe_legacy_tokens?;
|
||||
if tokens.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(tokens.into_iter().map(|token| TauriMediaSource::MediaFileToken { token }).collect())
|
||||
})
|
||||
}
|
||||
|
||||
/// Validate every source a request carries; call once at command entry.
|
||||
pub fn validate_sources<'a>(sources: impl IntoIterator<Item = &'a TauriMediaSource>) -> Result<(), GenerateError> {
|
||||
for source in sources {
|
||||
source.validate()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn media_bytes(bytes: Vec<u8>, maybe_file_name: Option<String>) -> MediaBytes {
|
||||
match maybe_file_name {
|
||||
Some(file_name) => MediaBytes::new(bytes).with_file_name(file_name),
|
||||
None => MediaBytes::new(bytes),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn wire_format_round_trip() {
|
||||
let source: TauriMediaSource = serde_json::from_str(r#"{"kind":"media_file_token","token":"m_abc"}"#).unwrap();
|
||||
assert!(matches!(&source, TauriMediaSource::MediaFileToken { token } if token.as_str() == "m_abc"));
|
||||
|
||||
let source: TauriMediaSource = serde_json::from_str(r#"{"kind":"local_path","path":"/tmp/a.png"}"#).unwrap();
|
||||
assert!(matches!(&source, TauriMediaSource::LocalPath { path } if path == &PathBuf::from("/tmp/a.png")));
|
||||
|
||||
let source: TauriMediaSource = serde_json::from_str(r#"{"kind":"bytes","bytes":[1,2,3]}"#).unwrap();
|
||||
assert!(matches!(&source, TauriMediaSource::Bytes { bytes, file_name: None } if bytes == &vec![1, 2, 3]));
|
||||
|
||||
let source: TauriMediaSource = serde_json::from_str(r#"{"kind":"bytes","bytes":[1],"file_name":"a.png"}"#).unwrap();
|
||||
assert!(matches!(&source, TauriMediaSource::Bytes { file_name: Some(name), .. } if name == "a.png"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debug_never_dumps_bytes() {
|
||||
let source = TauriMediaSource::Bytes { bytes: vec![0; 100_000], file_name: Some("a.png".into()) };
|
||||
let debug = format!("{source:?}");
|
||||
assert!(debug.contains("<100000 bytes>"));
|
||||
assert!(debug.len() < 200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validation() {
|
||||
assert!(TauriMediaSource::MediaFileToken { token: MediaFileToken::new_from_str("m_a") }.validate().is_ok());
|
||||
assert!(TauriMediaSource::Bytes { bytes: vec![1], file_name: None }.validate().is_ok());
|
||||
assert!(matches!(
|
||||
TauriMediaSource::Bytes { bytes: vec![], file_name: None }.validate(),
|
||||
Err(GenerateError::BadInput(BadInputReason::EmptyMediaBytes)),
|
||||
));
|
||||
assert!(matches!(
|
||||
TauriMediaSource::LocalPath { path: PathBuf::from("/definitely/not/real.png") }.validate(),
|
||||
Err(GenerateError::BadInput(BadInputReason::LocalMediaFileNotFound { .. })),
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_tokens_fold_into_sources() {
|
||||
let token = MediaFileToken::new_from_str("m_x");
|
||||
let merged = merge_source_with_legacy_token(None, Some(token.clone()));
|
||||
assert!(matches!(merged, Some(TauriMediaSource::MediaFileToken { .. })));
|
||||
|
||||
// A source field wins over its legacy twin.
|
||||
let merged = merge_source_with_legacy_token(
|
||||
Some(TauriMediaSource::LocalPath { path: PathBuf::from("/tmp/a.png") }),
|
||||
Some(token.clone()),
|
||||
);
|
||||
assert!(matches!(merged, Some(TauriMediaSource::LocalPath { .. })));
|
||||
|
||||
assert!(merge_sources_with_legacy_tokens(None, Some(vec![])).is_none());
|
||||
let merged = merge_sources_with_legacy_tokens(None, Some(vec![token])).unwrap();
|
||||
assert_eq!(merged.len(), 1);
|
||||
}
|
||||
}
|
||||
|
|
@ -15,6 +15,8 @@ use crate::commands::utils::api_adapters::models::audio::tauri_audio_model_to_ro
|
|||
use crate::commands::generate::generate_error::GenerateError;
|
||||
use crate::commands::generate::task_enqueue_success::TaskEnqueueSuccess;
|
||||
use crate::commands::generate::common::generation_credential::{credential_not_usable, resolve_generation_credential, storyteller_creds_from_credential};
|
||||
use crate::commands::generate::common::media_source_conversion::{sources_to_artcraft_tokens, ArtcraftMediaKind};
|
||||
use crate::commands::generate::common::tauri_media_source::validate_sources;
|
||||
use crate::commands::generate::generate_audio::request::TauriGenerateAudioRequest;
|
||||
use crate::utils::services::artcraft_api_host::maybe_artcraft_api_host_for_service;
|
||||
use crate::credentials::auth_credential::AuthCredential;
|
||||
|
|
@ -27,6 +29,11 @@ pub async fn handle_credential_router(
|
|||
request: &TauriGenerateAudioRequest,
|
||||
app_data_root: &AppDataRoot,
|
||||
) -> Result<TaskEnqueueSuccess, GenerateError> {
|
||||
// Reject unusable media sources (missing local files, empty bytes) before
|
||||
// any provider work.
|
||||
validate_sources(request.audio_media_sources().iter().flatten())?;
|
||||
validate_sources(request.image_media_sources().iter().flatten())?;
|
||||
|
||||
let credential = resolve_generation_credential(
|
||||
request.credential_id.as_deref(),
|
||||
app_data_root,
|
||||
|
|
@ -52,8 +59,8 @@ pub async fn handle_credential_router(
|
|||
}
|
||||
|
||||
/// Generate via the router's Artcraft provider, authenticating with the
|
||||
/// stored session cookies. Requests carry media tokens only, which Artcraft
|
||||
/// accepts directly — no pre-upload step is needed.
|
||||
/// stored session cookies. Artcraft's API is token-native; local files and
|
||||
/// bytes upload to ArtCraft here, at generate time.
|
||||
async fn handle_artcraft_credential(
|
||||
request: &TauriGenerateAudioRequest,
|
||||
credential: &AuthCredential,
|
||||
|
|
@ -68,6 +75,13 @@ async fn handle_artcraft_credential(
|
|||
|
||||
let creds = storyteller_creds_from_credential(credential)?;
|
||||
|
||||
let audio_references = sources_to_artcraft_tokens(
|
||||
request.audio_media_sources(), ArtcraftMediaKind::Audio, Some(&creds), &api_host,
|
||||
).await?.map(AudioListRef::MediaFileTokens);
|
||||
let image_references = sources_to_artcraft_tokens(
|
||||
request.image_media_sources(), ArtcraftMediaKind::Image, Some(&creds), &api_host,
|
||||
).await?.map(ImageListRef::MediaFileTokens);
|
||||
|
||||
let client = RouterClient::Artcraft(RouterArtcraftClient::new(
|
||||
api_host,
|
||||
creds,
|
||||
|
|
@ -78,8 +92,8 @@ async fn handle_artcraft_credential(
|
|||
provider: RouterProvider::Artcraft,
|
||||
prompt: request.prompt.clone(),
|
||||
style_prompt: request.style_prompt.clone(),
|
||||
audio_references: request.audio_media_tokens.clone().map(AudioListRef::MediaFileTokens),
|
||||
image_references: request.image_media_tokens.clone().map(ImageListRef::MediaFileTokens),
|
||||
audio_references,
|
||||
image_references,
|
||||
keep_lyrics: request.keep_lyrics,
|
||||
is_instrumental: request.is_instrumental,
|
||||
is_loopable: request.is_loopable,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ use serde_derive::{Deserialize, Serialize};
|
|||
use artcraft_client::enums::common::generation::common_musical_key::CommonMusicalKey;
|
||||
use sqlite_identifiers::ids::media_file_token::MediaFileToken;
|
||||
|
||||
use crate::commands::generate::common::tauri_media_source::{merge_sources_with_legacy_tokens, TauriMediaSource};
|
||||
use crate::commands::utils::response::success_response_wrapper::SerializeMarker;
|
||||
|
||||
// ── Request ──
|
||||
|
|
@ -29,6 +30,14 @@ pub struct TauriGenerateAudioRequest {
|
|||
/// Reference images (already uploaded).
|
||||
pub image_media_tokens: Option<Vec<MediaFileToken>>,
|
||||
|
||||
/// Three-way reference audio (bytes | local path | media token). Wins
|
||||
/// over `audio_media_tokens` when both are set.
|
||||
pub audio_sources: Option<Vec<TauriMediaSource>>,
|
||||
|
||||
/// Three-way reference images (bytes | local path | media token). Wins
|
||||
/// over `image_media_tokens` when both are set.
|
||||
pub image_sources: Option<Vec<TauriMediaSource>>,
|
||||
|
||||
/// Keep the original lyrics (Suno Remix).
|
||||
pub keep_lyrics: Option<bool>,
|
||||
|
||||
|
|
@ -68,6 +77,18 @@ pub struct TauriGenerateAudioRequest {
|
|||
pub frontend_subscriber_payload: Option<String>,
|
||||
}
|
||||
|
||||
impl TauriGenerateAudioRequest {
|
||||
/// Reference audio, normalized: `audio_sources` wins, legacy tokens fold in.
|
||||
pub fn audio_media_sources(&self) -> Option<Vec<TauriMediaSource>> {
|
||||
merge_sources_with_legacy_tokens(self.audio_sources.clone(), self.audio_media_tokens.clone())
|
||||
}
|
||||
|
||||
/// Reference images, normalized: `image_sources` wins, legacy tokens fold in.
|
||||
pub fn image_media_sources(&self) -> Option<Vec<TauriMediaSource>> {
|
||||
merge_sources_with_legacy_tokens(self.image_sources.clone(), self.image_media_tokens.clone())
|
||||
}
|
||||
}
|
||||
|
||||
/// The audio models the frontend can request, identified by their omni
|
||||
/// model ids (`CommonAudioModel` serde strings).
|
||||
#[derive(Clone, Copy, Debug, Deserialize)]
|
||||
|
|
|
|||
|
|
@ -66,6 +66,10 @@ pub enum BadInputReason {
|
|||
Base64DecodeError,
|
||||
BothImageMaskMediaTokenAndBytesSupplied,
|
||||
CannotDetermineImageMimeType,
|
||||
/// A `local_path` media source doesn't point at an existing regular file.
|
||||
LocalMediaFileNotFound { path: std::path::PathBuf },
|
||||
/// A `bytes` media source arrived empty.
|
||||
EmptyMediaBytes,
|
||||
InvalidNumberOfInputImages {
|
||||
provided: u32,
|
||||
min: u32,
|
||||
|
|
|
|||
|
|
@ -25,6 +25,10 @@ use sqlite_identifiers::ids::media_file_token::MediaFileToken;
|
|||
use crate::commands::generate::common::generation_credential::{
|
||||
credential_not_usable, resolve_generation_credential, storyteller_creds_from_credential,
|
||||
};
|
||||
use crate::commands::generate::common::media_source_conversion::{
|
||||
image_sources_to_fal_urls, sources_to_artcraft_tokens, ArtcraftMediaKind,
|
||||
};
|
||||
use crate::commands::generate::common::tauri_media_source::validate_sources;
|
||||
use crate::commands::generate::generate_error::{BadInputReason, GenerateError};
|
||||
use crate::commands::generate::generate_image::higgsfield::enqueue_via_higgsfield::enqueue_via_higgsfield;
|
||||
use crate::commands::generate::generate_image::tauri_generate_image_request::TauriGenerateImageRequest;
|
||||
|
|
@ -61,6 +65,10 @@ pub async fn enqueue_image_generation(
|
|||
mj_session: &MidjourneyLiveSession,
|
||||
grok_websockets: &GrokWebsockets,
|
||||
) -> Result<TaskEnqueueSuccess, GenerateError> {
|
||||
// Reject unusable media sources (missing local files, empty bytes) before
|
||||
// any provider work.
|
||||
validate_sources(request.reference_media_sources().iter().flatten())?;
|
||||
|
||||
let credential = resolve_generation_credential(request.credential_id.as_deref(), app_data_root)?;
|
||||
let credential = maybe_reroute_grok_model_to_grok_credential(request, credential, app_data_root)?;
|
||||
|
||||
|
|
@ -79,7 +87,7 @@ pub async fn enqueue_image_generation(
|
|||
let api_key = credential.api_key().ok_or_else(|| {
|
||||
credential_not_usable(&credential, "the FAL credential has no API key")
|
||||
})?;
|
||||
enqueue_via_fal(request, &api_key.api_key).await
|
||||
enqueue_via_fal(request, &api_key.api_key, app_data_root).await
|
||||
}
|
||||
|
||||
GenerationSource::MidjourneyCookies | GenerationSource::Midjourney => {
|
||||
|
|
@ -124,9 +132,14 @@ async fn enqueue_via_artcraft(
|
|||
|
||||
let creds = storyteller_creds_from_credential(credential)?;
|
||||
|
||||
// Upload any raw image bytes before the generate request.
|
||||
// Upload any raw image bytes before the generate request. Local-file /
|
||||
// bytes references also upload here: ArtCraft's API is token-native and
|
||||
// the media must reach ArtCraft anyway.
|
||||
let semantic_media_files = parse_semantic_media_files(request, &creds, &api_host).await?;
|
||||
let image_inputs = collect_artcraft_image_inputs(request, &semantic_media_files);
|
||||
let reference_tokens = sources_to_artcraft_tokens(
|
||||
request.reference_media_sources(), ArtcraftMediaKind::Image, Some(&creds), &api_host,
|
||||
).await?;
|
||||
let image_inputs = collect_artcraft_image_inputs(&semantic_media_files, reference_tokens);
|
||||
|
||||
let router_request = GenerateImageRequestBuilder {
|
||||
model: router_model,
|
||||
|
|
@ -170,11 +183,10 @@ async fn enqueue_via_artcraft(
|
|||
}
|
||||
|
||||
/// Reference images for the Artcraft router request: canvas first, then scene,
|
||||
/// then the un-semantic reference images. (Artcraft accepts media tokens
|
||||
/// directly; no URL resolution needed.)
|
||||
/// then the un-semantic reference images (already resolved to tokens).
|
||||
fn collect_artcraft_image_inputs(
|
||||
request: &TauriGenerateImageRequest,
|
||||
semantic_media_files: &SemanticMediaFiles,
|
||||
reference_tokens: Option<Vec<MediaFileToken>>,
|
||||
) -> Option<ImageListRef> {
|
||||
let mut tokens: Vec<MediaFileToken> = Vec::new();
|
||||
|
||||
|
|
@ -184,9 +196,7 @@ fn collect_artcraft_image_inputs(
|
|||
if let Some(scene_token) = &semantic_media_files.scene_image_media_token {
|
||||
tokens.push(scene_token.clone());
|
||||
}
|
||||
if let Some(media_tokens) = &request.image_media_tokens {
|
||||
tokens.extend(media_tokens.clone());
|
||||
}
|
||||
tokens.extend(reference_tokens.unwrap_or_default());
|
||||
|
||||
if tokens.is_empty() {
|
||||
None
|
||||
|
|
@ -198,10 +208,13 @@ fn collect_artcraft_image_inputs(
|
|||
// ── Fal ──
|
||||
|
||||
/// Generate via the router's FAL provider using a stored FAL API key. FAL only
|
||||
/// accepts URLs, so Artcraft media tokens are resolved to CDN URLs first.
|
||||
/// accepts URLs, so media tokens resolve to CDN URLs — and local files / raw
|
||||
/// bytes upload to ArtCraft first to get one (FAL is the ONLY non-ArtCraft
|
||||
/// provider allowed to route local media through the ArtCraft cloud).
|
||||
async fn enqueue_via_fal(
|
||||
request: &TauriGenerateImageRequest,
|
||||
api_key: &str,
|
||||
app_data_root: &AppDataRoot,
|
||||
) -> Result<TaskEnqueueSuccess, GenerateError> {
|
||||
let tauri_model = request.model.ok_or(GenerateError::no_model_specified())?;
|
||||
|
||||
|
|
@ -212,7 +225,7 @@ async fn enqueue_via_fal(
|
|||
)),
|
||||
)?;
|
||||
|
||||
let image_inputs = resolve_fal_image_inputs(request, &ApiHost::Storyteller).await?;
|
||||
let image_inputs = resolve_fal_image_inputs(request, &ApiHost::Storyteller, app_data_root).await?;
|
||||
|
||||
let router_request = GenerateImageRequestBuilder {
|
||||
model: router_model,
|
||||
|
|
@ -245,24 +258,33 @@ async fn enqueue_via_fal(
|
|||
async fn resolve_fal_image_inputs(
|
||||
request: &TauriGenerateImageRequest,
|
||||
api_host: &ApiHost,
|
||||
app_data_root: &AppDataRoot,
|
||||
) -> Result<Option<ImageListRef>, GenerateError> {
|
||||
let mut tokens: Vec<MediaFileToken> = Vec::new();
|
||||
let mut canvas_and_scene_tokens: Vec<MediaFileToken> = Vec::new();
|
||||
|
||||
if let Some(canvas_token) = &request.canvas_image_media_token {
|
||||
tokens.push(canvas_token.clone());
|
||||
canvas_and_scene_tokens.push(canvas_token.clone());
|
||||
}
|
||||
if let Some(scene_token) = &request.scene_image_media_token {
|
||||
tokens.push(scene_token.clone());
|
||||
}
|
||||
if let Some(media_tokens) = &request.image_media_tokens {
|
||||
tokens.extend(media_tokens.clone());
|
||||
canvas_and_scene_tokens.push(scene_token.clone());
|
||||
}
|
||||
|
||||
if tokens.is_empty() {
|
||||
let mut urls = map_media_file_tokens_to_cdn_urls(&canvas_and_scene_tokens, api_host).await?;
|
||||
|
||||
// Local files / raw bytes upload to ArtCraft, authenticated with any
|
||||
// stored ArtCraft account (anonymous otherwise).
|
||||
let maybe_artcraft_credential = find_first_credential_for_service(app_data_root, GenerationSource::ArtcraftCookies)
|
||||
.or_else(|| find_first_credential_for_service(app_data_root, GenerationSource::Artcraft));
|
||||
let maybe_creds = maybe_artcraft_credential.as_ref()
|
||||
.and_then(|credential| storyteller_creds_from_credential(credential).ok());
|
||||
let reference_urls = image_sources_to_fal_urls(
|
||||
request.reference_media_sources(), maybe_creds.as_ref(), api_host,
|
||||
).await?;
|
||||
urls.extend(reference_urls.unwrap_or_default());
|
||||
|
||||
if urls.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let urls = map_media_file_tokens_to_cdn_urls(&tokens, api_host).await?;
|
||||
Ok(Some(ImageListRef::Urls(urls)))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ use sqlite_identifiers::ids::media_file_token::MediaFileToken;
|
|||
use crate::commands::generate::common::higgsfield_generation::{
|
||||
higgsfield_media_url_map, higgsfield_router_client, join_higgsfield_job_ids, send_higgsfield_image_request,
|
||||
};
|
||||
use crate::commands::generate::common::media_source_conversion::collect_source_tokens;
|
||||
use crate::commands::generate::common::tauri_media_source::TauriMediaSource;
|
||||
use crate::commands::generate::generate_error::GenerateError;
|
||||
use crate::commands::generate::generate_image::tauri_generate_image_request::TauriGenerateImageRequest;
|
||||
use crate::commands::generate::generate_image::utils::convert_enums_to_router::{convert_aspect_ratio, convert_quality, convert_resolution};
|
||||
|
|
@ -24,11 +26,12 @@ use crate::credentials::auth_credential::AuthCredential;
|
|||
|
||||
/// Enqueue via the router's first-party Higgsfield provider.
|
||||
///
|
||||
/// Reference images (canvas, scene, and the prompt box's image references)
|
||||
/// travel as ArtCraft media tokens; the router resolves them to CDN URLs
|
||||
/// through the map built here, downloads them, and uploads them to
|
||||
/// Higgsfield before enqueuing. The returned task carries every job id of
|
||||
/// the Higgsfield job set so the poller can follow a batch.
|
||||
/// Reference images (canvas, scene, and the prompt box's three-way image
|
||||
/// sources) go to the router; local files and raw bytes upload straight to
|
||||
/// Higgsfield, while ArtCraft media tokens (cloud-library picks) resolve to
|
||||
/// CDN URLs through the map built here before the router downloads and
|
||||
/// re-uploads them. The returned task carries every job id of the
|
||||
/// Higgsfield job set so the poller can follow a batch.
|
||||
pub async fn enqueue_via_higgsfield(
|
||||
request: &TauriGenerateImageRequest,
|
||||
credential: &AuthCredential,
|
||||
|
|
@ -42,9 +45,11 @@ pub async fn enqueue_via_higgsfield(
|
|||
)),
|
||||
)?;
|
||||
|
||||
let tokens = collect_reference_tokens(request);
|
||||
let media_url_map = higgsfield_media_url_map(&tokens).await?;
|
||||
let image_inputs = (!tokens.is_empty()).then(|| ImageListRef::MediaFileTokens(tokens));
|
||||
let sources = collect_reference_sources(request);
|
||||
let media_url_map = higgsfield_media_url_map(&collect_source_tokens(&sources)).await?;
|
||||
let image_inputs = (!sources.is_empty()).then(|| {
|
||||
ImageListRef::Sources(sources.into_iter().map(TauriMediaSource::into_image_ref).collect())
|
||||
});
|
||||
|
||||
let router_request = GenerateImageRequestBuilder {
|
||||
model: router_model,
|
||||
|
|
@ -90,20 +95,19 @@ pub async fn enqueue_via_higgsfield(
|
|||
}
|
||||
|
||||
/// Reference images in priority order: canvas first, then scene, then the
|
||||
/// un-semantic references. (Raw canvas / scene bytes aren't accepted on this
|
||||
/// path — they'd need an ArtCraft session to become tokens.)
|
||||
fn collect_reference_tokens(request: &TauriGenerateImageRequest) -> Vec<MediaFileToken> {
|
||||
let mut tokens: Vec<MediaFileToken> = Vec::new();
|
||||
/// un-semantic references (three-way sources). (Raw canvas / scene bytes
|
||||
/// aren't accepted on this path — they'd need an ArtCraft session to become
|
||||
/// tokens.)
|
||||
fn collect_reference_sources(request: &TauriGenerateImageRequest) -> Vec<TauriMediaSource> {
|
||||
let mut sources: Vec<TauriMediaSource> = Vec::new();
|
||||
if let Some(canvas_token) = &request.canvas_image_media_token {
|
||||
tokens.push(canvas_token.clone());
|
||||
sources.push(TauriMediaSource::MediaFileToken { token: canvas_token.clone() });
|
||||
}
|
||||
if let Some(scene_token) = &request.scene_image_media_token {
|
||||
tokens.push(scene_token.clone());
|
||||
sources.push(TauriMediaSource::MediaFileToken { token: scene_token.clone() });
|
||||
}
|
||||
if let Some(media_tokens) = &request.image_media_tokens {
|
||||
tokens.extend(media_tokens.clone());
|
||||
}
|
||||
tokens
|
||||
sources.extend(request.reference_media_sources().unwrap_or_default());
|
||||
sources
|
||||
}
|
||||
|
||||
/// Live smoke tests that hit the REAL Higgsfield API with the REAL stored
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ use sqlite_identifiers::enums::tauri_command_caller::TauriCommandCaller;
|
|||
use serde_derive::{Deserialize, Serialize};
|
||||
use sqlite_identifiers::ids::media_file_token::MediaFileToken;
|
||||
|
||||
use crate::commands::generate::common::tauri_media_source::{merge_sources_with_legacy_tokens, TauriMediaSource};
|
||||
use crate::commands::generate::generate_image::tauri_image_model::TauriImageModel;
|
||||
use crate::commands::utils::response::success_response_wrapper::SerializeMarker;
|
||||
|
||||
|
|
@ -44,6 +45,11 @@ pub struct TauriGenerateImageRequest {
|
|||
/// The purpose varies on a model-by-model basis.
|
||||
pub image_media_tokens: Option<Vec<MediaFileToken>>,
|
||||
|
||||
/// Three-way reference images (bytes | local path | media token). Wins
|
||||
/// over `image_media_tokens` when both are set; local files and bytes
|
||||
/// never touch the ArtCraft cloud unless the target provider requires it.
|
||||
pub image_sources: Option<Vec<TauriMediaSource>>,
|
||||
|
||||
// ── Canvas / scene images ──
|
||||
|
||||
/// Supply this *XOR* `canvas_image_raw_bytes`.
|
||||
|
|
@ -98,6 +104,14 @@ pub struct TauriGenerateImageRequest {
|
|||
pub frontend_subscriber_payload: Option<String>,
|
||||
}
|
||||
|
||||
impl TauriGenerateImageRequest {
|
||||
/// The un-semantic reference images, normalized: `image_sources` wins,
|
||||
/// legacy `image_media_tokens` fold into [`TauriMediaSource`]s.
|
||||
pub fn reference_media_sources(&self) -> Option<Vec<TauriMediaSource>> {
|
||||
merge_sources_with_legacy_tokens(self.image_sources.clone(), self.image_media_tokens.clone())
|
||||
}
|
||||
}
|
||||
|
||||
// ── Response ──
|
||||
|
||||
#[derive(Serialize)]
|
||||
|
|
|
|||
|
|
@ -16,6 +16,10 @@ use crate::commands::utils::api_adapters::models::mesh::tauri_mesh_model_to_rout
|
|||
use crate::commands::generate::generate_error::GenerateError;
|
||||
use crate::commands::generate::task_enqueue_success::TaskEnqueueSuccess;
|
||||
use crate::commands::generate::common::generation_credential::{credential_not_usable, resolve_generation_credential, storyteller_creds_from_credential};
|
||||
use crate::commands::generate::common::media_source_conversion::{
|
||||
maybe_source_to_artcraft_token, sources_to_artcraft_tokens, ArtcraftMediaKind,
|
||||
};
|
||||
use crate::commands::generate::common::tauri_media_source::{validate_sources, TauriMediaSource};
|
||||
use crate::commands::generate::generate_mesh::request::TauriGenerateMeshRequest;
|
||||
use crate::utils::services::artcraft_api_host::maybe_artcraft_api_host_for_service;
|
||||
use crate::credentials::auth_credential::AuthCredential;
|
||||
|
|
@ -28,6 +32,10 @@ pub async fn handle_credential_router(
|
|||
request: &TauriGenerateMeshRequest,
|
||||
app_data_root: &AppDataRoot,
|
||||
) -> Result<TaskEnqueueSuccess, GenerateError> {
|
||||
// Reject unusable media sources (missing local files, empty bytes) before
|
||||
// any provider work.
|
||||
validate_sources(request.media_sources().iter())?;
|
||||
|
||||
let credential = resolve_generation_credential(
|
||||
request.credential_id.as_deref(),
|
||||
app_data_root,
|
||||
|
|
@ -53,8 +61,8 @@ pub async fn handle_credential_router(
|
|||
}
|
||||
|
||||
/// Generate via the router's Artcraft provider, authenticating with the
|
||||
/// stored session cookies. Requests carry media tokens only, which Artcraft
|
||||
/// accepts directly — no pre-upload step is needed.
|
||||
/// stored session cookies. Artcraft's API is token-native; local image
|
||||
/// files and bytes upload to ArtCraft here, at generate time.
|
||||
async fn handle_artcraft_credential(
|
||||
request: &TauriGenerateMeshRequest,
|
||||
credential: &AuthCredential,
|
||||
|
|
@ -69,6 +77,28 @@ async fn handle_artcraft_credential(
|
|||
|
||||
let creds = storyteller_creds_from_credential(credential)?;
|
||||
|
||||
let media = request.media_sources();
|
||||
let reference_images = sources_to_artcraft_tokens(media.reference_images, ArtcraftMediaKind::Image, Some(&creds), &api_host)
|
||||
.await?.map(ImageListRef::MediaFileTokens);
|
||||
let front_image = maybe_source_to_artcraft_token(media.front_image, ArtcraftMediaKind::Image, Some(&creds), &api_host)
|
||||
.await?.map(ImageRef::MediaFileToken);
|
||||
let back_image = maybe_source_to_artcraft_token(media.back_image, ArtcraftMediaKind::Image, Some(&creds), &api_host)
|
||||
.await?.map(ImageRef::MediaFileToken);
|
||||
let left_image = maybe_source_to_artcraft_token(media.left_image, ArtcraftMediaKind::Image, Some(&creds), &api_host)
|
||||
.await?.map(ImageRef::MediaFileToken);
|
||||
let right_image = maybe_source_to_artcraft_token(media.right_image, ArtcraftMediaKind::Image, Some(&creds), &api_host)
|
||||
.await?.map(ImageRef::MediaFileToken);
|
||||
// There's no ArtCraft mesh-file upload endpoint yet; meshes stay token-only.
|
||||
let input_mesh = match media.input_mesh {
|
||||
None => None,
|
||||
Some(TauriMediaSource::MediaFileToken { token }) => Some(MeshRef::MediaFileToken(token)),
|
||||
Some(_) => {
|
||||
return Err(GenerateError::NotYetImplemented(
|
||||
"Local mesh files can't be used as generation inputs yet; pick one from the library".to_string(),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let client = RouterClient::Artcraft(RouterArtcraftClient::new(
|
||||
api_host,
|
||||
creds,
|
||||
|
|
@ -78,12 +108,12 @@ async fn handle_artcraft_credential(
|
|||
model: router_model,
|
||||
provider: RouterProvider::Artcraft,
|
||||
prompt: request.prompt.clone(),
|
||||
reference_images: request.reference_image_media_tokens.clone().map(ImageListRef::MediaFileTokens),
|
||||
front_image: request.front_image_media_token.clone().map(ImageRef::MediaFileToken),
|
||||
back_image: request.back_image_media_token.clone().map(ImageRef::MediaFileToken),
|
||||
left_image: request.left_image_media_token.clone().map(ImageRef::MediaFileToken),
|
||||
right_image: request.right_image_media_token.clone().map(ImageRef::MediaFileToken),
|
||||
input_mesh: request.input_mesh_media_token.clone().map(MeshRef::MediaFileToken),
|
||||
reference_images,
|
||||
front_image,
|
||||
back_image,
|
||||
left_image,
|
||||
right_image,
|
||||
input_mesh,
|
||||
mesh_output_type: request.mesh_output_type,
|
||||
polygon_type: request.polygon_type,
|
||||
face_count: request.face_count,
|
||||
|
|
|
|||
|
|
@ -5,6 +5,9 @@ use artcraft_client::enums::common::generation::common_mesh_quality::CommonMeshQ
|
|||
use artcraft_client::enums::common::generation::common_polygon_type::CommonPolygonType;
|
||||
use sqlite_identifiers::ids::media_file_token::MediaFileToken;
|
||||
|
||||
use crate::commands::generate::common::tauri_media_source::{
|
||||
merge_source_with_legacy_token, merge_sources_with_legacy_tokens, TauriMediaSource,
|
||||
};
|
||||
use crate::commands::utils::response::success_response_wrapper::SerializeMarker;
|
||||
|
||||
// ── Request ──
|
||||
|
|
@ -34,6 +37,15 @@ pub struct TauriGenerateMeshRequest {
|
|||
/// Input mesh for mesh-to-mesh models (part splitting, retopology).
|
||||
pub input_mesh_media_token: Option<MediaFileToken>,
|
||||
|
||||
// Three-way media sources (bytes | local path | media token). Each wins
|
||||
// over its legacy `*_media_token(s)` twin above when both are set.
|
||||
pub reference_image_sources: Option<Vec<TauriMediaSource>>,
|
||||
pub front_image_source: Option<TauriMediaSource>,
|
||||
pub back_image_source: Option<TauriMediaSource>,
|
||||
pub left_image_source: Option<TauriMediaSource>,
|
||||
pub right_image_source: Option<TauriMediaSource>,
|
||||
pub input_mesh_source: Option<TauriMediaSource>,
|
||||
|
||||
/// Requested mesh output type.
|
||||
pub mesh_output_type: Option<CommonMeshOutputType>,
|
||||
|
||||
|
|
@ -67,6 +79,45 @@ pub struct TauriGenerateMeshRequest {
|
|||
pub frontend_subscriber_payload: Option<String>,
|
||||
}
|
||||
|
||||
/// The mesh request's media inputs, normalized: legacy token fields fold
|
||||
/// into [`TauriMediaSource`]s so handlers see one shape.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MeshRequestMediaSources {
|
||||
pub reference_images: Option<Vec<TauriMediaSource>>,
|
||||
pub front_image: Option<TauriMediaSource>,
|
||||
pub back_image: Option<TauriMediaSource>,
|
||||
pub left_image: Option<TauriMediaSource>,
|
||||
pub right_image: Option<TauriMediaSource>,
|
||||
pub input_mesh: Option<TauriMediaSource>,
|
||||
}
|
||||
|
||||
impl TauriGenerateMeshRequest {
|
||||
pub fn media_sources(&self) -> MeshRequestMediaSources {
|
||||
MeshRequestMediaSources {
|
||||
reference_images: merge_sources_with_legacy_tokens(
|
||||
self.reference_image_sources.clone(), self.reference_image_media_tokens.clone(),
|
||||
),
|
||||
front_image: merge_source_with_legacy_token(self.front_image_source.clone(), self.front_image_media_token.clone()),
|
||||
back_image: merge_source_with_legacy_token(self.back_image_source.clone(), self.back_image_media_token.clone()),
|
||||
left_image: merge_source_with_legacy_token(self.left_image_source.clone(), self.left_image_media_token.clone()),
|
||||
right_image: merge_source_with_legacy_token(self.right_image_source.clone(), self.right_image_media_token.clone()),
|
||||
input_mesh: merge_source_with_legacy_token(self.input_mesh_source.clone(), self.input_mesh_media_token.clone()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MeshRequestMediaSources {
|
||||
/// Every source the request carries, for up-front validation.
|
||||
pub fn iter(&self) -> impl Iterator<Item = &TauriMediaSource> {
|
||||
self.reference_images.iter().flatten()
|
||||
.chain(self.front_image.iter())
|
||||
.chain(self.back_image.iter())
|
||||
.chain(self.left_image.iter())
|
||||
.chain(self.right_image.iter())
|
||||
.chain(self.input_mesh.iter())
|
||||
}
|
||||
}
|
||||
|
||||
/// The mesh models the frontend can request, identified by their omni
|
||||
/// model ids (`CommonMeshModel` serde strings).
|
||||
#[derive(Clone, Copy, Debug, Deserialize)]
|
||||
|
|
|
|||
|
|
@ -15,6 +15,10 @@ use crate::commands::utils::api_adapters::models::splat::tauri_splat_model_to_ro
|
|||
use crate::commands::generate::generate_error::GenerateError;
|
||||
use crate::commands::generate::task_enqueue_success::TaskEnqueueSuccess;
|
||||
use crate::commands::generate::common::generation_credential::{credential_not_usable, resolve_generation_credential, storyteller_creds_from_credential};
|
||||
use crate::commands::generate::common::media_source_conversion::{
|
||||
maybe_source_to_artcraft_token, sources_to_artcraft_tokens, ArtcraftMediaKind,
|
||||
};
|
||||
use crate::commands::generate::common::tauri_media_source::validate_sources;
|
||||
use crate::commands::generate::generate_splat::request::TauriGenerateSplatRequest;
|
||||
use crate::utils::services::artcraft_api_host::maybe_artcraft_api_host_for_service;
|
||||
use crate::credentials::auth_credential::AuthCredential;
|
||||
|
|
@ -27,6 +31,10 @@ pub async fn handle_credential_router(
|
|||
request: &TauriGenerateSplatRequest,
|
||||
app_data_root: &AppDataRoot,
|
||||
) -> Result<TaskEnqueueSuccess, GenerateError> {
|
||||
// Reject unusable media sources (missing local files, empty bytes) before
|
||||
// any provider work.
|
||||
validate_sources(request.media_sources().iter())?;
|
||||
|
||||
let credential = resolve_generation_credential(
|
||||
request.credential_id.as_deref(),
|
||||
app_data_root,
|
||||
|
|
@ -52,8 +60,8 @@ pub async fn handle_credential_router(
|
|||
}
|
||||
|
||||
/// Generate via the router's Artcraft provider, authenticating with the
|
||||
/// stored session cookies. Requests carry media tokens only, which Artcraft
|
||||
/// accepts directly — no pre-upload step is needed.
|
||||
/// stored session cookies. Artcraft's API is token-native; local files and
|
||||
/// bytes upload to ArtCraft here, at generate time.
|
||||
async fn handle_artcraft_credential(
|
||||
request: &TauriGenerateSplatRequest,
|
||||
credential: &AuthCredential,
|
||||
|
|
@ -68,6 +76,12 @@ async fn handle_artcraft_credential(
|
|||
|
||||
let creds = storyteller_creds_from_credential(credential)?;
|
||||
|
||||
let media = request.media_sources();
|
||||
let reference_images = sources_to_artcraft_tokens(media.reference_images, ArtcraftMediaKind::Image, Some(&creds), &api_host)
|
||||
.await?.map(ImageListRef::MediaFileTokens);
|
||||
let reference_video = maybe_source_to_artcraft_token(media.reference_video, ArtcraftMediaKind::Video, Some(&creds), &api_host)
|
||||
.await?.map(VideoRef::MediaFileToken);
|
||||
|
||||
let client = RouterClient::Artcraft(RouterArtcraftClient::new(
|
||||
api_host,
|
||||
creds,
|
||||
|
|
@ -77,8 +91,8 @@ async fn handle_artcraft_credential(
|
|||
model: router_model,
|
||||
provider: RouterProvider::Artcraft,
|
||||
prompt: request.prompt.clone(),
|
||||
reference_images: request.reference_image_media_tokens.clone().map(ImageListRef::MediaFileTokens),
|
||||
reference_video: request.reference_video_media_token.clone().map(VideoRef::MediaFileToken),
|
||||
reference_images,
|
||||
reference_video,
|
||||
is_panoramic: request.is_panoramic,
|
||||
disable_recaption: request.disable_recaption,
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,9 @@ use sqlite_identifiers::enums::tauri_command_caller::TauriCommandCaller;
|
|||
use serde_derive::{Deserialize, Serialize};
|
||||
use sqlite_identifiers::ids::media_file_token::MediaFileToken;
|
||||
|
||||
use crate::commands::generate::common::tauri_media_source::{
|
||||
merge_source_with_legacy_token, merge_sources_with_legacy_tokens, TauriMediaSource,
|
||||
};
|
||||
use crate::commands::utils::response::success_response_wrapper::SerializeMarker;
|
||||
|
||||
// ── Request ──
|
||||
|
|
@ -25,6 +28,11 @@ pub struct TauriGenerateSplatRequest {
|
|||
/// Reference video (already uploaded).
|
||||
pub reference_video_media_token: Option<MediaFileToken>,
|
||||
|
||||
// Three-way media sources (bytes | local path | media token). Each wins
|
||||
// over its legacy `*_media_token(s)` twin above when both are set.
|
||||
pub reference_image_sources: Option<Vec<TauriMediaSource>>,
|
||||
pub reference_video_source: Option<TauriMediaSource>,
|
||||
|
||||
/// The reference image is a 360-degree panorama.
|
||||
pub is_panoramic: Option<bool>,
|
||||
|
||||
|
|
@ -43,6 +51,34 @@ pub struct TauriGenerateSplatRequest {
|
|||
pub frontend_subscriber_payload: Option<String>,
|
||||
}
|
||||
|
||||
/// The splat request's media inputs, normalized: legacy token fields fold
|
||||
/// into [`TauriMediaSource`]s so handlers see one shape.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SplatRequestMediaSources {
|
||||
pub reference_images: Option<Vec<TauriMediaSource>>,
|
||||
pub reference_video: Option<TauriMediaSource>,
|
||||
}
|
||||
|
||||
impl TauriGenerateSplatRequest {
|
||||
pub fn media_sources(&self) -> SplatRequestMediaSources {
|
||||
SplatRequestMediaSources {
|
||||
reference_images: merge_sources_with_legacy_tokens(
|
||||
self.reference_image_sources.clone(), self.reference_image_media_tokens.clone(),
|
||||
),
|
||||
reference_video: merge_source_with_legacy_token(
|
||||
self.reference_video_source.clone(), self.reference_video_media_token.clone(),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SplatRequestMediaSources {
|
||||
/// Every source the request carries, for up-front validation.
|
||||
pub fn iter(&self) -> impl Iterator<Item = &TauriMediaSource> {
|
||||
self.reference_images.iter().flatten().chain(self.reference_video.iter())
|
||||
}
|
||||
}
|
||||
|
||||
/// The splat models the frontend can request, identified by their omni
|
||||
/// model ids (`CommonSplatModel` serde strings).
|
||||
#[derive(Clone, Copy, Debug, Deserialize)]
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
use crate::commands::generate::common::media_source_conversion::{
|
||||
maybe_source_to_artcraft_token, sources_to_artcraft_tokens, ArtcraftMediaKind,
|
||||
};
|
||||
use crate::commands::generate::generate_error::GenerateError;
|
||||
use crate::commands::generate::generate_video::request::TauriGenerateVideoRequest;
|
||||
use crate::commands::generate::task_enqueue_success::TaskEnqueueSuccess;
|
||||
|
|
@ -33,12 +36,20 @@ pub async fn handle_artcraft_video_via_router(
|
|||
creds.clone(),
|
||||
));
|
||||
|
||||
let start_frame = request.image_media_token.clone().map(ImageRef::MediaFileToken);
|
||||
let end_frame = request.end_frame_image_media_token.clone().map(ImageRef::MediaFileToken);
|
||||
// ArtCraft's API is token-native: local files and bytes upload to
|
||||
// ArtCraft here, at generate time (the media must reach ArtCraft anyway).
|
||||
let media = request.media_sources();
|
||||
let start_frame = maybe_source_to_artcraft_token(media.start_frame, ArtcraftMediaKind::Image, Some(creds), api_host)
|
||||
.await?.map(ImageRef::MediaFileToken);
|
||||
let end_frame = maybe_source_to_artcraft_token(media.end_frame, ArtcraftMediaKind::Image, Some(creds), api_host)
|
||||
.await?.map(ImageRef::MediaFileToken);
|
||||
|
||||
let reference_images = request.reference_image_media_tokens.clone().map(ImageListRef::MediaFileTokens);
|
||||
let reference_videos = request.reference_video_media_tokens.clone().map(VideoListRef::MediaFileTokens);
|
||||
let reference_audio = request.reference_audio_media_tokens.clone().map(AudioListRef::MediaFileTokens);
|
||||
let reference_images = sources_to_artcraft_tokens(media.reference_images, ArtcraftMediaKind::Image, Some(creds), api_host)
|
||||
.await?.map(ImageListRef::MediaFileTokens);
|
||||
let reference_videos = sources_to_artcraft_tokens(media.reference_videos, ArtcraftMediaKind::Video, Some(creds), api_host)
|
||||
.await?.map(VideoListRef::MediaFileTokens);
|
||||
let reference_audio = sources_to_artcraft_tokens(media.reference_audios, ArtcraftMediaKind::Audio, Some(creds), api_host)
|
||||
.await?.map(AudioListRef::MediaFileTokens);
|
||||
|
||||
let reference_character_tokens = request.reference_character_tokens.clone().map(CharacterListRef::CharacterTokens);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
use crate::commands::generate::common::notify_frontend_of_errors::notify_frontend_of_errors;
|
||||
use crate::commands::generate::common::tauri_media_source::validate_sources;
|
||||
use crate::commands::generate::generate_error::{BadInputReason, GenerateError, MissingCredentialsReason};
|
||||
use crate::commands::generate::task_enqueue_success::TaskEnqueueSuccess;
|
||||
use crate::commands::generate::generate_video::handle_credential_router::handle_credential_router;
|
||||
|
|
@ -63,6 +64,10 @@ pub async fn generate_video_command(
|
|||
error_type = TauriGenerateVideoErrorType::ModelNotSpecified;
|
||||
error_message = "No model specified for video generation".to_string();
|
||||
}
|
||||
GenerateError::BadInput(BadInputReason::LocalMediaFileNotFound { path }) => {
|
||||
status = CommandErrorStatus::BadRequest;
|
||||
error_message = format!("A reference file no longer exists on disk: {}", path.display());
|
||||
}
|
||||
GenerateError::NoProviderAvailable => {
|
||||
status = CommandErrorStatus::ServerError;
|
||||
error_type = TauriGenerateVideoErrorType::NoProviderAvailable;
|
||||
|
|
@ -121,6 +126,10 @@ async fn handle_request(
|
|||
task_database: &TaskDatabase,
|
||||
) -> Result<TaskEnqueueSuccess, GenerateError> {
|
||||
|
||||
// Reject unusable media sources (missing local files, empty bytes) before
|
||||
// any provider work.
|
||||
validate_sources(request.media_sources().iter())?;
|
||||
|
||||
// Generation is credential-driven: the request names a stored credential,
|
||||
// and the router dispatches to that credential's service.
|
||||
let result = handle_credential_router(&request, app_data_root).await;
|
||||
|
|
|
|||
|
|
@ -3,19 +3,17 @@
|
|||
|
||||
use core_types::enums::generation_source::GenerationSource;
|
||||
use log::info;
|
||||
use router::api::audio_list_ref::AudioListRef;
|
||||
use router::api::image_list_ref::ImageListRef;
|
||||
use router::api::image_ref::ImageRef;
|
||||
use router::api::router_provider::RouterProvider;
|
||||
use router::api::video_list_ref::VideoListRef;
|
||||
use router::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
|
||||
use router::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use sqlite_identifiers::enums::task_type::TaskType;
|
||||
use sqlite_identifiers::ids::media_file_token::MediaFileToken;
|
||||
|
||||
use crate::commands::generate::common::higgsfield_generation::{
|
||||
higgsfield_media_url_map, higgsfield_router_client, join_higgsfield_job_ids, send_higgsfield_video_request,
|
||||
};
|
||||
use crate::commands::generate::common::media_source_conversion::{
|
||||
collect_source_tokens, source_to_image_ref, sources_to_audio_list, sources_to_image_list, sources_to_video_list,
|
||||
};
|
||||
use crate::commands::generate::generate_error::GenerateError;
|
||||
use crate::commands::generate::generate_video::request::TauriGenerateVideoRequest;
|
||||
use crate::commands::generate::task_enqueue_success::TaskEnqueueSuccess;
|
||||
|
|
@ -25,11 +23,13 @@ use crate::credentials::auth_credential::AuthCredential;
|
|||
|
||||
/// Enqueue via the router's first-party Higgsfield provider.
|
||||
///
|
||||
/// Keyframes and image / video / audio references travel as ArtCraft media
|
||||
/// tokens; the router resolves them to CDN URLs through the map built here,
|
||||
/// downloads them, and uploads them to Higgsfield (running its IP check
|
||||
/// where the model demands it) before enqueuing. The returned task carries
|
||||
/// every job id of the Higgsfield job set so the poller can follow a batch.
|
||||
/// Keyframes and image / video / audio references arrive as three-way media
|
||||
/// sources. Local files and raw bytes go straight to Higgsfield; only
|
||||
/// ArtCraft media tokens (cloud-library picks) resolve to CDN URLs through
|
||||
/// the map built here before the router downloads and re-uploads them
|
||||
/// (running Higgsfield's IP check where the model demands it). The returned
|
||||
/// task carries every job id of the Higgsfield job set so the poller can
|
||||
/// follow a batch.
|
||||
pub async fn handle_higgsfield_video_via_router(
|
||||
request: &TauriGenerateVideoRequest,
|
||||
credential: &AuthCredential,
|
||||
|
|
@ -38,7 +38,8 @@ pub async fn handle_higgsfield_video_via_router(
|
|||
let router_model = tauri_video_model_to_router_model(tauri_model);
|
||||
let generation_model = tauri_video_model_to_generation_model(tauri_model);
|
||||
|
||||
let media_url_map = higgsfield_media_url_map(&collect_media_tokens(request)).await?;
|
||||
let media = request.media_sources();
|
||||
let media_url_map = higgsfield_media_url_map(&collect_source_tokens(media.iter())).await?;
|
||||
|
||||
// Character references have no Higgsfield equivalent; the router drops
|
||||
// them with a warning, so they aren't forwarded at all.
|
||||
|
|
@ -47,11 +48,11 @@ pub async fn handle_higgsfield_video_via_router(
|
|||
provider: RouterProvider::Higgsfield,
|
||||
prompt: request.prompt.clone(),
|
||||
negative_prompt: request.negative_prompt.clone(),
|
||||
start_frame: request.start_frame_image_media_token.clone().map(ImageRef::MediaFileToken),
|
||||
end_frame: request.end_frame_image_media_token.clone().map(ImageRef::MediaFileToken),
|
||||
reference_images: request.reference_image_media_tokens.clone().map(ImageListRef::MediaFileTokens),
|
||||
reference_videos: request.reference_video_media_tokens.clone().map(VideoListRef::MediaFileTokens),
|
||||
reference_audio: request.reference_audio_media_tokens.clone().map(AudioListRef::MediaFileTokens),
|
||||
start_frame: source_to_image_ref(media.start_frame),
|
||||
end_frame: source_to_image_ref(media.end_frame),
|
||||
reference_images: sources_to_image_list(media.reference_images),
|
||||
reference_videos: sources_to_video_list(media.reference_videos),
|
||||
reference_audio: sources_to_audio_list(media.reference_audios),
|
||||
reference_character_tokens: None,
|
||||
resolution: request.resolution,
|
||||
aspect_ratio: request.aspect_ratio,
|
||||
|
|
@ -89,19 +90,6 @@ pub async fn handle_higgsfield_video_via_router(
|
|||
})
|
||||
}
|
||||
|
||||
/// Every media token the request references, so each can be resolved to a
|
||||
/// CDN URL for re-upload.
|
||||
fn collect_media_tokens(request: &TauriGenerateVideoRequest) -> Vec<MediaFileToken> {
|
||||
let mut tokens: Vec<MediaFileToken> = Vec::new();
|
||||
tokens.extend(request.start_frame_image_media_token.clone());
|
||||
tokens.extend(request.end_frame_image_media_token.clone());
|
||||
tokens.extend(request.reference_image_media_tokens.clone().unwrap_or_default());
|
||||
tokens.extend(request.reference_video_media_tokens.clone().unwrap_or_default());
|
||||
tokens.extend(request.reference_audio_media_tokens.clone().unwrap_or_default());
|
||||
tokens.dedup();
|
||||
tokens
|
||||
}
|
||||
|
||||
/// Live smoke tests that hit the REAL Higgsfield API with the REAL stored
|
||||
/// credential and SPEND HIGGSFIELD CREDITS. `#[ignore]`; run explicitly:
|
||||
/// SQLX_OFFLINE=true cargo test -p artcraftx live_higgsfield_video -- --ignored --nocapture
|
||||
|
|
@ -109,6 +97,7 @@ fn collect_media_tokens(request: &TauriGenerateVideoRequest) -> Vec<MediaFileTok
|
|||
mod live_higgsfield_video_tests {
|
||||
use router::api::router_aspect_ratio::RouterAspectRatio;
|
||||
use router::api::router_resolution::RouterResolution;
|
||||
use sqlite_identifiers::ids::media_file_token::MediaFileToken;
|
||||
|
||||
use crate::commands::generate::generate_video::request::TauriVideoModel;
|
||||
use crate::state::data_dir::app_data_root::AppDataRoot;
|
||||
|
|
|
|||
|
|
@ -6,6 +6,9 @@ use serde_derive::{Deserialize, Serialize};
|
|||
use artcraft_client::tokens::characters::CharacterToken;
|
||||
use sqlite_identifiers::ids::media_file_token::MediaFileToken;
|
||||
|
||||
use crate::commands::generate::common::tauri_media_source::{
|
||||
merge_source_with_legacy_token, merge_sources_with_legacy_tokens, TauriMediaSource,
|
||||
};
|
||||
use crate::commands::utils::response::success_response_wrapper::SerializeMarker;
|
||||
|
||||
/// This is used in the Tauri command bridge.
|
||||
|
|
@ -127,6 +130,16 @@ pub struct TauriGenerateVideoRequest {
|
|||
pub reference_audio_media_tokens: Option<Vec<MediaFileToken>>,
|
||||
pub reference_character_tokens: Option<Vec<CharacterToken>>,
|
||||
|
||||
// Three-way media sources (bytes | local path | media token). Each wins
|
||||
// over its legacy `*_media_token(s)` twin above when both are set; local
|
||||
// files and bytes never touch the ArtCraft cloud unless the target
|
||||
// provider requires it.
|
||||
pub start_frame_image_source: Option<TauriMediaSource>,
|
||||
pub end_frame_image_source: Option<TauriMediaSource>,
|
||||
pub reference_image_sources: Option<Vec<TauriMediaSource>>,
|
||||
pub reference_video_sources: Option<Vec<TauriMediaSource>>,
|
||||
pub reference_audio_sources: Option<Vec<TauriMediaSource>>,
|
||||
|
||||
pub aspect_ratio: Option<RouterAspectRatio>,
|
||||
pub resolution: Option<RouterResolution>,
|
||||
|
||||
|
|
@ -145,6 +158,56 @@ pub struct TauriGenerateVideoRequest {
|
|||
pub frontend_subscriber_payload: Option<String>,
|
||||
}
|
||||
|
||||
/// The request's media inputs, normalized: legacy token fields are folded
|
||||
/// into [`TauriMediaSource`]s so handlers see one shape.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct VideoRequestMediaSources {
|
||||
pub start_frame: Option<TauriMediaSource>,
|
||||
pub end_frame: Option<TauriMediaSource>,
|
||||
pub reference_images: Option<Vec<TauriMediaSource>>,
|
||||
pub reference_videos: Option<Vec<TauriMediaSource>>,
|
||||
pub reference_audios: Option<Vec<TauriMediaSource>>,
|
||||
}
|
||||
|
||||
impl TauriGenerateVideoRequest {
|
||||
pub fn media_sources(&self) -> VideoRequestMediaSources {
|
||||
VideoRequestMediaSources {
|
||||
start_frame: merge_source_with_legacy_token(
|
||||
self.start_frame_image_source.clone(),
|
||||
self.start_frame_image_media_token.clone(),
|
||||
),
|
||||
end_frame: merge_source_with_legacy_token(
|
||||
self.end_frame_image_source.clone(),
|
||||
self.end_frame_image_media_token.clone(),
|
||||
),
|
||||
reference_images: merge_sources_with_legacy_tokens(
|
||||
self.reference_image_sources.clone(),
|
||||
self.reference_image_media_tokens.clone(),
|
||||
),
|
||||
reference_videos: merge_sources_with_legacy_tokens(
|
||||
self.reference_video_sources.clone(),
|
||||
self.reference_video_media_tokens.clone(),
|
||||
),
|
||||
reference_audios: merge_sources_with_legacy_tokens(
|
||||
self.reference_audio_sources.clone(),
|
||||
self.reference_audio_media_tokens.clone(),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl VideoRequestMediaSources {
|
||||
/// Every source the request carries, for up-front validation and for
|
||||
/// collecting the tokens that need CDN resolution.
|
||||
pub fn iter(&self) -> impl Iterator<Item = &TauriMediaSource> {
|
||||
self.start_frame.iter()
|
||||
.chain(self.end_frame.iter())
|
||||
.chain(self.reference_images.iter().flatten())
|
||||
.chain(self.reference_videos.iter().flatten())
|
||||
.chain(self.reference_audios.iter().flatten())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone, Copy)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SoraOrientation {
|
||||
|
|
|
|||
|
|
@ -7,12 +7,13 @@
|
|||
},
|
||||
"app": {
|
||||
"withGlobalTauri": true,
|
||||
"windows": [
|
||||
],
|
||||
"windows": [],
|
||||
"security": {
|
||||
"assetProtocol": {
|
||||
"enable": true,
|
||||
"scope": ["**"]
|
||||
"scope": [
|
||||
"**"
|
||||
]
|
||||
},
|
||||
"csp": {
|
||||
"default-src": [
|
||||
|
|
@ -59,6 +60,14 @@
|
|||
"ipc://localhost",
|
||||
"tauri://localhost"
|
||||
],
|
||||
"media-src": [
|
||||
"'self'",
|
||||
"*",
|
||||
"asset:",
|
||||
"blob:",
|
||||
"data:",
|
||||
"https://asset.localhost"
|
||||
],
|
||||
"script-src": [
|
||||
"'self'",
|
||||
"'unsafe-eval'",
|
||||
|
|
|
|||
|
|
@ -11,12 +11,13 @@
|
|||
},
|
||||
"app": {
|
||||
"withGlobalTauri": true,
|
||||
"windows": [
|
||||
],
|
||||
"windows": [],
|
||||
"security": {
|
||||
"assetProtocol": {
|
||||
"enable": true,
|
||||
"scope": ["**"]
|
||||
"scope": [
|
||||
"**"
|
||||
]
|
||||
},
|
||||
"csp": {
|
||||
"default-src": [
|
||||
|
|
@ -63,6 +64,14 @@
|
|||
"ipc://localhost",
|
||||
"tauri://localhost"
|
||||
],
|
||||
"media-src": [
|
||||
"'self'",
|
||||
"*",
|
||||
"asset:",
|
||||
"blob:",
|
||||
"data:",
|
||||
"https://asset.localhost"
|
||||
],
|
||||
"script-src": [
|
||||
"'self'",
|
||||
"'unsafe-eval'",
|
||||
|
|
|
|||
|
|
@ -15,7 +15,9 @@
|
|||
"security": {
|
||||
"assetProtocol": {
|
||||
"enable": true,
|
||||
"scope": ["**"]
|
||||
"scope": [
|
||||
"**"
|
||||
]
|
||||
},
|
||||
"csp": {
|
||||
"default-src": [
|
||||
|
|
@ -62,6 +64,14 @@
|
|||
"ipc://localhost",
|
||||
"tauri://localhost"
|
||||
],
|
||||
"media-src": [
|
||||
"'self'",
|
||||
"*",
|
||||
"asset:",
|
||||
"blob:",
|
||||
"data:",
|
||||
"https://asset.localhost"
|
||||
],
|
||||
"script-src": [
|
||||
"'self'",
|
||||
"'unsafe-eval'",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,35 @@
|
|||
use sqlite_identifiers::ids::media_file_token::MediaFileToken;
|
||||
|
||||
use crate::api::audio_ref::AudioRef;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum AudioListRef {
|
||||
MediaFileTokens(Vec<MediaFileToken>),
|
||||
Urls(Vec<String>),
|
||||
/// Mixed per-item sources (tokens, URLs, local paths, bytes) — the general
|
||||
/// form; the homogeneous variants above remain as conveniences.
|
||||
Sources(Vec<AudioRef>),
|
||||
}
|
||||
|
||||
impl AudioListRef {
|
||||
/// Normalize any variant into per-item refs, in order.
|
||||
pub fn into_refs(self) -> Vec<AudioRef> {
|
||||
match self {
|
||||
Self::MediaFileTokens(tokens) => tokens.into_iter().map(AudioRef::MediaFileToken).collect(),
|
||||
Self::Urls(urls) => urls.into_iter().map(AudioRef::Url).collect(),
|
||||
Self::Sources(refs) => refs,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
match self {
|
||||
Self::MediaFileTokens(tokens) => tokens.len(),
|
||||
Self::Urls(urls) => urls.len(),
|
||||
Self::Sources(refs) => refs.len(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.len() == 0
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,16 @@
|
|||
use std::path::PathBuf;
|
||||
|
||||
use sqlite_identifiers::ids::media_file_token::MediaFileToken;
|
||||
|
||||
use crate::api::media_bytes::MediaBytes;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum AudioRef {
|
||||
MediaFileToken(MediaFileToken),
|
||||
Url(String),
|
||||
/// A file on the local filesystem; read directly, never round-tripped
|
||||
/// through the ArtCraft cloud.
|
||||
LocalPath(PathBuf),
|
||||
/// Bytes the caller already holds (e.g. pasted media).
|
||||
Bytes(MediaBytes),
|
||||
}
|
||||
|
|
@ -1,7 +1,35 @@
|
|||
use sqlite_identifiers::ids::media_file_token::MediaFileToken;
|
||||
|
||||
use crate::api::image_ref::ImageRef;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum ImageListRef {
|
||||
MediaFileTokens(Vec<MediaFileToken>),
|
||||
Urls(Vec<String>),
|
||||
/// Mixed per-item sources (tokens, URLs, local paths, bytes) — the general
|
||||
/// form; the homogeneous variants above remain as conveniences.
|
||||
Sources(Vec<ImageRef>),
|
||||
}
|
||||
|
||||
impl ImageListRef {
|
||||
/// Normalize any variant into per-item refs, in order.
|
||||
pub fn into_refs(self) -> Vec<ImageRef> {
|
||||
match self {
|
||||
Self::MediaFileTokens(tokens) => tokens.into_iter().map(ImageRef::MediaFileToken).collect(),
|
||||
Self::Urls(urls) => urls.into_iter().map(ImageRef::Url).collect(),
|
||||
Self::Sources(refs) => refs,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
match self {
|
||||
Self::MediaFileTokens(tokens) => tokens.len(),
|
||||
Self::Urls(urls) => urls.len(),
|
||||
Self::Sources(refs) => refs.len(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.len() == 0
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,16 @@
|
|||
use std::path::PathBuf;
|
||||
|
||||
use sqlite_identifiers::ids::media_file_token::MediaFileToken;
|
||||
|
||||
use crate::api::media_bytes::MediaBytes;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum ImageRef {
|
||||
MediaFileToken(MediaFileToken),
|
||||
Url(String),
|
||||
/// A file on the local filesystem; read directly, never round-tripped
|
||||
/// through the ArtCraft cloud.
|
||||
LocalPath(PathBuf),
|
||||
/// Bytes the caller already holds (e.g. pasted media).
|
||||
Bytes(MediaBytes),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
use std::fmt::{Debug, Formatter};
|
||||
|
||||
/// In-memory media for a reference the caller already holds as bytes (e.g. a
|
||||
/// pasted image that never touched disk). The file name, when known, helps
|
||||
/// guess the MIME type; the bytes themselves are still sniffed first.
|
||||
#[derive(Clone)]
|
||||
pub struct MediaBytes {
|
||||
pub bytes: Vec<u8>,
|
||||
pub maybe_file_name: Option<String>,
|
||||
}
|
||||
|
||||
impl MediaBytes {
|
||||
pub fn new(bytes: Vec<u8>) -> Self {
|
||||
Self { bytes, maybe_file_name: None }
|
||||
}
|
||||
|
||||
pub fn with_file_name(mut self, file_name: impl Into<String>) -> Self {
|
||||
self.maybe_file_name = Some(file_name.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
// Manual impl: requests carrying these are logged; never dump the bytes.
|
||||
impl Debug for MediaBytes {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("MediaBytes")
|
||||
.field("bytes", &format_args!("<{} bytes>", self.bytes.len()))
|
||||
.field("maybe_file_name", &self.maybe_file_name)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +1,18 @@
|
|||
use std::path::PathBuf;
|
||||
|
||||
use sqlite_identifiers::ids::media_file_token::MediaFileToken;
|
||||
|
||||
use crate::api::media_bytes::MediaBytes;
|
||||
|
||||
/// Reference to an input 3D mesh file (e.g. GLB/OBJ/FBX) for mesh-to-mesh
|
||||
/// models like part splitting and retopology.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum MeshRef {
|
||||
MediaFileToken(MediaFileToken),
|
||||
Url(String),
|
||||
/// A file on the local filesystem; read directly, never round-tripped
|
||||
/// through the ArtCraft cloud.
|
||||
LocalPath(PathBuf),
|
||||
/// Bytes the caller already holds (e.g. pasted media).
|
||||
Bytes(MediaBytes),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
pub mod audio_list_ref;
|
||||
pub mod audio_ref;
|
||||
pub mod character_list_ref;
|
||||
pub mod image_list_ref;
|
||||
pub mod image_ref;
|
||||
pub mod media_bytes;
|
||||
pub mod mesh_ref;
|
||||
pub mod router_aspect_ratio;
|
||||
pub mod router_audio_model;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,35 @@
|
|||
use sqlite_identifiers::ids::media_file_token::MediaFileToken;
|
||||
|
||||
use crate::api::video_ref::VideoRef;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum VideoListRef {
|
||||
MediaFileTokens(Vec<MediaFileToken>),
|
||||
Urls(Vec<String>),
|
||||
/// Mixed per-item sources (tokens, URLs, local paths, bytes) — the general
|
||||
/// form; the homogeneous variants above remain as conveniences.
|
||||
Sources(Vec<VideoRef>),
|
||||
}
|
||||
|
||||
impl VideoListRef {
|
||||
/// Normalize any variant into per-item refs, in order.
|
||||
pub fn into_refs(self) -> Vec<VideoRef> {
|
||||
match self {
|
||||
Self::MediaFileTokens(tokens) => tokens.into_iter().map(VideoRef::MediaFileToken).collect(),
|
||||
Self::Urls(urls) => urls.into_iter().map(VideoRef::Url).collect(),
|
||||
Self::Sources(refs) => refs,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
match self {
|
||||
Self::MediaFileTokens(tokens) => tokens.len(),
|
||||
Self::Urls(urls) => urls.len(),
|
||||
Self::Sources(refs) => refs.len(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.len() == 0
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,16 @@
|
|||
use std::path::PathBuf;
|
||||
|
||||
use sqlite_identifiers::ids::media_file_token::MediaFileToken;
|
||||
|
||||
use crate::api::media_bytes::MediaBytes;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum VideoRef {
|
||||
MediaFileToken(MediaFileToken),
|
||||
Url(String),
|
||||
/// A file on the local filesystem; read directly, never round-tripped
|
||||
/// through the ArtCraft cloud.
|
||||
LocalPath(PathBuf),
|
||||
/// Bytes the caller already holds (e.g. pasted media).
|
||||
Bytes(MediaBytes),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
use std::error::Error;
|
||||
use std::fmt::{Display, Formatter};
|
||||
use std::path::PathBuf;
|
||||
use artcraft_client::tokens::characters::CharacterToken;
|
||||
use sqlite_identifiers::ids::media_file_token::MediaFileToken;
|
||||
|
||||
|
|
@ -72,6 +73,18 @@ pub enum ClientError {
|
|||
|
||||
/// A character token was not found in the provided character-token-to-id map
|
||||
CharacterTokenNotFoundInMap { token: CharacterToken },
|
||||
|
||||
/// A local-file reference points at a path that doesn't exist (or isn't a
|
||||
/// regular file).
|
||||
LocalFileNotFound { path: PathBuf },
|
||||
|
||||
/// A local-file reference exists but couldn't be read.
|
||||
LocalFileRead { path: PathBuf, error: std::io::Error },
|
||||
|
||||
/// This provider can't take local files or raw bytes directly; the caller
|
||||
/// must convert the reference first (e.g. upload it to obtain a token or
|
||||
/// URL).
|
||||
ProviderCannotUseLocalMedia { client_type: ClientType },
|
||||
}
|
||||
|
||||
impl Error for ClientError {}
|
||||
|
|
@ -115,6 +128,15 @@ impl Display for ClientError {
|
|||
Self::CharacterTokenNotFoundInMap { token } => {
|
||||
write!(f, "Character token '{}' was not found in the provided character-token-to-id map", token.as_str())
|
||||
}
|
||||
Self::LocalFileNotFound { path } => {
|
||||
write!(f, "Local media file not found (or not a regular file): {}", path.display())
|
||||
}
|
||||
Self::LocalFileRead { path, error } => {
|
||||
write!(f, "Could not read local media file {}: {}", path.display(), error)
|
||||
}
|
||||
Self::ProviderCannotUseLocalMedia { client_type } => {
|
||||
write!(f, "{} cannot take local files or raw bytes directly; convert the reference to a media token or URL first", client_type)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,9 @@
|
|||
use sqlite_identifiers::ids::media_file_token::MediaFileToken;
|
||||
|
||||
use crate::api::audio_list_ref::AudioListRef;
|
||||
use crate::api::audio_ref::AudioRef;
|
||||
use crate::api::image_list_ref::ImageListRef;
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::errors::client_error::ClientError;
|
||||
|
||||
|
|
@ -18,6 +20,16 @@ pub(super) fn resolve_audio_list_ref(
|
|||
Some(AudioListRef::Urls(_)) => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
|
||||
}
|
||||
Some(AudioListRef::Sources(refs)) => {
|
||||
let mut tokens = Vec::with_capacity(refs.len());
|
||||
for audio_ref in refs {
|
||||
match audio_ref {
|
||||
AudioRef::MediaFileToken(token) => tokens.push(token),
|
||||
_ => return Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens)),
|
||||
}
|
||||
}
|
||||
Ok(Some(tokens))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -30,5 +42,15 @@ pub(super) fn resolve_image_list_ref(
|
|||
Some(ImageListRef::Urls(_)) => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
|
||||
}
|
||||
Some(ImageListRef::Sources(refs)) => {
|
||||
let mut tokens = Vec::with_capacity(refs.len());
|
||||
for image_ref in refs {
|
||||
match image_ref {
|
||||
ImageRef::MediaFileToken(token) => tokens.push(token),
|
||||
_ => return Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens)),
|
||||
}
|
||||
}
|
||||
Ok(Some(tokens))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,9 @@ use fal_client::requests::api::audio::omni::seed_audio_1p0::api::{
|
|||
};
|
||||
|
||||
use crate::api::audio_list_ref::AudioListRef;
|
||||
use crate::api::audio_ref::AudioRef;
|
||||
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;
|
||||
|
|
@ -88,6 +90,16 @@ fn plan_audio_urls(
|
|||
Some(AudioListRef::MediaFileTokens(_)) => {
|
||||
return Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls));
|
||||
}
|
||||
Some(AudioListRef::Sources(refs)) => {
|
||||
let mut urls = Vec::with_capacity(refs.len());
|
||||
for audio_ref in refs {
|
||||
match audio_ref {
|
||||
AudioRef::Url(url) => urls.push(url),
|
||||
_ => return Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)),
|
||||
}
|
||||
}
|
||||
urls
|
||||
}
|
||||
};
|
||||
if urls.is_empty() {
|
||||
return Ok(None);
|
||||
|
|
@ -127,6 +139,16 @@ fn plan_image_url(
|
|||
Some(ImageListRef::MediaFileTokens(_)) => {
|
||||
return Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls));
|
||||
}
|
||||
Some(ImageListRef::Sources(refs)) => {
|
||||
let mut urls = Vec::with_capacity(refs.len());
|
||||
for image_ref in refs {
|
||||
match image_ref {
|
||||
ImageRef::Url(url) => urls.push(url),
|
||||
_ => return Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)),
|
||||
}
|
||||
}
|
||||
urls
|
||||
}
|
||||
};
|
||||
if urls.is_empty() {
|
||||
return Ok(None);
|
||||
|
|
|
|||
|
|
@ -1,26 +1,23 @@
|
|||
//! Shared resolve helpers for the Kinovi audio provider.
|
||||
//!
|
||||
//! The remix/sample models take exactly one user audio reference, which must
|
||||
//! be downloaded from our CDN and re-uploaded to the Kinovi CDN during the
|
||||
//! draft `finalize()` phase. The upload plumbing is shared with the Kinovi
|
||||
//! video provider (`generate_video::providers::kinovi::upload`).
|
||||
//! The remix/sample models take exactly one user audio reference, which is
|
||||
//! resolved to bytes (CDN download, local read, or as-is) and re-uploaded to
|
||||
//! the Kinovi CDN during the draft `finalize()` phase. The upload plumbing is
|
||||
//! shared with the Kinovi video provider
|
||||
//! (`generate_video::providers::kinovi::upload`).
|
||||
|
||||
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
|
||||
use sqlite_identifiers::ids::media_file_token::MediaFileToken;
|
||||
|
||||
use crate::api::audio_list_ref::AudioListRef;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::errors::client_error::ClientError;
|
||||
use crate::generate::generate_audio::audio_generation_draft_context::AudioGenerationDraftContext;
|
||||
use crate::generate::generate_video::providers::kinovi::upload::upload_to_seedance2pro;
|
||||
use crate::generate::generate_video::providers::kinovi::upload::upload_source_to_seedance2pro;
|
||||
use crate::utils::media_source_ref::MediaSourceRef;
|
||||
|
||||
/// A single unresolved audio reference: either an ArtCraft/public URL or a
|
||||
/// media file token that resolves to one via the draft context's map.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum SingleAudioRef {
|
||||
Url(String),
|
||||
MediaFileToken(MediaFileToken),
|
||||
}
|
||||
/// A single unresolved audio reference: a media token (resolved via the
|
||||
/// draft context's map), a URL, a local file, or raw bytes.
|
||||
pub type SingleAudioRef = MediaSourceRef;
|
||||
|
||||
/// Extract exactly one audio reference from the builder's audio list.
|
||||
/// The Kinovi remix/sample models operate on a single source track, so zero
|
||||
|
|
@ -28,16 +25,13 @@ pub enum SingleAudioRef {
|
|||
pub(crate) fn require_single_audio_ref(
|
||||
audio_references: Option<AudioListRef>,
|
||||
) -> Result<SingleAudioRef, ArtcraftRouterError> {
|
||||
let refs = match audio_references {
|
||||
let refs: Vec<MediaSourceRef> = match audio_references {
|
||||
None => {
|
||||
return Err(ArtcraftRouterError::InvalidInput(
|
||||
"Exactly one audio reference is required, but none was provided".to_string(),
|
||||
));
|
||||
}
|
||||
Some(AudioListRef::Urls(urls)) => urls.into_iter().map(SingleAudioRef::Url).collect::<Vec<_>>(),
|
||||
Some(AudioListRef::MediaFileTokens(tokens)) => {
|
||||
tokens.into_iter().map(SingleAudioRef::MediaFileToken).collect::<Vec<_>>()
|
||||
}
|
||||
Some(list) => MediaSourceRef::list_from_audios(list),
|
||||
};
|
||||
|
||||
match refs.len() {
|
||||
|
|
@ -52,33 +46,25 @@ pub(crate) fn require_single_audio_ref(
|
|||
}
|
||||
}
|
||||
|
||||
/// Resolve a single audio reference to a source URL and re-upload it to the
|
||||
/// Kinovi CDN, returning the Kinovi CDN URL.
|
||||
/// Resolve a single audio reference and re-upload it to the Kinovi CDN,
|
||||
/// returning the Kinovi CDN URL.
|
||||
pub(crate) async fn resolve_and_upload_audio_ref(
|
||||
session: &Seedance2ProSession,
|
||||
audio_ref: &SingleAudioRef,
|
||||
draft_context: &AudioGenerationDraftContext<'_>,
|
||||
) -> Result<String, ArtcraftRouterError> {
|
||||
let source_url = match audio_ref {
|
||||
SingleAudioRef::Url(url) => url.clone(),
|
||||
SingleAudioRef::MediaFileToken(token) => {
|
||||
let map = draft_context.media_file_to_artcraft_url_map
|
||||
.ok_or(ArtcraftRouterError::Client(ClientError::MediaFileToUrlMapNotProvided))?;
|
||||
map.get(token).cloned().ok_or_else(|| {
|
||||
ArtcraftRouterError::Client(ClientError::MediaFileTokenNotFoundInMap {
|
||||
token: token.clone(),
|
||||
})
|
||||
})?
|
||||
}
|
||||
};
|
||||
upload_to_seedance2pro(session, &source_url).await
|
||||
upload_source_to_seedance2pro(session, audio_ref.clone(), draft_context.media_file_to_artcraft_url_map).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use sqlite_identifiers::ids::media_file_token::MediaFileToken;
|
||||
|
||||
use super::*;
|
||||
|
||||
mod require_single_audio_ref_tests {
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
|
|
@ -112,6 +98,15 @@ mod tests {
|
|||
assert!(matches!(result, SingleAudioRef::MediaFileToken(t) if t.as_str() == "mf_test123"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_local_path_is_accepted() {
|
||||
use crate::api::audio_ref::AudioRef;
|
||||
let result = require_single_audio_ref(Some(AudioListRef::Sources(vec![
|
||||
AudioRef::LocalPath(PathBuf::from("/tmp/track.mp3")),
|
||||
]))).expect("should accept a single local path");
|
||||
assert!(matches!(result, SingleAudioRef::LocalPath(path) if path == PathBuf::from("/tmp/track.mp3")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_urls_are_rejected() {
|
||||
let result = require_single_audio_ref(Some(AudioListRef::Urls(vec![
|
||||
|
|
|
|||
|
|
@ -40,11 +40,7 @@ pub(crate) fn reject_unsupported_audio_references(
|
|||
refs: Option<&AudioListRef>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<(), ArtcraftRouterError> {
|
||||
let has_refs = match refs {
|
||||
None => false,
|
||||
Some(AudioListRef::Urls(urls)) => !urls.is_empty(),
|
||||
Some(AudioListRef::MediaFileTokens(tokens)) => !tokens.is_empty(),
|
||||
};
|
||||
let has_refs = refs.is_some_and(|refs| !refs.is_empty());
|
||||
if has_refs {
|
||||
return reject_unsupported_option("audio_references", refs, strategy);
|
||||
}
|
||||
|
|
@ -57,11 +53,7 @@ pub(crate) fn reject_unsupported_image_references(
|
|||
refs: Option<&ImageListRef>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<(), ArtcraftRouterError> {
|
||||
let has_refs = match refs {
|
||||
None => false,
|
||||
Some(ImageListRef::Urls(urls)) => !urls.is_empty(),
|
||||
Some(ImageListRef::MediaFileTokens(tokens)) => !tokens.is_empty(),
|
||||
};
|
||||
let has_refs = refs.is_some_and(|refs| !refs.is_empty());
|
||||
if has_refs {
|
||||
return reject_unsupported_option("image_references", refs, strategy);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
use crate::api::image_list_ref::ImageListRef;
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::errors::client_error::ClientError;
|
||||
|
||||
|
|
@ -23,5 +24,18 @@ pub fn require_at_least_one_image_input(
|
|||
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
|
||||
}
|
||||
Some(ImageListRef::MediaFileTokens(_)) => Ok(()),
|
||||
Some(ImageListRef::Sources(refs)) if refs.is_empty() => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "image_inputs",
|
||||
value: format!("Angle models require exactly one input image, got {}", refs.len()),
|
||||
}))
|
||||
}
|
||||
Some(ImageListRef::Sources(refs)) => {
|
||||
if refs.iter().all(|image_ref| matches!(image_ref, ImageRef::MediaFileToken(_))) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ use crate::api::router_aspect_ratio::RouterAspectRatio;
|
|||
use crate::api::router_quality::RouterQuality;
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
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;
|
||||
|
|
@ -88,6 +89,19 @@ pub fn resolve_image_list_ref(
|
|||
None => Ok(None),
|
||||
Some(ImageListRef::MediaFileTokens(tokens)) => Ok(Some(tokens)),
|
||||
Some(ImageListRef::Urls(_)) => Ok(None),
|
||||
// Unlike the Urls form, mixed sources may carry local files or raw bytes
|
||||
// that have never been uploaded — silently dropping them would lose the
|
||||
// input entirely, so any non-token item errors out.
|
||||
Some(ImageListRef::Sources(refs)) => {
|
||||
let mut tokens = Vec::with_capacity(refs.len());
|
||||
for image_ref in refs {
|
||||
match image_ref {
|
||||
ImageRef::MediaFileToken(token) => tokens.push(token),
|
||||
_ => return Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens)),
|
||||
}
|
||||
}
|
||||
Ok(Some(tokens))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ use fal_client::requests::api::image::text::flux_1_dev_text_to_image::api::{
|
|||
|
||||
use crate::api::router_aspect_ratio::RouterAspectRatio;
|
||||
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;
|
||||
|
|
@ -138,6 +139,22 @@ fn resolve_single_image_url(
|
|||
Some(ImageListRef::MediaFileTokens(_)) => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls))
|
||||
}
|
||||
Some(ImageListRef::Sources(refs)) => {
|
||||
let mut urls = Vec::with_capacity(refs.len());
|
||||
for image_ref in refs {
|
||||
match image_ref {
|
||||
ImageRef::Url(url) => urls.push(url),
|
||||
_ => return Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)),
|
||||
}
|
||||
}
|
||||
if urls.len() > 1 {
|
||||
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "image_inputs",
|
||||
value: format!("Flux 1 Dev image-to-image supports exactly 1 image, got {}", urls.len()),
|
||||
}));
|
||||
}
|
||||
Ok(urls.into_iter().next())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ use fal_client::requests::api::image::text::flux_1_schnell_text_to_image::api::{
|
|||
|
||||
use crate::api::router_aspect_ratio::RouterAspectRatio;
|
||||
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;
|
||||
|
|
@ -159,6 +160,22 @@ fn resolve_single_image_url(
|
|||
Some(ImageListRef::MediaFileTokens(_)) => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls))
|
||||
}
|
||||
Some(ImageListRef::Sources(refs)) => {
|
||||
let mut urls = Vec::with_capacity(refs.len());
|
||||
for image_ref in refs {
|
||||
match image_ref {
|
||||
ImageRef::Url(url) => urls.push(url),
|
||||
_ => return Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)),
|
||||
}
|
||||
}
|
||||
if urls.len() > 1 {
|
||||
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "image_inputs",
|
||||
value: format!("Flux 1 Schnell redux supports exactly 1 image, got {}", urls.len()),
|
||||
}));
|
||||
}
|
||||
Ok(urls.into_iter().next())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ use fal_client::requests::api::image::angle::flux_2_lora_edit_image_angle::api::
|
|||
|
||||
use crate::api::router_aspect_ratio::RouterAspectRatio;
|
||||
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;
|
||||
|
|
@ -50,6 +51,13 @@ fn resolve_image_urls(image_inputs: Option<ImageListRef>) -> Result<Vec<String>,
|
|||
Some(ImageListRef::MediaFileTokens(_)) => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls))
|
||||
}
|
||||
Some(ImageListRef::Sources(refs)) => refs
|
||||
.into_iter()
|
||||
.map(|image_ref| match image_ref {
|
||||
ImageRef::Url(url) => Ok(url),
|
||||
_ => Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)),
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ use fal_client::requests::api::image::text::gpt_image_1_text_to_image::api::{
|
|||
use crate::api::router_aspect_ratio::RouterAspectRatio;
|
||||
use crate::api::router_quality::RouterQuality;
|
||||
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;
|
||||
|
|
@ -192,6 +193,13 @@ fn resolve_image_urls(
|
|||
Some(ImageListRef::MediaFileTokens(_)) => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls))
|
||||
}
|
||||
Some(ImageListRef::Sources(refs)) => refs
|
||||
.into_iter()
|
||||
.map(|image_ref| match image_ref {
|
||||
ImageRef::Url(url) => Ok(url),
|
||||
_ => Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)),
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ use fal_client::requests::api::image::text::gpt_image_1p5_text_to_image::api::{
|
|||
use crate::api::router_aspect_ratio::RouterAspectRatio;
|
||||
use crate::api::router_quality::RouterQuality;
|
||||
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;
|
||||
|
|
@ -192,6 +193,13 @@ fn resolve_image_urls(
|
|||
Some(ImageListRef::MediaFileTokens(_)) => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls))
|
||||
}
|
||||
Some(ImageListRef::Sources(refs)) => refs
|
||||
.into_iter()
|
||||
.map(|image_ref| match image_ref {
|
||||
ImageRef::Url(url) => Ok(url),
|
||||
_ => Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)),
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ use crate::api::router_aspect_ratio::RouterAspectRatio;
|
|||
use crate::api::router_quality::RouterQuality;
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
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;
|
||||
|
|
@ -217,6 +218,13 @@ fn resolve_image_urls(
|
|||
Some(ImageListRef::MediaFileTokens(_)) => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls))
|
||||
}
|
||||
Some(ImageListRef::Sources(refs)) => refs
|
||||
.into_iter()
|
||||
.map(|image_ref| match image_ref {
|
||||
ImageRef::Url(url) => Ok(url),
|
||||
_ => Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)),
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ use fal_client::requests_old::webhook::image::text::enqueue_gemini_25_flash_text
|
|||
|
||||
use crate::api::router_aspect_ratio::RouterAspectRatio;
|
||||
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;
|
||||
|
|
@ -184,6 +185,13 @@ fn resolve_image_list_ref(
|
|||
Some(ImageListRef::MediaFileTokens(_)) => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls))
|
||||
}
|
||||
Some(ImageListRef::Sources(refs)) => refs
|
||||
.into_iter()
|
||||
.map(|image_ref| match image_ref {
|
||||
ImageRef::Url(url) => Ok(url),
|
||||
_ => Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)),
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ use fal_client::requests::api::image::text::nano_banana_2_text_to_image::api::{
|
|||
use crate::api::router_aspect_ratio::RouterAspectRatio;
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
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;
|
||||
|
|
@ -202,6 +203,13 @@ fn resolve_image_urls(
|
|||
Some(ImageListRef::MediaFileTokens(_)) => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls))
|
||||
}
|
||||
Some(ImageListRef::Sources(refs)) => refs
|
||||
.into_iter()
|
||||
.map(|image_ref| match image_ref {
|
||||
ImageRef::Url(url) => Ok(url),
|
||||
_ => Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)),
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ use fal_client::requests::api::image::text::nano_banana_pro_text_to_image::api::
|
|||
use crate::api::router_aspect_ratio::RouterAspectRatio;
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
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;
|
||||
|
|
@ -199,6 +200,13 @@ fn resolve_image_urls(
|
|||
Some(ImageListRef::MediaFileTokens(_)) => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls))
|
||||
}
|
||||
Some(ImageListRef::Sources(refs)) => refs
|
||||
.into_iter()
|
||||
.map(|image_ref| match image_ref {
|
||||
ImageRef::Url(url) => Ok(url),
|
||||
_ => Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)),
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ use fal_client::requests::api::image::angle::qwen_edit_2511_edit_image_angle::ap
|
|||
|
||||
use crate::api::router_aspect_ratio::RouterAspectRatio;
|
||||
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;
|
||||
|
|
@ -51,6 +52,13 @@ fn resolve_image_urls(image_inputs: Option<ImageListRef>) -> Result<Vec<String>,
|
|||
Some(ImageListRef::MediaFileTokens(_)) => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls))
|
||||
}
|
||||
Some(ImageListRef::Sources(refs)) => refs
|
||||
.into_iter()
|
||||
.map(|image_ref| match image_ref {
|
||||
ImageRef::Url(url) => Ok(url),
|
||||
_ => Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)),
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ use fal_client::requests_old::webhook::image::text::enqueue_bytedance_seedream_v
|
|||
|
||||
use crate::api::router_aspect_ratio::RouterAspectRatio;
|
||||
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;
|
||||
|
|
@ -208,6 +209,13 @@ fn resolve_image_list_ref(
|
|||
Some(ImageListRef::MediaFileTokens(_)) => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls))
|
||||
}
|
||||
Some(ImageListRef::Sources(refs)) => refs
|
||||
.into_iter()
|
||||
.map(|image_ref| match image_ref {
|
||||
ImageRef::Url(url) => Ok(url),
|
||||
_ => Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)),
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ use fal_client::requests_old::webhook::image::text::enqueue_bytedance_seedream_v
|
|||
|
||||
use crate::api::router_aspect_ratio::RouterAspectRatio;
|
||||
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;
|
||||
|
|
@ -204,6 +205,13 @@ fn resolve_image_list_ref(
|
|||
Some(ImageListRef::MediaFileTokens(_)) => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls))
|
||||
}
|
||||
Some(ImageListRef::Sources(refs)) => refs
|
||||
.into_iter()
|
||||
.map(|image_ref| match image_ref {
|
||||
ImageRef::Url(url) => Ok(url),
|
||||
_ => Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)),
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ use fal_client::requests_old::webhook::image::text::enqueue_bytedance_seedream_v
|
|||
use crate::api::router_aspect_ratio::RouterAspectRatio;
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
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;
|
||||
|
|
@ -213,6 +214,13 @@ fn resolve_image_list_ref(
|
|||
Some(ImageListRef::MediaFileTokens(_)) => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls))
|
||||
}
|
||||
Some(ImageListRef::Sources(refs)) => refs
|
||||
.into_iter()
|
||||
.map(|image_ref| match image_ref {
|
||||
ImageRef::Url(url) => Ok(url),
|
||||
_ => Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)),
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -32,11 +32,7 @@ pub fn build_grok_imagine_image(
|
|||
}
|
||||
|
||||
fn has_image_inputs(image_inputs: Option<&ImageListRef>) -> bool {
|
||||
match image_inputs {
|
||||
None => false,
|
||||
Some(ImageListRef::Urls(urls)) => !urls.is_empty(),
|
||||
Some(ImageListRef::MediaFileTokens(tokens)) => !tokens.is_empty(),
|
||||
}
|
||||
image_inputs.is_some_and(|refs| !refs.is_empty())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
|
|||
|
|
@ -31,11 +31,7 @@ pub(crate) fn require_prompt(prompt: Option<String>) -> Result<String, ArtcraftR
|
|||
}
|
||||
|
||||
pub(crate) fn image_input_count(image_inputs: Option<&ImageListRef>) -> usize {
|
||||
match image_inputs {
|
||||
None => 0,
|
||||
Some(ImageListRef::Urls(urls)) => urls.len(),
|
||||
Some(ImageListRef::MediaFileTokens(tokens)) => tokens.len(),
|
||||
}
|
||||
image_inputs.map_or(0, ImageListRef::len)
|
||||
}
|
||||
|
||||
/// Reject over-limit reference lists before anything is uploaded.
|
||||
|
|
|
|||
|
|
@ -23,11 +23,9 @@ pub struct HiggsfieldImageRemainingItems {
|
|||
|
||||
impl HiggsfieldImageDraftState {
|
||||
pub fn reference_image_count(&self) -> usize {
|
||||
match self.unhandled_request_state.as_ref().and_then(|remaining| remaining.reference_images.as_ref()) {
|
||||
None => 0,
|
||||
Some(ImageListRef::Urls(urls)) => urls.len(),
|
||||
Some(ImageListRef::MediaFileTokens(tokens)) => tokens.len(),
|
||||
}
|
||||
self.unhandled_request_state.as_ref()
|
||||
.and_then(|remaining| remaining.reference_images.as_ref())
|
||||
.map_or(0, ImageListRef::len)
|
||||
}
|
||||
|
||||
pub async fn to_request(
|
||||
|
|
|
|||
|
|
@ -66,11 +66,7 @@ pub fn build_kinovi_midjourney_7(
|
|||
}
|
||||
|
||||
fn has_image_inputs(image_inputs: Option<&ImageListRef>) -> bool {
|
||||
match image_inputs {
|
||||
None => false,
|
||||
Some(ImageListRef::Urls(urls)) => !urls.is_empty(),
|
||||
Some(ImageListRef::MediaFileTokens(tokens)) => !tokens.is_empty(),
|
||||
}
|
||||
image_inputs.is_some_and(|refs| !refs.is_empty())
|
||||
}
|
||||
|
||||
// ── Plan helpers ──
|
||||
|
|
|
|||
|
|
@ -62,11 +62,7 @@ pub fn build_kinovi_midjourney_7_niji(
|
|||
}
|
||||
|
||||
fn has_image_inputs(image_inputs: Option<&ImageListRef>) -> bool {
|
||||
match image_inputs {
|
||||
None => false,
|
||||
Some(ImageListRef::Urls(urls)) => !urls.is_empty(),
|
||||
Some(ImageListRef::MediaFileTokens(tokens)) => !tokens.is_empty(),
|
||||
}
|
||||
image_inputs.is_some_and(|refs| !refs.is_empty())
|
||||
}
|
||||
|
||||
// ── Plan helpers ──
|
||||
|
|
|
|||
|
|
@ -62,11 +62,7 @@ pub fn build_kinovi_midjourney_8(
|
|||
}
|
||||
|
||||
fn has_image_inputs(image_inputs: Option<&ImageListRef>) -> bool {
|
||||
match image_inputs {
|
||||
None => false,
|
||||
Some(ImageListRef::Urls(urls)) => !urls.is_empty(),
|
||||
Some(ImageListRef::MediaFileTokens(tokens)) => !tokens.is_empty(),
|
||||
}
|
||||
image_inputs.is_some_and(|refs| !refs.is_empty())
|
||||
}
|
||||
|
||||
// ── Plan helpers ──
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@ use sqlite_identifiers::ids::media_file_token::MediaFileToken;
|
|||
|
||||
use crate::api::image_list_ref::ImageListRef;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::errors::client_error::ClientError;
|
||||
use crate::generate::generate_image::providers::kinovi::upload::upload_to_seedance2pro;
|
||||
use crate::generate::generate_image::providers::kinovi::upload::upload_source_to_seedance2pro;
|
||||
use crate::utils::media_source_ref::MediaSourceRef;
|
||||
|
||||
/// Resolve an [`ImageListRef`] to a list of public Seedance2Pro CDN URLs,
|
||||
/// uploading each image one at a time. Preserves order.
|
||||
|
|
@ -19,33 +19,15 @@ pub(crate) async fn resolve_and_upload_image_list(
|
|||
list: Option<ImageListRef>,
|
||||
maybe_map: Option<&HashMap<MediaFileToken, String>>,
|
||||
) -> Result<Option<Vec<String>>, ArtcraftRouterError> {
|
||||
let source_urls = match list {
|
||||
let sources = match list {
|
||||
None => return Ok(None),
|
||||
Some(ImageListRef::Urls(urls)) if urls.is_empty() => return Ok(None),
|
||||
Some(ImageListRef::Urls(urls)) => urls,
|
||||
Some(ImageListRef::MediaFileTokens(tokens)) if tokens.is_empty() => return Ok(None),
|
||||
Some(ImageListRef::MediaFileTokens(tokens)) => resolve_tokens(maybe_map, &tokens)?,
|
||||
Some(list) if list.is_empty() => return Ok(None),
|
||||
Some(list) => MediaSourceRef::list_from_images(list),
|
||||
};
|
||||
|
||||
let mut uploaded = Vec::with_capacity(source_urls.len());
|
||||
for url in &source_urls {
|
||||
uploaded.push(upload_to_seedance2pro(session, url).await?);
|
||||
let mut uploaded = Vec::with_capacity(sources.len());
|
||||
for source in sources {
|
||||
uploaded.push(upload_source_to_seedance2pro(session, source, maybe_map).await?);
|
||||
}
|
||||
Ok(Some(uploaded))
|
||||
}
|
||||
|
||||
fn resolve_tokens(
|
||||
maybe_map: Option<&HashMap<MediaFileToken, String>>,
|
||||
tokens: &[MediaFileToken],
|
||||
) -> Result<Vec<String>, ArtcraftRouterError> {
|
||||
let map = maybe_map.ok_or(ArtcraftRouterError::Client(ClientError::MediaFileToUrlMapNotProvided))?;
|
||||
tokens.iter()
|
||||
.map(|token| {
|
||||
map.get(token).cloned().ok_or_else(|| {
|
||||
ArtcraftRouterError::Client(ClientError::MediaFileTokenNotFoundInMap {
|
||||
token: token.clone(),
|
||||
})
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,11 +59,7 @@ pub fn build_kinovi_seedream_5p0_pro(
|
|||
}
|
||||
|
||||
fn has_image_inputs(image_inputs: Option<&ImageListRef>) -> bool {
|
||||
match image_inputs {
|
||||
None => false,
|
||||
Some(ImageListRef::Urls(urls)) => !urls.is_empty(),
|
||||
Some(ImageListRef::MediaFileTokens(tokens)) => !tokens.is_empty(),
|
||||
}
|
||||
image_inputs.is_some_and(|refs| !refs.is_empty())
|
||||
}
|
||||
|
||||
/// Reject over-limit reference lists before any uploads happen — the
|
||||
|
|
@ -72,11 +68,7 @@ fn has_image_inputs(image_inputs: Option<&ImageListRef>) -> bool {
|
|||
fn validate_reference_image_count(
|
||||
image_inputs: Option<&ImageListRef>,
|
||||
) -> Result<(), ArtcraftRouterError> {
|
||||
let count = match image_inputs {
|
||||
None => 0,
|
||||
Some(ImageListRef::Urls(urls)) => urls.len(),
|
||||
Some(ImageListRef::MediaFileTokens(tokens)) => tokens.len(),
|
||||
};
|
||||
let count = image_inputs.map_or(0, ImageListRef::len);
|
||||
if count > MAX_REFERENCE_IMAGES {
|
||||
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "image_inputs",
|
||||
|
|
|
|||
|
|
@ -1,28 +1,38 @@
|
|||
use std::collections::HashMap;
|
||||
|
||||
use log::info;
|
||||
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
|
||||
use seedance2pro_client::requests::prepare_file_upload::prepare_file_upload::{prepare_file_upload, PrepareFileUploadArgs};
|
||||
use seedance2pro_client::requests::upload_file::upload_file::{upload_file, UploadFileArgs};
|
||||
use url_utils::extension::extract_extension_from_url::{extract_extension_from_url_str, ExtractExtensions};
|
||||
use sqlite_identifiers::ids::media_file_token::MediaFileToken;
|
||||
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::errors::provider_error::ProviderError;
|
||||
use crate::utils::download_file::download_file;
|
||||
use crate::utils::media_source_ref::{
|
||||
extension_from_name_hint, resolve_media_source_bytes, MediaSourceRef, ResolvedMediaBytes,
|
||||
};
|
||||
|
||||
/// Downloads an image from a source URL and re-uploads it to the
|
||||
/// Resolves one image source to bytes (token → CDN download, URL → download,
|
||||
/// local path → disk read, bytes → as-is) and re-uploads it to the
|
||||
/// Seedance2Pro/Kinovi CDN. Returns the public URL.
|
||||
///
|
||||
/// This is the image-side twin of `generate_video::providers::kinovi::upload`.
|
||||
/// The actual upload path doesn't care whether the file is a video or an
|
||||
/// image, but keeping the two helpers separate lets each side evolve
|
||||
/// independently and makes intent clear at call sites.
|
||||
pub(crate) async fn upload_to_seedance2pro(
|
||||
pub(crate) async fn upload_source_to_seedance2pro(
|
||||
session: &Seedance2ProSession,
|
||||
source_url: &str,
|
||||
source: MediaSourceRef,
|
||||
maybe_map: Option<&HashMap<MediaFileToken, String>>,
|
||||
) -> Result<String, ArtcraftRouterError> {
|
||||
let extension = extract_extension_from_url_str(source_url, &ExtractExtensions::All)
|
||||
.map(|ext| ext.without_period().to_string())
|
||||
.unwrap_or_else(|| "jpg".to_string());
|
||||
let ResolvedMediaBytes { bytes, maybe_name_hint, description, .. } =
|
||||
resolve_media_source_bytes(source, maybe_map).await?;
|
||||
|
||||
let file_bytes = download_file(source_url).await?;
|
||||
let extension = maybe_name_hint.as_deref()
|
||||
.and_then(extension_from_name_hint)
|
||||
.unwrap_or_else(|| "jpg".to_string());
|
||||
|
||||
info!("Uploading image reference to Seedance2Pro from {} ({} bytes, .{})", description, bytes.len(), extension);
|
||||
|
||||
let prepare_response = prepare_file_upload(PrepareFileUploadArgs {
|
||||
session,
|
||||
|
|
@ -34,7 +44,7 @@ pub(crate) async fn upload_to_seedance2pro(
|
|||
|
||||
let upload_response = upload_file(UploadFileArgs {
|
||||
upload_url: prepare_response.upload_url,
|
||||
file_bytes,
|
||||
file_bytes: bytes,
|
||||
host_override: None,
|
||||
})
|
||||
.await
|
||||
|
|
|
|||
|
|
@ -67,11 +67,7 @@ fn aspect_ratio_flag(aspect_ratio: Option<RouterAspectRatio>) -> Option<&'static
|
|||
}
|
||||
|
||||
fn has_image_inputs(image_inputs: Option<&ImageListRef>) -> bool {
|
||||
match image_inputs {
|
||||
None => false,
|
||||
Some(ImageListRef::Urls(urls)) => !urls.is_empty(),
|
||||
Some(ImageListRef::MediaFileTokens(tokens)) => !tokens.is_empty(),
|
||||
}
|
||||
image_inputs.is_some_and(|refs| !refs.is_empty())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
|
|||
|
|
@ -19,6 +19,16 @@ pub(super) fn resolve_image_list_ref(
|
|||
Some(ImageListRef::Urls(_)) => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
|
||||
}
|
||||
Some(ImageListRef::Sources(refs)) => {
|
||||
let mut tokens = Vec::with_capacity(refs.len());
|
||||
for image_ref in refs {
|
||||
match image_ref {
|
||||
ImageRef::MediaFileToken(token) => tokens.push(token),
|
||||
_ => return Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens)),
|
||||
}
|
||||
}
|
||||
Ok(Some(tokens))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -28,7 +38,7 @@ pub(super) fn resolve_image_ref(
|
|||
match image_ref {
|
||||
None => Ok(None),
|
||||
Some(ImageRef::MediaFileToken(token)) => Ok(Some(token)),
|
||||
Some(ImageRef::Url(_)) => {
|
||||
Some(ImageRef::Url(_) | ImageRef::LocalPath(_) | ImageRef::Bytes(_)) => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
|
||||
}
|
||||
}
|
||||
|
|
@ -40,7 +50,7 @@ pub(super) fn resolve_mesh_ref(
|
|||
match mesh_ref {
|
||||
None => Ok(None),
|
||||
Some(MeshRef::MediaFileToken(token)) => Ok(Some(token)),
|
||||
Some(MeshRef::Url(_)) => {
|
||||
Some(MeshRef::Url(_) | MeshRef::LocalPath(_) | MeshRef::Bytes(_)) => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,14 @@ pub(crate) fn plan_primary_image_url(
|
|||
Some(ImageListRef::MediaFileTokens(_)) => {
|
||||
return Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls));
|
||||
}
|
||||
Some(ImageListRef::Sources(refs)) => {
|
||||
for image_ref in refs {
|
||||
match image_ref {
|
||||
ImageRef::Url(url) => candidates.push(url),
|
||||
_ => return Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(front) = front_image {
|
||||
|
|
@ -62,7 +70,7 @@ pub(crate) fn plan_input_mesh_url(
|
|||
match mesh_ref {
|
||||
None => Ok(None),
|
||||
Some(MeshRef::Url(url)) => Ok(Some(url)),
|
||||
Some(MeshRef::MediaFileToken(_)) => {
|
||||
Some(MeshRef::MediaFileToken(_) | MeshRef::LocalPath(_) | MeshRef::Bytes(_)) => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls))
|
||||
}
|
||||
}
|
||||
|
|
@ -119,7 +127,7 @@ pub(crate) fn plan_textured_mesh(
|
|||
fn image_ref_to_url(image_ref: ImageRef) -> Result<String, ArtcraftRouterError> {
|
||||
match image_ref {
|
||||
ImageRef::Url(url) => Ok(url),
|
||||
ImageRef::MediaFileToken(_) => {
|
||||
ImageRef::MediaFileToken(_) | ImageRef::LocalPath(_) | ImageRef::Bytes(_) => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -137,12 +137,20 @@ fn plan_image_urls(
|
|||
Some(ImageListRef::MediaFileTokens(_)) => {
|
||||
return Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls));
|
||||
}
|
||||
Some(ImageListRef::Sources(refs)) => {
|
||||
for image_ref in refs {
|
||||
match image_ref {
|
||||
ImageRef::Url(url) => urls.push(url),
|
||||
_ => return Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match front_image {
|
||||
None => {}
|
||||
Some(ImageRef::Url(url)) => urls.push(url),
|
||||
Some(ImageRef::MediaFileToken(_)) => {
|
||||
Some(ImageRef::MediaFileToken(_) | ImageRef::LocalPath(_) | ImageRef::Bytes(_)) => {
|
||||
return Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
use crate::api::image_list_ref::ImageListRef;
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::errors::client_error::ClientError;
|
||||
use crate::generate::generate_splat::generate_splat_request::GenerateSplatRequest;
|
||||
|
|
@ -35,5 +36,15 @@ fn resolve_single_image_ref(
|
|||
Some(ImageListRef::Urls(_)) => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
|
||||
}
|
||||
Some(ImageListRef::Sources(refs)) => {
|
||||
let mut tokens = Vec::with_capacity(refs.len());
|
||||
for image_ref in refs {
|
||||
match image_ref {
|
||||
ImageRef::MediaFileToken(token) => tokens.push(token),
|
||||
_ => return Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens)),
|
||||
}
|
||||
}
|
||||
Ok(tokens.into_iter().next())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
use crate::api::image_list_ref::ImageListRef;
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::errors::client_error::ClientError;
|
||||
use crate::generate::generate_splat::generate_splat_request::GenerateSplatRequest;
|
||||
|
|
@ -35,5 +36,15 @@ fn resolve_single_image_ref(
|
|||
Some(ImageListRef::Urls(_)) => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
|
||||
}
|
||||
Some(ImageListRef::Sources(refs)) => {
|
||||
let mut tokens = Vec::with_capacity(refs.len());
|
||||
for image_ref in refs {
|
||||
match image_ref {
|
||||
ImageRef::MediaFileToken(token) => tokens.push(token),
|
||||
_ => return Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens)),
|
||||
}
|
||||
}
|
||||
Ok(tokens.into_iter().next())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
use sqlite_identifiers::ids::media_file_token::MediaFileToken;
|
||||
|
||||
use crate::api::image_list_ref::ImageListRef;
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::video_ref::VideoRef;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::errors::client_error::ClientError;
|
||||
|
|
@ -18,6 +19,16 @@ pub(super) fn resolve_image_list_ref(
|
|||
Some(ImageListRef::Urls(_)) => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
|
||||
}
|
||||
Some(ImageListRef::Sources(refs)) => {
|
||||
let mut tokens = Vec::with_capacity(refs.len());
|
||||
for image_ref in refs {
|
||||
match image_ref {
|
||||
ImageRef::MediaFileToken(token) => tokens.push(token),
|
||||
_ => return Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens)),
|
||||
}
|
||||
}
|
||||
Ok(Some(tokens))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -27,7 +38,7 @@ pub(super) fn resolve_video_ref(
|
|||
match video_ref {
|
||||
None => Ok(None),
|
||||
Some(VideoRef::MediaFileToken(token)) => Ok(Some(token)),
|
||||
Some(VideoRef::Url(_)) => {
|
||||
Some(VideoRef::Url(_) | VideoRef::LocalPath(_) | VideoRef::Bytes(_)) => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ use fal_client::requests::api::splat::image::triposplat_image_to_splat::api::{
|
|||
};
|
||||
|
||||
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;
|
||||
|
|
@ -66,6 +67,16 @@ fn plan_single_image_url(
|
|||
Some(ImageListRef::MediaFileTokens(_)) => {
|
||||
return Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls));
|
||||
}
|
||||
Some(ImageListRef::Sources(refs)) => {
|
||||
let mut urls = Vec::with_capacity(refs.len());
|
||||
for image_ref in refs {
|
||||
match image_ref {
|
||||
ImageRef::Url(url) => urls.push(url),
|
||||
_ => return Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)),
|
||||
}
|
||||
}
|
||||
urls
|
||||
}
|
||||
};
|
||||
|
||||
if urls.len() > 1 {
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ use crate::api::image_ref::ImageRef;
|
|||
use crate::api::video_ref::VideoRef;
|
||||
use crate::client::router_worldlabs_client::RouterWorldLabsClient;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::errors::client_error::ClientError;
|
||||
use crate::errors::client_error::{ClientError, ClientType};
|
||||
use crate::errors::provider_error::ProviderError;
|
||||
use crate::generate::generate_splat::providers::worldlabs::request_common::WorldLabsSplatRequest;
|
||||
use crate::generate::generate_splat::providers::worldlabs::resolve::SplatInput;
|
||||
|
|
@ -150,6 +150,11 @@ fn resolve_image_source_url(
|
|||
match image {
|
||||
ImageRef::Url(url) => Ok(url.clone()),
|
||||
ImageRef::MediaFileToken(token) => resolve_token_source_url(token, draft_context),
|
||||
ImageRef::LocalPath(_) | ImageRef::Bytes(_) => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ProviderCannotUseLocalMedia {
|
||||
client_type: ClientType::WorldLabs,
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -160,6 +165,11 @@ fn resolve_video_source_url(
|
|||
match video {
|
||||
VideoRef::Url(url) => Ok(url.clone()),
|
||||
VideoRef::MediaFileToken(token) => resolve_token_source_url(token, draft_context),
|
||||
VideoRef::LocalPath(_) | VideoRef::Bytes(_) => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ProviderCannotUseLocalMedia {
|
||||
client_type: ClientType::WorldLabs,
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -81,10 +81,7 @@ pub(crate) fn plan_splat_input(
|
|||
fn image_list_to_refs(reference_images: Option<ImageListRef>) -> Vec<ImageRef> {
|
||||
match reference_images {
|
||||
None => Vec::new(),
|
||||
Some(ImageListRef::Urls(urls)) => urls.into_iter().map(ImageRef::Url).collect(),
|
||||
Some(ImageListRef::MediaFileTokens(tokens)) => {
|
||||
tokens.into_iter().map(ImageRef::MediaFileToken).collect()
|
||||
}
|
||||
Some(list) => list.into_refs(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,10 +6,12 @@ use artcraft_client::tokens::characters::CharacterToken;
|
|||
use sqlite_identifiers::ids::media_file_token::MediaFileToken;
|
||||
|
||||
use crate::api::audio_list_ref::AudioListRef;
|
||||
use crate::api::audio_ref::AudioRef;
|
||||
use crate::api::character_list_ref::CharacterListRef;
|
||||
use crate::api::image_list_ref::ImageListRef;
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::video_list_ref::VideoListRef;
|
||||
use crate::api::video_ref::VideoRef;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::errors::client_error::ClientError;
|
||||
|
||||
|
|
@ -19,7 +21,7 @@ pub(super) fn resolve_image_ref(
|
|||
match image_ref {
|
||||
None => Ok(None),
|
||||
Some(ImageRef::MediaFileToken(t)) => Ok(Some(t)),
|
||||
Some(ImageRef::Url(_)) => {
|
||||
Some(ImageRef::Url(_) | ImageRef::LocalPath(_) | ImageRef::Bytes(_)) => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
|
||||
}
|
||||
}
|
||||
|
|
@ -34,6 +36,16 @@ pub(super) fn resolve_image_list_ref(
|
|||
Some(ImageListRef::Urls(_)) => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
|
||||
}
|
||||
Some(ImageListRef::Sources(refs)) => {
|
||||
let mut tokens = Vec::with_capacity(refs.len());
|
||||
for image_ref in refs {
|
||||
match image_ref {
|
||||
ImageRef::MediaFileToken(token) => tokens.push(token),
|
||||
_ => return Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens)),
|
||||
}
|
||||
}
|
||||
Ok(Some(tokens))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -46,6 +58,16 @@ pub(super) fn resolve_video_list_ref(
|
|||
Some(VideoListRef::Urls(_)) => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
|
||||
}
|
||||
Some(VideoListRef::Sources(refs)) => {
|
||||
let mut tokens = Vec::with_capacity(refs.len());
|
||||
for video_ref in refs {
|
||||
match video_ref {
|
||||
VideoRef::MediaFileToken(token) => tokens.push(token),
|
||||
_ => return Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens)),
|
||||
}
|
||||
}
|
||||
Ok(Some(tokens))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -58,6 +80,16 @@ pub(super) fn resolve_audio_list_ref(
|
|||
Some(AudioListRef::Urls(_)) => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
|
||||
}
|
||||
Some(AudioListRef::Sources(refs)) => {
|
||||
let mut tokens = Vec::with_capacity(refs.len());
|
||||
for audio_ref in refs {
|
||||
match audio_ref {
|
||||
AudioRef::MediaFileToken(token) => tokens.push(token),
|
||||
_ => return Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens)),
|
||||
}
|
||||
}
|
||||
Ok(Some(tokens))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -113,7 +113,7 @@ pub(crate) fn require_url(
|
|||
) -> Result<String, ArtcraftRouterError> {
|
||||
match image_ref {
|
||||
Some(ImageRef::Url(url)) => Ok(url),
|
||||
Some(ImageRef::MediaFileToken(_)) => {
|
||||
Some(ImageRef::MediaFileToken(_) | ImageRef::LocalPath(_) | ImageRef::Bytes(_)) => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls))
|
||||
}
|
||||
None => Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
|
|
@ -127,7 +127,7 @@ pub(crate) fn optional_url(image_ref: Option<ImageRef>) -> Result<Option<String>
|
|||
match image_ref {
|
||||
None => Ok(None),
|
||||
Some(ImageRef::Url(url)) => Ok(Some(url)),
|
||||
Some(ImageRef::MediaFileToken(_)) => {
|
||||
Some(ImageRef::MediaFileToken(_) | ImageRef::LocalPath(_) | ImageRef::Bytes(_)) => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls))
|
||||
}
|
||||
}
|
||||
|
|
@ -142,6 +142,13 @@ fn resolve_reference_image_urls(
|
|||
Some(ImageListRef::MediaFileTokens(_)) => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls))
|
||||
}
|
||||
Some(ImageListRef::Sources(refs)) => refs
|
||||
.into_iter()
|
||||
.map(|image_ref| match image_ref {
|
||||
ImageRef::Url(url) => Ok(url),
|
||||
_ => Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)),
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ fn require_url(
|
|||
) -> Result<String, ArtcraftRouterError> {
|
||||
match start_frame {
|
||||
Some(ImageRef::Url(url)) => Ok(url),
|
||||
Some(ImageRef::MediaFileToken(_)) => {
|
||||
Some(ImageRef::MediaFileToken(_) | ImageRef::LocalPath(_) | ImageRef::Bytes(_)) => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field,
|
||||
value: "Fal only supports image URLs, not media file tokens".to_string(),
|
||||
|
|
@ -70,7 +70,7 @@ fn optional_url(image_ref: Option<ImageRef>) -> Result<Option<String>, ArtcraftR
|
|||
match image_ref {
|
||||
None => Ok(None),
|
||||
Some(ImageRef::Url(url)) => Ok(Some(url)),
|
||||
Some(ImageRef::MediaFileToken(_)) => {
|
||||
Some(ImageRef::MediaFileToken(_) | ImageRef::LocalPath(_) | ImageRef::Bytes(_)) => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "end_frame",
|
||||
value: "Fal only supports image URLs, not media file tokens".to_string(),
|
||||
|
|
|
|||
|
|
@ -104,7 +104,7 @@ fn optional_url(image_ref: Option<ImageRef>) -> Result<Option<String>, ArtcraftR
|
|||
match image_ref {
|
||||
None => Ok(None),
|
||||
Some(ImageRef::Url(url)) => Ok(Some(url)),
|
||||
Some(ImageRef::MediaFileToken(_)) => {
|
||||
Some(ImageRef::MediaFileToken(_) | ImageRef::LocalPath(_) | ImageRef::Bytes(_)) => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "start_frame/end_frame",
|
||||
value: "Fal only supports image URLs, not media file tokens".to_string(),
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ pub(crate) fn build_fal_veo_2_state(
|
|||
image_url: url,
|
||||
duration: duration.map(to_i2v_duration),
|
||||
}),
|
||||
Some(ImageRef::MediaFileToken(_)) => {
|
||||
Some(ImageRef::MediaFileToken(_) | ImageRef::LocalPath(_) | ImageRef::Bytes(_)) => {
|
||||
return Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls));
|
||||
}
|
||||
None => {
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@ pub(crate) fn build_fal_veo_3_state(
|
|||
safety_tolerance: None,
|
||||
})
|
||||
}
|
||||
Some(ImageRef::MediaFileToken(_)) => {
|
||||
Some(ImageRef::MediaFileToken(_) | ImageRef::LocalPath(_) | ImageRef::Bytes(_)) => {
|
||||
return Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls));
|
||||
}
|
||||
None => {
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ pub(crate) fn build_fal_veo_3_fast_state(
|
|||
safety_tolerance: None,
|
||||
})
|
||||
}
|
||||
Some(ImageRef::MediaFileToken(_)) => {
|
||||
Some(ImageRef::MediaFileToken(_) | ImageRef::LocalPath(_) | ImageRef::Bytes(_)) => {
|
||||
return Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls));
|
||||
}
|
||||
None => {
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ use crate::api::image_ref::ImageRef;
|
|||
use crate::api::router_aspect_ratio::RouterAspectRatio;
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
use crate::api::video_list_ref::VideoListRef;
|
||||
use crate::api::video_ref::VideoRef;
|
||||
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::errors::client_error::ClientError;
|
||||
|
|
@ -202,7 +203,7 @@ fn optional_url(image_ref: Option<ImageRef>) -> Result<Option<String>, ArtcraftR
|
|||
match image_ref {
|
||||
None => Ok(None),
|
||||
Some(ImageRef::Url(url)) => Ok(Some(url)),
|
||||
Some(ImageRef::MediaFileToken(_)) => {
|
||||
Some(ImageRef::MediaFileToken(_) | ImageRef::LocalPath(_) | ImageRef::Bytes(_)) => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls))
|
||||
}
|
||||
}
|
||||
|
|
@ -217,6 +218,17 @@ fn reference_image_urls(refs: Option<ImageListRef>) -> Result<Option<Vec<String>
|
|||
Some(ImageListRef::MediaFileTokens(_)) => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls))
|
||||
}
|
||||
Some(ImageListRef::Sources(refs)) if refs.is_empty() => Ok(None),
|
||||
Some(ImageListRef::Sources(refs)) => {
|
||||
let mut urls = Vec::with_capacity(refs.len());
|
||||
for image_ref in refs {
|
||||
match image_ref {
|
||||
ImageRef::Url(url) => urls.push(url),
|
||||
_ => return Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)),
|
||||
}
|
||||
}
|
||||
Ok(Some(urls))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -229,6 +241,17 @@ fn reference_video_urls(refs: Option<VideoListRef>) -> Result<Option<Vec<String>
|
|||
Some(VideoListRef::MediaFileTokens(_)) => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls))
|
||||
}
|
||||
Some(VideoListRef::Sources(refs)) if refs.is_empty() => Ok(None),
|
||||
Some(VideoListRef::Sources(refs)) => {
|
||||
let mut urls = Vec::with_capacity(refs.len());
|
||||
for video_ref in refs {
|
||||
match video_ref {
|
||||
VideoRef::Url(url) => urls.push(url),
|
||||
_ => return Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)),
|
||||
}
|
||||
}
|
||||
Ok(Some(urls))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ use crate::api::image_ref::ImageRef;
|
|||
use crate::api::router_aspect_ratio::RouterAspectRatio;
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
use crate::api::video_list_ref::VideoListRef;
|
||||
use crate::api::video_ref::VideoRef;
|
||||
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::errors::client_error::ClientError;
|
||||
|
|
@ -204,7 +205,7 @@ fn optional_url(image_ref: Option<ImageRef>) -> Result<Option<String>, ArtcraftR
|
|||
match image_ref {
|
||||
None => Ok(None),
|
||||
Some(ImageRef::Url(url)) => Ok(Some(url)),
|
||||
Some(ImageRef::MediaFileToken(_)) => {
|
||||
Some(ImageRef::MediaFileToken(_) | ImageRef::LocalPath(_) | ImageRef::Bytes(_)) => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls))
|
||||
}
|
||||
}
|
||||
|
|
@ -219,6 +220,17 @@ fn reference_image_urls(refs: Option<ImageListRef>) -> Result<Option<Vec<String>
|
|||
Some(ImageListRef::MediaFileTokens(_)) => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls))
|
||||
}
|
||||
Some(ImageListRef::Sources(refs)) if refs.is_empty() => Ok(None),
|
||||
Some(ImageListRef::Sources(refs)) => {
|
||||
let mut urls = Vec::with_capacity(refs.len());
|
||||
for image_ref in refs {
|
||||
match image_ref {
|
||||
ImageRef::Url(url) => urls.push(url),
|
||||
_ => return Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)),
|
||||
}
|
||||
}
|
||||
Ok(Some(urls))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -231,6 +243,17 @@ fn reference_video_urls(refs: Option<VideoListRef>) -> Result<Option<Vec<String>
|
|||
Some(VideoListRef::MediaFileTokens(_)) => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls))
|
||||
}
|
||||
Some(VideoListRef::Sources(refs)) if refs.is_empty() => Ok(None),
|
||||
Some(VideoListRef::Sources(refs)) => {
|
||||
let mut urls = Vec::with_capacity(refs.len());
|
||||
for video_ref in refs {
|
||||
match video_ref {
|
||||
VideoRef::Url(url) => urls.push(url),
|
||||
_ => return Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)),
|
||||
}
|
||||
}
|
||||
Ok(Some(urls))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -136,7 +136,7 @@ fn optional_url(image_ref: Option<ImageRef>) -> Result<Option<String>, ArtcraftR
|
|||
match image_ref {
|
||||
None => Ok(None),
|
||||
Some(ImageRef::Url(url)) => Ok(Some(url)),
|
||||
Some(ImageRef::MediaFileToken(_)) => {
|
||||
Some(ImageRef::MediaFileToken(_) | ImageRef::LocalPath(_) | ImageRef::Bytes(_)) => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls))
|
||||
}
|
||||
}
|
||||
|
|
@ -147,6 +147,7 @@ fn has_reference_images(refs: &Option<ImageListRef>) -> bool {
|
|||
None => false,
|
||||
Some(ImageListRef::Urls(urls)) => !urls.is_empty(),
|
||||
Some(ImageListRef::MediaFileTokens(tokens)) => !tokens.is_empty(),
|
||||
Some(ImageListRef::Sources(refs)) => !refs.is_empty(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -155,6 +156,7 @@ fn has_reference_videos(refs: &Option<VideoListRef>) -> bool {
|
|||
None => false,
|
||||
Some(VideoListRef::Urls(urls)) => !urls.is_empty(),
|
||||
Some(VideoListRef::MediaFileTokens(tokens)) => !tokens.is_empty(),
|
||||
Some(VideoListRef::Sources(refs)) => !refs.is_empty(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ use fal_client::requests::api::video::text::vidu_q3::api::{
|
|||
};
|
||||
|
||||
use crate::api::image_list_ref::ImageListRef;
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::router_aspect_ratio::RouterAspectRatio;
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
use crate::api::video_list_ref::VideoListRef;
|
||||
|
|
@ -156,6 +157,7 @@ fn reject_reference_videos(
|
|||
None => false,
|
||||
Some(VideoListRef::Urls(urls)) => !urls.is_empty(),
|
||||
Some(VideoListRef::MediaFileTokens(tokens)) => !tokens.is_empty(),
|
||||
Some(VideoListRef::Sources(refs)) => !refs.is_empty(),
|
||||
};
|
||||
if has_reference_videos {
|
||||
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
|
|
@ -175,6 +177,13 @@ fn resolve_reference_image_urls(
|
|||
Some(ImageListRef::MediaFileTokens(_)) => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls))
|
||||
}
|
||||
Some(ImageListRef::Sources(refs)) => refs
|
||||
.into_iter()
|
||||
.map(|image_ref| match image_ref {
|
||||
ImageRef::Url(url) => Ok(url),
|
||||
_ => Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)),
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -109,6 +109,7 @@ fn reject_reference_videos(
|
|||
None => false,
|
||||
Some(VideoListRef::Urls(urls)) => !urls.is_empty(),
|
||||
Some(VideoListRef::MediaFileTokens(tokens)) => !tokens.is_empty(),
|
||||
Some(VideoListRef::Sources(refs)) => !refs.is_empty(),
|
||||
};
|
||||
if has_reference_videos {
|
||||
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
|
|
@ -126,6 +127,7 @@ fn reject_reference_images(
|
|||
None => false,
|
||||
Some(ImageListRef::Urls(urls)) => !urls.is_empty(),
|
||||
Some(ImageListRef::MediaFileTokens(tokens)) => !tokens.is_empty(),
|
||||
Some(ImageListRef::Sources(refs)) => !refs.is_empty(),
|
||||
};
|
||||
if has_reference_images {
|
||||
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
|
|
|
|||
|
|
@ -3,14 +3,16 @@ use gmicloud_client::requests::api::video::seedance_2_0_fast_260128::api::{
|
|||
};
|
||||
|
||||
use crate::api::audio_list_ref::AudioListRef;
|
||||
use crate::api::audio_ref::AudioRef;
|
||||
use crate::api::router_aspect_ratio::RouterAspectRatio;
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
use crate::api::image_list_ref::ImageListRef;
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::video_list_ref::VideoListRef;
|
||||
use crate::api::video_ref::VideoRef;
|
||||
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::errors::client_error::ClientError;
|
||||
use crate::errors::client_error::{ClientError, ClientType};
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::providers::gmicloud::seedance_2p0_fast_g::request::GmiCloudSeedance2p0UltraFastRequestState;
|
||||
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
|
|
@ -102,6 +104,11 @@ fn resolve_url(image_ref: Option<ImageRef>) -> Result<Option<String>, ArtcraftRo
|
|||
value: "GmiCloud only supports image URLs, not media file tokens".to_string(),
|
||||
}))
|
||||
}
|
||||
Some(ImageRef::LocalPath(_)) | Some(ImageRef::Bytes(_)) => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ProviderCannotUseLocalMedia {
|
||||
client_type: ClientType::GmiCloud,
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -117,6 +124,20 @@ fn resolve_url_list_from_images(
|
|||
value: "GmiCloud only supports image URLs, not media file tokens".to_string(),
|
||||
}))
|
||||
}
|
||||
Some(ImageListRef::Sources(refs)) => {
|
||||
let mut urls = Vec::with_capacity(refs.len());
|
||||
for image_ref in refs {
|
||||
match image_ref {
|
||||
ImageRef::Url(url) => urls.push(url),
|
||||
ImageRef::MediaFileToken(_) | ImageRef::LocalPath(_) | ImageRef::Bytes(_) => {
|
||||
return Err(ArtcraftRouterError::Client(ClientError::ProviderCannotUseLocalMedia {
|
||||
client_type: ClientType::GmiCloud,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Some(urls))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -132,6 +153,20 @@ fn resolve_url_list_from_videos(
|
|||
value: "GmiCloud only supports video URLs, not media file tokens".to_string(),
|
||||
}))
|
||||
}
|
||||
Some(VideoListRef::Sources(refs)) => {
|
||||
let mut urls = Vec::with_capacity(refs.len());
|
||||
for video_ref in refs {
|
||||
match video_ref {
|
||||
VideoRef::Url(url) => urls.push(url),
|
||||
VideoRef::MediaFileToken(_) | VideoRef::LocalPath(_) | VideoRef::Bytes(_) => {
|
||||
return Err(ArtcraftRouterError::Client(ClientError::ProviderCannotUseLocalMedia {
|
||||
client_type: ClientType::GmiCloud,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Some(urls))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -319,5 +354,19 @@ fn resolve_url_list_from_audios(
|
|||
value: "GmiCloud only supports audio URLs, not media file tokens".to_string(),
|
||||
}))
|
||||
}
|
||||
Some(AudioListRef::Sources(refs)) => {
|
||||
let mut urls = Vec::with_capacity(refs.len());
|
||||
for audio_ref in refs {
|
||||
match audio_ref {
|
||||
AudioRef::Url(url) => urls.push(url),
|
||||
AudioRef::MediaFileToken(_) | AudioRef::LocalPath(_) | AudioRef::Bytes(_) => {
|
||||
return Err(ArtcraftRouterError::Client(ClientError::ProviderCannotUseLocalMedia {
|
||||
client_type: ClientType::GmiCloud,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Some(urls))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,14 +3,16 @@ use gmicloud_client::requests::api::video::seedance_2_0_260128::api::{
|
|||
};
|
||||
|
||||
use crate::api::audio_list_ref::AudioListRef;
|
||||
use crate::api::audio_ref::AudioRef;
|
||||
use crate::api::router_aspect_ratio::RouterAspectRatio;
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
use crate::api::image_list_ref::ImageListRef;
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::video_list_ref::VideoListRef;
|
||||
use crate::api::video_ref::VideoRef;
|
||||
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::errors::client_error::ClientError;
|
||||
use crate::errors::client_error::{ClientError, ClientType};
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::providers::gmicloud::seedance_2p0_g::request::GmiCloudSeedance2p0UltraRequestState;
|
||||
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
|
|
@ -101,6 +103,11 @@ fn resolve_url(image_ref: Option<ImageRef>) -> Result<Option<String>, ArtcraftRo
|
|||
value: "GmiCloud only supports image URLs, not media file tokens".to_string(),
|
||||
}))
|
||||
}
|
||||
Some(ImageRef::LocalPath(_)) | Some(ImageRef::Bytes(_)) => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ProviderCannotUseLocalMedia {
|
||||
client_type: ClientType::GmiCloud,
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -116,6 +123,20 @@ fn resolve_url_list_from_images(
|
|||
value: "GmiCloud only supports image URLs, not media file tokens".to_string(),
|
||||
}))
|
||||
}
|
||||
Some(ImageListRef::Sources(refs)) => {
|
||||
let mut urls = Vec::with_capacity(refs.len());
|
||||
for image_ref in refs {
|
||||
match image_ref {
|
||||
ImageRef::Url(url) => urls.push(url),
|
||||
ImageRef::MediaFileToken(_) | ImageRef::LocalPath(_) | ImageRef::Bytes(_) => {
|
||||
return Err(ArtcraftRouterError::Client(ClientError::ProviderCannotUseLocalMedia {
|
||||
client_type: ClientType::GmiCloud,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Some(urls))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -131,6 +152,20 @@ fn resolve_url_list_from_videos(
|
|||
value: "GmiCloud only supports video URLs, not media file tokens".to_string(),
|
||||
}))
|
||||
}
|
||||
Some(VideoListRef::Sources(refs)) => {
|
||||
let mut urls = Vec::with_capacity(refs.len());
|
||||
for video_ref in refs {
|
||||
match video_ref {
|
||||
VideoRef::Url(url) => urls.push(url),
|
||||
VideoRef::MediaFileToken(_) | VideoRef::LocalPath(_) | VideoRef::Bytes(_) => {
|
||||
return Err(ArtcraftRouterError::Client(ClientError::ProviderCannotUseLocalMedia {
|
||||
client_type: ClientType::GmiCloud,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Some(urls))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -351,5 +386,19 @@ fn resolve_url_list_from_audios(
|
|||
value: "GmiCloud only supports audio URLs, not media file tokens".to_string(),
|
||||
}))
|
||||
}
|
||||
Some(AudioListRef::Sources(refs)) => {
|
||||
let mut urls = Vec::with_capacity(refs.len());
|
||||
for audio_ref in refs {
|
||||
match audio_ref {
|
||||
AudioRef::Url(url) => urls.push(url),
|
||||
AudioRef::MediaFileToken(_) | AudioRef::LocalPath(_) | AudioRef::Bytes(_) => {
|
||||
return Err(ArtcraftRouterError::Client(ClientError::ProviderCannotUseLocalMedia {
|
||||
client_type: ClientType::GmiCloud,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Some(urls))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ use crate::api::image_ref::ImageRef;
|
|||
use crate::api::video_list_ref::VideoListRef;
|
||||
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::errors::client_error::{ClientError, ClientType};
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::providers::grok_api::grok_imagine_video::request::GrokApiGrokImagineVideoRequestState;
|
||||
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
|
|
@ -29,6 +30,9 @@ use crate::generate::generate_video::video_generation_request::VideoGenerationRe
|
|||
///
|
||||
/// Fields that Grok DOESN'T accept (`end_frame`, `reference_videos`, `reference_audio`,
|
||||
/// and `MediaFileToken`-style image refs) are silently dropped with an info-level log.
|
||||
/// `LocalPath`/`Bytes` image refs are the exception: they error with
|
||||
/// `ProviderCannotUseLocalMedia`, since silently dropping the caller's local media
|
||||
/// would produce a video that ignores their input.
|
||||
/// Same goes for the `start_frame` + `reference_images` conflict — we prefer
|
||||
/// `start_frame` (image-to-video) and drop `reference_images`. This keeps the
|
||||
/// pipeline tolerant when callers fan a single builder out to multiple providers
|
||||
|
|
@ -52,7 +56,7 @@ pub fn build_grok_api_grok_imagine_video(
|
|||
// Image sources: prefer start_frame (image-to-video). If both start_frame and
|
||||
// reference_images are supplied, drop reference_images (xAI rejects the
|
||||
// combination on the wire).
|
||||
let start_frame = resolve_url_to_image_source(builder.start_frame.take());
|
||||
let start_frame = resolve_url_to_image_source(builder.start_frame.take())?;
|
||||
let reference_images_supplied = builder.reference_images.take();
|
||||
|
||||
let (image, reference_images) = match (start_frame, reference_images_supplied) {
|
||||
|
|
@ -60,12 +64,12 @@ pub fn build_grok_api_grok_imagine_video(
|
|||
info!(
|
||||
"grok_imagine_video: both start_frame and reference_images supplied — \
|
||||
preferring start_frame (image-to-video). Dropping {} reference images.",
|
||||
count_image_list_ref(&refs),
|
||||
refs.len(),
|
||||
);
|
||||
(Some(img), None)
|
||||
}
|
||||
(Some(img), None) => (Some(img), None),
|
||||
(None, Some(refs)) => (None, resolve_url_list_to_image_sources(refs)),
|
||||
(None, Some(refs)) => (None, resolve_url_list_to_image_sources(refs)?),
|
||||
(None, None) => (None, None),
|
||||
};
|
||||
|
||||
|
|
@ -149,40 +153,67 @@ fn plan_resolution(
|
|||
|
||||
// ── Image source resolvers (silent drops for unsupported variants) ──
|
||||
|
||||
fn resolve_url_to_image_source(image_ref: Option<ImageRef>) -> Option<GrokVideoImageSource> {
|
||||
fn resolve_url_to_image_source(
|
||||
image_ref: Option<ImageRef>,
|
||||
) -> Result<Option<GrokVideoImageSource>, ArtcraftRouterError> {
|
||||
match image_ref {
|
||||
None => None,
|
||||
Some(ImageRef::Url(url)) => Some(GrokVideoImageSource::Url(url)),
|
||||
None => Ok(None),
|
||||
Some(ImageRef::Url(url)) => Ok(Some(GrokVideoImageSource::Url(url))),
|
||||
Some(ImageRef::MediaFileToken(token)) => {
|
||||
info!(
|
||||
"grok_imagine_video: dropping MediaFileToken {:?} for start_frame — \
|
||||
Grok only accepts public URLs. Resolve the token to a URL upstream if needed.",
|
||||
token,
|
||||
);
|
||||
None
|
||||
Ok(None)
|
||||
}
|
||||
Some(ImageRef::LocalPath(_)) | Some(ImageRef::Bytes(_)) => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ProviderCannotUseLocalMedia {
|
||||
client_type: ClientType::GrokApi,
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_url_list_to_image_sources(list_ref: ImageListRef) -> Option<Vec<GrokVideoImageSource>> {
|
||||
fn resolve_url_list_to_image_sources(
|
||||
list_ref: ImageListRef,
|
||||
) -> Result<Option<Vec<GrokVideoImageSource>>, ArtcraftRouterError> {
|
||||
match list_ref {
|
||||
ImageListRef::Urls(urls) if urls.is_empty() => None,
|
||||
ImageListRef::Urls(urls) => Some(urls.into_iter().map(GrokVideoImageSource::Url).collect()),
|
||||
ImageListRef::Urls(urls) if urls.is_empty() => Ok(None),
|
||||
ImageListRef::Urls(urls) => {
|
||||
Ok(Some(urls.into_iter().map(GrokVideoImageSource::Url).collect()))
|
||||
}
|
||||
ImageListRef::MediaFileTokens(tokens) => {
|
||||
info!(
|
||||
"grok_imagine_video: dropping {} MediaFileToken reference image(s) — \
|
||||
Grok only accepts public URLs. Resolve tokens to URLs upstream if needed.",
|
||||
tokens.len(),
|
||||
);
|
||||
None
|
||||
Ok(None)
|
||||
}
|
||||
ImageListRef::Sources(refs) => {
|
||||
let mut urls = Vec::with_capacity(refs.len());
|
||||
let mut dropped_tokens = 0usize;
|
||||
for image_ref in refs {
|
||||
match image_ref {
|
||||
ImageRef::Url(url) => urls.push(url),
|
||||
ImageRef::MediaFileToken(_) => dropped_tokens += 1,
|
||||
ImageRef::LocalPath(_) | ImageRef::Bytes(_) => {
|
||||
return Err(ArtcraftRouterError::Client(ClientError::ProviderCannotUseLocalMedia {
|
||||
client_type: ClientType::GrokApi,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
if dropped_tokens > 0 {
|
||||
info!(
|
||||
"grok_imagine_video: dropping {} MediaFileToken reference image(s) — \
|
||||
Grok only accepts public URLs. Resolve tokens to URLs upstream if needed.",
|
||||
dropped_tokens,
|
||||
);
|
||||
}
|
||||
resolve_url_list_to_image_sources(ImageListRef::Urls(urls))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn count_image_list_ref(refs: &ImageListRef) -> usize {
|
||||
match refs {
|
||||
ImageListRef::Urls(v) => v.len(),
|
||||
ImageListRef::MediaFileTokens(v) => v.len(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -196,26 +227,18 @@ fn log_and_drop_end_frame(end_frame: Option<ImageRef>) {
|
|||
|
||||
fn log_and_drop_reference_videos(refs: Option<VideoListRef>) {
|
||||
if let Some(refs) = refs {
|
||||
let count = match refs {
|
||||
VideoListRef::Urls(v) => v.len(),
|
||||
VideoListRef::MediaFileTokens(v) => v.len(),
|
||||
};
|
||||
info!(
|
||||
"grok_imagine_video: dropping {} reference video(s) — Grok doesn't accept reference videos.",
|
||||
count,
|
||||
refs.len(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn log_and_drop_reference_audio(refs: Option<AudioListRef>) {
|
||||
if let Some(refs) = refs {
|
||||
let count = match refs {
|
||||
AudioListRef::Urls(v) => v.len(),
|
||||
AudioListRef::MediaFileTokens(v) => v.len(),
|
||||
};
|
||||
info!(
|
||||
"grok_imagine_video: dropping {} reference audio clip(s) — Grok doesn't accept reference audio.",
|
||||
count,
|
||||
refs.len(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ use crate::api::image_ref::ImageRef;
|
|||
use crate::api::video_list_ref::VideoListRef;
|
||||
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::errors::client_error::ClientError;
|
||||
use crate::errors::client_error::{ClientError, ClientType};
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::providers::grok_api::grok_imagine_video_1p5::request::GrokApiGrokImagineVideo1p5RequestState;
|
||||
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
|
|
@ -36,7 +36,10 @@ use crate::generate::generate_video::video_generation_request::VideoGenerationRe
|
|||
///
|
||||
/// Fields that Grok DOESN'T accept (`end_frame`, `reference_videos`,
|
||||
/// `reference_audio`, and `MediaFileToken`-style image refs) are silently
|
||||
/// dropped with an info-level log.
|
||||
/// dropped with an info-level log. `LocalPath`/`Bytes` image refs are the
|
||||
/// exception: they error with `ProviderCannotUseLocalMedia`, since silently
|
||||
/// dropping the caller's local media would produce a video that ignores
|
||||
/// their input.
|
||||
pub fn build_grok_api_grok_imagine_video_1p5(
|
||||
mut builder: GenerateVideoRequestBuilder,
|
||||
) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
|
||||
|
|
@ -52,7 +55,7 @@ pub fn build_grok_api_grok_imagine_video_1p5(
|
|||
log_and_drop_reference_videos(builder.reference_videos.take());
|
||||
log_and_drop_reference_audio(builder.reference_audio.take());
|
||||
|
||||
let start_frame = resolve_url_to_image_source(builder.start_frame.take());
|
||||
let start_frame = resolve_url_to_image_source(builder.start_frame.take())?;
|
||||
let reference_images_supplied = builder.reference_images.take();
|
||||
|
||||
// xAI's v1.5 model explicitly does NOT accept `reference_images` (it only
|
||||
|
|
@ -64,12 +67,12 @@ pub fn build_grok_api_grok_imagine_video_1p5(
|
|||
info!(
|
||||
"grok_imagine_video_1p5: both start_frame and reference_images supplied — \
|
||||
preferring start_frame (image-to-video). Dropping {} reference image(s).",
|
||||
count_image_list_ref(&refs),
|
||||
refs.len(),
|
||||
);
|
||||
(Some(img), None)
|
||||
}
|
||||
(Some(img), None) => (Some(img), None),
|
||||
(None, Some(refs)) => promote_first_reference_to_image(refs),
|
||||
(None, Some(refs)) => promote_first_reference_to_image(refs)?,
|
||||
(None, None) => (None, None),
|
||||
};
|
||||
|
||||
|
|
@ -159,17 +162,24 @@ fn plan_resolution(
|
|||
|
||||
// ── Image source resolvers (silent drops for unsupported variants) ──
|
||||
|
||||
fn resolve_url_to_image_source(image_ref: Option<ImageRef>) -> Option<GrokVideoImageSource> {
|
||||
fn resolve_url_to_image_source(
|
||||
image_ref: Option<ImageRef>,
|
||||
) -> Result<Option<GrokVideoImageSource>, ArtcraftRouterError> {
|
||||
match image_ref {
|
||||
None => None,
|
||||
Some(ImageRef::Url(url)) => Some(GrokVideoImageSource::Url(url)),
|
||||
None => Ok(None),
|
||||
Some(ImageRef::Url(url)) => Ok(Some(GrokVideoImageSource::Url(url))),
|
||||
Some(ImageRef::MediaFileToken(token)) => {
|
||||
info!(
|
||||
"grok_imagine_video_1p5: dropping MediaFileToken {:?} for start_frame — \
|
||||
Grok only accepts public URLs. Resolve the token to a URL upstream if needed.",
|
||||
token,
|
||||
);
|
||||
None
|
||||
Ok(None)
|
||||
}
|
||||
Some(ImageRef::LocalPath(_)) | Some(ImageRef::Bytes(_)) => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ProviderCannotUseLocalMedia {
|
||||
client_type: ClientType::GrokApi,
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -180,9 +190,9 @@ fn resolve_url_to_image_source(image_ref: Option<ImageRef>) -> Option<GrokVideoI
|
|||
/// with a log so it's visible in operator traces.
|
||||
fn promote_first_reference_to_image(
|
||||
list_ref: ImageListRef,
|
||||
) -> (Option<GrokVideoImageSource>, Option<Vec<GrokVideoImageSource>>) {
|
||||
) -> Result<(Option<GrokVideoImageSource>, Option<Vec<GrokVideoImageSource>>), ArtcraftRouterError> {
|
||||
match list_ref {
|
||||
ImageListRef::Urls(urls) if urls.is_empty() => (None, None),
|
||||
ImageListRef::Urls(urls) if urls.is_empty() => Ok((None, None)),
|
||||
ImageListRef::Urls(mut urls) => {
|
||||
let first_url = urls.remove(0);
|
||||
let dropped = urls.len();
|
||||
|
|
@ -199,7 +209,7 @@ fn promote_first_reference_to_image(
|
|||
the single reference image to start_frame (image-to-video).",
|
||||
);
|
||||
}
|
||||
(Some(GrokVideoImageSource::Url(first_url)), None)
|
||||
Ok((Some(GrokVideoImageSource::Url(first_url)), None))
|
||||
}
|
||||
ImageListRef::MediaFileTokens(tokens) => {
|
||||
info!(
|
||||
|
|
@ -207,15 +217,31 @@ fn promote_first_reference_to_image(
|
|||
Grok only accepts public URLs. Resolve tokens to URLs upstream if needed.",
|
||||
tokens.len(),
|
||||
);
|
||||
(None, None)
|
||||
Ok((None, None))
|
||||
}
|
||||
ImageListRef::Sources(refs) => {
|
||||
let mut urls = Vec::with_capacity(refs.len());
|
||||
let mut dropped_tokens = 0usize;
|
||||
for image_ref in refs {
|
||||
match image_ref {
|
||||
ImageRef::Url(url) => urls.push(url),
|
||||
ImageRef::MediaFileToken(_) => dropped_tokens += 1,
|
||||
ImageRef::LocalPath(_) | ImageRef::Bytes(_) => {
|
||||
return Err(ArtcraftRouterError::Client(ClientError::ProviderCannotUseLocalMedia {
|
||||
client_type: ClientType::GrokApi,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
if dropped_tokens > 0 {
|
||||
info!(
|
||||
"grok_imagine_video_1p5: dropping {} MediaFileToken reference image(s) — \
|
||||
Grok only accepts public URLs. Resolve tokens to URLs upstream if needed.",
|
||||
dropped_tokens,
|
||||
);
|
||||
}
|
||||
promote_first_reference_to_image(ImageListRef::Urls(urls))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn count_image_list_ref(refs: &ImageListRef) -> usize {
|
||||
match refs {
|
||||
ImageListRef::Urls(v) => v.len(),
|
||||
ImageListRef::MediaFileTokens(v) => v.len(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -229,26 +255,18 @@ fn log_and_drop_end_frame(end_frame: Option<ImageRef>) {
|
|||
|
||||
fn log_and_drop_reference_videos(refs: Option<VideoListRef>) {
|
||||
if let Some(refs) = refs {
|
||||
let count = match refs {
|
||||
VideoListRef::Urls(v) => v.len(),
|
||||
VideoListRef::MediaFileTokens(v) => v.len(),
|
||||
};
|
||||
info!(
|
||||
"grok_imagine_video_1p5: dropping {} reference video(s) — Grok doesn't accept reference videos.",
|
||||
count,
|
||||
refs.len(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn log_and_drop_reference_audio(refs: Option<AudioListRef>) {
|
||||
if let Some(refs) = refs {
|
||||
let count = match refs {
|
||||
AudioListRef::Urls(v) => v.len(),
|
||||
AudioListRef::MediaFileTokens(v) => v.len(),
|
||||
};
|
||||
info!(
|
||||
"grok_imagine_video_1p5: dropping {} reference audio clip(s) — Grok doesn't accept reference audio.",
|
||||
count,
|
||||
refs.len(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -244,24 +244,15 @@ fn check_limit(count: usize, max: usize, field: &'static str, what: &str, model:
|
|||
}
|
||||
|
||||
fn list_len(list: &ImageListRef) -> usize {
|
||||
match list {
|
||||
ImageListRef::Urls(urls) => urls.len(),
|
||||
ImageListRef::MediaFileTokens(tokens) => tokens.len(),
|
||||
}
|
||||
list.len()
|
||||
}
|
||||
|
||||
fn video_list_len(list: &VideoListRef) -> usize {
|
||||
match list {
|
||||
VideoListRef::Urls(urls) => urls.len(),
|
||||
VideoListRef::MediaFileTokens(tokens) => tokens.len(),
|
||||
}
|
||||
list.len()
|
||||
}
|
||||
|
||||
fn audio_list_len(list: &AudioListRef) -> usize {
|
||||
match list {
|
||||
AudioListRef::Urls(urls) => urls.len(),
|
||||
AudioListRef::MediaFileTokens(tokens) => tokens.len(),
|
||||
}
|
||||
list.len()
|
||||
}
|
||||
|
||||
/// Wrap a plan: with media to upload it's a draft, otherwise it's ready to
|
||||
|
|
|
|||
|
|
@ -3,42 +3,13 @@ use std::collections::HashMap;
|
|||
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
|
||||
use sqlite_identifiers::ids::media_file_token::MediaFileToken;
|
||||
|
||||
use crate::api::audio_list_ref::AudioListRef;
|
||||
use crate::api::character_list_ref::CharacterListRef;
|
||||
use crate::api::image_list_ref::ImageListRef;
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::video_list_ref::VideoListRef;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::errors::client_error::ClientError;
|
||||
use crate::generate::generate_video::providers::kinovi::upload::upload_to_seedance2pro;
|
||||
use crate::generate::generate_video::providers::kinovi::upload::upload_source_to_seedance2pro;
|
||||
use crate::generate::generate_video::video_generation_draft_context::VideoGenerationDraftContext;
|
||||
|
||||
/// Either resolved URLs or unresolved media file tokens.
|
||||
pub(crate) enum UrlsOrTokens {
|
||||
Urls(Vec<String>),
|
||||
Tokens(Vec<MediaFileToken>),
|
||||
}
|
||||
|
||||
pub(crate) fn image_list_ref_into_urls_or_tokens(list: ImageListRef) -> UrlsOrTokens {
|
||||
match list {
|
||||
ImageListRef::Urls(urls) => UrlsOrTokens::Urls(urls),
|
||||
ImageListRef::MediaFileTokens(tokens) => UrlsOrTokens::Tokens(tokens),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn video_list_ref_into_urls_or_tokens(list: VideoListRef) -> UrlsOrTokens {
|
||||
match list {
|
||||
VideoListRef::Urls(urls) => UrlsOrTokens::Urls(urls),
|
||||
VideoListRef::MediaFileTokens(tokens) => UrlsOrTokens::Tokens(tokens),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn audio_list_ref_into_urls_or_tokens(list: AudioListRef) -> UrlsOrTokens {
|
||||
match list {
|
||||
AudioListRef::Urls(urls) => UrlsOrTokens::Urls(urls),
|
||||
AudioListRef::MediaFileTokens(tokens) => UrlsOrTokens::Tokens(tokens),
|
||||
}
|
||||
}
|
||||
use crate::utils::media_source_ref::MediaSourceRef;
|
||||
|
||||
/// Resolve a single ImageRef and upload to Seedance2Pro CDN.
|
||||
pub(crate) async fn resolve_and_upload_single(
|
||||
|
|
@ -46,32 +17,28 @@ pub(crate) async fn resolve_and_upload_single(
|
|||
image_ref: Option<ImageRef>,
|
||||
maybe_map: Option<&HashMap<MediaFileToken, String>>,
|
||||
) -> Result<Option<String>, ArtcraftRouterError> {
|
||||
let source_url = match image_ref {
|
||||
None => return Ok(None),
|
||||
Some(ImageRef::Url(url)) => url,
|
||||
Some(ImageRef::MediaFileToken(token)) => resolve_token(maybe_map, &token)?,
|
||||
};
|
||||
Ok(Some(upload_to_seedance2pro(session, &source_url).await?))
|
||||
match image_ref {
|
||||
None => Ok(None),
|
||||
Some(image_ref) => Ok(Some(upload_source_to_seedance2pro(session, image_ref.into(), maybe_map).await?)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a list of refs to URLs and upload each to Seedance2Pro CDN.
|
||||
/// Order is preserved.
|
||||
/// Resolve a list of sources and upload each to Seedance2Pro CDN. Order is
|
||||
/// preserved. `None` / empty lists resolve to `None`.
|
||||
pub(crate) async fn resolve_and_upload_list(
|
||||
session: &Seedance2ProSession,
|
||||
urls_or_tokens: Option<UrlsOrTokens>,
|
||||
sources: Option<Vec<MediaSourceRef>>,
|
||||
maybe_map: Option<&HashMap<MediaFileToken, String>>,
|
||||
) -> Result<Option<Vec<String>>, ArtcraftRouterError> {
|
||||
let source_urls = match urls_or_tokens {
|
||||
let sources = match sources {
|
||||
None => return Ok(None),
|
||||
Some(UrlsOrTokens::Urls(urls)) if urls.is_empty() => return Ok(None),
|
||||
Some(UrlsOrTokens::Urls(urls)) => urls,
|
||||
Some(UrlsOrTokens::Tokens(tokens)) if tokens.is_empty() => return Ok(None),
|
||||
Some(UrlsOrTokens::Tokens(tokens)) => resolve_tokens(maybe_map, &tokens)?,
|
||||
Some(sources) if sources.is_empty() => return Ok(None),
|
||||
Some(sources) => sources,
|
||||
};
|
||||
|
||||
let mut uploaded = Vec::with_capacity(source_urls.len());
|
||||
for url in &source_urls {
|
||||
uploaded.push(upload_to_seedance2pro(session, url).await?);
|
||||
let mut uploaded = Vec::with_capacity(sources.len());
|
||||
for source in sources {
|
||||
uploaded.push(upload_source_to_seedance2pro(session, source, maybe_map).await?);
|
||||
}
|
||||
Ok(Some(uploaded))
|
||||
}
|
||||
|
|
@ -105,22 +72,3 @@ pub(crate) fn resolve_character_tokens(
|
|||
|
||||
ids.map(Some)
|
||||
}
|
||||
|
||||
fn resolve_token(
|
||||
maybe_map: Option<&HashMap<MediaFileToken, String>>,
|
||||
token: &MediaFileToken,
|
||||
) -> Result<String, ArtcraftRouterError> {
|
||||
let map = maybe_map.ok_or(ArtcraftRouterError::Client(ClientError::MediaFileToUrlMapNotProvided))?;
|
||||
map.get(token).cloned().ok_or_else(|| {
|
||||
ArtcraftRouterError::Client(ClientError::MediaFileTokenNotFoundInMap {
|
||||
token: token.clone(),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_tokens(
|
||||
maybe_map: Option<&HashMap<MediaFileToken, String>>,
|
||||
tokens: &[MediaFileToken],
|
||||
) -> Result<Vec<String>, ArtcraftRouterError> {
|
||||
tokens.iter().map(|t| resolve_token(maybe_map, t)).collect()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,10 +10,9 @@ use crate::api::image_ref::ImageRef;
|
|||
use crate::api::video_list_ref::VideoListRef;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::generate::generate_video::providers::kinovi::resolve::{
|
||||
audio_list_ref_into_urls_or_tokens, image_list_ref_into_urls_or_tokens,
|
||||
resolve_and_upload_list, resolve_and_upload_single, resolve_character_tokens,
|
||||
video_list_ref_into_urls_or_tokens,
|
||||
};
|
||||
use crate::utils::media_source_ref::MediaSourceRef;
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p0::request::KinoviSeedance2p0RequestState;
|
||||
use crate::generate::generate_video::video_generation_draft_context::VideoGenerationDraftContext;
|
||||
|
||||
|
|
@ -64,15 +63,15 @@ impl KinoviSeedance2p0DraftState {
|
|||
end_frame_url = resolve_and_upload_single(session, remaining.end_frame, map).await?;
|
||||
|
||||
reference_image_urls = resolve_and_upload_list(
|
||||
session, remaining.reference_images.map(image_list_ref_into_urls_or_tokens), map,
|
||||
session, remaining.reference_images.map(MediaSourceRef::list_from_images), map,
|
||||
).await?;
|
||||
|
||||
reference_video_urls = resolve_and_upload_list(
|
||||
session, remaining.reference_videos.map(video_list_ref_into_urls_or_tokens), map,
|
||||
session, remaining.reference_videos.map(MediaSourceRef::list_from_videos), map,
|
||||
).await?;
|
||||
|
||||
reference_audio_urls = resolve_and_upload_list(
|
||||
session, remaining.reference_audio.map(audio_list_ref_into_urls_or_tokens), map,
|
||||
session, remaining.reference_audio.map(MediaSourceRef::list_from_audios), map,
|
||||
).await?;
|
||||
|
||||
character_ids = resolve_character_tokens(
|
||||
|
|
|
|||
|
|
@ -10,10 +10,9 @@ use crate::api::image_ref::ImageRef;
|
|||
use crate::api::video_list_ref::VideoListRef;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::generate::generate_video::providers::kinovi::resolve::{
|
||||
audio_list_ref_into_urls_or_tokens, image_list_ref_into_urls_or_tokens,
|
||||
resolve_and_upload_list, resolve_and_upload_single, resolve_character_tokens,
|
||||
video_list_ref_into_urls_or_tokens,
|
||||
};
|
||||
use crate::utils::media_source_ref::MediaSourceRef;
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p0_fast::request::KinoviSeedance2p0FastRequestState;
|
||||
use crate::generate::generate_video::video_generation_draft_context::VideoGenerationDraftContext;
|
||||
|
||||
|
|
@ -61,15 +60,15 @@ impl KinoviSeedance2p0FastDraftState {
|
|||
end_frame_url = resolve_and_upload_single(session, remaining.end_frame, map).await?;
|
||||
|
||||
reference_image_urls = resolve_and_upload_list(
|
||||
session, remaining.reference_images.map(image_list_ref_into_urls_or_tokens), map,
|
||||
session, remaining.reference_images.map(MediaSourceRef::list_from_images), map,
|
||||
).await?;
|
||||
|
||||
reference_video_urls = resolve_and_upload_list(
|
||||
session, remaining.reference_videos.map(video_list_ref_into_urls_or_tokens), map,
|
||||
session, remaining.reference_videos.map(MediaSourceRef::list_from_videos), map,
|
||||
).await?;
|
||||
|
||||
reference_audio_urls = resolve_and_upload_list(
|
||||
session, remaining.reference_audio.map(audio_list_ref_into_urls_or_tokens), map,
|
||||
session, remaining.reference_audio.map(MediaSourceRef::list_from_audios), map,
|
||||
).await?;
|
||||
|
||||
character_ids = resolve_character_tokens(
|
||||
|
|
|
|||
|
|
@ -10,10 +10,9 @@ use crate::api::image_ref::ImageRef;
|
|||
use crate::api::video_list_ref::VideoListRef;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::generate::generate_video::providers::kinovi::resolve::{
|
||||
audio_list_ref_into_urls_or_tokens, image_list_ref_into_urls_or_tokens,
|
||||
resolve_and_upload_list, resolve_and_upload_single, resolve_character_tokens,
|
||||
video_list_ref_into_urls_or_tokens,
|
||||
};
|
||||
use crate::utils::media_source_ref::MediaSourceRef;
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p0_mini::request::KinoviSeedance2p0MiniRequestState;
|
||||
use crate::generate::generate_video::video_generation_draft_context::VideoGenerationDraftContext;
|
||||
|
||||
|
|
@ -61,15 +60,15 @@ impl KinoviSeedance2p0MiniDraftState {
|
|||
end_frame_url = resolve_and_upload_single(session, remaining.end_frame, map).await?;
|
||||
|
||||
reference_image_urls = resolve_and_upload_list(
|
||||
session, remaining.reference_images.map(image_list_ref_into_urls_or_tokens), map,
|
||||
session, remaining.reference_images.map(MediaSourceRef::list_from_images), map,
|
||||
).await?;
|
||||
|
||||
reference_video_urls = resolve_and_upload_list(
|
||||
session, remaining.reference_videos.map(video_list_ref_into_urls_or_tokens), map,
|
||||
session, remaining.reference_videos.map(MediaSourceRef::list_from_videos), map,
|
||||
).await?;
|
||||
|
||||
reference_audio_urls = resolve_and_upload_list(
|
||||
session, remaining.reference_audio.map(audio_list_ref_into_urls_or_tokens), map,
|
||||
session, remaining.reference_audio.map(MediaSourceRef::list_from_audios), map,
|
||||
).await?;
|
||||
|
||||
character_ids = resolve_character_tokens(
|
||||
|
|
|
|||
|
|
@ -1,22 +1,49 @@
|
|||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::errors::provider_error::ProviderError;
|
||||
use crate::utils::download_file::download_file;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use log::info;
|
||||
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
|
||||
use seedance2pro_client::requests::prepare_file_upload::prepare_file_upload::{prepare_file_upload, PrepareFileUploadArgs};
|
||||
use seedance2pro_client::requests::upload_file::upload_file::{upload_file, UploadFileArgs};
|
||||
use url_utils::extension::extract_extension_from_url::{extract_extension_from_url_str, ExtractExtensions};
|
||||
use sqlite_identifiers::ids::media_file_token::MediaFileToken;
|
||||
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::errors::provider_error::ProviderError;
|
||||
use crate::utils::media_source_ref::{
|
||||
extension_from_name_hint, resolve_media_source_bytes, MediaSourceRef, ResolvedMediaBytes,
|
||||
};
|
||||
|
||||
/// Resolves one media source to bytes (token → CDN download, URL → download,
|
||||
/// local path → disk read, bytes → as-is) and uploads it to the Seedance2Pro
|
||||
/// CDN. Returns the public URL.
|
||||
pub(crate) async fn upload_source_to_seedance2pro(
|
||||
session: &Seedance2ProSession,
|
||||
source: MediaSourceRef,
|
||||
maybe_map: Option<&HashMap<MediaFileToken, String>>,
|
||||
) -> Result<String, ArtcraftRouterError> {
|
||||
let ResolvedMediaBytes { bytes, maybe_name_hint, description, .. } =
|
||||
resolve_media_source_bytes(source, maybe_map).await?;
|
||||
|
||||
let extension = maybe_name_hint.as_deref()
|
||||
.and_then(extension_from_name_hint)
|
||||
.unwrap_or_else(|| "jpg".to_string());
|
||||
|
||||
info!("Uploading reference to Seedance2Pro from {} ({} bytes, .{})", description, bytes.len(), extension);
|
||||
upload_bytes_to_seedance2pro(session, bytes, extension).await
|
||||
}
|
||||
|
||||
/// Downloads a file from a source URL and re-uploads it to seedance2pro CDN.
|
||||
pub(crate) async fn upload_to_seedance2pro(
|
||||
session: &Seedance2ProSession,
|
||||
source_url: &str,
|
||||
) -> Result<String, ArtcraftRouterError> {
|
||||
let extension = extract_extension_from_url_str(source_url, &ExtractExtensions::All)
|
||||
.map(|ext| ext.without_period().to_string())
|
||||
.unwrap_or_else(|| "jpg".to_string());
|
||||
|
||||
let file_bytes = download_file(source_url).await?;
|
||||
upload_source_to_seedance2pro(session, MediaSourceRef::Url(source_url.to_string()), None).await
|
||||
}
|
||||
|
||||
async fn upload_bytes_to_seedance2pro(
|
||||
session: &Seedance2ProSession,
|
||||
file_bytes: Vec<u8>,
|
||||
extension: String,
|
||||
) -> Result<String, ArtcraftRouterError> {
|
||||
let prepare_response = prepare_file_upload(PrepareFileUploadArgs {
|
||||
session,
|
||||
extension,
|
||||
|
|
@ -35,4 +62,3 @@ pub(crate) async fn upload_to_seedance2pro(
|
|||
|
||||
Ok(upload_response.public_url)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,14 @@
|
|||
//! Reference media for first-party Higgsfield: a source URL (an ArtCraft CDN
|
||||
//! link, or any public URL) is downloaded and re-uploaded through the
|
||||
//! session, and the resulting [`MediaInput`] is what generation requests
|
||||
//! reference. Shared by the image and video Higgsfield providers.
|
||||
//! Reference media for first-party Higgsfield: each source — an ArtCraft
|
||||
//! media token, a public URL, a local file, or in-memory bytes — is turned
|
||||
//! into bytes and uploaded through the session, and the resulting
|
||||
//! [`MediaInput`] is what generation requests reference. Shared by the image
|
||||
//! and video Higgsfield providers.
|
||||
//!
|
||||
//! Only ArtCraft media tokens touch the ArtCraft cloud (they resolve through
|
||||
//! the token→URL map); local files and bytes go straight to Higgsfield.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
use higgsfield_client::session::higgsfield_session::HiggsfieldSession;
|
||||
use higgsfield_client::session::upload_media::ReferenceMediaFile;
|
||||
|
|
@ -12,19 +17,21 @@ use higgsfield_client::types::media_input::MediaInput;
|
|||
use higgsfield_client::types::media_mime_type::MediaMimeType;
|
||||
use log::info;
|
||||
use sqlite_identifiers::ids::media_file_token::MediaFileToken;
|
||||
use url_utils::extension::extract_extension_from_url::{extract_extension_from_url_str, ExtractExtensions};
|
||||
|
||||
use crate::api::audio_list_ref::AudioListRef;
|
||||
use crate::api::image_list_ref::ImageListRef;
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::video_list_ref::VideoListRef;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::errors::client_error::ClientError;
|
||||
use crate::errors::provider_error::ProviderError;
|
||||
use crate::utils::download_file::download_file;
|
||||
use crate::utils::media_source_ref::{
|
||||
extension_from_name_hint, resolve_media_source_bytes, MediaSourceRef, ResolvedMediaBytes,
|
||||
};
|
||||
|
||||
pub use crate::utils::media_source_ref::{resolve_media_token, resolve_media_tokens};
|
||||
|
||||
/// What a reference file is expected to be. Picks the upload endpoint family
|
||||
/// and the fallback MIME type when the bytes and URL don't say.
|
||||
/// and the fallback MIME type when the bytes and name don't say.
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||
pub enum HiggsfieldMediaKind {
|
||||
Image,
|
||||
|
|
@ -50,49 +57,35 @@ impl HiggsfieldMediaKind {
|
|||
}
|
||||
}
|
||||
|
||||
/// A list of reference sources before they're resolved to URLs.
|
||||
pub enum HiggsfieldMediaSources {
|
||||
Urls(Vec<String>),
|
||||
Tokens(Vec<MediaFileToken>),
|
||||
}
|
||||
/// A list of reference sources, in upload order.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct HiggsfieldMediaSources(pub Vec<MediaSourceRef>);
|
||||
|
||||
impl From<ImageListRef> for HiggsfieldMediaSources {
|
||||
fn from(list: ImageListRef) -> Self {
|
||||
match list {
|
||||
ImageListRef::Urls(urls) => Self::Urls(urls),
|
||||
ImageListRef::MediaFileTokens(tokens) => Self::Tokens(tokens),
|
||||
}
|
||||
Self(MediaSourceRef::list_from_images(list))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<VideoListRef> for HiggsfieldMediaSources {
|
||||
fn from(list: VideoListRef) -> Self {
|
||||
match list {
|
||||
VideoListRef::Urls(urls) => Self::Urls(urls),
|
||||
VideoListRef::MediaFileTokens(tokens) => Self::Tokens(tokens),
|
||||
}
|
||||
Self(MediaSourceRef::list_from_videos(list))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AudioListRef> for HiggsfieldMediaSources {
|
||||
fn from(list: AudioListRef) -> Self {
|
||||
match list {
|
||||
AudioListRef::Urls(urls) => Self::Urls(urls),
|
||||
AudioListRef::MediaFileTokens(tokens) => Self::Tokens(tokens),
|
||||
}
|
||||
Self(MediaSourceRef::list_from_audios(list))
|
||||
}
|
||||
}
|
||||
|
||||
impl HiggsfieldMediaSources {
|
||||
pub fn len(&self) -> usize {
|
||||
match self {
|
||||
Self::Urls(urls) => urls.len(),
|
||||
Self::Tokens(tokens) => tokens.len(),
|
||||
}
|
||||
self.0.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.len() == 0
|
||||
self.0.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -105,14 +98,13 @@ pub async fn upload_media_list(
|
|||
ip_check: bool,
|
||||
maybe_map: Option<&HashMap<MediaFileToken, String>>,
|
||||
) -> Result<Vec<MediaInput>, ArtcraftRouterError> {
|
||||
let source_urls = match list {
|
||||
let sources = match list {
|
||||
None => return Ok(Vec::new()),
|
||||
Some(HiggsfieldMediaSources::Urls(urls)) => urls,
|
||||
Some(HiggsfieldMediaSources::Tokens(tokens)) => resolve_media_tokens(maybe_map, &tokens)?,
|
||||
Some(sources) => sources.0,
|
||||
};
|
||||
let mut uploaded = Vec::with_capacity(source_urls.len());
|
||||
for url in &source_urls {
|
||||
uploaded.push(upload_url_to_higgsfield(session, url, kind, ip_check).await?);
|
||||
let mut uploaded = Vec::with_capacity(sources.len());
|
||||
for source in sources {
|
||||
uploaded.push(upload_source_to_higgsfield(session, source, kind, ip_check, maybe_map).await?);
|
||||
}
|
||||
Ok(uploaded)
|
||||
}
|
||||
|
|
@ -124,58 +116,74 @@ pub async fn upload_image_ref(
|
|||
ip_check: bool,
|
||||
maybe_map: Option<&HashMap<MediaFileToken, String>>,
|
||||
) -> Result<Option<MediaInput>, ArtcraftRouterError> {
|
||||
let source_url = match image_ref {
|
||||
None => return Ok(None),
|
||||
Some(ImageRef::Url(url)) => url,
|
||||
Some(ImageRef::MediaFileToken(token)) => resolve_media_token(maybe_map, &token)?,
|
||||
};
|
||||
Ok(Some(upload_url_to_higgsfield(session, &source_url, HiggsfieldMediaKind::Image, ip_check).await?))
|
||||
match image_ref {
|
||||
None => Ok(None),
|
||||
Some(image_ref) => {
|
||||
Ok(Some(upload_source_to_higgsfield(session, image_ref.into(), HiggsfieldMediaKind::Image, ip_check, maybe_map).await?))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Download `source_url` and upload it to Higgsfield as reference media.
|
||||
/// `ip_check` asks Higgsfield to run (and waits for) its intellectual-property
|
||||
/// check, which the Seedance video models require on images and clips.
|
||||
pub async fn upload_url_to_higgsfield(
|
||||
/// Turn one source into bytes and upload it to Higgsfield as reference
|
||||
/// media. `ip_check` asks Higgsfield to run (and waits for) its
|
||||
/// intellectual-property check, which the Seedance video models require on
|
||||
/// images and clips.
|
||||
///
|
||||
/// The first-party-domain guard applies only to caller-supplied `Url`
|
||||
/// sources: a token that resolves to our own CDN is a deliberate library
|
||||
/// pick, and local paths / bytes never had a URL at all.
|
||||
pub async fn upload_source_to_higgsfield(
|
||||
session: &HiggsfieldSession,
|
||||
source_url: &str,
|
||||
source: MediaSourceRef,
|
||||
kind: HiggsfieldMediaKind,
|
||||
ip_check: bool,
|
||||
maybe_map: Option<&HashMap<MediaFileToken, String>>,
|
||||
) -> Result<MediaInput, ArtcraftRouterError> {
|
||||
// Checked again by the client on upload; failing here skips the download.
|
||||
check_upload_source_url(source_url)
|
||||
.map_err(|err| ArtcraftRouterError::from(ProviderError::Higgsfield(err.into())))?;
|
||||
let bytes = download_file(source_url).await?;
|
||||
let file = reference_media_file_for_url(source_url, kind, bytes, ip_check);
|
||||
if let MediaSourceRef::Url(url) = &source {
|
||||
check_upload_source_url(url)
|
||||
.map_err(|err| ArtcraftRouterError::from(ProviderError::Higgsfield(err.into())))?;
|
||||
}
|
||||
let resolved = resolve_media_source_bytes(source, maybe_map).await?;
|
||||
|
||||
let file = reference_media_file(resolved, kind, ip_check);
|
||||
info!(
|
||||
"Uploading {:?} reference to Higgsfield: {} ({} bytes, {}, ip_check={})",
|
||||
kind, source_url, file.bytes.len(), file.mime_type, ip_check,
|
||||
"Uploading {:?} reference to Higgsfield ({} bytes, {}, ip_check={})",
|
||||
kind, file.bytes.len(), file.mime_type, ip_check,
|
||||
);
|
||||
session.upload_reference_media(file).await
|
||||
.map_err(|err| ArtcraftRouterError::from(ProviderError::Higgsfield(err)))
|
||||
}
|
||||
|
||||
/// Describe downloaded bytes for upload: the MIME type is sniffed from the
|
||||
/// bytes, then guessed from the URL's extension, then defaulted by `kind`.
|
||||
pub fn reference_media_file_for_url(source_url: &str, kind: HiggsfieldMediaKind, bytes: Vec<u8>, ip_check: bool) -> ReferenceMediaFile {
|
||||
let mime_type = media_mime_type_for(source_url, &bytes, kind);
|
||||
/// Describe resolved bytes for upload: the MIME type is sniffed from the
|
||||
/// bytes, then guessed from the name hint (URL, path, or file name), then
|
||||
/// defaulted by `kind`. The resolved source URL (caller-supplied URLs only)
|
||||
/// engages the client's first-party-domain guard.
|
||||
pub fn reference_media_file(resolved: ResolvedMediaBytes, kind: HiggsfieldMediaKind, ip_check: bool) -> ReferenceMediaFile {
|
||||
let ResolvedMediaBytes { bytes, maybe_name_hint, maybe_source_url, description } = resolved;
|
||||
let mime_type = media_mime_type_for(maybe_name_hint.as_deref(), &bytes, kind);
|
||||
info!("Higgsfield reference resolved from {} as {}", description, mime_type);
|
||||
let file_name = format!("reference.{}", mime_type.file_extension());
|
||||
let file = ReferenceMediaFile::new(file_name, mime_type, bytes).with_source_url(source_url);
|
||||
let mut file = ReferenceMediaFile::new(file_name, mime_type, bytes);
|
||||
if let Some(source_url) = maybe_source_url {
|
||||
file = file.with_source_url(source_url);
|
||||
}
|
||||
if ip_check { file.with_ip_check() } else { file }
|
||||
}
|
||||
|
||||
/// The best MIME type for a reference: magic bytes first (the URL may have
|
||||
/// no extension, or lie), then the URL's extension, then the kind's default.
|
||||
/// A type from the wrong family for `kind` is ignored.
|
||||
pub fn media_mime_type_for(source_url: &str, bytes: &[u8], kind: HiggsfieldMediaKind) -> MediaMimeType {
|
||||
/// The best MIME type for a reference: magic bytes first (the name may have
|
||||
/// no extension, or lie), then the name hint's extension, then the kind's
|
||||
/// default. A type from the wrong family for `kind` is ignored.
|
||||
pub fn media_mime_type_for(maybe_name_hint: Option<&str>, bytes: &[u8], kind: HiggsfieldMediaKind) -> MediaMimeType {
|
||||
sniff_mime_type(bytes)
|
||||
.or_else(|| mime_type_from_url_extension(source_url))
|
||||
.or_else(|| maybe_name_hint.and_then(mime_type_from_name_hint))
|
||||
.filter(|mime_type| kind.accepts(mime_type))
|
||||
.unwrap_or_else(|| kind.fallback_mime_type())
|
||||
}
|
||||
|
||||
fn mime_type_from_url_extension(source_url: &str) -> Option<MediaMimeType> {
|
||||
let extension = extract_extension_from_url_str(source_url, &ExtractExtensions::All)?;
|
||||
MediaMimeType::from_file_name(&format!("file.{}", extension.without_period()))
|
||||
/// Extension-based MIME guess for a URL, filesystem path, or bare file name.
|
||||
fn mime_type_from_name_hint(name_hint: &str) -> Option<MediaMimeType> {
|
||||
let extension = extension_from_name_hint(name_hint)?;
|
||||
MediaMimeType::from_file_name(&format!("file.{extension}"))
|
||||
}
|
||||
|
||||
/// Recognise the common image / video / audio containers by their magic
|
||||
|
|
@ -220,25 +228,13 @@ pub fn sniff_mime_type(bytes: &[u8]) -> Option<MediaMimeType> {
|
|||
None
|
||||
}
|
||||
|
||||
pub fn resolve_media_token(
|
||||
maybe_map: Option<&HashMap<MediaFileToken, String>>,
|
||||
token: &MediaFileToken,
|
||||
) -> Result<String, ArtcraftRouterError> {
|
||||
let map = maybe_map.ok_or(ArtcraftRouterError::Client(ClientError::MediaFileToUrlMapNotProvided))?;
|
||||
map.get(token).cloned().ok_or_else(|| {
|
||||
ArtcraftRouterError::Client(ClientError::MediaFileTokenNotFoundInMap { token: token.clone() })
|
||||
})
|
||||
}
|
||||
|
||||
pub fn resolve_media_tokens(
|
||||
maybe_map: Option<&HashMap<MediaFileToken, String>>,
|
||||
tokens: &[MediaFileToken],
|
||||
) -> Result<Vec<String>, ArtcraftRouterError> {
|
||||
tokens.iter().map(|token| resolve_media_token(maybe_map, token)).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::api::media_bytes::MediaBytes;
|
||||
use crate::errors::client_error::ClientError;
|
||||
|
||||
use super::*;
|
||||
|
||||
const PNG: &[u8] = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR";
|
||||
|
|
@ -278,43 +274,65 @@ mod tests {
|
|||
#[test]
|
||||
fn bytes_win_over_extension() {
|
||||
// A PNG served from a ".jpg" URL is still a PNG.
|
||||
assert_eq!(media_mime_type_for("https://cdn.example.com/a.jpg", PNG, HiggsfieldMediaKind::Image), MediaMimeType::ImagePng);
|
||||
assert_eq!(media_mime_type_for(Some("https://cdn.example.com/a.jpg"), PNG, HiggsfieldMediaKind::Image), MediaMimeType::ImagePng);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extension_is_used_when_bytes_are_unrecognised() {
|
||||
assert_eq!(media_mime_type_for("https://cdn.example.com/a.webp?x=1", b"????", HiggsfieldMediaKind::Image), MediaMimeType::ImageWebp);
|
||||
assert_eq!(media_mime_type_for("https://cdn.example.com/clip.mov", b"????", HiggsfieldMediaKind::Video), MediaMimeType::VideoQuicktime);
|
||||
assert_eq!(media_mime_type_for("https://cdn.example.com/song.mp3", b"????", HiggsfieldMediaKind::Audio), MediaMimeType::AudioMpeg);
|
||||
assert_eq!(media_mime_type_for(Some("https://cdn.example.com/a.webp?x=1"), b"????", HiggsfieldMediaKind::Image), MediaMimeType::ImageWebp);
|
||||
assert_eq!(media_mime_type_for(Some("https://cdn.example.com/clip.mov"), b"????", HiggsfieldMediaKind::Video), MediaMimeType::VideoQuicktime);
|
||||
assert_eq!(media_mime_type_for(Some("https://cdn.example.com/song.mp3"), b"????", HiggsfieldMediaKind::Audio), MediaMimeType::AudioMpeg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_paths_and_bare_names_give_extensions() {
|
||||
assert_eq!(media_mime_type_for(Some("/Users/me/Pictures/photo.webp"), b"????", HiggsfieldMediaKind::Image), MediaMimeType::ImageWebp);
|
||||
assert_eq!(media_mime_type_for(Some("clip.mov"), b"????", HiggsfieldMediaKind::Video), MediaMimeType::VideoQuicktime);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falls_back_by_kind() {
|
||||
assert_eq!(media_mime_type_for("https://cdn.example.com/media/abc", b"????", HiggsfieldMediaKind::Image), MediaMimeType::ImageJpeg);
|
||||
assert_eq!(media_mime_type_for("https://cdn.example.com/media/abc", b"????", HiggsfieldMediaKind::Video), MediaMimeType::VideoMp4);
|
||||
assert_eq!(media_mime_type_for("https://cdn.example.com/media/abc", b"????", HiggsfieldMediaKind::Audio), MediaMimeType::AudioMpeg);
|
||||
assert_eq!(media_mime_type_for(Some("https://cdn.example.com/media/abc"), b"????", HiggsfieldMediaKind::Image), MediaMimeType::ImageJpeg);
|
||||
assert_eq!(media_mime_type_for(None, b"????", HiggsfieldMediaKind::Video), MediaMimeType::VideoMp4);
|
||||
assert_eq!(media_mime_type_for(None, b"????", HiggsfieldMediaKind::Audio), MediaMimeType::AudioMpeg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_family_for_kind_is_ignored() {
|
||||
// An image URL handed in as a video reference: don't claim it's a PNG video.
|
||||
assert_eq!(media_mime_type_for("https://cdn.example.com/a.png", PNG, HiggsfieldMediaKind::Video), MediaMimeType::VideoMp4);
|
||||
// An image handed in as a video reference: don't claim it's a PNG video.
|
||||
assert_eq!(media_mime_type_for(Some("https://cdn.example.com/a.png"), PNG, HiggsfieldMediaKind::Video), MediaMimeType::VideoMp4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_file_carries_extension_and_ip_check() {
|
||||
let file = reference_media_file_for_url("https://cdn.example.com/x", HiggsfieldMediaKind::Video, MP4.to_vec(), true);
|
||||
fn reference_file_carries_extension_ip_check_and_source_url() {
|
||||
let resolved = ResolvedMediaBytes {
|
||||
bytes: MP4.to_vec(),
|
||||
maybe_name_hint: Some("https://cdn.example.com/x".into()),
|
||||
maybe_source_url: Some("https://cdn.example.com/x".into()),
|
||||
description: "url https://cdn.example.com/x".into(),
|
||||
};
|
||||
let file = reference_media_file(resolved, HiggsfieldMediaKind::Video, true);
|
||||
assert_eq!(file.file_name, "reference.mp4");
|
||||
assert_eq!(file.mime_type, MediaMimeType::VideoMp4);
|
||||
assert!(file.force_ip_check);
|
||||
let file = reference_media_file_for_url("https://cdn.example.com/x.png", HiggsfieldMediaKind::Image, PNG.to_vec(), false);
|
||||
assert_eq!(file.maybe_source_url.as_deref(), Some("https://cdn.example.com/x"));
|
||||
|
||||
let resolved = ResolvedMediaBytes {
|
||||
bytes: PNG.to_vec(),
|
||||
maybe_name_hint: Some("/local/x.png".into()),
|
||||
maybe_source_url: None,
|
||||
description: "local file /local/x.png".into(),
|
||||
};
|
||||
let file = reference_media_file(resolved, HiggsfieldMediaKind::Image, false);
|
||||
assert_eq!(file.file_name, "reference.png");
|
||||
assert!(!file.force_ip_check);
|
||||
assert_eq!(file.maybe_source_url.as_deref(), Some("https://cdn.example.com/x.png"));
|
||||
assert!(file.maybe_source_url.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
mod token_resolution {
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
|
|
@ -341,6 +359,14 @@ mod tests {
|
|||
assert_eq!(videos.len(), 1);
|
||||
let audio: HiggsfieldMediaSources = AudioListRef::Urls(vec![]).into();
|
||||
assert!(audio.is_empty());
|
||||
let mixed: HiggsfieldMediaSources = ImageListRef::Sources(vec![
|
||||
ImageRef::MediaFileToken(MediaFileToken::new_from_str("m_2")),
|
||||
ImageRef::LocalPath(PathBuf::from("/tmp/a.png")),
|
||||
ImageRef::Bytes(MediaBytes::new(vec![1, 2, 3])),
|
||||
]).into();
|
||||
assert_eq!(mixed.len(), 3);
|
||||
assert!(matches!(mixed.0[1], MediaSourceRef::LocalPath(_)));
|
||||
assert!(matches!(mixed.0[2], MediaSourceRef::Bytes(_)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,237 @@
|
|||
//! A provider-agnostic view of one media reference, and the machinery to
|
||||
//! turn it into bytes. The per-kind ref enums (`ImageRef`, `VideoRef`,
|
||||
//! `AudioRef`, `MeshRef`) all collapse into [`MediaSourceRef`] for providers
|
||||
//! that re-upload media (Higgsfield, Kinovi).
|
||||
//!
|
||||
//! Only ArtCraft media tokens touch the ArtCraft cloud (they resolve through
|
||||
//! the token→URL map); local files and bytes never leave the machine until
|
||||
//! they're uploaded to the target provider.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use sqlite_identifiers::ids::media_file_token::MediaFileToken;
|
||||
use url_utils::extension::extract_extension_from_url::{extract_extension_from_url_str, ExtractExtensions};
|
||||
|
||||
use crate::api::audio_list_ref::AudioListRef;
|
||||
use crate::api::audio_ref::AudioRef;
|
||||
use crate::api::image_list_ref::ImageListRef;
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::media_bytes::MediaBytes;
|
||||
use crate::api::mesh_ref::MeshRef;
|
||||
use crate::api::video_list_ref::VideoListRef;
|
||||
use crate::api::video_ref::VideoRef;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::errors::client_error::ClientError;
|
||||
use crate::utils::download_file::download_file;
|
||||
|
||||
/// One media reference, stripped of its image/video/audio kind.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum MediaSourceRef {
|
||||
MediaFileToken(MediaFileToken),
|
||||
Url(String),
|
||||
LocalPath(PathBuf),
|
||||
Bytes(MediaBytes),
|
||||
}
|
||||
|
||||
/// A reference resolved to bytes, plus what's known about where it came from.
|
||||
#[derive(Debug)]
|
||||
pub struct ResolvedMediaBytes {
|
||||
pub bytes: Vec<u8>,
|
||||
|
||||
/// URL, filesystem path, or bare file name — whatever can lend an
|
||||
/// extension for MIME/extension guessing.
|
||||
pub maybe_name_hint: Option<String>,
|
||||
|
||||
/// Set only for caller-supplied `Url` sources; feeds provider-side source
|
||||
/// guards. Token-resolved CDN URLs deliberately don't set this: a token is
|
||||
/// a library pick, not a leaked local file.
|
||||
pub maybe_source_url: Option<String>,
|
||||
|
||||
/// Human-readable origin for logs ("local file /a/b.png", ...).
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
impl MediaSourceRef {
|
||||
pub fn list_from_images(list: ImageListRef) -> Vec<MediaSourceRef> {
|
||||
list.into_refs().into_iter().map(Into::into).collect()
|
||||
}
|
||||
|
||||
pub fn list_from_videos(list: VideoListRef) -> Vec<MediaSourceRef> {
|
||||
list.into_refs().into_iter().map(Into::into).collect()
|
||||
}
|
||||
|
||||
pub fn list_from_audios(list: AudioListRef) -> Vec<MediaSourceRef> {
|
||||
list.into_refs().into_iter().map(Into::into).collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ImageRef> for MediaSourceRef {
|
||||
fn from(image_ref: ImageRef) -> Self {
|
||||
match image_ref {
|
||||
ImageRef::MediaFileToken(token) => Self::MediaFileToken(token),
|
||||
ImageRef::Url(url) => Self::Url(url),
|
||||
ImageRef::LocalPath(path) => Self::LocalPath(path),
|
||||
ImageRef::Bytes(bytes) => Self::Bytes(bytes),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<VideoRef> for MediaSourceRef {
|
||||
fn from(video_ref: VideoRef) -> Self {
|
||||
match video_ref {
|
||||
VideoRef::MediaFileToken(token) => Self::MediaFileToken(token),
|
||||
VideoRef::Url(url) => Self::Url(url),
|
||||
VideoRef::LocalPath(path) => Self::LocalPath(path),
|
||||
VideoRef::Bytes(bytes) => Self::Bytes(bytes),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AudioRef> for MediaSourceRef {
|
||||
fn from(audio_ref: AudioRef) -> Self {
|
||||
match audio_ref {
|
||||
AudioRef::MediaFileToken(token) => Self::MediaFileToken(token),
|
||||
AudioRef::Url(url) => Self::Url(url),
|
||||
AudioRef::LocalPath(path) => Self::LocalPath(path),
|
||||
AudioRef::Bytes(bytes) => Self::Bytes(bytes),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<MeshRef> for MediaSourceRef {
|
||||
fn from(mesh_ref: MeshRef) -> Self {
|
||||
match mesh_ref {
|
||||
MeshRef::MediaFileToken(token) => Self::MediaFileToken(token),
|
||||
MeshRef::Url(url) => Self::Url(url),
|
||||
MeshRef::LocalPath(path) => Self::LocalPath(path),
|
||||
MeshRef::Bytes(bytes) => Self::Bytes(bytes),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Turn one source into bytes: tokens resolve through `maybe_map` and
|
||||
/// download; URLs download; local paths read from disk; bytes pass through.
|
||||
pub async fn resolve_media_source_bytes(
|
||||
source: MediaSourceRef,
|
||||
maybe_map: Option<&HashMap<MediaFileToken, String>>,
|
||||
) -> Result<ResolvedMediaBytes, ArtcraftRouterError> {
|
||||
match source {
|
||||
MediaSourceRef::MediaFileToken(token) => {
|
||||
let url = resolve_media_token(maybe_map, &token)?;
|
||||
let bytes = download_file(&url).await?;
|
||||
Ok(ResolvedMediaBytes {
|
||||
bytes,
|
||||
description: format!("media token {}", token.as_str()),
|
||||
maybe_name_hint: Some(url),
|
||||
maybe_source_url: None,
|
||||
})
|
||||
}
|
||||
MediaSourceRef::Url(url) => {
|
||||
let bytes = download_file(&url).await?;
|
||||
Ok(ResolvedMediaBytes {
|
||||
bytes,
|
||||
description: format!("url {url}"),
|
||||
maybe_name_hint: Some(url.clone()),
|
||||
maybe_source_url: Some(url),
|
||||
})
|
||||
}
|
||||
MediaSourceRef::LocalPath(path) => {
|
||||
let bytes = read_local_file(&path).await?;
|
||||
Ok(ResolvedMediaBytes {
|
||||
bytes,
|
||||
description: format!("local file {}", path.display()),
|
||||
maybe_name_hint: Some(path.to_string_lossy().into_owned()),
|
||||
maybe_source_url: None,
|
||||
})
|
||||
}
|
||||
MediaSourceRef::Bytes(media_bytes) => {
|
||||
let MediaBytes { bytes, maybe_file_name } = media_bytes;
|
||||
Ok(ResolvedMediaBytes {
|
||||
bytes,
|
||||
description: "raw bytes".to_string(),
|
||||
maybe_name_hint: maybe_file_name,
|
||||
maybe_source_url: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a local reference file, distinguishing "not there" from "unreadable".
|
||||
pub async fn read_local_file(path: &Path) -> Result<Vec<u8>, ArtcraftRouterError> {
|
||||
if !path.is_file() {
|
||||
return Err(ArtcraftRouterError::Client(ClientError::LocalFileNotFound { path: path.to_path_buf() }));
|
||||
}
|
||||
tokio::fs::read(path).await.map_err(|error| {
|
||||
ArtcraftRouterError::Client(ClientError::LocalFileRead { path: path.to_path_buf(), error })
|
||||
})
|
||||
}
|
||||
|
||||
/// The extension a name hint (URL, filesystem path, or bare file name)
|
||||
/// carries, without its period. `None` when there isn't one.
|
||||
pub fn extension_from_name_hint(name_hint: &str) -> Option<String> {
|
||||
if let Some(extension) = extract_extension_from_url_str(name_hint, &ExtractExtensions::All) {
|
||||
return Some(extension.without_period().to_string());
|
||||
}
|
||||
Path::new(name_hint).extension()?.to_str().map(str::to_string)
|
||||
}
|
||||
|
||||
pub fn resolve_media_token(
|
||||
maybe_map: Option<&HashMap<MediaFileToken, String>>,
|
||||
token: &MediaFileToken,
|
||||
) -> Result<String, ArtcraftRouterError> {
|
||||
let map = maybe_map.ok_or(ArtcraftRouterError::Client(ClientError::MediaFileToUrlMapNotProvided))?;
|
||||
map.get(token).cloned().ok_or_else(|| {
|
||||
ArtcraftRouterError::Client(ClientError::MediaFileTokenNotFoundInMap { token: token.clone() })
|
||||
})
|
||||
}
|
||||
|
||||
pub fn resolve_media_tokens(
|
||||
maybe_map: Option<&HashMap<MediaFileToken, String>>,
|
||||
tokens: &[MediaFileToken],
|
||||
) -> Result<Vec<String>, ArtcraftRouterError> {
|
||||
tokens.iter().map(|token| resolve_media_token(maybe_map, token)).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn extensions_come_from_urls_paths_and_names() {
|
||||
assert_eq!(extension_from_name_hint("https://cdn.example.com/a.webp?x=1").as_deref(), Some("webp"));
|
||||
assert_eq!(extension_from_name_hint("/Users/me/Pictures/photo.PNG").as_deref(), Some("PNG"));
|
||||
assert_eq!(extension_from_name_hint("clip.mov").as_deref(), Some("mov"));
|
||||
assert_eq!(extension_from_name_hint("no_extension"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lists_collapse_to_sources() {
|
||||
let sources = MediaSourceRef::list_from_images(ImageListRef::Sources(vec![
|
||||
ImageRef::Url("https://a.example/a.png".into()),
|
||||
ImageRef::LocalPath(PathBuf::from("/tmp/b.png")),
|
||||
ImageRef::Bytes(MediaBytes::new(vec![1])),
|
||||
]));
|
||||
assert_eq!(sources.len(), 3);
|
||||
assert!(matches!(sources[0], MediaSourceRef::Url(_)));
|
||||
assert!(matches!(sources[1], MediaSourceRef::LocalPath(_)));
|
||||
assert!(matches!(sources[2], MediaSourceRef::Bytes(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_local_file_is_a_clear_error() {
|
||||
let err = read_local_file(Path::new("/definitely/not/a/real/file.png")).await.unwrap_err();
|
||||
assert!(matches!(err, ArtcraftRouterError::Client(ClientError::LocalFileNotFound { .. })));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bytes_pass_straight_through() {
|
||||
let resolved = resolve_media_source_bytes(
|
||||
MediaSourceRef::Bytes(MediaBytes::new(vec![9, 9]).with_file_name("a.png")),
|
||||
None,
|
||||
).await.unwrap();
|
||||
assert_eq!(resolved.bytes, vec![9, 9]);
|
||||
assert_eq!(resolved.maybe_name_hint.as_deref(), Some("a.png"));
|
||||
assert!(resolved.maybe_source_url.is_none());
|
||||
}
|
||||
}
|
||||
|
|
@ -1,2 +1,3 @@
|
|||
pub mod download_file;
|
||||
pub mod higgsfield_media;
|
||||
pub mod media_source_ref;
|
||||
|
|
|
|||
|
|
@ -1,12 +1,15 @@
|
|||
import { useMemo, useRef, useState } from "react";
|
||||
import { Box, Earth, Plus, X } from "lucide-react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { MediaUploadApi } from "@storyteller/api";
|
||||
import { GenerateIconButton } from "@storyteller/ui-button";
|
||||
import { useCostBreakdownModalStore } from "@storyteller/ui-pricing-modal";
|
||||
import { GenerateMesh, GenerateSplat, useSplatModels } from "@storyteller/tauri-api";
|
||||
import {
|
||||
GenerateMesh,
|
||||
GenerateSplat,
|
||||
useSplatModels,
|
||||
type MediaSource,
|
||||
} from "@storyteller/tauri-api";
|
||||
import {
|
||||
ClassyModelSelector,
|
||||
ModelPage,
|
||||
|
|
@ -28,7 +31,10 @@ type Variant = "object" | "world";
|
|||
interface TrayImage {
|
||||
id: string;
|
||||
preview: string;
|
||||
/** Set only for library picks; locally attached files keep their bytes in
|
||||
* `file` and travel with the generate request instead. */
|
||||
mediaToken: string | null;
|
||||
file: File | null;
|
||||
name: string;
|
||||
isUploading: boolean;
|
||||
}
|
||||
|
|
@ -69,15 +75,13 @@ export const ImageTo3DComposer = ({ variant }: ImageTo3DComposerProps) => {
|
|||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const readyTokens = useMemo(
|
||||
const readyImages = useMemo(
|
||||
() =>
|
||||
images
|
||||
.filter((img) => img.mediaToken && !img.isUploading)
|
||||
.map((img) => img.mediaToken!),
|
||||
images.filter((img) => (img.mediaToken || img.file) && !img.isUploading),
|
||||
[images],
|
||||
);
|
||||
const canGenerate =
|
||||
!isGenerating && readyTokens.length > 0 && !images.some((i) => i.isUploading);
|
||||
!isGenerating && readyImages.length > 0 && !images.some((i) => i.isUploading);
|
||||
|
||||
const handleFiles = async (files?: FileList | File[] | null) => {
|
||||
if (!files) return;
|
||||
|
|
@ -87,6 +91,9 @@ export const ImageTo3DComposer = ({ variant }: ImageTo3DComposerProps) => {
|
|||
.slice(0, Math.max(0, remaining));
|
||||
if (imageFiles.length === 0) return;
|
||||
|
||||
// No upload at attach time: keep the bytes local; they travel with the
|
||||
// generate request (the backend uploads to ArtCraft only then, since
|
||||
// mesh/splat generation runs on the ArtCraft provider).
|
||||
await Promise.all(
|
||||
imageFiles.map(async (file) => {
|
||||
const imageId = generateId();
|
||||
|
|
@ -102,32 +109,11 @@ export const ImageTo3DComposer = ({ variant }: ImageTo3DComposerProps) => {
|
|||
id: imageId,
|
||||
preview,
|
||||
mediaToken: null,
|
||||
file,
|
||||
name: file.name,
|
||||
isUploading: true,
|
||||
isUploading: false,
|
||||
},
|
||||
]);
|
||||
|
||||
try {
|
||||
const mediaUploadApi = new MediaUploadApi();
|
||||
const uploadResult = await mediaUploadApi.UploadImage({
|
||||
blob: file,
|
||||
fileName: file.name,
|
||||
uuid: uuidv4(),
|
||||
});
|
||||
if (!uploadResult.success || !uploadResult.data) {
|
||||
throw new Error("Upload failed");
|
||||
}
|
||||
setImages((prev) =>
|
||||
prev.map((img) =>
|
||||
img.id === imageId
|
||||
? { ...img, mediaToken: uploadResult.data!, isUploading: false }
|
||||
: img,
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
toast.error(`Failed to upload ${file.name}`);
|
||||
setImages((prev) => prev.filter((img) => img.id !== imageId));
|
||||
}
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
|
@ -149,6 +135,20 @@ export const ImageTo3DComposer = ({ variant }: ImageTo3DComposerProps) => {
|
|||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
// Three-way sources: library tokens pass through; locally attached
|
||||
// files travel as bytes.
|
||||
const referenceImageSources: MediaSource[] = await Promise.all(
|
||||
readyImages.map(async (img): Promise<MediaSource> => {
|
||||
if (img.mediaToken) {
|
||||
return { kind: "media_file_token", token: img.mediaToken };
|
||||
}
|
||||
const bytes = Array.from(
|
||||
new Uint8Array(await img.file!.arrayBuffer()),
|
||||
);
|
||||
return { kind: "bytes", bytes, file_name: img.name || undefined };
|
||||
}),
|
||||
);
|
||||
|
||||
const result = isWorld
|
||||
? await GenerateSplat({
|
||||
credential_id: selectedAccountId ?? undefined,
|
||||
|
|
@ -156,7 +156,7 @@ export const ImageTo3DComposer = ({ variant }: ImageTo3DComposerProps) => {
|
|||
(selectedModel as any)?.tauriId ??
|
||||
defaultSplatModel?.tauriId,
|
||||
prompt: prompt.trim() || undefined,
|
||||
reference_image_media_tokens: readyTokens,
|
||||
reference_image_sources: referenceImageSources,
|
||||
frontend_caller: "splat_page",
|
||||
frontend_subscriber_id: subscriberId,
|
||||
})
|
||||
|
|
@ -164,7 +164,7 @@ export const ImageTo3DComposer = ({ variant }: ImageTo3DComposerProps) => {
|
|||
credential_id: selectedAccountId ?? undefined,
|
||||
model: selectedObjectModelId,
|
||||
prompt: prompt.trim() || undefined,
|
||||
reference_image_media_tokens: readyTokens,
|
||||
reference_image_sources: referenceImageSources,
|
||||
frontend_caller: "mesh_page",
|
||||
frontend_subscriber_id: subscriberId,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { useMemo } from "react";
|
||||
import { PromptBoxAudio, usePromptAudioStore } from "@storyteller/ui-promptbox";
|
||||
import { UploadAudioMedia, UploadImageMedia } from "@storyteller/api";
|
||||
import {
|
||||
useOmniGenAudioModels,
|
||||
useAudioCostEstimate,
|
||||
|
|
@ -48,8 +47,6 @@ const CreateAudio = () => {
|
|||
<PromptBoxAudio
|
||||
fullBleed
|
||||
models={models}
|
||||
uploadAudio={UploadAudioMedia}
|
||||
uploadImage={UploadImageMedia}
|
||||
credits={audioCredits}
|
||||
accountSelector={<AccountSelector />}
|
||||
credentialId={selectedAccountId}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { PromptBoxImage } from "@storyteller/ui-promptbox";
|
||||
import { UploadImageMedia } from "@storyteller/api";
|
||||
import {
|
||||
useTextToImagePageModelList,
|
||||
ModelPage,
|
||||
|
|
@ -50,7 +49,6 @@ const TextToImage = ({ imageMediaId, imageUrl }: TextToImageProps) => {
|
|||
>
|
||||
<PromptBoxImage
|
||||
fullBleed
|
||||
uploadImage={UploadImageMedia}
|
||||
selectedModel={selectedImageModel}
|
||||
selectedProvider={selectedProvider}
|
||||
imageMediaId={imageMediaId}
|
||||
|
|
|
|||
|
|
@ -2,11 +2,6 @@ import {
|
|||
PromptBoxVideo,
|
||||
PromptBoxErrorBoundary,
|
||||
} from "@storyteller/ui-promptbox";
|
||||
import {
|
||||
UploadImageMedia,
|
||||
UploadVideoMedia,
|
||||
UploadAudioMedia,
|
||||
} from "@storyteller/api";
|
||||
import {
|
||||
ClassyModelSelector,
|
||||
useImageToVideoPageModelList,
|
||||
|
|
@ -82,9 +77,6 @@ const ImageToVideo = ({ imageMediaId, imageUrl }: ImageToVideoProps) => {
|
|||
selectedProvider={selectedProvider}
|
||||
imageMediaId={imageMediaId}
|
||||
url={imageUrl ?? undefined}
|
||||
uploadImage={UploadImageMedia}
|
||||
uploadVideo={UploadVideoMedia}
|
||||
uploadAudio={UploadAudioMedia}
|
||||
credits={videoCredits}
|
||||
accountSelector={<AccountSelector />}
|
||||
credentialId={selectedAccountId}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue