MAJOR change to support local files as refs

This commit is contained in:
Hanashi 2026-09-01 18:02:26 -04:00
parent 28c2605aab
commit 5d9192914e
123 changed files with 2814 additions and 797 deletions

View File

@ -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;

View 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)
}

View File

@ -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());

View File

@ -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();
}
}

View File

@ -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;

View File

@ -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);
}
}

View File

@ -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,

View File

@ -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)]

View File

@ -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,

View File

@ -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)))
}

View File

@ -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

View File

@ -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)]

View File

@ -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,

View File

@ -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)]

View File

@ -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,

View File

@ -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)]

View File

@ -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);

View File

@ -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;

View File

@ -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;

View File

@ -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 {

View File

@ -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'",

View File

@ -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'",

View File

@ -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'",

View File

@ -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
}
}

View File

@ -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),
}

View File

@ -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
}
}

View File

@ -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),
}

View File

@ -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()
}
}

View File

@ -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),
}

View File

@ -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;

View File

@ -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
}
}

View File

@ -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),
}

View File

@ -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)
}
}
}
}

View File

@ -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))
}
}
}

View File

@ -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);

View File

@ -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![

View File

@ -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);
}

View File

@ -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))
}
}
}
}

View File

@ -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))
}
}
}

View File

@ -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())
}
}
}

View File

@ -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())
}
}
}

View File

@ -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(),
}
}

View File

@ -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(),
}
}

View File

@ -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(),
}
}

View File

@ -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(),
}
}

View File

@ -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(),
}
}

View File

@ -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(),
}
}

View File

@ -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(),
}
}

View File

@ -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(),
}
}

View File

@ -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(),
}
}

View File

@ -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(),
}
}

View File

@ -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(),
}
}

View File

@ -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)]

View File

@ -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.

View File

@ -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(

View File

@ -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 ──

View File

@ -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 ──

View File

@ -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 ──

View File

@ -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()
}

View File

@ -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",

View File

@ -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

View File

@ -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)]

View File

@ -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))
}
}

View File

@ -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))
}
}

View File

@ -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));
}
}

View File

@ -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())
}
}
}

View File

@ -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())
}
}
}

View File

@ -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))
}
}

View File

@ -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 {

View File

@ -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,
}))
}
}
}

View File

@ -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(),
}
}

View File

@ -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))
}
}
}

View File

@ -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(),
}
}

View File

@ -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(),

View File

@ -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(),

View File

@ -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 => {

View File

@ -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 => {

View File

@ -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 => {

View File

@ -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))
}
}
}

View File

@ -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))
}
}
}

View File

@ -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(),
}
}

View File

@ -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(),
}
}

View File

@ -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 {

View File

@ -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))
}
}
}

View File

@ -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))
}
}
}

View File

@ -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(),
);
}
}

View File

@ -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(),
);
}
}

View File

@ -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

View File

@ -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()
}

View File

@ -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(

View File

@ -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(

View File

@ -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(

View File

@ -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)
}

View File

@ -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(_)));
}
}
}

View File

@ -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());
}
}

View File

@ -1,2 +1,3 @@
pub mod download_file;
pub mod higgsfield_media;
pub mod media_source_ref;

View File

@ -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,
});

View File

@ -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}

View File

@ -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}

View File

@ -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